Compare commits
6 Commits
76ffee449d
...
36b3addf59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b3addf59 | ||
|
|
96e8cd4f02 | ||
|
|
bbea27a3f5 | ||
|
|
a663aa3fbc | ||
|
|
653710c8a8 | ||
|
|
9243ac2df5 |
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"chatgpt.openOnStartup": true
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Modules
|
## 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.
|
- `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.
|
- `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.
|
- `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`.
|
- `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
|
## 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`.
|
- 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.
|
- Use a modern desktop/mobile browser; Matter.js is loaded via CDN.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
- JavaScript uses 2-space indentation, semicolons, and double quotes; keep functions/variables camelCase, constants UPPER_SNAKE_CASE.
|
- 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`.
|
- 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.
|
- Favor small helpers over inline duplication; attach shared globals to `window.Physilinks*` consistently.
|
||||||
|
|
||||||
## Testing Guidelines
|
## 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.
|
- 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-<sceneId>`) remain honored.
|
- Validate persistence: switch scenes and ensure highscores reload; refresh to confirm `localStorage` keys (`physilinks-highscore-<sceneId>`) remain honored.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## 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`).
|
- 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.
|
- Call out any new globals, storage keys, or DOM IDs/classes. Keep diffs focused and avoid unrelated formatting churn.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Play instructions
|
||||||
|
|
||||||
- Open `index.html` in a modern browser (desktop or mobile touch).
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Tech notes
|
||||||
|
|
||||||
- **Engine**: Matter.js (via CDN). Canvas rendering with custom overlays for HUD, pause, game over, and score popups.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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-<sceneId>`; loaded on scene change; HUD shows current scene's best.
|
- **Persistence**: Per-scene high score stored in `localStorage` under `physilinks-highscore-<sceneId>`; 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.
|
- **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.
|
- **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
|
## File structure
|
||||||
|
|
||||||
- `index.html`: Shell layout and HUD overlays; loads Matter.js plus game scripts.
|
- `index.html`: Shell layout and HUD overlays; loads Matter.js plus game scripts.
|
||||||
- `styles.css`: Styling for canvas, HUD, overlays, and score popups.
|
- `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 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/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/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/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/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.
|
- `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`.
|
- `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
|
## Development quick start
|
||||||
|
|
||||||
- No build step. Open `index.html` directly in the browser.
|
- 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).
|
- 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.
|
- Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function.
|
||||||
|
|
||||||
## Adding a new scene
|
## Adding a new scene
|
||||||
|
|
||||||
- Create `src/scenes/scene-<your-id>.js` based on `src/scenes/scene-template.js` or an existing scene, and keep the `id` aligned to the filename suffix.
|
- Create `src/scenes/scene-<your-id>.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.
|
- 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).
|
- Register the scene id in `src/scenes/index.js` (append to `desiredOrder` or rely on the unordered fallback).
|
||||||
|
|||||||
8
assets/sfx/SOURCES.txt
Normal file
8
assets/sfx/SOURCES.txt
Normal file
@@ -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
|
||||||
BIN
assets/sfx/clear-pop.ogg
Normal file
BIN
assets/sfx/clear-pop.ogg
Normal file
Binary file not shown.
BIN
assets/sfx/drag-buzz.ogg
Normal file
BIN
assets/sfx/drag-buzz.ogg
Normal file
Binary file not shown.
11
assets/sfx/link-click.ogg
Normal file
11
assets/sfx/link-click.ogg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/sounds/v1/ui/button_click.ogg</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
<script src="./src/scenes/scene-fastdrop.js"></script>
|
<script src="./src/scenes/scene-fastdrop.js"></script>
|
||||||
<script src="./src/scenes/scene-lavalamp.js"></script>
|
<script src="./src/scenes/scene-lavalamp.js"></script>
|
||||||
<script src="./src/scenes/scene-relax.js"></script>
|
<script src="./src/scenes/scene-relax.js"></script>
|
||||||
|
<script src="./src/scenes/scene-christmas-calm.js"></script>
|
||||||
<script src="./src/scenes/scene-swirl-arena.js"></script>
|
<script src="./src/scenes/scene-swirl-arena.js"></script>
|
||||||
<script src="./src/scenes/scene-storm-grid.js"></script>
|
<script src="./src/scenes/scene-storm-grid.js"></script>
|
||||||
<script src="./src/scenes/scene-stack-blocks-chaos.js"></script>
|
<script src="./src/scenes/scene-stack-blocks-chaos.js"></script>
|
||||||
@@ -121,8 +122,10 @@
|
|||||||
<script src="./src/scene-registry.js"></script>
|
<script src="./src/scene-registry.js"></script>
|
||||||
<script src="./src/ui.js"></script>
|
<script src="./src/ui.js"></script>
|
||||||
<script src="./src/storage.js"></script>
|
<script src="./src/storage.js"></script>
|
||||||
|
<script src="./src/spawn-balls.js"></script>
|
||||||
<script src="./src/spawn.js"></script>
|
<script src="./src/spawn.js"></script>
|
||||||
<script src="./src/goals.js"></script>
|
<script src="./src/goals.js"></script>
|
||||||
|
<script src="./src/sounds.js"></script>
|
||||||
<script src="./src/chain-controller.js"></script>
|
<script src="./src/chain-controller.js"></script>
|
||||||
<script src="./src/loop.js"></script>
|
<script src="./src/loop.js"></script>
|
||||||
<script src="./src/input.js"></script>
|
<script src="./src/input.js"></script>
|
||||||
|
|||||||
@@ -14,10 +14,20 @@
|
|||||||
updateHud,
|
updateHud,
|
||||||
checkWinCondition,
|
checkWinCondition,
|
||||||
ui,
|
ui,
|
||||||
|
sound,
|
||||||
}) => {
|
}) => {
|
||||||
const setHighlight = (body, on) => {
|
const setHighlight = (body, on) => {
|
||||||
body.render.lineWidth = on ? 4 : 2;
|
const lineWidth = on ? 4 : 2;
|
||||||
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
|
const strokeStyle = on ? "#f8fafc" : "#0b1222";
|
||||||
|
const applyHighlight = (target) => {
|
||||||
|
target.render = target.render || {};
|
||||||
|
target.render.lineWidth = lineWidth;
|
||||||
|
target.render.strokeStyle = strokeStyle;
|
||||||
|
};
|
||||||
|
applyHighlight(body);
|
||||||
|
if (Array.isArray(body.parts) && body.parts.length > 1) {
|
||||||
|
body.parts.forEach((part) => applyHighlight(part));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetChainVisuals = () => {
|
const resetChainVisuals = () => {
|
||||||
@@ -28,6 +38,7 @@
|
|||||||
chain.bodies = [];
|
chain.bodies = [];
|
||||||
chain.constraints = [];
|
chain.constraints = [];
|
||||||
chain.pointer = null;
|
chain.pointer = null;
|
||||||
|
sound?.stopDrag?.();
|
||||||
updateHud();
|
updateHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +78,7 @@
|
|||||||
chain.bodies.push(body);
|
chain.bodies.push(body);
|
||||||
setHighlight(body, true);
|
setHighlight(body, true);
|
||||||
World.add(world, constraint);
|
World.add(world, constraint);
|
||||||
|
sound?.playClick?.();
|
||||||
updateHud();
|
updateHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,15 +243,18 @@
|
|||||||
chain.bodies = [];
|
chain.bodies = [];
|
||||||
chain.constraints = [];
|
chain.constraints = [];
|
||||||
chain.pointer = null;
|
chain.pointer = null;
|
||||||
|
sound?.stopDrag?.();
|
||||||
updateHud();
|
updateHud();
|
||||||
checkWinCondition();
|
checkWinCondition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishChain = (releasePoint) => {
|
const finishChain = (releasePoint) => {
|
||||||
if (!chain.active || state.gameOver || state.paused) return;
|
if (!chain.active || state.gameOver || state.paused) return;
|
||||||
|
sound?.stopDrag?.();
|
||||||
const chainLength = chain.bodies.length;
|
const chainLength = chain.bodies.length;
|
||||||
const currentScene = getCurrentScene();
|
const currentScene = getCurrentScene();
|
||||||
if (chainLength >= config.minChain) {
|
if (chainLength >= config.minChain) {
|
||||||
|
sound?.playPop?.();
|
||||||
updateLongestChain(chainLength);
|
updateLongestChain(chainLength);
|
||||||
const { gain, isNegativeProgress } = getChainScoreState();
|
const { gain, isNegativeProgress } = getChainScoreState();
|
||||||
state.score += gain;
|
state.score += gain;
|
||||||
|
|||||||
@@ -71,6 +71,30 @@
|
|||||||
blur: 24,
|
blur: 24,
|
||||||
speedSec: 30,
|
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,
|
showFps: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +104,10 @@
|
|||||||
...config,
|
...config,
|
||||||
link: { ...config.link },
|
link: { ...config.link },
|
||||||
messages: { ...config.messages },
|
messages: { ...config.messages },
|
||||||
|
sounds: {
|
||||||
|
...config.sounds,
|
||||||
|
channels: { ...config.sounds.channels },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
addToChain,
|
addToChain,
|
||||||
finishChain,
|
finishChain,
|
||||||
updateHud,
|
updateHud,
|
||||||
|
sound,
|
||||||
}) => {
|
}) => {
|
||||||
let dragConstraint = null;
|
let dragConstraint = null;
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
|
|
||||||
const handlePointerDown = (evt) => {
|
const handlePointerDown = (evt) => {
|
||||||
if (isGameOver() || isPaused() || isLevelWon()) return;
|
if (isGameOver() || isPaused() || isLevelWon()) return;
|
||||||
|
sound?.unlock?.();
|
||||||
const point = getPointerPosition(evt);
|
const point = getPointerPosition(evt);
|
||||||
const dragTarget = getDraggableBody(point);
|
const dragTarget = getDraggableBody(point);
|
||||||
if (dragTarget) {
|
if (dragTarget) {
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
chain.constraints = [];
|
chain.constraints = [];
|
||||||
chain.pointer = point;
|
chain.pointer = point;
|
||||||
setHighlight(body, true);
|
setHighlight(body, true);
|
||||||
|
sound?.startDrag?.();
|
||||||
updateHud();
|
updateHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +126,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
|
sound?.stopDrag?.();
|
||||||
if (dragConstraint) {
|
if (dragConstraint) {
|
||||||
endDrag();
|
endDrag();
|
||||||
return;
|
return;
|
||||||
|
|||||||
28
src/main.js
28
src/main.js
@@ -86,12 +86,30 @@
|
|||||||
: null,
|
: 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 normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => {
|
||||||
const {
|
const {
|
||||||
link = {},
|
link = {},
|
||||||
messages = {},
|
messages = {},
|
||||||
goalEffects = {},
|
goalEffects = {},
|
||||||
backdrop = {},
|
backdrop = {},
|
||||||
|
sounds = {},
|
||||||
...rest
|
...rest
|
||||||
} = sceneConfig;
|
} = sceneConfig;
|
||||||
const base = defaults || {};
|
const base = defaults || {};
|
||||||
@@ -105,6 +123,7 @@
|
|||||||
),
|
),
|
||||||
goalEffects: normalizeGoalEffects(goalEffects, base.goalEffects),
|
goalEffects: normalizeGoalEffects(goalEffects, base.goalEffects),
|
||||||
backdrop: normalizeBackdrop(backdrop, base.backdrop),
|
backdrop: normalizeBackdrop(backdrop, base.backdrop),
|
||||||
|
sounds: normalizeSounds(sounds, base.sounds),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +143,12 @@
|
|||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
const { sceneEl } = ui;
|
const { sceneEl } = ui;
|
||||||
|
|
||||||
|
const createSound = load("PhysilinksSound", {
|
||||||
|
create: "create",
|
||||||
|
fallback: () => ({}),
|
||||||
|
});
|
||||||
|
const sound = createSound({ sounds: config.sounds });
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
width: sceneEl.clientWidth,
|
width: sceneEl.clientWidth,
|
||||||
height: sceneEl.clientHeight,
|
height: sceneEl.clientHeight,
|
||||||
@@ -246,6 +271,7 @@
|
|||||||
setSceneIdInUrl(next.id);
|
setSceneIdInUrl(next.id);
|
||||||
const prevRadius = config.ballRadius;
|
const prevRadius = config.ballRadius;
|
||||||
setConfigForScene(next.config);
|
setConfigForScene(next.config);
|
||||||
|
sound?.setConfig?.(config.sounds);
|
||||||
ui.setBackdrop(config.backdrop, config.palette);
|
ui.setBackdrop(config.backdrop, config.palette);
|
||||||
ui.setFpsVisibility(config.showFps);
|
ui.setFpsVisibility(config.showFps);
|
||||||
ui.setMessageDefaults(config.messages);
|
ui.setMessageDefaults(config.messages);
|
||||||
@@ -613,6 +639,7 @@
|
|||||||
updateHud,
|
updateHud,
|
||||||
checkWinCondition,
|
checkWinCondition,
|
||||||
ui,
|
ui,
|
||||||
|
sound,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createInput = load("PhysilinksInput", { create: "create" });
|
const createInput = load("PhysilinksInput", { create: "create" });
|
||||||
@@ -633,6 +660,7 @@
|
|||||||
addToChain,
|
addToChain,
|
||||||
finishChain,
|
finishChain,
|
||||||
updateHud,
|
updateHud,
|
||||||
|
sound,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildLegend = () => {
|
const buildLegend = () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"scene-lava",
|
"scene-lava",
|
||||||
"swirl-arena",
|
"swirl-arena",
|
||||||
"relax",
|
"relax",
|
||||||
|
"christmas-calm",
|
||||||
"stack-blocks-chaos",
|
"stack-blocks-chaos",
|
||||||
"low-g-terraces",
|
"low-g-terraces",
|
||||||
"fast-drop-maze",
|
"fast-drop-maze",
|
||||||
|
|||||||
171
src/scenes/scene-christmas-calm.js
Normal file
171
src/scenes/scene-christmas-calm.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
(() => {
|
||||||
|
const { Bodies, Body, Vertices } = Matter;
|
||||||
|
const scenes = (window.PhysilinksSceneDefs =
|
||||||
|
window.PhysilinksSceneDefs || []);
|
||||||
|
|
||||||
|
scenes.push({
|
||||||
|
id: "christmas-calm",
|
||||||
|
name: "Christmas calm",
|
||||||
|
config: {
|
||||||
|
gravity: 0.5,
|
||||||
|
spawnIntervalMs: 720,
|
||||||
|
spawnBatchMin: 1,
|
||||||
|
spawnBatchMax: 2,
|
||||||
|
minChain: 3,
|
||||||
|
palette: ["#b91c1c", "#15803d", "#f59e0b", "#7dd3fc", "#fda4af"],
|
||||||
|
ballRadius: 20,
|
||||||
|
ballShape: "gift",
|
||||||
|
giftRibbonColor: "#f8fafc",
|
||||||
|
debugSpawn: true,
|
||||||
|
initialSpawnCount: 10,
|
||||||
|
initialSpawnArea: ({ width, height }) => ({
|
||||||
|
xMin: width * 0.28,
|
||||||
|
xMax: width * 0.72,
|
||||||
|
yMin: height * 0.76,
|
||||||
|
yMax: height * 0.88,
|
||||||
|
}),
|
||||||
|
winCondition: {
|
||||||
|
type: "score",
|
||||||
|
target: 12000,
|
||||||
|
onWin: { shoveBalls: true },
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
text: "Cozy winter glow",
|
||||||
|
position: { xPercent: 50, yPercent: 12 },
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
stiffness: 0.7,
|
||||||
|
lengthScale: 1.05,
|
||||||
|
damping: 0.05,
|
||||||
|
lineWidth: 4,
|
||||||
|
rope: true,
|
||||||
|
renderType: "line",
|
||||||
|
maxLengthMultiplier: 5.4,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
colors: ["#0f172a", "#14532d", "#1f2937"],
|
||||||
|
opacity: 0.35,
|
||||||
|
blur: 26,
|
||||||
|
speedSec: 34,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createBodies: (w, h) => {
|
||||||
|
const wallThickness = Math.max(32, w * 0.045);
|
||||||
|
const wallHeight = h * 1.7;
|
||||||
|
const floorHeight = Math.max(70, h * 0.12);
|
||||||
|
const treeBaseRadius = Math.min(w, h) * 0.22;
|
||||||
|
const treeMidRadius = Math.min(w, h) * 0.16;
|
||||||
|
const treeTopRadius = Math.min(w, h) * 0.11;
|
||||||
|
const trunkWidth = treeBaseRadius * 0.3;
|
||||||
|
const trunkHeight = treeBaseRadius * 0.35;
|
||||||
|
const treeCenterX = w * 0.5;
|
||||||
|
const treeBaseY = h * 0.62;
|
||||||
|
const treeMidY = h * 0.52;
|
||||||
|
const treeTopY = h * 0.43;
|
||||||
|
const treeColor = "#166534";
|
||||||
|
const trunkColor = "#7c2d12";
|
||||||
|
|
||||||
|
const makeTriangle = (x, y, radius) => {
|
||||||
|
const points = [
|
||||||
|
{ x: 0, y: -radius },
|
||||||
|
{ x: radius, y: radius },
|
||||||
|
{ x: -radius, y: radius },
|
||||||
|
];
|
||||||
|
return Bodies.fromVertices(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
[points],
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: treeColor, strokeStyle: treeColor },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeComet = (x, y, size) => {
|
||||||
|
const baseStar = Vertices.fromPath(
|
||||||
|
"50 0 63 38 100 38 69 59 82 100 50 75 18 100 31 59 0 38 37 38",
|
||||||
|
);
|
||||||
|
const scale = (size || 50) / 50;
|
||||||
|
const points = baseStar.map((p) => ({
|
||||||
|
x: (p.x - 50) * scale,
|
||||||
|
y: (p.y - 50) * scale,
|
||||||
|
}));
|
||||||
|
const star = Bodies.fromVertices(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
[points],
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#facc15", strokeStyle: "#facc15" },
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const tail = Bodies.rectangle(
|
||||||
|
x + size * 1.1,
|
||||||
|
y + size * 0.2,
|
||||||
|
size * 2.1,
|
||||||
|
size * 0.45,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
angle: 0.35,
|
||||||
|
render: { fillStyle: "#fde68a", strokeStyle: "#fde68a" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const starParts =
|
||||||
|
Array.isArray(star.parts) && star.parts.length > 1
|
||||||
|
? star.parts
|
||||||
|
: [star];
|
||||||
|
return Body.create({
|
||||||
|
isStatic: true,
|
||||||
|
parts: [...starParts, tail],
|
||||||
|
render: { fillStyle: "#facc15", strokeStyle: "#facc15" },
|
||||||
|
plugin: { rotSpeed: 0.35 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
Bodies.rectangle(
|
||||||
|
w / 2,
|
||||||
|
h + floorHeight / 2,
|
||||||
|
w + wallThickness * 2,
|
||||||
|
floorHeight,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
restitution: 0.7,
|
||||||
|
render: { fillStyle: "#e2e8f0", strokeStyle: "#e2e8f0" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0f766e", strokeStyle: "#0f766e" },
|
||||||
|
}),
|
||||||
|
Bodies.rectangle(
|
||||||
|
w + wallThickness / 2,
|
||||||
|
h / 2,
|
||||||
|
wallThickness,
|
||||||
|
wallHeight,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0f766e", strokeStyle: "#0f766e" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
makeTriangle(treeCenterX, treeBaseY, treeBaseRadius),
|
||||||
|
makeTriangle(treeCenterX, treeMidY, treeMidRadius),
|
||||||
|
makeTriangle(treeCenterX, treeTopY, treeTopRadius),
|
||||||
|
Bodies.rectangle(
|
||||||
|
treeCenterX,
|
||||||
|
treeBaseY + treeBaseRadius * 0.65,
|
||||||
|
trunkWidth,
|
||||||
|
trunkHeight,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: trunkColor, strokeStyle: trunkColor },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
makeComet(w * 0.2, h * 0.25, Math.max(18, w * 0.025)),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
354
src/sounds.js
Normal file
354
src/sounds.js
Normal file
@@ -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 };
|
||||||
|
})();
|
||||||
181
src/spawn-balls.js
Normal file
181
src/spawn-balls.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
(() => {
|
||||||
|
const { Bodies, Composites, Body } = Matter;
|
||||||
|
|
||||||
|
const create = ({ config, getCurrentScene }) => {
|
||||||
|
const applyBallPlugin = (body, { color, shape, blobId } = {}) => {
|
||||||
|
body.plugin = {
|
||||||
|
color,
|
||||||
|
hasEntered: false,
|
||||||
|
entryCheckId: null,
|
||||||
|
};
|
||||||
|
if (shape) body.plugin.shape = shape;
|
||||||
|
if (blobId) body.plugin.blobId = blobId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommonBallOpts = (scene, color) => {
|
||||||
|
const ballPhysics = scene?.config?.ballPhysics || {};
|
||||||
|
return {
|
||||||
|
restitution: ballPhysics.restitution ?? 0.72,
|
||||||
|
friction: ballPhysics.friction ?? 0.01,
|
||||||
|
frictionAir: ballPhysics.frictionAir ?? 0.012,
|
||||||
|
frictionStatic: ballPhysics.frictionStatic ?? 0,
|
||||||
|
density: ballPhysics.density ?? 0.001,
|
||||||
|
render: {
|
||||||
|
fillStyle: color,
|
||||||
|
strokeStyle: "#0b1222",
|
||||||
|
lineWidth: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSoftBlob = (x, y, color, commonOpts) => {
|
||||||
|
const cols = 3;
|
||||||
|
const rows = 2;
|
||||||
|
const radius = Math.max(10, config.ballRadius * 0.55);
|
||||||
|
const soft = Composites.softBody(
|
||||||
|
x - cols * radius * 1.2,
|
||||||
|
y - rows * radius * 1.2,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
radius,
|
||||||
|
commonOpts,
|
||||||
|
);
|
||||||
|
const blobId = `blob-${Date.now()}-${Math.random()
|
||||||
|
.toString(16)
|
||||||
|
.slice(2)}`;
|
||||||
|
soft.bodies.forEach((b) => applyBallPlugin(b, { color, blobId }));
|
||||||
|
soft.constraints.forEach((c) => {
|
||||||
|
c.plugin = { blobId, blobConstraint: true };
|
||||||
|
c.render = c.render || {};
|
||||||
|
c.render.type = "line";
|
||||||
|
});
|
||||||
|
return { bodies: soft.bodies, constraints: soft.constraints, blobId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createJaggedBall = (x, y, color, commonOpts) => {
|
||||||
|
const points = [];
|
||||||
|
const segments = 6;
|
||||||
|
for (let i = 0; i < segments; i += 1) {
|
||||||
|
const angle = Math.min((i / segments) * Math.PI * 2, Math.PI * 2);
|
||||||
|
const variance = 0.6 + Math.random() * 0.5;
|
||||||
|
const r = config.ballRadius * variance;
|
||||||
|
points.push({
|
||||||
|
x: x + Math.cos(angle) * r,
|
||||||
|
y: y + Math.sin(angle) * r,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const body = Bodies.fromVertices(x, y, [points], commonOpts, true);
|
||||||
|
applyBallPlugin(body, { color, shape: "jagged" });
|
||||||
|
return { bodies: [body], constraints: [], blobId: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGiftBall = (x, y, color, commonOpts, scene, debugSpawn) => {
|
||||||
|
const size = config.ballRadius * 2;
|
||||||
|
const ribbonSize = Math.max(4, config.ballRadius * 0.35);
|
||||||
|
const ribbonColor = scene?.config?.giftRibbonColor || "#f8fafc";
|
||||||
|
if (debugSpawn) {
|
||||||
|
console.log("Spawn gift", {
|
||||||
|
sceneId: scene?.id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const base = Bodies.rectangle(x, y, size, size, {
|
||||||
|
...commonOpts,
|
||||||
|
chamfer: { radius: Math.max(2, config.ballRadius * 0.18) },
|
||||||
|
});
|
||||||
|
const ribbonRender = {
|
||||||
|
fillStyle: ribbonColor,
|
||||||
|
strokeStyle: "#0b1222",
|
||||||
|
lineWidth: 2,
|
||||||
|
};
|
||||||
|
const verticalRibbon = Bodies.rectangle(x, y, ribbonSize, size * 1.02, {
|
||||||
|
render: ribbonRender,
|
||||||
|
});
|
||||||
|
const horizontalRibbon = Bodies.rectangle(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size * 1.02,
|
||||||
|
ribbonSize,
|
||||||
|
{
|
||||||
|
render: ribbonRender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const bow = Bodies.rectangle(
|
||||||
|
x,
|
||||||
|
y - size * 0.34,
|
||||||
|
ribbonSize * 1.4,
|
||||||
|
ribbonSize * 0.8,
|
||||||
|
{
|
||||||
|
render: ribbonRender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = Body.create({
|
||||||
|
parts: [base, verticalRibbon, horizontalRibbon, bow],
|
||||||
|
});
|
||||||
|
Body.setPosition(body, { x, y });
|
||||||
|
body.restitution = commonOpts.restitution;
|
||||||
|
body.friction = commonOpts.friction;
|
||||||
|
body.frictionAir = commonOpts.frictionAir;
|
||||||
|
body.frictionStatic = commonOpts.frictionStatic;
|
||||||
|
body.density = commonOpts.density;
|
||||||
|
body.render = {
|
||||||
|
...body.render,
|
||||||
|
...commonOpts.render,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
applyBallPlugin(body, { color, shape: "gift" });
|
||||||
|
return { bodies: [body], constraints: [], blobId: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRectBall = (x, y, color, commonOpts) => {
|
||||||
|
const side = config.ballRadius * 2;
|
||||||
|
const body = Bodies.rectangle(x, y, side, side, {
|
||||||
|
...commonOpts,
|
||||||
|
chamfer: 0,
|
||||||
|
});
|
||||||
|
applyBallPlugin(body, { color, shape: "rect" });
|
||||||
|
return { bodies: [body], constraints: [], blobId: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCircleBall = (x, y, color, commonOpts) => {
|
||||||
|
const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
|
||||||
|
applyBallPlugin(body, { color, shape: "circle" });
|
||||||
|
return { bodies: [body], constraints: [], blobId: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ballShapeFactories = {
|
||||||
|
gift: createGiftBall,
|
||||||
|
rect: createRectBall,
|
||||||
|
circle: createCircleBall,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBallBodies = (x, y, color) => {
|
||||||
|
const scene = getCurrentScene();
|
||||||
|
const debugSpawn = !!scene?.config?.debugSpawn;
|
||||||
|
const commonOpts = getCommonBallOpts(scene, color);
|
||||||
|
if (scene?.config?.blobBalls === "soft") {
|
||||||
|
return createSoftBlob(x, y, color, commonOpts);
|
||||||
|
}
|
||||||
|
if (scene?.config?.blobBalls === "jagged") {
|
||||||
|
return createJaggedBall(x, y, color, commonOpts);
|
||||||
|
}
|
||||||
|
const shape = scene?.config?.ballShape || "circle";
|
||||||
|
const factory = ballShapeFactories[shape] || createCircleBall;
|
||||||
|
return factory(x, y, color, commonOpts, scene, debugSpawn);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createBallBodies,
|
||||||
|
applyBallPlugin,
|
||||||
|
getCommonBallOpts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PhysilinksSpawnBalls = { create };
|
||||||
|
})();
|
||||||
148
src/spawn.js
148
src/spawn.js
@@ -1,5 +1,5 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const { World, Bodies, Composites, Query, Body } = Matter;
|
const { World, Bodies, Query, Body } = Matter;
|
||||||
|
|
||||||
const create = ({
|
const create = ({
|
||||||
config,
|
config,
|
||||||
@@ -117,103 +117,21 @@
|
|||||||
config.ballRadius = nextRadius;
|
config.ballRadius = nextRadius;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBallBodies = (x, y, color) => {
|
const createBallBodies = window.PhysilinksSpawnBalls?.create({
|
||||||
const scene = getCurrentScene();
|
config,
|
||||||
const ballPhysics = scene?.config?.ballPhysics || {};
|
getCurrentScene,
|
||||||
const commonOpts = {
|
})?.createBallBodies;
|
||||||
restitution: ballPhysics.restitution ?? 0.72,
|
|
||||||
friction: ballPhysics.friction ?? 0.01,
|
|
||||||
frictionAir: ballPhysics.frictionAir ?? 0.012,
|
|
||||||
frictionStatic: ballPhysics.frictionStatic ?? 0,
|
|
||||||
density: ballPhysics.density ?? 0.001,
|
|
||||||
render: {
|
|
||||||
fillStyle: color,
|
|
||||||
strokeStyle: "#0b1222",
|
|
||||||
lineWidth: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (scene?.config?.blobBalls === "soft") {
|
|
||||||
const cols = 3;
|
|
||||||
const rows = 2;
|
|
||||||
const radius = Math.max(10, config.ballRadius * 0.55);
|
|
||||||
const soft = Composites.softBody(
|
|
||||||
x - cols * radius * 1.2,
|
|
||||||
y - rows * radius * 1.2,
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
true,
|
|
||||||
radius,
|
|
||||||
commonOpts,
|
|
||||||
);
|
|
||||||
const blobId = `blob-${Date.now()}-${Math.random()
|
|
||||||
.toString(16)
|
|
||||||
.slice(2)}`;
|
|
||||||
soft.bodies.forEach((b) => {
|
|
||||||
b.plugin = {
|
|
||||||
color,
|
|
||||||
hasEntered: false,
|
|
||||||
entryCheckId: null,
|
|
||||||
blobId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
soft.constraints.forEach((c) => {
|
|
||||||
c.plugin = { blobId, blobConstraint: true };
|
|
||||||
c.render = c.render || {};
|
|
||||||
c.render.type = "line";
|
|
||||||
});
|
|
||||||
return { bodies: soft.bodies, constraints: soft.constraints, blobId };
|
|
||||||
}
|
|
||||||
if (scene?.config?.blobBalls === "jagged") {
|
|
||||||
const points = [];
|
|
||||||
const segments = 6;
|
|
||||||
for (let i = 0; i < segments; i += 1) {
|
|
||||||
const angle = Math.min((i / segments) * Math.PI * 2, Math.PI * 2);
|
|
||||||
const variance = 0.6 + Math.random() * 0.5;
|
|
||||||
const r = config.ballRadius * variance;
|
|
||||||
points.push({
|
|
||||||
x: x + Math.cos(angle) * r,
|
|
||||||
y: y + Math.sin(angle) * r,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const body = Bodies.fromVertices(x, y, [points], commonOpts, true);
|
|
||||||
body.plugin = {
|
|
||||||
color,
|
|
||||||
hasEntered: false,
|
|
||||||
entryCheckId: null,
|
|
||||||
shape: "jagged",
|
|
||||||
};
|
|
||||||
return { bodies: [body], constraints: [], blobId: null };
|
|
||||||
}
|
|
||||||
if (scene?.config?.ballShape === "rect") {
|
|
||||||
const side = config.ballRadius * 2;
|
|
||||||
const body = Bodies.rectangle(x, y, side, side, {
|
|
||||||
...commonOpts,
|
|
||||||
chamfer: 0,
|
|
||||||
});
|
|
||||||
body.plugin = {
|
|
||||||
color,
|
|
||||||
hasEntered: false,
|
|
||||||
entryCheckId: null,
|
|
||||||
shape: "rect",
|
|
||||||
};
|
|
||||||
return { bodies: [body], constraints: [], blobId: null };
|
|
||||||
}
|
|
||||||
const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
|
|
||||||
body.plugin = {
|
|
||||||
color,
|
|
||||||
hasEntered: false,
|
|
||||||
entryCheckId: null,
|
|
||||||
shape: "circle",
|
|
||||||
};
|
|
||||||
return { bodies: [body], constraints: [], blobId: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
const spawnBall = () => {
|
const spawnBall = () => {
|
||||||
if (isGameOver()) return;
|
if (isGameOver()) return;
|
||||||
const scene = getCurrentScene();
|
const scene = getCurrentScene();
|
||||||
const sceneConfig = scene?.config || {};
|
const sceneConfig = scene?.config || {};
|
||||||
|
if (sceneConfig.debugSpawn) {
|
||||||
|
console.log("Spawn tick", {
|
||||||
|
sceneId: scene?.id,
|
||||||
|
ballShape: sceneConfig.ballShape,
|
||||||
|
});
|
||||||
|
}
|
||||||
const { width, height } = getDimensions();
|
const { width, height } = getDimensions();
|
||||||
const spawnLimit = sceneConfig.spawnLimit;
|
const spawnLimit = sceneConfig.spawnLimit;
|
||||||
if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) {
|
if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) {
|
||||||
@@ -393,6 +311,50 @@
|
|||||||
const scene = getCurrentScene();
|
const scene = getCurrentScene();
|
||||||
const initialCount = scene?.config?.initialSpawnCount || 0;
|
const initialCount = scene?.config?.initialSpawnCount || 0;
|
||||||
if (!initialCount || initialCount <= 0) return;
|
if (!initialCount || initialCount <= 0) return;
|
||||||
|
if (scene?.config?.debugSpawn) {
|
||||||
|
console.log("Initial burst", {
|
||||||
|
sceneId: scene?.id,
|
||||||
|
initialCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const areaSource = scene?.config?.initialSpawnArea;
|
||||||
|
let area = null;
|
||||||
|
if (typeof areaSource === "function") {
|
||||||
|
try {
|
||||||
|
area = areaSource({ ...getDimensions(), world });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("initialSpawnArea function failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scene?.config?.debugSpawn) {
|
||||||
|
console.log("Initial spawn area", {
|
||||||
|
sceneId: scene?.id,
|
||||||
|
area,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (area && Number.isFinite(area.xMin) && Number.isFinite(area.xMax)) {
|
||||||
|
const { width, height } = getDimensions();
|
||||||
|
const pad = config.ballRadius + 4;
|
||||||
|
const minX = Math.max(pad, Math.min(area.xMin, area.xMax));
|
||||||
|
const maxX = Math.min(width - pad, Math.max(area.xMin, area.xMax));
|
||||||
|
const minY = Math.max(pad, Math.min(area.yMin, area.yMax));
|
||||||
|
const maxY = Math.min(height - pad, Math.max(area.yMin, area.yMax));
|
||||||
|
if (scene?.config?.debugSpawn) {
|
||||||
|
console.log("Initial spawn bounds", {
|
||||||
|
sceneId: scene?.id,
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < initialCount; i += 1) {
|
||||||
|
const x = minX + Math.random() * Math.max(0, maxX - minX);
|
||||||
|
const y = minY + Math.random() * Math.max(0, maxY - minY);
|
||||||
|
spawnAtPosition({ x, y, markEntered: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let i = 0; i < initialCount; i += 1) {
|
for (let i = 0; i < initialCount; i += 1) {
|
||||||
spawnBall();
|
spawnBall();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user