Compare commits
10 Commits
33b38a3b4d
...
b95438dfd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b95438dfd0 | ||
|
|
b88f47c70d | ||
|
|
808b2545e2 | ||
|
|
c28a1d1f6a | ||
|
|
9b68630764 | ||
|
|
7e2d6243b2 | ||
|
|
021877902a | ||
|
|
47d4ba8bfe | ||
|
|
176ad34214 | ||
|
|
4bb46115ca |
212
PROPOSAL_20240905.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Untitled Maze Game - ID30011 Midterm Project Proposal (Revised)
|
||||
|
||||
- **Name:** Bumgyu Suh
|
||||
- **Student ID:** 20240905
|
||||
- **Repository URL:** https://git.prototyping.id/20240905/homework5
|
||||
- **Engine:** Babylon.js
|
||||
|
||||
## 1. Project Summary
|
||||
|
||||
Untitled Maze Game is a 3D first-person dungeon maze game. Each level generates a new maze, and the player must find the key hidden in one chest, then reach the exit area to progress.
|
||||
|
||||
Main twist:
|
||||
- Opening a chest that has already been opened in the current level causes immediate game over.
|
||||
- This memory pressure creates the core challenge.
|
||||
|
||||
Scoring tracked for now:
|
||||
- Total time spent
|
||||
- Progress (level reached)
|
||||
|
||||
## 2. Finalized Design Decisions
|
||||
|
||||
These are locked decisions for implementation:
|
||||
|
||||
1. Chest reset scope:
|
||||
- Opened chest state resets every level.
|
||||
|
||||
2. Chest interaction:
|
||||
- Player must left click while targeting a chest.
|
||||
- Interaction prompt shown: "Click to open chest" when chest is targetable.
|
||||
|
||||
3. Maze topology:
|
||||
- Maze is allowed to contain loops (not necessarily a perfect maze).
|
||||
- Each level must guarantee a minimum number of dead-end cells with chests.
|
||||
- Additional dead-ends without chests are allowed.
|
||||
|
||||
4. Difficulty scaling:
|
||||
- Increase maze width and height by level.
|
||||
- Lighting settings stay fixed (no light-based difficulty scaling).
|
||||
|
||||
5. Win condition:
|
||||
- Exit is a highlighted zone/area.
|
||||
- Entering exit zone with key automatically transitions to next level.
|
||||
|
||||
6. Visual implementation order:
|
||||
- Start with primitive meshes for all gameplay-critical elements.
|
||||
|
||||
## 3. Gameplay Rules
|
||||
|
||||
## Objective
|
||||
- Search chests to find the key.
|
||||
- Avoid reopening previously opened chests in that level.
|
||||
- Reach exit area after obtaining key.
|
||||
- Clear levels as fast as possible.
|
||||
|
||||
## Win / Lose Conditions
|
||||
- Win level: player enters exit zone while holding key.
|
||||
- Soft block: entering exit without key shows message and does not transition.
|
||||
- Lose run: player opens a chest already opened in current level.
|
||||
|
||||
## Player Controls
|
||||
- `W/A/S/D`: movement
|
||||
- Mouse: look direction
|
||||
- Left click: open chest when targeted
|
||||
|
||||
## 4. Technical Architecture Plan
|
||||
|
||||
To keep implementation clean, split into four layers:
|
||||
|
||||
1. Maze Logic Layer (pure functions)
|
||||
- Generate grid layout
|
||||
- Detect dead-ends
|
||||
- Place chests, key chest, exit, and spawn
|
||||
- Return plain data only
|
||||
|
||||
2. Game State Layer
|
||||
- Track current level
|
||||
- Track timer / elapsed time
|
||||
- Track hasKey
|
||||
- Track chest open state (per level)
|
||||
- Resolve win/lose transitions
|
||||
|
||||
3. Babylon Scene Layer
|
||||
- Build meshes from grid
|
||||
- Configure first-person camera and collisions
|
||||
- Handle raycast/pick chest targeting and click interaction
|
||||
- Render highlighted exit zone
|
||||
|
||||
4. UI Layer
|
||||
- HUD: level, time, key possession, prompt/status text
|
||||
- Messages: wrong chest, key found, find key first, game over, level clear
|
||||
|
||||
## 5. Data Model (Revised)
|
||||
|
||||
Use separate static and dynamic state instead of overloading one numeric ID.
|
||||
|
||||
## Static cell data
|
||||
- `cellType`: wall | path | chest | exit | spawn
|
||||
|
||||
## Dynamic state
|
||||
- `openedChests`: set/map keyed by cell coordinate
|
||||
- `keyChestCoord`: coordinate of the one correct chest
|
||||
- `hasKey`: boolean
|
||||
|
||||
Benefits:
|
||||
- Cleaner logic
|
||||
- Easier debugging
|
||||
- Lower chance of state bugs from mixed meanings
|
||||
|
||||
## 6. Level Generation Specification
|
||||
|
||||
Per level:
|
||||
|
||||
1. Compute maze dimensions from level number.
|
||||
2. Generate a solvable maze grid (loops allowed).
|
||||
3. Find dead-end candidates.
|
||||
4. Place at least `minChestDeadEnds` chests on dead-ends.
|
||||
5. Choose one chest as key chest.
|
||||
6. Choose one exit location (highlighted zone).
|
||||
7. Choose valid spawn point.
|
||||
8. Validate reachability:
|
||||
- Spawn can reach key chest
|
||||
- Spawn (after key) can reach exit
|
||||
|
||||
If validation fails, regenerate.
|
||||
|
||||
## 7. Babylon.js Implementation Notes
|
||||
|
||||
- Camera: use first-person style camera (for example `UniversalCamera`) with pointer lock.
|
||||
- Collision: enable gravity/collision with wall meshes.
|
||||
- Interaction: center-screen ray pick + left click.
|
||||
- Chest meshes: primitive boxes in MVP.
|
||||
- Exit zone: highlighted plane or emissive ground area.
|
||||
- Keep one scene; rebuild level meshes on level transition.
|
||||
|
||||
## 8. MVP Checklist (Implementation Order)
|
||||
|
||||
## Phase A - Core scaffold
|
||||
- [ ] Refactor current Babylon template into modular files/functions.
|
||||
- [ ] Add first-person camera controls and pointer lock.
|
||||
- [ ] Add movement collision against wall meshes.
|
||||
|
||||
## Phase B - Maze system
|
||||
- [ ] Implement maze generation with loops allowed.
|
||||
- [ ] Implement dead-end detection.
|
||||
- [ ] Implement chest placement with minimum dead-end chest count.
|
||||
- [ ] Implement key chest assignment and exit placement.
|
||||
- [ ] Implement reachability validation and regenerate on failure.
|
||||
|
||||
## Phase C - Gameplay rules
|
||||
- [ ] Implement per-level chest open tracking.
|
||||
- [ ] Implement chest click interaction and "Click to open chest" prompt.
|
||||
- [ ] Implement outcomes: wrong chest / key found / reopened chest game over.
|
||||
- [ ] Implement exit-zone behavior: block without key, auto-next-level with key.
|
||||
|
||||
## Phase D - UI and scoring
|
||||
- [ ] Display level number.
|
||||
- [ ] Display elapsed total time.
|
||||
- [ ] Display key possession status.
|
||||
- [ ] Display gameplay feedback messages.
|
||||
|
||||
## Phase E - Level progression
|
||||
- [ ] Increase maze width/height per level.
|
||||
- [ ] Reset per-level state correctly on transition.
|
||||
- [ ] Preserve run-level state (time, current level progression).
|
||||
|
||||
## 9. Testing Checklist
|
||||
|
||||
- [ ] Reopening an opened chest always ends run.
|
||||
- [ ] Chest-open state resets at new level.
|
||||
- [ ] Entering exit without key never transitions.
|
||||
- [ ] Entering exit with key always transitions.
|
||||
- [ ] Every generated level is solvable.
|
||||
- [ ] Minimum chest dead-end count is always satisfied.
|
||||
- [ ] Performance remains stable across first several levels.
|
||||
|
||||
## 10. Assets Plan
|
||||
|
||||
Immediate plan (MVP):
|
||||
- Use primitives for wall, floor, chest, and exit zone.
|
||||
- Use simple material colors to distinguish gameplay objects.
|
||||
|
||||
Prepare soon (after core loop works):
|
||||
- Tileable wall texture
|
||||
- Tileable floor texture
|
||||
- Chest texture/material
|
||||
- Basic SFX set:
|
||||
- chest open
|
||||
- wrong chest
|
||||
- key found
|
||||
- level clear
|
||||
- game over
|
||||
|
||||
Optional polish later:
|
||||
- Better chest model
|
||||
- Exit marker model
|
||||
- Ambient loop audio
|
||||
|
||||
## 11. Class Concept Coverage
|
||||
|
||||
This project uses:
|
||||
- JavaScript arrays and object state
|
||||
- Functional decomposition in generation pipeline
|
||||
- Event handling (keyboard/mouse)
|
||||
- External library usage (Babylon.js)
|
||||
- Real-time tracking (elapsed time HUD)
|
||||
|
||||
## 12. Scope Guardrails
|
||||
|
||||
To keep delivery reliable:
|
||||
- Prioritize clean gameplay loop over art polish.
|
||||
- Keep lighting simple and fixed.
|
||||
- Do not add non-essential mechanics before MVP checklist is complete.
|
||||
155
README.md
@@ -1,141 +1,34 @@
|
||||
# P5.js-vite Starter Template 🚀
|
||||
# Untitled Maze Game - ID30011 Midterm Project README
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
- **Name:** Bumgyu Suh
|
||||
- **Student ID:** 20240905
|
||||
- **Student Email:** bumgyu@kaist.ac.kr
|
||||
- **Repository URL:** https://git.prototyping.id/20240905/???
|
||||
- **Video URL:** youtube.com
|
||||
|
||||
[Vite](https://vitejs.dev/) starter template to scaffold a new [p5.js](https://p5js.org) project.
|
||||
## The Game
|
||||
A description of the game - how it works and what the user has to do
|
||||
|
||||
This is an unopinionated template; aside from P5.js and Vite, the rest of your project's tools are entirely up to you.
|
||||
WASD, V to switch view
|
||||
|
||||
## Live demo
|
||||
## 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
|
||||
|
||||
For a live demo please [visit this page](https://p5js-vite-demo.surge.sh).
|
||||
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.
|
||||
|
||||
## Installation
|
||||
## 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
|
||||
|
||||
Pull the template files with [degit](https://github.com/Rich-Harris/degit) and install dependencies.
|
||||
The game can be played through the multi_sketch.html
|
||||
"Start run" to generate first leve, etc, seed,
|
||||
left0click chests
|
||||
|
||||
```
|
||||
npx degit makinteract/p5js-vite my-project
|
||||
How well and complete is your documentation? Your documentation should contain the following information:
|
||||
|
||||
cd my-project
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Write a README.md file that contains the following information.
|
||||
|
||||
## npm scripts
|
||||
|
||||
- `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/)
|
||||
|
||||
Note that if after this last command you do not see anything, you can use instead this other command:
|
||||
|
||||
- `npm run preview --host` - You should then be able to see your files locally at port [5000](http://localhost:3000/)
|
||||
|
||||
## A single p5.js sketch
|
||||
|
||||
```js
|
||||
import '../css/style.css';
|
||||
import { sketch } from 'p5js-wrapper';
|
||||
|
||||
sketch.setup = function () {
|
||||
createCanvas(800, 600);
|
||||
};
|
||||
|
||||
sketch.draw = function () {
|
||||
background(127); // grey
|
||||
fill(255, 0, 0); // red
|
||||
noStroke();
|
||||
rectMode(CENTER);
|
||||
rect(width / 2, height / 2, 50, 50);
|
||||
};
|
||||
|
||||
sketch.mousePressed = function () {
|
||||
console.log(`I am here at ${mouseX}:${mouseY}`);
|
||||
};
|
||||
```
|
||||
|
||||
And here the body of the html file:
|
||||
|
||||
```html
|
||||
<body>
|
||||
<script type="module" src="/src/single_sketch.js"></script>
|
||||
</body>
|
||||
```
|
||||
|
||||
## Multiple p5.js sketches
|
||||
|
||||
If you want to use multiple sketches, you need to use a different syntax.
|
||||
|
||||
```js
|
||||
import '../css/style.css';
|
||||
import { p5 } from 'p5js-wrapper';
|
||||
|
||||
let sketch1 = new p5((p) => {
|
||||
p.setup = () => {
|
||||
const one = document.getElementById('one');
|
||||
p.createCanvas(one.clientWidth, one.clientHeight);
|
||||
};
|
||||
|
||||
p.draw = () => {
|
||||
p.background(100);
|
||||
};
|
||||
}, 'one');
|
||||
|
||||
// Sketch2
|
||||
let sketch2 = new p5((p) => {
|
||||
p.setup = () => {
|
||||
const two = document.getElementById('two');
|
||||
p.createCanvas(two.clientWidth, two.clientHeight);
|
||||
};
|
||||
|
||||
p.draw = () => {
|
||||
p.background(170);
|
||||
};
|
||||
}, 'two');
|
||||
```
|
||||
|
||||
This file is expecting two divs in the html file:
|
||||
|
||||
```html
|
||||
<body>
|
||||
<script type="module" src="/src/multi_sketch.js"></script>
|
||||
<div id="one"></div>
|
||||
<div id="two"></div>
|
||||
</body>
|
||||
```
|
||||
|
||||
## Adding sound
|
||||
|
||||
Sound is an [experimental feature](https://github.com/makinteract/p5js-wrapper/blob/main/README_SOUND.md).
|
||||
|
||||
Examples usage:
|
||||
|
||||
```js
|
||||
import { sketch } from 'p5js-wrapper';
|
||||
import 'p5js-wrapper/sound';
|
||||
|
||||
import mysound from './mysound.mp3';
|
||||
|
||||
let soundEffect;
|
||||
|
||||
sketch.setup = function () {
|
||||
createCanvas(100, 100);
|
||||
soundEffect = loadSound(mysound);
|
||||
};
|
||||
|
||||
sketch.draw = function () {
|
||||
background('#eeeeee');
|
||||
};
|
||||
|
||||
// Play sound on click
|
||||
sketch.mousePressed = function () {
|
||||
soundEffect.play();
|
||||
};
|
||||
```
|
||||
|
||||
This example assumes you have a file _mysound.mp3_ in the _src_ folder.
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the [MIT License](LICENSE).
|
||||
|
||||
34
ZZREADME.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Untitled Maze Game - ID30011 Midterm Project README
|
||||
|
||||
- **Name:** Bumgyu Suh
|
||||
- **Student ID:** 20240905
|
||||
- **Student Email:** bumgyu@kaist.ac.kr
|
||||
- **Repository URL:** https://git.prototyping.id/20240905/???
|
||||
- **Video URL:** youtube.com
|
||||
|
||||
## The Game
|
||||
A description of the game - how it works and what the user has to do
|
||||
|
||||
WASD, V to switch view
|
||||
|
||||
## Code Documentation
|
||||
A description of the organization of your code. Feel free to use diagrams, UML, or others. What are the main functions/classes? If you used patterns, what did you use them for, and how do different parts of your code speak to each other?
|
||||
Highlight any issue you want us to know about or whether the code has any known bug. If there are special features you want us to know about, write them here
|
||||
|
||||
Acknowledge any help or resource you used
|
||||
Writing style might be considered in grading (not the grammar, but rather the clarity of your writing)
|
||||
Be visual so use images and tables
|
||||
Try to be complete in your explanation - you do not need to write a lot, but the professor and the TA should be able to understand your documentation and your code by reading this file.
|
||||
|
||||
## Help from AI
|
||||
I got help from AI in criticizing my original plan in structuring my code. For example, although I originally planned to have a single 2D array that stores both the "static" information (the generated maze), and the state of whether the chests have been open or not, but chatgpt suggested that separating these two (one static, one dynamically changing during gameplay) is better as it prevents everything being coupled to this array.
|
||||
It also suggested ot
|
||||
|
||||
The game can be played through the multi_sketch.html
|
||||
"Start run" to generate first leve, etc, seed,
|
||||
left0click chests
|
||||
|
||||
How well and complete is your documentation? Your documentation should contain the following information:
|
||||
|
||||
Write a README.md file that contains the following information.
|
||||
|
||||
222
css/style.css
@@ -15,11 +15,24 @@ canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvas-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maze-layout {
|
||||
width: min(1100px, calc(100vw - 24px));
|
||||
margin: 12px auto 20px;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.maze-layout > .panel:first-child {
|
||||
margin: 12px;
|
||||
border-radius: 16px;
|
||||
height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -41,7 +54,194 @@ canvas {
|
||||
|
||||
#renderCanvas {
|
||||
width: 100%;
|
||||
height: min(62vh, 680px);
|
||||
height: min(76vh, 820px);
|
||||
}
|
||||
|
||||
.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 +250,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 +402,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,18 +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>
|
||||
<canvas id="renderCanvas"></canvas>
|
||||
<div class="panel-label">UNTITLED MAZE GAME</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">
|
||||
<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">Game Controls</div>
|
||||
<div id="control-panel" class="control-panel">
|
||||
<div class="control-group">
|
||||
@@ -26,42 +45,25 @@
|
||||
</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>
|
||||
</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,8 +1532,20 @@
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -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
580
src/babylon_panel.js
Normal file
@@ -0,0 +1,580 @@
|
||||
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 { startParticleSketch, stopParticleSketch, startStartScreenSketch, stopStartScreenSketch } from "./p5_particles.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 p5GameOverPanel = document.getElementById("p5-game-over-panel");
|
||||
const p5StartPanel = document.getElementById("p5-start-panel");
|
||||
const controlPanelSection = document.getElementById("control-panel-section");
|
||||
let controlsVisible = false;
|
||||
|
||||
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;
|
||||
const canvasStage = document.querySelector(".canvas-stage");
|
||||
if (canvasStage) {
|
||||
canvasStage.hidden = true;
|
||||
}
|
||||
if (p5GameOverPanel) {
|
||||
p5GameOverPanel.hidden = false;
|
||||
const sketchContainer = document.getElementById("p5-sketch-container");
|
||||
if (sketchContainer) {
|
||||
startParticleSketch(sketchContainer);
|
||||
}
|
||||
}
|
||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
}
|
||||
|
||||
function hideGameOverScreen() {
|
||||
gameOverActive = false;
|
||||
const canvasStage = document.querySelector(".canvas-stage");
|
||||
if (canvasStage) {
|
||||
canvasStage.hidden = false;
|
||||
}
|
||||
if (p5GameOverPanel) {
|
||||
p5GameOverPanel.hidden = true;
|
||||
stopParticleSketch();
|
||||
}
|
||||
}
|
||||
|
||||
function restartRunFromGameOver() {
|
||||
sharedState.runtime.runActive = true;
|
||||
sharedState.runtime.hasKey = false;
|
||||
sharedState.runtime.roundsCompleted = 0;
|
||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||
sharedState.runtime.message = "Run restarted.";
|
||||
sharedState.config.level = 1;
|
||||
hideGameOverScreen();
|
||||
generateLevel();
|
||||
}
|
||||
|
||||
function switchCameraMode() {
|
||||
if (cameraMode === "fp") {
|
||||
if (document.pointerLockElement === canvas && document.exitPointerLock) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
camera.detachControl(canvas);
|
||||
overviewCamera.attachControl(canvas, true);
|
||||
scene.activeCamera = overviewCamera;
|
||||
cameraMode = "overview";
|
||||
sharedState.runtime.message = "Overview camera (press V to return to first-person).";
|
||||
return;
|
||||
}
|
||||
|
||||
overviewCamera.detachControl(canvas);
|
||||
camera.attachControl(canvas, true);
|
||||
scene.activeCamera = camera;
|
||||
cameraMode = "fp";
|
||||
sharedState.runtime.message = "First-person camera (W/A/S/D + mouse).";
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyV" || event.code === "KeyR") {
|
||||
primeSfx();
|
||||
}
|
||||
if (event.code === "KeyB") {
|
||||
toggleControlsPanel();
|
||||
return;
|
||||
}
|
||||
if (event.code === "KeyR") {
|
||||
if (gameOverActive) {
|
||||
restartRunFromGameOver();
|
||||
} else if (!sharedState.runtime.runActive && p5StartPanel && !p5StartPanel.hidden) {
|
||||
startRunFromStartScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.code === "KeyV") {
|
||||
switchCameraMode();
|
||||
}
|
||||
});
|
||||
|
||||
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
|
||||
|
||||
// Central sphere (hidden but kept for reference)
|
||||
const sphere = BABYLON.MeshBuilder.CreateSphere("sphere", { diameter: 2 }, scene);
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleControlsPanel() {
|
||||
controlsVisible = !controlsVisible;
|
||||
if (controlPanelSection) {
|
||||
controlPanelSection.hidden = !controlsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
function startRunFromStartScreen() {
|
||||
if (p5StartPanel && !p5StartPanel.hidden) {
|
||||
p5StartPanel.hidden = true;
|
||||
stopStartScreenSketch();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Initialize start screen on page load
|
||||
window.addEventListener("load", () => {
|
||||
if (p5StartPanel && !p5StartPanel.hidden) {
|
||||
const startContainer = document.getElementById("p5-start-container");
|
||||
if (startContainer) {
|
||||
startStartScreenSketch(startContainer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export shared state for p5 to use
|
||||
export { sharedState };
|
||||
@@ -1,25 +1,25 @@
|
||||
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.",
|
||||
},
|
||||
});
|
||||
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);
|
||||
@@ -38,19 +38,109 @@ 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", () => {
|
||||
if (document.pointerLockElement !== canvas) {
|
||||
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);
|
||||
|
||||
// Placeholder sphere
|
||||
// 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);
|
||||
@@ -62,9 +152,15 @@ engine.runRenderLoop(() => {
|
||||
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);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -78,10 +174,32 @@ 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) {}
|
||||
}
|
||||
@@ -91,74 +209,13 @@ function clearLevelMeshes() {
|
||||
if (exitBox) { try { exitBox.dispose(); } catch(e){}; exitBox = null; }
|
||||
if (spawnMarker) { try { spawnMarker.dispose(); } catch(e){}; spawnMarker = null; }
|
||||
exitGridPos = null;
|
||||
spawnGridPos = 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 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) {
|
||||
@@ -172,12 +229,17 @@ function buildLevelFromGrid(grid) {
|
||||
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);
|
||||
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.diffuseColor = new BABYLON.Color3(0.33, 0.28, 0.22);
|
||||
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++) {
|
||||
@@ -203,7 +265,8 @@ function placeChestsOnDeadEnds(grid, deadEnds, minCount, seed) {
|
||||
const halfH = (grid.length * cellSize) / 2;
|
||||
|
||||
const chestMat = new BABYLON.StandardMaterial('chestMat', scene);
|
||||
chestMat.diffuseColor = new BABYLON.Color3(0.75, 0.45, 0.15);
|
||||
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);
|
||||
@@ -221,7 +284,9 @@ function placeChestsOnDeadEnds(grid, deadEnds, minCount, seed) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -247,85 +312,107 @@ function placeExit(grid, seed) {
|
||||
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);
|
||||
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);
|
||||
|
||||
// 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 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 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 w = Math.max(9, cfg.mazeWidth);
|
||||
const h = Math.max(9, cfg.mazeHeight);
|
||||
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, cfg.minChestDeadEnds, seed + cfg.level);
|
||||
placeChestsOnDeadEnds(grid, dead, chestCount, seed + cfg.level);
|
||||
placeExit(grid, seed + cfg.level);
|
||||
spawnCameraAt(grid);
|
||||
sharedState.runtime.message = `Level ${cfg.level} generated.`;
|
||||
|
||||
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 */ });
|
||||
}
|
||||
|
||||
@@ -335,6 +422,7 @@ 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;
|
||||
@@ -343,16 +431,20 @@ scene.onPointerObservable.add((pi) => {
|
||||
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;
|
||||
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;
|
||||
playSfx("key", 0.85);
|
||||
sharedState.runtime.message = 'You found the key! Find the exit.';
|
||||
} else {
|
||||
sharedState.runtime.message = 'This chest was empty.';
|
||||
@@ -361,17 +453,69 @@ scene.onPointerObservable.add((pi) => {
|
||||
|
||||
// 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) {
|
||||
sharedState.runtime.elapsedSeconds += engine.getDeltaTime() / 1000;
|
||||
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.elapsedSeconds = 0;
|
||||
sharedState.runtime.roundsCompleted += 1;
|
||||
sharedState.runtime.elapsedSeconds = ROUND_TIME_SECONDS;
|
||||
sharedState.runtime.message = `Level ${sharedState.config.level} starting.`;
|
||||
generateLevel();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
59
src/game/sfx.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const soundFiles = {
|
||||
chestClose: "/sfx/sfx_chest_close.wav",
|
||||
chestOpen: "/sfx/sfx_chest_open.wav",
|
||||
click: "/sfx/sfx_click.wav",
|
||||
clock: "/sfx/sfx_clock.wav",
|
||||
key: "/sfx/sfx_key.wav",
|
||||
lose: "/sfx/sfx_lose.wav",
|
||||
step: "/sfx/sfx_step.wav",
|
||||
win: "/sfx/sfx_win.wav",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
16
src/game/state.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export const sharedState = (window.mazeGameState ??= {
|
||||
config: {
|
||||
seed: Math.floor(Math.random() * 100000),
|
||||
level: 1,
|
||||
mazeWidth: 11,
|
||||
mazeHeight: 11,
|
||||
minChestDeadEnds: 2,
|
||||
},
|
||||
runtime: {
|
||||
runActive: false,
|
||||
hasKey: false,
|
||||
roundsCompleted: 0,
|
||||
elapsedSeconds: 60,
|
||||
message: "Press Start to play.",
|
||||
},
|
||||
});
|
||||
@@ -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,85 @@ 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) {
|
||||
console.warn("Some status display elements are missing from DOM");
|
||||
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", () => {
|
||||
resetRun("Run started.");
|
||||
});
|
||||
const btnStart = document.getElementById("btn-start");
|
||||
const btnRestart = document.getElementById("btn-restart");
|
||||
const btnRandomize = document.getElementById("btn-randomize");
|
||||
|
||||
document.getElementById("btn-restart").addEventListener("click", () => {
|
||||
restartLevel("Level restarted.");
|
||||
});
|
||||
if (btnStart) {
|
||||
btnStart.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
playSfx("click", 0.7);
|
||||
resetRun("Run started.");
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-randomize").addEventListener("click", () => {
|
||||
randomizeSeed();
|
||||
});
|
||||
if (btnRestart) {
|
||||
btnRestart.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
playSfx("click", 0.7);
|
||||
restartLevel("Level restarted.");
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
if (btnRandomize) {
|
||||
btnRandomize.addEventListener("click", () => {
|
||||
primeSfx();
|
||||
playSfx("click", 0.7);
|
||||
randomizeSeed();
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||