Compare commits
6 Commits
b95438dfd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151d2ed596 | ||
|
|
7f6548d9fb | ||
|
|
6532387f59 | ||
|
|
74dc932766 | ||
|
|
9748c2e2f5 | ||
|
|
e4d3d32702 |
@@ -1,212 +0,0 @@
|
|||||||
# Untitled Maze Game - ID30011 Midterm Project Proposal (Revised)
|
|
||||||
|
|
||||||
- **Name:** Bumgyu Suh
|
|
||||||
- **Student ID:** 20240905
|
|
||||||
- **Repository URL:** https://git.prototyping.id/20240905/homework5
|
|
||||||
- **Engine:** Babylon.js
|
|
||||||
|
|
||||||
## 1. Project Summary
|
|
||||||
|
|
||||||
Untitled Maze Game is a 3D first-person dungeon maze game. Each level generates a new maze, and the player must find the key hidden in one chest, then reach the exit area to progress.
|
|
||||||
|
|
||||||
Main twist:
|
|
||||||
- Opening a chest that has already been opened in the current level causes immediate game over.
|
|
||||||
- This memory pressure creates the core challenge.
|
|
||||||
|
|
||||||
Scoring tracked for now:
|
|
||||||
- Total time spent
|
|
||||||
- Progress (level reached)
|
|
||||||
|
|
||||||
## 2. Finalized Design Decisions
|
|
||||||
|
|
||||||
These are locked decisions for implementation:
|
|
||||||
|
|
||||||
1. Chest reset scope:
|
|
||||||
- Opened chest state resets every level.
|
|
||||||
|
|
||||||
2. Chest interaction:
|
|
||||||
- Player must left click while targeting a chest.
|
|
||||||
- Interaction prompt shown: "Click to open chest" when chest is targetable.
|
|
||||||
|
|
||||||
3. Maze topology:
|
|
||||||
- Maze is allowed to contain loops (not necessarily a perfect maze).
|
|
||||||
- Each level must guarantee a minimum number of dead-end cells with chests.
|
|
||||||
- Additional dead-ends without chests are allowed.
|
|
||||||
|
|
||||||
4. Difficulty scaling:
|
|
||||||
- Increase maze width and height by level.
|
|
||||||
- Lighting settings stay fixed (no light-based difficulty scaling).
|
|
||||||
|
|
||||||
5. Win condition:
|
|
||||||
- Exit is a highlighted zone/area.
|
|
||||||
- Entering exit zone with key automatically transitions to next level.
|
|
||||||
|
|
||||||
6. Visual implementation order:
|
|
||||||
- Start with primitive meshes for all gameplay-critical elements.
|
|
||||||
|
|
||||||
## 3. Gameplay Rules
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
- Search chests to find the key.
|
|
||||||
- Avoid reopening previously opened chests in that level.
|
|
||||||
- Reach exit area after obtaining key.
|
|
||||||
- Clear levels as fast as possible.
|
|
||||||
|
|
||||||
## Win / Lose Conditions
|
|
||||||
- Win level: player enters exit zone while holding key.
|
|
||||||
- Soft block: entering exit without key shows message and does not transition.
|
|
||||||
- Lose run: player opens a chest already opened in current level.
|
|
||||||
|
|
||||||
## Player Controls
|
|
||||||
- `W/A/S/D`: movement
|
|
||||||
- Mouse: look direction
|
|
||||||
- Left click: open chest when targeted
|
|
||||||
|
|
||||||
## 4. Technical Architecture Plan
|
|
||||||
|
|
||||||
To keep implementation clean, split into four layers:
|
|
||||||
|
|
||||||
1. Maze Logic Layer (pure functions)
|
|
||||||
- Generate grid layout
|
|
||||||
- Detect dead-ends
|
|
||||||
- Place chests, key chest, exit, and spawn
|
|
||||||
- Return plain data only
|
|
||||||
|
|
||||||
2. Game State Layer
|
|
||||||
- Track current level
|
|
||||||
- Track timer / elapsed time
|
|
||||||
- Track hasKey
|
|
||||||
- Track chest open state (per level)
|
|
||||||
- Resolve win/lose transitions
|
|
||||||
|
|
||||||
3. Babylon Scene Layer
|
|
||||||
- Build meshes from grid
|
|
||||||
- Configure first-person camera and collisions
|
|
||||||
- Handle raycast/pick chest targeting and click interaction
|
|
||||||
- Render highlighted exit zone
|
|
||||||
|
|
||||||
4. UI Layer
|
|
||||||
- HUD: level, time, key possession, prompt/status text
|
|
||||||
- Messages: wrong chest, key found, find key first, game over, level clear
|
|
||||||
|
|
||||||
## 5. Data Model (Revised)
|
|
||||||
|
|
||||||
Use separate static and dynamic state instead of overloading one numeric ID.
|
|
||||||
|
|
||||||
## Static cell data
|
|
||||||
- `cellType`: wall | path | chest | exit | spawn
|
|
||||||
|
|
||||||
## Dynamic state
|
|
||||||
- `openedChests`: set/map keyed by cell coordinate
|
|
||||||
- `keyChestCoord`: coordinate of the one correct chest
|
|
||||||
- `hasKey`: boolean
|
|
||||||
|
|
||||||
Benefits:
|
|
||||||
- Cleaner logic
|
|
||||||
- Easier debugging
|
|
||||||
- Lower chance of state bugs from mixed meanings
|
|
||||||
|
|
||||||
## 6. Level Generation Specification
|
|
||||||
|
|
||||||
Per level:
|
|
||||||
|
|
||||||
1. Compute maze dimensions from level number.
|
|
||||||
2. Generate a solvable maze grid (loops allowed).
|
|
||||||
3. Find dead-end candidates.
|
|
||||||
4. Place at least `minChestDeadEnds` chests on dead-ends.
|
|
||||||
5. Choose one chest as key chest.
|
|
||||||
6. Choose one exit location (highlighted zone).
|
|
||||||
7. Choose valid spawn point.
|
|
||||||
8. Validate reachability:
|
|
||||||
- Spawn can reach key chest
|
|
||||||
- Spawn (after key) can reach exit
|
|
||||||
|
|
||||||
If validation fails, regenerate.
|
|
||||||
|
|
||||||
## 7. Babylon.js Implementation Notes
|
|
||||||
|
|
||||||
- Camera: use first-person style camera (for example `UniversalCamera`) with pointer lock.
|
|
||||||
- Collision: enable gravity/collision with wall meshes.
|
|
||||||
- Interaction: center-screen ray pick + left click.
|
|
||||||
- Chest meshes: primitive boxes in MVP.
|
|
||||||
- Exit zone: highlighted plane or emissive ground area.
|
|
||||||
- Keep one scene; rebuild level meshes on level transition.
|
|
||||||
|
|
||||||
## 8. MVP Checklist (Implementation Order)
|
|
||||||
|
|
||||||
## Phase A - Core scaffold
|
|
||||||
- [ ] Refactor current Babylon template into modular files/functions.
|
|
||||||
- [ ] Add first-person camera controls and pointer lock.
|
|
||||||
- [ ] Add movement collision against wall meshes.
|
|
||||||
|
|
||||||
## Phase B - Maze system
|
|
||||||
- [ ] Implement maze generation with loops allowed.
|
|
||||||
- [ ] Implement dead-end detection.
|
|
||||||
- [ ] Implement chest placement with minimum dead-end chest count.
|
|
||||||
- [ ] Implement key chest assignment and exit placement.
|
|
||||||
- [ ] Implement reachability validation and regenerate on failure.
|
|
||||||
|
|
||||||
## Phase C - Gameplay rules
|
|
||||||
- [ ] Implement per-level chest open tracking.
|
|
||||||
- [ ] Implement chest click interaction and "Click to open chest" prompt.
|
|
||||||
- [ ] Implement outcomes: wrong chest / key found / reopened chest game over.
|
|
||||||
- [ ] Implement exit-zone behavior: block without key, auto-next-level with key.
|
|
||||||
|
|
||||||
## Phase D - UI and scoring
|
|
||||||
- [ ] Display level number.
|
|
||||||
- [ ] Display elapsed total time.
|
|
||||||
- [ ] Display key possession status.
|
|
||||||
- [ ] Display gameplay feedback messages.
|
|
||||||
|
|
||||||
## Phase E - Level progression
|
|
||||||
- [ ] Increase maze width/height per level.
|
|
||||||
- [ ] Reset per-level state correctly on transition.
|
|
||||||
- [ ] Preserve run-level state (time, current level progression).
|
|
||||||
|
|
||||||
## 9. Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Reopening an opened chest always ends run.
|
|
||||||
- [ ] Chest-open state resets at new level.
|
|
||||||
- [ ] Entering exit without key never transitions.
|
|
||||||
- [ ] Entering exit with key always transitions.
|
|
||||||
- [ ] Every generated level is solvable.
|
|
||||||
- [ ] Minimum chest dead-end count is always satisfied.
|
|
||||||
- [ ] Performance remains stable across first several levels.
|
|
||||||
|
|
||||||
## 10. Assets Plan
|
|
||||||
|
|
||||||
Immediate plan (MVP):
|
|
||||||
- Use primitives for wall, floor, chest, and exit zone.
|
|
||||||
- Use simple material colors to distinguish gameplay objects.
|
|
||||||
|
|
||||||
Prepare soon (after core loop works):
|
|
||||||
- Tileable wall texture
|
|
||||||
- Tileable floor texture
|
|
||||||
- Chest texture/material
|
|
||||||
- Basic SFX set:
|
|
||||||
- chest open
|
|
||||||
- wrong chest
|
|
||||||
- key found
|
|
||||||
- level clear
|
|
||||||
- game over
|
|
||||||
|
|
||||||
Optional polish later:
|
|
||||||
- Better chest model
|
|
||||||
- Exit marker model
|
|
||||||
- Ambient loop audio
|
|
||||||
|
|
||||||
## 11. Class Concept Coverage
|
|
||||||
|
|
||||||
This project uses:
|
|
||||||
- JavaScript arrays and object state
|
|
||||||
- Functional decomposition in generation pipeline
|
|
||||||
- Event handling (keyboard/mouse)
|
|
||||||
- External library usage (Babylon.js)
|
|
||||||
- Real-time tracking (elapsed time HUD)
|
|
||||||
|
|
||||||
## 12. Scope Guardrails
|
|
||||||
|
|
||||||
To keep delivery reliable:
|
|
||||||
- Prioritize clean gameplay loop over art polish.
|
|
||||||
- Keep lighting simple and fixed.
|
|
||||||
- Do not add non-essential mechanics before MVP checklist is complete.
|
|
||||||
523
README.md
523
README.md
@@ -1,34 +1,517 @@
|
|||||||
# Untitled Maze Game - ID30011 Midterm Project README
|
# Untitled Maze Game - ID30011 Midterm Project
|
||||||
|
|
||||||
|
**A 3D First-Person Time-Attack Maze Game with Progressive Difficulty**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Information
|
||||||
|
|
||||||
- **Name:** Bumgyu Suh
|
- **Name:** Bumgyu Suh
|
||||||
- **Student ID:** 20240905
|
- **Student ID:** 20240905
|
||||||
- **Student Email:** bumgyu@kaist.ac.kr
|
- **Student Email:** bumgyu@kaist.ac.kr
|
||||||
- **Repository URL:** https://git.prototyping.id/20240905/???
|
- **Repository URL:** https://git.prototyping.id/20240905/Untitled-Maze-Game
|
||||||
- **Video URL:** youtube.com
|
- **Video Demonstration:** https://youtu.be/8LDcLpsNJck (turn on youtube subtitles)
|
||||||
|
- **Play Link:** https://pobadoba.com/games/maze
|
||||||
|
|
||||||
## The Game
|
---
|
||||||
A description of the game - how it works and what the user has to do
|
|
||||||
|
|
||||||
WASD, V to switch view
|
## Game Overview
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
**Untitled Maze Game** is a 3D first-person maze escape game built with Babylon.js and p5.js. Players must navigate procedurally-generated mazes in a race against time, collecting a key and finding the exit within 60 seconds, but there's a twist! You must not open a chest that you have already opened before! Each successful round increases difficulty—maze size grows, more chests appear, and players advance to the next level. If you fail... there is a little surprising waiting for you (made with p5.js).
|
||||||
|
|
||||||
|
### Gameplay Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
START SCREEN
|
||||||
|
↓ Press R
|
||||||
|
GAMEPLAY (60 seconds)
|
||||||
|
↙ Time Up / Found Exit ↖
|
||||||
|
GAME OVER NEXT LEVEL
|
||||||
|
(Job Application
|
||||||
|
Jumpscare)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Play
|
||||||
|
|
||||||
|
0. **Project Setup:** Run the commands: `npm install`, then `npm run dev` in the project repository top folder; alternatively, access the game in [this website](https://pobadoba.com/games/maze)
|
||||||
|
1. **Start the Game:** Press **R** on the start screen to begin
|
||||||
|
2. **Navigate:** Use **WASD** to move, mouse to look around
|
||||||
|
3. **Find the Key:** **Left-Click** chests until you find the key, do not click on a chest you have opened before (leads to game over)
|
||||||
|
4. **Reach the Exit:** Once you have the key, reach the exit door
|
||||||
|
5. **Survive the Time:** You have 60 seconds per round. Time runs out = Game Over (Job Application Jumpscare ending)
|
||||||
|
6. **Progress:** Successfully exiting unlocks the next level with a larger maze and more chests
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| **W/A/S/D** | Move forward/left/backward/right |
|
||||||
|
| **Mouse** | Look around (first-person) |
|
||||||
|
| **Left Click** | Open a highlighted chest |
|
||||||
|
| **R** | Start game (from start screen) or restart (from game-over screen) |
|
||||||
|
| **B** | (For debugging purposes) Toggle debug panel (hidden by default) |
|
||||||
|
| **V** | (For debugging purposes) Switch camera mode (first-person ↔ overview) |
|
||||||
|
| **ESC** | Exit pointer lock |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Code Documentation
|
## Code Documentation
|
||||||
A description of the organization of your code. Feel free to use diagrams, UML, or others. What are the main functions/classes? If you used patterns, what did you use them for, and how do different parts of your code speak to each other?
|
|
||||||
Highlight any issue you want us to know about or whether the code has any known bug. If there are special features you want us to know about, write them here
|
|
||||||
|
|
||||||
Acknowledge any help or resource you used
|
### Packages Used
|
||||||
Writing style might be considered in grading (not the grammar, but rather the clarity of your writing)
|
- **3D Graphics:** Babylon.js (3D scene, camera, meshes, rendering)
|
||||||
Be visual so use images and tables
|
- **2D Graphics:** p5.js (particle effects for game-over screen)
|
||||||
Try to be complete in your explanation - you do not need to write a lot, but the professor and the TA should be able to understand your documentation and your code by reading this file.
|
- **Audio:** Web Audio API
|
||||||
|
|
||||||
## Help from AI
|
### Files Structure
|
||||||
I got help from AI in criticizing my original plan in structuring my code. For example, although I originally planned to have a single 2D array that stores both the "static" information (the generated maze), and the state of whether the chests have been open or not, but chatgpt suggested that separating these two (one static, one dynamically changing during gameplay) is better as it prevents everything being coupled to this array.
|
|
||||||
It also suggested ot
|
|
||||||
|
|
||||||
The game can be played through the multi_sketch.html
|
```
|
||||||
"Start run" to generate first leve, etc, seed,
|
src/
|
||||||
left0click chests
|
├── babylon_panel.js # Game orchestrator (scene init, controller, API)
|
||||||
|
├── html_panel.js # Debug UI state management
|
||||||
|
├── p5_particles.js # Particle effects for game over screen
|
||||||
|
├── game/
|
||||||
|
│ ├── state.js # Shared game state (config + runtime)
|
||||||
|
│ ├── maze.js # Procedural maze generation (seeded RNG)
|
||||||
|
│ ├── grid.js # Coordinate conversion, collision helpers
|
||||||
|
│ ├── sfx.js # Sound effect playback system
|
||||||
|
│ ├── scene-init.js # Babylon.js engine & scene initialization
|
||||||
|
│ ├── camera-manager.js # Camera creation, mode switching, updates
|
||||||
|
│ ├── game-loop.js # Main render loop, game state machine
|
||||||
|
│ ├── level-generator.js # Level building, mesh placement, spawning
|
||||||
|
│ ├── collisions.js # Raycasting, proximity checks, highlighting
|
||||||
|
│ └── screen-manager.js # Start/game-over screen transitions
|
||||||
|
├── controls/
|
||||||
|
│ └── input-handler.js # Keyboard, pointer, click event handling
|
||||||
|
├── assets/
|
||||||
|
│ └── materials.js # Babylon.js material & texture factories
|
||||||
|
└── ui/
|
||||||
|
└── hud.js # HUD updates, visual animations
|
||||||
|
|
||||||
How well and complete is your documentation? Your documentation should contain the following information:
|
css/
|
||||||
|
└── style.css # Responsive layout, HUD styling, animations
|
||||||
|
|
||||||
Write a README.md file that contains the following information.
|
img/
|
||||||
|
├── img_start.png # Start screen background
|
||||||
|
├── img_jobapplication.png # Game-over screen background
|
||||||
|
├── img_chest.png # Chest texture
|
||||||
|
├── img_door.png # Exit door texture
|
||||||
|
├── img_wall.png # Wall texture
|
||||||
|
└── img_ground.png # Floor texture
|
||||||
|
|
||||||
|
sfx/
|
||||||
|
├── sfx_click.wav # UI interaction sound
|
||||||
|
├── sfx_chest_open.wav # Chest opening sound
|
||||||
|
├── sfx_key.wav # Key collection sound
|
||||||
|
├── sfx_clock.wav # Low-time warning alarm
|
||||||
|
├── sfx_step.wav # Footstep sound
|
||||||
|
├── sfx_win.wav # Level complete sound
|
||||||
|
├── sfx_lose.wav # Game over sound
|
||||||
|
└── sfx_chest_close.wav # Chest closing sound
|
||||||
|
```
|
||||||
|
|
||||||
|
**Module Organization:**
|
||||||
|
- **game/**: Core game logic (state, generation, collision, audio)
|
||||||
|
- **controls/**: Input and event handling
|
||||||
|
- **assets/**: Reusable factories (materials, textures)
|
||||||
|
- **ui/**: Visual feedback (HUD, overlays)
|
||||||
|
- Root level (babylon_panel.js): Main script that ties everything together
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
#### 1. **babylon_panel.js** (Game Orchestrator)
|
||||||
|
**Purpose:** Main application controller that coordinates all game systems
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
- Scene initialization via `initializeScene()`
|
||||||
|
- Camera setup and attachment via `createCameras()`
|
||||||
|
- Input handler registration via `setupInputHandlers()`
|
||||||
|
- Game loop registration via `registerGameLoop()`
|
||||||
|
- Screen transitions via `showGameOverScreen()`, `hideGameOverScreen()`, etc.
|
||||||
|
- Level generation coordination
|
||||||
|
- State management and API exposure
|
||||||
|
|
||||||
|
**Key Flow:**
|
||||||
|
```
|
||||||
|
Page Load → babylon_panel.js
|
||||||
|
↓ initialize scene, cameras, sphere
|
||||||
|
↓ setupInputHandlers() → register keyboard/pointer events
|
||||||
|
↓ registerGameLoop() → register frame-by-frame updates
|
||||||
|
↓ showStartScreen()
|
||||||
|
↓ (user presses R)
|
||||||
|
↓ startRunFromStartScreen() → generateLevel()
|
||||||
|
↓ (gameplay loop runs until win/lose)
|
||||||
|
↓ showGameOverScreen() → p5 particle sketch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- `window.mazeGameApi.generateLevel()` — Called by html_panel.js (debug controls)
|
||||||
|
|
||||||
|
#### 2. **game/scene-init.js** (Scene Initialization)
|
||||||
|
**Purpose:** Babylon.js engine and scene setup
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `initializeScene(canvas)` — Creates engine, scene, lighting, gravity, collision setup
|
||||||
|
- `startRenderLoop(engine, scene)` — Starts the main render loop and resize handler
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Encapsulates Babylon.js boilerplate
|
||||||
|
- Easier to swap or test rendering configuration
|
||||||
|
- Decouples main scene runner from engine details
|
||||||
|
|
||||||
|
#### 3. **game/camera-manager.js** (Camera Management)
|
||||||
|
**Purpose:** First-person and overview camera creation and switching
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `createCameras(scene, canvas)` — Creates both fpCamera and overviewCamera with all settings
|
||||||
|
- `switchCameraMode(scene, canvas, fpCamera, overviewCamera, state)` — Toggles between modes (V key)
|
||||||
|
- `updateOverviewCameraForMaze(overviewCamera, w, h)` — Adjusts overview camera for current maze size
|
||||||
|
- `attachCamera(scene, camera, canvas)` — Attaches camera to scene and activates it
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Isolates complex camera configuration
|
||||||
|
- Pointer lock exit handled cleanly during mode switches
|
||||||
|
- Easier to add new camera modes (e.g., isometric, cinematic)
|
||||||
|
|
||||||
|
#### 4. **game/level-generator.js** (Level Generation & Building)
|
||||||
|
**Purpose:** Procedural maze-to-scene conversion
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `clearLevelMeshes(levelMeshes, state)` — Disposes old meshes, clears chest map
|
||||||
|
- `buildLevelFromGrid(scene, grid, state, levelMeshes)` — Creates floor and wall meshes from grid
|
||||||
|
- `placeChestsOnDeadEnds(scene, grid, deadEnds, minCount, seed, state, levelMeshes)` — Places chests, marks key chest
|
||||||
|
- `placeExit(scene, grid, seed, state, levelMeshes)` — Places exit door on available dead-end
|
||||||
|
- `spawnCameraAt(scene, grid, camera, state)` — Positions camera far from exit
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Pure spatial logic, no game state mutations beyond scene meshes
|
||||||
|
- Can be tested with mock scenes
|
||||||
|
- Easy to add new level features (traps, collectibles, etc.)
|
||||||
|
|
||||||
|
#### 5. **game/collisions.js** (Interaction Detection)
|
||||||
|
**Purpose:** Raycasting and proximity checks for game interactions
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `checkChestRaycast(scene, fpCamera, maxDistance)` — Raycast from camera to detect highlighted chest
|
||||||
|
- `checkExitProximity(playerPos, exitPos, threshold)` — Distance check for win condition
|
||||||
|
- `setChestHighlight(mesh)` — Apply/remove outline highlight
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Isolated raycasting logic
|
||||||
|
- Reusable collision checks
|
||||||
|
- Easier to add new interaction types
|
||||||
|
|
||||||
|
#### 6. **game/game-loop.js** (Main Game Loop)
|
||||||
|
**Purpose:** Frame-by-frame game state updates and logic
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `registerGameLoop(scene, engine, state, callbacks)` — Registers `scene.registerBeforeRender()` with:
|
||||||
|
- HUD updates (time, key, rounds)
|
||||||
|
- Chest raycasting and highlighting
|
||||||
|
- Timer countdown
|
||||||
|
- Low-time alert (< 10 seconds)
|
||||||
|
- Footstep audio based on movement
|
||||||
|
- Exit proximity check for win
|
||||||
|
- Time-up check for lose
|
||||||
|
|
||||||
|
**Game Loop Sequence:**
|
||||||
|
1. Update HUD display and sphere animation
|
||||||
|
2. Raycast for highlighted chest
|
||||||
|
3. If gameplay active:
|
||||||
|
- Decrement timer
|
||||||
|
- Check low-time threshold (play clock sound once)
|
||||||
|
- Update footsteps (0.75 distance, 220ms min)
|
||||||
|
- Check exit proximity (< 1.8 units)
|
||||||
|
- If time up: call onGameOver callback
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Separates frame logic from initialization
|
||||||
|
- Callbacks for win/lose handled by orchestrator
|
||||||
|
- Easy to debug timing and collision issues
|
||||||
|
|
||||||
|
#### 7. **controls/input-handler.js** (Input Management)
|
||||||
|
**Purpose:** Keyboard, pointer lock, and click event handling
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `setupInputHandlers(canvas, state, callbacks)` — Registers:
|
||||||
|
- Click for pointer lock (requestPointerLockSafely)
|
||||||
|
- Keyboard: W/A/S/D/V/R/B with audio priming
|
||||||
|
- Pointer events for chest interaction
|
||||||
|
|
||||||
|
**Event Bindings:**
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| **Click** | Request pointer lock |
|
||||||
|
| **W/A/S/D** | Prime audio context + movement |
|
||||||
|
| **V** | onCameraToggle callback |
|
||||||
|
| **R** | onRestart (if game over) or onStartGame (if on start screen) |
|
||||||
|
| **B** | onDebugToggle callback |
|
||||||
|
| **Left Click on Chest** | Check chest state, mark as opened, play sounds |
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Centralized input routing
|
||||||
|
- Callbacks allow orchestrator to control behavior
|
||||||
|
- Easy to add gamepad support
|
||||||
|
|
||||||
|
#### 8. **assets/materials.js** (Material Factories)
|
||||||
|
**Purpose:** Create reusable Babylon.js materials with textures
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `createFloorMaterial(scene, width, height)` — Ground with repeating texture
|
||||||
|
- `createWallMaterial(scene)` — Wall texture
|
||||||
|
- `createChestMaterial(scene, isKey)` — Chest with optional golden emissive (key variant)
|
||||||
|
- `createExitMaterial(scene)` — Door texture
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Decouples texture setup from level generation
|
||||||
|
- Reusable material factories for future features
|
||||||
|
- Centralized texture configuration
|
||||||
|
|
||||||
|
#### 9. **game/screen-manager.js** (Screen Transitions)
|
||||||
|
**Purpose:** Show/hide start and game-over screens with p5.js sketches
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `showGameOverScreen(state)` — Hide canvas, show p5 overlay, start particle sketch
|
||||||
|
- `hideGameOverScreen(state)` — Show canvas, hide p5 overlay, stop sketches
|
||||||
|
- `showStartScreen(state)` — Initialize p5 start screen sketch
|
||||||
|
- `hideStartScreen(state)` — Stop start screen sketch
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Encapsulates p5 lifecycle management
|
||||||
|
- Clean separation of screen logic
|
||||||
|
- Easy to add new screens (level intro, pause menu, etc.)
|
||||||
|
|
||||||
|
#### 10. **ui/hud.js** (HUD Display)
|
||||||
|
**Purpose:** Update on-screen HUD elements and visual feedback
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `updateHUD(state)` — Update time, key, rounds displays from shared state
|
||||||
|
- `setLowTimeWarning(isLowTime)` — Add/remove "low-time" CSS class for pulsing red
|
||||||
|
- `updateSphereMesh(sphere, level)` — Rotate and scale sphere based on level
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Separates DOM updates from game logic
|
||||||
|
- Reusable styling/animation controls
|
||||||
|
- Can easily add new HUD elements
|
||||||
|
|
||||||
|
#### 11. **game/state.js** (Shared State)
|
||||||
|
**Purpose:** Centralized game state management to prevent coupling between modules
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```javascript
|
||||||
|
window.mazeGameState = {
|
||||||
|
config: {
|
||||||
|
seed, // Reproducible maze generation
|
||||||
|
level, // Current progression level
|
||||||
|
mazeWidth: 11, // Base width (increases per level)
|
||||||
|
mazeHeight: 11, // Base height
|
||||||
|
minChestDeadEnds: 2 // Min chests to place
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
runActive, // Is gameplay active?
|
||||||
|
hasKey, // Player collected key?
|
||||||
|
roundsCompleted, // Levels completed this run
|
||||||
|
elapsedSeconds, // Countdown timer (60 → 0)
|
||||||
|
message // Status text for HUD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Single source of truth for all game state
|
||||||
|
- All modules read/write to same object (no data duplication)
|
||||||
|
- Easy to serialize/save game state
|
||||||
|
- Enables time-travel debugging via console inspection
|
||||||
|
|
||||||
|
#### 12. **game/maze.js** (Procedural Generation)
|
||||||
|
**Purpose:** Generate solvable, deterministic mazes using seeded randomization
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `seededRng(seed)` — Deterministic random number generator (same seed = same sequence)
|
||||||
|
- `generateMazeGrid(w, h, seed)` — Recursive backtracking maze algorithm
|
||||||
|
- `findDeadEnds(grid)` — Locate dead-end cells for chest/exit placement
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
Recursive backtracking with seeded RNG ensures:
|
||||||
|
- Every maze is solvable (connected)
|
||||||
|
- Same seed produces identical maze (perfect for replays)
|
||||||
|
- Configurable difficulty (width/height parameters)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Deterministic yet varied level generation
|
||||||
|
- No performance issues (pre-computed before rendering)
|
||||||
|
- Perfect reproducibility for competitive play
|
||||||
|
|
||||||
|
#### 13. **game/grid.js** (Coordinate Utilities)
|
||||||
|
**Purpose:** Convert between grid coordinates and 3D world space for collision detection
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `gridCellToWorld(grid, x, y, cellSize)` — Grid cell → world position (returns `{x, z}`)
|
||||||
|
- `isWalkableCell(grid, x, y)` — Check if cell is navigable (not a wall)
|
||||||
|
|
||||||
|
**Key Insight:**
|
||||||
|
Mazes are generated as 2D grids (1 = wall, 0 = path). This module bridges the gap between grid coordinates used by `maze.js` and 3D world positions used by `level-generator.js`.
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Decouples grid logic from spatial positioning
|
||||||
|
- Makes collision detection and pathfinding calculations simpler
|
||||||
|
- Reusable for any grid-based game mechanic
|
||||||
|
|
||||||
|
#### 14. **game/sfx.js** (Audio System)
|
||||||
|
**Purpose:** Load and playback polyphonic sound effects with Web Audio API
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `playSfx(name, volume)` — Play a sound effect by name (cloned for polyphony)
|
||||||
|
- `primeSfx()` — Initialize audio context (required by browser for first sound)
|
||||||
|
|
||||||
|
**Audio Files** (imported via ES6, bundled by Vite):
|
||||||
|
```javascript
|
||||||
|
import clickUrl from "../../sfx/sfx_click.wav"
|
||||||
|
import chestOpenUrl from "../../sfx/sfx_chest_open.wav"
|
||||||
|
import chestCloseUrl from "../../sfx/sfx_chest_close.wav"
|
||||||
|
import keyUrl from "../../sfx/sfx_key.wav"
|
||||||
|
import clockUrl from "../../sfx/sfx_clock.wav"
|
||||||
|
import stepUrl from "../../sfx/sfx_step.wav"
|
||||||
|
import winUrl from "../../sfx/sfx_win.wav"
|
||||||
|
import loseUrl from "../../sfx/sfx_lose.wav"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Polyphony via Cloning:**
|
||||||
|
Each call to `playSfx()` clones the audio node, allowing multiple sounds to play simultaneously (e.g., footsteps + clock alarm).
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Polyphonic sound effects (no cutting each other off)
|
||||||
|
- Browser-compatible audio context priming
|
||||||
|
- Centralized audio management (easy to add volume controls, reverb, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features & Implementation Details
|
||||||
|
|
||||||
|
| Feature | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| **3D Rendering** | Babylon.js UniversalCamera, procedural mesh generation |
|
||||||
|
| **Procedural Mazes** | Seeded random generation with configurable dimensions |
|
||||||
|
| **Time-Attack Mode** | 60-second countdown timer with auto-game-over on timeout |
|
||||||
|
| **Progressive Difficulty** | Maze size & chest count increase per completed level |
|
||||||
|
| **Collision Detection** | Raycasting for chest interaction, sphere collision for exit |
|
||||||
|
| **Sound System** | polyphonic sound effects with Web Audio API |
|
||||||
|
| **Particle Effects** | p5.js animated particles with physics on game-over screen |
|
||||||
|
| **Start Screen** | Full-screen p5.js panel with starting image |
|
||||||
|
| **Game-Over Screen** | Full-screen overlay with job application image + particles |
|
||||||
|
| **Visual Warnings** | Red pulsing timer + clock sound when time < 10 seconds |
|
||||||
|
| **Camera Modes** | First-person (WASD + mouse) and overhead orbital view |
|
||||||
|
| **Responsive Layout** | Full-screen canvas with bottom-overlay debug controls |
|
||||||
|
|
||||||
|
**Code Quality Patterns:**
|
||||||
|
- **Shared State Pattern:** `window.mazeGameState` prevents data coupling across modules
|
||||||
|
- **Factory Pattern:** `generateLevel()` creates and caches mesh instances
|
||||||
|
- **Observer Pattern:** `registerBeforeRender()` for frame-synchronized updates
|
||||||
|
- **Async/Await:** p5.js async image loading for non-blocking resource loading
|
||||||
|
|
||||||
|
**Known Problems**
|
||||||
|
1. **Pointer Lock Exit:** Pointer lock may be annoying for newcomers to web browser games
|
||||||
|
2. **Chunk Size Warning:** Built JavaScript is ~9.1 MB due to image assets
|
||||||
|
- Not an issue for local play
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions & Rationale
|
||||||
|
|
||||||
|
### 1. Modular Architecture with Separation of Concerns
|
||||||
|
**Decision:** Organize code into 14 focused modules rather than monolithic files
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Each module has a single responsibility (scene setup, input handling, collision detection, etc.)
|
||||||
|
- No circular dependencies — clean dependency flow from orchestrator down to utilities
|
||||||
|
- Easier to test, debug, and extend individual features
|
||||||
|
- New developers can understand one module without understanding the whole codebase
|
||||||
|
- Easy to add features: extend relevant modules instead of modifying monolithic files
|
||||||
|
|
||||||
|
**Architecture Pattern:**
|
||||||
|
```
|
||||||
|
babylon_panel.js (orchestrator) ← high-level control
|
||||||
|
├→ game/scene-init.js ← rendering setup
|
||||||
|
├→ game/camera-manager.js ← camera control
|
||||||
|
├→ controls/input-handler.js ← event routing
|
||||||
|
├→ game/level-generator.js ← spatial layout
|
||||||
|
├→ game/game-loop.js ← frame updates
|
||||||
|
├→ game/screen-manager.js ← UI transitions
|
||||||
|
└→ game/state.js ← shared data (all modules)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Shared State Pattern via window.mazeGameState
|
||||||
|
**Decision:** Centralize all game state in single `window.mazeGameState` object
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Prevents tight coupling between UI, physics, and rendering systems
|
||||||
|
- All modules read/write from same source of truth (no data duplication)
|
||||||
|
- Simplifies debugging (inspect state in console at any time)
|
||||||
|
- Enables hot-reloading during development
|
||||||
|
|
||||||
|
### 3. Callback-Based Event Routing
|
||||||
|
**Decision:** Lower modules don't know about higher modules; communication via callbacks
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Reduces coupling between layers
|
||||||
|
- Easy to mock/test: pass different callbacks to change behavior
|
||||||
|
- Flexible event handling: same module used for different purposes
|
||||||
|
- Example: `registerGameLoop(scene, state, callbacks)` doesn't know about UI — it just calls callbacks
|
||||||
|
|
||||||
|
### 4. Babylon.js Over Three.js
|
||||||
|
**Decision:** Used Babylon.js for 3D graphics
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Built-in collision detection and raycasting
|
||||||
|
- Superior camera controls (UniversalCamera with pointer lock)
|
||||||
|
- Efficient mesh instancing for maze cells
|
||||||
|
- Excellent documentation for procedural generation
|
||||||
|
|
||||||
|
### 5. p5.js for Particle Effects
|
||||||
|
**Decision:** Delegated particle rendering to p5.js instead of Babylon.js
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Cleaner separation of concerns (game logic ≠ visual effects)
|
||||||
|
- p5.js's simple drawing API reduces code complexity
|
||||||
|
- Easy to swap/experiment with particle physics without affecting core game
|
||||||
|
- Full-screen 2D canvas doesn't compete with 3D rendering pipeline
|
||||||
|
|
||||||
|
### 6. Time-Attack Mode (vs. Exploration, Level Editing)
|
||||||
|
**Decision:** 60-second countdown instead of unlimited time
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Creates urgency and strategic decision-making (pick efficient paths vs. explore)
|
||||||
|
- Enables meaningful progression (faster times unlock harder mazes)
|
||||||
|
- Reduces scope (no need for complex AI, item management, etc.)
|
||||||
|
|
||||||
|
### 7. Procedural Maze Generation with Seeded RNG
|
||||||
|
**Decision:** Use seeded random number generator for deterministic maze generation
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Varied layouts via different seeds (infinite replayability)
|
||||||
|
- Better than storing pre-made levels (scalable to any difficulty)
|
||||||
|
- Recursive backtracking ensures every maze is solvable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Help from AI & Resources
|
||||||
|
|
||||||
|
### AI Assistance
|
||||||
|
- **GitHub Copilot:** Used throughout development for code quality, structure, and refactoring
|
||||||
|
- **Early Development:** Suggested separating static maze data from dynamic game state (prevents coupling)
|
||||||
|
- **Module Organization:** Proposed logical file structure to improve maintainability
|
||||||
|
- **Code Review:** Reviewed p5.js particle physics, Babylon.js camera control, Web Audio API integration
|
||||||
|
- **Refactoring:** Assisted with modularizing 570-line monolithic file into 14 focused modules
|
||||||
|
- Extracted scene initialization, camera management, game loop, level generation, collision detection
|
||||||
|
- Created input handler, material factories, HUD updates, screen manager modules
|
||||||
|
- Verified no breaking changes, ensured build compatibility, tested audio bundling
|
||||||
|
- **Documentation:**
|
||||||
|
|
||||||
|
### Resources & Documentation
|
||||||
|
- **Babylon.js Playground:** Reference for collision detection, camera control, and mesh creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Untitled Maze Game** was an interesting project as it made me realize that a lot is possible simply through javascript libraries and today's LLMs. AI really saved a lot of time that would have normally taken me hours to debug, and having dialogue with the AI over the code structure and such was quite helpful and interesting. I think the advantage of AI is I can spend a lot more time on thinking about the big-picture structure of the code rather than the little details and line-by-line code, which is more fun.
|
||||||
|
|||||||
34
ZZREADME.md
34
ZZREADME.md
@@ -1,34 +0,0 @@
|
|||||||
# Untitled Maze Game - ID30011 Midterm Project README
|
|
||||||
|
|
||||||
- **Name:** Bumgyu Suh
|
|
||||||
- **Student ID:** 20240905
|
|
||||||
- **Student Email:** bumgyu@kaist.ac.kr
|
|
||||||
- **Repository URL:** https://git.prototyping.id/20240905/???
|
|
||||||
- **Video URL:** youtube.com
|
|
||||||
|
|
||||||
## The Game
|
|
||||||
A description of the game - how it works and what the user has to do
|
|
||||||
|
|
||||||
WASD, V to switch view
|
|
||||||
|
|
||||||
## Code Documentation
|
|
||||||
A description of the organization of your code. Feel free to use diagrams, UML, or others. What are the main functions/classes? If you used patterns, what did you use them for, and how do different parts of your code speak to each other?
|
|
||||||
Highlight any issue you want us to know about or whether the code has any known bug. If there are special features you want us to know about, write them here
|
|
||||||
|
|
||||||
Acknowledge any help or resource you used
|
|
||||||
Writing style might be considered in grading (not the grammar, but rather the clarity of your writing)
|
|
||||||
Be visual so use images and tables
|
|
||||||
Try to be complete in your explanation - you do not need to write a lot, but the professor and the TA should be able to understand your documentation and your code by reading this file.
|
|
||||||
|
|
||||||
## Help from AI
|
|
||||||
I got help from AI in criticizing my original plan in structuring my code. For example, although I originally planned to have a single 2D array that stores both the "static" information (the generated maze), and the state of whether the chests have been open or not, but chatgpt suggested that separating these two (one static, one dynamically changing during gameplay) is better as it prevents everything being coupled to this array.
|
|
||||||
It also suggested ot
|
|
||||||
|
|
||||||
The game can be played through the multi_sketch.html
|
|
||||||
"Start run" to generate first leve, etc, seed,
|
|
||||||
left0click chests
|
|
||||||
|
|
||||||
How well and complete is your documentation? Your documentation should contain the following information:
|
|
||||||
|
|
||||||
Write a README.md file that contains the following information.
|
|
||||||
|
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
|
/* Use border-box globally to avoid unexpected overflow from padding/borders */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
|
/* Prevent global scrollbars — inner panels manage their own scrolling */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.maze-page {
|
body.maze-page {
|
||||||
@@ -17,22 +24,24 @@ canvas {
|
|||||||
|
|
||||||
.canvas-stage {
|
.canvas-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maze-layout {
|
.maze-layout {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin: 0;
|
padding: 12px; /* use padding instead of margin to avoid adding to viewport height */
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maze-layout > .panel:first-child {
|
.maze-layout > .panel:first-child {
|
||||||
margin: 12px;
|
margin: 0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
height: calc(100vh - 24px);
|
height: calc(100vh - 24px); /* accounts for container padding */
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -54,7 +63,8 @@ canvas {
|
|||||||
|
|
||||||
#renderCanvas {
|
#renderCanvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(76vh, 820px);
|
height: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-hud {
|
.canvas-hud {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<body class="maze-page">
|
<body class="maze-page">
|
||||||
<main class="maze-layout">
|
<main class="maze-layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-label">UNTITLED MAZE GAME</div>
|
|
||||||
<div class="canvas-stage">
|
<div class="canvas-stage">
|
||||||
<canvas id="renderCanvas"></canvas>
|
<canvas id="renderCanvas"></canvas>
|
||||||
<div class="canvas-hud">
|
<div class="canvas-hud">
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="control-panel-section" hidden>
|
<section class="panel" id="control-panel-section" hidden>
|
||||||
<div class="panel-label">Game Controls</div>
|
<div class="panel-label">Debug Controls</div>
|
||||||
<div id="control-panel" class="control-panel">
|
<div id="control-panel" class="control-panel">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<h3>Run Controls</h3>
|
<h3>Run Controls</h3>
|
||||||
@@ -55,6 +54,7 @@
|
|||||||
<div class="status-line"><strong>Time left:</strong> <span id="status-time">60.0</span></div>
|
<div class="status-line"><strong>Time left:</strong> <span id="status-time">60.0</span></div>
|
||||||
<div class="status-line"><strong>Key:</strong> <span id="status-key">no</span></div>
|
<div class="status-line"><strong>Key:</strong> <span id="status-key">no</span></div>
|
||||||
<div class="status-line"><strong>Rounds:</strong> <span id="status-rounds">0</span></div>
|
<div class="status-line"><strong>Rounds:</strong> <span id="status-rounds">0</span></div>
|
||||||
|
<div id="status-message" class="status-line-message"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
54
src/assets/materials.js
Normal file
54
src/assets/materials.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
import chestTextureUrl from "../../img/img_chest.png";
|
||||||
|
import wallTextureUrl from "../../img/img_wall.png";
|
||||||
|
import groundTextureUrl from "../../img/img_ground.png";
|
||||||
|
import doorTextureUrl from "../../img/img_door.png";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create floor material
|
||||||
|
*/
|
||||||
|
export function createFloorMaterial(scene, width, height) {
|
||||||
|
const fm = new BABYLON.StandardMaterial('floorMat', scene);
|
||||||
|
fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
||||||
|
fm.diffuseTexture = new BABYLON.Texture(groundTextureUrl, scene);
|
||||||
|
fm.diffuseTexture.uScale = Math.max(1, Math.floor(width / 2));
|
||||||
|
fm.diffuseTexture.vScale = Math.max(1, Math.floor(height / 2));
|
||||||
|
fm.diffuseColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create wall material
|
||||||
|
*/
|
||||||
|
export function createWallMaterial(scene) {
|
||||||
|
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
|
||||||
|
wallMat.diffuseTexture = new BABYLON.Texture(wallTextureUrl, scene);
|
||||||
|
wallMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||||
|
return wallMat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create chest material (normal or key variant)
|
||||||
|
*/
|
||||||
|
export function createChestMaterial(scene, isKey = false) {
|
||||||
|
const mat = new BABYLON.StandardMaterial(`chestMat_${isKey ? 'key' : 'normal'}`, scene);
|
||||||
|
mat.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
||||||
|
mat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||||
|
|
||||||
|
if (isKey) {
|
||||||
|
mat.emissiveColor = new BABYLON.Color3(0.3, 0.22, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create exit door material
|
||||||
|
*/
|
||||||
|
export function createExitMaterial(scene) {
|
||||||
|
const exitMat = new BABYLON.StandardMaterial('exitMat', scene);
|
||||||
|
exitMat.diffuseTexture = new BABYLON.Texture(doorTextureUrl, scene);
|
||||||
|
exitMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||||
|
exitMat.emissiveColor = new BABYLON.Color3(0.07, 0.07, 0.07);
|
||||||
|
return exitMat;
|
||||||
|
}
|
||||||
@@ -1,165 +1,27 @@
|
|||||||
import * as BABYLON from "babylonjs";
|
import * as BABYLON from "babylonjs";
|
||||||
import { sharedState } from "./game/state.js";
|
import { sharedState } from "./game/state.js";
|
||||||
import { seededRng, generateMazeGrid, findDeadEnds } from "./game/maze.js";
|
import { generateMazeGrid, findDeadEnds } from "./game/maze.js";
|
||||||
import { gridCellToWorld, isWalkableCell } from "./game/grid.js";
|
import { isWalkableCell } from "./game/grid.js";
|
||||||
import { playSfx, primeSfx } from "./game/sfx.js";
|
import { playSfx, primeSfx } from "./game/sfx.js";
|
||||||
import { startParticleSketch, stopParticleSketch, startStartScreenSketch, stopStartScreenSketch } from "./p5_particles.js";
|
import { initializeScene, startRenderLoop } from "./game/scene-init.js";
|
||||||
import chestTextureUrl from "../img/img_chest.png";
|
import { createCameras, switchCameraMode, updateOverviewCameraForMaze, attachCamera } from "./game/camera-manager.js";
|
||||||
import wallTextureUrl from "../img/img_wall.png";
|
import { setupInputHandlers } from "./controls/input-handler.js";
|
||||||
import groundTextureUrl from "../img/img_ground.png";
|
import { buildLevelFromGrid, placeChestsOnDeadEnds, placeExit, spawnCameraAt, clearLevelMeshes } from "./game/level-generator.js";
|
||||||
import doorTextureUrl from "../img/img_door.png";
|
import { registerGameLoop, ROUND_TIME_SECONDS } from "./game/game-loop.js";
|
||||||
import gameOverImageUrl from "../img/img_jobapplication.png";
|
import { showGameOverScreen, hideGameOverScreen, showStartScreen, hideStartScreen } from "./game/screen-manager.js";
|
||||||
|
import { checkChestRaycast, setChestHighlight } from "./game/collisions.js";
|
||||||
|
|
||||||
// Initialize Babylon.js engine and scene
|
// DOM elements
|
||||||
const canvas = document.getElementById("renderCanvas");
|
const canvas = document.getElementById("renderCanvas");
|
||||||
const engine = new BABYLON.Engine(canvas, true);
|
|
||||||
const canvasTime = document.getElementById("canvas-time");
|
|
||||||
const canvasKey = document.getElementById("canvas-key");
|
|
||||||
const canvasRounds = document.getElementById("canvas-rounds");
|
|
||||||
const p5GameOverPanel = document.getElementById("p5-game-over-panel");
|
const p5GameOverPanel = document.getElementById("p5-game-over-panel");
|
||||||
const p5StartPanel = document.getElementById("p5-start-panel");
|
const p5StartPanel = document.getElementById("p5-start-panel");
|
||||||
const controlPanelSection = document.getElementById("control-panel-section");
|
const controlPanelSection = document.getElementById("control-panel-section");
|
||||||
let controlsVisible = false;
|
|
||||||
|
|
||||||
const scene = new BABYLON.Scene(engine);
|
// Initialize Babylon.js
|
||||||
scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1);
|
const { engine, scene } = initializeScene(canvas);
|
||||||
|
const { fpCamera, overviewCamera } = createCameras(scene, canvas);
|
||||||
|
|
||||||
const camera = new BABYLON.UniversalCamera("cam", new BABYLON.Vector3(0, 1.6, 0), scene);
|
// Initialize central sphere (visual indicator)
|
||||||
camera.minZ = 0.1;
|
|
||||||
camera.speed = 1.12;
|
|
||||||
camera.angularSensibility = 1000;
|
|
||||||
camera.inertia = 0.6;
|
|
||||||
camera.keysUp = [87]; // W
|
|
||||||
camera.keysDown = [83]; // S
|
|
||||||
camera.keysLeft = [65]; // A
|
|
||||||
camera.keysRight = [68]; // D
|
|
||||||
camera.checkCollisions = true;
|
|
||||||
camera.applyGravity = true;
|
|
||||||
camera.ellipsoid = new BABYLON.Vector3(0.35, 0.9, 0.35);
|
|
||||||
camera.attachControl(canvas, true);
|
|
||||||
|
|
||||||
const overviewCamera = new BABYLON.ArcRotateCamera(
|
|
||||||
"overviewCam",
|
|
||||||
-Math.PI / 2,
|
|
||||||
Math.PI / 3.2,
|
|
||||||
40,
|
|
||||||
new BABYLON.Vector3(0, 0, 0),
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
overviewCamera.lowerBetaLimit = 0.2;
|
|
||||||
overviewCamera.upperBetaLimit = Math.PI / 2.05;
|
|
||||||
overviewCamera.lowerRadiusLimit = 8;
|
|
||||||
overviewCamera.inertia = 0.7;
|
|
||||||
|
|
||||||
let cameraMode = "fp";
|
|
||||||
scene.activeCamera = camera;
|
|
||||||
let lastFootstepPosition = null;
|
|
||||||
let footstepAccumulator = 0;
|
|
||||||
let footstepElapsed = 0;
|
|
||||||
let gameOverActive = false;
|
|
||||||
let lowTimeAlertPlayed = false;
|
|
||||||
|
|
||||||
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
|
||||||
scene.collisionsEnabled = true;
|
|
||||||
|
|
||||||
canvas.addEventListener("click", () => {
|
|
||||||
primeSfx();
|
|
||||||
if (cameraMode === "fp" && document.pointerLockElement !== canvas) {
|
|
||||||
canvas.requestPointerLock();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateOverviewCameraForMaze(w, h) {
|
|
||||||
const mazeSpan = Math.max(w, h) * cellSize;
|
|
||||||
overviewCamera.radius = Math.max(mazeSpan * 1.05, 16);
|
|
||||||
overviewCamera.target = new BABYLON.Vector3(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showGameOverScreen() {
|
|
||||||
gameOverActive = true;
|
|
||||||
const canvasStage = document.querySelector(".canvas-stage");
|
|
||||||
if (canvasStage) {
|
|
||||||
canvasStage.hidden = true;
|
|
||||||
}
|
|
||||||
if (p5GameOverPanel) {
|
|
||||||
p5GameOverPanel.hidden = false;
|
|
||||||
const sketchContainer = document.getElementById("p5-sketch-container");
|
|
||||||
if (sketchContainer) {
|
|
||||||
startParticleSketch(sketchContainer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
|
||||||
document.exitPointerLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideGameOverScreen() {
|
|
||||||
gameOverActive = false;
|
|
||||||
const canvasStage = document.querySelector(".canvas-stage");
|
|
||||||
if (canvasStage) {
|
|
||||||
canvasStage.hidden = false;
|
|
||||||
}
|
|
||||||
if (p5GameOverPanel) {
|
|
||||||
p5GameOverPanel.hidden = true;
|
|
||||||
stopParticleSketch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restartRunFromGameOver() {
|
|
||||||
sharedState.runtime.runActive = true;
|
|
||||||
sharedState.runtime.hasKey = false;
|
|
||||||
sharedState.runtime.roundsCompleted = 0;
|
|
||||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
|
||||||
sharedState.runtime.message = "Run restarted.";
|
|
||||||
sharedState.config.level = 1;
|
|
||||||
hideGameOverScreen();
|
|
||||||
generateLevel();
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchCameraMode() {
|
|
||||||
if (cameraMode === "fp") {
|
|
||||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
|
||||||
document.exitPointerLock();
|
|
||||||
}
|
|
||||||
camera.detachControl(canvas);
|
|
||||||
overviewCamera.attachControl(canvas, true);
|
|
||||||
scene.activeCamera = overviewCamera;
|
|
||||||
cameraMode = "overview";
|
|
||||||
sharedState.runtime.message = "Overview camera (press V to return to first-person).";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
overviewCamera.detachControl(canvas);
|
|
||||||
camera.attachControl(canvas, true);
|
|
||||||
scene.activeCamera = camera;
|
|
||||||
cameraMode = "fp";
|
|
||||||
sharedState.runtime.message = "First-person camera (W/A/S/D + mouse).";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyV" || event.code === "KeyR") {
|
|
||||||
primeSfx();
|
|
||||||
}
|
|
||||||
if (event.code === "KeyB") {
|
|
||||||
toggleControlsPanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.code === "KeyR") {
|
|
||||||
if (gameOverActive) {
|
|
||||||
restartRunFromGameOver();
|
|
||||||
} else if (!sharedState.runtime.runActive && p5StartPanel && !p5StartPanel.hidden) {
|
|
||||||
startRunFromStartScreen();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.code === "KeyV") {
|
|
||||||
switchCameraMode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
|
||||||
|
|
||||||
// Central sphere (hidden but kept for reference)
|
|
||||||
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
|
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
|
||||||
sphere.isVisible = false;
|
sphere.isVisible = false;
|
||||||
const sphereMaterial = new BABYLON.StandardMaterial("sphereMaterial", scene);
|
const sphereMaterial = new BABYLON.StandardMaterial("sphereMaterial", scene);
|
||||||
@@ -167,394 +29,64 @@ sphereMaterial.diffuseColor = new BABYLON.Color3(0.2, 0.55, 0.95);
|
|||||||
sphereMaterial.emissiveColor = new BABYLON.Color3(0.05, 0.12, 0.2);
|
sphereMaterial.emissiveColor = new BABYLON.Color3(0.05, 0.12, 0.2);
|
||||||
sphere.material = sphereMaterial;
|
sphere.material = sphereMaterial;
|
||||||
|
|
||||||
// Main render loop
|
// Game state extensions
|
||||||
engine.runRenderLoop(() => {
|
sharedState.gameOverActive = false;
|
||||||
const level = sharedState.config.level;
|
sharedState.cameraMode = "fp";
|
||||||
sphere.rotation.y += 0.01;
|
sharedState.chestMap = new Map();
|
||||||
sphere.scaling.x = 1 + (level - 1) * 0.05;
|
sharedState.keyChestKey = null;
|
||||||
sphere.scaling.z = 1 + (level - 1) * 0.05;
|
sharedState.exitBox = null;
|
||||||
if (canvasTime) {
|
sharedState.exitGridPos = null;
|
||||||
canvasTime.textContent = `${sharedState.runtime.elapsedSeconds.toFixed(1)}s`;
|
sharedState.spawnGridPos = null;
|
||||||
}
|
sharedState.highlightedChest = null;
|
||||||
if (canvasKey) {
|
|
||||||
canvasKey.textContent = sharedState.runtime.hasKey ? "yes" : "no";
|
|
||||||
}
|
|
||||||
if (canvasRounds) {
|
|
||||||
canvasRounds.textContent = String(sharedState.runtime.roundsCompleted);
|
|
||||||
}
|
|
||||||
scene.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
// Attach first-person camera by default
|
||||||
engine.resize();
|
attachCamera(scene, fpCamera, canvas);
|
||||||
});
|
|
||||||
|
|
||||||
// Maze data structures
|
|
||||||
let levelMeshes = [];
|
let levelMeshes = [];
|
||||||
let chestMap = new Map(); // key: "x,y" -> {mesh, opened}
|
let controlsVisible = false;
|
||||||
let keyChestKey = null;
|
|
||||||
let exitBox = null;
|
|
||||||
let exitGridPos = null; // track exit grid position for collision checking
|
|
||||||
let spawnGridPos = null; // track spawn grid position for validation
|
|
||||||
let spawnMarker = null;
|
|
||||||
let highlightedChest = null;
|
|
||||||
const cellSize = 2;
|
|
||||||
const ROUND_TIME_SECONDS = 60;
|
|
||||||
|
|
||||||
function setChestHighlight(mesh) {
|
/**
|
||||||
if (highlightedChest === mesh) {
|
* Generate a new level
|
||||||
return;
|
*/
|
||||||
}
|
async function generateLevel() {
|
||||||
|
hideGameOverScreen(sharedState);
|
||||||
if (highlightedChest) {
|
|
||||||
highlightedChest.renderOutline = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
highlightedChest = mesh;
|
|
||||||
|
|
||||||
if (highlightedChest) {
|
|
||||||
highlightedChest.outlineColor = new BABYLON.Color3(0.85, 0.85, 0.85);
|
|
||||||
highlightedChest.outlineWidth = 0.08;
|
|
||||||
highlightedChest.renderOutline = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLevelMeshes() {
|
|
||||||
setChestHighlight(null);
|
|
||||||
for (const m of levelMeshes) {
|
|
||||||
try { m.dispose(); } catch(e) {}
|
|
||||||
}
|
|
||||||
levelMeshes = [];
|
|
||||||
chestMap.clear();
|
|
||||||
keyChestKey = null;
|
|
||||||
if (exitBox) { try { exitBox.dispose(); } catch(e){}; exitBox = null; }
|
|
||||||
if (spawnMarker) { try { spawnMarker.dispose(); } catch(e){}; spawnMarker = null; }
|
|
||||||
exitGridPos = null;
|
|
||||||
spawnGridPos = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReservedCell(x, y) {
|
|
||||||
if (chestMap.has(`${x},${y}`)) return true;
|
|
||||||
if (exitGridPos && exitGridPos.x === x && exitGridPos.y === y) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLevelFromGrid(grid) {
|
|
||||||
clearLevelMeshes();
|
|
||||||
const h = grid.length;
|
|
||||||
const w = grid[0].length;
|
|
||||||
const halfW = (w * cellSize) / 2;
|
|
||||||
const halfH = (h * cellSize) / 2;
|
|
||||||
|
|
||||||
const floor = BABYLON.MeshBuilder.CreateGround('levelGround', { width: w*cellSize, height: h*cellSize }, scene);
|
|
||||||
floor.position = new BABYLON.Vector3(0, 0, 0);
|
|
||||||
floor.checkCollisions = true;
|
|
||||||
const fm = new BABYLON.StandardMaterial('floorMat', scene);
|
|
||||||
fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
|
||||||
fm.diffuseTexture = new BABYLON.Texture(groundTextureUrl, scene);
|
|
||||||
fm.diffuseTexture.uScale = Math.max(1, Math.floor(w / 2));
|
|
||||||
fm.diffuseTexture.vScale = Math.max(1, Math.floor(h / 2));
|
|
||||||
fm.diffuseColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
|
||||||
floor.material = fm;
|
|
||||||
levelMeshes.push(floor);
|
|
||||||
|
|
||||||
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
|
|
||||||
wallMat.diffuseTexture = new BABYLON.Texture(wallTextureUrl, scene);
|
|
||||||
wallMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
if (grid[y][x] === 1) {
|
|
||||||
const box = BABYLON.MeshBuilder.CreateBox(`wall_${x}_${y}`, { size: cellSize }, scene);
|
|
||||||
box.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize/2, y*cellSize - halfH + cellSize/2);
|
|
||||||
box.material = wallMat;
|
|
||||||
box.checkCollisions = true;
|
|
||||||
levelMeshes.push(box);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeChestsOnDeadEnds(grid, deadEnds, minCount, seed) {
|
|
||||||
const rng = seededRng(seed);
|
|
||||||
for (let i = deadEnds.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(rng() * (i+1));
|
|
||||||
[deadEnds[i], deadEnds[j]] = [deadEnds[j], deadEnds[i]];
|
|
||||||
}
|
|
||||||
const chosen = deadEnds.slice(0, Math.min(minCount, deadEnds.length));
|
|
||||||
const halfW = (grid[0].length * cellSize) / 2;
|
|
||||||
const halfH = (grid.length * cellSize) / 2;
|
|
||||||
|
|
||||||
const chestMat = new BABYLON.StandardMaterial('chestMat', scene);
|
|
||||||
chestMat.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
|
||||||
chestMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
|
|
||||||
for (const [x,y] of chosen) {
|
|
||||||
const c = BABYLON.MeshBuilder.CreateBox(`chest_${x}_${y}`, { width: cellSize*0.8, height: cellSize*0.6, depth: cellSize*0.6 }, scene);
|
|
||||||
c.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize*0.3, y*cellSize - halfH + cellSize/2);
|
|
||||||
c.material = chestMat;
|
|
||||||
c.isPickable = true;
|
|
||||||
levelMeshes.push(c);
|
|
||||||
chestMap.set(`${x},${y}`, { mesh: c, opened: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosen.length > 0) {
|
|
||||||
const k = Math.floor(rng() * chosen.length);
|
|
||||||
const [kx, ky] = chosen[k];
|
|
||||||
keyChestKey = `${kx},${ky}`;
|
|
||||||
const entry = chestMap.get(keyChestKey);
|
|
||||||
if (entry) {
|
|
||||||
const km = new BABYLON.StandardMaterial('keyChestMat', scene);
|
|
||||||
km.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
|
||||||
km.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
km.emissiveColor = new BABYLON.Color3(0.3, 0.22, 0.02);
|
|
||||||
entry.mesh.material = km;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeExit(grid, seed) {
|
|
||||||
const dead = findDeadEnds(grid);
|
|
||||||
const rng = seededRng(seed+1);
|
|
||||||
if (dead.length === 0) return;
|
|
||||||
|
|
||||||
// Filter out dead ends that have chests
|
|
||||||
const availableDead = dead.filter(([x, y]) => !chestMap.has(`${x},${y}`));
|
|
||||||
if (availableDead.length === 0) {
|
|
||||||
// Fallback: use any dead end if no chest-free spot available
|
|
||||||
// This shouldn't happen in normal cases
|
|
||||||
const idx = Math.floor(rng() * dead.length);
|
|
||||||
const [x,y] = dead[idx];
|
|
||||||
exitGridPos = { x, y };
|
|
||||||
// Continue below
|
|
||||||
} else {
|
|
||||||
const idx = Math.floor(rng() * availableDead.length);
|
|
||||||
const [x,y] = availableDead[idx];
|
|
||||||
exitGridPos = { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x,y] = [exitGridPos.x, exitGridPos.y];
|
|
||||||
if (!isWalkableCell(grid, x, y)) {
|
|
||||||
console.warn("Exit selected on non-walkable cell", { x, y });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exitWorld = gridCellToWorld(grid, x, y, cellSize);
|
|
||||||
const ex = exitWorld.x;
|
|
||||||
const ez = exitWorld.z;
|
|
||||||
|
|
||||||
const plane = BABYLON.MeshBuilder.CreatePlane('exitDoor', {
|
|
||||||
width: cellSize * 1.35,
|
|
||||||
height: cellSize * 1.85,
|
|
||||||
sideOrientation: BABYLON.Mesh.DOUBLESIDE,
|
|
||||||
}, scene);
|
|
||||||
const exitMat = new BABYLON.StandardMaterial('exitMat', scene);
|
|
||||||
exitMat.diffuseTexture = new BABYLON.Texture(doorTextureUrl, scene);
|
|
||||||
exitMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
exitMat.emissiveColor = new BABYLON.Color3(0.07, 0.07, 0.07);
|
|
||||||
plane.material = exitMat;
|
|
||||||
plane.position = new BABYLON.Vector3(ex, cellSize * 0.92, ez);
|
|
||||||
plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y;
|
|
||||||
exitBox = plane;
|
|
||||||
levelMeshes.push(plane);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnCameraAt(grid) {
|
|
||||||
const h = grid.length;
|
|
||||||
const w = grid[0].length;
|
|
||||||
let bestCell = null;
|
|
||||||
let bestDist = -1;
|
|
||||||
|
|
||||||
// Choose a valid spawn that is far from exit when possible.
|
|
||||||
for (let y = 1; y < h - 1; y++) {
|
|
||||||
for (let x = 1; x < w - 1; x++) {
|
|
||||||
if (!isWalkableCell(grid, x, y)) continue;
|
|
||||||
if (isReservedCell(x, y)) continue;
|
|
||||||
|
|
||||||
const d = exitGridPos ? Math.hypot(x - exitGridPos.x, y - exitGridPos.y) : 0;
|
|
||||||
if (d > bestDist) {
|
|
||||||
bestDist = d;
|
|
||||||
bestCell = { x, y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bestCell) {
|
|
||||||
console.warn("No valid spawn cell found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnGridPos = bestCell;
|
|
||||||
const spawnWorld = gridCellToWorld(grid, bestCell.x, bestCell.y, cellSize);
|
|
||||||
const px = spawnWorld.x;
|
|
||||||
const pz = spawnWorld.z;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (camera && camera.position) {
|
|
||||||
camera.position = new BABYLON.Vector3(px, 1.6, pz);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : new BABYLON.Vector3(px, 1.6, pz);
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateLevel() {
|
|
||||||
hideGameOverScreen();
|
|
||||||
lowTimeAlertPlayed = false;
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.remove("low-time");
|
|
||||||
}
|
|
||||||
const cfg = sharedState.config;
|
const cfg = sharedState.config;
|
||||||
const seed = cfg.seed;
|
const seed = cfg.seed;
|
||||||
const roundScale = Math.max(0, cfg.level - 1);
|
const roundScale = Math.max(0, cfg.level - 1);
|
||||||
const w = Math.max(9, cfg.mazeWidth + roundScale * 2);
|
const w = Math.max(9, cfg.mazeWidth + roundScale * 2);
|
||||||
const h = Math.max(9, cfg.mazeHeight + roundScale * 2);
|
const h = Math.max(9, cfg.mazeHeight + roundScale * 2);
|
||||||
const chestCount = Math.max(1, cfg.minChestDeadEnds + roundScale);
|
const chestCount = Math.max(1, cfg.minChestDeadEnds + roundScale);
|
||||||
|
|
||||||
const grid = generateMazeGrid(w, h, seed + cfg.level);
|
const grid = generateMazeGrid(w, h, seed + cfg.level);
|
||||||
updateOverviewCameraForMaze(w, h);
|
|
||||||
const dead = findDeadEnds(grid);
|
const dead = findDeadEnds(grid);
|
||||||
buildLevelFromGrid(grid);
|
|
||||||
placeChestsOnDeadEnds(grid, dead, chestCount, seed + cfg.level);
|
clearLevelMeshes(levelMeshes, sharedState);
|
||||||
placeExit(grid, seed + cfg.level);
|
buildLevelFromGrid(scene, grid, sharedState, levelMeshes);
|
||||||
spawnCameraAt(grid);
|
placeChestsOnDeadEnds(scene, grid, dead, chestCount, seed + cfg.level, sharedState, levelMeshes);
|
||||||
|
placeExit(scene, grid, seed + cfg.level, sharedState, levelMeshes);
|
||||||
|
spawnCameraAt(scene, grid, fpCamera, sharedState);
|
||||||
|
updateOverviewCameraForMaze(overviewCamera, w, h);
|
||||||
|
|
||||||
const placementValid =
|
const placementValid =
|
||||||
!!exitGridPos &&
|
!!sharedState.exitGridPos &&
|
||||||
!!spawnGridPos &&
|
!!sharedState.spawnGridPos &&
|
||||||
isWalkableCell(grid, exitGridPos.x, exitGridPos.y) &&
|
isWalkableCell(grid, sharedState.exitGridPos.x, sharedState.exitGridPos.y) &&
|
||||||
isWalkableCell(grid, spawnGridPos.x, spawnGridPos.y) &&
|
isWalkableCell(grid, sharedState.spawnGridPos.x, sharedState.spawnGridPos.y) &&
|
||||||
!(exitGridPos.x === spawnGridPos.x && exitGridPos.y === spawnGridPos.y);
|
!(sharedState.exitGridPos.x === sharedState.spawnGridPos.x && sharedState.exitGridPos.y === sharedState.spawnGridPos.y);
|
||||||
|
|
||||||
if (!placementValid) {
|
if (!placementValid) {
|
||||||
sharedState.runtime.message = `Placement warning: spawn/exit invalid on level ${cfg.level}.`;
|
sharedState.runtime.message = `Placement warning: spawn/exit invalid on level ${cfg.level}.`;
|
||||||
console.warn("Invalid spawn/exit placement", { exitGridPos, spawnGridPos });
|
console.warn("Invalid spawn/exit placement", { exitGridPos: sharedState.exitGridPos, spawnGridPos: sharedState.spawnGridPos });
|
||||||
} else {
|
} else {
|
||||||
sharedState.runtime.message = `Level ${cfg.level} generated (spawn ${spawnGridPos.x},${spawnGridPos.y} / exit ${exitGridPos.x},${exitGridPos.y}).`;
|
sharedState.runtime.message = `Level ${cfg.level} generated (spawn ${sharedState.spawnGridPos.x},${sharedState.spawnGridPos.y} / exit ${sharedState.exitGridPos.x},${sharedState.exitGridPos.y}).`;
|
||||||
}
|
|
||||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : lastFootstepPosition;
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
window.requestAnimationFrame(()=>{ /* let scene update */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose API for p5 to call
|
|
||||||
window.mazeGameApi = { generateLevel };
|
|
||||||
|
|
||||||
// Pointer interaction for chests
|
|
||||||
scene.onPointerObservable.add((pi) => {
|
|
||||||
if (pi.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;
|
|
||||||
if (!sharedState.runtime.runActive || gameOverActive) return;
|
|
||||||
const pick = scene.pick(scene.pointerX, scene.pointerY);
|
|
||||||
if (!pick || !pick.hit || !pick.pickedMesh) return;
|
|
||||||
const m = pick.pickedMesh;
|
|
||||||
if (!m.name.startsWith('chest_')) return;
|
|
||||||
const coords = m.name.split('_').slice(1).join(',');
|
|
||||||
const entry = chestMap.get(coords);
|
|
||||||
if (!entry) return;
|
|
||||||
if (entry.opened) {
|
|
||||||
primeSfx();
|
|
||||||
playSfx("chestClose", 0.8);
|
|
||||||
playSfx("lose", 0.85);
|
|
||||||
sharedState.runtime.runActive = false;
|
|
||||||
sharedState.runtime.message = 'Opened chest again — game over.';
|
|
||||||
showGameOverScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
primeSfx();
|
|
||||||
playSfx("chestOpen", 0.8);
|
|
||||||
entry.opened = true;
|
|
||||||
if (coords === keyChestKey) {
|
|
||||||
sharedState.runtime.hasKey = true;
|
|
||||||
playSfx("key", 0.85);
|
|
||||||
sharedState.runtime.message = 'You found the key! Find the exit.';
|
|
||||||
} else {
|
|
||||||
sharedState.runtime.message = 'This chest was empty.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Level transition check
|
|
||||||
scene.registerBeforeRender(() => {
|
|
||||||
const targetRay = camera.getForwardRay(cellSize * 3.5);
|
|
||||||
const targetPick = scene.pickWithRay(targetRay, (mesh) => mesh.name.startsWith('chest_'));
|
|
||||||
if (targetPick && targetPick.hit && targetPick.pickedMesh) {
|
|
||||||
setChestHighlight(targetPick.pickedMesh);
|
|
||||||
} else {
|
|
||||||
setChestHighlight(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedState.runtime.runActive) {
|
|
||||||
const dt = engine.getDeltaTime() / 1000;
|
|
||||||
sharedState.runtime.elapsedSeconds = Math.max(0, sharedState.runtime.elapsedSeconds - dt);
|
|
||||||
|
|
||||||
const isLowTime = sharedState.runtime.elapsedSeconds < 10;
|
|
||||||
if (isLowTime && !lowTimeAlertPlayed) {
|
|
||||||
lowTimeAlertPlayed = true;
|
|
||||||
playSfx("clock", 0.75);
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.add("low-time");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isLowTime && lowTimeAlertPlayed) {
|
|
||||||
lowTimeAlertPlayed = false;
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.remove("low-time");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedState.runtime.elapsedSeconds <= 0) {
|
|
||||||
sharedState.runtime.runActive = false;
|
|
||||||
sharedState.runtime.message = "Time up — game over.";
|
|
||||||
playSfx("lose", 0.85);
|
|
||||||
showGameOverScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sharedState.runtime.runActive && cameraMode === "fp" && camera && camera.position && document.pointerLockElement === canvas) {
|
|
||||||
const currentPosition = camera.position;
|
|
||||||
if (!lastFootstepPosition) {
|
|
||||||
lastFootstepPosition = currentPosition.clone();
|
|
||||||
}
|
|
||||||
const horizontalDistance = Math.hypot(
|
|
||||||
currentPosition.x - lastFootstepPosition.x,
|
|
||||||
currentPosition.z - lastFootstepPosition.z,
|
|
||||||
);
|
|
||||||
footstepAccumulator += horizontalDistance;
|
|
||||||
footstepElapsed += engine.getDeltaTime();
|
|
||||||
if (footstepAccumulator > 0.75 && footstepElapsed > 220) {
|
|
||||||
playSfx("step", 0.65);
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
lastFootstepPosition = currentPosition.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sharedState.runtime.hasKey && exitBox && camera && camera.position) {
|
|
||||||
const pos = camera.position;
|
|
||||||
const ex = exitBox.position.x, ez = exitBox.position.z;
|
|
||||||
const dist = Math.hypot(pos.x - ex, pos.z - ez);
|
|
||||||
if (dist < cellSize * 0.9) {
|
|
||||||
playSfx("win", 0.85);
|
|
||||||
sharedState.config.level += 1;
|
|
||||||
sharedState.runtime.hasKey = false;
|
|
||||||
sharedState.runtime.roundsCompleted += 1;
|
|
||||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
|
||||||
sharedState.runtime.message = `Level ${sharedState.config.level} starting.`;
|
|
||||||
generateLevel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleControlsPanel() {
|
|
||||||
controlsVisible = !controlsVisible;
|
|
||||||
if (controlPanelSection) {
|
|
||||||
controlPanelSection.hidden = !controlsVisible;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start run from start screen
|
||||||
|
*/
|
||||||
function startRunFromStartScreen() {
|
function startRunFromStartScreen() {
|
||||||
if (p5StartPanel && !p5StartPanel.hidden) {
|
hideStartScreen(sharedState);
|
||||||
p5StartPanel.hidden = true;
|
|
||||||
stopStartScreenSketch();
|
|
||||||
}
|
|
||||||
sharedState.runtime.runActive = true;
|
sharedState.runtime.runActive = true;
|
||||||
sharedState.runtime.hasKey = false;
|
sharedState.runtime.hasKey = false;
|
||||||
sharedState.runtime.roundsCompleted = 0;
|
sharedState.runtime.roundsCompleted = 0;
|
||||||
@@ -566,14 +98,101 @@ function startRunFromStartScreen() {
|
|||||||
playSfx("click", 0.7);
|
playSfx("click", 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart from game-over
|
||||||
|
*/
|
||||||
|
function restartRunFromGameOver() {
|
||||||
|
sharedState.runtime.runActive = true;
|
||||||
|
sharedState.runtime.hasKey = false;
|
||||||
|
sharedState.runtime.roundsCompleted = 0;
|
||||||
|
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||||
|
sharedState.runtime.message = "Run restarted.";
|
||||||
|
sharedState.config.level = 1;
|
||||||
|
hideGameOverScreen(sharedState);
|
||||||
|
generateLevel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle debug controls panel
|
||||||
|
*/
|
||||||
|
function toggleControlsPanel() {
|
||||||
|
controlsVisible = !controlsVisible;
|
||||||
|
if (controlPanelSection) {
|
||||||
|
controlPanelSection.hidden = !controlsVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup input handlers
|
||||||
|
setupInputHandlers(canvas, sharedState, {
|
||||||
|
getCameraMode: () => sharedState.cameraMode,
|
||||||
|
isGameOverActive: () => sharedState.gameOverActive,
|
||||||
|
isStartPanelVisible: () => p5StartPanel && !p5StartPanel.hidden,
|
||||||
|
onDebugToggle: toggleControlsPanel,
|
||||||
|
onRestart: restartRunFromGameOver,
|
||||||
|
onStartGame: startRunFromStartScreen,
|
||||||
|
onCameraToggle: () => {
|
||||||
|
switchCameraMode(scene, canvas, fpCamera, overviewCamera, sharedState);
|
||||||
|
},
|
||||||
|
setupScenePointerObserver: () => {
|
||||||
|
scene.onPointerObservable.add((pi) => {
|
||||||
|
if (pi.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;
|
||||||
|
if (!sharedState.runtime.runActive || sharedState.gameOverActive) return;
|
||||||
|
|
||||||
|
const pick = scene.pick(scene.pointerX, scene.pointerY);
|
||||||
|
if (!pick || !pick.hit || !pick.pickedMesh) return;
|
||||||
|
|
||||||
|
const m = pick.pickedMesh;
|
||||||
|
if (!m.name.startsWith('chest_')) return;
|
||||||
|
|
||||||
|
const coords = m.name.split('_').slice(1).join(',');
|
||||||
|
const entry = sharedState.chestMap.get(coords);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (entry.opened) {
|
||||||
|
primeSfx();
|
||||||
|
playSfx("chestClose", 0.8);
|
||||||
|
playSfx("lose", 0.85);
|
||||||
|
sharedState.runtime.runActive = false;
|
||||||
|
sharedState.runtime.message = 'Opened chest again — game over.';
|
||||||
|
showGameOverScreen(sharedState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
primeSfx();
|
||||||
|
playSfx("chestOpen", 0.8);
|
||||||
|
entry.opened = true;
|
||||||
|
if (coords === sharedState.keyChestKey) {
|
||||||
|
sharedState.runtime.hasKey = true;
|
||||||
|
playSfx("key", 0.85);
|
||||||
|
sharedState.runtime.message = 'You found the key! Find the exit.';
|
||||||
|
} else {
|
||||||
|
sharedState.runtime.message = 'This chest was empty.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register game loop
|
||||||
|
registerGameLoop(scene, engine, sharedState, {
|
||||||
|
sphere,
|
||||||
|
fpCamera,
|
||||||
|
onGameOver: () => {
|
||||||
|
showGameOverScreen(sharedState);
|
||||||
|
},
|
||||||
|
onLevelComplete: () => {
|
||||||
|
generateLevel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start render loop
|
||||||
|
startRenderLoop(engine, scene);
|
||||||
|
|
||||||
|
// Expose API for other modules
|
||||||
|
window.mazeGameApi = { generateLevel };
|
||||||
|
|
||||||
// Initialize start screen on page load
|
// Initialize start screen on page load
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
if (p5StartPanel && !p5StartPanel.hidden) {
|
showStartScreen(sharedState);
|
||||||
const startContainer = document.getElementById("p5-start-container");
|
|
||||||
if (startContainer) {
|
|
||||||
startStartScreenSketch(startContainer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export shared state for p5 to use
|
// Export shared state for p5 to use
|
||||||
|
|||||||
@@ -1,526 +0,0 @@
|
|||||||
import * as BABYLON from "babylonjs";
|
|
||||||
import { sharedState } from "./game/state.js";
|
|
||||||
import { seededRng, generateMazeGrid, findDeadEnds } from "./game/maze.js";
|
|
||||||
import { gridCellToWorld, isWalkableCell } from "./game/grid.js";
|
|
||||||
import { playSfx, primeSfx } from "./game/sfx.js";
|
|
||||||
import chestTextureUrl from "../img/img_chest.png";
|
|
||||||
import wallTextureUrl from "../img/img_wall.png";
|
|
||||||
import groundTextureUrl from "../img/img_ground.png";
|
|
||||||
import doorTextureUrl from "../img/img_door.png";
|
|
||||||
import gameOverImageUrl from "../img/img_jobapplication.png";
|
|
||||||
|
|
||||||
// Initialize Babylon.js engine and scene
|
|
||||||
const canvas = document.getElementById("renderCanvas");
|
|
||||||
const engine = new BABYLON.Engine(canvas, true);
|
|
||||||
const canvasTime = document.getElementById("canvas-time");
|
|
||||||
const canvasKey = document.getElementById("canvas-key");
|
|
||||||
const canvasRounds = document.getElementById("canvas-rounds");
|
|
||||||
const gameOverOverlay = document.getElementById("game-over-overlay");
|
|
||||||
const gameOverImage = document.getElementById("game-over-image");
|
|
||||||
if (gameOverImage) {
|
|
||||||
gameOverImage.src = gameOverImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scene = new BABYLON.Scene(engine);
|
|
||||||
scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1);
|
|
||||||
|
|
||||||
const camera = new BABYLON.UniversalCamera("cam", new BABYLON.Vector3(0, 1.6, 0), scene);
|
|
||||||
camera.minZ = 0.1;
|
|
||||||
camera.speed = 1.12;
|
|
||||||
camera.angularSensibility = 1000;
|
|
||||||
camera.inertia = 0.6;
|
|
||||||
camera.keysUp = [87]; // W
|
|
||||||
camera.keysDown = [83]; // S
|
|
||||||
camera.keysLeft = [65]; // A
|
|
||||||
camera.keysRight = [68]; // D
|
|
||||||
camera.checkCollisions = true;
|
|
||||||
camera.applyGravity = true;
|
|
||||||
camera.ellipsoid = new BABYLON.Vector3(0.35, 0.9, 0.35);
|
|
||||||
camera.attachControl(canvas, true);
|
|
||||||
|
|
||||||
const overviewCamera = new BABYLON.ArcRotateCamera(
|
|
||||||
"overviewCam",
|
|
||||||
-Math.PI / 2,
|
|
||||||
Math.PI / 3.2,
|
|
||||||
40,
|
|
||||||
new BABYLON.Vector3(0, 0, 0),
|
|
||||||
scene,
|
|
||||||
);
|
|
||||||
overviewCamera.lowerBetaLimit = 0.2;
|
|
||||||
overviewCamera.upperBetaLimit = Math.PI / 2.05;
|
|
||||||
overviewCamera.lowerRadiusLimit = 8;
|
|
||||||
overviewCamera.inertia = 0.7;
|
|
||||||
|
|
||||||
let cameraMode = "fp";
|
|
||||||
scene.activeCamera = camera;
|
|
||||||
let lastFootstepPosition = null;
|
|
||||||
let footstepAccumulator = 0;
|
|
||||||
let footstepElapsed = 0;
|
|
||||||
let gameOverActive = false;
|
|
||||||
let lowTimeAlertPlayed = false;
|
|
||||||
|
|
||||||
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
|
||||||
scene.collisionsEnabled = true;
|
|
||||||
|
|
||||||
canvas.addEventListener("click", () => {
|
|
||||||
primeSfx();
|
|
||||||
if (cameraMode === "fp" && document.pointerLockElement !== canvas) {
|
|
||||||
canvas.requestPointerLock();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateOverviewCameraForMaze(w, h) {
|
|
||||||
const mazeSpan = Math.max(w, h) * cellSize;
|
|
||||||
overviewCamera.radius = Math.max(mazeSpan * 1.05, 16);
|
|
||||||
overviewCamera.target = new BABYLON.Vector3(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showGameOverScreen() {
|
|
||||||
gameOverActive = true;
|
|
||||||
if (gameOverOverlay) {
|
|
||||||
gameOverOverlay.hidden = false;
|
|
||||||
}
|
|
||||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
|
||||||
document.exitPointerLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideGameOverScreen() {
|
|
||||||
gameOverActive = false;
|
|
||||||
if (gameOverOverlay) {
|
|
||||||
gameOverOverlay.hidden = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restartRunFromGameOver() {
|
|
||||||
sharedState.runtime.runActive = true;
|
|
||||||
sharedState.runtime.hasKey = false;
|
|
||||||
sharedState.runtime.roundsCompleted = 0;
|
|
||||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
|
||||||
sharedState.runtime.message = "Run restarted.";
|
|
||||||
sharedState.config.level = 1;
|
|
||||||
hideGameOverScreen();
|
|
||||||
generateLevel();
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchCameraMode() {
|
|
||||||
if (cameraMode === "fp") {
|
|
||||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
|
||||||
document.exitPointerLock();
|
|
||||||
}
|
|
||||||
camera.detachControl(canvas);
|
|
||||||
overviewCamera.attachControl(canvas, true);
|
|
||||||
scene.activeCamera = overviewCamera;
|
|
||||||
cameraMode = "overview";
|
|
||||||
sharedState.runtime.message = "Overview camera (press V to return to first-person).";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
overviewCamera.detachControl(canvas);
|
|
||||||
camera.attachControl(canvas, true);
|
|
||||||
scene.activeCamera = camera;
|
|
||||||
cameraMode = "fp";
|
|
||||||
sharedState.runtime.message = "First-person camera (W/A/S/D + mouse).";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
|
||||||
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyD" || event.code === "KeyV" || event.code === "KeyR") {
|
|
||||||
primeSfx();
|
|
||||||
}
|
|
||||||
if (event.code === "KeyR" && gameOverActive) {
|
|
||||||
restartRunFromGameOver();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.code === "KeyV") {
|
|
||||||
switchCameraMode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
|
||||||
|
|
||||||
// Central sphere (hidden but kept for reference)
|
|
||||||
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
|
|
||||||
sphere.isVisible = false;
|
|
||||||
const sphereMaterial = new BABYLON.StandardMaterial("sphereMaterial", scene);
|
|
||||||
sphereMaterial.diffuseColor = new BABYLON.Color3(0.2, 0.55, 0.95);
|
|
||||||
sphereMaterial.emissiveColor = new BABYLON.Color3(0.05, 0.12, 0.2);
|
|
||||||
sphere.material = sphereMaterial;
|
|
||||||
|
|
||||||
// Main render loop
|
|
||||||
engine.runRenderLoop(() => {
|
|
||||||
const level = sharedState.config.level;
|
|
||||||
sphere.rotation.y += 0.01;
|
|
||||||
sphere.scaling.x = 1 + (level - 1) * 0.05;
|
|
||||||
sphere.scaling.z = 1 + (level - 1) * 0.05;
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.textContent = `${sharedState.runtime.elapsedSeconds.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
if (canvasKey) {
|
|
||||||
canvasKey.textContent = sharedState.runtime.hasKey ? "yes" : "no";
|
|
||||||
}
|
|
||||||
if (canvasRounds) {
|
|
||||||
canvasRounds.textContent = String(sharedState.runtime.roundsCompleted);
|
|
||||||
}
|
|
||||||
scene.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
engine.resize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Maze data structures
|
|
||||||
let levelMeshes = [];
|
|
||||||
let chestMap = new Map(); // key: "x,y" -> {mesh, opened}
|
|
||||||
let keyChestKey = null;
|
|
||||||
let exitBox = null;
|
|
||||||
let exitGridPos = null; // track exit grid position for collision checking
|
|
||||||
let spawnGridPos = null; // track spawn grid position for validation
|
|
||||||
let spawnMarker = null;
|
|
||||||
let highlightedChest = null;
|
|
||||||
const cellSize = 2;
|
|
||||||
const ROUND_TIME_SECONDS = 60;
|
|
||||||
|
|
||||||
function setChestHighlight(mesh) {
|
|
||||||
if (highlightedChest === mesh) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (highlightedChest) {
|
|
||||||
highlightedChest.renderOutline = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
highlightedChest = mesh;
|
|
||||||
|
|
||||||
if (highlightedChest) {
|
|
||||||
highlightedChest.outlineColor = new BABYLON.Color3(0.85, 0.85, 0.85);
|
|
||||||
highlightedChest.outlineWidth = 0.08;
|
|
||||||
highlightedChest.renderOutline = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLevelMeshes() {
|
|
||||||
setChestHighlight(null);
|
|
||||||
for (const m of levelMeshes) {
|
|
||||||
try { m.dispose(); } catch(e) {}
|
|
||||||
}
|
|
||||||
levelMeshes = [];
|
|
||||||
chestMap.clear();
|
|
||||||
keyChestKey = null;
|
|
||||||
if (exitBox) { try { exitBox.dispose(); } catch(e){}; exitBox = null; }
|
|
||||||
if (spawnMarker) { try { spawnMarker.dispose(); } catch(e){}; spawnMarker = null; }
|
|
||||||
exitGridPos = null;
|
|
||||||
spawnGridPos = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReservedCell(x, y) {
|
|
||||||
if (chestMap.has(`${x},${y}`)) return true;
|
|
||||||
if (exitGridPos && exitGridPos.x === x && exitGridPos.y === y) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLevelFromGrid(grid) {
|
|
||||||
clearLevelMeshes();
|
|
||||||
const h = grid.length;
|
|
||||||
const w = grid[0].length;
|
|
||||||
const halfW = (w * cellSize) / 2;
|
|
||||||
const halfH = (h * cellSize) / 2;
|
|
||||||
|
|
||||||
const floor = BABYLON.MeshBuilder.CreateGround('levelGround', { width: w*cellSize, height: h*cellSize }, scene);
|
|
||||||
floor.position = new BABYLON.Vector3(0, 0, 0);
|
|
||||||
floor.checkCollisions = true;
|
|
||||||
const fm = new BABYLON.StandardMaterial('floorMat', scene);
|
|
||||||
fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
|
||||||
fm.diffuseTexture = new BABYLON.Texture(groundTextureUrl, scene);
|
|
||||||
fm.diffuseTexture.uScale = Math.max(1, Math.floor(w / 2));
|
|
||||||
fm.diffuseTexture.vScale = Math.max(1, Math.floor(h / 2));
|
|
||||||
fm.diffuseColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
|
||||||
floor.material = fm;
|
|
||||||
levelMeshes.push(floor);
|
|
||||||
|
|
||||||
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
|
|
||||||
wallMat.diffuseTexture = new BABYLON.Texture(wallTextureUrl, scene);
|
|
||||||
wallMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
|
|
||||||
for (let y = 0; y < h; y++) {
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
if (grid[y][x] === 1) {
|
|
||||||
const box = BABYLON.MeshBuilder.CreateBox(`wall_${x}_${y}`, { size: cellSize }, scene);
|
|
||||||
box.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize/2, y*cellSize - halfH + cellSize/2);
|
|
||||||
box.material = wallMat;
|
|
||||||
box.checkCollisions = true;
|
|
||||||
levelMeshes.push(box);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeChestsOnDeadEnds(grid, deadEnds, minCount, seed) {
|
|
||||||
const rng = seededRng(seed);
|
|
||||||
for (let i = deadEnds.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(rng() * (i+1));
|
|
||||||
[deadEnds[i], deadEnds[j]] = [deadEnds[j], deadEnds[i]];
|
|
||||||
}
|
|
||||||
const chosen = deadEnds.slice(0, Math.min(minCount, deadEnds.length));
|
|
||||||
const halfW = (grid[0].length * cellSize) / 2;
|
|
||||||
const halfH = (grid.length * cellSize) / 2;
|
|
||||||
|
|
||||||
const chestMat = new BABYLON.StandardMaterial('chestMat', scene);
|
|
||||||
chestMat.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
|
||||||
chestMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
|
|
||||||
for (const [x,y] of chosen) {
|
|
||||||
const c = BABYLON.MeshBuilder.CreateBox(`chest_${x}_${y}`, { width: cellSize*0.8, height: cellSize*0.6, depth: cellSize*0.6 }, scene);
|
|
||||||
c.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize*0.3, y*cellSize - halfH + cellSize/2);
|
|
||||||
c.material = chestMat;
|
|
||||||
c.isPickable = true;
|
|
||||||
levelMeshes.push(c);
|
|
||||||
chestMap.set(`${x},${y}`, { mesh: c, opened: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosen.length > 0) {
|
|
||||||
const k = Math.floor(rng() * chosen.length);
|
|
||||||
const [kx, ky] = chosen[k];
|
|
||||||
keyChestKey = `${kx},${ky}`;
|
|
||||||
const entry = chestMap.get(keyChestKey);
|
|
||||||
if (entry) {
|
|
||||||
const km = new BABYLON.StandardMaterial('keyChestMat', scene);
|
|
||||||
km.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
|
||||||
km.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
km.emissiveColor = new BABYLON.Color3(0.3, 0.22, 0.02);
|
|
||||||
entry.mesh.material = km;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeExit(grid, seed) {
|
|
||||||
const dead = findDeadEnds(grid);
|
|
||||||
const rng = seededRng(seed+1);
|
|
||||||
if (dead.length === 0) return;
|
|
||||||
|
|
||||||
// Filter out dead ends that have chests
|
|
||||||
const availableDead = dead.filter(([x, y]) => !chestMap.has(`${x},${y}`));
|
|
||||||
if (availableDead.length === 0) {
|
|
||||||
// Fallback: use any dead end if no chest-free spot available
|
|
||||||
// This shouldn't happen in normal cases
|
|
||||||
const idx = Math.floor(rng() * dead.length);
|
|
||||||
const [x,y] = dead[idx];
|
|
||||||
exitGridPos = { x, y };
|
|
||||||
// Continue below
|
|
||||||
} else {
|
|
||||||
const idx = Math.floor(rng() * availableDead.length);
|
|
||||||
const [x,y] = availableDead[idx];
|
|
||||||
exitGridPos = { x, y };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x,y] = [exitGridPos.x, exitGridPos.y];
|
|
||||||
if (!isWalkableCell(grid, x, y)) {
|
|
||||||
console.warn("Exit selected on non-walkable cell", { x, y });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exitWorld = gridCellToWorld(grid, x, y, cellSize);
|
|
||||||
const ex = exitWorld.x;
|
|
||||||
const ez = exitWorld.z;
|
|
||||||
|
|
||||||
const plane = BABYLON.MeshBuilder.CreatePlane('exitDoor', {
|
|
||||||
width: cellSize * 1.35,
|
|
||||||
height: cellSize * 1.85,
|
|
||||||
sideOrientation: BABYLON.Mesh.DOUBLESIDE,
|
|
||||||
}, scene);
|
|
||||||
const exitMat = new BABYLON.StandardMaterial('exitMat', scene);
|
|
||||||
exitMat.diffuseTexture = new BABYLON.Texture(doorTextureUrl, scene);
|
|
||||||
exitMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
|
||||||
exitMat.emissiveColor = new BABYLON.Color3(0.07, 0.07, 0.07);
|
|
||||||
plane.material = exitMat;
|
|
||||||
plane.position = new BABYLON.Vector3(ex, cellSize * 0.92, ez);
|
|
||||||
plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y;
|
|
||||||
exitBox = plane;
|
|
||||||
levelMeshes.push(plane);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnCameraAt(grid) {
|
|
||||||
const h = grid.length;
|
|
||||||
const w = grid[0].length;
|
|
||||||
let bestCell = null;
|
|
||||||
let bestDist = -1;
|
|
||||||
|
|
||||||
// Choose a valid spawn that is far from exit when possible.
|
|
||||||
for (let y = 1; y < h - 1; y++) {
|
|
||||||
for (let x = 1; x < w - 1; x++) {
|
|
||||||
if (!isWalkableCell(grid, x, y)) continue;
|
|
||||||
if (isReservedCell(x, y)) continue;
|
|
||||||
|
|
||||||
const d = exitGridPos ? Math.hypot(x - exitGridPos.x, y - exitGridPos.y) : 0;
|
|
||||||
if (d > bestDist) {
|
|
||||||
bestDist = d;
|
|
||||||
bestCell = { x, y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bestCell) {
|
|
||||||
console.warn("No valid spawn cell found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnGridPos = bestCell;
|
|
||||||
const spawnWorld = gridCellToWorld(grid, bestCell.x, bestCell.y, cellSize);
|
|
||||||
const px = spawnWorld.x;
|
|
||||||
const pz = spawnWorld.z;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (camera && camera.position) {
|
|
||||||
camera.position = new BABYLON.Vector3(px, 1.6, pz);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : new BABYLON.Vector3(px, 1.6, pz);
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateLevel() {
|
|
||||||
hideGameOverScreen();
|
|
||||||
lowTimeAlertPlayed = false;
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.remove("low-time");
|
|
||||||
}
|
|
||||||
const cfg = sharedState.config;
|
|
||||||
const seed = cfg.seed;
|
|
||||||
const roundScale = Math.max(0, cfg.level - 1);
|
|
||||||
const w = Math.max(9, cfg.mazeWidth + roundScale * 2);
|
|
||||||
const h = Math.max(9, cfg.mazeHeight + roundScale * 2);
|
|
||||||
const chestCount = Math.max(1, cfg.minChestDeadEnds + roundScale);
|
|
||||||
const grid = generateMazeGrid(w, h, seed + cfg.level);
|
|
||||||
updateOverviewCameraForMaze(w, h);
|
|
||||||
const dead = findDeadEnds(grid);
|
|
||||||
buildLevelFromGrid(grid);
|
|
||||||
placeChestsOnDeadEnds(grid, dead, chestCount, seed + cfg.level);
|
|
||||||
placeExit(grid, seed + cfg.level);
|
|
||||||
spawnCameraAt(grid);
|
|
||||||
|
|
||||||
const placementValid =
|
|
||||||
!!exitGridPos &&
|
|
||||||
!!spawnGridPos &&
|
|
||||||
isWalkableCell(grid, exitGridPos.x, exitGridPos.y) &&
|
|
||||||
isWalkableCell(grid, spawnGridPos.x, spawnGridPos.y) &&
|
|
||||||
!(exitGridPos.x === spawnGridPos.x && exitGridPos.y === spawnGridPos.y);
|
|
||||||
|
|
||||||
if (!placementValid) {
|
|
||||||
sharedState.runtime.message = `Placement warning: spawn/exit invalid on level ${cfg.level}.`;
|
|
||||||
console.warn("Invalid spawn/exit placement", { exitGridPos, spawnGridPos });
|
|
||||||
} else {
|
|
||||||
sharedState.runtime.message = `Level ${cfg.level} generated (spawn ${spawnGridPos.x},${spawnGridPos.y} / exit ${exitGridPos.x},${exitGridPos.y}).`;
|
|
||||||
}
|
|
||||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : lastFootstepPosition;
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
window.requestAnimationFrame(()=>{ /* let scene update */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose API for p5 to call
|
|
||||||
window.mazeGameApi = { generateLevel };
|
|
||||||
|
|
||||||
// Pointer interaction for chests
|
|
||||||
scene.onPointerObservable.add((pi) => {
|
|
||||||
if (pi.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;
|
|
||||||
if (!sharedState.runtime.runActive || gameOverActive) return;
|
|
||||||
const pick = scene.pick(scene.pointerX, scene.pointerY);
|
|
||||||
if (!pick || !pick.hit || !pick.pickedMesh) return;
|
|
||||||
const m = pick.pickedMesh;
|
|
||||||
if (!m.name.startsWith('chest_')) return;
|
|
||||||
const coords = m.name.split('_').slice(1).join(',');
|
|
||||||
const entry = chestMap.get(coords);
|
|
||||||
if (!entry) return;
|
|
||||||
if (entry.opened) {
|
|
||||||
primeSfx();
|
|
||||||
playSfx("chestClose", 0.8);
|
|
||||||
playSfx("lose", 0.85);
|
|
||||||
sharedState.runtime.runActive = false;
|
|
||||||
sharedState.runtime.message = 'Opened chest again — game over.';
|
|
||||||
showGameOverScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
primeSfx();
|
|
||||||
playSfx("chestOpen", 0.8);
|
|
||||||
entry.opened = true;
|
|
||||||
if (coords === keyChestKey) {
|
|
||||||
sharedState.runtime.hasKey = true;
|
|
||||||
playSfx("key", 0.85);
|
|
||||||
sharedState.runtime.message = 'You found the key! Find the exit.';
|
|
||||||
} else {
|
|
||||||
sharedState.runtime.message = 'This chest was empty.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Level transition check
|
|
||||||
scene.registerBeforeRender(() => {
|
|
||||||
const targetRay = camera.getForwardRay(cellSize * 3.5);
|
|
||||||
const targetPick = scene.pickWithRay(targetRay, (mesh) => mesh.name.startsWith('chest_'));
|
|
||||||
if (targetPick && targetPick.hit && targetPick.pickedMesh) {
|
|
||||||
setChestHighlight(targetPick.pickedMesh);
|
|
||||||
} else {
|
|
||||||
setChestHighlight(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedState.runtime.runActive) {
|
|
||||||
const dt = engine.getDeltaTime() / 1000;
|
|
||||||
sharedState.runtime.elapsedSeconds = Math.max(0, sharedState.runtime.elapsedSeconds - dt);
|
|
||||||
|
|
||||||
const isLowTime = sharedState.runtime.elapsedSeconds < 10;
|
|
||||||
if (isLowTime && !lowTimeAlertPlayed) {
|
|
||||||
lowTimeAlertPlayed = true;
|
|
||||||
playSfx("clock", 0.75);
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.add("low-time");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isLowTime && lowTimeAlertPlayed) {
|
|
||||||
lowTimeAlertPlayed = false;
|
|
||||||
if (canvasTime) {
|
|
||||||
canvasTime.classList.remove("low-time");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedState.runtime.elapsedSeconds <= 0) {
|
|
||||||
sharedState.runtime.runActive = false;
|
|
||||||
sharedState.runtime.message = "Time up — game over.";
|
|
||||||
playSfx("lose", 0.85);
|
|
||||||
showGameOverScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sharedState.runtime.runActive && cameraMode === "fp" && camera && camera.position && document.pointerLockElement === canvas) {
|
|
||||||
const currentPosition = camera.position;
|
|
||||||
if (!lastFootstepPosition) {
|
|
||||||
lastFootstepPosition = currentPosition.clone();
|
|
||||||
}
|
|
||||||
const horizontalDistance = Math.hypot(
|
|
||||||
currentPosition.x - lastFootstepPosition.x,
|
|
||||||
currentPosition.z - lastFootstepPosition.z,
|
|
||||||
);
|
|
||||||
footstepAccumulator += horizontalDistance;
|
|
||||||
footstepElapsed += engine.getDeltaTime();
|
|
||||||
if (footstepAccumulator > 0.75 && footstepElapsed > 220) {
|
|
||||||
playSfx("step", 0.65);
|
|
||||||
footstepAccumulator = 0;
|
|
||||||
footstepElapsed = 0;
|
|
||||||
lastFootstepPosition = currentPosition.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sharedState.runtime.hasKey && exitBox && camera && camera.position) {
|
|
||||||
const pos = camera.position;
|
|
||||||
const ex = exitBox.position.x, ez = exitBox.position.z;
|
|
||||||
const dist = Math.hypot(pos.x - ex, pos.z - ez);
|
|
||||||
if (dist < cellSize * 0.9) {
|
|
||||||
playSfx("win", 0.85);
|
|
||||||
sharedState.config.level += 1;
|
|
||||||
sharedState.runtime.hasKey = false;
|
|
||||||
sharedState.runtime.roundsCompleted += 1;
|
|
||||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
|
||||||
sharedState.runtime.message = `Level ${sharedState.config.level} starting.`;
|
|
||||||
generateLevel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export shared state for p5 to use
|
|
||||||
export { sharedState };
|
|
||||||
79
src/controls/input-handler.js
Normal file
79
src/controls/input-handler.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { playSfx, primeSfx } from "../game/sfx.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup input handlers for keyboard and pointer events
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {Object} state - Shared game state
|
||||||
|
* @param {Object} callbacks - Event callbacks
|
||||||
|
*/
|
||||||
|
export function setupInputHandlers(canvas, state, callbacks) {
|
||||||
|
// Pointer lock helper
|
||||||
|
function requestPointerLockSafely() {
|
||||||
|
const cameraMode = callbacks.getCameraMode?.() || "fp";
|
||||||
|
if (cameraMode !== "fp" || document.pointerLockElement === canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lockResult = canvas.requestPointerLock();
|
||||||
|
if (lockResult && typeof lockResult.catch === "function") {
|
||||||
|
lockResult.catch((error) => {
|
||||||
|
if (error && error.name === "SecurityError") {
|
||||||
|
state.runtime.message = "Click once more to re-enable mouse look.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.name === "SecurityError") {
|
||||||
|
state.runtime.message = "Click once more to re-enable mouse look.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas click for pointer lock
|
||||||
|
canvas.addEventListener("click", () => {
|
||||||
|
primeSfx();
|
||||||
|
requestPointerLockSafely();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard input
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
// Prime audio context on common movement keys
|
||||||
|
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyV" || event.code === "KeyR") {
|
||||||
|
primeSfx();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug panel toggle
|
||||||
|
if (event.code === "KeyB") {
|
||||||
|
callbacks.onDebugToggle?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart or start game
|
||||||
|
if (event.code === "KeyR") {
|
||||||
|
const gameOverActive = callbacks.isGameOverActive?.() || false;
|
||||||
|
const startPanelVisible = callbacks.isStartPanelVisible?.() || false;
|
||||||
|
|
||||||
|
if (gameOverActive) {
|
||||||
|
callbacks.onRestart?.();
|
||||||
|
} else if (!state.runtime.runActive && startPanelVisible) {
|
||||||
|
callbacks.onStartGame?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera mode toggle
|
||||||
|
if (event.code === "KeyV") {
|
||||||
|
callbacks.onCameraToggle?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pointer interaction for chests
|
||||||
|
if (callbacks.setupScenePointerObserver) {
|
||||||
|
callbacks.setupScenePointerObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/game/camera-manager.js
Normal file
79
src/game/camera-manager.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create first-person and overview cameras
|
||||||
|
*/
|
||||||
|
export function createCameras(scene, canvas) {
|
||||||
|
// First-person camera
|
||||||
|
const fpCamera = new BABYLON.UniversalCamera("cam", new BABYLON.Vector3(0, 1.6, 0), scene);
|
||||||
|
fpCamera.minZ = 0.1;
|
||||||
|
fpCamera.speed = 1.12;
|
||||||
|
fpCamera.angularSensibility = 1000;
|
||||||
|
fpCamera.inertia = 0.6;
|
||||||
|
fpCamera.keysUp = [87]; // W
|
||||||
|
fpCamera.keysDown = [83]; // S
|
||||||
|
fpCamera.keysLeft = [65]; // A
|
||||||
|
fpCamera.keysRight = [68]; // D
|
||||||
|
fpCamera.checkCollisions = true;
|
||||||
|
fpCamera.applyGravity = true;
|
||||||
|
fpCamera.ellipsoid = new BABYLON.Vector3(0.35, 0.9, 0.35);
|
||||||
|
|
||||||
|
// Overview camera
|
||||||
|
const overviewCamera = new BABYLON.ArcRotateCamera(
|
||||||
|
"overviewCam",
|
||||||
|
-Math.PI / 2,
|
||||||
|
Math.PI / 3.2,
|
||||||
|
40,
|
||||||
|
new BABYLON.Vector3(0, 0, 0),
|
||||||
|
scene,
|
||||||
|
);
|
||||||
|
overviewCamera.lowerBetaLimit = 0.2;
|
||||||
|
overviewCamera.upperBetaLimit = Math.PI / 2.05;
|
||||||
|
overviewCamera.lowerRadiusLimit = 8;
|
||||||
|
overviewCamera.inertia = 0.7;
|
||||||
|
|
||||||
|
return { fpCamera, overviewCamera };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch between camera modes
|
||||||
|
*/
|
||||||
|
export function switchCameraMode(scene, canvas, fpCamera, overviewCamera, state) {
|
||||||
|
const currentMode = state.cameraMode || "fp";
|
||||||
|
|
||||||
|
if (currentMode === "fp") {
|
||||||
|
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
||||||
|
document.exitPointerLock();
|
||||||
|
}
|
||||||
|
fpCamera.detachControl(canvas);
|
||||||
|
overviewCamera.attachControl(canvas, true);
|
||||||
|
scene.activeCamera = overviewCamera;
|
||||||
|
state.cameraMode = "overview";
|
||||||
|
state.runtime.message = "Overview camera (press V to return to first-person).";
|
||||||
|
return "overview";
|
||||||
|
} else {
|
||||||
|
overviewCamera.detachControl(canvas);
|
||||||
|
fpCamera.attachControl(canvas, true);
|
||||||
|
scene.activeCamera = fpCamera;
|
||||||
|
state.cameraMode = "fp";
|
||||||
|
state.runtime.message = "First-person camera (W/A/S/D + mouse).";
|
||||||
|
return "fp";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update overview camera for maze size
|
||||||
|
*/
|
||||||
|
export function updateOverviewCameraForMaze(overviewCamera, w, h, cellSize = 2) {
|
||||||
|
const mazeSpan = Math.max(w, h) * cellSize;
|
||||||
|
overviewCamera.radius = Math.max(mazeSpan * 1.05, 16);
|
||||||
|
overviewCamera.target = new BABYLON.Vector3(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach camera to canvas and activate it
|
||||||
|
*/
|
||||||
|
export function attachCamera(scene, camera, canvas) {
|
||||||
|
camera.attachControl(canvas, true);
|
||||||
|
scene.activeCamera = camera;
|
||||||
|
}
|
||||||
54
src/game/collisions.js
Normal file
54
src/game/collisions.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which chest the player is looking at
|
||||||
|
* @param {BABYLON.Scene} scene
|
||||||
|
* @param {BABYLON.Camera} fpCamera
|
||||||
|
* @param {number} maxDistance
|
||||||
|
* @returns {{chest: BABYLON.Mesh, index: string} | null}
|
||||||
|
*/
|
||||||
|
export function checkChestRaycast(scene, fpCamera, maxDistance = 50) {
|
||||||
|
const targetRay = fpCamera.getForwardRay(maxDistance);
|
||||||
|
const targetPick = scene.pickWithRay(targetRay, (mesh) => mesh.name.startsWith('chest_'));
|
||||||
|
|
||||||
|
if (targetPick && targetPick.hit && targetPick.pickedMesh) {
|
||||||
|
const coords = targetPick.pickedMesh.name.split('_').slice(1).join(',');
|
||||||
|
return { mesh: targetPick.pickedMesh, index: coords };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if player is near exit door
|
||||||
|
* @param {{x: number, y: number, z: number}} playerPos
|
||||||
|
* @param {BABYLON.Vector3} exitPos
|
||||||
|
* @param {number} threshold
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function checkExitProximity(playerPos, exitPos, threshold = 2) {
|
||||||
|
const cellSize = 2; // Default cell size
|
||||||
|
const dist = Math.hypot(playerPos.x - exitPos.x, playerPos.z - exitPos.z);
|
||||||
|
return dist < cellSize * threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight or unhighlight chest
|
||||||
|
* @param {BABYLON.Mesh | null} mesh
|
||||||
|
*/
|
||||||
|
export function setChestHighlight(mesh) {
|
||||||
|
// Reset previous highlight
|
||||||
|
const allChests = document.querySelectorAll('[data-chest-highlighted="true"]');
|
||||||
|
allChests.forEach(el => {
|
||||||
|
const prev = window.mazeGameApi?.getMeshByName?.(el.getAttribute('data-mesh-name'));
|
||||||
|
if (prev) {
|
||||||
|
prev.renderOutline = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mesh) return;
|
||||||
|
|
||||||
|
// Apply new highlight
|
||||||
|
mesh.outlineColor = new BABYLON.Color3(0.85, 0.85, 0.85);
|
||||||
|
mesh.outlineWidth = 0.08;
|
||||||
|
mesh.renderOutline = true;
|
||||||
|
}
|
||||||
103
src/game/game-loop.js
Normal file
103
src/game/game-loop.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
import { playSfx } from "./sfx.js";
|
||||||
|
import { checkChestRaycast, checkExitProximity, setChestHighlight } from "../game/collisions.js";
|
||||||
|
import { updateHUD, setLowTimeWarning, updateSphereMesh } from "../ui/hud.js";
|
||||||
|
|
||||||
|
const cellSize = 2;
|
||||||
|
const ROUND_TIME_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the main game loop
|
||||||
|
*/
|
||||||
|
export function registerGameLoop(scene, engine, state, callbacks) {
|
||||||
|
let lowTimeAlertPlayed = false;
|
||||||
|
let lastFootstepPosition = null;
|
||||||
|
let footstepAccumulator = 0;
|
||||||
|
let footstepElapsed = 0;
|
||||||
|
|
||||||
|
scene.registerBeforeRender(() => {
|
||||||
|
// Update HUD
|
||||||
|
updateHUD(state);
|
||||||
|
updateSphereMesh(callbacks.sphere, state.config.level);
|
||||||
|
|
||||||
|
// Chest highlight raycasting
|
||||||
|
if (state.runtime.runActive && callbacks.fpCamera && state.cameraMode === "fp") {
|
||||||
|
const highlighted = checkChestRaycast(scene, callbacks.fpCamera, cellSize * 3.5);
|
||||||
|
if (highlighted) {
|
||||||
|
setChestHighlight(highlighted.mesh);
|
||||||
|
} else {
|
||||||
|
setChestHighlight(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.runtime.runActive) {
|
||||||
|
const dt = engine.getDeltaTime() / 1000;
|
||||||
|
state.runtime.elapsedSeconds = Math.max(0, state.runtime.elapsedSeconds - dt);
|
||||||
|
|
||||||
|
// Low-time alert
|
||||||
|
const isLowTime = state.runtime.elapsedSeconds < 10;
|
||||||
|
if (isLowTime && !lowTimeAlertPlayed) {
|
||||||
|
lowTimeAlertPlayed = true;
|
||||||
|
playSfx("clock", 0.75);
|
||||||
|
setLowTimeWarning(true);
|
||||||
|
}
|
||||||
|
if (!isLowTime && lowTimeAlertPlayed) {
|
||||||
|
lowTimeAlertPlayed = false;
|
||||||
|
setLowTimeWarning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-up check
|
||||||
|
if (state.runtime.elapsedSeconds <= 0) {
|
||||||
|
state.runtime.runActive = false;
|
||||||
|
state.runtime.message = "Time up — game over.";
|
||||||
|
playSfx("lose", 0.85);
|
||||||
|
callbacks.onGameOver?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footstep sounds
|
||||||
|
if (callbacks.fpCamera && callbacks.fpCamera.position && document.pointerLockElement === document.getElementById("renderCanvas")) {
|
||||||
|
const currentPosition = callbacks.fpCamera.position;
|
||||||
|
if (!lastFootstepPosition) {
|
||||||
|
lastFootstepPosition = currentPosition.clone();
|
||||||
|
}
|
||||||
|
const horizontalDistance = Math.hypot(
|
||||||
|
currentPosition.x - lastFootstepPosition.x,
|
||||||
|
currentPosition.z - lastFootstepPosition.z,
|
||||||
|
);
|
||||||
|
footstepAccumulator += horizontalDistance;
|
||||||
|
footstepElapsed += engine.getDeltaTime();
|
||||||
|
if (footstepAccumulator > 0.75 && footstepElapsed > 220) {
|
||||||
|
playSfx("step", 0.65);
|
||||||
|
footstepAccumulator = 0;
|
||||||
|
footstepElapsed = 0;
|
||||||
|
lastFootstepPosition = currentPosition.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit proximity check
|
||||||
|
if (state.runtime.hasKey && state.exitBox && callbacks.fpCamera && callbacks.fpCamera.position) {
|
||||||
|
const pos = callbacks.fpCamera.position;
|
||||||
|
const ex = state.exitBox.position.x;
|
||||||
|
const ez = state.exitBox.position.z;
|
||||||
|
const dist = Math.hypot(pos.x - ex, pos.z - ez);
|
||||||
|
if (dist < cellSize * 0.9) {
|
||||||
|
playSfx("win", 0.85);
|
||||||
|
state.config.level += 1;
|
||||||
|
state.runtime.hasKey = false;
|
||||||
|
state.runtime.roundsCompleted += 1;
|
||||||
|
state.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||||
|
state.runtime.message = `Level ${state.config.level} starting.`;
|
||||||
|
callbacks.onLevelComplete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stop: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export constants
|
||||||
|
*/
|
||||||
|
export { ROUND_TIME_SECONDS };
|
||||||
169
src/game/level-generator.js
Normal file
169
src/game/level-generator.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
import { findDeadEnds } from "./maze.js";
|
||||||
|
import { gridCellToWorld, isWalkableCell } from "./grid.js";
|
||||||
|
import { seededRng } from "./maze.js";
|
||||||
|
import { createFloorMaterial, createWallMaterial, createChestMaterial, createExitMaterial } from "../assets/materials.js";
|
||||||
|
|
||||||
|
const cellSize = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all level meshes and associated data
|
||||||
|
*/
|
||||||
|
export function clearLevelMeshes(levelMeshes, state) {
|
||||||
|
for (const m of levelMeshes) {
|
||||||
|
try { m.dispose(); } catch(e) {}
|
||||||
|
}
|
||||||
|
levelMeshes.length = 0;
|
||||||
|
state.chestMap = new Map();
|
||||||
|
state.keyChestKey = null;
|
||||||
|
state.exitBox = null;
|
||||||
|
state.exitGridPos = null;
|
||||||
|
state.spawnGridPos = null;
|
||||||
|
state.highlightedChest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cell is reserved (has chest or exit)
|
||||||
|
*/
|
||||||
|
function isReservedCell(x, y, state) {
|
||||||
|
if (state.chestMap.has(`${x},${y}`)) return true;
|
||||||
|
if (state.exitGridPos && state.exitGridPos.x === x && state.exitGridPos.y === y) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build maze level from grid
|
||||||
|
*/
|
||||||
|
export function buildLevelFromGrid(scene, grid, state, levelMeshes) {
|
||||||
|
const h = grid.length;
|
||||||
|
const w = grid[0].length;
|
||||||
|
const halfW = (w * cellSize) / 2;
|
||||||
|
const halfH = (h * cellSize) / 2;
|
||||||
|
|
||||||
|
// Floor
|
||||||
|
const floor = BABYLON.MeshBuilder.CreateGround('levelGround', { width: w*cellSize, height: h*cellSize }, scene);
|
||||||
|
floor.position = new BABYLON.Vector3(0, 0, 0);
|
||||||
|
floor.checkCollisions = true;
|
||||||
|
floor.material = createFloorMaterial(scene, w, h);
|
||||||
|
levelMeshes.push(floor);
|
||||||
|
|
||||||
|
// Walls
|
||||||
|
const wallMat = createWallMaterial(scene);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
if (grid[y][x] === 1) {
|
||||||
|
const box = BABYLON.MeshBuilder.CreateBox(`wall_${x}_${y}`, { size: cellSize }, scene);
|
||||||
|
box.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize/2, y*cellSize - halfH + cellSize/2);
|
||||||
|
box.material = wallMat;
|
||||||
|
box.checkCollisions = true;
|
||||||
|
levelMeshes.push(box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place chests on dead ends
|
||||||
|
*/
|
||||||
|
export function placeChestsOnDeadEnds(scene, grid, deadEnds, minCount, seed, state, levelMeshes) {
|
||||||
|
const rng = seededRng(seed);
|
||||||
|
for (let i = deadEnds.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i+1));
|
||||||
|
[deadEnds[i], deadEnds[j]] = [deadEnds[j], deadEnds[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = deadEnds.slice(0, Math.min(minCount, deadEnds.length));
|
||||||
|
const halfW = (grid[0].length * cellSize) / 2;
|
||||||
|
const halfH = (grid.length * cellSize) / 2;
|
||||||
|
|
||||||
|
for (const [x, y] of chosen) {
|
||||||
|
const c = BABYLON.MeshBuilder.CreateBox(`chest_${x}_${y}`, { width: cellSize*0.8, height: cellSize*0.6, depth: cellSize*0.6 }, scene);
|
||||||
|
c.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize*0.3, y*cellSize - halfH + cellSize/2);
|
||||||
|
c.material = createChestMaterial(scene, false);
|
||||||
|
c.isPickable = true;
|
||||||
|
levelMeshes.push(c);
|
||||||
|
state.chestMap.set(`${x},${y}`, { mesh: c, opened: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark one chest as containing the key
|
||||||
|
if (chosen.length > 0) {
|
||||||
|
const k = Math.floor(rng() * chosen.length);
|
||||||
|
const [kx, ky] = chosen[k];
|
||||||
|
state.keyChestKey = `${kx},${ky}`;
|
||||||
|
const entry = state.chestMap.get(state.keyChestKey);
|
||||||
|
if (entry) {
|
||||||
|
entry.mesh.material = createChestMaterial(scene, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place exit door
|
||||||
|
*/
|
||||||
|
export function placeExit(scene, grid, seed, state, levelMeshes) {
|
||||||
|
const dead = findDeadEnds(grid);
|
||||||
|
const rng = seededRng(seed + 1);
|
||||||
|
if (dead.length === 0) return;
|
||||||
|
|
||||||
|
const availableDead = dead.filter(([x, y]) => !state.chestMap.has(`${x},${y}`));
|
||||||
|
if (availableDead.length === 0) {
|
||||||
|
const idx = Math.floor(rng() * dead.length);
|
||||||
|
state.exitGridPos = { x: dead[idx][0], y: dead[idx][1] };
|
||||||
|
} else {
|
||||||
|
const idx = Math.floor(rng() * availableDead.length);
|
||||||
|
state.exitGridPos = { x: availableDead[idx][0], y: availableDead[idx][1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x, y] = [state.exitGridPos.x, state.exitGridPos.y];
|
||||||
|
if (!isWalkableCell(grid, x, y)) {
|
||||||
|
console.warn("Exit selected on non-walkable cell", { x, y });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitWorld = gridCellToWorld(grid, x, y, cellSize);
|
||||||
|
const plane = BABYLON.MeshBuilder.CreatePlane('exitDoor', {
|
||||||
|
width: cellSize * 1.35,
|
||||||
|
height: cellSize * 1.85,
|
||||||
|
sideOrientation: BABYLON.Mesh.DOUBLESIDE,
|
||||||
|
}, scene);
|
||||||
|
plane.material = createExitMaterial(scene);
|
||||||
|
plane.position = new BABYLON.Vector3(exitWorld.x, cellSize * 0.92, exitWorld.z);
|
||||||
|
plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y;
|
||||||
|
state.exitBox = plane;
|
||||||
|
levelMeshes.push(plane);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn camera at optimal location
|
||||||
|
*/
|
||||||
|
export function spawnCameraAt(scene, grid, camera, state) {
|
||||||
|
const h = grid.length;
|
||||||
|
const w = grid[0].length;
|
||||||
|
let bestCell = null;
|
||||||
|
let bestDist = -1;
|
||||||
|
|
||||||
|
for (let y = 1; y < h - 1; y++) {
|
||||||
|
for (let x = 1; x < w - 1; x++) {
|
||||||
|
if (!isWalkableCell(grid, x, y)) continue;
|
||||||
|
if (isReservedCell(x, y, state)) continue;
|
||||||
|
|
||||||
|
const d = state.exitGridPos ? Math.hypot(x - state.exitGridPos.x, y - state.exitGridPos.y) : 0;
|
||||||
|
if (d > bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
bestCell = { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestCell) {
|
||||||
|
console.warn("No valid spawn cell found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.spawnGridPos = bestCell;
|
||||||
|
const spawnWorld = gridCellToWorld(grid, bestCell.x, bestCell.y, cellSize);
|
||||||
|
|
||||||
|
if (camera && camera.position) {
|
||||||
|
camera.position = new BABYLON.Vector3(spawnWorld.x, 1.6, spawnWorld.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/game/scene-init.js
Normal file
34
src/game/scene-init.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import * as BABYLON from "babylonjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Babylon.js engine and scene
|
||||||
|
* @returns {{engine: BABYLON.Engine, scene: BABYLON.Scene}}
|
||||||
|
*/
|
||||||
|
export function initializeScene(canvas) {
|
||||||
|
const engine = new BABYLON.Engine(canvas, true);
|
||||||
|
const scene = new BABYLON.Scene(engine);
|
||||||
|
|
||||||
|
scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1);
|
||||||
|
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
||||||
|
scene.collisionsEnabled = true;
|
||||||
|
|
||||||
|
// Add hemispheric lighting
|
||||||
|
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
||||||
|
|
||||||
|
return { engine, scene };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the main render loop
|
||||||
|
* @param {BABYLON.Engine} engine
|
||||||
|
* @param {BABYLON.Scene} scene
|
||||||
|
*/
|
||||||
|
export function startRenderLoop(engine, scene) {
|
||||||
|
engine.runRenderLoop(() => {
|
||||||
|
scene.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
engine.resize();
|
||||||
|
});
|
||||||
|
}
|
||||||
72
src/game/screen-manager.js
Normal file
72
src/game/screen-manager.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { startParticleSketch, stopParticleSketch, startStartScreenSketch, stopStartScreenSketch } from "../p5_particles.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show game-over screen
|
||||||
|
*/
|
||||||
|
export function showGameOverScreen(state) {
|
||||||
|
const canvasStage = document.querySelector(".canvas-stage");
|
||||||
|
const p5GameOverPanel = document.getElementById("p5-game-over-panel");
|
||||||
|
const renderCanvas = document.getElementById("renderCanvas");
|
||||||
|
|
||||||
|
if (canvasStage) {
|
||||||
|
canvasStage.hidden = true;
|
||||||
|
}
|
||||||
|
if (p5GameOverPanel) {
|
||||||
|
p5GameOverPanel.hidden = false;
|
||||||
|
const sketchContainer = document.getElementById("p5-sketch-container");
|
||||||
|
if (sketchContainer) {
|
||||||
|
startParticleSketch(sketchContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit pointer lock
|
||||||
|
if (document.pointerLockElement === renderCanvas && document.exitPointerLock) {
|
||||||
|
document.exitPointerLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.gameOverActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide game-over screen
|
||||||
|
*/
|
||||||
|
export function hideGameOverScreen(state) {
|
||||||
|
const canvasStage = document.querySelector(".canvas-stage");
|
||||||
|
const p5GameOverPanel = document.getElementById("p5-game-over-panel");
|
||||||
|
|
||||||
|
if (canvasStage) {
|
||||||
|
canvasStage.hidden = false;
|
||||||
|
}
|
||||||
|
if (p5GameOverPanel) {
|
||||||
|
p5GameOverPanel.hidden = true;
|
||||||
|
stopParticleSketch();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.gameOverActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show start screen
|
||||||
|
*/
|
||||||
|
export function showStartScreen(state) {
|
||||||
|
const p5StartPanel = document.getElementById("p5-start-panel");
|
||||||
|
|
||||||
|
if (p5StartPanel && !p5StartPanel.hidden) {
|
||||||
|
const startContainer = document.getElementById("p5-start-container");
|
||||||
|
if (startContainer) {
|
||||||
|
startStartScreenSketch(startContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide start screen
|
||||||
|
*/
|
||||||
|
export function hideStartScreen(state) {
|
||||||
|
const p5StartPanel = document.getElementById("p5-start-panel");
|
||||||
|
|
||||||
|
if (p5StartPanel && !p5StartPanel.hidden) {
|
||||||
|
p5StartPanel.hidden = true;
|
||||||
|
stopStartScreenSketch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
|
// Import audio files as modules so Vite bundles them
|
||||||
|
import chestCloseUrl from "../../sfx/sfx_chest_close.wav";
|
||||||
|
import chestOpenUrl from "../../sfx/sfx_chest_open.wav";
|
||||||
|
import clickUrl from "../../sfx/sfx_click.wav";
|
||||||
|
import clockUrl from "../../sfx/sfx_clock.wav";
|
||||||
|
import keyUrl from "../../sfx/sfx_key.wav";
|
||||||
|
import loseUrl from "../../sfx/sfx_lose.wav";
|
||||||
|
import stepUrl from "../../sfx/sfx_step.wav";
|
||||||
|
import winUrl from "../../sfx/sfx_win.wav";
|
||||||
|
|
||||||
const soundFiles = {
|
const soundFiles = {
|
||||||
chestClose: "/sfx/sfx_chest_close.wav",
|
chestClose: chestCloseUrl,
|
||||||
chestOpen: "/sfx/sfx_chest_open.wav",
|
chestOpen: chestOpenUrl,
|
||||||
click: "/sfx/sfx_click.wav",
|
click: clickUrl,
|
||||||
clock: "/sfx/sfx_clock.wav",
|
clock: clockUrl,
|
||||||
key: "/sfx/sfx_key.wav",
|
key: keyUrl,
|
||||||
lose: "/sfx/sfx_lose.wav",
|
lose: loseUrl,
|
||||||
step: "/sfx/sfx_step.wav",
|
step: stepUrl,
|
||||||
win: "/sfx/sfx_win.wav",
|
win: winUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sounds = {};
|
const sounds = {};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
const initialSeed = Math.floor(Math.random() * 100000);
|
||||||
|
|
||||||
export const sharedState = (window.mazeGameState ??= {
|
export const sharedState = (window.mazeGameState ??= {
|
||||||
config: {
|
config: {
|
||||||
seed: Math.floor(Math.random() * 100000),
|
seed: initialSeed,
|
||||||
level: 1,
|
level: 1,
|
||||||
mazeWidth: 11,
|
mazeWidth: 11,
|
||||||
mazeHeight: 11,
|
mazeHeight: 11,
|
||||||
@@ -14,3 +16,6 @@ export const sharedState = (window.mazeGameState ??= {
|
|||||||
message: "Press Start to play.",
|
message: "Press Start to play.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log("State initialized with seed:", sharedState.config.seed, "Initial seed value:", initialSeed);
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function updateDisplay() {
|
|||||||
const statusMessage = document.getElementById("status-message");
|
const statusMessage = document.getElementById("status-message");
|
||||||
|
|
||||||
if (!statusSeed || !statusLevel || !statusMazeSize || !statusChests || !statusTime || !statusKey || !statusRounds || !statusMessage) {
|
if (!statusSeed || !statusLevel || !statusMazeSize || !statusChests || !statusTime || !statusKey || !statusRounds || !statusMessage) {
|
||||||
console.warn("Some status display elements are missing from DOM");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
src/ui/hud.js
Normal file
43
src/ui/hud.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Update HUD display with current game state
|
||||||
|
*/
|
||||||
|
export function updateHUD(state) {
|
||||||
|
const canvasTime = document.getElementById("canvas-time");
|
||||||
|
const canvasKey = document.getElementById("canvas-key");
|
||||||
|
const canvasRounds = document.getElementById("canvas-rounds");
|
||||||
|
|
||||||
|
if (canvasTime) {
|
||||||
|
canvasTime.textContent = `${state.runtime.elapsedSeconds.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
if (canvasKey) {
|
||||||
|
canvasKey.textContent = state.runtime.hasKey ? "yes" : "no";
|
||||||
|
}
|
||||||
|
if (canvasRounds) {
|
||||||
|
canvasRounds.textContent = String(state.runtime.roundsCompleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply low-time warning styling
|
||||||
|
*/
|
||||||
|
export function setLowTimeWarning(isLowTime) {
|
||||||
|
const canvasTime = document.getElementById("canvas-time");
|
||||||
|
if (!canvasTime) return;
|
||||||
|
|
||||||
|
if (isLowTime) {
|
||||||
|
canvasTime.classList.add("low-time");
|
||||||
|
} else {
|
||||||
|
canvasTime.classList.remove("low-time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update animated sphere (visual indicator)
|
||||||
|
*/
|
||||||
|
export function updateSphereMesh(sphere, level) {
|
||||||
|
if (!sphere) return;
|
||||||
|
|
||||||
|
sphere.rotation.y += 0.01;
|
||||||
|
sphere.scaling.x = 1 + (level - 1) * 0.05;
|
||||||
|
sphere.scaling.z = 1 + (level - 1) * 0.05;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user