Merge pull request 'Physics upgrade + repo modularization (RC-375D84)' (#1) from physics-modularization-rc375d84 into main

This commit is contained in:
relay 2026-09-29 13:21:30 +00:00
commit 0fc46c5fd9
52 changed files with 2413 additions and 838 deletions

View file

@ -377,10 +377,10 @@ python3 -m http.server 8000 # then open http://localhost:8000/
# or open index.html directly in a browser # or open index.html directly in a browser
# verify (both arms): # 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/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 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) ## 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. See the repository commit SHA recorded in the RC completion payload.
Working tree at commit time contains exactly the files listed in §3. 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.

58
MODULES.md Normal file
View file

@ -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.

154
architecture.md Normal file
View file

@ -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 `<script>` load, single page)
There is no bundler and no import/export in the browser. Modules are ordinary
UMD-style files loaded in a strict order by `index.html`; the test harness
(`test/page.test.mjs`) replays that exact order in a DOM stub, and the
headless suites (`test/sim.test.mjs`, `test/balance.test.mjs`) load the same
modules through Node's CommonJS `require` branch of each UMD wrapper.
Two kinds of module:
1. **Shared-scope modules** — a group of submodules that must share one
lexical scope (the way a single file used to). Each submodule exports an
*installer function* `P -> void`; the aggregator builds one shared object
(`P` for physics, `L` for levels) and calls every installer against it.
Submodules therefore reference each other's units as `P.<unit>` /
`L.<unit>` — never as free names. This preserves the monolith's
single-closure semantics exactly (proven by the golden-step hash: identical
scripted playthroughs produce byte-identical state hashes).
In the browser, submodules push their installer onto a global part list
(`ISLE_PHYSICS_PARTS` / `ISLE_LEVEL_PARTS`); the aggregator (the last
`<script>` of the group) consumes the list in order.
2. **Self-contained modules** — `audio`, `render`, `app`. One file, one
global (`ISLE_AUDIO`, `ISLE_RENDERER`, plus the page state object on
`window`), no shared scope.
## Load order (contract — do not reorder)
```
physics submodules : constants, state, aim, abilities, damage, step,
collision, topple, rules, camera (shared scope P)
physics/index.js : aggregator -> window.ISLE_CORE (the physics API)
levels/helpers : constructors + SLING anchor (shared scope L)
levels/data/* : one level definition each (shared scope L)
levels/index.js : aggregator -> window.ISLE_LEVELS {LEVELS, SLING}
audio/index.js : window.ISLE_AUDIO {AudioEngine}
render/index.js : window.ISLE_RENDERER {Renderer, VIEW_W, VIEW_H}
app/index.js : the page — input, screens, main loop
```
`app` (the former `main.js`) is the only module that touches DOM wiring;
`render` draws from the state object returned by `ISLE_CORE.createGame`;
`audio` plays the events the core appends to `state.events`.
## Physics model (RC-375D84, landed 2026-09-29)
The simulation is a deterministic impulse-ish engine: bodies integrate under
gravity at fixed 1/120 s, contacts resolve positionally, and damage routes
through two funnels (`damageBlock`, `damageEnemy`). What each RC-375D84 step
added, and where it lives:
- **Boards tip and topple (S-3, Q1 = full continuous rotation).** Blocks carry
a rotational DOF: `ang`, `angVel`, `pivoting`. A resting block whose centre
of mass lies outside the x-span of its support (ground, or the top face of a
block below — `topple.blockSupportSpan`) pivots about the support-edge
corner under gravity (point-mass COM, `alpha = G·(comX−pivotX)/r²`), a
smooth continuous swing — no discrete snap. Two endings: a far corner
reaches the ground line → it lands, impact speed `|angVel|·r` deals
self-damage and footprint damage (`topplePathDamage`: blocks take impact,
enemies in the footprint can be killed); or the pivot is too high for any
corner to reach the ground → the board is a pendulum and swings back,
settling flat on its support (`settleBack`, with anti-livelock: it
re-tips only on a real knock ≥ 6 px or a damaging hit).
- **Falling objects kill (S-4).** Crush is velocity-direction-aware: the
moving box must be the body plowing into the enemy (`boxAlong > 30`), so
vertical drops and side plows both register and a merely resting box never
does. One landing = one crush instance (per-enemy 0.12 s cooldown). Stacked
block contact is mass-weighted inelastic, so a heavy block landing on a
light plank transfers real momentum through the stack (chain-crush).
- **Landed birds stay physical (S-5, Q2 = real continuous gravity, no
lifetime timer).** A bird that settles while ANOTHER bird is in play
(queued, or flying/aiming/landed) becomes `landed`: it keeps integrating
under gravity, falls when its support breaks, chain-damages the boards it
lands on, and can kill enemies — for as long as the level runs. The LAST
bird still spends exactly as before, so win/lose resolution is unchanged;
`rules` must not declare `lost` while a landed bird is in the world, and
level end clears landed bodies.
## Data flow
```
input (drag/keys) -> app -> ISLE_CORE.launch / ISLE_CORE.triggerAbility
|
main loop (rAF, fixed 1/120s accumulator) -> ISLE_CORE.step(state, dt)
|
state = { birds, blocks, enemies, events, score, camera, status, ... }
|
Renderer.draw(ctx, state) + AudioEngine.play(event)
```
The core is **deterministic and DOM-free**: `createGame(levelDef)` returns a
plain state object; `step(state, dt)` mutates it and is safe to call from a
headless loop (that is how all physics tests run).
## Invariants (the load-bearing contracts)
- `ISLE_CORE` public API is key-for-key stable (see `js/physics/index.js`):
consumers use `ISLE_CORE.WORLD`, `MATERIALS`, `BIRD_TYPES`, `ENEMY_TYPES`,
`createGame`, `activeBird`, `aimVector`, `trajectory`, `launch`,
`triggerAbility`, `step`, `starsFor`, `birdsRemaining`, `LAUNCH_POWER`,
`MAX_PULL`, `MIN_PULL`, `BIRD_BONUS`, `AIR_DRAG`, `VIEW`.
- `ISLE_LEVELS` public API: `{ LEVELS, SLING }`. Level geometry uses block
**centers**; enemies sit with their bottom at `y + r` resting on ground
(y=900) or a flat block top — the level-integrity test enforces this.
- Physics timestep: 1/120 s in tests and the page accumulator; collision
response, crush damage and settle logic all assume `dt` at that scale.
- One global per module, attached to `window` in the browser and exported
via `module.exports` in Node. Shared-scope part lists
(`ISLE_PHYSICS_PARTS`, `ISLE_LEVEL_PARTS`) are **browser-only** transport;
Node resolves parts via `require`.
## Test surface
- `test/sim.test.mjs` (15) — headless: parses every file under `js/`, DOM-id
wiring, level integrity, determinism, stability, trajectory, abilities,
win/lose, all 5 levels clearable, fort rest, aim framing.
- `test/page.test.mjs` (7) — DOM stub: boots the real page in browser order,
click-through the UI, a full 8 s launch + sim, pause/mute/keys, launch
direction, sling-band release.
- `test/balance.test.mjs` (5) — the balance spec: per-material break budgets
and enemy kill budgets under full-power direct hits.
- `test/topple.test.mjs` (5) — S-3: overhanging board tips (COM past support
edge), rotation is continuous, a fully-supported board never tips (negative
control), tip impact damages a block in the fall path, falling board kills
an enemy in the fall path.
- `test/knifeedge.test.mjs` (6) — S-3 regression: a knife-edge overhang (COM a
hair past the edge, pivot too high to reach the ground) tips once and
settles back to rest — no livelock; a real knock re-arms it.
- `test/fallingkill.test.mjs` (5) — S-4: chain-kill through a knocked plank,
direct one-hit kill, repeated-impact kill (grum), one landing = one crush
instance, and a resting block never crushes.
- `test/landedbird.test.mjs` (4) — S-5: perched bird falls when its platform
breaks (Q2 real gravity), no drift on an intact board, chain-damages the
board it lands on, kills an enemy in its fall path.
Total: 47/47. New physics behavior must ship with RED-watched tests in this
same surface (every new spec was watched RED on the pre-change tree first).
## Conventions for agents working in this tree
1. Change a physics rule -> edit the one submodule that owns it (see each
`agents.md`); re-run `npm test` (27/27) and the golden-step hash
(`/opt/data/s2_golden.mjs` against `/opt/data/core.orig.js`) when touching
anything that predates RC-375D84 behavior.
2. Never re-introduce a flat file for a unit that has a submodule folder.
3. Keep `index.html` script order in lockstep with `test/page.test.mjs`'s
`FILES` list — both encode the load contract.
4. No new runtime dependencies; the game must stay fully offline.

View file

@ -77,10 +77,30 @@
</div> </div>
</div> </div>
<script src="js/core.js"></script> <!-- RC-375D84 S-2: modularized tree. Order matters:
<script src="js/levels.js"></script> physics submodules (shared scope) -> physics aggregator (ISLE_CORE)
<script src="js/audio.js"></script> -> levels helpers + data (shared scope) -> levels aggregator (ISLE_LEVELS)
<script src="js/renderer.js"></script> -> audio -> render -> app -->
<script src="js/main.js"></script> <script src="js/physics/constants/index.js"></script>
<script src="js/physics/state/index.js"></script>
<script src="js/physics/aim/index.js"></script>
<script src="js/physics/abilities/index.js"></script>
<script src="js/physics/damage/index.js"></script>
<script src="js/physics/step/index.js"></script>
<script src="js/physics/collision/index.js"></script>
<script src="js/physics/topple/index.js"></script>
<script src="js/physics/rules/index.js"></script>
<script src="js/physics/camera/index.js"></script>
<script src="js/physics/index.js"></script>
<script src="js/levels/helpers/index.js"></script>
<script src="js/levels/data/level-1.js"></script>
<script src="js/levels/data/level-2.js"></script>
<script src="js/levels/data/level-3.js"></script>
<script src="js/levels/data/level-4.js"></script>
<script src="js/levels/data/level-5.js"></script>
<script src="js/levels/index.js"></script>
<script src="js/audio/index.js"></script>
<script src="js/render/index.js"></script>
<script src="js/app/index.js"></script>
</body> </body>
</html> </html>

