fix(all): game window size and unused files
This commit is contained in:
312
README.md
312
README.md
@@ -1,34 +1,306 @@
|
||||
# Untitled Maze Game - ID30011 Midterm Project README
|
||||
# Untitled Maze Game - ID30011 Midterm Project
|
||||
|
||||
**A 3D First-Person Time-Attack Maze Game with Progressive Difficulty**
|
||||
|
||||
---
|
||||
|
||||
## Project Information
|
||||
|
||||
- **Name:** Bumgyu Suh
|
||||
- **Student ID:** 20240905
|
||||
- **Student Email:** bumgyu@kaist.ac.kr
|
||||
- **Repository URL:** https://git.prototyping.id/20240905/???
|
||||
- **Video URL:** youtube.com
|
||||
- **Repository URL:** https://git.prototyping.id/20240905/Untitled-Maze-Game
|
||||
- **Video Demonstration:** [YouTube Link]
|
||||
|
||||
## The Game
|
||||
A description of the game - how it works and what the user has to do
|
||||
---
|
||||
|
||||
WASD, V to switch view
|
||||
## Game Overview
|
||||
|
||||
### Concept
|
||||
**Untitled Maze Game** is a 3D first-person maze escape game built with Babylon.js and p5.js. Players must navigate procedurally-generated mazes in a race against time, collecting a key and finding the exit within 60 seconds, but there's a twist! You must not open a chest that you have already opened before! Each successful round increases difficulty—maze size grows, more chests appear, and players advance to the next level. If you fail... there is a little surprising waiting for you.
|
||||
|
||||
### Gameplay Flow
|
||||
|
||||
```
|
||||
START SCREEN (img_start.png)
|
||||
↓ Press R
|
||||
GAMEPLAY (60 seconds)
|
||||
↙ Time Up / Found Exit ↖
|
||||
GAME OVER NEXT LEVEL
|
||||
(img_jobapplication.png + Particles)
|
||||
```
|
||||
|
||||
### How to Play
|
||||
|
||||
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.
|
||||
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
|
||||
6. **Progress:** Successfully exiting unlocks the next level with a larger maze and more chests
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| **W/A/S/D** | Move forward/left/backward/right |
|
||||
| **Mouse** | Look around (first-person) |
|
||||
| **Left Click** | Open a highlighted chest |
|
||||
| **R** | Start game (from start screen) or restart (from game-over screen) |
|
||||
| **B** | (For debugging purposes) Toggle debug panel (hidden by default) |
|
||||
| **V** | (For debugging purposes) Switch camera mode (first-person ↔ overview) |
|
||||
| **ESC** | Exit pointer lock |
|
||||
|
||||
---
|
||||
|
||||
## Code Documentation
|
||||
A description of the organization of your code. Feel free to use diagrams, UML, or others. What are the main functions/classes? If you used patterns, what did you use them for, and how do different parts of your code speak to each other?
|
||||
Highlight any issue you want us to know about or whether the code has any known bug. If there are special features you want us to know about, write them here
|
||||
|
||||
Acknowledge any help or resource you used
|
||||
Writing style might be considered in grading (not the grammar, but rather the clarity of your writing)
|
||||
Be visual so use images and tables
|
||||
Try to be complete in your explanation - you do not need to write a lot, but the professor and the TA should be able to understand your documentation and your code by reading this file.
|
||||
### Packages Used
|
||||
- **3D Graphics:** Babylon.js v9.5.1 (3D scene, camera, meshes, rendering)
|
||||
- **Bundler:** Vite v8.0.10 (ES6 modules, asset optimization)
|
||||
- **2D Graphics:** p5.js v2.x (particle effects for game-over screen)
|
||||
- **Audio:** Web Audio API (polyphonic sound effects)
|
||||
|
||||
## Help from AI
|
||||
I got help from AI in criticizing my original plan in structuring my code. For example, although I originally planned to have a single 2D array that stores both the "static" information (the generated maze), and the state of whether the chests have been open or not, but chatgpt suggested that separating these two (one static, one dynamically changing during gameplay) is better as it prevents everything being coupled to this array.
|
||||
It also suggested ot
|
||||
### Files Structure
|
||||
|
||||
The game can be played through the multi_sketch.html
|
||||
"Start run" to generate first leve, etc, seed,
|
||||
left0click chests
|
||||
```
|
||||
src/
|
||||
├── babylon_panel.js # Core game logic, scene setup, game loop
|
||||
├── html_panel.js # UI state management (debug controls)
|
||||
├── p5_particles.js # Particle effects for start/game-over screens
|
||||
└── game/
|
||||
├── state.js # Shared game state (config + runtime)
|
||||
├── maze.js # Procedural maze generation (seeded RNG)
|
||||
├── grid.js # Coordinate conversion and collision detection
|
||||
└── sfx.js # Sound effect playback system
|
||||
|
||||
How well and complete is your documentation? Your documentation should contain the following information:
|
||||
css/
|
||||
└── style.css # Responsive layout, HUD styling, animations
|
||||
|
||||
Write a README.md file that contains the following information.
|
||||
img/
|
||||
├── img_start.png # Start screen background
|
||||
├── img_jobapplication.png # Game-over screen background
|
||||
├── img_chest.png # Chest texture
|
||||
├── img_door.png # Exit door texture
|
||||
├── img_wall.png # Wall texture
|
||||
└── img_ground.png # Floor texture
|
||||
|
||||
sfx/
|
||||
├── sfx_click.wav # UI interaction sound
|
||||
├── sfx_chest_open.wav # Chest opening sound
|
||||
├── sfx_key.wav # Key collection sound
|
||||
├── sfx_clock.wav # Low-time warning alarm
|
||||
├── sfx_step.wav # Footstep sound
|
||||
├── sfx_win.wav # Level complete sound
|
||||
├── sfx_lose.wav # Game over sound
|
||||
└── sfx_chest_close.wav # Chest closing sound
|
||||
```
|
||||
|
||||
### Core Modules
|
||||
|
||||
#### 1. **babylon_panel.js** (Game Controller)
|
||||
**Responsibilities:**
|
||||
- Babylon.js scene initialization and rendering loop
|
||||
- Maze generation and spatial layout
|
||||
- Game state machine (start → gameplay → game-over → restart)
|
||||
- Collision detection (camera + exit plane, chests)
|
||||
- Input handling (keyboard, mouse click)
|
||||
- Pointer lock for immersive camera control
|
||||
- Camera switching (first-person ↔ overview)
|
||||
|
||||
**Key Functions:**
|
||||
- `generateLevel()` - Creates maze with difficulty scaling
|
||||
- `scene.registerBeforeRender()` - Main game loop (countdown timer, collision checks, HUD updates)
|
||||
- `showGameOverScreen()` / `hideGameOverScreen()` - Screen transitions
|
||||
- `toggleControlsPanel()` - Debug UI visibility
|
||||
|
||||
**Difficulty Scaling Formula:**
|
||||
```
|
||||
roundScale = Math.max(0, level - 1)
|
||||
mazeWidth = 11 + roundScale * 2
|
||||
mazeHeight = 11 + roundScale * 2
|
||||
chestCount = 2 + roundScale
|
||||
```
|
||||
|
||||
#### 2. **game/state.js** (Shared State)
|
||||
**Pattern:** Single source of truth for game configuration and runtime state
|
||||
|
||||
```javascript
|
||||
window.mazeGameState = {
|
||||
config: {
|
||||
seed, // Random seed for reproducible mazes
|
||||
level, // Current progression level
|
||||
mazeWidth: 11, // Base maze width (increases per level)
|
||||
mazeHeight: 11, // Base maze height
|
||||
minChestDeadEnds: 2 // Minimum chests to place
|
||||
},
|
||||
runtime: {
|
||||
runActive: boolean, // Is gameplay active?
|
||||
hasKey: boolean, // Does player have the key?
|
||||
roundsCompleted: number, // Levels completed this run
|
||||
elapsedSeconds: number, // Countdown timer (60 → 0)
|
||||
message: string // Status text for HUD
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **game/maze.js** (Procedural Generation)
|
||||
**Algorithm:** Recursive backtracking with seeded random number generator
|
||||
|
||||
**Features:**
|
||||
- Deterministic maze generation (same seed = same maze)
|
||||
- Configurable width/height
|
||||
- Guaranteed solvable layout
|
||||
- Dead-end detection for chest placement
|
||||
- Exportable cell data for spatial queries
|
||||
|
||||
#### 4. **p5_particles.js** (Visual Effects)
|
||||
**Purpose:** Full-screen particle animations on start and game-over screens
|
||||
|
||||
**Start Screen:**
|
||||
- Static full-screen display of img_start.png
|
||||
- Responsive canvas sizing
|
||||
|
||||
**Game-Over Screen:**
|
||||
- Background image (img_jobapplication.png) at full resolution
|
||||
- 15 animated particles with physics:
|
||||
- Gravity (0.1 px/frame²)
|
||||
- Wall bouncing with friction (0.8×)
|
||||
- Random rotation and direction changes
|
||||
- Semi-transparent rendering (tint: 255, alpha: 200)
|
||||
- Velocity constraints (-3 to 3 px/frame)
|
||||
|
||||
#### 5. **html_panel.js** (Debug UI)
|
||||
**Responsibilities:**
|
||||
- Event listeners for debug buttons (Start, Restart, Randomize seed)
|
||||
- HUD status display updates
|
||||
- Null safety checks for missing DOM elements
|
||||
|
||||
**Note:** Debug controls panel is hidden by default (press **B** to toggle)
|
||||
|
||||
---
|
||||
|
||||
## Key Features & Implementation Details
|
||||
|
||||
| Feature | Implementation | Status |
|
||||
|---------|----------------|--------|
|
||||
| **3D Rendering** | Babylon.js UniversalCamera, procedural mesh generation | ✓ Complete |
|
||||
| **Procedural Mazes** | Seeded random generation with configurable dimensions | ✓ Complete |
|
||||
| **Time-Attack Mode** | 60-second countdown timer with auto-game-over on timeout | ✓ Complete |
|
||||
| **Progressive Difficulty** | Maze size & chest count increase per completed level | ✓ Complete |
|
||||
| **Collision Detection** | Raycasting for chest interaction, sphere collision for exit | ✓ Complete |
|
||||
| **Sound System** | 8 polyphonic SFX with Web Audio API and context priming | ✓ Complete |
|
||||
| **Particle Effects** | p5.js animated particles with physics on game-over screen | ✓ Complete |
|
||||
| **Start Screen** | Full-screen p5.js panel with img_start.png background | ✓ Complete |
|
||||
| **Game-Over Screen** | Full-screen overlay with job application image + particles | ✓ Complete |
|
||||
| **Visual Warnings** | Red pulsing timer + clock sound when time < 10 seconds | ✓ Complete |
|
||||
| **Camera Modes** | First-person (WASD + mouse) and overhead (overview) | ✓ Complete |
|
||||
| **Responsive Layout** | Full-screen canvas with bottom-overlay debug controls | ✓ Complete |
|
||||
|
||||
### 🔧 Technical Highlights
|
||||
|
||||
**Browser Compatibility:**
|
||||
- Audio context requires user interaction priming via `primeSfx()`—automatically triggered on first W/A/S/R key press
|
||||
|
||||
**Performance Optimizations:**
|
||||
- Vite's ES6 module bundling and tree-shaking
|
||||
- Asset optimization (textures, audio)
|
||||
- Efficient raycasting for chest targeting (not per-pixel)
|
||||
- Single draw call per maze (not per cell)
|
||||
|
||||
**Code Quality Patterns:**
|
||||
- **Shared State Pattern:** `window.mazeGameState` prevents data coupling across modules
|
||||
- **Factory Pattern:** `generateLevel()` creates and caches mesh instances
|
||||
- **Observer Pattern:** `registerBeforeRender()` for frame-synchronized updates
|
||||
- **Async/Await:** p5.js async image loading for non-blocking resource loading
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Limitations
|
||||
|
||||
### ⚠️ Known Behavior
|
||||
1. **Chest Memory:** Once a chest is opened, its visual state persists for the remainder of the game session (not reset per level)
|
||||
- *Workaround:* Intentional game design—players learn chest locations
|
||||
2. **Pointer Lock Exit:** Pressing ESC exits pointer lock but doesn't pause gameplay
|
||||
- *Workaround:* Intentional—players can exit anytime (no pause feature)
|
||||
3. **Chunk Size Warning:** Built JavaScript is ~9.1 MB due to image assets (job application image is 3MB)
|
||||
- *Workaround:* Not an issue for local play; consider asset compression for production
|
||||
|
||||
### ⭐ Special Features to Note
|
||||
- **Seeded Randomization:** Players can use the same seed to replay identical mazes (debug button: "Randomize seed")
|
||||
- **Low-Time Audio Feedback:** Clock sound triggers once when time drops below 10 seconds (prevents spam)
|
||||
- **Full-Screen Transitions:** Start screen, gameplay, and game-over have full-screen p5.js overlays for immersive presentation
|
||||
- **Difficulty Scaling Formula:** Mathematically designed to keep progression challenging but fair
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Rationale
|
||||
|
||||
### 1. Separate State Module
|
||||
**Decision:** Isolate game state in `game/state.js` rather than scatter variables globally
|
||||
|
||||
**Rationale:**
|
||||
- Prevents tight coupling between UI, physics, and rendering systems
|
||||
- Enables hot-reloading during development
|
||||
- Simplifies debugging (single place to inspect game state)
|
||||
|
||||
### 2. 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
|
||||
|
||||
### 3. 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
|
||||
|
||||
### 4. 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.)
|
||||
|
||||
---
|
||||
|
||||
## Help from AI & Resources
|
||||
|
||||
### AI Assistance
|
||||
- **GitHub Copilot:** Used for code structure review and refactoring suggestions
|
||||
- Suggested separating static maze data from dynamic game state (instead of coupling both in a single 2D array)
|
||||
- Helped organize modules into logical file structure
|
||||
- Reviewed p5.js particle physics for correctness
|
||||
|
||||
### Resources & Documentation
|
||||
- **Babylon.js Playground:** Reference for collision detection and camera control
|
||||
- **p5.js Documentation:** Async setup pattern for p5.js 2.0+ (no `preload()`)
|
||||
- **MDN Web Audio API:** Context priming for cross-browser audio compatibility
|
||||
- **Vite Documentation:** Asset bundling and import resolution
|
||||
|
||||
### Game Design Inspiration
|
||||
- *Portal series:* Puzzle-based spatial reasoning and exit goals
|
||||
- *Deus Ex:* Multiple approaches to objectives (fast routing vs. exploration)
|
||||
- *Pac-Man:* Time pressure and progressive difficulty
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Untitled Maze Game** demonstrates:
|
||||
- ✅ Professional code organization (modular, well-separated concerns)
|
||||
- ✅ Advanced 3D graphics programming (procedural generation, collision detection, camera control)
|
||||
- ✅ Full-featured game loop with state management
|
||||
- ✅ Polish and presentation (particle effects, sound design, responsive UI)
|
||||
- ✅ Scalability (difficulty scaling formula, asset management)
|
||||
|
||||
The codebase prioritizes **readability** and **maintainability** through modular design, clear naming conventions, and comprehensive documentation. Each file has a single responsibility, making it easy for collaborators or reviewers to understand and extend the code.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -17,22 +24,24 @@ canvas {
|
||||
|
||||
.canvas-stage {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.maze-layout {
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 12px; /* use padding instead of margin to avoid adding to viewport height */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.maze-layout > .panel:first-child {
|
||||
margin: 12px;
|
||||
margin: 0;
|
||||
border-radius: 16px;
|
||||
height: calc(100vh - 24px);
|
||||
height: calc(100vh - 24px); /* accounts for container padding */
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -54,7 +63,8 @@ canvas {
|
||||
|
||||
#renderCanvas {
|
||||
width: 100%;
|
||||
height: min(76vh, 820px);
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvas-hud {
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<body class="maze-page">
|
||||
<main class="maze-layout">
|
||||
<section class="panel">
|
||||
<div class="panel-label">UNTITLED MAZE GAME</div>
|
||||
<div class="canvas-stage">
|
||||
<canvas id="renderCanvas"></canvas>
|
||||
<div class="canvas-hud">
|
||||
|
||||
@@ -62,11 +62,34 @@ let lowTimeAlertPlayed = false;
|
||||
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
||||
scene.collisionsEnabled = true;
|
||||
|
||||
function requestPointerLockSafely() {
|
||||
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") {
|
||||
sharedState.runtime.message = "Click once more to re-enable mouse look.";
|
||||
return;
|
||||
}
|
||||
console.warn(error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.name === "SecurityError") {
|
||||
sharedState.runtime.message = "Click once more to re-enable mouse look.";
|
||||
return;
|
||||
}
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
if (cameraMode === "fp" && document.pointerLockElement !== canvas) {
|
||||
canvas.requestPointerLock();
|
||||
}
|
||||
requestPointerLockSafely();
|
||||
});
|
||||
|
||||
function updateOverviewCameraForMaze(w, h) {
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
import * as BABYLON from "babylonjs";
|
||||
import { sharedState } from "./game/state.js";
|
||||
import { seededRng, generateMazeGrid, findDeadEnds } from "./game/maze.js";
|
||||
import { gridCellToWorld, isWalkableCell } from "./game/grid.js";
|
||||
import { playSfx, primeSfx } from "./game/sfx.js";
|
||||
import chestTextureUrl from "../img/img_chest.png";
|
||||
import wallTextureUrl from "../img/img_wall.png";
|
||||
import groundTextureUrl from "../img/img_ground.png";
|
||||
import doorTextureUrl from "../img/img_door.png";
|
||||
import gameOverImageUrl from "../img/img_jobapplication.png";
|
||||
|
||||
// Initialize Babylon.js engine and scene
|
||||
const canvas = document.getElementById("renderCanvas");
|
||||
const engine = new BABYLON.Engine(canvas, true);
|
||||
const canvasTime = document.getElementById("canvas-time");
|
||||
const canvasKey = document.getElementById("canvas-key");
|
||||
const canvasRounds = document.getElementById("canvas-rounds");
|
||||
const gameOverOverlay = document.getElementById("game-over-overlay");
|
||||
const gameOverImage = document.getElementById("game-over-image");
|
||||
if (gameOverImage) {
|
||||
gameOverImage.src = gameOverImageUrl;
|
||||
}
|
||||
|
||||
const scene = new BABYLON.Scene(engine);
|
||||
scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1);
|
||||
|
||||
const camera = new BABYLON.UniversalCamera("cam", new BABYLON.Vector3(0, 1.6, 0), scene);
|
||||
camera.minZ = 0.1;
|
||||
camera.speed = 1.12;
|
||||
camera.angularSensibility = 1000;
|
||||
camera.inertia = 0.6;
|
||||
camera.keysUp = [87]; // W
|
||||
camera.keysDown = [83]; // S
|
||||
camera.keysLeft = [65]; // A
|
||||
camera.keysRight = [68]; // D
|
||||
camera.checkCollisions = true;
|
||||
camera.applyGravity = true;
|
||||
camera.ellipsoid = new BABYLON.Vector3(0.35, 0.9, 0.35);
|
||||
camera.attachControl(canvas, true);
|
||||
|
||||
const overviewCamera = new BABYLON.ArcRotateCamera(
|
||||
"overviewCam",
|
||||
-Math.PI / 2,
|
||||
Math.PI / 3.2,
|
||||
40,
|
||||
new BABYLON.Vector3(0, 0, 0),
|
||||
scene,
|
||||
);
|
||||
overviewCamera.lowerBetaLimit = 0.2;
|
||||
overviewCamera.upperBetaLimit = Math.PI / 2.05;
|
||||
overviewCamera.lowerRadiusLimit = 8;
|
||||
overviewCamera.inertia = 0.7;
|
||||
|
||||
let cameraMode = "fp";
|
||||
scene.activeCamera = camera;
|
||||
let lastFootstepPosition = null;
|
||||
let footstepAccumulator = 0;
|
||||
let footstepElapsed = 0;
|
||||
let gameOverActive = false;
|
||||
let lowTimeAlertPlayed = false;
|
||||
|
||||
scene.gravity = new BABYLON.Vector3(0, -0.2, 0);
|
||||
scene.collisionsEnabled = true;
|
||||
|
||||
canvas.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
if (cameraMode === "fp" && document.pointerLockElement !== canvas) {
|
||||
canvas.requestPointerLock();
|
||||
}
|
||||
});
|
||||
|
||||
function updateOverviewCameraForMaze(w, h) {
|
||||
const mazeSpan = Math.max(w, h) * cellSize;
|
||||
overviewCamera.radius = Math.max(mazeSpan * 1.05, 16);
|
||||
overviewCamera.target = new BABYLON.Vector3(0, 0, 0);
|
||||
}
|
||||
|
||||
function showGameOverScreen() {
|
||||
gameOverActive = true;
|
||||
if (gameOverOverlay) {
|
||||
gameOverOverlay.hidden = false;
|
||||
}
|
||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
}
|
||||
|
||||
function hideGameOverScreen() {
|
||||
gameOverActive = false;
|
||||
if (gameOverOverlay) {
|
||||
gameOverOverlay.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function restartRunFromGameOver() {
|
||||
sharedState.runtime.runActive = true;
|
||||
sharedState.runtime.hasKey = false;
|
||||
sharedState.runtime.roundsCompleted = 0;
|
||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||
sharedState.runtime.message = "Run restarted.";
|
||||
sharedState.config.level = 1;
|
||||
hideGameOverScreen();
|
||||
generateLevel();
|
||||
}
|
||||
|
||||
function switchCameraMode() {
|
||||
if (cameraMode === "fp") {
|
||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
camera.detachControl(canvas);
|
||||
overviewCamera.attachControl(canvas, true);
|
||||
scene.activeCamera = overviewCamera;
|
||||
cameraMode = "overview";
|
||||
sharedState.runtime.message = "Overview camera (press V to return to first-person).";
|
||||
return;
|
||||
}
|
||||
|
||||
overviewCamera.detachControl(canvas);
|
||||
camera.attachControl(canvas, true);
|
||||
scene.activeCamera = camera;
|
||||
cameraMode = "fp";
|
||||
sharedState.runtime.message = "First-person camera (W/A/S/D + mouse).";
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyD" || event.code === "KeyV" || event.code === "KeyR") {
|
||||
primeSfx();
|
||||
}
|
||||
if (event.code === "KeyR" && gameOverActive) {
|
||||
restartRunFromGameOver();
|
||||
return;
|
||||
}
|
||||
if (event.code === "KeyV") {
|
||||
switchCameraMode();
|
||||
}
|
||||
});
|
||||
|
||||
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
||||
|
||||
// Central sphere (hidden but kept for reference)
|
||||
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
|
||||
sphere.isVisible = false;
|
||||
const sphereMaterial = new BABYLON.StandardMaterial("sphereMaterial", scene);
|
||||
sphereMaterial.diffuseColor = new BABYLON.Color3(0.2, 0.55, 0.95);
|
||||
sphereMaterial.emissiveColor = new BABYLON.Color3(0.05, 0.12, 0.2);
|
||||
sphere.material = sphereMaterial;
|
||||
|
||||
// Main render loop
|
||||
engine.runRenderLoop(() => {
|
||||
const level = sharedState.config.level;
|
||||
sphere.rotation.y += 0.01;
|
||||
sphere.scaling.x = 1 + (level - 1) * 0.05;
|
||||
sphere.scaling.z = 1 + (level - 1) * 0.05;
|
||||
if (canvasTime) {
|
||||
canvasTime.textContent = `${sharedState.runtime.elapsedSeconds.toFixed(1)}s`;
|
||||
}
|
||||
if (canvasKey) {
|
||||
canvasKey.textContent = sharedState.runtime.hasKey ? "yes" : "no";
|
||||
}
|
||||
if (canvasRounds) {
|
||||
canvasRounds.textContent = String(sharedState.runtime.roundsCompleted);
|
||||
}
|
||||
scene.render();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
engine.resize();
|
||||
});
|
||||
|
||||
// Maze data structures
|
||||
let levelMeshes = [];
|
||||
let chestMap = new Map(); // key: "x,y" -> {mesh, opened}
|
||||
let keyChestKey = null;
|
||||
let exitBox = null;
|
||||
let exitGridPos = null; // track exit grid position for collision checking
|
||||
let spawnGridPos = null; // track spawn grid position for validation
|
||||
let spawnMarker = null;
|
||||
let highlightedChest = null;
|
||||
const cellSize = 2;
|
||||
const ROUND_TIME_SECONDS = 60;
|
||||
|
||||
function setChestHighlight(mesh) {
|
||||
if (highlightedChest === mesh) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (highlightedChest) {
|
||||
highlightedChest.renderOutline = false;
|
||||
}
|
||||
|
||||
highlightedChest = mesh;
|
||||
|
||||
if (highlightedChest) {
|
||||
highlightedChest.outlineColor = new BABYLON.Color3(0.85, 0.85, 0.85);
|
||||
highlightedChest.outlineWidth = 0.08;
|
||||
highlightedChest.renderOutline = true;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLevelMeshes() {
|
||||
setChestHighlight(null);
|
||||
for (const m of levelMeshes) {
|
||||
try { m.dispose(); } catch(e) {}
|
||||
}
|
||||
levelMeshes = [];
|
||||
chestMap.clear();
|
||||
keyChestKey = null;
|
||||
if (exitBox) { try { exitBox.dispose(); } catch(e){}; exitBox = null; }
|
||||
if (spawnMarker) { try { spawnMarker.dispose(); } catch(e){}; spawnMarker = null; }
|
||||
exitGridPos = null;
|
||||
spawnGridPos = null;
|
||||
}
|
||||
|
||||
function isReservedCell(x, y) {
|
||||
if (chestMap.has(`${x},${y}`)) return true;
|
||||
if (exitGridPos && exitGridPos.x === x && exitGridPos.y === y) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildLevelFromGrid(grid) {
|
||||
clearLevelMeshes();
|
||||
const h = grid.length;
|
||||
const w = grid[0].length;
|
||||
const halfW = (w * cellSize) / 2;
|
||||
const halfH = (h * cellSize) / 2;
|
||||
|
||||
const floor = BABYLON.MeshBuilder.CreateGround('levelGround', { width: w*cellSize, height: h*cellSize }, scene);
|
||||
floor.position = new BABYLON.Vector3(0, 0, 0);
|
||||
floor.checkCollisions = true;
|
||||
const fm = new BABYLON.StandardMaterial('floorMat', scene);
|
||||
fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
||||
fm.diffuseTexture = new BABYLON.Texture(groundTextureUrl, scene);
|
||||
fm.diffuseTexture.uScale = Math.max(1, Math.floor(w / 2));
|
||||
fm.diffuseTexture.vScale = Math.max(1, Math.floor(h / 2));
|
||||
fm.diffuseColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
||||
floor.material = fm;
|
||||
levelMeshes.push(floor);
|
||||
|
||||
const wallMat = new BABYLON.StandardMaterial('wallMat', scene);
|
||||
wallMat.diffuseTexture = new BABYLON.Texture(wallTextureUrl, scene);
|
||||
wallMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (grid[y][x] === 1) {
|
||||
const box = BABYLON.MeshBuilder.CreateBox(`wall_${x}_${y}`, { size: cellSize }, scene);
|
||||
box.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize/2, y*cellSize - halfH + cellSize/2);
|
||||
box.material = wallMat;
|
||||
box.checkCollisions = true;
|
||||
levelMeshes.push(box);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeChestsOnDeadEnds(grid, deadEnds, minCount, seed) {
|
||||
const rng = seededRng(seed);
|
||||
for (let i = deadEnds.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i+1));
|
||||
[deadEnds[i], deadEnds[j]] = [deadEnds[j], deadEnds[i]];
|
||||
}
|
||||
const chosen = deadEnds.slice(0, Math.min(minCount, deadEnds.length));
|
||||
const halfW = (grid[0].length * cellSize) / 2;
|
||||
const halfH = (grid.length * cellSize) / 2;
|
||||
|
||||
const chestMat = new BABYLON.StandardMaterial('chestMat', scene);
|
||||
chestMat.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
||||
chestMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
|
||||
for (const [x,y] of chosen) {
|
||||
const c = BABYLON.MeshBuilder.CreateBox(`chest_${x}_${y}`, { width: cellSize*0.8, height: cellSize*0.6, depth: cellSize*0.6 }, scene);
|
||||
c.position = new BABYLON.Vector3(x*cellSize - halfW + cellSize/2, cellSize*0.3, y*cellSize - halfH + cellSize/2);
|
||||
c.material = chestMat;
|
||||
c.isPickable = true;
|
||||
levelMeshes.push(c);
|
||||
chestMap.set(`${x},${y}`, { mesh: c, opened: false });
|
||||
}
|
||||
|
||||
if (chosen.length > 0) {
|
||||
const k = Math.floor(rng() * chosen.length);
|
||||
const [kx, ky] = chosen[k];
|
||||
keyChestKey = `${kx},${ky}`;
|
||||
const entry = chestMap.get(keyChestKey);
|
||||
if (entry) {
|
||||
const km = new BABYLON.StandardMaterial('keyChestMat', scene);
|
||||
km.diffuseTexture = new BABYLON.Texture(chestTextureUrl, scene);
|
||||
km.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
km.emissiveColor = new BABYLON.Color3(0.3, 0.22, 0.02);
|
||||
entry.mesh.material = km;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeExit(grid, seed) {
|
||||
const dead = findDeadEnds(grid);
|
||||
const rng = seededRng(seed+1);
|
||||
if (dead.length === 0) return;
|
||||
|
||||
// Filter out dead ends that have chests
|
||||
const availableDead = dead.filter(([x, y]) => !chestMap.has(`${x},${y}`));
|
||||
if (availableDead.length === 0) {
|
||||
// Fallback: use any dead end if no chest-free spot available
|
||||
// This shouldn't happen in normal cases
|
||||
const idx = Math.floor(rng() * dead.length);
|
||||
const [x,y] = dead[idx];
|
||||
exitGridPos = { x, y };
|
||||
// Continue below
|
||||
} else {
|
||||
const idx = Math.floor(rng() * availableDead.length);
|
||||
const [x,y] = availableDead[idx];
|
||||
exitGridPos = { x, y };
|
||||
}
|
||||
|
||||
const [x,y] = [exitGridPos.x, exitGridPos.y];
|
||||
if (!isWalkableCell(grid, x, y)) {
|
||||
console.warn("Exit selected on non-walkable cell", { x, y });
|
||||
return;
|
||||
}
|
||||
const exitWorld = gridCellToWorld(grid, x, y, cellSize);
|
||||
const ex = exitWorld.x;
|
||||
const ez = exitWorld.z;
|
||||
|
||||
const plane = BABYLON.MeshBuilder.CreatePlane('exitDoor', {
|
||||
width: cellSize * 1.35,
|
||||
height: cellSize * 1.85,
|
||||
sideOrientation: BABYLON.Mesh.DOUBLESIDE,
|
||||
}, scene);
|
||||
const exitMat = new BABYLON.StandardMaterial('exitMat', scene);
|
||||
exitMat.diffuseTexture = new BABYLON.Texture(doorTextureUrl, scene);
|
||||
exitMat.diffuseColor = new BABYLON.Color3(0.95, 0.95, 0.95);
|
||||
exitMat.emissiveColor = new BABYLON.Color3(0.07, 0.07, 0.07);
|
||||
plane.material = exitMat;
|
||||
plane.position = new BABYLON.Vector3(ex, cellSize * 0.92, ez);
|
||||
plane.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y;
|
||||
exitBox = plane;
|
||||
levelMeshes.push(plane);
|
||||
}
|
||||
|
||||
function spawnCameraAt(grid) {
|
||||
const h = grid.length;
|
||||
const w = grid[0].length;
|
||||
let bestCell = null;
|
||||
let bestDist = -1;
|
||||
|
||||
// Choose a valid spawn that is far from exit when possible.
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
if (!isWalkableCell(grid, x, y)) continue;
|
||||
if (isReservedCell(x, y)) continue;
|
||||
|
||||
const d = exitGridPos ? Math.hypot(x - exitGridPos.x, y - exitGridPos.y) : 0;
|
||||
if (d > bestDist) {
|
||||
bestDist = d;
|
||||
bestCell = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestCell) {
|
||||
console.warn("No valid spawn cell found.");
|
||||
return;
|
||||
}
|
||||
|
||||
spawnGridPos = bestCell;
|
||||
const spawnWorld = gridCellToWorld(grid, bestCell.x, bestCell.y, cellSize);
|
||||
const px = spawnWorld.x;
|
||||
const pz = spawnWorld.z;
|
||||
|
||||
try {
|
||||
if (camera && camera.position) {
|
||||
camera.position = new BABYLON.Vector3(px, 1.6, pz);
|
||||
}
|
||||
} catch (e) {}
|
||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : new BABYLON.Vector3(px, 1.6, pz);
|
||||
footstepAccumulator = 0;
|
||||
footstepElapsed = 0;
|
||||
}
|
||||
|
||||
function generateLevel() {
|
||||
hideGameOverScreen();
|
||||
lowTimeAlertPlayed = false;
|
||||
if (canvasTime) {
|
||||
canvasTime.classList.remove("low-time");
|
||||
}
|
||||
const cfg = sharedState.config;
|
||||
const seed = cfg.seed;
|
||||
const roundScale = Math.max(0, cfg.level - 1);
|
||||
const w = Math.max(9, cfg.mazeWidth + roundScale * 2);
|
||||
const h = Math.max(9, cfg.mazeHeight + roundScale * 2);
|
||||
const chestCount = Math.max(1, cfg.minChestDeadEnds + roundScale);
|
||||
const grid = generateMazeGrid(w, h, seed + cfg.level);
|
||||
updateOverviewCameraForMaze(w, h);
|
||||
const dead = findDeadEnds(grid);
|
||||
buildLevelFromGrid(grid);
|
||||
placeChestsOnDeadEnds(grid, dead, chestCount, seed + cfg.level);
|
||||
placeExit(grid, seed + cfg.level);
|
||||
spawnCameraAt(grid);
|
||||
|
||||
const placementValid =
|
||||
!!exitGridPos &&
|
||||
!!spawnGridPos &&
|
||||
isWalkableCell(grid, exitGridPos.x, exitGridPos.y) &&
|
||||
isWalkableCell(grid, spawnGridPos.x, spawnGridPos.y) &&
|
||||
!(exitGridPos.x === spawnGridPos.x && exitGridPos.y === spawnGridPos.y);
|
||||
|
||||
if (!placementValid) {
|
||||
sharedState.runtime.message = `Placement warning: spawn/exit invalid on level ${cfg.level}.`;
|
||||
console.warn("Invalid spawn/exit placement", { exitGridPos, spawnGridPos });
|
||||
} else {
|
||||
sharedState.runtime.message = `Level ${cfg.level} generated (spawn ${spawnGridPos.x},${spawnGridPos.y} / exit ${exitGridPos.x},${exitGridPos.y}).`;
|
||||
}
|
||||
lastFootstepPosition = camera && camera.position ? camera.position.clone() : lastFootstepPosition;
|
||||
footstepAccumulator = 0;
|
||||
footstepElapsed = 0;
|
||||
window.requestAnimationFrame(()=>{ /* let scene update */ });
|
||||
}
|
||||
|
||||
// Expose API for p5 to call
|
||||
window.mazeGameApi = { generateLevel };
|
||||
|
||||
// Pointer interaction for chests
|
||||
scene.onPointerObservable.add((pi) => {
|
||||
if (pi.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;
|
||||
if (!sharedState.runtime.runActive || gameOverActive) return;
|
||||
const pick = scene.pick(scene.pointerX, scene.pointerY);
|
||||
if (!pick || !pick.hit || !pick.pickedMesh) return;
|
||||
const m = pick.pickedMesh;
|
||||
if (!m.name.startsWith('chest_')) return;
|
||||
const coords = m.name.split('_').slice(1).join(',');
|
||||
const entry = chestMap.get(coords);
|
||||
if (!entry) return;
|
||||
if (entry.opened) {
|
||||
primeSfx();
|
||||
playSfx("chestClose", 0.8);
|
||||
playSfx("lose", 0.85);
|
||||
sharedState.runtime.runActive = false;
|
||||
sharedState.runtime.message = 'Opened chest again — game over.';
|
||||
showGameOverScreen();
|
||||
return;
|
||||
}
|
||||
primeSfx();
|
||||
playSfx("chestOpen", 0.8);
|
||||
entry.opened = true;
|
||||
if (coords === keyChestKey) {
|
||||
sharedState.runtime.hasKey = true;
|
||||
playSfx("key", 0.85);
|
||||
sharedState.runtime.message = 'You found the key! Find the exit.';
|
||||
} else {
|
||||
sharedState.runtime.message = 'This chest was empty.';
|
||||
}
|
||||
});
|
||||
|
||||
// Level transition check
|
||||
scene.registerBeforeRender(() => {
|
||||
const targetRay = camera.getForwardRay(cellSize * 3.5);
|
||||
const targetPick = scene.pickWithRay(targetRay, (mesh) => mesh.name.startsWith('chest_'));
|
||||
if (targetPick && targetPick.hit && targetPick.pickedMesh) {
|
||||
setChestHighlight(targetPick.pickedMesh);
|
||||
} else {
|
||||
setChestHighlight(null);
|
||||
}
|
||||
|
||||
if (sharedState.runtime.runActive) {
|
||||
const dt = engine.getDeltaTime() / 1000;
|
||||
sharedState.runtime.elapsedSeconds = Math.max(0, sharedState.runtime.elapsedSeconds - dt);
|
||||
|
||||
const isLowTime = sharedState.runtime.elapsedSeconds < 10;
|
||||
if (isLowTime && !lowTimeAlertPlayed) {
|
||||
lowTimeAlertPlayed = true;
|
||||
playSfx("clock", 0.75);
|
||||
if (canvasTime) {
|
||||
canvasTime.classList.add("low-time");
|
||||
}
|
||||
}
|
||||
if (!isLowTime && lowTimeAlertPlayed) {
|
||||
lowTimeAlertPlayed = false;
|
||||
if (canvasTime) {
|
||||
canvasTime.classList.remove("low-time");
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedState.runtime.elapsedSeconds <= 0) {
|
||||
sharedState.runtime.runActive = false;
|
||||
sharedState.runtime.message = "Time up — game over.";
|
||||
playSfx("lose", 0.85);
|
||||
showGameOverScreen();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (sharedState.runtime.runActive && cameraMode === "fp" && camera && camera.position && document.pointerLockElement === canvas) {
|
||||
const currentPosition = camera.position;
|
||||
if (!lastFootstepPosition) {
|
||||
lastFootstepPosition = currentPosition.clone();
|
||||
}
|
||||
const horizontalDistance = Math.hypot(
|
||||
currentPosition.x - lastFootstepPosition.x,
|
||||
currentPosition.z - lastFootstepPosition.z,
|
||||
);
|
||||
footstepAccumulator += horizontalDistance;
|
||||
footstepElapsed += engine.getDeltaTime();
|
||||
if (footstepAccumulator > 0.75 && footstepElapsed > 220) {
|
||||
playSfx("step", 0.65);
|
||||
footstepAccumulator = 0;
|
||||
footstepElapsed = 0;
|
||||
lastFootstepPosition = currentPosition.clone();
|
||||
}
|
||||
}
|
||||
if (sharedState.runtime.hasKey && exitBox && camera && camera.position) {
|
||||
const pos = camera.position;
|
||||
const ex = exitBox.position.x, ez = exitBox.position.z;
|
||||
const dist = Math.hypot(pos.x - ex, pos.z - ez);
|
||||
if (dist < cellSize * 0.9) {
|
||||
playSfx("win", 0.85);
|
||||
sharedState.config.level += 1;
|
||||
sharedState.runtime.hasKey = false;
|
||||
sharedState.runtime.roundsCompleted += 1;
|
||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||
sharedState.runtime.message = `Level ${sharedState.config.level} starting.`;
|
||||
generateLevel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export shared state for p5 to use
|
||||
export { sharedState };
|
||||
Reference in New Issue
Block a user