Compare commits
16 Commits
33b38a3b4d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151d2ed596 | ||
|
|
7f6548d9fb | ||
|
|
6532387f59 | ||
|
|
74dc932766 | ||
|
|
9748c2e2f5 | ||
|
|
e4d3d32702 | ||
|
|
b95438dfd0 | ||
|
|
b88f47c70d | ||
|
|
808b2545e2 | ||
|
|
c28a1d1f6a | ||
|
|
9b68630764 | ||
|
|
7e2d6243b2 | ||
|
|
021877902a | ||
|
|
47d4ba8bfe | ||
|
|
176ad34214 | ||
|
|
4bb46115ca |
564
README.md
@@ -1,141 +1,517 @@
|
||||
# P5.js-vite Starter Template 🚀
|
||||
# Untitled Maze Game - ID30011 Midterm Project
|
||||
|
||||
[](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.
|
||||
|
||||
234
css/style.css
@@ -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
|
After Width: | Height: | Size: 401 B |
@@ -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
|
After Width: | Height: | Size: 120 KiB |
BIN
img/img_door.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
img/img_ground.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
img/img_jobapplication.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
img/img_start.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
img/img_wall.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
60
index.html
@@ -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
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
BIN
sfx/sfx_chest_open.wav
Normal file
BIN
sfx/sfx_click.wav
Normal file
BIN
sfx/sfx_clock.wav
Normal file
BIN
sfx/sfx_key.wav
Normal file
BIN
sfx/sfx_lose.wav
Normal file
BIN
sfx/sfx_step.wav
Normal file
BIN
sfx/sfx_win.wav
Normal file
54
src/assets/materials.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as BABYLON from "babylonjs";
|
||||
import chestTextureUrl from "../../img/img_chest.png";
|
||||
import wallTextureUrl from "../../img/img_wall.png";
|
||||
import groundTextureUrl from "../../img/img_ground.png";
|
||||
import doorTextureUrl from "../../img/img_door.png";
|
||||
|
||||
/**
|
||||
* Create floor material
|
||||
*/
|
||||
export function createFloorMaterial(scene, width, height) {
|
||||
const fm = new BABYLON.StandardMaterial('floorMat', scene);
|
||||
fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
||||
fm.diffuseTexture = new BABYLON.Texture(groundTextureUrl, scene);
|
||||
fm.diffuseTexture.uScale = Math.max(1, Math.floor(width / 2));
|
||||
fm.diffuseTexture.vScale = Math.max(1, Math.floor(height / 2));
|
||||
fm.diffuseColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
||||
return fm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create wall material
|
||||
*/
|
||||
export function createWallMaterial(scene) {
|
||||
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
|
||||
wallMat.diffuseTexture = new BABYLON.Texture(wallTextureUrl, scene);
|
||||
wallMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
return wallMat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chest material (normal or key variant)
|
||||
*/
|
||||
export function createChestMaterial(scene, isKey = false) {
|
||||
const mat = new BABYLON.StandardMaterial(`chestMat_${isKey ? 'key' : 'normal'}`, scene);
|
||||
mat.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
||||
mat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
|
||||
if (isKey) {
|
||||
mat.emissiveColor = new BABYLON.Color3(0.3, 0.22, 0.02);
|
||||
}
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exit door material
|
||||
*/
|
||||
export function createExitMaterial(scene) {
|
||||
const exitMat = new BABYLON.StandardMaterial('exitMat', scene);
|
||||
exitMat.diffuseTexture = new BABYLON.Texture(doorTextureUrl, scene);
|
||||
exitMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
exitMat.emissiveColor = new BABYLON.Color3(0.07, 0.07, 0.07);
|
||||
return exitMat;
|
||||
}
|
||||
199
src/babylon_panel.js
Normal 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 };
|
||||
@@ -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 };
|
||||
79
src/controls/input-handler.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { playSfx, primeSfx } from "../game/sfx.js";
|
||||
|
||||
/**
|
||||
* Setup input handlers for keyboard and pointer events
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Object} state - Shared game state
|
||||
* @param {Object} callbacks - Event callbacks
|
||||
*/
|
||||
export function setupInputHandlers(canvas, state, callbacks) {
|
||||
// Pointer lock helper
|
||||
function requestPointerLockSafely() {
|
||||
const cameraMode = callbacks.getCameraMode?.() || "fp";
|
||||
if (cameraMode !== "fp" || document.pointerLockElement === canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lockResult = canvas.requestPointerLock();
|
||||
if (lockResult && typeof lockResult.catch === "function") {
|
||||
lockResult.catch((error) => {
|
||||
if (error && error.name === "SecurityError") {
|
||||
state.runtime.message = "Click once more to re-enable mouse look.";
|
||||
return;
|
||||
}
|
||||
console.warn(error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.name === "SecurityError") {
|
||||
state.runtime.message = "Click once more to re-enable mouse look.";
|
||||
return;
|
||||
}
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas click for pointer lock
|
||||
canvas.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
requestPointerLockSafely();
|
||||
});
|
||||
|
||||
// Keyboard input
|
||||
window.addEventListener("keydown", (event) => {
|
||||
// Prime audio context on common movement keys
|
||||
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyV" || event.code === "KeyR") {
|
||||
primeSfx();
|
||||
}
|
||||
|
||||
// Debug panel toggle
|
||||
if (event.code === "KeyB") {
|
||||
callbacks.onDebugToggle?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Restart or start game
|
||||
if (event.code === "KeyR") {
|
||||
const gameOverActive = callbacks.isGameOverActive?.() || false;
|
||||
const startPanelVisible = callbacks.isStartPanelVisible?.() || false;
|
||||
|
||||
if (gameOverActive) {
|
||||
callbacks.onRestart?.();
|
||||
} else if (!state.runtime.runActive && startPanelVisible) {
|
||||
callbacks.onStartGame?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Camera mode toggle
|
||||
if (event.code === "KeyV") {
|
||||
callbacks.onCameraToggle?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Pointer interaction for chests
|
||||
if (callbacks.setupScenePointerObserver) {
|
||||
callbacks.setupScenePointerObserver();
|
||||
}
|
||||
}
|
||||
79
src/game/camera-manager.js
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,34 @@
|
||||
import * as BABYLON from "babylonjs";
|
||||
|
||||
/**
|
||||
* Initialize Babylon.js engine and scene
|
||||
* @returns {{engine: BABYLON.Engine, scene: BABYLON.Scene}}
|
||||
*/
|
||||
export function initializeScene(canvas) {
|
||||
const engine = new BABYLON.Engine(canvas, true);
|
||||
const scene = new BABYLON.Scene(engine);
|
||||
|
||||
scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1);
|
||||
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
||||
scene.collisionsEnabled = true;
|
||||
|
||||
// Add hemispheric lighting
|
||||
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
||||
|
||||
return { engine, scene };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the main render loop
|
||||
* @param {BABYLON.Engine} engine
|
||||
* @param {BABYLON.Scene} scene
|
||||
*/
|
||||
export function startRenderLoop(engine, scene) {
|
||||
engine.runRenderLoop(() => {
|
||||
scene.render();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
engine.resize();
|
||||
});
|
||||
}
|
||||
72
src/game/screen-manager.js
Normal file
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||