17
js/app/agents.md Normal file
View file

@ -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.

14
js/audio/agents.md Normal file
View file

@ -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).

View file

@ -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
};
});

View file

@ -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 };
});

14
js/levels/agents.md Normal file
View file

@ -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.

10
js/levels/data/agents.md Normal file
View file

@ -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).

31
js/levels/data/level-1.js Normal file
View file

@ -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)
]
}
};
});

37
js/levels/data/level-2.js Normal file
View file

@ -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)
]
}
};
});

40
js/levels/data/level-3.js Normal file
View file

@ -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)
]
}
};
});

43
js/levels/data/level-4.js Normal file
View file

@ -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)
]
}
};
});

46
js/levels/data/level-5.js Normal file
View file

@ -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)
]
}
};
});

View file

@ -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).

View file

@ -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 };
};
});

44
js/levels/index.js Normal file
View file

@ -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 <script> order);
* node - this file requires the parts itself, in the same order.
*/
(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';
var L = {}; // the single shared levels scope
var parts;
if (typeof module !== 'undefined' && module.exports) {
parts = [
require('./helpers/index.js'),
require('./data/level-1.js'),
require('./data/level-2.js'),
require('./data/level-3.js'),
require('./data/level-4.js'),
require('./data/level-5.js')
];
} else {
parts = (typeof globalThis !== 'undefined' && globalThis.ISLE_LEVEL_PARTS) || [];
}
var LEVELS = [];
for (var i = 0; i < parts.length; i++) {
var part = parts[i](L);
if (part && part.id) LEVELS.push(part); // level data parts carry an `id`
}
if (!LEVELS.length) throw new Error('ISLE_LEVELS: no level parts loaded before the aggregator');
return { LEVELS: LEVELS, SLING: L.SLING };
});

View file

@ -0,0 +1,12 @@
# js/physics/abilities/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: launch, triggerAbility.
- `launch(g, vx, vy)` performs the turn transition (aiming -> flying, launched
counter, active-bird advance on settle).
- `triggerAbility(g)` implements the three mid-flight moves (fury / dive /
quake) keyed off the active bird's type and `ABILITY_CD`. Each slinger's move
is pinned by the "abilities" test arm (distinct, usable, no-op when
unavailable).
- Consumes: activeBird, aimVector, MATERIALS, BIRD_TYPES.

View file

@ -0,0 +1,65 @@
/* ISLE SLINGERS -- physics/abilities/index.js (submodule: abilities)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: launch, triggerAbility.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.launch = function(g, dragPos) {
var b = P.activeBird(g);
if (!b || b.state !== 'aiming' || g.status !== 'ready') return false;
var v = P.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: 'P.launch', x: b.x, y: b.y });
return true;
}
P.triggerAbility = function(g) {
var b = P.activeBird(g);
if (!b || b.state !== 'flying' || b.abilityUsed) return false;
if (g.time - b.launchT < P.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; P.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; P.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;
}
};
});

23
js/physics/agents.md Normal file
View file

@ -0,0 +1,23 @@
# js/physics/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
The physics **module** (aggregator). Deterministic, DOM-free.
- `index.js` is the ONLY public face: it builds the shared scope `P`, runs every
submodule installer against it, and exports `ISLE_CORE` (20-key API, see the
return literal — keep it key-for-key stable; adding a key is fine, renaming or
dropping one is not).
- Submodules must load BEFORE this file in the browser (index.html order); under
Node this file requires them in dependency order. Do not add a submodule
without registering it in BOTH the index.html script list and this file's
`require` list.
- The core is the simulation's single source of truth: `step(state, dt)` is the
only thing that mutates gameplay state. Everything else is either setup
(`createGame`), input translation (`launch`), or read-only queries
(`activeBird`, `trajectory`, `aimVector`, `starsFor`, `birdsRemaining`).
- Invariants: fixed-timestep consumer (tests run 1/120 s); no `Math.random()`
in gameplay paths (determinism is a test arm); no DOM access.
- New physics behavior lands in the owning submodule, NOT here. RC-375D84
S-3..S-5 (landed 2026-09-29): topple/ owns board tip-over (Q1 full continuous
rotation), collision/ owns velocity-aware crush + mass-weighted stacks (S-4),
step/ owns the landed-bird state and its integration (S-5).

14
js/physics/aim/agents.md Normal file
View file

@ -0,0 +1,14 @@
# js/physics/aim/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: aimVector, trajectory.
- `aimVector(g, sling, drag)` clamps drag to [MIN_PULL, MAX_PULL] and maps it to
a launch velocity. The pull clamp is what makes full-power shots deterministic
(the balance spec relies on it).
- `trajectory(g, x, y, vx, vy, steps)` is the preview integrator; it must track
the real flight — there is a dedicated test arm ("trajectory preview tracks
the real flight"). If you change integration in stepPhysics, change it here in
lockstep.
- Consumes: LAUNCH_POWER, GRAVITY, AIR_DRAG, BIRD_BOUNCE, WORLD,
BIRD_GROUND_FRICTION.

45
js/physics/aim/index.js Normal file
View file

@ -0,0 +1,45 @@
/* ISLE SLINGERS -- physics/aim/index.js (submodule: aim)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: aimVector, trajectory.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.aimVector = function(g, sling, drag) {
var dx = sling.x - drag.x, dy = sling.y - drag.y;
var len = Math.sqrt(dx * dx + dy * dy);
if (len < P.MIN_PULL) return null;
var cl = Math.min(len, P.MAX_PULL);
var v = cl * P.LAUNCH_POWER;
return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl };
}
// Pure P.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.
P.trajectory = function(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 + P.GRAVITY * dt;
x += vx * dt;
y += (vy + nvy) * 0.5 * dt;
vx *= P.AIR_DRAG;
vy = nvy;
}
if (y > P.WORLD.ground - 18) { pts.push({ x: x, y: P.WORLD.ground - 18, last: true }); break; }
pts.push({ x: x, y: y, last: false });
}
return pts;
}
};
});

View file

@ -0,0 +1,9 @@
# js/physics/camera/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: updatePopups, updateCameraTarget, decayCamera.
- The follow-cam: tracks the active bird / action zone inside the level bounds,
eases and decays, and ages score popups. Consumes WORLD + VIEW for clamping.
- The "aim framing" test arm asserts the sling + action zone stay in view while
aiming — keep that guarantee when changing targets or damping.

View file

@ -0,0 +1,70 @@
/* ISLE SLINGERS -- physics/camera/index.js (submodule: camera)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: updatePopups, updateCameraTarget, decayCamera.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.updatePopups = function(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);
}
}
P.updateCameraTarget = function(g) {
var b = P.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(P.WORLD.w - 420, b.x + 220));
ty = Math.max(380, Math.min(P.WORLD.ground - 160, b.y + 60));
tz = 1;
} else {
// aiming / waiting: frame the slingshot AND the whole action zone
// (live blocks + enemies) so P.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, P.VIEW.w / span);
tx = left + span / 2;
// ground at ~78% down the view, whatever the zoom
ty = Math.max(300, Math.min(P.WORLD.ground, P.WORLD.ground - (0.28 * P.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;
}
P.decayCamera = function(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;
}
}
};
});

View file

@ -0,0 +1,18 @@
# js/physics/collision/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: crushCircleVsBox, collideBirdWorld, collideBirdBlocks,
collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair.
- Contact resolution for circles (birds, enemies) vs axis-aligned blocks and
block-block pairs. `resolveBoxPair` is the box-box rest/impulse resolver;
`crushCircleVsBox` is the fast-falling-onto-structure damage path.
- RC-375D84 S-4 (landed 2026-09-29): `crushCircleVsBox` is velocity-direction-
aware (the box must be the body plowing into the enemy, `boxAlong > 30`; a
resting box never crushes), carries a per-enemy crush cooldown (0.12 s — one
landing = one crush instance) and an inelastic velocity transfer so boxes do
not phase through enemies; `resolveBoxPair` stacked contact is mass-weighted
inelastic (heavy blocks drive light planks — chain-crush). S-3 (topple) does
NOT live here: pivoting blocks are excluded from box-pair resolution by the
step loop and integrate in `topple/` — keep it that way.
- Consumes: MATERIALS, WORLD, damageBlock, damageEnemy.

View file

@ -0,0 +1,212 @@
/* ISLE SLINGERS -- physics/collision/index.js (submodule: collision)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: crushCircleVsBox, collideBirdWorld, collideBirdBlocks, collideBirdEnemies, REST_VEL_CLAMP, resolveBoxPair.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.crushCircleVsBox = function(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: a fast box hurts what it lands on.
// S-4 (RC-375D84): the direction gate is velocity-aware -- the BOX
// must be the body moving into the enemy (boxAlong > 30). The old gate
// (ny < -0.3, enemy above the box) only let SIDE plows crush: a block
// dropped STRAIGHT DOWN onto an enemy never registered ("falling
// objects can't kill"). Vertical drops and side plows share this path.
// Two sub-fixes, both watched in test/fallingkill.test.mjs:
// (a) per-enemy crush cooldown (0.12s, same idea as the bird-hit
// gate): a box resting on an enemy must not machine-gun damage
// every frame; one landing = one crush instance.
// (b) inelastic transfer: the box sheds its approach velocity into
// the enemy (mass-scaled), so a block does not phase through
// the enemy at terminal velocity after the hit.
var spec = P.ENEMY_TYPES[ent.kind];
var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0);
var boxAlong = (box.vx || 0) * nx + (box.vy || 0) * ny; // box velocity toward the enemy
if (boxSpeed > spec.crushVel && boxAlong > 30) {
if (g.time - (ent.lastCrushT || -9) > 0.12) {
P.damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
g.events.push({ type: 'crush', x: ent.x, y: ent.y });
ent.lastCrushT = g.time;
// inelastic: box loses a mass-scaled share of its normal velocity
var entM = ent.r / 24; // mogg~1, grum~1.3, king~1.6
var share = entM / ((box.mass || 1) + entM);
box.vx -= nx * boxAlong * share;
box.vy -= ny * boxAlong * share;
}
}
} else {
// bird: transfer momentum into the box
var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25;
if (dmg > 25) P.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;
}
}
P.collideBirdWorld = function(g, b) {
if (b.y + b.r > P.WORLD.ground) {
var impact = -b.vy;
b.y = P.WORLD.ground - b.r;
if (b.vy > 0) b.vy = -b.vy * P.BIRD_BOUNCE;
b.vx *= P.BIRD_GROUND_FRICTION;
if (impact > 250) {
g.events.push({ type: 'bird_ground', x: b.x, y: P.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; }
}
P.collideBirdBlocks = function(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 + P.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) * P.MATERIALS[bl.mat].dmgFactor;
if (dmg > 30) {
P.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;
}
}
}
P.collideBirdEnemies = function(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);
P.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.
P.REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed
P.resolveBoxPair = function(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 < -P.REST_VEL_CLAMP) {
// real impact: inelastic common velocity.
// S-4 (RC-375D84): mass-WEIGHTED (was a plain (a.vy+c.vy)/2), so a
// heavy block landing on a light plank transfers its real momentum
// through the stack (chain-crush). A 22.6-mass stone at v over a
// 7.2-mass plank now drives the plank to ~0.76v instead of 0.5v.
var mA2 = a.mass || 1, mC2 = c.mass || 1;
var avgY = (a.vy * mA2 + c.vy * mC2) / (mA2 + mC2);
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) < P.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;
}
}
}
};
});

View file

@ -0,0 +1,15 @@
# js/physics/constants/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: 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.
- Leaf submodule: consumes nothing from `P` — it may be loaded first (and in the
browser it is).
- MATERIALS / BIRD_TYPES / ENEMY_TYPES are the **balance tables**: hp, dmgFactor,
density, speed, bonus. The balance spec (`test/balance.test.mjs`) pins the
per-material break budget and enemy kill budget — change a table value only
with a matching spec update and a watched RED->GREEN.
- WORLD.w / WORLD.ground (2400 x 900 surface) and VIEW (1280x720 design
resolution) are the spatial contract; render and camera assume them.

View file

@ -0,0 +1,54 @@
/* ISLE SLINGERS -- physics/constants/index.js (submodule: constants)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: 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.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.WORLD = { w: 2400, ground: 900 };
P.VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size,
// then CSS-scales; camera math lives in the core)
P.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 }
};
P.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
};
P.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 }
};
P.GRAVITY = 1500;
P.LAUNCH_POWER = 11.5; // velocity per px of pull
P.MAX_PULL = 150;
P.MIN_PULL = 14;
P.AIR_DRAG = 0.999;
P.BIRD_BOUNCE = 0.34;
P.BIRD_GROUND_FRICTION = 0.86;
P.REST_SPEED = 14; // below this a bird counts as settling
P.SETTLE_TIME = 0.9; // seconds under P.REST_SPEED before bird is spent
P.BIRD_MAX_AGE = 12;
P.ABILITY_CD = 0.35; // min seconds after P.launch before ability usable
P.BIRD_BONUS = 10000;
};
});

View file

@ -0,0 +1,14 @@
# js/physics/damage/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: damageBlock, damageEnemy.
- Central damage accounting: material break points (with the wood/ice/stone hp
ladder from constants.MATERIALS) and enemy hp floors. Score and stats counters
(`stats.destroyedBlocks`, `stats.destroyedEnemies`, scorePopups) are minted
here.
- The balance spec (`test/balance.test.mjs`) asserts per-material break budgets
and enemy kill budgets under full-power direct hits — that spec is the
contract for everything that deals damage. S-4 (RC-375D84, falling-object
impact damage) routes through these two functions so it inherits the spec.
- Consumes: MATERIALS, ENEMY_TYPES.

View file

@ -0,0 +1,45 @@
/* ISLE SLINGERS -- physics/damage/index.js (submodule: damage)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: damageBlock, damageEnemy.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.damageBlock = function(g, bl, dmg, src) {
if (bl.hp <= 0) return;
bl.toppled = false; // a real hit can dislodge a settled board to tip again
bl.hp -= dmg;
if (bl.hp <= 0) {
bl.hp = 0;
g.score += P.MATERIALS[bl.mat].score;
g.stats.destroyedBlocks++;
g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + P.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 });
}
}
P.damageEnemy = function(g, e, dmg) {
if (e.dead) return;
e.hp -= dmg;
if (e.hp <= 0) {
e.dead = true;
e.deathAge = 0;
g.score += P.ENEMY_TYPES[e.kind].score;
g.stats.destroyedEnemies++;
g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + P.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);
}
}
};
});

72
js/physics/index.js Normal file
View file

@ -0,0 +1,72 @@
/* ISLE SLINGERS -- physics/index.js (module: physics -- aggregator / public API)
* RC-375D84 S-2 modularization. This is the THIN AGGREGATOR for the physics
* module. The original js/core.js monolith is now split into submodules
* (physics/constants, -/state, -/aim, -/abilities, -/damage, -/step,
* -/collision, -/rules, -/camera); each installs its units into ONE shared
* scope `P`, preserving the monolith's single-closure semantics exactly.
*
* Load contract:
* browser -- every physics/<unit>/index.js <script> must load BEFORE this
* file (it reads the ordered parts off globalThis.ISLE_PHYSICS_PARTS);
* node -- this file requires the submodules in dependency order itself.
*
* Public API (ISLE_CORE) is key-for-key identical to the pre-modularization
* core.js return object -- same 20 keys, same values. No DOM, deterministic.
*/
(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 P = {}; // the single shared physics scope (== the old monolith closure)
var parts;
if (typeof module !== 'undefined' && module.exports) {
// Node: load submodules in dependency order (constants first, camera last)
parts = [
require('./constants/index.js'),
require('./state/index.js'),
require('./aim/index.js'),
require('./abilities/index.js'),
require('./damage/index.js'),
require('./step/index.js'),
require('./collision/index.js'),
require('./topple/index.js'),
require('./rules/index.js'),
require('./camera/index.js')
];
} else {
// browser: submodules already pushed their installers, in <script> order
parts = (typeof globalThis !== 'undefined' && globalThis.ISLE_PHYSICS_PARTS) || [];
}
for (var i = 0; i < parts.length; i++) parts[i](P);
// ------------------------------------------------------------------ API
// (kept key-for-key identical to the pre-S-2 core.js public surface)
return {
WORLD: P.WORLD,
VIEW: P.VIEW,
MATERIALS: P.MATERIALS,
BIRD_TYPES: P.BIRD_TYPES,
ENEMY_TYPES: P.ENEMY_TYPES,
GRAVITY: P.GRAVITY,
MAX_PULL: P.MAX_PULL,
MIN_PULL: P.MIN_PULL,
LAUNCH_POWER: P.LAUNCH_POWER,
BIRD_BONUS: P.BIRD_BONUS,
createGame: P.createGame,
activeBird: P.activeBird,
aimVector: P.aimVector,
trajectory: P.trajectory,
launch: P.launch,
triggerAbility: P.triggerAbility,
step: P.step,
starsFor: P.starsFor,
birdsRemaining: P.birdsRemaining,
AIR_DRAG: P.AIR_DRAG
};
});

