From 36b3addf59537b404af43feadfbc44c229273ff9 Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Mon, 5 Jan 2026 09:40:41 +0100 Subject: [PATCH] Add configurable procedural sounds --- .vscode/settings.json | 3 + AGENTS.md | 5 + README.md | 8 + assets/sfx/SOURCES.txt | 8 + assets/sfx/clear-pop.ogg | Bin 0 -> 15571 bytes assets/sfx/drag-buzz.ogg | Bin 0 -> 8124 bytes assets/sfx/link-click.ogg | 11 ++ index.html | 1 + src/chain-controller.js | 6 + src/config.js | 28 +++ src/input.js | 4 + src/main.js | 28 +++ src/sounds.js | 354 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 456 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 assets/sfx/SOURCES.txt create mode 100644 assets/sfx/clear-pop.ogg create mode 100644 assets/sfx/drag-buzz.ogg create mode 100644 assets/sfx/link-click.ogg create mode 100644 src/sounds.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..02be578 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "chatgpt.openOnStartup": true +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 3953071..6cd6e73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Repository Guidelines ## Project Structure & Modules + - `index.html` boots the canvas, HUD overlays, and pulls Matter.js plus all project scripts; open it directly or via a simple static server. - `styles.css` holds layout, HUD, overlays, and popup styling. - `src/main.js` runs the physics loop, spawning, scoring, and scene application; `src/ui.js` wires DOM/HUD controls and overlays. @@ -9,18 +10,22 @@ - `src/decomp-setup.js` configures `poly-decomp` for concave shapes; `src/storage.js` reads/writes per-scene highscores/records in `localStorage`. ## Build, Test, and Development Commands + - No build step. Serve or open locally: `python3 -m http.server 8000` then visit `http://localhost:8000`, or double-click `index.html`. - Use a modern desktop/mobile browser; Matter.js is loaded via CDN. ## Coding Style & Naming Conventions + - JavaScript uses 2-space indentation, semicolons, and double quotes; keep functions/variables camelCase, constants UPPER_SNAKE_CASE. - Scene files are kebab-cased (e.g., `scene-storm-grid.js`) and should export an `id` matching the filename. Keep configs minimal: gravity, spawn settings, palette, `link` options, and `createBodies`. - Favor small helpers over inline duplication; attach shared globals to `window.Physilinks*` consistently. ## Testing Guidelines + - No automated tests yet; rely on manual playthroughs. Recommended pass: load each scene, start a chain, clear at least one valid link, verify score popup and HUD update, pause/resume, and confirm run-over detection triggers when the entry is blocked. - Validate persistence: switch scenes and ensure highscores reload; refresh to confirm `localStorage` keys (`physilinks-highscore-`) remain honored. ## Commit & Pull Request Guidelines + - Follow the short, present-tense style from history (e.g., `Add Storm Grid Shift scene`, `Clamp square spawns for stack blocks`). - Call out any new globals, storage keys, or DOM IDs/classes. Keep diffs focused and avoid unrelated formatting churn. diff --git a/README.md b/README.md index aa0eeeb..bbb0ac6 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match and chain same-colored falling balls; link enough to clear them and rack up points. ## Play instructions + - Open `index.html` in a modern browser (desktop or mobile touch). - Choose a scene from the selector (changes gravity, obstacles, spawn rate, palette, ball size). Switching scenes restarts the run. - Click/touch a ball to start a chain; drag through balls of the same color to add them. Drag back to the previous ball to undo the last link. @@ -11,21 +12,26 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a - Pause/resume with the button or `Esc`. HUD shows spawn rate, min link, chain length, score, per-scene high score, and palette legend. ## Tech notes + - **Engine**: Matter.js (via CDN). Canvas rendering with custom overlays for HUD, pause, game over, and score popups. - **Scenes**: Defined in `src/scenes/*.js` as presets (config + `createBodies` factory). Applying a scene updates gravity, spawn rate, ball radius, palette, link constraints (stiffness/stretch/damping/width), and static bodies, then restarts the game. - **Physics entities**: Falling balls (`Bodies.circle`) with gentle restitution/friction; static boundaries/obstacles per scene. The top is open; sides and floor are static bodies. - **Input**: Pointer/touch events mapped to scene coords; chain state tracks bodies and a dashed preview line to the pointer. Undo by dragging back to the previous node. +- **Sound**: Procedural Web Audio by default with optional sample playback per channel (buzz/click/pop). Configure via `config.sounds` and per-scene overrides. - **Scoring**: `10 × length²` per cleared chain. Score popup rendered as DOM element near release point (via UI module). - **Persistence**: Per-scene high score stored in `localStorage` under `physilinks-highscore-`; loaded on scene change; HUD shows current scene's best. - **Game loop**: Single Matter runner controlled in `main.js`, with spawning handled by `src/spawn.js` and goal messaging handled by `src/goals.js`. Pause/game over stop the runner and spawner and zero `engine.timing.timeScale` so physics and rotating obstacles freeze; resume restarts the runner and spawner. - **Lose detection**: Spawned balls monitor entry; if they remain near the spawn zone with negligible velocity after a short delay, the run is over. ## File structure + - `index.html`: Shell layout and HUD overlays; loads Matter.js plus game scripts. - `styles.css`: Styling for canvas, HUD, overlays, and score popups. - `src/scenes/`: Scene presets split per file (`scene-*.js`) plus `index.js` that registers them to `window.PhysilinksScenes` (e.g., zero-G grid, balanced, low-G, fast drop, lava drift). - `src/scenes/scene-template.js`: Reference-only template documenting every scene config option; not loaded by default. - `src/config.js`: Base game config defaults (gravity, spawn timing, link settings, palettes, message defaults). +- `src/sounds.js`: Audio system (procedural + sample playback) with per-channel config and scene overrides. +- `assets/sfx/`: Optional sample audio files plus `SOURCES.txt` with source URLs. - `src/engine.js`: Matter engine/render/runner setup helpers (create, start/stop runner, resize render). - `src/decomp-setup.js`: Registers `poly-decomp` with Matter to allow concave shapes (stars, blobs) built via `Bodies.fromVertices`. - `src/ui.js`: DOM access, HUD updates, overlays, popups, and control/selector wiring. @@ -35,11 +41,13 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a - `src/main.js`: Physics setup, state machine, chain interaction, scene application, and pause/restart logic; delegates spawn duties to `src/spawn.js`, goal handling to `src/goals.js`, and input/chain interactions to `src/input.js`. ## Development quick start + - No build step. Open `index.html` directly in the browser. - Key files: `index.html` (layout), `styles.css` (styling), `src/ui.js` (DOM/HUD), `src/main.js` (physics/game logic), `src/scenes/index.js` (scene registration). - Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function. ## Adding a new scene + - Create `src/scenes/scene-.js` based on `src/scenes/scene-template.js` or an existing scene, and keep the `id` aligned to the filename suffix. - Add the script tag to `index.html` with the other scene files so it loads in the browser. - Register the scene id in `src/scenes/index.js` (append to `desiredOrder` or rely on the unordered fallback). diff --git a/assets/sfx/SOURCES.txt b/assets/sfx/SOURCES.txt new file mode 100644 index 0000000..2678f60 --- /dev/null +++ b/assets/sfx/SOURCES.txt @@ -0,0 +1,8 @@ +drag-buzz.ogg +https://actions.google.com/sounds/v1/alarms/beep_short.ogg + +link-click.ogg +https://actions.google.com/sounds/v1/ui/button_click.ogg + +clear-pop.ogg +https://actions.google.com/sounds/v1/cartoon/pop.ogg diff --git a/assets/sfx/clear-pop.ogg b/assets/sfx/clear-pop.ogg new file mode 100644 index 0000000000000000000000000000000000000000..cf25752fc8726a2f3be363927443411a6d576d4a GIT binary patch literal 15571 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 + + + + Error 404 (Not Found)!!1 + + +

