From dc68c6e3dd1f89ad5bce6f667009df32624a6fff Mon Sep 17 00:00:00 2001 From: Relay Date: Tue, 29 Sep 2026 13:21:18 +0000 Subject: [PATCH] Physics upgrade + repo modularization (RC-375D84) Delivered by Hermies Qwen, gated and deployed by Relay. - Board tip/topple: full continuous rotation when a board's center of mass passes its support edge (Q1: smooth pivot swing, no discrete snap). Falling boards deal impact damage and can kill enemies in their fall path. - Falling-object impact damage: blocks and birds can now kill enemies from vertical drops and side plows (velocity-aware crush gate), chain momentum transfers correctly through stacks (mass-weighted contact), repeated impacts finish tougher enemies. - Landed-bird physics: a bird resting on a board remains a real physical body under continuous gravity (Q2: no lifetime timer). If its platform is destroyed it falls, damages what it lands on (chain reaction), and can kill enemies. - Repo modularized: folders = modules, subfolders = submodules, architecture.md + MODULES.md at root, an agents.md card in every module and submodule (17 total). - Test suite grown from 27 to 47 (20 new RED-watched tests for the above behaviors), all green. Golden differential against the original core confirmed the only behavioral changes are the intended ones. Live at http://10.244.136.121:8093/, independently re-verified by Relay (full suite re-run, module structure check, headless Chromium smoke) before this push. Co-Authored-By: Claude Sonnet 5 --- BUILD-RECORD.md | 28 +- MODULES.md | 58 +++ architecture.md | 154 +++++++ index.html | 30 +- js/app/agents.md | 17 + js/{main.js => app/index.js} | 0 js/audio/agents.md | 14 + js/{audio.js => audio/index.js} | 0 js/core.js | 658 ---------------------------- js/levels.js | 158 ------- js/levels/agents.md | 14 + js/levels/data/agents.md | 10 + js/levels/data/level-1.js | 31 ++ js/levels/data/level-2.js | 37 ++ js/levels/data/level-3.js | 40 ++ js/levels/data/level-4.js | 43 ++ js/levels/data/level-5.js | 46 ++ js/levels/helpers/agents.md | 10 + js/levels/helpers/index.js | 21 + js/levels/index.js | 44 ++ js/physics/abilities/agents.md | 12 + js/physics/abilities/index.js | 65 +++ js/physics/agents.md | 23 + js/physics/aim/agents.md | 14 + js/physics/aim/index.js | 45 ++ js/physics/camera/agents.md | 9 + js/physics/camera/index.js | 70 +++ js/physics/collision/agents.md | 18 + js/physics/collision/index.js | 212 +++++++++ js/physics/constants/agents.md | 15 + js/physics/constants/index.js | 54 +++ js/physics/damage/agents.md | 14 + js/physics/damage/index.js | 45 ++ js/physics/index.js | 72 +++ js/physics/rules/agents.md | 11 + js/physics/rules/index.js | 59 +++ js/physics/state/agents.md | 18 + js/physics/state/index.js | 85 ++++ js/physics/step/agents.md | 22 + js/physics/step/index.js | 212 +++++++++ js/physics/topple/agents.md | 23 + js/physics/topple/index.js | 223 ++++++++++ js/render/agents.md | 16 + js/{renderer.js => render/index.js} | 9 +- package.json | 2 +- test/balance.test.mjs | 4 +- test/fallingkill.test.mjs | 121 +++++ test/knifeedge.test.mjs | 62 +++ test/landedbird.test.mjs | 151 +++++++ test/page.test.mjs | 13 +- test/sim.test.mjs | 43 +- test/topple.test.mjs | 96 ++++ 52 files changed, 2413 insertions(+), 838 deletions(-) create mode 100644 MODULES.md create mode 100644 architecture.md create mode 100644 js/app/agents.md rename js/{main.js => app/index.js} (100%) create mode 100644 js/audio/agents.md rename js/{audio.js => audio/index.js} (100%) delete mode 100644 js/core.js delete mode 100644 js/levels.js create mode 100644 js/levels/agents.md create mode 100644 js/levels/data/agents.md create mode 100644 js/levels/data/level-1.js create mode 100644 js/levels/data/level-2.js create mode 100644 js/levels/data/level-3.js create mode 100644 js/levels/data/level-4.js create mode 100644 js/levels/data/level-5.js create mode 100644 js/levels/helpers/agents.md create mode 100644 js/levels/helpers/index.js create mode 100644 js/levels/index.js create mode 100644 js/physics/abilities/agents.md create mode 100644 js/physics/abilities/index.js create mode 100644 js/physics/agents.md create mode 100644 js/physics/aim/agents.md create mode 100644 js/physics/aim/index.js create mode 100644 js/physics/camera/agents.md create mode 100644 js/physics/camera/index.js create mode 100644 js/physics/collision/agents.md create mode 100644 js/physics/collision/index.js create mode 100644 js/physics/constants/agents.md create mode 100644 js/physics/constants/index.js create mode 100644 js/physics/damage/agents.md create mode 100644 js/physics/damage/index.js create mode 100644 js/physics/index.js create mode 100644 js/physics/rules/agents.md create mode 100644 js/physics/rules/index.js create mode 100644 js/physics/state/agents.md create mode 100644 js/physics/state/index.js create mode 100644 js/physics/step/agents.md create mode 100644 js/physics/step/index.js create mode 100644 js/physics/topple/agents.md create mode 100644 js/physics/topple/index.js create mode 100644 js/render/agents.md rename js/{renderer.js => render/index.js} (97%) create mode 100644 test/fallingkill.test.mjs create mode 100644 test/knifeedge.test.mjs create mode 100644 test/landedbird.test.mjs create mode 100644 test/topple.test.mjs diff --git a/BUILD-RECORD.md b/BUILD-RECORD.md index de7696f..318ad1d 100644 --- a/BUILD-RECORD.md +++ b/BUILD-RECORD.md @@ -377,10 +377,10 @@ python3 -m http.server 8000 # then open http://localhost:8000/ # or open index.html directly in a browser # verify (both arms): -node --check js/core.js js/renderer.js js/main.js js/levels.js node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability node test/page.test.mjs # 7/7 — page boot, click session, direction, release -# or: npm test (runs both, in order) +node test/balance.test.mjs# 5/5 — balance spec (material break + enemy kill budgets) +# or: npm test (runs all, in order) ``` ## 8. Known limitations (honest list) @@ -406,3 +406,27 @@ designs are original. The game is named **Isle Slingers**. See the repository commit SHA recorded in the RC completion payload. Working tree at commit time contains exactly the files listed in §3. + +## 11. Addendum — RC-375D84 S-2 modularization (2026-09-28) + +The flat `js/{core,levels,audio,renderer,main}.js` files described in §3 no +longer exist. The tree was restructured (behavior-neutral, proven by a +golden-step hash — identical scripted playthroughs through the original +monolith and the split build produce byte-identical state hashes — and by +27/27 green with the old files DELETED, so the new paths are the only load +path): + +- `js/physics/` — the engine, split into 9 submodules (constants, state, + aim, abilities, damage, step, collision, rules, camera) that install into + ONE shared scope `P`; `js/physics/index.js` aggregates and exports the + unchanged 20-key `ISLE_CORE` API. +- `js/levels/` — `helpers/` (constructors + SLING) + `data/level-1..5.js` + into shared scope `L`; `index.js` exports the unchanged `{LEVELS, SLING}`. +- `js/audio/`, `js/render/`, `js/app/` — the former audio.js / renderer.js / + main.js, moved into module folders (content unchanged at S-2 time). +- Every module and submodule carries an `agents.md`; the master list is + `MODULES.md`; the runtime model and load-order contract is `architecture.md`. +- The test suites were re-pointed to the new paths (`sim.test.mjs` now walks + the whole `js/` tree; `page.test.mjs` loads the 19-script browser order). + +See `architecture.md` before touching any of these. diff --git a/MODULES.md b/MODULES.md new file mode 100644 index 0000000..4acaf4c --- /dev/null +++ b/MODULES.md @@ -0,0 +1,58 @@ +# Isle Slingers — MODULES.md (master module list) + +RC-375D84 S-2. Folders = modules, subfolders = submodules. Every module and +submodule carries an `agents.md` describing its surface and invariants. +Load contract (order is load-bearing) is in `architecture.md` and encoded in +`index.html` + `test/page.test.mjs`. + +| # | Module | Submodules | Public global | Role | +|---|--------|-----------|---------------|------| +| 1 | `js/physics/` | constants, state, aim, abilities, damage, step, collision, topple, rules, camera | `ISLE_CORE` (via `js/physics/index.js`) | Deterministic DOM-free physics engine (shared scope `P`) | +| 2 | `js/levels/` | helpers, data (level-1..5) | `ISLE_LEVELS` `{LEVELS, SLING}` (via `js/levels/index.js`) | Level geometry + enemy/bird layouts (shared scope `L`) | +| 3 | `js/audio/` | — (single file) | `ISLE_AUDIO` `{AudioEngine}` | Synthesized WebAudio SFX (no assets) | +| 4 | `js/render/` | — (single file) | `ISLE_RENDERER` `{Renderer, VIEW_W, VIEW_H}` | Canvas drawing of the state object | +| 5 | `js/app/` | — (single file) | page state on `window` | Input, screens/HUD, fixed-timestep main loop | + +## physics submodules (shared scope `P`) + +| Submodule | Installs (into `P`) | Consumes from `P` | +|-----------|--------------------|--------------------| +| constants | WORLD, VIEW, MATERIALS, BIRD_TYPES, ENEMY_TYPES, GRAVITY, LAUNCH_POWER, MAX_PULL, MIN_PULL, AIR_DRAG, BIRD_BOUNCE, BIRD_GROUND_FRICTION, REST_SPEED, SETTLE_TIME, BIRD_MAX_AGE, ABILITY_CD, BIRD_BONUS | — | +| state | createGame, makeBird, activeBird | WORLD, MATERIALS, BIRD_TYPES, ENEMY_TYPES, makeBird | +| aim | aimVector, trajectory | LAUNCH_POWER, GRAVITY, AIR_DRAG, BIRD_BOUNCE, WORLD, BIRD_GROUND_FRICTION | +| abilities | launch, triggerAbility | activeBird, aimVector, MATERIALS, BIRD_TYPES | +| damage | damageBlock, damageEnemy | MATERIALS, ENEMY_TYPES | +| step | step, stepPhysics, spendBird | (nearly all — the physics hub) | +| collision | crushCircleVsBox, collideBirdWorld, collideBirdBlocks, collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair | MATERIALS, WORLD, damageBlock, damageEnemy | +| topple | toppleBlocks, blockSupportSpan, beginTopple, integrateTopple, landTopple, topplePathDamage, settleBack, supportStillAlive | WORLD, GRAVITY, MATERIALS, REST_SPEED, damageBlock, damageEnemy, ENEMY_TYPES | +| rules | checkEndStates, birdsRemaining, starsFor | — | +| camera | updatePopups, updateCameraTarget, decayCamera | WORLD, VIEW | + +## levels submodules (shared scope `L`) + +| Submodule | Installs (into `L`) | Consumes from `L` | +|-----------|--------------------|--------------------| +| helpers | wood, ice, stone, mogg, grum, king, SLING | — | +| data/level-N | one level object each (returns it) | wood/ice/stone/mogg/grum/king, SLING | + +## Physics notes (S-3/S-4/S-5, RC-375D84 — landed 2026-09-29) +- **topple** (S-3): blocks carry a rotational DOF (`ang`, `angVel`, `pivoting`). + A resting block whose COM is past its support span pivots about the + support-edge corner (full continuous rotation, Q1) and either lands on the + ground (impact damage via `topplePathDamage`) or, if the pivot is too high to + reach the ground, swings back and settles flat (`settleBack`, anti-livelock). +- **collision** (S-4): crush is velocity-direction-aware (`boxAlong > 30` — + falling boxes crush enemies from above or the side), per-enemy crush cooldown + (0.12 s, one landing = one crush), and stacked block contact is mass-weighted + so heavy blocks drive light planks (chain-crush). +- **step** (S-5): a settled bird with another bird in play becomes `landed` + (a live body under real gravity, no lifetime timer) and is integrated with + the blocks; the last bird still spends (win/lose unchanged). `rules` must not + declare `lost` while a landed bird is in the world. +- **state** (S-3/S-5): block records carry `ang`/`angVel`/`pivoting`; bird + records gain the `landed` state (queued | aiming | flying | landed | spent). + +## Invariants (see architecture.md) +- `ISLE_CORE` and `ISLE_LEVELS` public API surfaces are key-for-key stable. +- Deterministic core: `createGame` + `step` are DOM-free and headless-safe. +- Fixed timestep 1/120 s. Game is fully offline; no new runtime deps. diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..c8037a2 --- /dev/null +++ b/architecture.md @@ -0,0 +1,154 @@ +# Isle Slingers — Architecture + +Original architecture for a fully offline, canvas-rendered slingshot game. +No frameworks, no build step, no external assets. As of RC-375D84 (S-2) the +JavaScript is a **modularized tree**: folders are modules, subfolders are +submodules, and every module/submodule carries its own `agents.md`. + +## Runtime model (plain ` - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/app/agents.md b/js/app/agents.md new file mode 100644 index 0000000..f1bbb8b --- /dev/null +++ b/js/app/agents.md @@ -0,0 +1,17 @@ +# js/app/ — agents.md +RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`. + +Self-contained module — the page. Owns DOM, input, screens and the main loop. + +- Reads `window.ISLE_CORE`, `ISLE_LEVELS`, `ISLE_RENDERER`, `ISLE_AUDIO`; writes + `window.ISLE_PAGE = { getState }` for tests. +- Fixed-timestep loop: rAF accumulator at 1/120 s feeding `ISLE_CORE.step`; + `pumpFrames` in the page test drives it. +- Pointer model: mousedown near the sling arms aim, mousemove drags, mouseup + calls `ISLE_CORE.launch` with `aimVector(...)`; tap mid-flight = + `triggerAbility`. The direction test (bug #15) asserts pulling back launches + TOWARD the fort. +- Screens: start / level-select / pause / win / lose; HUD (level, birds, score, + pause, mute). Level select unlocks by localStorage progress. +- Keep DOM touches in this file only — physics/levels/audio/render must stay + DOM-free so the headless suites keep working. diff --git a/js/main.js b/js/app/index.js similarity index 100% rename from js/main.js rename to js/app/index.js diff --git a/js/audio/agents.md b/js/audio/agents.md new file mode 100644 index 0000000..fdc4063 --- /dev/null +++ b/js/audio/agents.md @@ -0,0 +1,14 @@ +# js/audio/ — agents.md +RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`. + +Self-contained module. Exports `ISLE_AUDIO = { AudioEngine }`. + +- `new AudioEngine()` + `.init()` + `.resume()` + `.setMuted(m)` + `.play(ev)` + where `ev` is an item from `state.events` ({type, strength, mat, ...}). +- All sound is synthesized (oscillators + noise buffers); no assets, offline. + Must degrade silently when `AudioContext` is undefined (the DOM-stub test + asserts boot with `AudioContext: undefined`). +- Event names are a contract with physics/damage + step ('launch','hit', + 'break','crack','pop','crush','bird_ground','ability_*','bird_gone', + 'level_won','level_lost','star','ui'). Adding an event type means adding its + synth branch here (unknown types no-op — that is intentional). diff --git a/js/audio.js b/js/audio/index.js similarity index 100% rename from js/audio.js rename to js/audio/index.js diff --git a/js/core.js b/js/core.js deleted file mode 100644 index d376e59..0000000 --- a/js/core.js +++ /dev/null @@ -1,658 +0,0 @@ -/* ISLE SLINGERS — core.js - * Pure, deterministic game core: physics, rules, scoring, events. - * No DOM, no audio, no Math.random — the renderer consumes state.events - * for visual-only effects. Loadable in browser (window.ISLE_CORE) or node. - */ -(function (root, factory) { - var api = factory(); - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.ISLE_CORE = api; -})(typeof globalThis !== 'undefined' ? globalThis : this, function () { - 'use strict'; - - var WORLD = { w: 2400, ground: 900 }; - var VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size, - // then CSS-scales; camera math lives in the core) - - var MATERIALS = { - wood: { hp: 1300, density: 1.0, restitution: 0.18, dmgFactor: 0.9, score: 500, maxVel: 520 }, - ice: { hp: 480, density: 0.7, restitution: 0.05, dmgFactor: 1.6, score: 800, maxVel: 560 }, - // stone: retuned 2026-09-28 (RC-0D193E, Commander bug #3 "blocks and pig - // bad guys too hard to kill"): at hp 3400 with dmgFactor 0.35 a 70x70 - // cube took ~11 full-power direct hits — stone walls were functionally - // unbreakable, so every enemy behind one was unkillable. Now ~2-3 hits. - stone: { hp: 1400, density: 2.2, restitution: 0.04, dmgFactor: 0.8, score: 300, maxVel: 480 } - }; - - var BIRD_TYPES = { - wren: { r: 20, mass: 3.0, label: 'Wren', ability: 'fury' }, // tap: horizontal burst - zippy: { r: 17, mass: 2.2, label: 'Zippy', ability: 'dive' }, // tap: steep dive - boulder: { r: 26, mass: 6.5, label: 'Boulder', ability: 'quake' } // tap: radial shock - }; - - var ENEMY_TYPES = { - mogg: { hp: 1, r: 24, score: 5000, crushVel: 330, crushDmg: 1.2 }, - grum: { hp: 2, r: 32, score: 8000, crushVel: 380, crushDmg: 1.0 }, - king: { hp: 3, r: 38, score: 15000, crushVel: 430, crushDmg: 0.85 } - }; - - var GRAVITY = 1500; - var LAUNCH_POWER = 11.5; // velocity per px of pull - var MAX_PULL = 150; - var MIN_PULL = 14; - var AIR_DRAG = 0.999; - var BIRD_BOUNCE = 0.34; - var BIRD_GROUND_FRICTION = 0.86; - var REST_SPEED = 14; // below this a bird counts as settling - var SETTLE_TIME = 0.9; // seconds under REST_SPEED before bird is spent - var BIRD_MAX_AGE = 12; - var ABILITY_CD = 0.35; // min seconds after launch before ability usable - var BIRD_BONUS = 10000; - - // ------------------------------------------------------------------ state - - function createGame(levelDef) { - var g = { - level: levelDef, - world: { w: WORLD.w, ground: WORLD.ground }, - time: 0, - score: 0, - birds: [], - blocks: [], - enemies: [], - events: [], // visual/audio events for the renderer - scorePopups: [], // {x,y,text,age} - hadEnemies: levelDef.enemies.length > 0, - camera: { x: levelDef.sling.x + 260, y: 560, zoom: 1, shake: 0, snap: true }, - status: 'ready', // ready | flying | won | lost - activeBird: -1, - launched: 0, - wonAt: null, - lostAt: null, - stats: { impacts: 0, destroyedBlocks: 0, destroyedEnemies: 0, abilityUses: 0, distance: 0 } - }; - g.blocks = levelDef.blocks.map(function (b, i) { - var m = MATERIALS[b.mat]; - return { - id: i, x: b.x, y: b.y, w: b.w, h: b.h, mat: b.mat, - hp: m.hp, maxHp: m.hp, - vx: 0, vy: 0, rest: 1, - mass: m.density * b.w * b.h / 1400 - }; - }); - g.enemies = levelDef.enemies.map(function (e, i) { - var t = ENEMY_TYPES[e.kind]; - // level data y is the CENTER (the level-integrity test treats it the - // same way); spawning at e.y - r made every enemy drop 24-38px on - // load — a visible "objects moving on their own" wobble at start - return { - id: i, kind: e.kind, x: e.x, y: e.y, r: t.r, - hp: t.hp, maxHp: t.hp, vx: 0, vy: 0, rest: 1, dead: false, deathAge: 0 - }; - }); - var i; - for (i = 0; i < levelDef.birds.length; i++) { - g.birds.push(makeBird(levelDef.birds[i], i)); - } - g.activeBird = g.birds.length ? 0 : -1; - if (g.activeBird >= 0) { - var fb = g.birds[0]; - fb.state = 'aiming'; - fb.x = levelDef.sling.x; - fb.y = levelDef.sling.y; - } - return g; - } - - function makeBird(kind, idx) { - var t = BIRD_TYPES[kind]; - return { - id: idx, kind: kind, r: t.r, mass: t.mass, - x: 0, y: 0, vx: 0, vy: 0, - state: 'queued', // queued | aiming | flying | spent - abilityUsed: false, - settleTimer: 0, age: 0, - launchT: 0 - }; - } - - function activeBird(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; } - - // ---------------------------------------------------------------- aiming - - // Returns {vx,vy,pull} if a valid launch for the given drag, else null. - function aimVector(g, sling, drag) { - var dx = sling.x - drag.x, dy = sling.y - drag.y; - var len = Math.sqrt(dx * dx + dy * dy); - if (len < MIN_PULL) return null; - var cl = Math.min(len, MAX_PULL); - var v = cl * LAUNCH_POWER; - return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl }; - } - - // Pure trajectory preview: identical integrator + substepping as the live - // sim (2x 1/120 per output point), so the dotted preview matches the real - // flight exactly. Gravity + drag only; stops at the ground. - function trajectory(g, sx, sy, vx, vy, steps) { - var pts = []; - var x = sx, y = sy, i, sub; - for (i = 0; i < steps; i++) { - for (sub = 0; sub < 2; sub++) { - var dt = 1 / 120; - var nvy = vy + GRAVITY * dt; - x += vx * dt; - y += (vy + nvy) * 0.5 * dt; - vx *= AIR_DRAG; - vy = nvy; - } - if (y > WORLD.ground - 18) { pts.push({ x: x, y: WORLD.ground - 18, last: true }); break; } - pts.push({ x: x, y: y, last: false }); - } - return pts; - } - - function launch(g, dragPos) { - var b = activeBird(g); - if (!b || b.state !== 'aiming' || g.status !== 'ready') return false; - var v = aimVector(g, { x: g.level.sling.x, y: g.level.sling.y }, dragPos); - if (!v) return false; - b.x = g.level.sling.x; b.y = g.level.sling.y; - b.vx = v.vx; b.vy = v.vy; - b.state = 'flying'; - b.launchT = g.time; - g.status = 'flying'; - g.launched++; - g.events.push({ type: 'launch', x: b.x, y: b.y }); - return true; - } - - function triggerAbility(g) { - var b = activeBird(g); - if (!b || b.state !== 'flying' || b.abilityUsed) return false; - if (g.time - b.launchT < ABILITY_CD) return false; - var kind = b.kind; - if (kind === 'wren') { - b.vx = b.vx * 0.4 + 980; - g.events.push({ type: 'ability_fury', x: b.x, y: b.y }); - } else if (kind === 'zippy') { - b.vy = Math.max(b.vy, 0) + 1050; - b.vx *= 0.55; - g.events.push({ type: 'ability_dive', x: b.x, y: b.y }); - } else if (kind === 'boulder') { - var R = 150; - var i, d, f, ent; - for (i = 0; i < g.blocks.length; i++) { - var bl = g.blocks[i]; - if (bl.hp <= 0) continue; - d = Math.hypot(bl.x - b.x, bl.y - b.y) - (bl.w + bl.h) / 4; - if (d < R) { f = 1 - Math.max(0, d) / R; damageBlock(g, bl, 120 + 300 * f, 'quake'); } - } - for (i = 0; i < g.enemies.length; i++) { - ent = g.enemies[i]; - if (ent.dead) continue; - d = Math.hypot(ent.x - b.x, ent.y - b.y) - ent.r; - if (d < R) { f = 1 - Math.max(0, d) / R; damageEnemy(g, ent, 0.7 + 1.6 * f); } - } - g.camera.shake = Math.max(g.camera.shake, 14); - g.events.push({ type: 'ability_quake', x: b.x, y: b.y, r: R }); - } - b.abilityUsed = true; - g.stats.abilityUses++; - return true; - } - - // ---------------------------------------------------------------- damage - - function damageBlock(g, bl, dmg, src) { - if (bl.hp <= 0) return; - bl.hp -= dmg; - if (bl.hp <= 0) { - bl.hp = 0; - g.score += MATERIALS[bl.mat].score; - g.stats.destroyedBlocks++; - g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + MATERIALS[bl.mat].score, age: 0 }); - g.events.push({ type: 'break', x: bl.x, y: bl.y, mat: bl.mat, big: bl.w * bl.h > 12000 }); - g.camera.shake = Math.max(g.camera.shake, 4 + bl.w * bl.h / 4000); - } else if (dmg > 40) { - g.events.push({ type: 'crack', x: bl.x, y: bl.y, mat: bl.mat }); - } - } - - function damageEnemy(g, e, dmg) { - if (e.dead) return; - e.hp -= dmg; - if (e.hp <= 0) { - e.dead = true; - e.deathAge = 0; - g.score += ENEMY_TYPES[e.kind].score; - g.stats.destroyedEnemies++; - g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + ENEMY_TYPES[e.kind].score, age: 0 }); - g.events.push({ type: 'pop', x: e.x, y: e.y, kind: e.kind }); - g.camera.shake = Math.max(g.camera.shake, 7); - } - } - - // ----------------------------------------------------------------- step - - function step(g, dt) { - g.time += dt; - if (g.status === 'won' || g.status === 'lost') { - // let physics settle for a beat so the win/lose screen lands naturally - stepPhysics(g, dt); - updatePopups(g, dt); - decayCamera(g, dt); - return; - } - stepPhysics(g, dt); - updatePopups(g, dt); - decayCamera(g, dt); - updateCameraTarget(g); - checkEndStates(g); - } - - function stepPhysics(g, dt) { - var i, b; - - // active bird flight - b = activeBird(g); - if (b && b.state === 'flying') { - b.age += dt; - var nvy = b.vy + GRAVITY * dt; - b.x += b.vx * dt; - b.y += (b.vy + nvy) * 0.5 * dt; - b.vx *= AIR_DRAG; - b.vy = nvy; - g.stats.distance = Math.max(g.stats.distance, b.x); - collideBirdWorld(g, b); - collideBirdBlocks(g, b); - collideBirdEnemies(g, b); - // settle / spend detection (low speed anywhere = spent, whether on - // the ground or perched on a beam; jitter-tolerant accumulation) - var sp = Math.hypot(b.vx, b.vy); - if (sp < REST_SPEED) b.settleTimer += dt; - else b.settleTimer = Math.max(0, b.settleTimer - dt * 4); - if (b.settleTimer > SETTLE_TIME || b.age > BIRD_MAX_AGE || b.x > WORLD.w + 200 || b.x < -200) { - spendBird(g, b); - } - } - - // blocks: gravity + integration + collisions - for (i = 0; i < g.blocks.length; i++) { - var bl = g.blocks[i]; - if (bl.hp <= 0) continue; - bl.vy = Math.min(bl.vy + GRAVITY * dt, MATERIALS[bl.mat].maxVel); - bl.vx *= 0.995; - bl.x += bl.vx * dt; - bl.y += bl.vy * dt; - // ground - var halfH = bl.h / 2; - if (bl.y + halfH > WORLD.ground) { - var impact = bl.vy; - bl.y = WORLD.ground - halfH; - if (impact > 90) { - bl.vy = -impact * MATERIALS[bl.mat].restitution; - bl.vx *= 0.8; - if (impact > 240) { - damageBlock(g, bl, (impact - 240) * 0.45, 'ground'); - g.events.push({ type: 'block_impact', x: bl.x, y: WORLD.ground, mat: bl.mat, strength: impact }); - } - bl.rest = 0; - } else { - bl.vy = 0; - bl.vx *= 0.82; // rolling friction - if (Math.abs(bl.vx) < 4) { bl.vx = 0; bl.rest = 1; } - } - } - // walls (keep the mess on the island) - if (bl.x - bl.w / 2 < 0) { bl.x = bl.w / 2; bl.vx = Math.abs(bl.vx) * 0.2; } - if (bl.x + bl.w / 2 > WORLD.w) { bl.x = WORLD.w - bl.w / 2; bl.vx = -Math.abs(bl.vx) * 0.2; } - } - // block vs block (positional + 1D impulse, 2 relaxation passes) - var pass, j, a, c; - for (pass = 0; pass < 2; pass++) { - for (i = 0; i < g.blocks.length; i++) { - a = g.blocks[i]; - if (a.hp <= 0) continue; - for (j = i + 1; j < g.blocks.length; j++) { - c = g.blocks[j]; - if (c.hp <= 0) continue; - resolveBoxPair(g, a, c); - } - } - } - // block vs bird (crush) - if (b && b.state === 'flying') { - for (i = 0; i < g.blocks.length; i++) { - var blk = g.blocks[i]; - if (blk.hp <= 0) continue; - crushCircleVsBox(g, blk, b, false); - } - } - - // enemies: settle on ground/blocks, take crush damage - for (i = 0; i < g.enemies.length; i++) { - var e = g.enemies[i]; - if (e.dead) { e.deathAge += dt; continue; } - e.vy = Math.min(e.vy + GRAVITY * dt, 700); - e.x += e.vx * dt; - e.y += e.vy * dt; - e.vx *= 0.9; - if (e.y + e.r > WORLD.ground) { - if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; } - else { e.vy = 0; e.y = WORLD.ground - e.r; e.vx *= 0.8; } - } - for (j = 0; j < g.blocks.length; j++) { - var bb = g.blocks[j]; - if (bb.hp <= 0) continue; - crushCircleVsBox(g, bb, e, true); - } - } - } - - function crushCircleVsBox(g, box, ent, isEnemy) { - var cx = Math.max(box.x - box.w / 2, Math.min(ent.x, box.x + box.w / 2)); - var cy = Math.max(box.y - box.h / 2, Math.min(ent.y, box.y + box.h / 2)); - var dx = ent.x - cx, dy = ent.y - cy; - var d2 = dx * dx + dy * dy; - if (d2 >= ent.r * ent.r) return; - var d = Math.sqrt(d2) || 0.001; - var nx = dx / d, ny = dy / d; - var overlap = ent.r - d; - ent.x += nx * overlap * 0.9; - ent.y += ny * overlap * 0.9; - // impulse: relative velocity along normal - var rvx = (ent.vx || 0) - (box.vx || 0), rvy = (ent.vy || 0) - (box.vy || 0); - var rel = rvx * nx + rvy * ny; - if (rel < -30) { - var relSpeed = -rel; - ent.vx -= nx * rel * 1.4; - ent.vy -= ny * rel * 1.4; - if (isEnemy) { - // crush damage: fast falling boxes hurt what they land on - var spec = ENEMY_TYPES[ent.kind]; - var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0); - if (boxSpeed > spec.crushVel && ny < -0.3) { - damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35); - g.events.push({ type: 'crush', x: ent.x, y: ent.y }); - } - } else { - // bird: transfer momentum into the box - var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25; - if (dmg > 25) damageBlock(g, box, dmg, 'bird'); - box.vx += nx * rel * 0.05 * (ent.mass || 3); - box.vy += ny * rel * 0.05 * (ent.mass || 3); - } - } else if (rel < 0) { - // slow resting contact: remove the normal approach velocity so a - // perched enemy/box pair stops oscillating under gravity - ent.vx -= nx * rel; - ent.vy -= ny * rel; - } - } - - function collideBirdWorld(g, b) { - if (b.y + b.r > WORLD.ground) { - var impact = -b.vy; - b.y = WORLD.ground - b.r; - if (b.vy > 0) b.vy = -b.vy * BIRD_BOUNCE; - b.vx *= BIRD_GROUND_FRICTION; - if (impact > 250) { - g.events.push({ type: 'bird_ground', x: b.x, y: WORLD.ground, strength: impact }); - g.camera.shake = Math.max(g.camera.shake, 5); - } - if (Math.abs(b.vy) < 30) b.vy = 0; - } - if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.3; } - } - - function collideBirdBlocks(g, b) { - var i; - for (i = 0; i < g.blocks.length; i++) { - var bl = g.blocks[i]; - if (bl.hp <= 0) continue; - var cx = Math.max(bl.x - bl.w / 2, Math.min(b.x, bl.x + bl.w / 2)); - var cy = Math.max(bl.y - bl.h / 2, Math.min(b.y, bl.y + bl.h / 2)); - var dx = b.x - cx, dy = b.y - cy; - var d2 = dx * dx + dy * dy; - if (d2 >= b.r * b.r) continue; - var d = Math.sqrt(d2) || 0.001; - var nx = dx / d, ny = dy / d; - var overlap = b.r - d; - b.x += nx * overlap; - b.y += ny * overlap; - var rvx = b.vx - bl.vx, rvy = b.vy - bl.vy; - var rel = rvx * nx + rvy * ny; - if (rel < 0) { - var relSpeed = -rel; - // impulse: j = -(1+e)*rel / (1/m1 + 1/m2) - var invM = 1 / b.mass + 1 / bl.mass; - var j = -(1 + BIRD_BOUNCE) * rel / invM; - b.vx += (j / b.mass) * nx; - b.vy += (j / b.mass) * ny; - bl.vx -= (j / bl.mass) * nx; - bl.vy -= (j / bl.mass) * ny; - g.stats.impacts++; - var dmg = relSpeed * 0.55 * (b.mass / 3) * MATERIALS[bl.mat].dmgFactor; - if (dmg > 30) { - damageBlock(g, bl, dmg, 'bird'); - g.events.push({ type: 'hit', x: cx, y: cy, mat: bl.mat, strength: relSpeed }); - g.camera.shake = Math.max(g.camera.shake, Math.min(12, relSpeed / 90)); - } - b.vx *= 0.985; b.vy *= 0.985; - } - } - } - - function collideBirdEnemies(g, b) { - var i; - for (i = 0; i < g.enemies.length; i++) { - var e = g.enemies[i]; - if (e.dead) continue; - var dx = e.x - b.x, dy = e.y - b.y; - var rr = e.r + b.r; - var d2 = dx * dx + dy * dy; - if (d2 >= rr * rr) continue; - var d = Math.sqrt(d2) || 0.001; - var nx = dx / d, ny = dy / d; - var rel = (b.vx - e.vx) * nx + (b.vy - e.vy) * ny; - if (rel > 0) { - e.vx += nx * rel * 0.8; - e.vy += ny * rel * 0.8 - 120; - // per-target hit cooldown so one pass-through = one damage instance - if (g.time - (e.lastHit || -9) > 0.12) { - var speed = Math.hypot(b.vx, b.vy); - // retuned 2026-09-28 (RC-0D193E, Commander bug #3): the old curve - // gave ~0.9 dmg to a soft wren lob — a mogg (hp1) could not be - // killed without a perfect full-power hit. Bumped so a decent - // medium shot connects; the king (hp3) still survives one - // full-power wren hit (pinned in test/sim.test.mjs). - var dmg = 0.35 + speed / 800 * (b.mass / 3); - damageEnemy(g, e, dmg); - e.lastHit = g.time; - g.events.push({ type: 'bird_hit_enemy', x: e.x, y: e.y, strength: speed }); - } - b.vx *= 0.92; b.vy *= 0.92; - } - } - } - - // Resting-contact clamp: inelastic contacts + zeroing below this speed, - // so stacked blocks settle to exactly still instead of oscillating under - // gravity re-injection. - var REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed - - function resolveBoxPair(g, a, c) { - var ox = (a.w / 2 + c.w / 2) - Math.abs(a.x - c.x); - var oy = (a.h / 2 + c.h / 2) - Math.abs(a.y - c.y); - if (ox <= 0 || oy <= 0) return; - if (ox < oy) { - // side contact: push apart along x - var n = c.x >= a.x ? 1 : -1; // direction from a toward c - a.x -= n * ox * 0.5; c.x += n * ox * 0.5; - var relv = (c.vx - a.vx) * n; - if (relv < 0) { // approaching - var avg = (a.vx + c.vx) / 2; - a.vx = avg; c.vx = avg; - } - a.vx *= 0.9; c.vx *= 0.9; - } else { - // stacked contact: push apart along y, then kill resting velocity - var n2 = c.y >= a.y ? 1 : -1; - a.y -= n2 * oy * 0.5; c.y += n2 * oy * 0.5; - var relv2 = (c.vy - a.vy) * n2; - if (relv2 < -REST_VEL_CLAMP) { - // real impact: inelastic average (blocks do not bounce off each other) - var avgY = (a.vy + c.vy) / 2; - a.vy = avgY; c.vy = avgY; - } else if (relv2 < 0) { - // resting: top takes the bottom's velocity (kills the relative - // velocity), then zero both below the clamp so stacks stop - // oscillating under gravity re-injection - var top = n2 === 1 ? a : c; - var bot = n2 === 1 ? c : a; - top.vy = bot.vy; - if (Math.abs(top.vy) < REST_VEL_CLAMP) { top.vy = 0; bot.vy = 0; } - } else { - // separating / at rest: clamp near-zero velocities - if (Math.abs(a.vy) < 2) a.vy = 0; - if (Math.abs(c.vy) < 2) c.vy = 0; - } - } - } - - function spendBird(g, b) { - b.state = 'spent'; - b.vx = 0; b.vy = 0; - g.events.push({ type: 'bird_gone', x: b.x, y: b.y }); - // next bird onto the sling - var next = -1; - for (var i = g.activeBird + 1; i < g.birds.length; i++) { - if (g.birds[i].state === 'queued') { next = i; break; } - } - g.activeBird = next; - if (next >= 0) { - var nb = g.birds[next]; - nb.state = 'aiming'; - nb.x = g.level.sling.x; nb.y = g.level.sling.y; - g.status = 'ready'; - g.events.push({ type: 'bird_load', x: nb.x, y: nb.y, kind: nb.kind }); - } - } - - function checkEndStates(g) { - if (g.status === 'won' || g.status === 'lost') return; - var alive = 0; - for (var i = 0; i < g.enemies.length; i++) if (!g.enemies[i].dead) alive++; - if (g.hadEnemies && alive === 0 && g.launched > 0) { - g.status = 'won'; - g.wonAt = g.time; - g.score += BIRD_BONUS * birdsRemaining(g); - g.events.push({ type: 'level_won', x: g.camera.x, y: 400 }); - return; - } - var b = activeBird(g); - var inFlight = false; - for (var k = 0; k < g.birds.length; k++) if (g.birds[k].state === 'flying') inFlight = true; - if (!inFlight && !b && g.status === 'flying') { - // last bird spent, enemies remain - g.status = 'lost'; - g.lostAt = g.time; - g.events.push({ type: 'level_lost', x: g.camera.x, y: 400 }); - } - } - - function birdsRemaining(g) { - var n = 0; - for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++; - return n; - } - - function starsFor(g) { - var th = g.level.stars; - if (!th) return 0; - if (g.score >= th[2]) return 3; - if (g.score >= th[1]) return 2; - if (g.score >= th[0] || g.status === 'won') return 1; - return 0; - } - - function updatePopups(g, dt) { - for (var i = g.scorePopups.length - 1; i >= 0; i--) { - g.scorePopups[i].age += dt; - g.scorePopups[i].y -= 40 * dt; - if (g.scorePopups[i].age > 1.4) g.scorePopups.splice(i, 1); - } - } - - function updateCameraTarget(g) { - var b = activeBird(g); - var cam = g.camera; - var tx, ty, tz; - if (b && b.state === 'flying') { - // in flight: follow the slinger 1:1 (the bird IS the point of interest) - tx = Math.max(420, Math.min(WORLD.w - 420, b.x + 220)); - ty = Math.max(380, Math.min(WORLD.ground - 160, b.y + 60)); - tz = 1; - } else { - // aiming / waiting: frame the slingshot AND the whole action zone - // (live blocks + enemies) so launch point and target are always - // visible at once — zooming out when the level needs it. Without - // this the sling was visible but the fort was off-screen (or - // vice-versa mid-flight), making aiming pure guesswork. - var left = g.level.sling.x - 190; - var right = left + 300; - for (var i = 0; i < g.blocks.length; i++) { - var bl = g.blocks[i]; - if (bl.hp > 0) right = Math.max(right, bl.x + bl.w / 2 + 90); - } - for (var k = 0; k < g.enemies.length; k++) { - var en = g.enemies[k]; - if (!en.dead) right = Math.max(right, en.x + en.r + 90); - } - var span = right - left; - tz = Math.min(1, VIEW.w / span); - tx = left + span / 2; - // ground at ~78% down the view, whatever the zoom - ty = Math.max(300, Math.min(WORLD.ground, WORLD.ground - (0.28 * VIEW.h) / tz)); - } - if (cam.snap) { // first frame of a level: land on target, no pan - cam.x = tx; cam.y = ty; cam.zoom = tz; cam.snap = false; - return; - } - cam.x += (tx - cam.x) * 0.06; - cam.y += (ty - cam.y) * 0.06; - cam.zoom = (cam.zoom || 1) + (tz - (cam.zoom || 1)) * 0.08; - } - - function decayCamera(g, dt) { - if (g.camera.shake > 0) { - g.camera.shake *= Math.pow(0.03, dt); // fast decay - if (g.camera.shake < 0.3) g.camera.shake = 0; - } - } - - // ------------------------------------------------------------------ API - - return { - WORLD: WORLD, - VIEW: VIEW, - MATERIALS: MATERIALS, - BIRD_TYPES: BIRD_TYPES, - ENEMY_TYPES: ENEMY_TYPES, - GRAVITY: GRAVITY, - MAX_PULL: MAX_PULL, - MIN_PULL: MIN_PULL, - LAUNCH_POWER: LAUNCH_POWER, - BIRD_BONUS: BIRD_BONUS, - createGame: createGame, - activeBird: activeBird, - aimVector: aimVector, - trajectory: trajectory, - launch: launch, - triggerAbility: triggerAbility, - step: step, - starsFor: starsFor, - birdsRemaining: birdsRemaining, - AIR_DRAG: AIR_DRAG - }; -}); diff --git a/js/levels.js b/js/levels.js deleted file mode 100644 index 9c8af59..0000000 --- a/js/levels.js +++ /dev/null @@ -1,158 +0,0 @@ -/* ISLE SLINGERS — levels.js - * Original level layouts. Coordinates are world pixels; (x,y) = block CENTER. - * World: width 2400, ground surface at y=900. - */ -(function (root, factory) { - var api = factory(); - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.ISLE_LEVELS = api; -})(typeof globalThis !== 'undefined' ? globalThis : this, function () { - 'use strict'; - - // helpers to keep the data below readable - function wood(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'wood' }; } - function ice(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'ice' }; } - function stone(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'stone' }; } - function mogg(x, y) { return { x: x, y: y, kind: 'mogg' }; } - function grum(x, y) { return { x: x, y: y, kind: 'grum' }; } - function king(x, y) { return { x: x, y: y, kind: 'king' }; } - - var SLING = { x: 260, y: 745 }; - - var LEVELS = [ - { - id: 1, - name: 'First Perch', - birds: ['wren', 'wren', 'zippy'], - sling: SLING, - stars: [12000, 24000, 36000], - decor: { palms: [950, 2150, 2350], flowers: [700, 1200, 2050] }, - blocks: [ - // simple hut - wood(1500, 830, 30, 140), - wood(1640, 830, 30, 140), - wood(1570, 745, 200, 30) - ], - enemies: [ - mogg(1570, 876), - mogg(1770, 876) - ] - }, - { - id: 2, - name: 'Crate Caves', - birds: ['wren', 'zippy', 'wren'], - sling: SLING, - stars: [15000, 28000, 42000], - decor: { palms: [1000, 2200], flowers: [800, 1350, 2000] }, - blocks: [ - // left tower: stone base + wood post - stone(1420, 865, 70, 70), - wood(1420, 760, 30, 140), - // right tower: ice base + ice post - ice(1660, 860, 80, 80), - ice(1660, 765, 30, 110), - // side crates - wood(1300, 860, 80, 80), - wood(1790, 860, 80, 80) - ], - enemies: [ - mogg(1420, 666), - mogg(1660, 686), - mogg(1540, 876) - ] - }, - { - id: 3, - name: 'Glass Works', - birds: ['wren', 'zippy', 'boulder'], - sling: SLING, - stars: [18000, 34000, 50000], - decor: { palms: [900, 2250], flowers: [750, 1300, 2100] }, - blocks: [ - // ice workshop: two-storey - ice(1500, 840, 30, 120), - ice(1640, 840, 30, 120), - wood(1570, 765, 200, 30), - ice(1520, 700, 30, 100), - ice(1620, 700, 30, 100), - ice(1570, 635, 170, 30), - // wood hut - wood(1850, 830, 30, 140), - wood(1950, 830, 30, 140), - wood(1900, 745, 160, 30) - ], - enemies: [ - mogg(1570, 876), - mogg(1570, 596), - grum(1900, 868), - mogg(1900, 706) - ] - }, - { - id: 4, - name: 'Stone Keep', - birds: ['wren', 'zippy', 'boulder', 'wren'], - sling: SLING, - stars: [22000, 40000, 58000], - decor: { palms: [1050, 2300], flowers: [850, 1400, 2150] }, - blocks: [ - // stone wall (three cubes) - stone(1600, 865, 70, 70), - stone(1750, 865, 70, 70), - stone(1900, 865, 70, 70), - wood(1750, 815, 400, 30), - // second floor - wood(1650, 740, 30, 120), - wood(1850, 740, 30, 120), - wood(1750, 665, 260, 30), - // left ice watchtower - ice(1450, 840, 30, 120), - // right crate - stone(2050, 865, 70, 70) - ], - enemies: [ - mogg(1675, 876), - mogg(1825, 876), - grum(1750, 618), - mogg(2050, 806), - mogg(1500, 876) - ] - }, - { - id: 5, - name: 'Mog Fortress', - birds: ['wren', 'zippy', 'boulder', 'boulder'], - sling: SLING, - stars: [28000, 48000, 70000], - decor: { palms: [980, 2320], flowers: [780, 1300, 2180] }, - blocks: [ - // left stone tower (2 stories + ice cap) - stone(1450, 865, 70, 70), - stone(1450, 760, 70, 70), - ice(1450, 710, 30, 30), - // center keep - stone(1700, 865, 70, 70), - stone(1850, 865, 70, 70), - wood(1775, 815, 260, 30), - wood(1700, 740, 30, 120), - wood(1850, 740, 30, 120), - wood(1775, 665, 220, 30), - ice(1775, 635, 260, 30), - // right post - stone(2050, 865, 70, 70), - wood(2050, 760, 30, 120) - ], - enemies: [ - king(1775, 862), - mogg(1450, 671), - mogg(1775, 626), - mogg(2050, 676), - mogg(1575, 876), - mogg(1975, 876) - ] - } - ]; - - return { LEVELS: LEVELS, SLING: SLING }; -}); diff --git a/js/levels/agents.md b/js/levels/agents.md new file mode 100644 index 0000000..71c0da2 --- /dev/null +++ b/js/levels/agents.md @@ -0,0 +1,14 @@ +# js/levels/ — agents.md +RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`. + +The levels **module** (aggregator). Shared scope `L`. + +- `index.js` runs helpers first (constructors + SLING anchor), then one installer + per level, and exports `ISLE_LEVELS = { LEVELS, SLING }` — key-for-key stable. +- Level objects: `id, name, birds[], sling, stars[3] (ascending), decor, blocks[] + (centers! x,y = block CENTER), enemies[] (x,y = enemy center)`. The + level-integrity test enforces world bounds, support (ground or flat block top) + and non-decreasing enemy-HP difficulty — keep new levels honest. +- Adding level 6+ = add `data/level-6.js` + register it in index.html AND in the + aggregator's browser part-list expectation (order = difficulty order). +- Do not import from physics; levels are pure data plus the constructor helpers. diff --git a/js/levels/data/agents.md b/js/levels/data/agents.md new file mode 100644 index 0000000..6d0bc2a --- /dev/null +++ b/js/levels/data/agents.md @@ -0,0 +1,10 @@ +# js/levels/data/ — agents.md +RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`. + +Level definitions, one file per level (`level-1.js`..`level-5.js`). + +- Each file installs `L -> levelObject` using the shared helpers (L.wood, L.ice, + L.stone, L.mogg, L.grum, L.king, L.SLING). Coordinates are block CENTERS. +- The aggregator collects the returned objects in load order = difficulty order. +- Balance rule for new levels: star thresholds ascend, enemy HP budget does not + decrease across the list (level-integrity test). diff --git a/js/levels/data/level-1.js b/js/levels/data/level-1.js new file mode 100644 index 0000000..28703f6 --- /dev/null +++ b/js/levels/data/level-1.js @@ -0,0 +1,31 @@ +/* ISLE SLINGERS -- levels/data/level-1.js (submodule: level-1) + * RC-375D84 S-2: level 1 definition, split from the original js/levels.js. + * Installer: returns the level object built from the shared helpers scope L. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + return { + id: 1, + name: 'First Perch', + birds: ['wren', 'wren', 'zippy'], + sling: L.SLING, + stars: [12000, 24000, 36000], + decor: { palms: [950, 2150, 2350], flowers: [700, 1200, 2050] }, + blocks: [ + // simple hut + L.wood(1500, 830, 30, 140), + L.wood(1640, 830, 30, 140), + L.wood(1570, 745, 200, 30) + ], + enemies: [ + L.mogg(1570, 876), + L.mogg(1770, 876) + ] + } + }; +}); diff --git a/js/levels/data/level-2.js b/js/levels/data/level-2.js new file mode 100644 index 0000000..8d5a936 --- /dev/null +++ b/js/levels/data/level-2.js @@ -0,0 +1,37 @@ +/* ISLE SLINGERS -- levels/data/level-2.js (submodule: level-2) + * RC-375D84 S-2: level 2 definition, split from the original js/levels.js. + * Installer: returns the level object built from the shared helpers scope L. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + return { + id: 2, + name: 'Crate Caves', + birds: ['wren', 'zippy', 'wren'], + sling: L.SLING, + stars: [15000, 28000, 42000], + decor: { palms: [1000, 2200], flowers: [800, 1350, 2000] }, + blocks: [ + // left tower: stone base + wood post + L.stone(1420, 865, 70, 70), + L.wood(1420, 760, 30, 140), + // right tower: ice base + ice post + L.ice(1660, 860, 80, 80), + L.ice(1660, 765, 30, 110), + // side crates + L.wood(1300, 860, 80, 80), + L.wood(1790, 860, 80, 80) + ], + enemies: [ + L.mogg(1420, 666), + L.mogg(1660, 686), + L.mogg(1540, 876) + ] + } + }; +}); diff --git a/js/levels/data/level-3.js b/js/levels/data/level-3.js new file mode 100644 index 0000000..9c1bc6e --- /dev/null +++ b/js/levels/data/level-3.js @@ -0,0 +1,40 @@ +/* ISLE SLINGERS -- levels/data/level-3.js (submodule: level-3) + * RC-375D84 S-2: level 3 definition, split from the original js/levels.js. + * Installer: returns the level object built from the shared helpers scope L. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + return { + id: 3, + name: 'Glass Works', + birds: ['wren', 'zippy', 'boulder'], + sling: L.SLING, + stars: [18000, 34000, 50000], + decor: { palms: [900, 2250], flowers: [750, 1300, 2100] }, + blocks: [ + // ice workshop: two-storey + L.ice(1500, 840, 30, 120), + L.ice(1640, 840, 30, 120), + L.wood(1570, 765, 200, 30), + L.ice(1520, 700, 30, 100), + L.ice(1620, 700, 30, 100), + L.ice(1570, 635, 170, 30), + // wood hut + L.wood(1850, 830, 30, 140), + L.wood(1950, 830, 30, 140), + L.wood(1900, 745, 160, 30) + ], + enemies: [ + L.mogg(1570, 876), + L.mogg(1570, 596), + L.grum(1900, 868), + L.mogg(1900, 706) + ] + } + }; +}); diff --git a/js/levels/data/level-4.js b/js/levels/data/level-4.js new file mode 100644 index 0000000..44164bf --- /dev/null +++ b/js/levels/data/level-4.js @@ -0,0 +1,43 @@ +/* ISLE SLINGERS -- levels/data/level-4.js (submodule: level-4) + * RC-375D84 S-2: level 4 definition, split from the original js/levels.js. + * Installer: returns the level object built from the shared helpers scope L. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + return { + id: 4, + name: 'Stone Keep', + birds: ['wren', 'zippy', 'boulder', 'wren'], + sling: L.SLING, + stars: [22000, 40000, 58000], + decor: { palms: [1050, 2300], flowers: [850, 1400, 2150] }, + blocks: [ + // stone wall (three cubes) + L.stone(1600, 865, 70, 70), + L.stone(1750, 865, 70, 70), + L.stone(1900, 865, 70, 70), + L.wood(1750, 815, 400, 30), + // second floor + L.wood(1650, 740, 30, 120), + L.wood(1850, 740, 30, 120), + L.wood(1750, 665, 260, 30), + // left ice watchtower + L.ice(1450, 840, 30, 120), + // right crate + L.stone(2050, 865, 70, 70) + ], + enemies: [ + L.mogg(1675, 876), + L.mogg(1825, 876), + L.grum(1750, 618), + L.mogg(2050, 806), + L.mogg(1500, 876) + ] + } + }; +}); diff --git a/js/levels/data/level-5.js b/js/levels/data/level-5.js new file mode 100644 index 0000000..36875e3 --- /dev/null +++ b/js/levels/data/level-5.js @@ -0,0 +1,46 @@ +/* ISLE SLINGERS -- levels/data/level-5.js (submodule: level-5) + * RC-375D84 S-2: level 5 definition, split from the original js/levels.js. + * Installer: returns the level object built from the shared helpers scope L. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + return { + id: 5, + name: 'Mog Fortress', + birds: ['wren', 'zippy', 'boulder', 'boulder'], + sling: L.SLING, + stars: [28000, 48000, 70000], + decor: { palms: [980, 2320], flowers: [780, 1300, 2180] }, + blocks: [ + // left stone tower (2 stories + ice cap) + L.stone(1450, 865, 70, 70), + L.stone(1450, 760, 70, 70), + L.ice(1450, 710, 30, 30), + // center keep + L.stone(1700, 865, 70, 70), + L.stone(1850, 865, 70, 70), + L.wood(1775, 815, 260, 30), + L.wood(1700, 740, 30, 120), + L.wood(1850, 740, 30, 120), + L.wood(1775, 665, 220, 30), + L.ice(1775, 635, 260, 30), + // right post + L.stone(2050, 865, 70, 70), + L.wood(2050, 760, 30, 120) + ], + enemies: [ + L.king(1775, 862), + L.mogg(1450, 671), + L.mogg(1775, 626), + L.mogg(2050, 676), + L.mogg(1575, 876), + L.mogg(1975, 876) + ] + } + }; +}); diff --git a/js/levels/helpers/agents.md b/js/levels/helpers/agents.md new file mode 100644 index 0000000..0f2e7b9 --- /dev/null +++ b/js/levels/helpers/agents.md @@ -0,0 +1,10 @@ +# js/levels/helpers/ — agents.md +RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`. + +Installs into `L`: wood, ice, stone (block factories, return center-based +records), mogg, grum, king (enemy factories) and the SLING anchor {x:260,y:745}. + +- Must load before every `data/level-N.js`. Leaf submodule — consumes nothing. +- Material names here ('wood','ice','stone') and enemy kinds ('mogg','grum', + 'king') are the string contract against constants.MATERIALS / ENEMY_TYPES. + Renaming a kind is a cross-module change (levels + constants + render). diff --git a/js/levels/helpers/index.js b/js/levels/helpers/index.js new file mode 100644 index 0000000..f858c20 --- /dev/null +++ b/js/levels/helpers/index.js @@ -0,0 +1,21 @@ +/* ISLE SLINGERS -- levels/helpers/index.js (submodule: helpers) + * RC-375D84 S-2: block/enemy constructor helpers + SLING anchor, split from + * the original js/levels.js. Installs into the shared levels scope `L`; + * js/levels/index.js loads it first, then the level-N data submodules. + */ +(function (root, factory) { + var part = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = part; + else (root.ISLE_LEVEL_PARTS = root.ISLE_LEVEL_PARTS || []).push(part); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + return function (L) { + L.wood = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'wood' }; }; + L.ice = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'ice' }; }; + L.stone = function (x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'stone' }; }; + L.mogg = function (x, y) { return { x: x, y: y, kind: 'mogg' }; }; + L.grum = function (x, y) { return { x: x, y: y, kind: 'grum' }; }; + L.king = function (x, y) { return { x: x, y: y, kind: 'king' }; }; + L.SLING = { x: 260, y: 745 }; + }; +}); diff --git a/js/levels/index.js b/js/levels/index.js new file mode 100644 index 0000000..d4c02a4 --- /dev/null +++ b/js/levels/index.js @@ -0,0 +1,44 @@ +/* ISLE SLINGERS -- levels/index.js (module: levels -- aggregator / public API) + * RC-375D84 S-2 modularization. The original js/levels.js monolith is now: + * levels/helpers/ - block/enemy constructors + SLING anchor (shared scope L) + * levels/data/level-N.js - one level definition each (uses the shared scope) + * This aggregator loads the parts in order, builds the LEVELS array, and + * exposes the byte-identical public API: { LEVELS, SLING }. + * + * Load contract: + * browser - levels/helpers/index.js first, then data/level-1..5.js, then this file + * (parts arrive on globalThis.ISLE_LEVEL_PARTS in