View file

@ -0,0 +1,11 @@
# js/physics/rules/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: checkEndStates, birdsRemaining, starsFor.
- Win/lose arbitration (called from step): all enemies dead -> won, no bird
left in play with enemies alive -> lost. S-5 (RC-375D84, landed 2026-09-29):
a LANDED bird counts as a bird still in play — do not declare `lost` while
one is out in the world (it can still fall, smash and kill). `starsFor(g)`
maps score to the level's three star thresholds.
- Keep this submodule free of physics: it decides, it does not move anything.

59
js/physics/rules/index.js Normal file
View file

@ -0,0 +1,59 @@
/* ISLE SLINGERS -- physics/rules/index.js (submodule: rules)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: checkEndStates, birdsRemaining, starsFor.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.checkEndStates = function(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 += P.BIRD_BONUS * P.birdsRemaining(g);
g.events.push({ type: 'level_won', x: g.camera.x, y: 400 });
return;
}
var b = P.activeBird(g);
var inFlight = false;
// S-5: a LANDED bird is still a live body (it can fall, smash, and kill
// while it exists) -- the level must not be declared lost while one is out
// in the world.
var landedOut = false;
for (var k = 0; k < g.birds.length; k++) {
if (g.birds[k].state === 'flying') inFlight = true;
if (g.birds[k].state === 'landed') landedOut = true;
}
if (!inFlight && !landedOut && !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 });
}
}
P.birdsRemaining = function(g) {
var n = 0;
for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++;
return n;
}
P.starsFor = function(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;
}
};
});

View file

@ -0,0 +1,18 @@
# js/physics/state/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: createGame, makeBird, activeBird.
- `createGame(levelDef)` is the ONLY constructor of a gameplay state object.
Its shape (keys `level, world, time, score, birds, blocks, enemies, events,
scorePopups, hadEnemies, camera, status, activeBird, launched, wonAt, lostAt,
stats`) is consumed by render, app and every test — extending is OK, silently
dropping a key is not.
- Block/enemy/bird records are plain objects. S-3/S-5 (RC-375D84, landed)
extended them additively: blocks carry `ang`, `angVel`, `pivoting`
(topple DOF) and birds gain the `landed` state (a live body under gravity
while another bird is in play; see step/topple). Keep further extensions
additive and keep `createGame` the single place where initial state is minted.
- `activeBird(g)` is the canonical "whose turn is it" query — input, camera and
rules all go through it; do not re-derive it elsewhere.
- Consumes: WORLD, MATERIALS, BIRD_TYPES, ENEMY_TYPES (constants) + makeBird.

85
js/physics/state/index.js Normal file
View file

@ -0,0 +1,85 @@
/* ISLE SLINGERS -- physics/state/index.js (submodule: state)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: createGame, makeBird, activeBird.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.createGame = function(levelDef) {
var g = {
level: levelDef,
world: { w: P.WORLD.w, ground: P.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 = P.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,
// S-3 rotational degree of freedom (topple). ang/angVel stay 0 for
// every supported stack, so pre-S-3 motion is preserved exactly.
ang: 0, angVel: 0, pivoting: false,
mass: m.density * b.w * b.h / 1400
};
});
g.enemies = levelDef.enemies.map(function (e, i) {
var t = P.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(P.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;
}
P.makeBird = function(kind, idx) {
var t = P.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
};
}
P.activeBird = function(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; }
};
});

22
js/physics/step/agents.md Normal file
View file

@ -0,0 +1,22 @@
# js/physics/step/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: step, stepPhysics, spendBird. **The physics hub.**
- `step(g, dt)` = time advance + stepPhysics + end-state check + camera. `stepPhysics`
is the integration core: gravity, air drag, bird/bird-block/bird-enemy/
block-block contacts, crush, settle bookkeeping.
- Because it reaches into nearly every unit, it is installed AFTER aim/damage and
BEFORE nothing — but all of its callees must exist in `P` by the time `step`
runs (they do, because install order is load order). Keep it that way.
- `spendBird` retires a bird that is GONE (aged out / off the island). S-5
(RC-375D84, landed 2026-09-29) split the settle branch: a bird that settles
while ANOTHER bird is in play becomes `landed` — a live body integrated
every frame in `stepPhysics` (real gravity, no lifetime timer) that falls
when its support breaks, chain-damages boards, and can kill enemies. The LAST
bird still spends (win/lose unchanged — keep it that way; the sim win/lose
test is the gate). Landed birds are cleared when the level ends (inside
`step`'s won/lost branch). `advanceToNextBird` is the shared "next bird onto
the sling" path used by both the spent and landed transitions.
- Determinism invariant: no Math.random, no Date, no DOM. Tests call this
directly at dt=1/120.

212
js/physics/step/index.js Normal file
View file

@ -0,0 +1,212 @@
/* ISLE SLINGERS -- physics/step/index.js (submodule: step)
* RC-375D84 S-2 modularization (behavior-neutral split of the original
* js/core.js monolith). Installs its units into ONE shared physics scope `P`
* that every physics submodule sees -- the same single-scope semantics the
* monolith had. Must load before js/physics/index.js (the aggregator that
* attaches the public ISLE_CORE API). Surface installed: step, stepPhysics, spendBird.
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
P.step = function(g, dt) {
g.time += dt;
if (g.status === 'won' || g.status === 'lost') {
// S-5: the level is over -- landed bodies no longer exist (their window
// was "while another bird was active"), so remove them; the rest of the
// world still settles a beat for the win/lose screen.
for (var lb = 0; lb < g.birds.length; lb++) {
if (g.birds[lb].state === 'landed') {
g.birds[lb].state = 'spent';
g.events.push({ type: 'bird_gone', x: g.birds[lb].x, y: g.birds[lb].y });
}
}
P.stepPhysics(g, dt);
P.updatePopups(g, dt);
P.decayCamera(g, dt);
return;
}
P.stepPhysics(g, dt);
P.updatePopups(g, dt);
P.decayCamera(g, dt);
P.updateCameraTarget(g);
P.checkEndStates(g);
}
P.stepPhysics = function(g, dt) {
var i, b;
// active bird flight
b = P.activeBird(g);
if (b && b.state === 'flying') {
b.age += dt;
var nvy = b.vy + P.GRAVITY * dt;
b.x += b.vx * dt;
b.y += (b.vy + nvy) * 0.5 * dt;
b.vx *= P.AIR_DRAG;
b.vy = nvy;
g.stats.distance = Math.max(g.stats.distance, b.x);
P.collideBirdWorld(g, b);
P.collideBirdBlocks(g, b);
P.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 < P.REST_SPEED) b.settleTimer += dt;
else b.settleTimer = Math.max(0, b.settleTimer - dt * 4);
if (b.age > P.BIRD_MAX_AGE || b.x > P.WORLD.w + 200 || b.x < -200) {
// gone for good (off the island or aged out) -> spent, next bird loads
P.spendBird(g, b);
} else if (b.settleTimer > P.SETTLE_TIME) {
// S-5 (RC-375D84, Q2): the "another bird is already active" window.
// A bird that settles while MORE birds are in play (queued or active)
// is NOT a corpse: it becomes a LANDED body -- real continuous
// gravity, no lifetime timer. It falls if its support breaks,
// chain-damages blocks it lands on, and can kill enemies, for as long
// as the level runs. The level-end rule (last bird, no window left)
// keeps the pre-S-5 behaviour exactly: the bird is spent there, so
// win/lose resolve unchanged.
// ANOTHER bird must be in play (queued, or flying/aiming): the settled
// bird lingers only during the "another bird is already active" window.
// It never counts itself -- for the LAST bird this stays false and the
// pre-S-5 spend path runs exactly as before (win/lose unchanged).
var moreBirds = P.birdsRemaining(g) > 0;
if (!moreBirds) {
for (var ob = 0; ob < g.birds.length; ob++) {
if (ob === g.birds.indexOf(b)) continue;
if (g.birds[ob].state === 'flying' || g.birds[ob].state === 'landed' || g.birds[ob].state === 'aiming') { moreBirds = true; break; }
}
}
if (moreBirds) {
b.state = 'landed';
b.vx = 0; b.vy = 0;
g.events.push({ type: 'bird_landed', x: b.x, y: b.y, kind: b.kind });
P.advanceToNextBird(g);
} else {
P.spendBird(g, b);
}
}
}
// S-5: landed birds stay physical bodies -- real gravity, fall when their
// support breaks, hit blocks (chain damage) and enemies (kills). No
// lifetime timer: they live until the level resolves.
for (i = 0; i < g.birds.length; i++) {
var lb = g.birds[i];
if (lb.state !== 'landed') continue;
var lvy = lb.vy + P.GRAVITY * dt;
lb.x += lb.vx * dt;
lb.y += (lb.vy + lvy) * 0.5 * dt;
lb.vy = lvy;
lb.vx *= 0.999; // a landed body keeps (almost) all of its momentum
P.collideBirdWorld(g, lb);
P.collideBirdBlocks(g, lb);
P.collideBirdEnemies(g, lb);
}
// blocks: gravity + integration + collisions
for (i = 0; i < g.blocks.length; i++) {
var bl = g.blocks[i];
if (bl.hp <= 0) continue;
if (bl.pivoting) continue; // rotate about its pivot; integrated by toppleBlocks
bl.vy = Math.min(bl.vy + P.GRAVITY * dt, P.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 > P.WORLD.ground) {
var impact = bl.vy;
bl.y = P.WORLD.ground - halfH;
if (impact > 90) {
bl.vy = -impact * P.MATERIALS[bl.mat].restitution;
bl.vx *= 0.8;
if (impact > 240) {
P.damageBlock(g, bl, (impact - 240) * 0.45, 'ground');
g.events.push({ type: 'block_impact', x: bl.x, y: P.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 > P.WORLD.w) { bl.x = P.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 || a.pivoting) continue;
for (j = i + 1; j < g.blocks.length; j++) {
c = g.blocks[j];
if (c.hp <= 0 || c.pivoting) continue;
P.resolveBoxPair(g, a, c);
}
}
}
// block tip/topple (S-3): rotate about a support-edge pivot, land, impact
P.toppleBlocks(g, dt);
// block vs bird (crush) -- S-5: the active bird AND any landed bodies
for (i = 0; i < g.birds.length; i++) {
var cb = g.birds[i];
if (cb.state !== 'flying' && cb.state !== 'landed') continue;
for (var ci = 0; ci < g.blocks.length; ci++) {
var blk = g.blocks[ci];
if (blk.hp <= 0 || blk.pivoting) continue;
P.crushCircleVsBox(g, blk, cb, 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 + P.GRAVITY * dt, 700);
e.x += e.vx * dt;
e.y += e.vy * dt;
e.vx *= 0.9;
if (e.y + e.r > P.WORLD.ground) {
if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; }
else { e.vy = 0; e.y = P.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 || bb.pivoting) continue;
P.crushCircleVsBox(g, bb, e, true);
}
}
}
// next bird onto the sling (shared by spendBird and the S-5 landed
// transition -- the same "another bird is already active" moment)
P.advanceToNextBird = function(g) {
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 });
}
}
P.spendBird = function(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
P.advanceToNextBird(g);
}
};
});

View file

@ -0,0 +1,23 @@
# js/physics/topple/ — agents.md
RC-375D84 S-3 (2026-09-29). Module map: `MODULES.md`; runtime model: `architecture.md`.
Installs into `P`: toppleBlocks, blockSupportSpan, beginTopple, integrateTopple,
landTopple, topplePathDamage, settleBack, supportStillAlive.
- Owns the board tip/topple behavior (Q1: FULL CONTINUOUS ROTATION). A resting
block whose COM lies past the x-span of its support (ground or the top face of
a block below, via `blockSupportSpan`) pivots about the support-edge corner
under gravity (point-mass COM, `alpha = G*(comX-pivotX)/r^2`).
- Two endings: (a) a far corner reaches the ground line -> `landTopple`
(impact speed = |angVel|*r, self-damage + `topplePathDamage` on the footprint:
blocks take impact, enemies in the footprint can be killed through the normal
crush curve); (b) the pivot is too high for any end to reach the ground ->
the board is a pendulum and `settleBack` returns it flat to its rest position.
- Anti-livelock: a swung-and-settled board is marked `toppled` and only re-tips
on a real knock (displacement >= 6px) or a damaging hit (`damageBlock` clears
the mark). Do NOT re-arm on velocity alone -- the pendulum's own swing-back
speed defeats that (regression: test/knifeedge.test.mjs).
- A pivoting block is EXCLUDED from linear integration, box-pair resolution and
crush in `step` -- those loops must keep skipping `bl.pivoting`.
- Consumes: WORLD, GRAVITY, REST_SPEED, MATERIALS, ENEMY_TYPES, damageBlock,
damageEnemy. Tests: test/topple.test.mjs (5 arms), test/knifeedge.test.mjs (6 arms).

223
js/physics/topple/index.js Normal file
View file

@ -0,0 +1,223 @@
/* ISLE SLINGERS -- physics/topple/index.js (submodule: topple)
* RC-375D84 S-3. Boards tip and topple end-over-end (Commander Q1: FULL
* CONTINUOUS ROTATION). A resting block whose centre of mass lies OUTSIDE the
* x-span of whatever supports it (ground, or the top face of a block below)
* begins pivoting about the support-edge corner on the side the COM leans to.
* The pivot is a fixed point; the board rotates about it under gravity
* (rigid body, point-mass COM) -- a smooth continuous swing, no snap. When the
* far end strikes the ground it lands: impact speed = angVel * r drives damage
* to the board itself and to anything in the swept path (blocks take impact,
* enemies can be killed). The board then rests at its landed angle.
*
* Adds a single rotational degree of freedom (ang, angVel, pivot) to blocks.
* Blocks that never overhang keep ang=0 forever, so pre-S-3 behaviour is
* exactly preserved for supported stacks (proven by topple.test arm 3).
*
* Installs into the shared physics scope P (same single-scope semantics as the
* monolith). Must load before js/physics/index.js. Surface installed:
* P.toppleBlocks, P.blockSupportSpan
*/
(function (root, factory) {
var part = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = part;
else (root.ISLE_PHYSICS_PARTS = root.ISLE_PHYSICS_PARTS || []).push(part);
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function (P) {
// The x-span [lo, hi] that supports the block, built from the ground and the
// top faces of live blocks whose top is at (or just below) this block's
// bottom and which overlap it in x. Returns null if the block is free-
// falling (nothing directly under it).
P.blockSupportSpan = function(g, bl) {
var bottom = bl.y + bl.h / 2;
var lo = null, hi = null;
var i, o, oTop, oBot, oLo, oHi;
// ground support: if the block's bottom sits on the ground line
if (Math.abs(bottom - P.WORLD.ground) < 2 && Math.abs(bl.vy) < P.REST_SPEED) {
lo = bl.x - bl.w / 2; hi = bl.x + bl.w / 2;
}
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
oTop = o.y - o.h / 2; // top face of the support block (unrotated support)
oBot = o.y + o.h / 2;
oLo = o.x - o.w / 2; oHi = o.x + o.w / 2;
// o supports bl when o's top face is within a hair below bl's bottom
if (bottom - oTop > 2 || oTop - bottom > 4) continue; // not touching
if (oBot < bottom - 4) continue;
// x-overlap between bl and o
var ovlLo = Math.max(bl.x - bl.w / 2, oLo);
var ovlHi = Math.min(bl.x + bl.w / 2, oHi);
if (ovlLo >= ovlHi) continue;
lo = (lo === null) ? ovlLo : Math.min(lo, ovlLo);
hi = (hi === null) ? ovlHi : Math.max(hi, ovlHi);
}
if (lo === null || hi === null) return null;
return { lo: lo, hi: hi };
}
P.toppleBlocks = function(g, dt) {
var i, bl, sp, margin = 2;
for (i = 0; i < g.blocks.length; i++) {
bl = g.blocks[i];
if (bl.hp <= 0) continue;
if (bl.pivoting) {
// support destroyed or block removed mid-swing -> free fall, keep angle
if (!P.supportStillAlive(g, bl)) { bl.pivoting = false; bl.angVel = 0; continue; }
P.integrateTopple(g, bl, dt);
continue;
}
// candidate for tipping: must be (nearly) at rest to evaluate support
if (Math.abs(bl.vy) > P.REST_SPEED * 2 || Math.abs(bl.vx) > P.REST_SPEED * 2) continue;
if (bl.toppled) {
// anti-livelock: a swung-and-settled board rests until a REAL knock
// (displacement > 6px from its settled spot, or a damaging hit, which
// clears the flag in damageBlock). Gravity settling never moves it
// that far, so the knife-edge re-tip loop is closed.
if (Math.abs(bl.x - (bl.restX || bl.x)) + Math.abs(bl.y - (bl.restY || bl.y)) >= 6) {
bl.toppled = false;
} else {
continue;
}
}
sp = P.blockSupportSpan(g, bl);
if (!sp) continue; // free-falling or in the air
var comX = bl.x;
if (comX > sp.hi + margin) {
// COM leans right of the support span -> pivot about the support's
// top-right corner (the edge of what's underneath, not the board edge)
P.beginTopple(g, bl, sp.hi, bl.y + bl.h / 2);
} else if (comX < sp.lo - margin) {
P.beginTopple(g, bl, sp.lo, bl.y + bl.h / 2);
}
}
}
P.beginTopple = function(g, bl, px, py) {
bl.pivoting = true;
bl.restX = bl.x; bl.restY = bl.y; // where it sits if the swing settles back
bl.pivotX = px; bl.pivotY = py;
// vector from pivot to COM at the start of the swing
bl.pvx = bl.x - px; bl.pvy = bl.y - py;
bl.ang = 0; bl.angVel = 0;
bl.rest = 0;
g.events.push({ type: 'topple_start', x: bl.x, y: bl.y, mat: bl.mat });
}
P.integrateTopple = function(g, bl, dt) {
var r2 = bl.pvx * bl.pvx + bl.pvy * bl.pvy;
if (r2 < 1e-6) { bl.pivoting = false; return; }
// rotate the pivot->COM vector by the current angle to get the live COM
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
var comX = bl.pivotX + bl.pvx * c - bl.pvy * s;
var comY = bl.pivotY + bl.pvx * s + bl.pvy * c;
// torque per mass about the pivot: g * (COMx - pivotX); I/m = r2
var alpha = P.GRAVITY * (comX - bl.pivotX) / r2;
bl.angVel += alpha * dt;
bl.ang += bl.angVel * dt;
bl.x = comX; bl.y = comY;
// landing: the far (non-pivot) corners hit the ground line
var hw = bl.w / 2, hh = bl.h / 2;
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
var landed = false, k, rx0, ry0, rx, ry;
for (k = 0; k < 4; k++) {
// corner vector = (pivot->COM) + (COM->corner), rotated by ang
rx0 = bl.pvx + cx[k]; ry0 = bl.pvy + cy[k];
rx = rx0 * c - ry0 * s;
ry = rx0 * s + ry0 * c;
if (bl.pivotY + ry >= P.WORLD.ground - 0.5) { landed = true; break; }
}
if (landed) { P.landTopple(g, bl); return; }
// settle-back: the pivot is too high for either end to reach the ground,
// so the board is a damped-free pendulum -- when it swings back through
// the flat angle, it lands flat on its support (the physical resolution)
if (bl.ang < 0 && bl.angVel > 0 && bl.ang > -0.06) P.settleBack(g, bl);
else if (bl.ang > 0 && bl.angVel < 0 && bl.ang < 0.06) P.settleBack(g, bl);
}
// true while the block the pivot sits on is still alive (or the pivot is on
// the ground line). Used to detect a support destroyed mid-swing.
P.supportStillAlive = function(g, bl) {
if (Math.abs(bl.pivotY - P.WORLD.ground) < 2) return true;
var i, o, oTop;
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
oTop = o.y - o.h / 2;
if (Math.abs(oTop - bl.pivotY) < 3) {
if (bl.pivotX >= o.x - o.w / 2 - 1 && bl.pivotX <= o.x + o.w / 2 + 1) return true;
}
}
return false;
}
// the board swung back to flat on its support: stop the rotation, rest it
// flat (COM returns to its exact start point, so no position drift)
P.settleBack = function(g, bl) {
bl.angVel = 0;
bl.ang = 0;
bl.pivoting = false;
bl.x = bl.restX; bl.y = bl.restY; // damped pendulum: it sits back where it was
bl.toppled = true; // anti-livelock: don't re-tip until knocked or damaged
bl.vx = 0; bl.vy = 0; bl.rest = 1;
g.events.push({ type: 'topple_settle', x: bl.x, y: bl.y, mat: bl.mat });
}
P.landTopple = function(g, bl) {
// impact speed of the COM = tangential speed about the pivot
var r = Math.hypot(bl.pvx, bl.pvy);
var impact = Math.abs(bl.angVel) * r;
// settle: stop the swing, keep the landed angle (board rests propped/flat)
bl.angVel = 0;
bl.pivoting = false;
bl.vx = 0; bl.vy = 0; bl.rest = 1;
// clamp COM onto the ground line if it dipped below
if (bl.y + bl.h / 2 > P.WORLD.ground) bl.y = P.WORLD.ground - bl.h / 2;
if (impact > 60) {
// the board itself takes impact damage from the slam
P.damageBlock(g, bl, (impact - 60) * 0.35, 'topple');
g.events.push({ type: 'topple_land', x: bl.x, y: bl.y, mat: bl.mat, strength: impact });
g.camera.shake = Math.max(g.camera.shake, Math.min(14, impact / 60));
// anything in the swept path takes it too: check the board's current
// rotated extent against live blocks and enemies
P.topplePathDamage(g, bl, impact);
}
}
P.topplePathDamage = function(g, bl, impact) {
// rotated corners of the landed board
var c = Math.cos(bl.ang), s = Math.sin(bl.ang);
var hw = bl.w / 2, hh = bl.h / 2;
var cx = [ -hw, hw, hw, -hw ], cy = [ -hh, -hh, hh, hh ];
var xs = [], ys = [];
for (var k = 0; k < 4; k++) {
xs.push(bl.x + cx[k] * c - cy[k] * s);
ys.push(bl.y + cx[k] * s + cy[k] * c);
}
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
var minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys);
var i, o, spec;
// blocks in the landed footprint
for (i = 0; i < g.blocks.length; i++) {
o = g.blocks[i];
if (o === bl || o.hp <= 0) continue;
if (o.x + o.w / 2 < minX || o.x - o.w / 2 > maxX) continue;
if (o.y + o.h / 2 < minY || o.y - o.h / 2 > maxY) continue;
P.damageBlock(g, o, impact * 0.4 * P.MATERIALS[o.mat].dmgFactor, 'topple');
}
// enemies in the swept footprint can be killed (objective b)
for (i = 0; i < g.enemies.length; i++) {
o = g.enemies[i];
if (o.dead) continue;
if (o.x + o.r < minX || o.x - o.r > maxX) continue;
if (o.y + o.r < minY || o.y - o.r > maxY) continue;
spec = P.ENEMY_TYPES[o.kind];
if (impact > spec.crushVel) {
P.damageEnemy(g, o, (impact - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
g.events.push({ type: 'crush', x: o.x, y: o.y });
}
}
}
};
});

