diff --git a/PROPOSAL_20240905.md b/PROPOSAL_20240905.md new file mode 100644 index 0000000..e6de51d --- /dev/null +++ b/PROPOSAL_20240905.md @@ -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. diff --git a/ZZREADME.md b/ZZREADME.md new file mode 100644 index 0000000..28a796d --- /dev/null +++ b/ZZREADME.md @@ -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. + diff --git a/css/style.css b/css/style.css index a315b14..6a05b72 100644 --- a/css/style.css +++ b/css/style.css @@ -20,10 +20,12 @@ canvas { } .maze-layout { - width: min(1100px, calc(100vw - 24px)); + width: min(1200px, calc(100vw - 24px)); margin: 12px auto 20px; display: grid; - gap: 12px; + grid-template-columns: 2fr 1fr; + gap: 16px; + align-items: start; } .panel { @@ -45,7 +47,7 @@ canvas { #renderCanvas { width: 100%; - height: min(62vh, 680px); + height: min(76vh, 820px); } .canvas-hud { @@ -78,6 +80,28 @@ canvas { text-shadow: 0 0 12px rgba(121, 174, 242, 0.35); } +.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; +} + .control-panel { padding: 16px; overflow-y: auto; @@ -223,6 +247,7 @@ canvas { .maze-layout { width: calc(100vw - 16px); margin: 8px auto 16px; + grid-template-columns: 1fr; } #renderCanvas { diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..6a4b88e Binary files /dev/null and b/favicon.png differ diff --git a/favicon.svg b/favicon.svg deleted file mode 100644 index 3da8f99..0000000 --- a/favicon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/img/img_chest.png b/img/img_chest.png new file mode 100644 index 0000000..f03fe22 Binary files /dev/null and b/img/img_chest.png differ diff --git a/img/img_ground.png b/img/img_ground.png new file mode 100644 index 0000000..45489f9 Binary files /dev/null and b/img/img_ground.png differ diff --git a/img/img_wall.png b/img/img_wall.png new file mode 100644 index 0000000..d68ba06 Binary files /dev/null and b/img/img_wall.png differ diff --git a/index.html b/index.html index 17461b4..03659c7 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Untitled Maze Game @@ -16,6 +16,8 @@
Elapsed time
0.0s
+
Has keyno
+
Rounds0
@@ -61,6 +63,7 @@
Level: 1
Time: 0.0
Key: no
+
Rounds: 0
Adjust settings, then start a run.
diff --git a/sfx/sfx_chest_close.wav b/sfx/sfx_chest_close.wav new file mode 100644 index 0000000..a746fa4 Binary files /dev/null and b/sfx/sfx_chest_close.wav differ diff --git a/sfx/sfx_chest_open.wav b/sfx/sfx_chest_open.wav new file mode 100644 index 0000000..014682a Binary files /dev/null and b/sfx/sfx_chest_open.wav differ diff --git a/sfx/sfx_click.wav b/sfx/sfx_click.wav new file mode 100644 index 0000000..c14082d Binary files /dev/null and b/sfx/sfx_click.wav differ diff --git a/sfx/sfx_clock.wav b/sfx/sfx_clock.wav new file mode 100644 index 0000000..dc62617 Binary files /dev/null and b/sfx/sfx_clock.wav differ diff --git a/sfx/sfx_key.wav b/sfx/sfx_key.wav new file mode 100644 index 0000000..55b02de Binary files /dev/null and b/sfx/sfx_key.wav differ diff --git a/sfx/sfx_lose.wav b/sfx/sfx_lose.wav new file mode 100644 index 0000000..dd095d7 Binary files /dev/null and b/sfx/sfx_lose.wav differ diff --git a/sfx/sfx_step.wav b/sfx/sfx_step.wav new file mode 100644 index 0000000..0e2694c Binary files /dev/null and b/sfx/sfx_step.wav differ diff --git a/sfx/sfx_win.wav b/sfx/sfx_win.wav new file mode 100644 index 0000000..08b9e64 Binary files /dev/null and b/sfx/sfx_win.wav differ diff --git a/src/babylon_setup.js b/src/babylon_setup.js index de0c859..69fdab7 100644 --- a/src/babylon_setup.js +++ b/src/babylon_setup.js @@ -2,11 +2,17 @@ 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 "./sfx.js"; +import chestTextureUrl from "../img/img_chest.png"; +import wallTextureUrl from "../img/img_wall.png"; +import groundTextureUrl from "../img/img_ground.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 scene = new BABYLON.Scene(engine); scene.clearColor = new BABYLON.Color4(0.05, 0.07, 0.1, 1); @@ -40,11 +46,15 @@ overviewCamera.inertia = 0.7; let cameraMode = "fp"; scene.activeCamera = camera; +let lastFootstepPosition = null; +let footstepAccumulator = 0; +let footstepElapsed = 0; scene.gravity = new BABYLON.Vector3(0, -0.2, 0); scene.collisionsEnabled = true; canvas.addEventListener("click", () => { + primeSfx(); if (cameraMode === "fp" && document.pointerLockElement !== canvas) { canvas.requestPointerLock(); } @@ -77,6 +87,9 @@ function switchCameraMode() { } window.addEventListener("keydown", (event) => { + if (event.code === "KeyW" || event.code === "KeyA" || event.code === "KeyS" || event.code === "KeyD" || event.code === "KeyV") { + primeSfx(); + } if (event.code === "KeyV") { switchCameraMode(); } @@ -103,6 +116,12 @@ engine.runRenderLoop(() => { 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(); }); @@ -118,9 +137,29 @@ 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; +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) {} } @@ -151,12 +190,16 @@ function buildLevelFromGrid(grid) { floor.checkCollisions = true; const fm = new BABYLON.StandardMaterial('floorMat', scene); fm.specularColor = new BABYLON.Color3(0.1, 0.1, 0.1); - fm.diffuseColor = new BABYLON.Color3(0.58, 0.58, 0.59); + 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++) { @@ -182,7 +225,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); @@ -200,7 +244,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; } } @@ -304,6 +350,9 @@ function spawnCameraAt(grid) { 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; if (spawnMarker) { try { spawnMarker.dispose(); } catch(e) {} @@ -344,6 +393,9 @@ function generateLevel() { } 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 */ }); } @@ -361,16 +413,18 @@ scene.onPointerObservable.add((pi) => { const entry = chestMap.get(coords); if (!entry) return; if (entry.opened) { + primeSfx(); + playSfx("chestClose", 0.8); sharedState.runtime.runActive = false; sharedState.runtime.message = 'Opened chest again — game over.'; 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.'; @@ -379,16 +433,44 @@ 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; } + 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 = 0; sharedState.runtime.message = `Level ${sharedState.config.level} starting.`; generateLevel(); diff --git a/src/game/state.js b/src/game/state.js index 4104efb..3bee3ed 100644 --- a/src/game/state.js +++ b/src/game/state.js @@ -9,6 +9,7 @@ export const sharedState = (window.mazeGameState ??= { runtime: { runActive: false, hasKey: false, + roundsCompleted: 0, elapsedSeconds: 0, message: "Adjust settings, then start a run.", }, diff --git a/src/html_panel.js b/src/html_panel.js index ab3beab..9b27135 100644 --- a/src/html_panel.js +++ b/src/html_panel.js @@ -1,9 +1,11 @@ import { sharedState } from "./game/state.js"; +import { playSfx, primeSfx } from "./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.roundsCompleted = 0; sharedState.runtime.elapsedSeconds = 0; sharedState.runtime.message = message; sharedState.config.level = 1; @@ -35,35 +37,45 @@ function updateDisplay() { 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-rounds").textContent = sharedState.runtime.roundsCompleted; document.getElementById("status-message").textContent = sharedState.runtime.message; } // Initialize event listeners document.getElementById("btn-start").addEventListener("click", () => { + primeSfx(); + playSfx("click", 0.7); resetRun("Run started."); }); document.getElementById("btn-restart").addEventListener("click", () => { + primeSfx(); + playSfx("click", 0.7); restartLevel("Level restarted."); }); document.getElementById("btn-randomize").addEventListener("click", () => { + primeSfx(); + playSfx("click", 0.7); randomizeSeed(); }); document.getElementById("slider-width").addEventListener("input", (e) => { + primeSfx(); const value = parseInt(e.target.value) | 1; sharedState.config.mazeWidth = value; document.getElementById("value-width").textContent = value; }); document.getElementById("slider-height").addEventListener("input", (e) => { + primeSfx(); const value = parseInt(e.target.value) | 1; sharedState.config.mazeHeight = value; document.getElementById("value-height").textContent = value; }); document.getElementById("slider-deadends").addEventListener("input", (e) => { + primeSfx(); const value = parseInt(e.target.value); sharedState.config.minChestDeadEnds = value; document.getElementById("value-deadends").textContent = value; @@ -74,6 +86,7 @@ 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"; + document.getElementById("status-rounds").textContent = sharedState.runtime.roundsCompleted; } }, 100); diff --git a/src/sfx.js b/src/sfx.js new file mode 100644 index 0000000..447564e --- /dev/null +++ b/src/sfx.js @@ -0,0 +1,57 @@ +const soundFiles = { + chestClose: "/sfx/sfx_chest_close.wav", + chestOpen: "/sfx/sfx_chest_open.wav", + click: "/sfx/sfx_click.wav", + key: "/sfx/sfx_key.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); +} \ No newline at end of file