Compare commits

..

16 Commits

Author SHA1 Message Date
pobadoba
151d2ed596 final fixes and readme 2026-05-10 23:33:56 +09:00
pobadoba
7f6548d9fb update readme 2026-05-10 23:04:01 +09:00
pobadoba
6532387f59 docs: Standardize module documentation and focus on final product
- Added complete documentation for state.js, maze.js, grid.js, sfx.js
  (was: 'Already Well-Structured — No Changes')
- Removed 'Refactoring Summary' section (process-focused content)
- Restructured 'Design Decisions & Rationale' to document current architecture
  rather than refactoring journey
- Updated 'AI Assistance' section to mention refactoring work in context
  of overall development
- Simplified 'Files Structure' legend to remove distinction between
  refactored and original modules
- All 14 modules now documented consistently with Purpose/Functions/Benefits

Result: Documentation now explains the final product cleanly without the
'something changed' narrative, while preserving context of development work.
2026-05-10 22:33:56 +09:00
pobadoba
74dc932766 refactor(all): refactor code structure into separate js files 2026-05-10 22:25:43 +09:00
pobadoba
9748c2e2f5 refactor(sfx.js): use vite imports for npm run build to work properly 2026-05-10 22:03:24 +09:00
pobadoba
e4d3d32702 fix(all): game window size and unused files 2026-05-10 21:53:31 +09:00
pobadoba
b95438dfd0 screens cleanup 2026-05-10 18:05:17 +09:00
pobadoba
b88f47c70d feat: game over screen 2026-05-10 17:43:03 +09:00
pobadoba
808b2545e2 update GUI 2026-05-10 17:26:54 +09:00
pobadoba
c28a1d1f6a use timer based mechanic instead 2026-05-10 17:13:54 +09:00
pobadoba
9b68630764 add back game over from chest 2026-05-10 17:01:02 +09:00
pobadoba
7e2d6243b2 add sounds and textures 2026-05-10 16:49:59 +09:00
pobadoba
021877902a feat(gui): add time GUI
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 11:16:54 +09:00
pobadoba
47d4ba8bfe refactor(javascript): split babylon_setup into multiple files
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 01:58:12 +09:00
pobadoba
176ad34214 fix(babylon_setup.js): fix exit not generating properly
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 01:47:27 +09:00
pobadoba
4bb46115ca feat(babylon_setup.js): v to change view
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 01:32:35 +09:00
39 changed files with 2364 additions and 570 deletions

564
README.md
View File