16
js/render/agents.md Normal file
View file

@ -0,0 +1,16 @@
# js/render/ — agents.md
RC-375D84 S-2. Module map: `MODULES.md`; runtime model: `architecture.md`.
Self-contained module. Exports `ISLE_RENDERER = { Renderer, VIEW_W, VIEW_H }`.
- `new Renderer(canvas, { onEvent })` — pure draw: `draw(ctx, state)` renders
whatever the physics state contains (blocks, enemies, birds, sling, HUD-ish
scenery, popups). It never mutates `state`.
- VIEW_W/VIEW_H (1280x720) mirror physics constants VIEW — keep them in sync;
the camera lives in state (set by physics/camera), render only applies it.
- S-3/S-5 (RC-375D84, landed): `drawBlock` rotates about the block COM when
`bl.ang` is set (no-op at `ang=0`, so un-tipped blocks draw identically to
the pre-S-3 output), and `drawBird` renders `landed` birds as live bodies
alongside the `spent` corpses. The DOM-stub test only checks that drawing
does not throw and that the sling band stops tracking a launched bird
(bug #14 arm).

View file

@ -280,6 +280,10 @@
Renderer.prototype.drawBlock = function (ctx, bl) { Renderer.prototype.drawBlock = function (ctx, bl) {
if (bl.hp <= 0) return; if (bl.hp <= 0) return;
var x = bl.x - bl.w / 2, y = bl.y - bl.h / 2; var x = bl.x - bl.w / 2, y = bl.y - bl.h / 2;
// S-3: a tipped board draws rotated about its centre (COM). Supported
// stacks keep ang=0, so the transform is a no-op and output is identical.
var rotated = !!bl.ang;
if (rotated) { ctx.save(); ctx.translate(bl.x, bl.y); ctx.rotate(bl.ang); ctx.translate(-bl.x, -bl.y); }
var styles = { var styles = {
wood: { fill: '#b98a4e', edge: '#8a5f2e', line: 'rgba(90,60,25,0.5)' }, wood: { fill: '#b98a4e', edge: '#8a5f2e', line: 'rgba(90,60,25,0.5)' },
ice: { fill: 'rgba(168,220,250,0.82)', edge: 'rgba(120,180,220,0.9)', line: 'rgba(255,255,255,0.55)' }, ice: { fill: 'rgba(168,220,250,0.82)', edge: 'rgba(120,180,220,0.9)', line: 'rgba(255,255,255,0.55)' },
@ -336,6 +340,7 @@
ctx.stroke(); ctx.stroke();
} }
} }
if (rotated) ctx.restore();
}; };
Renderer.prototype.drawSling = function (ctx, sling, bird, drag) { Renderer.prototype.drawSling = function (ctx, sling, bird, drag) {
@ -506,10 +511,12 @@
// enemies // enemies
for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t); for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t);
// spent birds lie on the ground // spent birds lie on the ground; S-5: LANDED birds are live bodies too --
// they stay in the world (perched, falling, rolling) until level end.
for (var sp = 0; sp < state.birds.length; sp++) { for (var sp = 0; sp < state.birds.length; sp++) {
var sb = state.birds[sp]; var sb = state.birds[sp];
if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t); if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t);
else if (sb.state === 'landed') this.drawBird(ctx, sb, t);
} }
// score popups // score popups

