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
# verify (both arms):
node --check js/core.js js/renderer.js js/main.js js/levels.js
node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability
node test/page.test.mjs # 7/7 — page boot, click session, direction, release
# or: npm test (runs both, in order)
node test/balance.test.mjs# 5/5 — balance spec (material break + enemy kill budgets)
# or: npm test (runs all, in order)
```
## 8. Known limitations (honest list)
@ -406,3 +406,27 @@ designs are original. The game is named **Isle Slingers**.
See the repository commit SHA recorded in the RC completion payload.
Working tree at commit time contains exactly the files listed in §3.
## 11. Addendum — RC-375D84 S-2 modularization (2026-09-28)
The flat `js/{core,levels,audio,renderer,main}.js` files described in §3 no
longer exist. The tree was restructured (behavior-neutral, proven by a
golden-step hash — identical scripted playthroughs through the original
monolith and the split build produce byte-identical state hashes — and by
27/27 green with the old files DELETED, so the new paths are the only load
path):
- `js/physics/` — the engine, split into 9 submodules (constants, state,
aim, abilities, damage, step, collision, rules, camera) that install into
ONE shared scope `P`; `js/physics/index.js` aggregates and exports the
unchanged 20-key `ISLE_CORE` API.
- `js/levels/` — `helpers/` (constructors + SLING) + `data/level-1..5.js`
into shared scope `L`; `index.js` exports the unchanged `{LEVELS, SLING}`.
- `js/audio/`, `js/render/`, `js/app/` — the former audio.js / renderer.js /
main.js, moved into module folders (content unchanged at S-2 time).
- Every module and submodule carries an `agents.md`; the master list is
`MODULES.md`; the runtime model and load-order contract is `architecture.md`.
- The test suites were re-pointed to the new paths (`sim.test.mjs` now walks
the whole `js/` tree; `page.test.mjs` loads the 19-script browser order).
See `architecture.md` before touching any of these.

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>
<script src="js/core.js"></script>
<script src="js/levels.js"></script>
<script src="js/audio.js"></script>
<script src="js/renderer.js"></script>
<script src="js/main.js"></script>
<!-- RC-375D84 S-2: modularized tree. Order matters:
physics submodules (shared scope) -> physics aggregator (ISLE_CORE)
-> levels helpers + data (shared scope) -> levels aggregator (ISLE_LEVELS)
-> audio -> render -> app -->
<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>
</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) {
if (bl.hp <= 0) return;
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 = {
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)' },
@ -336,6 +340,7 @@
ctx.stroke();
}
}
if (rotated) ctx.restore();
};
Renderer.prototype.drawSling = function (ctx, sling, bird, drag) {
@ -506,10 +511,12 @@
// enemies
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++) {
var sb = state.birds[sp];
if (sb.state === 'spent' && !sb.gone) this.drawBird(ctx, sb, t);
else if (sb.state === 'landed') this.drawBird(ctx, sb, t);
}
// score popups

View file

@ -4,7 +4,7 @@
"private": true,
"description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.",
"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"
},
"license": "MIT"

View file

@ -13,8 +13,8 @@
* 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)
*/
import ISLE from '../js/core.js';
import levelsModule from '../js/levels.js';
import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
const LEVELS = levelsModule.LEVELS;
let passed = 0, failed = 0;
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
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 {
load(f);
} catch (e) {

View file

@ -5,14 +5,31 @@
* numeric stability, trajectory-preview fidelity, ability behaviour,
* 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 { fileURLToPath } from 'node:url';
import ISLE from '../js/core.js';
import levelsModule from '../js/levels.js';
import ISLE from '../js/physics/index.js'; // RC-375D84 S-2: physics module aggregator (was js/core.js)
import levelsModule from '../js/levels/index.js'; // RC-375D84 S-2: levels module aggregator (was js/levels.js)
const { LEVELS } = levelsModule;
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;
const failures = [];
@ -176,10 +193,16 @@ console.log('\n== ISLE SLINGERS headless sim ==');
// ------------------------------------------------------------------ 1. syntax
test('all JS files parse', () => {
for (const f of ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js']) {
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
const files = JS_FILES.map((fp) => fp.slice(fp.indexOf('js/')));
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
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;
const idRe = /id="([^"]+)"/g;
while ((m = idRe.exec(html))) ids.add(m[1]);
const files = ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js'];
const refs = [];
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
for (const f of files) {
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
for (const fp of JS_FILES) {
const rel = fp.slice(fp.indexOf('js/'));
const src = readFileSync(fp, 'utf8');
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');
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);