404. That’s an error. +

The requested URL /sounds/v1/ui/button_click.ogg was not found on this server. That’s all we know. diff --git a/index.html b/index.html index b789be7..c1a2673 100644 --- a/index.html +++ b/index.html @@ -125,6 +125,7 @@ + diff --git a/src/chain-controller.js b/src/chain-controller.js index afa964a..b741c97 100644 --- a/src/chain-controller.js +++ b/src/chain-controller.js @@ -14,6 +14,7 @@ updateHud, checkWinCondition, ui, + sound, }) => { const setHighlight = (body, on) => { const lineWidth = on ? 4 : 2; @@ -37,6 +38,7 @@ chain.bodies = []; chain.constraints = []; chain.pointer = null; + sound?.stopDrag?.(); updateHud(); }; @@ -76,6 +78,7 @@ chain.bodies.push(body); setHighlight(body, true); World.add(world, constraint); + sound?.playClick?.(); updateHud(); }; @@ -240,15 +243,18 @@ chain.bodies = []; chain.constraints = []; chain.pointer = null; + sound?.stopDrag?.(); updateHud(); checkWinCondition(); }; const finishChain = (releasePoint) => { if (!chain.active || state.gameOver || state.paused) return; + sound?.stopDrag?.(); const chainLength = chain.bodies.length; const currentScene = getCurrentScene(); if (chainLength >= config.minChain) { + sound?.playPop?.(); updateLongestChain(chainLength); const { gain, isNegativeProgress } = getChainScoreState(); state.score += gain; diff --git a/src/config.js b/src/config.js index a25c7c8..695c9a3 100644 --- a/src/config.js +++ b/src/config.js @@ -71,6 +71,30 @@ blur: 24, speedSec: 30, }, + sounds: { + enabled: true, + basePath: "./assets/sfx", + channels: { + buzz: { + mode: "off", // "procedural" / "sample" / "off" + procedural: "buzz", + file: "drag-buzz.ogg", + volume: 0.06, + }, + click: { + mode: "procedural", + procedural: "click", + file: "link-click.ogg", + volume: 0.18, + }, + pop: { + mode: "procedural", + procedural: "pop", + file: "clear-pop.ogg", + volume: 0.22, + }, + }, + }, showFps: false, }; @@ -80,6 +104,10 @@ ...config, link: { ...config.link }, messages: { ...config.messages }, + sounds: { + ...config.sounds, + channels: { ...config.sounds.channels }, + }, }, }; }; diff --git a/src/input.js b/src/input.js index 4c74e8d..b8df6b6 100644 --- a/src/input.js +++ b/src/input.js @@ -18,6 +18,7 @@ addToChain, finishChain, updateHud, + sound, }) => { let dragConstraint = null; @@ -71,6 +72,7 @@ const handlePointerDown = (evt) => { if (isGameOver() || isPaused() || isLevelWon()) return; + sound?.unlock?.(); const point = getPointerPosition(evt); const dragTarget = getDraggableBody(point); if (dragTarget) { @@ -90,6 +92,7 @@ chain.constraints = []; chain.pointer = point; setHighlight(body, true); + sound?.startDrag?.(); updateHud(); }; @@ -123,6 +126,7 @@ }; const handlePointerUp = () => { + sound?.stopDrag?.(); if (dragConstraint) { endDrag(); return; diff --git a/src/main.js b/src/main.js index 47fc273..e814e6c 100644 --- a/src/main.js +++ b/src/main.js @@ -86,12 +86,30 @@ : null, }); + const normalizeSounds = (sounds = {}, defaults = baseConfig.sounds || {}) => { + const defaultChannels = defaults.channels || {}; + const overrideChannels = sounds.channels || {}; + const mergedChannels = { ...defaultChannels }; + Object.keys(overrideChannels).forEach((key) => { + mergedChannels[key] = { + ...(defaultChannels[key] || {}), + ...(overrideChannels[key] || {}), + }; + }); + return { + ...defaults, + ...sounds, + channels: mergedChannels, + }; + }; + const normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => { const { link = {}, messages = {}, goalEffects = {}, backdrop = {}, + sounds = {}, ...rest } = sceneConfig; const base = defaults || {}; @@ -105,6 +123,7 @@ ), goalEffects: normalizeGoalEffects(goalEffects, base.goalEffects), backdrop: normalizeBackdrop(backdrop, base.backdrop), + sounds: normalizeSounds(sounds, base.sounds), }; }; @@ -124,6 +143,12 @@ const ui = createUI(); const { sceneEl } = ui; + const createSound = load("PhysilinksSound", { + create: "create", + fallback: () => ({}), + }); + const sound = createSound({ sounds: config.sounds }); + const state = { width: sceneEl.clientWidth, height: sceneEl.clientHeight, @@ -246,6 +271,7 @@ setSceneIdInUrl(next.id); const prevRadius = config.ballRadius; setConfigForScene(next.config); + sound?.setConfig?.(config.sounds); ui.setBackdrop(config.backdrop, config.palette); ui.setFpsVisibility(config.showFps); ui.setMessageDefaults(config.messages); @@ -613,6 +639,7 @@ updateHud, checkWinCondition, ui, + sound, }); const createInput = load("PhysilinksInput", { create: "create" }); @@ -633,6 +660,7 @@ addToChain, finishChain, updateHud, + sound, }); const buildLegend = () => { diff --git a/src/sounds.js b/src/sounds.js new file mode 100644 index 0000000..0780ec3 --- /dev/null +++ b/src/sounds.js @@ -0,0 +1,354 @@ +(() => { + const supportsAudio = () => typeof Audio !== "undefined"; + const supportsWebAudio = () => + typeof AudioContext !== "undefined" || + typeof webkitAudioContext !== "undefined"; + + const DEFAULT_SOUND_CONFIG = { + enabled: true, + basePath: "./assets/sfx", + channels: { + buzz: { + mode: "procedural", + procedural: "buzz", + file: "drag-buzz.ogg", + volume: 0.06, + }, + click: { + mode: "procedural", + procedural: "click", + file: "link-click.ogg", + volume: 0.18, + }, + pop: { + mode: "procedural", + procedural: "pop", + file: "clear-pop.ogg", + volume: 0.22, + }, + }, + }; + + const createAudio = (src, { volume = 1, loop = false, rate = 1 } = {}) => { + const audio = new Audio(src); + audio.preload = "auto"; + audio.loop = loop; + audio.volume = volume; + audio.playbackRate = rate; + return audio; + }; + + const createPoolPlayer = (src, { volume = 1, rate = 1, max = 6 } = {}) => { + const pool = []; + return () => { + if (!supportsAudio()) return; + let audio = pool.find((item) => item.paused || item.ended); + if (!audio) { + if (pool.length >= max) return; + audio = createAudio(src, { volume, rate }); + pool.push(audio); + } + audio.currentTime = 0; + audio.play().catch(() => {}); + }; + }; + + const normalizeSoundConfig = (sounds = {}) => { + const defaultChannels = DEFAULT_SOUND_CONFIG.channels; + const overrideChannels = sounds.channels || {}; + const mergedChannels = { ...defaultChannels }; + Object.keys(overrideChannels).forEach((key) => { + mergedChannels[key] = { + ...(defaultChannels[key] || {}), + ...(overrideChannels[key] || {}), + }; + }); + return { + ...DEFAULT_SOUND_CONFIG, + ...sounds, + channels: mergedChannels, + }; + }; + + const create = ({ sounds = {} } = {}) => { + let unlocked = false; + let buzzing = false; + let buzzLoop = null; + let audioCtx = null; + let masterGain = null; + let buzzNodes = null; + let soundConfig = normalizeSoundConfig(sounds); + const samplePlayers = new Map(); + let sampleBuzzKey = null; + + const ensureContext = () => { + if (!supportsWebAudio()) return false; + if (audioCtx) return true; + const Ctx = + typeof AudioContext !== "undefined" ? AudioContext : webkitAudioContext; + audioCtx = new Ctx(); + masterGain = audioCtx.createGain(); + masterGain.gain.value = 0.35; + masterGain.connect(audioCtx.destination); + return true; + }; + + const resolveSampleSrc = (file) => { + if (!file) return null; + if (file.includes("/") || file.startsWith("http")) { + return file; + } + return `${soundConfig.basePath}/${file}`; + }; + + const getSamplePlayer = ( + channelKey, + { file, volume = 1, rate = 1, max = 6 } = {}, + ) => { + const src = resolveSampleSrc(file); + if (!src) return null; + const key = `${channelKey}|${src}|${volume}|${rate}|${max}`; + if (!samplePlayers.has(key)) { + samplePlayers.set( + key, + createPoolPlayer(src, { volume, rate, max }), + ); + } + return samplePlayers.get(key); + }; + + const ensureBuzzLoop = ({ file, volume = 0.06, rate = 1 } = {}) => { + if (!supportsAudio()) return; + const src = resolveSampleSrc(file); + if (!src) return; + const key = `${src}|${volume}|${rate}`; + if (buzzLoop && sampleBuzzKey === key) return; + if (buzzLoop) { + buzzLoop.pause(); + } + buzzLoop = createAudio(src, { + volume, + loop: true, + rate, + }); + sampleBuzzKey = key; + }; + + const resumeContext = () => { + if (!audioCtx || audioCtx.state !== "suspended") return; + audioCtx.resume().catch(() => {}); + }; + + const createNoiseBuffer = (durationSec) => { + const buffer = audioCtx.createBuffer( + 1, + durationSec * audioCtx.sampleRate, + audioCtx.sampleRate, + ); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i += 1) { + data[i] = (Math.random() * 2 - 1) * 0.9; + } + return buffer; + }; + + const playClickProcedural = (volume = 0.12) => { + if (!ensureContext()) return; + resumeContext(); + const now = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = "triangle"; + osc.frequency.setValueAtTime(860, now); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(volume, now + 0.008); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08); + osc.connect(gain); + gain.connect(masterGain); + osc.start(now); + osc.stop(now + 0.1); + }; + + const playPopProcedural = (volume = 0.2) => { + if (!ensureContext()) return; + resumeContext(); + const now = audioCtx.currentTime; + const duration = 0.18; + const buffer = createNoiseBuffer(duration); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + const filter = audioCtx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(1200, now); + const gain = audioCtx.createGain(); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(volume, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + source.connect(filter); + filter.connect(gain); + gain.connect(masterGain); + source.start(now); + source.stop(now + duration); + }; + + const startBuzzProcedural = (volume = 0.02) => { + if (!ensureContext()) return; + resumeContext(); + const now = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const lfo = audioCtx.createOscillator(); + const lfoGain = audioCtx.createGain(); + const gate = audioCtx.createGain(); + const gateLfo = audioCtx.createOscillator(); + const gateLfoGain = audioCtx.createGain(); + const gain = audioCtx.createGain(); + osc.type = "sawtooth"; + osc.frequency.setValueAtTime(120, now); + lfo.type = "sine"; + lfo.frequency.setValueAtTime(0.8, now); + lfoGain.gain.setValueAtTime(0.015, now); + gate.gain.setValueAtTime(0.0001, now); + gateLfo.type = "sine"; + gateLfo.frequency.setValueAtTime(0.22, now); + gateLfoGain.gain.setValueAtTime(0.5, now); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(volume, now + 0.2); + lfo.connect(lfoGain); + lfoGain.connect(gain.gain); + gateLfo.connect(gateLfoGain); + gateLfoGain.connect(gate.gain); + osc.connect(gain); + gain.connect(gate); + gate.connect(masterGain); + osc.start(now); + lfo.start(now); + gateLfo.start(now); + buzzNodes = { osc, lfo, gain, gate, gateLfo }; + }; + + const stopBuzzProcedural = () => { + if (!buzzNodes) return; + const now = audioCtx.currentTime; + buzzNodes.gain.gain.cancelScheduledValues(now); + buzzNodes.gain.gain.setValueAtTime(buzzNodes.gain.gain.value, now); + buzzNodes.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); + buzzNodes.gate.gain.cancelScheduledValues(now); + buzzNodes.gate.gain.setValueAtTime(buzzNodes.gate.gain.value, now); + buzzNodes.gate.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); + buzzNodes.osc.stop(now + 0.06); + buzzNodes.lfo.stop(now + 0.06); + buzzNodes.gateLfo.stop(now + 0.06); + buzzNodes = null; + }; + + const unlock = () => { + unlocked = true; + if (!soundConfig.enabled) return; + if (!ensureContext()) return; + resumeContext(); + }; + + const playClick = () => { + const channel = soundConfig.channels.click || {}; + if (!soundConfig.enabled || !unlocked || channel.mode === "off") return; + if (channel.mode === "procedural" && supportsWebAudio()) { + const procKey = channel.procedural || "click"; + if (procKey === "click") { + playClickProcedural(channel.volume ?? 0.12); + } + return; + } + if (channel.mode === "sample") { + const player = getSamplePlayer("click", { + file: channel.file, + volume: channel.volume ?? 0.18, + max: 8, + }); + player?.(); + } + }; + + const playPop = () => { + const channel = soundConfig.channels.pop || {}; + if (!soundConfig.enabled || !unlocked || channel.mode === "off") return; + if (channel.mode === "procedural" && supportsWebAudio()) { + const procKey = channel.procedural || "pop"; + if (procKey === "pop") { + playPopProcedural(channel.volume ?? 0.2); + } + return; + } + if (channel.mode === "sample") { + const player = getSamplePlayer("pop", { + file: channel.file, + volume: channel.volume ?? 0.22, + max: 8, + }); + player?.(); + } + }; + + const startDrag = () => { + const channel = soundConfig.channels.buzz || {}; + if (!soundConfig.enabled || !unlocked || buzzing) return; + if (channel.mode === "off") return; + buzzing = true; + if (channel.mode === "procedural" && supportsWebAudio()) { + const procKey = channel.procedural || "buzz"; + if (procKey === "buzz") { + startBuzzProcedural(channel.volume ?? 0.05); + return; + } + } + if (channel.mode === "sample") { + ensureBuzzLoop({ + file: channel.file, + volume: channel.volume ?? 0.06, + }); + if (!buzzLoop) { + buzzing = false; + return; + } + buzzLoop.currentTime = 0; + buzzLoop.play().catch(() => { + buzzing = false; + }); + } + }; + + const stopDrag = () => { + if (!buzzing) return; + buzzing = false; + const channel = soundConfig.channels.buzz || {}; + if (channel.mode === "procedural" && supportsWebAudio()) { + stopBuzzProcedural(); + return; + } + if (!buzzLoop) return; + buzzLoop.pause(); + buzzLoop.currentTime = 0; + }; + + const setConfig = (nextSounds = {}) => { + soundConfig = normalizeSoundConfig(nextSounds); + samplePlayers.clear(); + sampleBuzzKey = null; + if (buzzLoop) { + buzzLoop.pause(); + buzzLoop = null; + } + if (!soundConfig.enabled) stopDrag(); + }; + + return { + unlock, + playClick, + playPop, + startDrag, + stopDrag, + setConfig, + }; + }; + + window.PhysilinksSound = { create }; +})();