View file

@ -4,7 +4,7 @@
"private": true, "private": true,
"description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.", "description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.",
"scripts": { "scripts": {
"test": "node test/sim.test.mjs && node test/page.test.mjs && node test/balance.test.mjs", "test": "node test/sim.test.mjs && node test/page.test.mjs && node test/balance.test.mjs && node test/topple.test.mjs && node test/knifeedge.test.mjs && node test/fallingkill.test.mjs && node test/landedbird.test.mjs",
"start": "python3 -m http.server 8000" "start": "python3 -m http.server 8000"
}, },
"license": "MIT" "license": "MIT"

View file

@ -13,8 +13,8 @@
* grum dies in <= 2 medium-speed direct hits (soft lobs were ~0.9 dmg) * grum dies in <= 2 medium-speed direct hits (soft lobs were ~0.9 dmg)
* king must still SURVIVE one full-power wren hit (sim.test.mjs pins hp3) * king must still SURVIVE one full-power wren hit (sim.test.mjs pins hp3)
*/ */
import ISLE from '../js/core.js'; import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
import levelsModule from '../js/levels.js'; import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
const LEVELS = levelsModule.LEVELS; const LEVELS = levelsModule.LEVELS;
let passed = 0, failed = 0; let passed = 0, failed = 0;
const failures = []; const failures = [];

