add sounds and textures
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
57
src/sfx.js
Normal file
57
src/sfx.js
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user