@@ -1,141 +1,517 @@
# P5.js-vite Starter Template 🚀
# Untitled Maze Game - ID30011 Midterm Project
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
**A 3D First-Person Time-Attack Maze Game with Progressive Difficulty**
[Vite](https://vitejs.dev/) starter template to scaffold a new [p5.js](https://p5js.org) project.
---
This is an unopinionated template; aside from P5.js and Vite, the rest of your project's tools are entirely up to you.
## Project Information
## Live demo
- **Name:** Bumgyu Suh
- **Student ID:** 20240905
- **Student Email:** bumgyu@kaist.ac.kr
- **Repository URL:** https://git.prototyping.id/20240905/Untitled-Maze-Game
- **Video Demonstration:** https://youtu.be/8LDcLpsNJck (turn on youtube subtitles)
- **Play Link:** https://pobadoba.com/games/maze
For a live demo please [visit this page](https://p5js-vite-demo.surge.sh).
---
## Installation
## Game Overview
Pull the template files with [degit](https://github.com/Rich-Harris/degit) and install dependencies.
### 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
```
npx degit makinteract/p5js-vite my-project
cd my-project
npm install
npm run dev
START SCREEN
↓ Press R
GAMEPLAY (60 seconds)
↙ Time Up / Found Exit ↖
GAME OVER NEXT LEVEL
(Job Application
Jumpscare)
```
## npm scripts
### How to Play
- `npm run dev` - Starts the development server at port [3000](http://localhost:3000/)
- `npm run build` - Builds the application in a `dist` folder
- `npm run preview` - Serves the build files (`dist` folder) locally at port [5000](http://localhost:3000/)
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
Note that if after this last command you do not see anything, you can use instead this other command:
### Controls
- `npm run preview --host` - You should then be able to see your files locally at port [5000](http://localhost:3000/)
| 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 |
## A single p5.js sketch
---
```js
import '../css/style.css';
import { sketch } from 'p5js-wrapper';
## Code Documentation
sketch.setup = function () {
createCanvas(800, 600);
};
### Packages Used
- **3D Graphics:** Babylon.js (3D scene, camera, meshes, rendering)
- **2D Graphics:** p5.js (particle effects for game-over screen)
- **Audio:** Web Audio API
sketch.draw = function () {
background(127); // grey
fill(255, 0, 0); // red
noStroke();
rectMode(CENTER);
rect(width / 2, height / 2, 50, 50);
};
### Files Structure
sketch.mousePressed = function () {
console.log(`I am here at ${mouseX}:${mouseY}`);
};
```
src/
├── 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
css/
└── style.css # Responsive layout, HUD styling, animations
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
```
And here the body of the html file:
**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
```html
<body>
<script type="module" src="/src/single_sketch.js"></script>
</body>
### 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
```
## Multiple p5.js sketches
**Exports:**
- `window.mazeGameApi.generateLevel()` — Called by html_panel.js (debug controls)
If you want to use multiple sketches, you need to use a different syntax.
#### 2. **game/scene-init.js** (Scene Initialization)
**Purpose:** Babylon.js engine and scene setup
```js
import '../css/style.css';
import { p5 } from 'p5js-wrapper';
**Functions:**
- `initializeScene(canvas)` — Creates engine, scene, lighting, gravity, collision setup
- `startRenderLoop(engine, scene)` — Starts the main render loop and resize handler
let sketch1 = new p5((p) => {
p.setup = () => {
const one = document.getElementById('one');
p.createCanvas(one.clientWidth, one.clientHeight);
};
**Benefits:**
- Encapsulates Babylon.js boilerplate
- Easier to swap or test rendering configuration
- Decouples main scene runner from engine details
p.draw = () => {
p.background(100);
};
}, 'one');
#### 3. **game/camera-manager.js** (Camera Management)
**Purpose:** First-person and overview camera creation and switching
// Sketch2
let sketch2 = new p5((p) => {
p.setup = () => {
const two = document.getElementById('two');
p.createCanvas(two.clientWidth, two.clientHeight);
};
**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
p.draw = () => {
p.background(170);
};
}, 'two');
**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
}
}
```
This file is expecting two divs in the html file:
**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
```html
<body>
<script type="module" src="/src/multi_sketch.js"></script>
<div id="one"></div>
<div id="two"></div>
</body>
#### 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"
```
## Adding sound
**Polyphony via Cloning:**
Each call to `playSfx()` clones the audio node, allowing multiple sounds to play simultaneously (e.g., footsteps + clock alarm).
Sound is an [experimental feature](https://github.com/makinteract/p5js-wrapper/blob/main/README_SOUND.md).
**Benefits:**
- Polyphonic sound effects (no cutting each other off)
- Browser-compatible audio context priming
- Centralized audio management (easy to add volume controls, reverb, etc.)
Examples usage:
---
```js
import { sketch } from 'p5js-wrapper';
import 'p5js-wrapper/sound';
## Key Features & Implementation Details
import mysound from './mysound.mp3';
| 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 |
let soundEffect;
**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
sketch.setup = function () {
createCanvas(100, 100);
soundEffect = loadSound(mysound);
};
**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
sketch.draw = function () {
background('#eeeeee');
};
---
// Play sound on click
sketch.mousePressed = function () {
soundEffect.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)
```
This example assumes you have a file _mysound.mp3_ in the _src_ folder.
### 2. Shared State Pattern via window.mazeGameState
**Decision:** Centralize all game state in single `window.mazeGameState` object
## License
**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
This project is open source and available under the [MIT License](LICENSE).
### 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.

View File

@@ -1,7 +1,14 @@
/* Use border-box globally to avoid unexpected overflow from padding/borders */
*, *::before, *::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
height: 100%;
/* Prevent global scrollbars — inner panels manage their own scrolling */
overflow: hidden;
}
body.maze-page {
@@ -15,11 +22,26 @@ canvas {
display: block;
}
.canvas-stage {
position: relative;
height: 100%;
}
.maze-layout {
width: min(1100px, calc(100vw - 24px));
margin: 12px auto 20px;
width: 100vw;
padding: 12px; /* use padding instead of margin to avoid adding to viewport height */
display: grid;
gap: 12px;
grid-template-columns: 1fr;
gap: 0;
align-items: start;
min-height: 100vh;
box-sizing: border-box;
}
.maze-layout > .panel:first-child {
margin: 0;
border-radius: 16px;
height: calc(100vh - 24px); /* accounts for container padding */
}
.panel {
@@ -41,7 +63,195 @@ canvas {
#renderCanvas {
width: 100%;
height: min(62vh, 680px);
height: 100%;
display: block;
}
.canvas-hud {
position: absolute;
top: 14px;
left: 14px;
z-index: 2;
padding: 10px 12px;
border-radius: 12px;
background: rgba(6, 10, 15, 0.72);
border: 1px solid rgba(121, 174, 242, 0.3);
backdrop-filter: blur(8px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
pointer-events: none;
}
.canvas-hud-label {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #93a4b8;
margin-bottom: 4px;
}
.canvas-hud-value {
font-size: 22px;
font-weight: 700;
line-height: 1;
color: #eef5ff;
text-shadow: 0 0 12px rgba(121, 174, 242, 0.35);
transition: all 0.2s ease;
}
.canvas-hud-value.low-time {
color: #ff4444;
text-shadow: 0 0 16px rgba(255, 68, 68, 0.6);
animation: pulse-warning 0.6s infinite;
}
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.canvas-hud-row {
display: flex;
justify-content: space-between;
gap: 14px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 11px;
color: #dbe6f2;
}
.canvas-hud-row span {
color: #93a4b8;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.canvas-hud-row strong {
color: #79aef2;
font-weight: 600;
}
.game-over-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(0, 0, 0, 0.78);
text-align: center;
padding: 20px;
}
.game-over-overlay[hidden] {
display: none;
}
.p5-particles-container {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.p5-game-over-panel {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: rgba(0, 0, 0, 0.95);
z-index: 9999;
}
.p5-game-over-panel[hidden] {
display: none;
pointer-events: none;
}
.p5-start-panel {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: rgba(0, 0, 0, 0.95);
z-index: 9999;
}
.p5-start-panel[hidden] {
display: none;
pointer-events: none;
}
.p5-sketch-container {
position: relative;
width: 100%;
height: 100%;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.game-over-info {
position: relative;
z-index: 10;
text-align: center;
padding: 20px;
}
.game-over-info .game-over-text {
margin-top: 0;
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
color: #f3f7ff;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.game-over-info .game-over-subtext {
font-size: 14px;
color: #c9d8ea;
letter-spacing: 0.05em;
}
.game-over-image {
width: min(70%, 460px);
max-height: 52vh;
object-fit: contain;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.45);
}
.game-over-text {
margin-top: 4px;
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
color: #f3f7ff;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.game-over-subtext {
font-size: 14px;
color: #c9d8ea;
letter-spacing: 0.05em;
}
.control-panel {
@@ -50,6 +260,19 @@ canvas {
max-height: 400px;
}
#control-panel-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 40vh;
overflow-y: auto;
z-index: 1000;
}
.control-group {
margin-bottom: 20px;
}
@@ -189,6 +412,7 @@ canvas {
.maze-layout {
width: calc(100vw - 16px);
margin: 8px auto 16px;
grid-template-columns: 1fr;
}
#renderCanvas {

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 28 28" enable-background="new 0 0 28 28" xml:space="preserve">
<path fill="#ED225D" stroke="#ED225D" stroke-miterlimit="10" d="M16.909,10.259l8.533-2.576l1.676,5.156l-8.498,2.899l5.275,7.48
l-4.447,3.225l-5.553-7.348L8.487,26.25l-4.318-3.289l5.275-7.223L0.88,12.647l1.678-5.16l8.598,2.771V1.364h5.754V10.259z"/>
</svg>

Before

Width:  |  Height:  |  Size: 692 B

BIN
img/img_chest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
img/img_door.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
img/img_ground.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
img/img_jobapplication.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
img/img_start.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
img/img_wall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -3,19 +3,37 @@
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" type="text/css" href="/css/style.css" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="icon" type="image/png" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Untitled Maze Game</title>
</head>
<body class="maze-page">
<main class="maze-layout">
<section class="panel">
<div class="panel-label">Babylon Scene</div>
<div class="canvas-stage">
<canvas id="renderCanvas"></canvas>
<div class="canvas-hud">
<div class="canvas-hud-label">Time left</div>
<div id="canvas-time" class="canvas-hud-value">0.0s</div>
<div class="canvas-hud-row"><span>Has key</span><strong id="canvas-key">no</strong></div>
<div class="canvas-hud-row"><span>Rounds</span><strong id="canvas-rounds">0</strong></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-label">Game Controls</div>
<section id="p5-start-panel" class="p5-start-panel">
<div id="p5-start-container" class="p5-sketch-container"></div>
</section>
<section id="p5-game-over-panel" class="p5-game-over-panel" hidden>
<div id="p5-sketch-container" class="p5-sketch-container"></div>
<div class="game-over-info">
<div class="game-over-subtext">Press R to play again</div>
</div>
</section>
<section class="panel" id="control-panel-section" hidden>
<div class="panel-label">Debug Controls</div>
<div id="control-panel" class="control-panel">
<div class="control-group">
<h3>Run Controls</h3>
@@ -26,42 +44,26 @@
</div>
</div>
<div class="control-group">
<h3>Settings</h3>
<div class="slider-group">
<label>Maze width:
<input id="slider-width" type="range" min="9" max="31" step="2" value="11" />
<span id="value-width">11</span> cells
</label>
</div>
<div class="slider-group">
<label>Maze height:
<input id="slider-height" type="range" min="9" max="31" step="2" value="11" />
<span id="value-height">11</span> cells
</label>
</div>
<div class="slider-group">
<label>Min chest dead-ends:
<input id="slider-deadends" type="range" min="1" max="10" step="1" value="2" />
<span id="value-deadends">2</span>
</label>
</div>
</div>
<div class="control-group">
<h3>Status</h3>
<div class="status-display">
<div class="status-line"><strong>Seed:</strong> <span id="status-seed">0</span></div>
<div class="status-line"><strong>Level:</strong> <span id="status-level">1</span></div>
<div class="status-line"><strong>Time:</strong> <span id="status-time">0.0</span>s</div>
<div class="status-line"><strong>Maze:</strong> <span id="status-maze-size">11x11</span></div>
<div class="status-line"><strong>Chests:</strong> <span id="status-chests">2</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-message" id="status-message">Adjust settings, then start a run.</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>
</section>
</main>
<script type="module" src="/src/multi_sketch.js"></script>
<script type="module">
import "./src/babylon_panel.js";
import "./src/html_panel.js";
</script>
</body>
</html>

335
package-lock.json generated
View File

@@ -1,20 +1,36 @@
{
"name": "p5js-vite",
"name": "untitled-maze-game",
"version": "0.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "p5js-vite",
"name": "untitled-maze-game",
"version": "0.1.0",
"dependencies": {
"babylonjs": "^9.5.1",
"p5": "^2.2.3",
"p5js-wrapper": "^1.2.3"
},
"devDependencies": {
"vite": "^8.0.10"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@davepagurek/bezier-path": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@davepagurek/bezier-path/-/bezier-path-0.0.7.tgz",
"integrity": "sha512-CVlnCOrV1iy4Z12T756i9l4G6kF7r8uhlnb+xqDemAMmWQB+8Q0b+8VEqIiUfywgZDSiDr18Rm7pZlnA69rE8Q==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -49,6 +65,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@japont/unicode-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
"integrity": "sha512-BckHvA2XdjRBVAWe2uceNuRf78lBeI28kyWEbfr/Q2pE17POkwuZ6WWY/UMv8FL9iBxhW4xfDoNLM9UVZaTeUQ==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -371,6 +393,30 @@
"tslib": "^2.4.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/babylonjs": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/babylonjs/-/babylonjs-9.5.1.tgz",
@@ -378,6 +424,16 @@
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/colorjs.io": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
"integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/color"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -388,6 +444,58 @@
"node": ">=8"
}
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -421,6 +529,36 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/i18next": {
"version": "19.9.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz",
"integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.3.1.tgz",
"integrity": "sha512-KIToAzf8zwWvacgnRwJp63ase26o24AuNUlfNVJ5YZAFmdGhsJpmFClxXPuk9rv1FMI4lnc8zLSqgZPEZMrW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5"
}
},
"node_modules/libtess": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz",
"integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw==",
"license": "SGI-B-2.0"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -713,10 +851,32 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/p5": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.4.1.tgz",
"integrity": "sha512-3/X+qb0bK2Cg8nuZNpZZvzxkeUSRghOf0S+l8c+U8yIkUTVSbbcV0R8y96rx3InVBVhk8cH9kFC93VlZZElqSw=="
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/p5/-/p5-2.2.3.tgz",
"integrity": "sha512-jz9uy0k3Fcj9vKSOafQlIrpaPZZjO4rAEBZF6dGkbokisshP0M3aFm4qtLHYCoEW1XJSkFaVaOMILCQAQxUHHA==",
"license": "LGPL-2.1",
"dependencies": {
"@davepagurek/bezier-path": "^0.0.7",
"@japont/unicode-range": "^1.0.0",
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"colorjs.io": "^0.6.0",
"escodegen": "^2.1.0",
"gifenc": "^1.0.3",
"i18next": "^19.0.2",
"i18next-browser-languagedetector": "^4.0.1",
"libtess": "^1.2.2",
"omggif": "^1.0.10",
"pako": "^2.1.0",
"zod": "^4.2.1"
}
},
"node_modules/p5js-wrapper": {
"version": "1.2.3",
@@ -726,6 +886,18 @@
"p5": "^1.4.1"
}
},
"node_modules/p5js-wrapper/node_modules/p5": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.11.13.tgz",
"integrity": "sha512-gfGo4AkyuNMs6Ko7UNFM9K2edqFRGyLrFaYUB+XXF127JVdEPu0BIaC5uDDNJpsRMOD9hJMUpsOH4HkfuNhvhA==",
"license": "LGPL-2.1"
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -809,6 +981,16 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -921,9 +1103,28 @@
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
},
"dependencies": {
"@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="
},
"@davepagurek/bezier-path": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@davepagurek/bezier-path/-/bezier-path-0.0.7.tgz",
"integrity": "sha512-CVlnCOrV1iy4Z12T756i9l4G6kF7r8uhlnb+xqDemAMmWQB+8Q0b+8VEqIiUfywgZDSiDr18Rm7pZlnA69rE8Q=="
},
"@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -955,6 +1156,11 @@
"tslib": "^2.4.0"
}
},
"@japont/unicode-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@japont/unicode-range/-/unicode-range-1.0.0.tgz",
"integrity": "sha512-BckHvA2XdjRBVAWe2uceNuRf78lBeI28kyWEbfr/Q2pE17POkwuZ6WWY/UMv8FL9iBxhW4xfDoNLM9UVZaTeUQ=="
},
"@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -1097,17 +1303,61 @@
"tslib": "^2.4.0"
}
},
"acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="
},
"acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"requires": {
"acorn": "^8.11.0"
}
},
"babylonjs": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/babylonjs/-/babylonjs-9.5.1.tgz",
"integrity": "sha512-EXROfSz1k+Mg88Qh/SrElEz1zp5QVFG4H+2OyCZ+uEXe4fFeY667N9kjMx2+6Y2XFCxKxf4q+za6k+B1x1qbGg=="
},
"colorjs.io": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
"integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg=="
},
"detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true
},
"escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"requires": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2",
"source-map": "~0.6.1"
}
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="
},
"esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1122,6 +1372,32 @@
"dev": true,
"optional": true
},
"gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="
},
"i18next": {
"version": "19.9.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz",
"integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==",
"requires": {
"@babel/runtime": "^7.12.0"
}
},
"i18next-browser-languagedetector": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.3.1.tgz",
"integrity": "sha512-KIToAzf8zwWvacgnRwJp63ase26o24AuNUlfNVJ5YZAFmdGhsJpmFClxXPuk9rv1FMI4lnc8zLSqgZPEZMrW4g==",
"requires": {
"@babel/runtime": "^7.5.5"
}
},
"libtess": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/libtess/-/libtess-1.2.2.tgz",
"integrity": "sha512-Nps8HPeVVcsmJxUvFLKVJcCgcz+1ajPTXDVAVPs6+giOQP4AHV31uZFFkh+CKow/bkB7GbZWKmwmit7myaqDSw=="
},
"lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1225,10 +1501,30 @@
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true
},
"omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="
},
"p5": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.4.1.tgz",
"integrity": "sha512-3/X+qb0bK2Cg8nuZNpZZvzxkeUSRghOf0S+l8c+U8yIkUTVSbbcV0R8y96rx3InVBVhk8cH9kFC93VlZZElqSw=="
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/p5/-/p5-2.2.3.tgz",
"integrity": "sha512-jz9uy0k3Fcj9vKSOafQlIrpaPZZjO4rAEBZF6dGkbokisshP0M3aFm4qtLHYCoEW1XJSkFaVaOMILCQAQxUHHA==",
"requires": {
"@davepagurek/bezier-path": "^0.0.7",
"@japont/unicode-range": "^1.0.0",
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"colorjs.io": "^0.6.0",
"escodegen": "^2.1.0",
"gifenc": "^1.0.3",
"i18next": "^19.0.2",
"i18next-browser-languagedetector": "^4.0.1",
"libtess": "^1.2.2",
"omggif": "^1.0.10",
"pako": "^2.1.0",
"zod": "^4.2.1"
}
},
"p5js-wrapper": {
"version": "1.2.3",
@@ -1236,7 +1532,19 @@
"integrity": "sha512-nG4xiyydY7v+q0+/6wbE2gj+L7RPf3FDwM68govCcuyrBckXgrfDy+0SKCTNTGwbpypxjVAu5juU4XOlz4IRPw==",
"requires": {
"p5": "^1.4.1"
},
"dependencies": {
"p5": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.11.13.tgz",
"integrity": "sha512-gfGo4AkyuNMs6Ko7UNFM9K2edqFRGyLrFaYUB+XXF127JVdEPu0BIaC5uDDNJpsRMOD9hJMUpsOH4HkfuNhvhA=="
}
}
},
"pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="
},
"picocolors": {
"version": "1.1.1",
@@ -1286,6 +1594,12 @@
"@rolldown/pluginutils": "1.0.0-rc.17"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"optional": true
},
"source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1322,6 +1636,11 @@
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
}
},
"zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="
}
}
}

View File

@@ -1,5 +1,5 @@
{
"name": "p5js-vite",
"name": "untitled-maze-game",
"version": "0.1.0",
"scripts": {
"dev": "vite",
@@ -11,6 +11,7 @@
},
"dependencies": {
"babylonjs": "^9.5.1",
"p5": "^2.2.3",
"p5js-wrapper": "^1.2.3"
}
}

BIN
sfx/sfx_chest_close.wav Normal file

Binary file not shown.

BIN
sfx/sfx_chest_open.wav Normal file

Binary file not shown.

BIN
sfx/sfx_click.wav Normal file

Binary file not shown.

BIN
sfx/sfx_clock.wav Normal file

Binary file not shown.

BIN
sfx/sfx_key.wav Normal file

Binary file not shown.

BIN
sfx/sfx_lose.wav Normal file

Binary file not shown.

BIN
sfx/sfx_step.wav Normal file

Binary file not shown.

BIN
sfx/sfx_win.wav Normal file

Binary file not shown.

54
src/assets/materials.js Normal file
View 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;
}

199
src/babylon_panel.js Normal file
View File

@@ -0,0 +1,199 @@
import * as BABYLON from "babylonjs";
import { sharedState } from "./game/state.js";
import { generateMazeGrid, findDeadEnds } from "./game/maze.js";
import { isWalkableCell } from "./game/grid.js";
import { playSfx, primeSfx } from "./game/sfx.js";
import { initializeScene, startRenderLoop } from "./game/scene-init.js";
import { createCameras, switchCameraMode, updateOverviewCameraForMaze, attachCamera } from "./game/camera-manager.js";
import { setupInputHandlers } from "./controls/input-handler.js";
import { buildLevelFromGrid, placeChestsOnDeadEnds, placeExit, spawnCameraAt, clearLevelMeshes } from "./game/level-generator.js";
import { registerGameLoop, ROUND_TIME_SECONDS } from "./game/game-loop.js";
import { showGameOverScreen, hideGameOverScreen, showStartScreen, hideStartScreen } from "./game/screen-manager.js";
import { checkChestRaycast, setChestHighlight } from "./game/collisions.js";
// DOM elements
const canvas = document.getElementById("renderCanvas");
const p5GameOverPanel = document.getElementById("p5-game-over-panel");
const p5StartPanel = document.getElementById("p5-start-panel");
const controlPanelSection = document.getElementById("control-panel-section");
// Initialize Babylon.js
const { engine, scene } = initializeScene(canvas);
const { fpCamera, overviewCamera } = createCameras(scene, canvas);
// Initialize central sphere (visual indicator)
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;
// Game state extensions
sharedState.gameOverActive = false;
sharedState.cameraMode = "fp";
sharedState.chestMap = new Map();
sharedState.keyChestKey = null;
sharedState.exitBox = null;
sharedState.exitGridPos = null;
sharedState.spawnGridPos = null;
sharedState.highlightedChest = null;
// Attach first-person camera by default
attachCamera(scene, fpCamera, canvas);
let levelMeshes = [];
let controlsVisible = false;
/**
* Generate a new level
*/
async function generateLevel() {
hideGameOverScreen(sharedState);
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);
const dead = findDeadEnds(grid);
clearLevelMeshes(levelMeshes, sharedState);
buildLevelFromGrid(scene, grid, sharedState, levelMeshes);
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 =
!!sharedState.exitGridPos &&
!!sharedState.spawnGridPos &&
isWalkableCell(grid, sharedState.exitGridPos.x, sharedState.exitGridPos.y) &&
isWalkableCell(grid, sharedState.spawnGridPos.x, sharedState.spawnGridPos.y) &&
!(sharedState.exitGridPos.x === sharedState.spawnGridPos.x && sharedState.exitGridPos.y === sharedState.spawnGridPos.y);
if (!placementValid) {
sharedState.runtime.message = `Placement warning: spawn/exit invalid on level ${cfg.level}.`;
console.warn("Invalid spawn/exit placement", { exitGridPos: sharedState.exitGridPos, spawnGridPos: sharedState.spawnGridPos });
} else {
sharedState.runtime.message = `Level ${cfg.level} generated (spawn ${sharedState.spawnGridPos.x},${sharedState.spawnGridPos.y} / exit ${sharedState.exitGridPos.x},${sharedState.exitGridPos.y}).`;
}
}
/**
* Start run from start screen
*/
function startRunFromStartScreen() {
hideStartScreen(sharedState);
sharedState.runtime.runActive = true;
sharedState.runtime.hasKey = false;
sharedState.runtime.roundsCompleted = 0;
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
sharedState.runtime.message = "Game started.";
sharedState.config.level = 1;
generateLevel();
primeSfx();
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
window.addEventListener("load", () => {
showStartScreen(sharedState);
});
// Export shared state for p5 to use
export { sharedState };

View File

@@ -1,382 +0,0 @@
import * as BABYLON from "babylonjs";
// Shared game state
const sharedState = (window.mazeGameState ??= {
config: {
seed: Math.floor(Math.random() * 100000),
level: 1,
mazeWidth: 11,
mazeHeight: 11,
minChestDeadEnds: 2,
},
runtime: {
runActive: false,
hasKey: false,
elapsedSeconds: 0,
message: "Adjust settings, then start a run.",
},
});
// Initialize Babylon.js engine and scene
const canvas = document.getElementById("renderCanvas");
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);
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);
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
scene.collisionsEnabled = true;
canvas.addEventListener("click", () => {
if (document.pointerLockElement !== canvas) {
canvas.requestPointerLock();
}
});
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
// Placeholder sphere
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
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;
sphereMaterial.diffuseColor = sharedState.runtime.hasKey
? new BABYLON.Color3(0.25, 0.8, 0.45)
: new BABYLON.Color3(0.2, 0.55, 0.95);
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 spawnMarker = null;
const cellSize = 2;
function clearLevelMeshes() {
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;
}
// Simple seeded RNG
function seededRng(seed) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
// Maze generation (recursive backtracker) returning grid: 0 path, 1 wall
function generateMazeGrid(w, h, seed) {
if (w % 2 === 0) w += 1;
if (h % 2 === 0) h += 1;
const rng = seededRng(seed);
const grid = Array.from({ length: h }, () => Array(w).fill(1));
function carve(x, y) {
grid[y][x] = 0;
const dirs = [ [0,-2],[2,0],[0,2],[-2,0] ];
for (let i = dirs.length -1; i > 0; i--) {
const j = Math.floor(rng() * (i+1));
[dirs[i], dirs[j]] = [dirs[j], dirs[i]];
}
for (const [dx,dy] of dirs) {
const nx = x + dx;
const ny = y + dy;
if (nx > 0 && nx < w-1 && ny > 0 && ny < h-1 && grid[ny][nx] === 1) {
grid[y + dy/2][x + dx/2] = 0;
carve(nx, ny);
}
}
}
const sx = 1 + Math.floor(rng() * ((w-1)/2)) * 2;
const sy = 1 + Math.floor(rng() * ((h-1)/2)) * 2;
carve(sx, sy);
const extra = Math.max(0, Math.floor((w*h) * 0.02));
for (let i = 0; i < extra; i++) {
const rx = 1 + Math.floor(rng() * ((w-1)/2)) * 2;
const ry = 1 + Math.floor(rng() * ((h-1)/2)) * 2;
const dirs = [ [0,-1],[1,0],[0,1],[-1,0] ];
const [dx,dy] = dirs[Math.floor(rng()*dirs.length)];
const nx = rx + dx;
const ny = ry + dy;
if (nx > 0 && nx < w-1 && ny > 0 && ny < h-1) grid[ny][nx] = 0;
}
return grid;
}
function findDeadEnds(grid) {
const h = grid.length;
const w = grid[0].length;
const dead = [];
for (let y = 1; y < h-1; y++) {
for (let x = 1; x < w-1; x++) {
if (grid[y][x] !== 0) continue;
let neighbors = 0;
const deltas = [[0,1],[1,0],[0,-1],[-1,0]];
for (const [dx,dy] of deltas) if (grid[y+dy][x+dx] === 0) neighbors++;
if (neighbors === 1) dead.push([x,y]);
}
}
return dead;
}
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.diffuseColor = new BABYLON.Color3(0.08, 0.08, 0.09);
floor.material = fm;
levelMeshes.push(floor);
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
wallMat.diffuseColor = new BABYLON.Color3(0.33, 0.28, 0.22);
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.diffuseColor = new BABYLON.Color3(0.75, 0.45, 0.15);
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.diffuseColor = new BABYLON.Color3(0.95, 0.8, 0.1);
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;
const halfW = (grid[0].length * cellSize) / 2;
const halfH = (grid.length * cellSize) / 2;
const ex = x*cellSize - halfW + cellSize/2;
const ez = y*cellSize - halfH + cellSize/2;
// Ground plane indicator
const plane = BABYLON.MeshBuilder.CreateGround('exitZone', { width: cellSize*0.9, height: cellSize*0.9 }, scene);
plane.material = new BABYLON.StandardMaterial('exitMat', scene);
plane.material.emissiveColor = new BABYLON.Color3(0.9, 0.8, 0.2);
plane.position = new BABYLON.Vector3(ex, 0.01, ez);
exitBox = plane;
levelMeshes.push(plane);
// Tall pillar for visibility
const pillar = BABYLON.MeshBuilder.CreateCylinder('exitPillar', { diameter: cellSize*0.3, height: cellSize*3.0 }, scene);
const pillarMat = new BABYLON.StandardMaterial('exitPillarMat', scene);
pillarMat.diffuseColor = new BABYLON.Color3(0.95, 0.8, 0.1);
pillarMat.emissiveColor = new BABYLON.Color3(0.3, 0.25, 0.05);
pillar.material = pillarMat;
pillar.position = new BABYLON.Vector3(ex, cellSize*0.75, ez);
levelMeshes.push(pillar);
}
function spawnCameraAt(grid) {
const h = grid.length, w = grid[0].length;
for (let y = 1; y < h-1; y++) {
for (let x = 1; x < w-1; x++) {
// Skip walls
if (grid[y][x] !== 0) continue;
// Skip chests
if (chestMap.has(`${x},${y}`)) continue;
// Skip exit
if (exitGridPos && exitGridPos.x === x && exitGridPos.y === y) continue;
// Found valid spawn point
const halfW = (w * cellSize) / 2;
const halfH = (h * cellSize) / 2;
const px = x*cellSize - halfW + cellSize/2;
const pz = y*cellSize - halfH + cellSize/2;
try {
if (camera && camera.position) {
camera.position = new BABYLON.Vector3(px, 1.6, pz);
}
} catch (e) {}
// Add spawn marker sphere
if (spawnMarker) {
try { spawnMarker.dispose(); } catch(e) {}
}
const marker = BABYLON.MeshBuilder.CreateSphere('spawnMarker', { diameter: cellSize*0.4 }, scene);
const markerMat = new BABYLON.StandardMaterial('spawnMarkerMat', scene);
markerMat.diffuseColor = new BABYLON.Color3(0.2, 0.6, 0.95);
markerMat.emissiveColor = new BABYLON.Color3(0.1, 0.3, 0.5);
marker.material = markerMat;
marker.position = new BABYLON.Vector3(px, cellSize*0.2, pz);
spawnMarker = marker;
levelMeshes.push(marker);
return;
}
}
}
function generateLevel() {
const cfg = sharedState.config;
const seed = cfg.seed;
const w = Math.max(9, cfg.mazeWidth);
const h = Math.max(9, cfg.mazeHeight);
const grid = generateMazeGrid(w, h, seed + cfg.level);
const dead = findDeadEnds(grid);
buildLevelFromGrid(grid);
placeChestsOnDeadEnds(grid, dead, cfg.minChestDeadEnds, seed + cfg.level);
placeExit(grid, seed + cfg.level);
spawnCameraAt(grid);
sharedState.runtime.message = `Level ${cfg.level} generated.`;
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;
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) {
sharedState.runtime.runActive = false;
sharedState.runtime.message = 'Opened chest again — game over.';
return;
}
entry.opened = true;
const openedMat = new BABYLON.StandardMaterial('openedMat', scene);
openedMat.diffuseColor = new BABYLON.Color3(0.25,0.25,0.25);
entry.mesh.material = openedMat;
if (coords === keyChestKey) {
sharedState.runtime.hasKey = true;
sharedState.runtime.message = 'You found the key! Find the exit.';
} else {
sharedState.runtime.message = 'This chest was empty.';
}
});
// Level transition check
scene.registerBeforeRender(() => {
if (sharedState.runtime.runActive) {
sharedState.runtime.elapsedSeconds += engine.getDeltaTime() / 1000;
}
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) {
sharedState.config.level += 1;
sharedState.runtime.hasKey = false;
sharedState.runtime.elapsedSeconds = 0;
sharedState.runtime.message = `Level ${sharedState.config.level} starting.`;
generateLevel();
}
}
});
// Export shared state for p5 to use
export { sharedState };

View 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();
}
}

View 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
View 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
View 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 };

15
src/game/grid.js Normal file
View File

@@ -0,0 +1,15 @@
import * as BABYLON from "babylonjs";
export function gridCellToWorld(grid, x, y, cellSize) {
const halfW = (grid[0].length * cellSize) / 2;
const halfH = (grid.length * cellSize) / 2;
return new BABYLON.Vector3(
x * cellSize - halfW + cellSize / 2,
0,
y * cellSize - halfH + cellSize / 2,
);
}
export function isWalkableCell(grid, x, y) {
return y >= 0 && y < grid.length && x >= 0 && x < grid[0].length && grid[y][x] === 0;
}

169
src/game/level-generator.js Normal file
View 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);
}
}

66
src/game/maze.js Normal file
View File

@@ -0,0 +1,66 @@
export function seededRng(seed) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
// Maze generation (recursive backtracker): 0 path, 1 wall
export function generateMazeGrid(w, h, seed) {
if (w % 2 === 0) w += 1;
if (h % 2 === 0) h += 1;
const rng = seededRng(seed);
const grid = Array.from({ length: h }, () => Array(w).fill(1));
function carve(x, y) {
grid[y][x] = 0;
const dirs = [[0, -2], [2, 0], [0, 2], [-2, 0]];
for (let i = dirs.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[dirs[i], dirs[j]] = [dirs[j], dirs[i]];
}
for (const [dx, dy] of dirs) {
const nx = x + dx;
const ny = y + dy;
if (nx > 0 && nx < w - 1 && ny > 0 && ny < h - 1 && grid[ny][nx] === 1) {
grid[y + dy / 2][x + dx / 2] = 0;
carve(nx, ny);
}
}
}
const sx = 1 + Math.floor(rng() * ((w - 1) / 2)) * 2;
const sy = 1 + Math.floor(rng() * ((h - 1) / 2)) * 2;
carve(sx, sy);
const extra = Math.max(0, Math.floor((w * h) * 0.02));
for (let i = 0; i < extra; i++) {
const rx = 1 + Math.floor(rng() * ((w - 1) / 2)) * 2;
const ry = 1 + Math.floor(rng() * ((h - 1) / 2)) * 2;
const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];
const [dx, dy] = dirs[Math.floor(rng() * dirs.length)];
const nx = rx + dx;
const ny = ry + dy;
if (nx > 0 && nx < w - 1 && ny > 0 && ny < h - 1) grid[ny][nx] = 0;
}
return grid;
}
export function findDeadEnds(grid) {
const h = grid.length;
const w = grid[0].length;
const dead = [];
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
if (grid[y][x] !== 0) continue;
let neighbors = 0;
const deltas = [[0, 1], [1, 0], [0, -1], [-1, 0]];
for (const [dx, dy] of deltas) if (grid[y + dy][x + dx] === 0) neighbors++;
if (neighbors === 1) dead.push([x, y]);
}
}
return dead;
}

34
src/game/scene-init.js Normal file
View 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();
});
}

View 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();
}
}

69
src/game/sfx.js Normal file
View File

@@ -0,0 +1,69 @@
// 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 = {
chestClose: chestCloseUrl,
chestOpen: chestOpenUrl,
click: clickUrl,
clock: clockUrl,
key: keyUrl,
lose: loseUrl,
step: stepUrl,
win: winUrl,
};
const sounds = {};
for (const [name, filePath] of Object.entries(soundFiles)) {
const audio = new Audio(filePath);
audio.preload = "auto";
sounds[name] = audio;
}
let audioPrimed = false;
function playAudio(audio, volume = 1) {
const instance = audio.cloneNode();
instance.volume = volume;
instance.currentTime = 0;
const promise = instance.play();
if (promise && typeof promise.catch === "function") {
promise.catch(() => {});
}
}
export function primeSfx() {
if (audioPrimed) {
return;
}
audioPrimed = true;
for (const audio of Object.values(sounds)) {
const probe = audio.cloneNode();
probe.volume = 0;
const promise = probe.play();
if (promise && typeof promise.then === "function") {
promise
.then(() => {
probe.pause();
probe.currentTime = 0;
})
.catch(() => {});
}
}
}
export function playSfx(name, volume = 1) {
const audio = sounds[name];
if (!audio) {
return;
}
playAudio(audio, volume);
}

21
src/game/state.js Normal file
View File

@@ -0,0 +1,21 @@
const initialSeed = Math.floor(Math.random() * 100000);
export const sharedState = (window.mazeGameState ??= {
config: {
seed: initialSeed,
level: 1,
mazeWidth: 11,
mazeHeight: 11,
minChestDeadEnds: 2,
},
runtime: {
runActive: false,
hasKey: false,
roundsCompleted: 0,
elapsedSeconds: 60,
message: "Press Start to play.",
},
});
// Debug logging
console.log("State initialized with seed:", sharedState.config.seed, "Initial seed value:", initialSeed);

View File

@@ -1,10 +1,12 @@
import { sharedState } from "./babylon_setup.js";
import { sharedState } from "./game/state.js";
import { playSfx, primeSfx } from "./game/sfx.js";
// Handler functions (same as p5_panel but without p5 scoping)
function resetRun(message) {
sharedState.runtime.runActive = true;
sharedState.runtime.hasKey = false;
sharedState.runtime.elapsedSeconds = 0;
sharedState.runtime.roundsCompleted = 0;
sharedState.runtime.elapsedSeconds = 60;
sharedState.runtime.message = message;
sharedState.config.level = 1;
try { window.mazeGameApi.generateLevel(); } catch (e) { console.warn(e); }
@@ -13,7 +15,7 @@ function resetRun(message) {
function restartLevel(message) {
sharedState.runtime.hasKey = false;
sharedState.runtime.elapsedSeconds = 0;
sharedState.runtime.elapsedSeconds = 60;
sharedState.runtime.message = message;
try { window.mazeGameApi.generateLevel(); } catch (e) { console.warn(e); }
updateDisplay();
@@ -27,53 +29,84 @@ function randomizeSeed() {
// Update display from shared state
function updateDisplay() {
document.getElementById("value-width").textContent = sharedState.config.mazeWidth;
document.getElementById("value-height").textContent = sharedState.config.mazeHeight;
document.getElementById("value-deadends").textContent = sharedState.config.minChestDeadEnds;
const statusSeed = document.getElementById("status-seed");
const statusLevel = document.getElementById("status-level");
const statusMazeSize = document.getElementById("status-maze-size");
const statusChests = document.getElementById("status-chests");
const statusTime = document.getElementById("status-time");
const statusKey = document.getElementById("status-key");
const statusRounds = document.getElementById("status-rounds");
const statusMessage = document.getElementById("status-message");
document.getElementById("status-seed").textContent = sharedState.config.seed;
document.getElementById("status-level").textContent = sharedState.config.level;
document.getElementById("status-time").textContent = sharedState.runtime.elapsedSeconds.toFixed(1);
document.getElementById("status-key").textContent = sharedState.runtime.hasKey ? "yes" : "no";
document.getElementById("status-message").textContent = sharedState.runtime.message;
if (!statusSeed || !statusLevel || !statusMazeSize || !statusChests || !statusTime || !statusKey || !statusRounds || !statusMessage) {
return;
}
statusSeed.textContent = sharedState.config.seed;
statusLevel.textContent = sharedState.config.level;
// Calculate effective maze size and chest count based on current level
const roundScale = Math.max(0, sharedState.config.level - 1);
const effectiveWidth = Math.max(9, sharedState.config.mazeWidth + roundScale * 2);
const effectiveHeight = Math.max(9, sharedState.config.mazeHeight + roundScale * 2);
const effectiveChests = Math.max(1, sharedState.config.minChestDeadEnds + roundScale);
statusMazeSize.textContent = `${effectiveWidth}x${effectiveHeight}`;
statusChests.textContent = effectiveChests;
statusTime.textContent = sharedState.runtime.elapsedSeconds.toFixed(1);
statusKey.textContent = sharedState.runtime.hasKey ? "yes" : "no";
statusRounds.textContent = sharedState.runtime.roundsCompleted;
statusMessage.textContent = sharedState.runtime.message;
}
// Initialize event listeners
document.getElementById("btn-start").addEventListener("click", () => {
const btnStart = document.getElementById("btn-start");
const btnRestart = document.getElementById("btn-restart");
const btnRandomize = document.getElementById("btn-randomize");
if (btnStart) {
btnStart.addEventListener("click", () => {
primeSfx();
playSfx("click", 0.7);
resetRun("Run started.");
});
}
document.getElementById("btn-restart").addEventListener("click", () => {
if (btnRestart) {
btnRestart.addEventListener("click", () => {
primeSfx();
playSfx("click", 0.7);
restartLevel("Level restarted.");
});
}
document.getElementById("btn-randomize").addEventListener("click", () => {
if (btnRandomize) {
btnRandomize.addEventListener("click", () => {
primeSfx();
playSfx("click", 0.7);
randomizeSeed();
});
document.getElementById("slider-width").addEventListener("input", (e) => {
const value = parseInt(e.target.value) | 1;
sharedState.config.mazeWidth = value;
document.getElementById("value-width").textContent = value;
});
document.getElementById("slider-height").addEventListener("input", (e) => {
const value = parseInt(e.target.value) | 1;
sharedState.config.mazeHeight = value;
document.getElementById("value-height").textContent = value;
});
document.getElementById("slider-deadends").addEventListener("input", (e) => {
const value = parseInt(e.target.value);
sharedState.config.minChestDeadEnds = value;
document.getElementById("value-deadends").textContent = value;
});
}
// Update status display on game loop
setInterval(() => {
if (sharedState.runtime.runActive) {
document.getElementById("status-time").textContent = sharedState.runtime.elapsedSeconds.toFixed(1);
document.getElementById("status-key").textContent = sharedState.runtime.hasKey ? "yes" : "no";
const roundScale = Math.max(0, sharedState.config.level - 1);
const effectiveWidth = Math.max(9, sharedState.config.mazeWidth + roundScale * 2);
const effectiveHeight = Math.max(9, sharedState.config.mazeHeight + roundScale * 2);
const effectiveChests = Math.max(1, sharedState.config.minChestDeadEnds + roundScale);
const statusMazeSize = document.getElementById("status-maze-size");
const statusChests = document.getElementById("status-chests");
const statusTime = document.getElementById("status-time");
const statusKey = document.getElementById("status-key");
const statusRounds = document.getElementById("status-rounds");
if (statusMazeSize) statusMazeSize.textContent = `${effectiveWidth}x${effectiveHeight}`;
if (statusChests) statusChests.textContent = effectiveChests;
if (statusTime) statusTime.textContent = sharedState.runtime.elapsedSeconds.toFixed(1);
if (statusKey) statusKey.textContent = sharedState.runtime.hasKey ? "yes" : "no";
if (statusRounds) statusRounds.textContent = sharedState.runtime.roundsCompleted;
}
}, 100);

View File

@@ -1,3 +0,0 @@
// Import Babylon.js game logic and HTML control panel
import "./babylon_setup.js";
import "./html_panel.js";

175
src/p5_particles.js Normal file
View File

@@ -0,0 +1,175 @@
import p5 from "p5";
import gameOverImageUrl from "../img/img_jobapplication.png";
import startImageUrl from "../img/img_start.png";
let sketch;
export function startParticleSketch(containerElement) {
if (sketch) {
sketch.remove();
}
sketch = new p5((p) => {
let particles = [];
let gameOverImg;
const imgSize = 50;
const particleCount = 15;
p.setup = async function() {
const width = containerElement.clientWidth || 800;
const height = containerElement.clientHeight || 600;
console.log("p5 setup:", { width, height });
const canv = p.createCanvas(width, height);
canv.parent(containerElement);
// Load image asynchronously
try {
gameOverImg = await p.loadImage(gameOverImageUrl);
console.log("Game over image loaded:", gameOverImg.width, "x", gameOverImg.height);
} catch (err) {
console.error("Failed to load particle image:", err);
}
// Initialize particles with random starting positions
for (let i = 0; i < particleCount; i++) {
particles.push({
x: p.random(width),
y: p.random(height),
vx: p.random(-2, 2),
vy: p.random(-2, 2),
life: 200,
rotation: p.random(p.TWO_PI),
rotationSpeed: p.random(-0.08, 0.08),
scale: p.random(0.7, 1.3),
});
}
console.log("Particles initialized:", particles.length);
};
p.draw = function() {
// Draw background image full screen
if (gameOverImg) {
p.imageMode(p.CORNER);
p.image(gameOverImg, 0, 0, p.width, p.height);
}
// Semi-transparent overlay for visibility
p.background(0, 0, 0, 20);
for (let i = 0; i < particles.length; i++) {
const part = particles[i];
// Update position
part.x += part.vx;
part.y += part.vy;
part.rotation += part.rotationSpeed;
// Bounce off walls with friction
if (part.x < 0 || part.x > p.width) {
part.vx *= -0.8;
part.x = p.constrain(part.x, 0, p.width);
}
if (part.y < 0 || part.y > p.height) {
part.vy *= -0.8;
part.y = p.constrain(part.y, 0, p.height);
}
// Add some gravity
part.vy += 0.1;
part.vy = p.constrain(part.vy, -5, 5);
// Randomly change direction slightly
if (p.random() < 0.03) {
part.vx += p.random(-0.5, 0.5);
part.vy += p.random(-0.5, 0.5);
part.vx = p.constrain(part.vx, -3, 3);
part.vy = p.constrain(part.vy, -3, 3);
}
// Draw particle
p.push();
p.translate(part.x, part.y);
p.rotate(part.rotation);
p.scale(part.scale);
if (gameOverImg) {
p.imageMode(p.CENTER);
p.tint(255, 200);
p.image(gameOverImg, 0, 0, imgSize, imgSize);
}
p.pop();
}
};
p.windowResized = function() {
if (containerElement && containerElement.offsetParent !== null) {
const width = containerElement.clientWidth;
const height = containerElement.clientHeight;
if (width > 0 && height > 0) {
p.resizeCanvas(width, height);
}
}
};
});
}
export function stopParticleSketch() {
if (sketch) {
sketch.remove();
sketch = null;
}
}
let startSketch;
export function startStartScreenSketch(containerElement) {
if (startSketch) {
startSketch.remove();
}
startSketch = new p5((p) => {
let startImg;
p.setup = async function() {
const width = containerElement.clientWidth || 800;
const height = containerElement.clientHeight || 600;
console.log("p5 start screen setup:", { width, height });
const canv = p.createCanvas(width, height);
canv.parent(containerElement);
// Load image asynchronously
try {
startImg = await p.loadImage(startImageUrl);
console.log("Start image loaded:", startImg.width, "x", startImg.height);
} catch (err) {
console.error("Failed to load start image:", err);
}
};
p.draw = function() {
// Draw background image full screen
if (startImg) {
p.imageMode(p.CORNER);
p.image(startImg, 0, 0, p.width, p.height);
}
};
p.windowResized = function() {
if (containerElement && containerElement.offsetParent !== null) {
const width = containerElement.clientWidth;
const height = containerElement.clientHeight;
if (width > 0 && height > 0) {
p.resizeCanvas(width, height);
}
}
};
});
}
export function stopStartScreenSketch() {
if (startSketch) {
startSketch.remove();
startSketch = null;
}
}

43
src/ui/hud.js Normal file
View 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;
}