121
test/fallingkill.test.mjs Normal file
View file

@ -0,0 +1,121 @@
// test/fallingkill.test.mjs -- RC-375D84 S-4: falling-object impact damage.
// Commander (verbatim in the RC): "falling objects need to be able to kill the
// enemy's as well not just the birds". Under the RC-0D193E-tuned balance
// (block maxVel clamps: stone 480, ice 560, wood 520) blocks kill a mogg in
// one impact (1.07-1.45) and a grum (hp 2) in two impacts (1.07 x 2 = 2.14).
// S-4 fixes the two gaps that made falling blocks unable to kill, plus a
// double-fire defect the fix exposed:
// (a) crush direction gate -- the old "ny < -0.3" (enemy ABOVE the box) meant
// a block dropped STRAIGHT DOWN onto an enemy never registered. Now the
// box must simply be the body moving into the enemy (boxAlong > 30).
// (b) chain momentum -- resolveBoxPair used a mass-less (a.vy+c.vy)/2, so a
// heavy stone landing on a light plank handed the plank only half the
// speed (240 px/s, UNDER the 330 mogg threshold). Mass-weighted inelastic
// contact transfers the real momentum (plank -> 466 px/s, OVER), so a
// knocked plank is itself a live projectile that can kill.
// (c) crush cooldown + inelastic transfer -- with (a) in place a box resting
// on an enemy at terminal velocity would otherwise re-fire damage every
// frame (4 frames = a king gone). One landing = one crush instance
// (0.12s per-enemy cooldown, same idea as the bird-hit gate); the box
// also sheds a mass-scaled share of its normal velocity so it does not
// phase through the enemy.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ISLE = require('../js/physics/index.js');
const DT = 1 / 120;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(' PASS ' + name); }
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
}
function game(def) {
const lvl = Object.assign({ name: 'fk-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
return ISLE.createGame(lvl);
}
function step(g, secs) {
const n = Math.round(secs / DT);
for (let i = 0; i < n; i++) ISLE.step(g, DT);
}
// arm 1 (chain kill): heavy stone 200x200 (mass 62.9) dropped onto an ice
// plank 120x30 (mass 1.8) that overhangs a mogg. Mass-weighted contact drives
// the plank to ~466 px/s (the old mass-less average would give ~240, UNDER the
// 330 threshold) -> the plank crushes the mogg for 1.01 >= hp 1. KILL through
// the stack: the falling stone kills the enemy via the plank it knocks.
{
const g = game({
blocks: [
{ x: 1300, y: 300, w: 200, h: 200, mat: 'stone' }, // bottom 400
{ x: 1300, y: 767, w: 120, h: 30, mat: 'ice' } // 752..782, overhangs mogg at 1340
],
enemies: [{ x: 1340, y: 876, kind: 'mogg' }]
});
step(g, 5.0);
const e = g.enemies[0];
check('chain: heavy stone drives a plank fast enough to KILL the mogg it sweeps', e.dead,
'mogg hp=' + e.hp + ' dead=' + e.dead);
}
// arm 2 (direct one-hit kill): stone dropped straight onto a mogg.
{
const g = game({ blocks: [{ x: 1200, y: 280, w: 120, h: 120, mat: 'stone' }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
step(g, 4.0);
const e = g.enemies[0];
check('direct: a falling stone kills the mogg it lands on (one hit)', e.dead,
'mogg hp=' + e.hp + ' dead=' + e.dead);
}
// arm 3 (repeated-impact kill): a grum (hp 2) dies to two ice drops landing at
// DISJOINT spots (offset 90px, blocks are 120 wide so they never touch each
// other). 1.07 x 2 = 2.14 >= 2.
{
const g = game({ enemies: [{ x: 1200, y: 868, kind: 'grum' }] });
g.blocks.push({ id: 90, x: 1160, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
step(g, 4.0); // first impact at x=1160: 1.07
const afterFirst = g.enemies[0].hp;
g.blocks.push({ id: 91, x: 1250, y: 280, w: 120, h: 120, mat: 'ice', hp: 480, maxHp: 480, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 0.7 * 120 * 120 / 1400 });
step(g, 4.0); // second impact at x=1250: 1.07
const e = g.enemies[0];
check('repeated: a grum dies to two disjoint ice drops (1.07 x 2 >= 2)', e.dead,
'after 1st=' + afterFirst.toFixed(2) + ' final hp=' + e.hp + ' dead=' + e.dead);
}
// arm 4 (one impact, no machine-gun): a LOW stone drop (70px fall -> v=458,
// below stone's 480 clamp) deals 0.96 < 1: the mogg survives with EXACTLY one
// damage instance even though the stone rests on it for the whole window.
// Without the cooldown, the resting box (still registered at terminal speed)
// would re-fire every frame and finish it.
{
const g = game({ blocks: [{ x: 1200, y: 722, w: 120, h: 120, mat: 'stone' }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
let crushEvents = 0;
const origPush = g.events.push.bind(g.events);
g.events.push = function (ev) { if (ev.type === 'crush') crushEvents++; return origPush(ev); };
step(g, 4.0);
const e = g.enemies[0];
// impact ~462 px/s (70px fall + ~5px detection overlap): dmg ~0.99, survives
// with a hair of hp -- and the resting box must not re-fire (exactly 1 event).
check('one landing = one crush instance (no per-frame re-fire)',
crushEvents === 1 && !e.dead && e.hp > 0 && e.hp < 0.05,
'crushEvents=' + crushEvents + ' mogg hp=' + e.hp.toFixed(3));
}
// arm 5 (negative: under threshold): a wood block dropped only 36px
// (v=328 < the 330 mogg crushVel) must deal zero crush damage.
{
// A block resting ON the mogg with no impact velocity must never crush it:
// the crush gate is velocity-based (boxAlong > 30), so a box that is merely
// sitting there (boxAlong ~ 0) deals nothing. Guards the per-frame
// machine-gun failure mode from the other direction.
const g = game({ blocks: [{ x: 1200, y: 832, w: 60, h: 40, mat: 'wood', hp: 1300, maxHp: 1300, vx: 0, vy: 0, rest: 1, ang: 0, angVel: 0, pivoting: false, mass: 1.0 * 60 * 40 / 1400 }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }] });
step(g, 4.0);
const e = g.enemies[0];
check('negative: a block RESTING on the mogg (no impact velocity) never crushes it', e.hp === 1 && !e.dead,
'mogg hp=' + e.hp);
}
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
process.exit(failed ? 1 : 0);

62
test/knifeedge.test.mjs Normal file
View file

@ -0,0 +1,62 @@
// test/knifeedge.test.mjs -- RC-375D84 S-3 regression: knife-edge overhang.
// A board whose COM is a hair (5px) past the support edge, with the pivot too
// high for either end to reach the ground, is a pendulum. It must tip ONCE,
// swing back, and REST (damped resolution) -- it must not re-tip in a
// livelock loop. This regression was found on real level L3 during the golden
// audit: 14 re-tips in 0.5s (gaps 0.01-0.04s) at one position; the v1
// anti-livelock flag was defeated by a velocity re-arm (the pendulum's own
// swing-back speed cleared the flag), so the board re-tipped forever.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ISLE = require('../js/physics/index.js');
const DT = 1 / 120;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(' PASS ' + name); }
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
}
function game(def) {
const lvl = Object.assign({ name: 'knife-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
return ISLE.createGame(lvl);
}
// pillar top y=700, right edge x=1100 (pillar 200x200 @1000,800).
// Board 300x30 center (1105,685): COM at x=1105, only 5px past the pivot edge
// x=1100. Far corner radius ~158 < 200 (pivot-to-ground) -> the ends can never
// reach the ground -> pendulum case (settle-back, not a ground landing).
const PILLAR = { x: 1000, y: 800, w: 200, h: 200, mat: 'stone' };
const KNIFE = { x: 1105, y: 685, w: 300, h: 30, mat: 'wood' };
const g = game({ blocks: [PILLAR, KNIFE] });
const board = g.blocks[1];
let tips = 0;
const origPush = g.events.push.bind(g.events);
g.events.push = function (ev) { if (ev.type === 'topple_start') tips++; return origPush(ev); };
// run 10s (covers ~9 pendulum periods; a livelock would re-tip every period)
const N = 10 / DT;
for (let i = 0; i < N; i++) ISLE.step(g, DT);
check('knife-edge board tips at all (COM 5px past edge)', tips >= 1, 'tips=' + tips);
check('NO livelock: a pendulum overhang tips at most ONCE in 10s', tips <= 2, 'tips=' + tips);
check('board ends at rest (not pivoting)', board.pivoting === false, 'pivoting=' + board.pivoting);
check('board rests flat after settle-back', Math.abs(board.ang || 0) < 0.02, 'ang=' + (board.ang * 180 / Math.PI).toFixed(1) + 'deg');
check('board snapped back to its rest position (no drift)', Math.abs(board.x - 1105) < 0.5 && Math.abs(board.y - 685) < 0.5, 'x=' + board.x.toFixed(2) + ' y=' + board.y.toFixed(2));
// a REAL knock (displacement >= 6px, COM pushed further past the edge) must
// re-arm the board to tip again
{
let tips2 = 0;
const g2 = game({ blocks: [PILLAR, KNIFE] });
const b2 = g2.blocks[1];
const push2 = g2.events.push.bind(g2.events);
g2.events.push = function (ev) { if (ev.type === 'topple_start') tips2++; return push2(ev); };
for (let i = 0; i < 2 / DT; i++) ISLE.step(g2, DT); // settle: tipped once, swung back, resting
// knock: shove it 20px PAST the edge (COM 1105 -> 1125, well outside span [1000,1100])
b2.x += 20; b2.vx = 60;
for (let i = 0; i < 3 / DT; i++) ISLE.step(g2, DT);
check('a real knock (past the edge) re-arms a settled board to tip again', tips2 >= 2, 'tips=' + tips2);
}
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
process.exit(failed ? 1 : 0);

151
test/landedbird.test.mjs Normal file
View file

@ -0,0 +1,151 @@
// test/landedbird.test.mjs -- RC-375D84 S-5: landed bird remains a physical body.
// Commander (verbatim): "When a board is broken from underneath a bird it just
// floats. Gravity needs to work for the birds as well so if the platform falls
// from underneath them they can fall causing more damage to any boards that they
// fall on like a chain reaction and the birds can also kill more enemy's that way
// as well. That is AFTER a bird has been shot from a slingshot and another bird is
// already active."
// Q2 (Commander ruling): REAL CONTINUOUS GRAVITY, no fixed lifetime timer -- the
// bird is a body as long as it exists.
//
// The pre-S-5 defect: spendBird() marked a settled bird 'spent' and zeroed its
// velocity; stepPhysics only integrated the ACTIVE bird, so a settled bird was a
// visual corpse -- when its platform broke it FLOATED in place (never fell).
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ISLE = require('../js/physics/index.js');
const DT = 1 / 120;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(' PASS ' + name); }
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
}
function game(def) {
const lvl = Object.assign({ name: 'lb-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
return ISLE.createGame(lvl);
}
function step(g, secs) {
const n = Math.round(secs / DT);
for (let i = 0; i < n; i++) ISLE.step(g, DT);
}
// helper: put a bird in a settled/landed state on a board, with a queued second
// bird active (the "another bird is already active" window).
function setupPerched() {
// board 200x30 at (1200,765): x 1100..1300, top 750.
const g = game({
blocks: [{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' }],
birds: ['wren', 'wren'], // [0] will be perched, [1] the 'already active' next bird
});
// bird 0: perched on the board, settled (no velocity).
const b0 = g.birds[0];
b0.state = 'landed';
b0.x = 1200; b0.y = 750 - b0.r; // resting on the board top
b0.vx = 0; b0.vy = 0;
// bird 1: active, aiming on the sling.
g.activeBird = 1;
g.birds[1].state = 'aiming';
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
return g;
}
// arm 1 (THE S-5 behaviour): the perched bird FALLS when its board breaks.
// The board rests on two stone pillars (so the platform is stable); the bird
// is perched on the board. A bird that is a corpse (pre-S-5) would stay at its
// perch forever while the board falls away. The landed bird must drop toward
// the ground (y rises well past the perch).
{
const g = game({
blocks: [
{ x: 1120, y: 840, w: 40, h: 120, mat: 'stone' }, // pillar L (top 780)
{ x: 1280, y: 840, w: 40, h: 120, mat: 'stone' }, // pillar R (top 780)
{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' } // board 750..780 on the pillars
],
birds: ['wren', 'wren']
});
const b0 = g.birds[0];
b0.state = 'landed';
b0.x = 1200; b0.y = 750 - b0.r;
b0.vx = 0; b0.vy = 0;
g.activeBird = 1; g.birds[1].state = 'aiming';
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
const board = g.blocks[2];
step(g, 0.5);
const yIntact = b0.y;
board.hp = 0; // break the board out from under the bird
step(g, 2.0);
check('perched bird FALLS when its platform breaks (Q2 real gravity)', b0.y > yIntact + 80,
'y intact=' + yIntact.toFixed(0) + ' -> after break=' + b0.y.toFixed(0) + ' (ground=' + ISLE.WORLD.ground + ')');
}
// arm 2: a landed bird on a stable board (pillars + board) does NOT drift.
{
const g = game({
blocks: [
{ x: 1120, y: 840, w: 40, h: 120, mat: 'stone' },
{ x: 1280, y: 840, w: 40, h: 120, mat: 'stone' },
{ x: 1200, y: 765, w: 200, h: 30, mat: 'wood' }
],
birds: ['wren', 'wren']
});
const b0 = g.birds[0];
b0.state = 'landed';
b0.x = 1200; b0.y = 750 - b0.r;
b0.vx = 0; b0.vy = 0;
g.activeBird = 1; g.birds[1].state = 'aiming';
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
const x0 = b0.x, y0 = b0.y;
step(g, 2.0);
check('landed bird on an intact board stays put (no drift, no float-away)', Math.abs(b0.x - x0) < 2 && Math.abs(b0.y - y0) < 3,
'x ' + x0.toFixed(1) + '->' + b0.x.toFixed(1) + ' y ' + y0.toFixed(1) + '->' + b0.y.toFixed(1));
}
// arm 3 (chain reaction): the falling bird damages the board it lands on.
// Bird perched high, board beneath it breaks, a lower board catches it -- the
// lower board must take impact damage.
{
const g = game({
blocks: [
{ x: 1200, y: 600, w: 120, h: 30, mat: 'ice' }, // upper perch (breaks)
{ x: 1200, y: 880, w: 200, h: 40, mat: 'ice' } // lower catcher
],
birds: ['wren', 'wren']
});
const b0 = g.birds[0];
b0.state = 'landed';
b0.x = 1200; b0.y = 585 - b0.r; // on the upper perch top (585)
b0.vx = 0; b0.vy = 0;
g.activeBird = 1;
g.birds[1].state = 'aiming';
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
const catcher = g.blocks[1];
const catcherHp0 = catcher.hp;
g.blocks[0].hp = 0; // break the perch
step(g, 2.0);
check('falling bird damages the board it lands on (chain reaction)', catcher.hp < catcherHp0,
'catcher hp ' + catcherHp0 + ' -> ' + catcher.hp.toFixed(0));
}
// arm 4 (kill): the falling bird can kill an enemy in its fall path.
{
const g = game({
blocks: [{ x: 1200, y: 600, w: 120, h: 30, mat: 'ice' }],
enemies: [{ x: 1200, y: 876, kind: 'mogg' }],
birds: ['wren', 'wren']
});
const b0 = g.birds[0];
b0.state = 'landed';
b0.x = 1200; b0.y = 585 - b0.r;
b0.vx = 0; b0.vy = 0;
g.activeBird = 1;
g.birds[1].state = 'aiming';
g.birds[1].x = g.level.sling.x; g.birds[1].y = g.level.sling.y;
const e = g.enemies[0];
g.blocks[0].hp = 0; // break the perch -> bird drops onto the mogg
step(g, 2.5);
check('falling bird KILLS an enemy in its fall path', e.dead,
'mogg hp=' + e.hp + ' dead=' + e.dead + ' birdY=' + b0.y.toFixed(0));
}
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
process.exit(failed ? 1 : 0);

View file

@ -145,7 +145,18 @@ console.log('\n== ISLE SLINGERS page boot (DOM stub) ==');
// ------------------------------------------------------------------- tests // ------------------------------------------------------------------- tests
test('page loads: all five modules execute without throwing (browser order)', () => { test('page loads: all five modules execute without throwing (browser order)', () => {
for (const f of ['js/core.js', 'js/levels.js', 'js/audio.js', 'js/renderer.js', 'js/main.js']) { // RC-375D84 S-2: modularized tree -- same browser order as index.html
const FILES = [
'js/physics/constants/index.js', 'js/physics/state/index.js', 'js/physics/aim/index.js',
'js/physics/abilities/index.js', 'js/physics/damage/index.js', 'js/physics/step/index.js',
'js/physics/collision/index.js', 'js/physics/topple/index.js', 'js/physics/rules/index.js', 'js/physics/camera/index.js',
'js/physics/index.js',
'js/levels/helpers/index.js',
'js/levels/data/level-1.js', 'js/levels/data/level-2.js', 'js/levels/data/level-3.js',
'js/levels/data/level-4.js', 'js/levels/data/level-5.js', 'js/levels/index.js',
'js/audio/index.js', 'js/render/index.js', 'js/app/index.js'
];
for (const f of FILES) {
try { try {
load(f); load(f);
} catch (e) { } catch (e) {

View file

@ -5,14 +5,31 @@
* numeric stability, trajectory-preview fidelity, ability behaviour, * numeric stability, trajectory-preview fidelity, ability behaviour,
* win/lose rules, and that every level is clearable by a perfect archer. * win/lose rules, and that every level is clearable by a perfect archer.
*/ */
import { readFileSync } from 'node:fs'; import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import ISLE from '../js/core.js'; import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
import levelsModule from '../js/levels.js'; import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
const { LEVELS } = levelsModule; const { LEVELS } = levelsModule;
const HERE = dirname(fileURLToPath(import.meta.url)); const HERE = dirname(fileURLToPath(import.meta.url));
// RC-375D84 S-2: recursive list of every .js under js/ (the modularized tree).
// Replaces the old hardcoded [core,levels,audio,renderer,main] file list so
// the syntax + DOM-wiring arms scan the real submodules, not orphaned monoliths.
function listJs(rootRel) {
const base = join(HERE, '..', rootRel);
const out = [];
(function walk(d) {
for (const name of readdirSync(d)) {
const fp = join(d, name);
if (statSync(fp).isDirectory()) walk(fp);
else if (name.endsWith('.js')) out.push(fp);
}
})(base);
return out.sort();
}
const JS_FILES = listJs('js');
let passed = 0, failed = 0; let passed = 0, failed = 0;
const failures = []; const failures = [];
@ -176,10 +193,16 @@ console.log('\n== ISLE SLINGERS headless sim ==');
// ------------------------------------------------------------------ 1. syntax // ------------------------------------------------------------------ 1. syntax
test('all JS files parse', () => { test('all JS files parse', () => {
for (const f of ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js']) { const files = JS_FILES.map((fp) => fp.slice(fp.indexOf('js/')));
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8'); ok(files.length >= 15, 'S-2: expected >=15 js files in the modularized tree, got ' + files.length);
for (const fp of files) {
const src = readFileSync(join(HERE, '..', fp), 'utf8');
new Function(src); // throws on syntax error new Function(src); // throws on syntax error
ok(src.length > 500, f + ' suspiciously small'); }
// every submodule that carries real logic must be more than a stub
for (const fp of files.filter((f) => /physics\/(state|step|collision)\//.test(f) || /physics\/index/.test(f))) {
const src = readFileSync(join(HERE, '..', fp), 'utf8');
ok(src.length > 800, fp + ' suspiciously small');
} }
}); });
@ -194,13 +217,13 @@ test('DOM wiring: every id referenced by js/ exists in index.html', () => {
let m; let m;
const idRe = /id="([^"]+)"/g; const idRe = /id="([^"]+)"/g;
while ((m = idRe.exec(html))) ids.add(m[1]); while ((m = idRe.exec(html))) ids.add(m[1]);
const files = ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js'];
const refs = []; const refs = [];
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g; const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
for (const f of files) { for (const fp of JS_FILES) {
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8'); const rel = fp.slice(fp.indexOf('js/'));
const src = readFileSync(fp, 'utf8');
let r; let r;
while ((r = refRe.exec(src))) refs.push(f + '->' + (r[1] || r[2])); while ((r = refRe.exec(src))) refs.push(rel + '->' + (r[1] || r[2]));
} }
ok(refs.length > 10, 'no DOM references found — scan pattern is broken'); ok(refs.length > 10, 'no DOM references found — scan pattern is broken');
const missing = [...new Set(refs.filter((rf) => !ids.has(rf.split('->')[1])))].sort(); const missing = [...new Set(refs.filter((rf) => !ids.has(rf.split('->')[1])))].sort();

96
test/topple.test.mjs Normal file
View file

@ -0,0 +1,96 @@
// test/topple.test.mjs -- RC-375D84 S-3: board tip/topple physics
// Q1 (Commander ruling, RC-375D84 turn 2): FULL CONTINUOUS ROTATION -- boards
// rotate smoothly through the fall (no discrete settle-then-snap); COM past
// the support edge triggers the tip; the tip lands with impact damage and
// falling objects can kill enemies.
//
// Every arm was proven RED on the pre-S-3 tree: blocks carried no rotation
// degree of freedom, so nothing could ever tip.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const ISLE = require('../js/physics/index.js');
const DT = 1 / 120;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(' PASS ' + name); }
else { failed++; console.log(' FAIL ' + name + (detail ? ' -- ' + detail : '')); }
}
function step(g, secs) {
const n = Math.round(secs / DT);
for (let i = 0; i < n; i++) ISLE.step(g, DT);
}
// Real createGame, custom level definition (no birds needed for these arms).
function game(def) {
const lvl = Object.assign({ name: 'topple-test', sling: { x: 260, y: 560 }, birds: [], blocks: [], enemies: [] }, def);
return ISLE.createGame(lvl);
}
// Geometry (verified against the pendulum prototype):
// ground y=900. Stone pillar 200x200 at (1000,800): x 900..1100, top y=700.
// Wood board 300x30 at (1200,685): x 1050..1350, COM at x=1200 -- 100px
// PAST the pivot edge (pillar top-right corner x=1100, y=700).
const PILLAR = { x: 1000, y: 800, w: 200, h: 200, mat: 'stone' };
const BOARD_OVER = { x: 1200, y: 685, w: 300, h: 30, mat: 'wood' };
const BOARD_CENTERED = { x: 1000, y: 685, w: 300, h: 30, mat: 'wood' }; // COM at x=1000, inside span 900..1100
// impact victim: ice 200x30 at (1250,885) -- under the board's fall path
const VICTIM = { x: 1250, y: 885, w: 200, h: 30, mat: 'ice' };
console.log('== ISLE SLINGERS topple (S-3) ==');
// ---- arm 1: COM past the support edge -> the board tips end-over-end ----
{
const g = game({ blocks: [PILLAR, BOARD_OVER] });
const board = g.blocks[1];
step(g, 2.0);
const ang = board.ang || 0;
check('overhanging board tips (COM past support edge)', ang > 0.5,
'ang=' + ang + 'rad (' + (ang * 180 / Math.PI).toFixed(1) + 'deg)');
}
// ---- arm 2: rotation is CONTINUOUS (Q1) -- smooth swing, intermediate
// angles, no discrete settle-then-snap ----
{
const g = game({ blocks: [PILLAR, BOARD_OVER] });
const board = g.blocks[1];
const samples = [];
for (let i = 0; i < 96; i++) { ISLE.step(g, DT); samples.push(board.ang || 0); }
let maxDelta = 0;
for (let i = 1; i < samples.length; i++) maxDelta = Math.max(maxDelta, Math.abs(samples[i] - samples[i - 1]));
const finalAng = samples[samples.length - 1];
const sawIntermediate = samples.some(a => a > 0.1 && a < finalAng - 0.1);
check('rotation is continuous (smooth swing, intermediate angles)', sawIntermediate && maxDelta < 0.05,
'samples(0.8s, every 4th)=' + samples.filter((_, i) => i % 4 === 0).map(a => a.toFixed(3)).join(',') + ' maxStep=' + maxDelta.toFixed(4) + ' final=' + finalAng.toFixed(3));
}
// ---- arm 3: a fully-supported board does NOT tip (negative control) ----
{
const g = game({ blocks: [PILLAR, BOARD_CENTERED] });
const board = g.blocks[1];
step(g, 2.0);
check('fully-supported board stays put (no tip)', (board.ang || 0) === 0 && (board.vy || 0) === 0,
'ang=' + board.ang + ' vy=' + board.vy);
}
// ---- arm 4: the tip lands with IMPACT that damages a block below ----
{
const g = game({ blocks: [PILLAR, BOARD_OVER, VICTIM] });
const victim = g.blocks[2];
step(g, 3.0);
check('tipping board impact damages a block in its fall path', victim.hp < ISLE.MATERIALS.ice.hp,
'victim hp=' + victim.hp + ' (full=' + ISLE.MATERIALS.ice.hp + ') boardAng=' + ((g.blocks[1].ang || 0) * 180 / Math.PI).toFixed(1) + 'deg');
}
// ---- arm 5: a tipping board KILLS an enemy in its fall path (objective (b)) ----
{
const g = game({
blocks: [PILLAR, BOARD_OVER],
enemies: [{ x: 1250, y: 876, kind: 'mogg' }], // r=24, stands under the fall path
});
const e = g.enemies[0];
step(g, 3.0);
check('falling board kills an enemy in its fall path', e.dead,
'enemy hp=' + e.hp + ' dead=' + e.dead + ' boardAng=' + ((g.blocks[1].ang || 0) * 180 / Math.PI).toFixed(1) + 'deg');
}
console.log('== ' + passed + ' passed, ' + failed + ' failed ==');
process.exit(failed ? 1 : 0);