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 0000000..cf25752 Binary files /dev/null and b/assets/sfx/clear-pop.ogg differ diff --git a/assets/sfx/drag-buzz.ogg b/assets/sfx/drag-buzz.ogg new file mode 100644 index 0000000..8b87079 Binary files /dev/null and b/assets/sfx/drag-buzz.ogg differ diff --git a/assets/sfx/link-click.ogg b/assets/sfx/link-click.ogg new file mode 100644 index 0000000..fd8dcfc --- /dev/null +++ b/assets/sfx/link-click.ogg @@ -0,0 +1,11 @@ + + + + + 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 }; +})();