Compare commits

...

No commits in common. "1fc218f03c56566e1076126d87ca00300ef04768" and "e08b838e2671f0737cab4e6f2b2d4248707a2dd1" have entirely different histories.

54 changed files with 6 additions and 4935 deletions

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
node_modules/
*.log.bak

View file

@ -1,431 +0,0 @@
# BUILD RECORD — "Build Angry Birds, Report Process"
- **Worker:** Hermie (HANDS worker, Hermes Agent v0.19.1, default profile)
- **Date:** 2026-08-28 (UTC)
- **Artifact root:** this git repository
- **Result:** shipped — playable browser game + this build record. Test
suites: **sim 15/15 PASS + page 7/7 PASS** (see §5; post-shipment
fixes: bug #11 boot crash, bugs #12-13 physics+camera, bugs #14-15
release+direction — all found in playtest).
---
## 1. Mission and deliverables
Dispatch asked for an "Angry Birds" build with a report on the process.
Two constraints shaped everything:
1. **IP boundary.** No Rooster/RIAA assets, art, audio, branding, or
trademarked names may be copied. The deliverable is therefore an
*original* slingshot-physics game, **Isle Slingers**: same genre and
same core loop (drag-sling projectiles at fragile structures to defeat
cartoon enemies), 100% original procedural art (Canvas 2D) and
synthesized audio (WebAudio oscillators — no sample files).
2. **Report the process.** This document is the process record: what was
built, how, what broke, how it was verified.
## 2. Environment baseline (telemetry)
Captured before coding into `telemetry/collected.json` (+ `ledger.log`,
collector `telemetry/collect.py`):
| Item | Value |
|---|---|
| Host | Debian 13 (trixie) container, kernel 6.12.33 (TrueNAS host), virtual |
| CPU / RAM | AMD Ryzen 9 9900X (24 threads), 123 GiB RAM |
| Disk | 638 GiB overlay, 46% used |
| Node | v20.19.2 (npm added post-shipment via corepack shim — see §6 notes) |
| Python | 3.11.15 |
| Git | 2.47.3 |
| Hermes | /opt/hermes @ ba2a4296 (pinned), v0.19.1, profile: default |
## 3. Architecture
Zero runtime dependencies. Everything is plain ES5-flavored JS in UMD
wrappers so each module loads in a browser (`window.*`) *and* in Node
(`require`/`import`) — that dual-loadability is what makes the headless
test harness possible.
```
index.html 86 L page shell, script include order
css/style.css 97 L layout, HUD styling
js/levels.js 158 L LEVELS[] data: 5 levels, coords = block centers
js/core.js ~640 L THE engine: pure deterministic simulation
js/renderer.js ~595 L Canvas 2D: procedural art, particles, camera pan+zoom
js/audio.js 137 L WebAudio synth (no samples): launch, hit, break, win
js/main.js 325 L glue: RAF loop, mouse+touch input, screens, HUD
test/sim.test.mjs ~410 L headless verification (see §5)
test/page.test.mjs ~200 L page-boot harness (see §5)
telemetry/ pre-build environment baseline
```
### Core invariants (js/core.js)
- **Fixed timestep 120 Hz**, semi-implicit Euler with velocity-averaged
position update, per-substep air drag. No `Math.random` anywhere in the
core (verified by grep; only a doc comment mentions it). Identical
inputs produce byte-identical states (test §6.3).
- **No DOM, no audio, no timers** in the core: `createGame(levelDef) -> state`,
`step(state, dt)` mutates state, `state.events[]` is an event queue the
renderer/audio *consume* (hit, break, kill, launch, score popups). This
split is what lets the game be unit-tested headlessly in Node.
- **Materials:** wood (hp 1300, e 0.35), ice (hp 480, e 0.2, fragile vs
high impact), stone (hp 3400, e 0.15, heavy). Restitution + friction
differ per pair; blocks are AABB bodies with rotation-free "tumble"
approximation (blocks fall, stack, shatter at hp ≤ 0 with material
damage thresholds).
- **Crush model:** a fast bird can crush blocks/enemies it plows through
(relative normal speed ≥ 24 px/s AND impact impulse above threshold);
slow contact never crushes — a resting bird is harmless.
- **Impulse model (fixed in dev):** collision response uses a single
normal-only impulse `j = -(1+e)*rel_v_n * m_b/(m_b+m_B)` — an earlier
draft applied a redundant per-body impulse that made pairs fly apart
unnaturally (see §7).
- **Birds (3 kinds, each with a mid-flight ability on tap/click):**
| bird | mass | r | ability |
|---|---|---|---|
| wren | 3.0 | 20 | fury — horizontal burst |
| zippy | 2.2 | 17 | dive — steep dive vector |
| boulder | 6.5 | 26 | quake — radial shock + damage falloff |
Birds are "spent" when they settle (jitter-tolerant 0.9 s rest window,
anywhere in the world — perched on a beam included), age out at 12 s,
or fly off-world. A 0.12 s per-enemy hit cooldown prevents one overlap
from dealing damage every substep.
- **Win/lose:** all enemies dead → `won` (unused birds pay a 5000-point
bonus); out of birds with enemies alive → `lost`. 3-star thresholds per
level.
### Renderer / audio / input
- **Renderer:** camera frames the slingshot AND the full action zone
while aiming (zooming out when the level needs it — bug #13 fix),
follows the bird 1:1 in flight, + shake on big impacts; enemies/birds
are drawn procedurally (vector shapes, squash & stretch, blink, damage
faces); particles from `state.events`.
- Audio: all SFX synthesized at event time (sine/triangle/noise envelopes);
mute toggle; no network fetches, no files.
- Input: mouse drag on the sling (with dotted trajectory preview computed
by the *same* integrator the live sim uses — preview is pixel-accurate),
tap-to-launch, tap-in-flight for abilities; full touch support
(pointer events, `touch-action: none`).
## 4. Game content
5 levels of ordered difficulty (world 2400 px wide, ground y=900):
| # | Level | birds | enemies | star gates |
|---|---|---|---|---|
| 1 | First Perch | wren wren zippy | 2 mogg | 12k/24k/36k |
| 2 | Crate Caves | wren zippy wren | 3 mogg | 15k/28k/42k |
| 3 | Glass Works | wren zippy boulder | 2 mogg + 1 grum + 1 mogg | 18k/34k/50k |
| 4 | Stone Keep | wren zippy boulder wren | 4 mogg + 1 grum | 22k/40k/58k |
| 5 | Mog Fortress | wren zippy boulder boulder | 5 mogg + 1 king | 28k/48k/70k |
Enemies: mogg (hp 1), grum (hp 2, armored), king (hp 3, large, armored).
Progress (unlocked levels, best stars) persists in `localStorage`.
## 5. Verification (the "Report Process" evidence)
Two headless suites, zero dependencies, no browser required:
- `node test/sim.test.mjs` — physics/rules harness. **15/15 PASS**:
1. all JS files parse (`node --check`-equivalent)
2. **DOM wiring: every id referenced by `js/*.js`
(`getElementById` + `querySelector('#id')`) exists in
`index.html`** — added post-shipment (bug #11); it is RED on the
shipped page and GREEN after the fix
3. level data integrity (5 levels, ordered difficulty, no overlap
nonsense)
4. **determinism:** two games with identical inputs run in lockstep to
identical states
5. **stability:** 15 s of physics after a full-power 45° shot stays
finite (no NaN/explosion)
6. **trajectory preview** tracks the real flight (empty air, < 40 px
error)
7. **abilities:** each slinger's ability is distinct and usable
mid-flight
8. **win/lose rules:** all-dead → `won` + unused-bird bonus; one wren
vs king → king survives, out of birds → `lost`
9–13. **level clearability (L1–L5):** each level is cleared by a
scripted "greedy perfect archer" — a test-internal AI that *only*
sees the same public API a human sees (the same `launch` + `step`
calls the page's input triggers). For each living enemy it (a)
picks the most exposed target (fewest blocks on the line of fire),
(b) evaluates candidates in two stages — a coarse 2° full-power
sweep, then ±3° at 0.5° steps across a pull ladder
[1, 0.85, 0.7, 0.55, 0.4] around the top-3 coarse angles — running
the real physics 5 s per candidate on a clone, (c) commits kill
shots first, else the shot that lands closest to the target.
Upgraded post-playtest (bug #12 follow-up): the AI now uses
partial-power pulls (players do) and refuses to spend a bird on a
shot that damages neither the target nor the board — the old
full-power 2°-grid AI left L1's mogg at 0.06 hp for a 4th bird
that does not exist.
Result: all 5 clear — L1 3/3 birds (1★), L2 2/3, L3 3/3, L4 4/4,
L5 3/4 (2★).
14. **fort rest (bug #12's arm):** every level sits still when
untouched — 15 s idle, max block/enemy speed < 20 px/s (sub-pixel
at 120 Hz) and < 0.5 px positional drift in the final second. RED
on the original solver (measured 34-112 px/s speeds and 21-108 px/s
drift on a level with *zero* input).
15. **aim framing (bug #13's arm):** on the first aiming frame of
every level the camera view must contain BOTH the slingshot and the
full action zone (all live blocks + enemies). RED on the old
camera (aim view showed the sling with the fort 400-900 px
off-screen).
- `node test/page.test.mjs` — **page-boot harness, 7/7 PASS.** Loads
`index.html` + all five `js/` modules *in browser order* against a
minimal DOM stub built from the real `id` attributes in
`index.html` (fake canvas 2D context, RAF queue, pointer events,
localStorage), then drives a full click session: PLAY → level tile →
aim & launch → 8 s of simulated play → pause/mute/keyboard. Any
null-element wiring, missing global, or boot-time throw fails here —
the class the physics suite is structurally blind to. Arms 16-17
drive the page's OWN pointer handlers (the exact calls a player
makes) against the real camera transform:
16. **direction (bug #15's arm):** grab the bird at the place it is
VISIBLE (inverse of the renderer transform), pull it back
(away from the fort), release — the launch velocity must point
TOWARD the fort (vx > 100). RED on 2d9c8f47.
17. **release (bug #14's arm):** mid-flight, record the canvas
`lineTo()` calls of one drawn frame and assert no band endpoint
sits at the flying bird's position (±12px of bird.x±6, bird.y).
RED on 2d9c8f47 (the banded tracked the projectile all flight).
## 6. Process log — what broke, what it taught
Iterative build order: data (levels) → deterministic core → renderer →
audio → UI → tests. Bugs found by the headless harness and fixed in order:
1. **Redundant impulse** — early collision code applied the impulse to
*both* bodies in a way that double-counted; fixed to a single
normal-only impulse on each body from the relative normal velocity.
2. **Slow birds crushing everything** — a bird resting against a block
still triggered crush every step; fixed with a rest-contact guard
(relative normal speed < 30 px/s is contact, crush requires ≥ 24 and a
real impulse).
3. **Enemy-identity leak in crush** — `crushCircleVsBox` used the bird's
kind as the identity key; enemy blocks misclassified. Fixed with an
explicit `isEnemy` flag.
4. **`g.time` never advanced** — abilities' cooldowns could never elapse
(cooldown measured 0 s forever). Added `state.time += dt` in `step()`.
5. **Empty-enemy instant win** — `won` fired at t=0 on levels with no
enemies; win now requires `hadEnemies && launched > 0`.
6. **Range tuning** — at g=2300 px/s² max flat range (≈1293 px) fell short
of the furthest structures (≈1900 px); g=1500 gives ≈1980 px max range
and snappier flight time. Gravity is a feel knob; tuned until the far
keeps were reachable.
7. **Preview/sim divergence** — the trajectory preview integrated at a
different rate than the live sim; fixed by making the preview run the
*exact same* 2×(1/120) substepping as the live integrator (dots now
match the real flight pixel-for-pixel).
8. **Multi-hit damage** — a bird overlapping an enemy for several
substeps dealt damage each step (one "hit" killed a 3-hp king). Fixed
with a 0.12 s per-enemy hit cooldown.
9. **Birds perched on beams never spent** — the original settle check
required the bird to be near the ground; a bird resting on a structure
stayed "in flight" forever and blocked the next bird. Fixed: low speed
*anywhere* = spent, with jitter-tolerant accumulation (timer decays 4×
faster than it fills when speed spikes).
10. **AI test-harness bugs (test-side, but instructive):** inverted kill
score (`!dead ? 1000` — the AI was rewarded for *missing* the target),
and a shot-budget gate that rejected all valid "approach" shots
(negative by construction). Both fixed; the harness then cleared all 5
levels unassisted.
11. **Page boot crash (found post-shipment, in a real browser).**
`main.js:256-257` wired `btn-replay` / `btn-menu` — ids left over from
a rename that never landed in `index.html` (the shipped buttons are
the role-specific pairs `btn-win-*` / `btn-lose-*` / `btn-pause-*`,
which were already wired). `getElementById` → null → `.addEventListener`
threw inside the setup IIFE *before* `show('start')` and
`requestAnimationFrame(frame)` ran: the canvas painted its sky once and
the page was a dead blue rectangle. The fix was deletion of the two
dead lines (every screen already had its full button set — no markup
added). **Lesson recorded:** the 12/12 physics suite was green on this
build because it imports only `core.js`/`levels.js` and cannot see the
page — a suite that cannot go red for the defect you shipped is not
evidence about that defect. Response: added the DOM-wiring arm (test
1b — RED before the fix, listing exactly `main.js->btn-menu,
main.js->btn-replay`; GREEN after) and the page-boot harness
(`test/page.test.mjs`), which executes all five modules in browser
order against a DOM stub built from the real `index.html` ids and
plays a full click session headlessly.
12. **Inverted box-pair separation — forts self-destructed (found
post-shipment, playtest feedback: "objects glitching like crazy,
bouncing around on their own", "unbreakable objects", "can't kill a
pig").** `resolveBoxPair` pushed *resting* blocks **toward** each
other: with `s = a.x < c.x ? -1 : 1`, `a.x -= s*ox*0.5; c.x +=
s*ox*0.5` moves both bodies *into* the overlap in both sign cases
(and the y-branch is identically inverted). Every structure in every
level was pulled together from t=0: posts crossed through each
other, beams dropped through the gap, the minimum-penetration axis
flipped mid-collapse, and the pair solver flung blocks at 34-112
px/s forever (probe, zero input: L1 drift 21.6 px/s, L3 108.7 px/s;
the beams visibly oscillated 100+ px — the "zigzag streaks" in the
screenshot are blocks drawn mid-flail). The physics suite was green
because it asserted *finiteness* and *kill outcomes* — the AI's
shots won against the chaos (collapsing blocks crushing the mogg),
so the clearability arms passed while the level was a demolition
derby. Fix: correct separation direction (push apart along the
axis), inelastic velocity handling, and a resting clamp (top takes
bottom's velocity; |v| < 30 px/s on a resting contact is zeroed) so
stacks settle to *exactly* still instead of oscillating under
gravity re-injection. The crush path also gained the same slow-rest
treatment for perched enemies. After the fix: 15 s idle → max speed
0.0 px/s, drift 0.005 px, on every level. **The (a)/(b)/(c)
reconciliation from the sent-back report:** (a) is false — the page
harness proves the browser runs the *same* `CORE.step`/`CORE.launch`
the AI drives (the page's own frame loop was replayed headlessly:
identical camera + physics behaviour); (c) is false — same 1/120
fixed steps on both sides; (b) is the truth — the AI won by a route
no human can reproduce, because the route *was* the collapse:
reproducible per-shot 5 s replays of a world falling apart, vs a
player aiming (with the sling off-screen, see #13) at targets
flailing at 30-100 px/s. The suite wasn't testing a different path
from the player's — the shared path was broken, and the suite had no
arm asserting the world's *health* (rest), only its finiteness.
Arms added: fort rest (test 14) — RED on the original solver
("L1: structure still moving at idle (max 76.5 px/s)"), GREEN
after.
13. **Camera never showed sling and target together (playtest: "cannot
see the entire playing field and have to guess where to sling the
birds", sling "not visible anywhere").** Two framings, neither
sufficient: aiming → camera at (480, 740), a 1280 px view showing
the sling at x=260 but the action zone ending at x=1794-2090 —
400-900 px off-screen right; flight → 1:1 follow of the bird, sling
off-screen left (the exact geometry of the screenshot: fort center,
sling nowhere). A 1280 px 1:1 view can never span sling (260) to
action-right (1794+), so the fix is a *zoom*, not a pan: the core
now owns `camera.zoom` — while aiming/waiting it frames
[sling-190, action-right+90] at `zoom = min(1, 1280/span)` (L1:
0.70, L5: 0.61) with the ground at 78% down; in flight it returns to
1:1 bird-follow; the renderer applies the zoom to the world
transform (shake stays in screen space). First frame of a level
snaps to the target (no level-start pan). Arm added: aim framing
(test 15) — RED on the old camera, GREEN after.
(Also fixed in this pass: `createGame` spawned enemies at
`e.y - r` while the level data and the integrity test treat `e.y` as
the center — every mogg dropped 24-38 px on load, one more "objects
moving on their own" moment.)
14. **Sling never lets go (playtest round 2: "sling never actually lets
go of bird when the bird is shot out").** Purely visual:
`drawSling` drew the taut rubber bands toward the active bird
whenever the bird existed — including while it was *flying* — so the
bands tracked the projectile the entire flight and the sling looked
glued to it. Fix: the taut band is drawn only when
`bird.state === 'aiming'` (pouch or mid-drag); in flight the bands
hang relaxed (the pre-existing else-branch). Arm 17 (page harness,
`lineTo` recording) is RED on 2d9c8f47, GREEN after.
15. **Birds only launched backwards (playtest round 2: "Sling shot
only shoots backwards and not in the direction of the target").**
Root cause in `main.js toWorld()`: the screen→world inverse was
`(sx - VW/2) + cam.x` — it added the camera center but never divided
by the camera zoom that the renderer applies. On the new aim-framing
camera (zoom ≈ 0.70 on L1, center x≈977) the whole visible canvas
mapped to world x ∈ [337, 1617] — *entirely to the right of the
sling at x=260*. A pull-back drag is aimed as `sling − drag`, so
every visible drag produced a leftward launch vector: the bird flew
back, off the left of the island, behind the sling. Measured on the
page by the relay: bird landed at x=230 (sling at 270, fort at
1500+). The fix is the one missing operator:
`(sx - VW/2) / cam.zoom + cam.x` (and y). The bird now sits exactly
under the cursor, the trajectory preview (same mapping) shows the
real shot, and pull-back launches toward the fort. A `window.ISLE_PAGE`
state handle (documented test hook, unused by the page) lets the
harness assert real launch state. Arm 16 is RED on 2d9c8f47, GREEN
after (measured launch vx = +1381 px/s toward the fort on the
standard gesture).
(Test-harness note: the page harness's `pumpFrames` was passed an
undefined rAF timestamp by the new arms, which NaN'd the fixed-
timestep accumulator and silently froze the sim — fixed by
carrying the last pumped timestamp forward. A harness bug that
briefly mimicked a physics freeze; the sim suite's 15/15 confirms
the core was unaffected.)
Process notes:
- The headless harness caught **11 bugs pre- and post-shipment on its
own** (10 in dev + the page boot); playtest then found
**4 more** (#12-15) that the harness *could* have caught — the missing
invariants were "the world is at rest", "the view shows the play",
"the input maps where the player sees", and "the sling lets go".
Building the core to be Node-testable first was the single
highest-value decision of the build; the standing lesson is that a
game's test suite must assert the *health* of the state (rest,
framing, input mapping, release), not just finiteness and outcomes —
a suite that only checks "no NaN and someone eventually died" is
green in a demolition derby.
- One terminal guardrail event: a repeated identical test command was
blocked once by the tool-loop guard; resolved by re-running through a
different entrypoint (`node ./test/sim.test.mjs`).
- npm was absent at build time; it was later added via a corepack shim
(`corepack prepare npm@10.8.2 --activate` + a two-line
`/usr/local/bin/npm` wrapper), so `npm test` now runs both suites in
this container.
## 7. How to run
```
# play (any static server, or just open the file):
cd isle-slingers # repo root, after cloning
python3 -m http.server 8000 # then open http://localhost:8000/
# or open index.html directly in a browser
# verify (both arms):
node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability
node test/page.test.mjs # 7/7 — page boot, click session, direction, release
node test/balance.test.mjs# 5/5 — balance spec (material break + enemy kill budgets)
# or: npm test (runs all, in order)
```
## 8. Known limitations (honest list)
- No mobile device tested in-browser (touch input implemented per spec,
verified by code review only).
- Physics is an arcade approximation (AABB blocks, tumble without true
rotation) — intentionally: deterministic, stable, fast, and matches the
genre's feel.
- No save-file export/import; progress lives in `localStorage`.
- Difficulty: the greedy-AI lower bound suggests early levels are easy and
L4/L5 are the challenge peak (L5 needs both boulders used well).
## 9. IP compliance statement
No assets, code, names, art, audio, or branding derived from, copied from,
or resembling any third party's copyrighted slingshot game were used. All
art is procedural Canvas drawing written for this project; all audio is
synthesized at runtime; level layouts, physics constants, and enemy
designs are original. The game is named **Isle Slingers**.
## 10. Commit
See the repository commit SHA for this build.
Working tree at commit time contains exactly the files listed in §3.
## 11. Addendum — modularization pass (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.

18
LICENSE
View file

@ -2,20 +2,8 @@ MIT License
Copyright (c) 2026 MrTrenchTrucker Copyright (c) 2026 MrTrenchTrucker
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,58 +0,0 @@
# Isle Slingers — MODULES.md (master module list)
the modularization pass. 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, the physics + modularization update — 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.

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Isle-Slingers
Angry Birds Clone using a modular system.

View file

@ -1,154 +0,0 @@
# Isle Slingers — Architecture
Original architecture for a fully offline, canvas-rendered slingshot game.
No frameworks, no build step, no external assets. As of the physics + modularization update (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 (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 physics-pass 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` (47/47) and re-check the golden differential
against the pre-modularization core when touching anything that predates
this modularization/physics pass.
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

@ -1,97 +0,0 @@
/* ISLE SLINGERS — UI styles. Original design, no external assets. */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #0c1a26; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; }
#stage { position: relative; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
#game { display: block; background: #7ec8f0; cursor: crosshair; touch-action: none; border-radius: 6px; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
/* ------------------------------------------------------------- HUD */
#hud {
position: absolute; top: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: calc(100vw);
display: none; justify-content: space-between; align-items: center;
padding: 10px 16px; pointer-events: none; z-index: 5;
}
#hud .hud-left, #hud .hud-right { display: flex; align-items: center; gap: 14px; pointer-events: auto; }
#hud-level {
color: #fff; font-weight: 700; font-size: 18px; letter-spacing: 0.5px;
background: rgba(12, 26, 38, 0.55); padding: 8px 14px; border-radius: 999px;
backdrop-filter: blur(3px);
}
#hud-score {
color: #ffd23e; font-weight: 800; font-size: 26px; font-variant-numeric: tabular-nums;
text-shadow: 0 2px 6px rgba(0,0,0,0.5); min-width: 90px; text-align: right;
}
#hud-birds { display: inline-flex; gap: 6px; }
.hud-bird {
width: 22px; height: 22px; border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #ff8a70, #e2452e 60%);
border: 2px solid rgba(255,255,255,0.7); box-shadow: 0 2px 5px rgba(0,0,0,0.4);
}
.hud-btn {
width: 42px; height: 42px; border-radius: 12px; border: none; cursor: pointer;
background: rgba(12, 26, 38, 0.55); color: #fff; font-size: 17px; font-weight: 700;
backdrop-filter: blur(3px); transition: background 0.15s;
}
.hud-btn:hover { background: rgba(12, 26, 38, 0.8); }
/* ----------------------------------------------------------- screens */
.screen {
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
z-index: 10;
}
.screen.dim { background: rgba(6, 14, 22, 0.45); }
.panel {
background: linear-gradient(160deg, rgba(255,255,255,0.97), rgba(240,247,255,0.94));
border-radius: 22px; padding: 34px 42px; text-align: center;
box-shadow: 0 24px 70px rgba(0,0,0,0.45); max-width: min(92vw, 560px);
}
.panel.wide { max-width: min(92vw, 720px); }
.title {
font-size: clamp(44px, 9vw, 76px); line-height: 0.95; font-weight: 900;
color: #2b6cb8; letter-spacing: 2px;
text-shadow: 3px 3px 0 #ffd23e, 6px 6px 0 rgba(0,0,0,0.08);
margin-bottom: 14px;
}
h2 { font-size: 30px; color: #17324d; margin-bottom: 18px; }
.tagline { color: #4a6076; font-size: 15px; margin-bottom: 20px; }
.hint { color: #7b8b9c; font-size: 12.5px; margin-top: 18px; max-width: 380px; margin-left: auto; margin-right: auto; }
.credit { color: #9fb0c0; font-size: 11.5px; margin-top: 10px; }
.score-line { font-size: 26px; font-weight: 800; color: #17324d; margin: 8px 0 18px; font-variant-numeric: tabular-nums; }
.btn {
border: none; cursor: pointer; border-radius: 14px; padding: 13px 26px;
font-size: 16px; font-weight: 800; letter-spacing: 0.6px; color: #fff;
background: linear-gradient(180deg, #4f9be0, #2b6cb8);
box-shadow: 0 5px 0 #1d4f8a, 0 8px 18px rgba(0,0,0,0.18);
transition: transform 0.06s, box-shadow 0.06s;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(3px); box-shadow: 0 2px 0 #1d4f8a; }
.btn.big { font-size: 20px; padding: 16px 44px; background: linear-gradient(180deg, #67c25b, #3d9b3a); box-shadow: 0 5px 0 #2c6e2a, 0 8px 18px rgba(0,0,0,0.18); }
.btn.big:active { box-shadow: 0 2px 0 #2c6e2a; }
.btnrow { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 6px; }
/* -------------------------------------------------------- level grid */
#level-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 14px; margin: 10px 0 22px; }
.level-tile {
border: none; cursor: pointer; border-radius: 16px; padding: 16px 10px;
background: linear-gradient(160deg, #eaf4ff, #d5e8fb);
box-shadow: 0 4px 0 #b3cde6, 0 6px 14px rgba(0,0,0,0.1);
transition: transform 0.08s; font-family: inherit;
}
.level-tile:hover { transform: translateY(-2px); }
.level-tile.locked { background: #cfd8e0; box-shadow: 0 4px 0 #aeb9c4; cursor: not-allowed; opacity: 0.75; }
.lt-num { font-size: 30px; font-weight: 900; color: #2b6cb8; }
.level-tile.locked .lt-num { color: #8a97a5; }
.lt-name { font-size: 13px; font-weight: 700; color: #41586e; margin: 4px 0 8px; min-height: 32px; }
.lt-stars { font-size: 20px; letter-spacing: 2px; }
.star { color: #c8d2dc; }
.star.on { color: #ffb400; text-shadow: 0 1px 3px rgba(255,140,0,0.5); }
@media (max-width: 640px) {
.panel { padding: 24px 22px; }
#hud { padding: 8px 10px; }
#hud-level { font-size: 14px; padding: 6px 10px; }
#hud-score { font-size: 20px; }
}

View file

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Isle Slingers</title>
<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='42' fill='%23e2452e'/><circle cx='62' cy='40' r='10' fill='white'/><circle cx='65' cy='42' r='4' fill='black'/><path d='M80 52 L98 58 L79 64 Z' fill='%23f5a623'/></svg>">
</head>
<body>
<div id="stage">
<canvas id="game"></canvas>
<div id="hud">
<div class="hud-left">
<span id="hud-level">Level 1</span>
<span id="hud-birds"></span>
</div>
<div class="hud-right">
<span id="hud-score">0</span>
<button id="btn-pause" class="hud-btn" title="Pause (P)">II</button>
<button id="btn-mute" class="hud-btn" title="Mute">&#128266;</button>
</div>
</div>
<div id="screen-start" class="screen">
<div class="panel">
<h1 class="title">ISLE<br>SLINGERS</h1>
<p class="tagline">Slingshot showdown on the tropical isle. Pick a slinger, break a fort, pop the mogs.</p>
<button id="btn-play" class="btn big">PLAY</button>
<p class="hint">Drag from the sling to aim · release to launch · tap mid-flight to use each slinger's move</p>
<p class="credit">Original characters &amp; art · runs fully offline</p>
</div>
</div>
<div id="screen-select" class="screen">
<div class="panel wide">
<h2>Choose a perch</h2>
<div id="level-grid"></div>
<button id="btn-back-start" class="btn">Back</button>
</div>
</div>
<div id="screen-pause" class="screen dim">
<div class="panel">
<h2>Paused</h2>
<div class="btnrow">
<button id="btn-pause-resume" class="btn">Resume</button>
<button id="btn-pause-restart" class="btn">Restart</button>
<button id="btn-pause-menu" class="btn">Menu</button>
</div>
</div>
</div>
<div id="screen-win" class="screen dim">
<div class="panel">
<h2>Perch cleared!</h2>
<div id="win-stars"></div>
<p id="win-score" class="score-line"></p>
<div class="btnrow">
<button id="btn-next" class="btn big">Next</button>
<button id="btn-win-retry" class="btn">Replay</button>
<button id="btn-win-menu" class="btn">Menu</button>
</div>
</div>
</div>
<div id="screen-lose" class="screen dim">
<div class="panel">
<h2>The mogs hold on…</h2>
<p class="tagline">Out of slingers. Take aim again.</p>
<div class="btnrow">
<button id="btn-lose-retry" class="btn big">Retry</button>
<button id="btn-lose-menu" class="btn">Menu</button>
</div>
</div>
</div>
</div>
<!-- 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>

View file

@ -1,17 +0,0 @@
# js/app/ — agents.md
Part of the modularization pass. 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.

View file

@ -1,339 +0,0 @@
/* ISLE SLINGERS — main.js
* Glue: fixed-timestep loop, mouse+touch input, screens, HUD, progress.
* Expects globals from core.js / levels.js / renderer.js / audio.js.
*/
(function () {
'use strict';
var CORE = window.ISLE_CORE;
var LEVELS = window.ISLE_LEVELS.LEVELS;
var SAVE_KEY = 'isleSlingers.progress.v1';
var canvas = document.getElementById('game');
var renderer = new window.ISLE_RENDERER.Renderer(canvas, { onEvent: function (e) { audio.play(e); } });
var audio = new window.ISLE_AUDIO.AudioEngine();
var state = null; // core game state
var levelIndex = 0;
var drag = null; // current pointer drag {x,y}
var aiming = false;
var paused = false;
var screen = 'start'; // start | play | levelselect | win | lose | pause
var fixedDt = 1 / 120;
var acc = 0;
var lastT = null;
var winShownAt = 0;
var loseShownAt = 0;
// ------------------------------------------------------------- progress
function loadProgress() {
try {
var raw = localStorage.getItem(SAVE_KEY);
if (raw) {
var p = JSON.parse(raw);
if (p && p.stars) return p;
}
} catch (e) { /* ignore */ }
return { stars: {}, unlocked: 1 };
}
var progress = loadProgress();
function saveProgress() {
try { localStorage.setItem(SAVE_KEY, JSON.stringify(progress)); } catch (e) { /* ignore */ }
}
// --------------------------------------------------------------- DOM
var el = {
start: document.getElementById('screen-start'),
select: document.getElementById('screen-select'),
win: document.getElementById('screen-win'),
lose: document.getElementById('screen-lose'),
pause: document.getElementById('screen-pause'),
hud: document.getElementById('hud'),
score: document.getElementById('hud-score'),
levelName: document.getElementById('hud-level'),
birdsLeft: document.getElementById('hud-birds'),
starsWin: document.getElementById('win-stars'),
scoreWin: document.getElementById('win-score'),
nextBtn: document.getElementById('btn-next'),
grid: document.getElementById('level-grid'),
muteBtn: document.getElementById('btn-mute'),
pauseBtn: document.getElementById('btn-pause')
};
function show(name) {
screen = name;
el.start.style.display = name === 'start' ? 'flex' : 'none';
el.select.style.display = name === 'select' ? 'flex' : 'none';
el.win.style.display = name === 'win' ? 'flex' : 'none';
el.lose.style.display = name === 'lose' ? 'flex' : 'none';
el.pause.style.display = name === 'pause' ? 'flex' : 'none';
el.hud.style.display = (name === 'play' || name === 'pause') ? 'flex' : 'none';
}
function renderSelect() {
el.grid.innerHTML = '';
for (var i = 0; i < LEVELS.length; i++) {
(function (i) {
var lv = LEVELS[i];
var locked = i + 1 > progress.unlocked;
var tile = document.createElement('button');
tile.className = 'level-tile' + (locked ? ' locked' : '');
tile.disabled = locked;
tile.innerHTML =
'<div class="lt-num">' + lv.id + '</div>' +
'<div class="lt-name">' + lv.name + '</div>' +
'<div class="lt-stars">' + starRow(progress.stars[lv.id] || 0) + '</div>';
if (!locked) tile.addEventListener('click', function () {
audio.init(); audio.play({ type: 'ui' });
startLevel(i);
});
el.grid.appendChild(tile);
})(i);
}
}
function starRow(n) {
var s = '';
for (var i = 0; i < 3; i++) s += '<span class="star' + (i < n ? ' on' : '') + '">★</span>';
return s;
}
// ------------------------------------------------------------- levels
function startLevel(i) {
levelIndex = i;
state = CORE.createGame(LEVELS[i]);
drag = null;
aiming = false;
paused = false;
acc = 0;
lastT = null;
winShownAt = 0;
loseShownAt = 0;
renderer.ring = null;
renderer.particles.length = 0;
updateHud();
show('play');
}
function restartLevel() {
audio.play({ type: 'ui' });
startLevel(levelIndex);
}
function updateHud() {
if (!state) return;
el.score.textContent = String(state.score);
el.levelName.textContent = 'Level ' + state.level.id + ' · ' + state.level.name;
var left = CORE.birdsRemaining(state);
var flying = false;
for (var i = 0; i < state.birds.length; i++) if (state.birds[i].state === 'flying') flying = true;
var s = '';
for (var k = 0; k < left + (flying ? 1 : 0); k++) s += '<span class="hud-bird"></span>';
el.birdsLeft.innerHTML = s;
}
function onWon() {
var stars = CORE.starsFor(state);
if (stars > (progress.stars[state.level.id] || 0)) progress.stars[state.level.id] = stars;
if (state.level.id >= progress.unlocked && state.level.id < LEVELS.length) {
progress.unlocked = state.level.id + 1;
}
saveProgress();
el.starsWin.innerHTML = starRow(stars);
el.scoreWin.textContent = state.score.toLocaleString();
el.nextBtn.style.display = state.level.id < LEVELS.length ? 'inline-block' : 'none';
audio.play({ type: 'level_won' });
state.events.push({ type: 'confetti_burst', x: 640, y: 300 });
setTimeout(function () { if (screen === 'play') show('win'); }, 900);
}
function onLost() {
audio.play({ type: 'level_lost' });
setTimeout(function () { if (screen === 'play') show('lose'); }, 1100);
}
// ------------------------------------------------------------- input
function toWorld(ev) {
var r = canvas.getBoundingClientRect();
var VW = window.ISLE_RENDERER.VIEW_W, VH = window.ISLE_RENDERER.VIEW_H;
var sx = (ev.clientX - r.left) / r.width * VW;
var sy = (ev.clientY - r.top) / r.height * VH;
var cam = state.camera;
// inverse of the renderer's world transform: screen offset from center
// must be divided by the camera zoom, then the camera center added.
// (Before the camera got a zoom the division was a no-op — adding zoom
// without dividing made every visible screen point map to the WRONG
// side of the sling, so the bird only ever launched backwards, bug #15.)
var z = (cam && cam.zoom) || 1;
return { x: (sx - VW / 2) / z + cam.x, y: (sy - VH / 2) / z + cam.y };
}
function pointerDown(ev) {
audio.init(); audio.resume();
if (screen !== 'play' || paused) return;
var b = CORE.activeBird(state);
if (!b || b.state !== 'aiming') {
// tap during flight = ability
if (b && b.state === 'flying' && !b.abilityUsed) {
if (CORE.triggerAbility(state)) audio.play({ type: b.kind === 'wren' ? 'ability_fury' : b.kind === 'zippy' ? 'ability_dive' : 'ability_quake' });
}
return;
}
var w = toWorld(ev);
// allow grabbing anywhere near the sling zone or on the bird itself
var sling = state.level.sling;
var dSling = Math.hypot(w.x - sling.x, w.y - sling.y);
if (dSling < 220) {
aiming = true;
drag = w;
ev.preventDefault();
}
}
function pointerMove(ev) {
if (!aiming || !drag) return;
var w = toWorld(ev);
var sling = state.level.sling;
// clamp the pull to MAX_PULL
var dx = w.x - sling.x, dy = w.y - sling.y;
var len = Math.hypot(dx, dy);
if (len > CORE.MAX_PULL) { dx = dx / len * CORE.MAX_PULL; dy = dy / len * CORE.MAX_PULL; }
drag = { x: sling.x + dx, y: sling.y + dy };
ev.preventDefault();
}
function pointerUp() {
if (!aiming || !drag) return;
aiming = false;
var b = CORE.activeBird(state);
if (b && b.state === 'aiming' && CORE.launch(state, drag)) {
audio.play({ type: 'launch' });
}
drag = null;
updateHud();
}
canvas.addEventListener('mousedown', pointerDown);
window.addEventListener('mousemove', pointerMove);
window.addEventListener('mouseup', pointerUp);
canvas.addEventListener('touchstart', function (ev) { pointerDown(ev.touches[0]); ev.preventDefault(); }, { passive: false });
canvas.addEventListener('touchmove', function (ev) { pointerMove(ev.touches[0]); ev.preventDefault(); }, { passive: false });
canvas.addEventListener('touchend', function (ev) { pointerUp(); ev.preventDefault(); }, { passive: false });
window.addEventListener('keydown', function (ev) {
if (ev.repeat) return;
var k = ev.key.toLowerCase();
if (screen === 'play' || screen === 'pause') {
if (k === 'p') togglePause();
if (k === 'r') restartLevel();
if (k === 'm') toMenu();
} else if (screen === 'select') {
if (k === 'escape') show('start');
for (var i = 0; i < 5; i++) if (k === String(i + 1) && i + 1 <= progress.unlocked) startLevel(i);
} else if (screen === 'start') {
if (k === 'enter' || k === ' ') { ev.preventDefault(); toSelect(); }
} else if (screen === 'win') {
if (k === 'enter') (state.level.id < LEVELS.length ? startLevel(levelIndex + 1) : show('select'));
} else if (screen === 'lose') {
if (k === 'enter' || k === 'r') restartLevel();
}
});
function togglePause() {
if (screen === 'play') { paused = true; show('pause'); }
else if (screen === 'pause') { paused = false; show('play'); lastT = null; }
}
function toMenu() {
paused = false;
renderSelect();
show('select');
}
// -------------------------------------------------------------- buttons
document.getElementById('btn-play').addEventListener('click', function () { audio.init(); audio.play({ type: 'ui' }); toSelect(); });
function toSelect() { renderSelect(); show('select'); }
el.select.querySelector('#btn-back-start').addEventListener('click', function () { audio.play({ type: 'ui' }); show('start'); });
// NOTE: no generic btn-replay/btn-menu — every screen ships its own
// role-specific pair (btn-win-retry/menu, btn-lose-retry/menu,
// btn-pause-restart/menu); the old generic pair ids never made it into
// index.html and their wiring crashed the page boot (see test 1b).
el.nextBtn.addEventListener('click', function () { if (state.level.id < LEVELS.length) startLevel(levelIndex + 1); });
document.getElementById('btn-win-retry').addEventListener('click', restartLevel);
document.getElementById('btn-win-menu').addEventListener('click', toMenu);
document.getElementById('btn-lose-retry').addEventListener('click', restartLevel);
document.getElementById('btn-lose-menu').addEventListener('click', toMenu);
document.getElementById('btn-pause-resume').addEventListener('click', togglePause);
document.getElementById('btn-pause-restart').addEventListener('click', restartLevel);
document.getElementById('btn-pause-menu').addEventListener('click', toMenu);
el.pauseBtn.addEventListener('click', function (ev) { ev.stopPropagation(); togglePause(); });
el.muteBtn.addEventListener('click', function (ev) {
ev.stopPropagation();
audio.init();
audio.setMuted(!audio.muted);
el.muteBtn.textContent = audio.muted ? '🔇' : '🔊';
});
// test/debug handle: the headless page harness (test/page.test.mjs)
// reads the live state through this to assert what the player actually
// sees (launch direction, bird spend, band drawing). Never used by the
// page itself.
window.ISLE_PAGE = { getState: function () { return state; } };
// --------------------------------------------------------------- loop
function frame(t) {
requestAnimationFrame(frame);
if (lastT === null) lastT = t;
var dt = Math.min(0.05, (t - lastT) / 1000);
lastT = t;
if (screen === 'play' && !paused && state) {
acc += dt;
var steps = 0;
while (acc >= fixedDt && steps < 12) {
CORE.step(state, fixedDt);
acc -= fixedDt;
steps++;
}
updateHud();
if (state.status === 'won' && !winShownAt) { winShownAt = state.time; onWon(); }
if (state.status === 'lost' && !loseShownAt) { loseShownAt = state.time; onLost(); }
renderer.handleEvents(state.events.splice(0));
} else if (state) {
// keep the world gently settling behind menus (win/lose screens)
if (screen === 'win' || screen === 'lose') {
CORE.step(state, fixedDt);
renderer.handleEvents(state.events.splice(0));
}
}
var view = {
drag: drag,
activeBird: CORE.activeBird(state),
traj: null
};
if (aiming && drag && state) {
var ab = CORE.activeBird(state);
if (ab && ab.state === 'aiming') {
var v = CORE.aimVector(state, state.level.sling, drag);
if (v) view.traj = CORE.trajectory(state, ab.x, ab.y, v.vx, v.vy, 46);
}
}
if (state) renderer.draw(state, view);
}
// --------------------------------------------------------------- boot
(function boot() {
// title screen with an idle demo state behind it
state = CORE.createGame(LEVELS[0]);
show('start');
requestAnimationFrame(frame);
})();
})();

View file

@ -1,14 +0,0 @@
# js/audio/ — agents.md
Part of the modularization pass. 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,137 +0,0 @@
/* ISLE SLINGERS — audio.js
* All sound is synthesized with WebAudio oscillators/noise buffers.
* No external audio assets, fully original.
*/
(function (root, factory) {
var api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.ISLE_AUDIO = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function AudioEngine() {
this.ctx = null;
this.master = null;
this.muted = false;
}
AudioEngine.prototype.init = function () {
if (this.ctx || typeof window === 'undefined') return;
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
};
AudioEngine.prototype.resume = function () {
if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
};
AudioEngine.prototype.setMuted = function (m) {
this.muted = m;
if (this.master) this.master.gain.value = m ? 0 : 0.5;
};
AudioEngine.prototype.noise = function (dur, filterFreq, gainVal) {
if (!this.ctx || this.muted) return;
var sr = this.ctx.sampleRate;
var buf = this.ctx.createBuffer(1, Math.max(1, (sr * dur) | 0), sr);
var data = buf.getChannelData(0);
for (var i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
var src = this.ctx.createBufferSource();
src.buffer = buf;
var filt = this.ctx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = filterFreq;
var g = this.ctx.createGain();
var t0 = this.ctx.currentTime;
g.gain.setValueAtTime(gainVal, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
src.connect(filt); filt.connect(g); g.connect(this.master);
src.start(t0);
};
AudioEngine.prototype.tone = function (freq, dur, type, gainVal, slideTo) {
if (!this.ctx || this.muted) return;
var t0 = this.ctx.currentTime;
var o = this.ctx.createOscillator();
o.type = type || 'sine';
o.frequency.setValueAtTime(freq, t0);
if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), t0 + dur);
var g = this.ctx.createGain();
g.gain.setValueAtTime(gainVal || 0.2, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
o.connect(g); g.connect(this.master);
o.start(t0);
o.stop(t0 + dur + 0.02);
};
AudioEngine.prototype.play = function (ev) {
if (!this.ctx || this.muted) return;
var s = ev.strength || 0;
switch (ev.type) {
case 'launch':
this.noise(0.25, 900, 0.25);
this.tone(300, 0.22, 'triangle', 0.15, 90);
break;
case 'hit':
case 'block_impact':
this.noise(0.16, ev.mat === 'ice' ? 3200 : 1200, 0.3);
this.tone(ev.mat === 'stone' ? 70 : 110, 0.12, 'sine', 0.3, 45);
break;
case 'break':
if (ev.mat === 'wood') { this.noise(0.3, 1600, 0.4); this.tone(140, 0.18, 'triangle', 0.3, 60); }
else if (ev.mat === 'ice') { this.noise(0.35, 5000, 0.45); this.tone(2200, 0.15, 'sine', 0.15, 3200); }
else { this.noise(0.4, 500, 0.5); this.tone(60, 0.3, 'sine', 0.45, 38); }
break;
case 'crack':
this.noise(0.1, 2000, 0.2);
break;
case 'pop':
this.tone(520, 0.16, 'sine', 0.35, 160);
this.noise(0.12, 2500, 0.2);
break;
case 'crush':
this.noise(0.2, 700, 0.3);
break;
case 'bird_ground':
this.noise(0.2, 800, 0.25);
break;
case 'ability_fury':
this.noise(0.3, 2600, 0.35);
this.tone(700, 0.25, 'sawtooth', 0.12, 1600);
break;
case 'ability_dive':
this.tone(900, 0.4, 'sawtooth', 0.14, 220);
break;
case 'ability_quake':
this.tone(60, 0.55, 'sine', 0.6, 30);
this.noise(0.5, 400, 0.5);
break;
case 'bird_gone':
this.tone(360, 0.2, 'sine', 0.12, 200);
break;
case 'level_won': {
var self = this;
[523, 659, 784, 1047].forEach(function (f, i) {
setTimeout(function () { self.tone(f, 0.3, 'triangle', 0.3); }, i * 140);
});
break;
}
case 'level_lost':
this.tone(300, 0.5, 'sine', 0.3, 120);
this.tone(200, 0.7, 'sine', 0.25, 80);
break;
case 'star':
this.tone(880, 0.25, 'triangle', 0.3, 1320);
break;
case 'ui':
this.tone(600, 0.06, 'sine', 0.12);
break;
}
};
return { AudioEngine: AudioEngine };
});

View file

@ -1,14 +0,0 @@
# js/levels/ — agents.md
Part of the modularization pass. 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.

View file

@ -1,10 +0,0 @@
# js/levels/data/ — agents.md
Part of the modularization pass. 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).

View file

@ -1,31 +0,0 @@
/* ISLE SLINGERS -- levels/data/level-1.js (submodule: level-1)
* Modularization pass: 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)
]
}
};
});

View file

@ -1,37 +0,0 @@
/* ISLE SLINGERS -- levels/data/level-2.js (submodule: level-2)
* Modularization pass: 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)
]
}
};
});

View file

@ -1,40 +0,0 @@
/* ISLE SLINGERS -- levels/data/level-3.js (submodule: level-3)
* Modularization pass: 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)
]
}
};
});

View file

@ -1,43 +0,0 @@
/* ISLE SLINGERS -- levels/data/level-4.js (submodule: level-4)
* Modularization pass: 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)
]
}
};
});

View file

@ -1,46 +0,0 @@
/* ISLE SLINGERS -- levels/data/level-5.js (submodule: level-5)
* Modularization pass: 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

@ -1,10 +0,0 @@
# js/levels/helpers/ — agents.md
Part of the modularization pass. 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

@ -1,21 +0,0 @@
/* ISLE SLINGERS -- levels/helpers/index.js (submodule: helpers)
* Modularization pass: 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 };
};
});

View file

@ -1,44 +0,0 @@
/* ISLE SLINGERS -- levels/index.js (module: levels -- aggregator / public API)
* Modularization pass. 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

@ -1,12 +0,0 @@
# js/physics/abilities/ — agents.md
Part of the modularization pass. 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

@ -1,65 +0,0 @@
/* ISLE SLINGERS -- physics/abilities/index.js (submodule: abilities)
* Modularization pass (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;
}
};
});

View file

@ -1,23 +0,0 @@
# js/physics/ — agents.md
Part of the modularization pass. 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. The physics pass
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).

View file

@ -1,14 +0,0 @@
# js/physics/aim/ — agents.md
Part of the modularization pass. 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.

View file

@ -1,45 +0,0 @@
/* ISLE SLINGERS -- physics/aim/index.js (submodule: aim)
* Modularization pass (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

@ -1,9 +0,0 @@
# js/physics/camera/ — agents.md
Part of the modularization pass. 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

@ -1,70 +0,0 @@
/* ISLE SLINGERS -- physics/camera/index.js (submodule: camera)
* Modularization pass (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

@ -1,18 +0,0 @@
# js/physics/collision/ — agents.md
Part of the modularization pass. 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.
- Added 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

@ -1,212 +0,0 @@
/* ISLE SLINGERS -- physics/collision/index.js (submodule: collision)
* Modularization pass (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.
// Added in the physics pass: 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 (playtest finding): 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.
// Added in the physics pass: 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

@ -1,15 +0,0 @@
# js/physics/constants/ — agents.md
Part of the modularization pass. 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

@ -1,54 +0,0 @@
/* ISLE SLINGERS -- physics/constants/index.js (submodule: constants)
* Modularization pass (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 (playtest finding: "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

@ -1,14 +0,0 @@
# js/physics/damage/ — agents.md
Part of the modularization pass. 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. Falling-object
impact damage) routes through these two functions so it inherits the spec.
- Consumes: MATERIALS, ENEMY_TYPES.

View file

@ -1,45 +0,0 @@
/* ISLE SLINGERS -- physics/damage/index.js (submodule: damage)
* Modularization pass (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);
}
}
};
});

View file

@ -1,72 +0,0 @@
/* ISLE SLINGERS -- physics/index.js (module: physics -- aggregator / public API)
* Modularization pass. 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

@ -1,11 +0,0 @@
# js/physics/rules/ — agents.md
Part of the modularization pass. 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 (added 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.

View file

@ -1,59 +0,0 @@
/* ISLE SLINGERS -- physics/rules/index.js (submodule: rules)
* Modularization pass (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

@ -1,18 +0,0 @@
# js/physics/state/ — agents.md
Part of the modularization pass. 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 (added)
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.

View file

@ -1,85 +0,0 @@
/* ISLE SLINGERS -- physics/state/index.js (submodule: state)
* Modularization pass (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; }
};
});

View file

@ -1,22 +0,0 @@
# js/physics/step/ — agents.md
Part of the modularization pass. 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
(added 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.

View file

@ -1,212 +0,0 @@
/* ISLE SLINGERS -- physics/step/index.js (submodule: step)
* Modularization pass (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) {
// Landed-bird gravity behavior: 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

@ -1,23 +0,0 @@
# js/physics/topple/ — agents.md
Added in the physics pass (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).

View file

@ -1,223 +0,0 @@
/* ISLE SLINGERS -- physics/topple/index.js (submodule: topple)
* Board tip/topple physics. Boards tip and topple end-over-end (design call: 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 });
}
}
}
};
});

View file

@ -1,16 +0,0 @@
# js/render/ — agents.md
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 (added): `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

@ -1,605 +0,0 @@
/* ISLE SLINGERS — renderer.js
* Canvas 2D renderer: procedural art, camera, particles, screen shake.
* Consumes state.events (visual effects) and reports them via onEvent for audio.
*/
(function (root, factory) {
var api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.ISLE_RENDERER = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
var VIEW_W = 1280, VIEW_H = 720;
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function Renderer(canvas, opts) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.onEvent = (opts && opts.onEvent) || function () {};
this.particles = [];
this.rng = mulberry32(1234);
this.fit();
this._onResize = this.fit.bind(this);
if (typeof window !== 'undefined') window.addEventListener('resize', this._onResize);
}
Renderer.prototype.fit = function () {
var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
this.dpr = dpr;
this.scale = 1;
if (typeof window !== 'undefined') {
var s = Math.min(window.innerWidth / VIEW_W, window.innerHeight / VIEW_H);
this.scale = s;
this.canvas.style.width = (VIEW_W * s) + 'px';
this.canvas.style.height = (VIEW_H * s) + 'px';
}
this.canvas.width = VIEW_W * dpr;
this.canvas.height = VIEW_H * dpr;
};
Renderer.prototype.destroy = function () {
if (typeof window !== 'undefined') window.removeEventListener('resize', this._onResize);
};
// ------------------------------------------------------------ particles
Renderer.prototype._spawn = function (n, fn) {
for (var i = 0; i < n; i++) {
var p = fn(this.rng);
if (p) this.particles.push(p);
}
};
Renderer.prototype.handleEvents = function (events) {
var self = this;
for (var i = 0; i < events.length; i++) {
var e = events[i];
switch (e.type) {
case 'hit':
case 'block_impact':
this._spawn(8, function (r) {
var ang = r() * Math.PI * 2, sp = 60 + r() * 220;
return { kind: 'spark', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 80,
life: 0.5, maxLife: 0.5, size: 2 + r() * 3, color: '#ffe9b0' };
});
this._spawn(6, function (r) {
var matColor = e.mat === 'wood' ? '#b98a4e' : e.mat === 'ice' ? '#bfe8ff' : '#9aa0a8';
var ang2 = r() * Math.PI * 2, sp2 = 40 + r() * 160;
return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang2) * sp2, vy: Math.sin(ang2) * sp2 - 120,
life: 1.2, maxLife: 1.2, size: 3 + r() * 5, rot: r() * 6.3, vr: (r() - 0.5) * 8, color: matColor };
});
break;
case 'break': {
var mat = e.mat;
var big = e.big ? 26 : 14;
var color = mat === 'wood' ? '#b98a4e' : mat === 'ice' ? '#bfe8ff' : '#9aa0a8';
this._spawn(big, function (r) {
var ang = r() * Math.PI * 2, sp = 80 + r() * 300;
return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 160,
life: 1.5, maxLife: 1.5, size: 3 + r() * 6, rot: r() * 6.3, vr: (r() - 0.5) * 10, color: color };
});
this._spawn(6, function (r) {
var ang = r() * Math.PI * 2, sp = 30 + r() * 90;
return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp * 0.4 - 30,
life: 0.7, maxLife: 0.7, size: 6 + r() * 8, color: 'rgba(200,190,170,0.5)' };
});
break;
}
case 'crack':
this._spawn(3, function (r) {
var ang = r() * Math.PI * 2, sp = 20 + r() * 60;
return { kind: 'debris', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: -40 - r() * 60,
life: 0.8, maxLife: 0.8, size: 2 + r() * 3, rot: r() * 6.3, vr: 4, color: '#c9b391' };
});
break;
case 'pop':
this._spawn(14, function (r) {
var ang = r() * Math.PI * 2, sp = 60 + r() * 200;
return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 60,
life: 0.8, maxLife: 0.8, size: 5 + r() * 7, color: e.kind === 'king' ? 'rgba(240,200,90,0.8)' : 'rgba(126,196,96,0.8)' };
});
this._spawn(8, function (r) {
var ang = r() * Math.PI * 2, sp = 90 + r() * 240;
return { kind: 'spark', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
life: 0.4, maxLife: 0.4, size: 2 + r() * 2, color: '#fff2c4' };
});
break;
case 'bird_ground':
this._spawn(7, function (r) {
var ang = -Math.PI / 2 + (r() - 0.5) * 1.6, sp = 40 + r() * 120;
return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
life: 0.6, maxLife: 0.6, size: 5 + r() * 6, color: 'rgba(190,170,130,0.55)' };
});
break;
case 'crush':
this._spawn(6, function (r) {
var ang = r() * Math.PI, sp = 30 + r() * 100;
return { kind: 'dust', x: e.x, y: e.y, vx: Math.cos(ang) * sp * (r() > 0.5 ? 1 : -1), vy: -30 - r() * 80,
life: 0.5, maxLife: 0.5, size: 4 + r() * 5, color: 'rgba(180,180,170,0.5)' };
});
break;
case 'ability_fury':
this._spawn(10, function (r) {
return { kind: 'spark', x: e.x - r() * 60, y: e.y + (r() - 0.5) * 40, vx: -300 - r() * 200, vy: (r() - 0.5) * 120,
life: 0.35, maxLife: 0.35, size: 2 + r() * 2, color: '#ffd9a8' };
});
break;
case 'ability_dive':
this._spawn(10, function (r) {
return { kind: 'spark', x: e.x + (r() - 0.5) * 50, y: e.y - r() * 40, vx: (r() - 0.5) * 100, vy: -200 - r() * 150,
life: 0.35, maxLife: 0.35, size: 2 + r() * 2, color: '#fff6c8' };
});
break;
case 'ability_quake':
this._spawn(24, function (r) {
var ang = r() * Math.PI * 2, sp = 120 + r() * 260;
return { kind: 'dust', x: e.x + Math.cos(ang) * 30, y: Math.max(e.y, 0) , vx: Math.cos(ang) * sp, vy: -Math.abs(Math.sin(ang)) * sp * 0.7 - 40,
life: 0.9, maxLife: 0.9, size: 5 + r() * 7, color: 'rgba(196,178,140,0.6)' };
});
this.ring = { x: e.x, y: e.y, r: 20, max: e.r, life: 0.5, maxLife: 0.5 };
break;
case 'bird_gone':
this._spawn(10, function (r) {
var ang = r() * Math.PI * 2, sp = 20 + r() * 60;
return { kind: 'feather', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 40,
life: 1.4, maxLife: 1.4, size: 4 + r() * 4, rot: r() * 6.3, vr: (r() - 0.5) * 6, color: 'rgba(235,235,240,0.9)' };
});
break;
case 'confetti_burst':
self.confetti = [];
self._spawn(80, function (r) {
var ang = -Math.PI / 2 + (r() - 0.5) * 1.8, sp = 200 + r() * 420;
var cols = ['#ffd23e', '#ff5d5d', '#5dc8ff', '#7de07d', '#e08cff'];
return { kind: 'confetti', x: e.x, y: e.y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
life: 2.4, maxLife: 2.4, size: 4 + r() * 5, rot: r() * 6.3, vr: (r() - 0.5) * 12, color: cols[(r() * cols.length) | 0] };
});
break;
}
self.onEvent(e);
}
};
// ------------------------------------------------------------ drawing
var BIRD_COLORS = {
wren: { body: '#e2452e', belly: '#f7d9b0', beak: '#f5a623' },
zippy: { body: '#f2c51d', belly: '#fff3c4', beak: '#e07f12' },
boulder: { body: '#31507f', belly: '#9db8d9', beak: '#d98e2b' }
};
Renderer.prototype.drawBird = function (ctx, b, t) {
var c = BIRD_COLORS[b.kind];
var r = b.r;
ctx.save();
ctx.translate(b.x, b.y);
var tilt = 0;
if (b.state === 'flying') tilt = Math.atan2(b.vy, Math.max(40, b.vx)) * 0.35;
ctx.rotate(tilt);
// tail
ctx.fillStyle = c.body;
ctx.beginPath();
ctx.moveTo(-r * 0.8, -r * 0.2); ctx.lineTo(-r * 1.5, -r * 0.55); ctx.lineTo(-r * 1.45, 0.1 * r);
ctx.lineTo(-r * 1.5, r * 0.35); ctx.lineTo(-r * 0.8, r * 0.25); ctx.closePath(); ctx.fill();
// body
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
// belly
ctx.fillStyle = c.belly;
ctx.beginPath(); ctx.ellipse(r * 0.1, r * 0.42, r * 0.62, r * 0.42, 0, 0, Math.PI * 2); ctx.fill();
// wing
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.beginPath(); ctx.ellipse(-r * 0.35, r * 0.05, r * 0.5, r * 0.3, -0.5, 0, Math.PI * 2); ctx.fill();
// eyes
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(r * 0.38, -r * 0.28, r * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.72, -r * 0.24, r * 0.26, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(r * 0.46, -r * 0.26, r * 0.13, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.78, -r * 0.22, r * 0.12, 0, Math.PI * 2); ctx.fill();
// beak
ctx.fillStyle = c.beak;
ctx.beginPath();
ctx.moveTo(r * 0.85, -r * 0.05); ctx.lineTo(r * 1.45, r * 0.08); ctx.lineTo(r * 0.8, r * 0.22);
ctx.closePath(); ctx.fill();
// angry brow
ctx.strokeStyle = '#3a2a20';
ctx.lineWidth = Math.max(2, r * 0.14);
ctx.beginPath(); ctx.moveTo(r * 0.12, -r * 0.62); ctx.lineTo(r * 0.9, -r * 0.42); ctx.stroke();
// ability-ready flash
if (b.state === 'flying' && !b.abilityUsed && b.age > 0.35) {
ctx.strokeStyle = 'rgba(255,255,190,' + (0.5 + 0.5 * Math.sin(t * 10)).toFixed(2) + ')';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(0, 0, r + 7, 0, Math.PI * 2); ctx.stroke();
}
ctx.restore();
};
Renderer.prototype.drawMog = function (ctx, e, t) {
var r = e.r;
var body = e.kind === 'grum' ? '#5d9e4a' : e.kind === 'king' ? '#4f8f3e' : '#7ec06a';
ctx.save();
ctx.translate(e.x, e.y);
if (e.dead) {
ctx.globalAlpha = Math.max(0, 1 - e.deathAge * 2.2);
ctx.scale(1 + e.deathAge * 0.6, Math.max(0.1, 1 - e.deathAge * 1.4));
}
// body
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
// snout
ctx.fillStyle = e.kind === 'king' ? '#d8c07a' : '#cfe8b8';
ctx.beginPath(); ctx.ellipse(0, r * 0.3, r * 0.55, r * 0.4, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath(); ctx.arc(-r * 0.2, r * 0.28, r * 0.09, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.2, r * 0.28, r * 0.09, 0, Math.PI * 2); ctx.fill();
// eyes
if (e.dead) {
ctx.strokeStyle = '#233';
ctx.lineWidth = 2.5;
[-1, 1].forEach(function (s) {
ctx.beginPath();
ctx.moveTo(s * r * 0.28 - 5, -r * 0.35 - 5); ctx.lineTo(s * r * 0.28 + 5, -r * 0.35 + 5);
ctx.moveTo(s * r * 0.28 + 5, -r * 0.35 - 5); ctx.lineTo(s * r * 0.28 - 5, -r * 0.35 + 5);
ctx.stroke();
});
} else {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(-r * 0.3, -r * 0.3, r * 0.24, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.3, -r * 0.3, r * 0.24, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#223';
var blink = Math.sin(t * 1.7 + e.id * 2.1) > 0.985 ? 0.15 : 1;
ctx.beginPath(); ctx.ellipse(-r * 0.28, -r * 0.28, r * 0.1, r * 0.1 * blink, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(r * 0.32, -r * 0.28, r * 0.1, r * 0.1 * blink, 0, 0, Math.PI * 2); ctx.fill();
}
// ears
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(-r * 0.55, -r * 0.75, r * 0.22, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.55, -r * 0.75, r * 0.22, 0, Math.PI * 2); ctx.fill();
if (e.kind === 'king') {
ctx.fillStyle = '#f2c230';
ctx.beginPath();
ctx.moveTo(-r * 0.5, -r * 0.82);
ctx.lineTo(-r * 0.35, -r * 1.25); ctx.lineTo(-r * 0.15, -r * 0.95);
ctx.lineTo(0, -r * 1.3); ctx.lineTo(r * 0.15, -r * 0.95);
ctx.lineTo(r * 0.35, -r * 1.25); ctx.lineTo(r * 0.5, -r * 0.82);
ctx.closePath(); ctx.fill();
} else if (e.kind === 'grum') {
ctx.fillStyle = '#6b4a2a';
ctx.fillRect(-r * 0.55, -r * 0.95, r * 1.1, r * 0.22);
}
ctx.restore();
};
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)' },
stone: { fill: '#9aa0a8', edge: '#6d7278', line: 'rgba(70,75,82,0.5)' }
};
var s = styles[bl.mat];
ctx.fillStyle = s.fill;
ctx.fillRect(x, y, bl.w, bl.h);
ctx.strokeStyle = s.edge;
ctx.lineWidth = 3;
ctx.strokeRect(x + 1.5, y + 1.5, bl.w - 3, bl.h - 3);
// plank lines / grain
ctx.strokeStyle = s.line;
ctx.lineWidth = 1.5;
if (bl.mat === 'wood') {
if (bl.h > bl.w) {
ctx.beginPath();
ctx.moveTo(x + bl.w * 0.3, y + 4); ctx.lineTo(x + bl.w * 0.35, y + bl.h - 4);
ctx.moveTo(x + bl.w * 0.7, y + 4); ctx.lineTo(x + bl.w * 0.62, y + bl.h - 4);
ctx.stroke();
} else {
for (var px = x + bl.w / 4; px < x + bl.w - 8; px += bl.w / 4) {
ctx.beginPath(); ctx.moveTo(px, y + 4); ctx.lineTo(px, y + bl.h - 4); ctx.stroke();
}
}
} else if (bl.mat === 'stone') {
var rnd = mulberry32(bl.id * 97 + 13);
ctx.fillStyle = 'rgba(70,75,82,0.35)';
for (var i = 0; i < 6; i++) {
ctx.beginPath();
ctx.arc(x + rnd() * bl.w, y + rnd() * bl.h, 1.5 + rnd() * 2, 0, Math.PI * 2);
ctx.fill();
}
} else {
ctx.beginPath();
ctx.moveTo(x + bl.w * 0.15, y + 6); ctx.lineTo(x + bl.w * 0.4, y + bl.h * 0.4);
ctx.stroke();
}
// cracks by damage
var dmg = 1 - bl.hp / bl.maxHp;
if (dmg > 0.15) {
var rnd2 = mulberry32(bl.id * 31 + 7);
ctx.strokeStyle = 'rgba(30,25,20,0.55)';
ctx.lineWidth = 2;
var nCracks = dmg > 0.6 ? 4 : dmg > 0.35 ? 2 : 1;
for (var c = 0; c < nCracks; c++) {
var cx0 = x + rnd2() * bl.w, cy0 = y;
ctx.beginPath(); ctx.moveTo(cx0, cy0);
var segs = 3;
for (var sgm = 1; sgm <= segs; sgm++) {
cx0 += (rnd2() - 0.5) * bl.w * 0.4;
ctx.lineTo(cx0, cy0 + (bl.h / segs) * sgm);
}
ctx.stroke();
}
}
if (rotated) ctx.restore();
};
Renderer.prototype.drawSling = function (ctx, sling, bird, drag) {
var sx = sling.x, sy = sling.y;
ctx.save();
ctx.strokeStyle = '#6d4a26';
ctx.lineCap = 'round';
ctx.lineWidth = 14;
ctx.beginPath();
ctx.moveTo(sx, sy + 130);
ctx.lineTo(sx, sy - 18);
ctx.moveTo(sx, sy - 18);
ctx.lineTo(sx - 26, sy - 62);
ctx.moveTo(sx, sy - 18);
ctx.lineTo(sx + 26, sy - 62);
ctx.stroke();
ctx.strokeStyle = '#8a5f2e';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(sx, sy + 130); ctx.lineTo(sx, sy - 10);
ctx.stroke();
// rubber bands: taut toward the bird ONLY while it is being aimed.
// The moment it launches the sling must let go — a band that keeps
// tracking the flying bird reads as "the sling never released it"
// (bug #14). In flight (or with no bird) the bands hang relaxed.
if (bird && bird.state === 'aiming') {
var bx = drag ? drag.x : bird.x;
var by = drag ? drag.y : bird.y;
ctx.strokeStyle = '#4a3018';
ctx.lineWidth = 7;
ctx.beginPath(); ctx.moveTo(sx - 26, sy - 62); ctx.lineTo(bx - 6, by); ctx.stroke();
ctx.lineWidth = 9;
ctx.beginPath(); ctx.moveTo(sx + 26, sy - 62); ctx.lineTo(bx + 6, by); ctx.stroke();
} else {
ctx.strokeStyle = '#4a3018';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(sx - 26, sy - 62);
ctx.quadraticCurveTo(sx, sy - 30, sx + 26, sy - 62);
ctx.stroke();
}
ctx.restore();
};
Renderer.prototype.drawPalm = function (ctx, x, ground, s) {
ctx.save();
ctx.translate(x, ground);
ctx.strokeStyle = '#7a5a33';
ctx.lineWidth = 14 * s;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(10 * s, -70 * s, 26 * s, -118 * s);
ctx.stroke();
ctx.strokeStyle = '#3f9d4f';
ctx.lineWidth = 9 * s;
for (var i = 0; i < 6; i++) {
var ang = -Math.PI / 2 + (i - 2.5) * 0.5;
ctx.beginPath();
ctx.moveTo(26 * s, -118 * s);
ctx.quadraticCurveTo(26 * s + Math.cos(ang) * 45 * s, -118 * s + Math.sin(ang) * 30 * s - 12 * s,
26 * s + Math.cos(ang) * 72 * s, -118 * s + Math.sin(ang) * 48 * s + 14 * s);
ctx.stroke();
}
ctx.fillStyle = '#8a5a2a';
ctx.beginPath(); ctx.arc(26 * s, -112 * s, 7 * s, 0, Math.PI * 2); ctx.fill();
ctx.restore();
};
Renderer.prototype.draw = function (state, view) {
var ctx = this.ctx;
var t = state.time;
var cam = state.camera;
var dpr = this.dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// sky
var sky = ctx.createLinearGradient(0, 0, 0, VIEW_H);
sky.addColorStop(0, '#7ec8f0');
sky.addColorStop(0.55, '#b8e4f7');
sky.addColorStop(1, '#e8f7ff');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
// sun
ctx.fillStyle = 'rgba(255,240,180,0.9)';
ctx.beginPath(); ctx.arc(VIEW_W - 140, 110, 46, 0, Math.PI * 2); ctx.fill();
// world transform: camera pan + shake
var shX = 0, shY = 0;
if (cam.shake > 0) {
shX = (this.rng() - 0.5) * cam.shake * 2;
shY = (this.rng() - 0.5) * cam.shake * 2;
}
ctx.save();
// world transform: camera pan + zoom + shake (shake applied in screen
// space so its magnitude does not scale with the zoom)
var zoom = (cam && cam.zoom) || 1;
ctx.translate(VIEW_W / 2 + shX, VIEW_H / 2 + shY);
ctx.scale(zoom, zoom);
ctx.translate(-cam.x, -cam.y);
var G = state.world.ground;
// distant island silhouette (parallax-lite via fixed offset)
ctx.fillStyle = 'rgba(122,178,140,0.35)';
ctx.beginPath();
ctx.ellipse(1200, G + 30, 900, 90, 0, Math.PI, 0);
ctx.fill();
// island ground
ctx.fillStyle = '#c9a36a';
ctx.beginPath();
ctx.moveTo(-400, G);
ctx.quadraticCurveTo(1200, G - 90, 2800, G);
ctx.lineTo(2800, G + 400); ctx.lineTo(-400, G + 400);
ctx.closePath(); ctx.fill();
// grass
ctx.fillStyle = '#67b34d';
ctx.beginPath();
ctx.moveTo(-400, G);
ctx.quadraticCurveTo(1200, G - 90, 2800, G);
ctx.quadraticCurveTo(1200, G - 44, -400, G);
ctx.closePath(); ctx.fill();
// decor
if (state.level.decor) {
var d = state.level.decor;
for (var p = 0; p < (d.palms || []).length; p++) this.drawPalm(ctx, d.palms[p], G - 40, 1);
for (var f = 0; f < (d.flowers || []).length; f++) {
var fx = d.flowers[f];
ctx.fillStyle = '#e2557b';
ctx.beginPath(); ctx.arc(fx, G - 48, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#3f9d4f'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(fx, G - 44); ctx.lineTo(fx, G - 30); ctx.stroke();
}
}
// queued birds at the beach
for (var qb = 0; qb < state.birds.length; qb++) {
var qb2 = state.birds[qb];
if (qb2.state === 'queued') {
var bx = state.level.sling.x - 130 - (state.birds.length - 1 - qb) * 56;
this.drawBird(ctx, { x: bx, y: G - 30 - qb2.r * 0.4, r: qb2.r, kind: qb2.kind, state: 'queued', vx: 0, vy: 0, age: t }, t);
}
}
// slingshot back arm + bird + front arm (bird sits between the bands)
var ab = (view && view.activeBird) || null;
var drag = (view && view.drag) || null;
this.drawSling(ctx, state.level.sling, ab, drag);
if (ab && (ab.state === 'aiming' || ab.state === 'flying')) {
var bb = { x: drag && ab.state === 'aiming' ? drag.x : ab.x, y: drag && ab.state === 'aiming' ? drag.y : ab.y,
r: ab.r, kind: ab.kind, state: ab.state, vx: ab.vx, vy: ab.vy, age: ab.age,
abilityUsed: ab.abilityUsed };
this.drawBird(ctx, bb, t);
}
// trajectory preview
if (view && view.traj) {
for (var tp = 0; tp < view.traj.length; tp++) {
var pt = view.traj[tp];
ctx.fillStyle = 'rgba(255,255,255,' + (0.75 - tp * 0.02).toFixed(2) + ')';
ctx.beginPath();
ctx.arc(pt.x, pt.y, Math.max(2, 6 - tp * 0.12), 0, Math.PI * 2);
ctx.fill();
}
}
// blocks
for (var bl = 0; bl < state.blocks.length; bl++) this.drawBlock(ctx, state.blocks[bl]);
// enemies
for (var en = 0; en < state.enemies.length; en++) this.drawMog(ctx, state.enemies[en], t);
// 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
ctx.textAlign = 'center';
for (var pp = 0; pp < state.scorePopups.length; pp++) {
var pop = state.scorePopups[pp];
ctx.globalAlpha = Math.max(0, 1 - pop.age / 1.4);
ctx.fillStyle = '#fff';
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 4;
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.strokeText(pop.text, pop.x, pop.y);
ctx.fillText(pop.text, pop.x, pop.y);
ctx.globalAlpha = 1;
}
// quake ring
if (this.ring) {
var rg = this.ring;
rg.life -= (1 / 60);
var k = 1 - rg.life / rg.maxLife;
ctx.strokeStyle = 'rgba(220,200,160,' + (0.7 * (1 - k)).toFixed(2) + ')';
ctx.lineWidth = 6;
ctx.beginPath(); ctx.arc(rg.x, rg.y, 20 + k * (rg.max - 20), 0, Math.PI * 2); ctx.stroke();
if (rg.life <= 0) this.ring = null;
}
// particles
var dtP = 1 / 60;
for (var pi = this.particles.length - 1; pi >= 0; pi--) {
var pt2 = this.particles[pi];
pt2.life -= dtP;
if (pt2.life <= 0) { this.particles.splice(pi, 1); continue; }
pt2.vy += (pt2.kind === 'dust' ? 160 : 900) * dtP;
pt2.x += pt2.vx * dtP;
pt2.y += pt2.vy * dtP;
var a = Math.max(0, pt2.life / pt2.maxLife);
if (pt2.kind === 'debris' || pt2.kind === 'feather' || pt2.kind === 'confetti') {
pt2.rot += (pt2.vr || 0) * dtP;
ctx.save();
ctx.translate(pt2.x, pt2.y);
ctx.rotate(pt2.rot);
ctx.globalAlpha = a;
ctx.fillStyle = pt2.color;
if (pt2.kind === 'feather') {
ctx.beginPath(); ctx.ellipse(0, 0, pt2.size * 1.4, pt2.size * 0.6, 0, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillRect(-pt2.size / 2, -pt2.size / 2, pt2.size, pt2.size * (pt2.kind === 'debris' ? 0.7 : 1));
}
ctx.restore();
ctx.globalAlpha = 1;
} else {
ctx.globalAlpha = a;
ctx.fillStyle = pt2.color;
ctx.beginPath(); ctx.arc(pt2.x, pt2.y, pt2.size * (pt2.kind === 'dust' ? (1.6 - a * 0.6) : a), 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
}
// confetti (screen-anchored burst from win)
if (this.confetti) {
for (var ci = this.confetti.length - 1; ci >= 0; ci--) {
var c2 = this.confetti[ci];
c2.life -= dtP;
if (c2.life <= 0) { this.confetti.splice(ci, 1); continue; }
c2.vy += 700 * dtP;
c2.x += c2.vx * dtP;
c2.y += c2.vy * dtP;
c2.rot += (c2.vr || 0) * dtP;
var ca = Math.max(0, Math.min(1, c2.life / 0.6));
ctx.save();
ctx.translate(c2.x, c2.y);
ctx.rotate(c2.rot);
ctx.globalAlpha = ca;
ctx.fillStyle = c2.color;
ctx.fillRect(-c2.size / 2, -c2.size / 2, c2.size, c2.size * 0.6);
ctx.restore();
ctx.globalAlpha = 1;
}
if (this.confetti.length === 0) this.confetti = null;
}
ctx.restore();
};
return { Renderer: Renderer, VIEW_W: VIEW_W, VIEW_H: VIEW_H };
});

View file

@ -1,11 +0,0 @@
{
"name": "isle-slingers",
"version": "3.0.0",
"private": true,
"description": "Isle Slingers — an original browser slingshot physics game. No dependencies; opens directly in a browser.",
"scripts": {
"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

@ -1,143 +0,0 @@
/* ISLE SLINGERS — balance spec (Bug #3:
"the blocks and pig bad guys too hard to kill").
*
* Measures with the REAL core (same integrator/colliders the game runs),
* repeated identical full-power DIRECT hits, and asserts hit-to-kill
* budgets. These numbers are what a human with a decent aim actually
* gets — not the suite's omniscient perfect archer.
*
* Pinned by this spec:
* wood breaks in <= 3 full-power direct hits
* ice breaks in <= 2 full-power direct hits
* stone breaks in <= 4 full-power direct hits (was ~11: unkillable)
* 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/physics/index.js'; // physics module aggregator (was js/core.js)
import levelsModule from '../js/levels/index.js'; // levels module aggregator (was js/levels.js)
const LEVELS = levelsModule.LEVELS;
let passed = 0, failed = 0;
const failures = [];
function test(name, fn) {
try { fn(); passed++; console.log(' ok ' + name); }
catch (err) {
failed++; failures.push({ name, err });
console.log(' FAIL ' + name + ' — ' + (err && err.message || err));
}
}
function ok(cond, msg) { if (!cond) throw new Error(msg); }
const DT = 1 / 120;
const SLING = LEVELS[0].sling;
const PULL = 150; // full power
// Full-power launch at `deg` above horizontal (toward +x).
function fire(g, deg) {
const r = deg * Math.PI / 180;
const v = PULL * ISLE.LAUNCH_POWER;
const b = ISLE.activeBird(g);
b.x = SLING.x; b.y = SLING.y;
b.vx = v * Math.cos(r); b.vy = -v * Math.sin(r);
b.state = 'flying'; b.launchT = g.time;
g.launched++;
let guard = 0;
while (g.status !== 'won' && g.status !== 'lost' && guard++ < 1600) {
ISLE.step(g, DT);
if (b.state === 'spent') break;
}
}
function settle(g, frames = 40) { for (let i = 0; i < frames; i++) ISLE.step(g, DT); }
// Best (most damaging) full-power direct angle against one isolated block.
function bestDirectAngle(mat) {
const mk = () => {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [], blocks: [{ x: 1100, y: 900 - 35, w: 70, h: 70, mat }] });
settle(g);
return g;
};
let best = null;
for (let deg = 0; deg <= 25; deg += 1) {
const g = mk();
const hp0 = g.blocks[0].maxHp;
fire(g, deg);
const dmg = hp0 - g.blocks[0].hp;
if (!best || dmg > best.dmg) best = { deg, dmg };
}
return best;
}
// Hits-to-break: repeated identical direct hits, hp carried over between birds.
function hitsToBreak(mat, deg) {
const spec = ISLE.MATERIALS[mat];
let hp = spec.hp, hits = 0;
while (hp > 0 && hits < 20) {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [], blocks: [{ x: 1100, y: 900 - 35, w: 70, h: 70, mat }] });
settle(g);
g.blocks[0].hp = hp;
fire(g, deg);
const dmg = hp - g.blocks[0].hp;
hp -= dmg; hits++;
if (dmg <= 0) break; // never connects — treat as unbreakable
}
return { hits, connected: hits > 0 && hp <= 0 };
}
// Enemy: repeated full-power direct hits from the sling (flat, 0° — the
// player's most reliable connection), hp carried over between birds.
function enemyHitsToDie(kind) {
const spec = ISLE.ENEMY_TYPES[kind];
let hp = spec.hp, hits = 0;
while (hp > 0 && hits < 10) {
const g = ISLE.createGame({ ...LEVELS[0], enemies: [{ x: 1100, y: 876, kind }], blocks: [], birds: ['wren'] });
settle(g, 10);
const e = g.enemies[0];
e.hp = hp;
fire(g, 0);
const dmg = hp - e.hp;
hp -= dmg; hits++;
if (dmg <= 0) break; // never connects — treat as unkillable
}
return { hits, connected: hp <= 0 };
}
console.log('\n== ISLE SLINGERS balance spec ==');
const W = bestDirectAngle('wood');
const I = bestDirectAngle('ice');
const S = bestDirectAngle('stone');
console.log(` (direct angles — wood ${W.deg}° ${W.dmg.toFixed(0)}dmg, ice ${I.deg}° ${I.dmg.toFixed(0)}dmg, stone ${S.deg}° ${S.dmg.toFixed(0)}dmg)`);
test('wood breaks in <= 3 full-power direct hits', () => {
const r = hitsToBreak('wood', W.deg);
ok(r.connected, 'wood block never broke');
ok(r.hits <= 3, `wood took ${r.hits} full-power direct hits (budget 3)`);
});
test('ice breaks in <= 2 full-power direct hits', () => {
const r = hitsToBreak('ice', I.deg);
ok(r.connected, 'ice block never broke');
ok(r.hits <= 2, `ice took ${r.hits} full-power direct hits (budget 2)`);
});
test('stone breaks in <= 4 full-power direct hits (was ~11: unkillable)', () => {
const r = hitsToBreak('stone', S.deg);
ok(r.connected, 'stone block never broke (unkillable wall)');
ok(r.hits <= 4, `stone took ${r.hits} full-power direct hits (budget 4)`);
});
test('grum dies in <= 2 full-power direct hits (soft lobs currently ~0.9 dmg)', () => {
const r = enemyHitsToDie('grum');
ok(r.connected, 'grum never died');
ok(r.hits <= 2, `grum took ${r.hits} full-power hits (budget 2)`);
});
test('king still survives one full-power flat wren hit (pinned hp3)', () => {
const h = ISLE.createGame({ ...LEVELS[0], enemies: [{ x: 1100, y: 876, kind: 'king' }], blocks: [], birds: ['wren'] });
settle(h, 10);
fire(h, 0); // full-power flat from the sling — the player's hardest reliable hit
const hp = h.enemies[0].hp;
ok(hp < 3, 'full-power flat wren shot did not connect with the king');
ok(hp > 0, `king died to a single full-power wren hit (hp ${hp}) — must survive (pinned by sim.test.mjs)`);
});
console.log(`\n== ${passed} passed, ${failed} failed ==`);
if (failed) {
process.exit(1);
}

View file

@ -1,121 +0,0 @@
// test/fallingkill.test.mjs -- S-4: falling-object impact damage.
// "falling objects need to be able to kill the
// enemy's as well not just the birds". 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);

View file

@ -1,62 +0,0 @@
// test/knifeedge.test.mjs -- 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);

View file

@ -1,151 +0,0 @@
// test/landedbird.test.mjs -- landed bird remains a physical body.
// Design spec (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 (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

@ -1,304 +0,0 @@
/* ISLE SLINGERS — page boot test (node, zero dependencies).
* Run: node test/page.test.mjs
*
* Loads index.html + all js/ files exactly as a browser would (same order,
* same globals), against a minimal DOM stub built from the REAL ids in
* index.html, and drives a full click session: PLAY -> level tile -> aim &
* launch -> play out the level. Any null-element wiring bug (the class
* that shipped a dead page) throws here. The physics suite cannot see this.
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..');
let passed = 0, failed = 0;
const failures = [];
function test(name, fn) {
try { fn(); passed++; console.log(' PASS ' + name); }
catch (e) { failed++; failures.push({ name, err: e }); console.log(' FAIL ' + name + ' :: ' + e.message); }
}
function ok(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
// ------------------------------------------------------------ DOM stub
const html = readFileSync(join(ROOT, 'index.html'), 'utf8');
function makeEl(id) {
const el = {
id, children: [], listeners: {},
style: {}, dataset: {}, disabled: false, className: '',
textContent: '', _innerHTML: '',
get innerHTML() { return this._innerHTML; },
set innerHTML(v) {
this._innerHTML = v;
if (v === '') this.children = [];
},
appendChild(c) { this.children.push(c); return c; },
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
removeEventListener() {},
dispatch(type, ev) {
const e = Object.assign({ stopPropagation() {}, preventDefault() {} }, ev || {});
for (const fn of this.listeners[type] || []) fn(e);
},
querySelector(sel) {
const m = /^#([A-Za-z0-9_-]+)$/.exec(sel);
if (!m) return null;
return collectAll().find((x) => x.id === m[1]) || null;
},
querySelectorAll() { return []; },
getBoundingClientRect() { return { left: 0, top: 0, width: 1280, height: 720 }; },
getContext(kind) { return makeCtx(this); },
get context() { return makeCtx(this); },
};
return el;
}
function makeCtx(el) {
const noop = () => {};
const grad = { addColorStop: noop };
// the game canvas records lineTo() coordinates so tests can assert what
// the sling actually draws (e.g. that the band stops tracking the bird
// once it launches — bug #14's arm)
const isGame = el.id === 'game';
if (isGame) el._lineTos = el._lineTos || [];
return new Proxy({}, {
get(t, k) {
if (k === 'createLinearGradient' || k === 'createRadialGradient' || k === 'createPattern') return () => grad;
if (k === 'measureText') return () => ({ width: 10 });
if (isGame && k === 'lineTo') return (x, y) => { el._lineTos.push([x, y]); };
if (k in t) return t[k];
return noop;
},
set(t, k, v) { t[k] = v; return true; },
});
}
// parse every id out of the real index.html
const ids = [...html.matchAll(/id="([^"]+)"/g)].map((m) => m[1]);
const els = new Map();
for (const id of ids) els.set(id, makeEl(id));
const collectAll = () => [...els.values()];
// the level-grid tiles are created dynamically by main.js
const dynamicEls = new Map();
const documentStub = {
getElementById(id) { return els.get(id) || dynamicEls.get(id) || null; },
createElement(tag) {
const el = makeEl('__dyn_' + tag + '_' + dynamicEls.size);
dynamicEls.set(el.id, el);
return el;
},
addEventListener() {},
body: makeEl('__body'),
};
const rafQueue = [];
const windowStub = {
innerWidth: 1280, innerHeight: 720,
addEventListener(type, fn) { (this._l = this._l || {})[type] = fn; },
dispatch(type, ev) {
const e = Object.assign({ stopPropagation() {}, preventDefault() {} }, ev || {});
if (this._l && this._l[type]) this._l[type](e);
},
requestAnimationFrame(fn) { rafQueue.push(fn); return rafQueue.length; },
localStorage: {
_s: {},
getItem(k) { return this._s[k] === undefined ? null : this._s[k]; },
setItem(k, v) { this._s[k] = String(v); },
},
};
// canvas sizing side effects touch window.devicePixelRatio & element styles
windowStub.devicePixelRatio = 1;
// ------------------------------------------------------------ load modules as a browser would
const sandbox = {
window: windowStub,
document: documentStub,
localStorage: windowStub.localStorage,
requestAnimationFrame: (fn) => windowStub.requestAnimationFrame(fn),
setTimeout: (fn) => { fn(); return 0; },
clearTimeout: () => {},
console,
performance: { now: () => Date.now() },
navigator: { userAgent: 'page-test' },
AudioContext: undefined, // audio.js must degrade without it
HTMLAudioElement: undefined,
};
sandbox.window.AudioContext = undefined;
function load(file) {
const src = readFileSync(join(ROOT, file), 'utf8');
// 'globalThis' is a shadowing parameter: UMD wrappers do
// `typeof globalThis !== 'undefined' ? globalThis : this`, so without this
// the modules would attach to Node's real global, not the browser stub.
const fn = new Function('window', 'document', 'localStorage', 'requestAnimationFrame',
'setTimeout', 'clearTimeout', 'console', 'performance', 'navigator',
'AudioContext', 'HTMLAudioElement', 'globalThis', src);
fn(windowStub, documentStub, windowStub.localStorage, windowStub.requestAnimationFrame,
sandbox.setTimeout, sandbox.clearTimeout, console, sandbox.performance,
sandbox.navigator, undefined, undefined, windowStub);
}
console.log('\n== ISLE SLINGERS page boot (DOM stub) ==');
// ------------------------------------------------------------------- tests
test('page loads: all five modules execute without throwing (browser order)', () => {
// 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) {
throw new Error(f + ' threw during load: ' + e.message + '\n' + (e.stack || '').split('\n').slice(0, 4).join('\n'));
}
}
ok(windowStub.ISLE_CORE && windowStub.ISLE_LEVELS && windowStub.ISLE_AUDIO && windowStub.ISLE_RENDERER,
'browser globals missing after load');
});
test('boot: requestAnimationFrame loop scheduled and title screen shown', () => {
ok(rafQueue.length >= 1, 'no RAF scheduled — boot died');
const start = els.get('screen-start');
ok(start.style.display !== 'none', 'start screen not shown on boot');
});
let lastPumpT = 0; // rAF timestamps must keep advancing across pumps —
// an undefined t0 would NaN the clock and silently freeze
// the fixed-timestep accumulator (the game would never step)
function pumpFrames(n, t0) {
let t = (t0 === undefined) ? lastPumpT : t0;
for (let i = 0; i < n; i++) {
t += 1000 / 60;
const q = rafQueue.splice(0);
for (const fn of q) fn(t);
}
lastPumpT = t;
return t;
}
test('click session: PLAY -> level select -> start level 1 -> play screen', () => {
els.get('btn-play').dispatch('click');
ok(els.get('screen-select').style.display !== 'none', 'level select did not open');
ok(els.get('level-grid').children.length >= 3, 'level tiles not rendered');
const tile1 = els.get('level-grid').children[0];
ok(!tile1.disabled, 'first level unexpectedly locked');
tile1.dispatch('click');
ok(els.get('hud').style.display !== 'none', 'HUD not shown in play screen');
ok(els.get('hud-level').textContent.indexOf('Level 1') === 0, 'HUD level label wrong: ' + els.get('hud-level').textContent);
});
test('play session: aim, launch, run the sim to resolution without throwing', () => {
const canvas = els.get('game');
// grab the bird near the sling and launch it full-power left-to-right
canvas.dispatch('mousedown', { clientX: 200, clientY: 560, preventDefault() {} });
windowStub.dispatch('mousemove', { clientX: 120, clientY: 600, preventDefault() {} });
windowStub.dispatch('mouseup', {});
let t = pumpFrames(60 * 8, 0); // 8s of gameplay at 60fps
ok(true, 'sim ran 8s without throwing (status: n/a headless)');
});
test('pause / mute / keyboard wiring respond without throwing', () => {
windowStub.dispatch('keydown', { key: 'p', repeat: false, preventDefault() {} });
els.get('btn-pause-resume').dispatch('click');
els.get('btn-mute').dispatch('click');
els.get('btn-mute').dispatch('click');
ok(els.get('btn-mute').textContent.length > 0, 'mute button label empty');
});
// ------------------------------ bugs #14-15 (playtest, round 2)
// Both bugs were invisible to the physics suite because they live in the
// input->world mapping and the renderer, not the core. These arms drive
// the page's OWN pointer handlers (the exact calls a player makes) and
// assert on the resulting state and drawing.
// Bug #15: "Sling shot only shoots backwards and not in the direction of
// the target." The player grabs the bird at the place it is VISIBLE,
// drags it back (away from the fort, i.e. left), and releases. The bird
// must launch toward the fort (right). RED on 2d9c8f47: toWorld() ignored
// the camera zoom, so the visible bird mapped to a world point ~210px to
// the RIGHT of the sling — every pull-back produced a leftward launch
// (measured: bird landed x=230, behind the sling at x=270, fort at 1500+).
test('direction: pulling the bird back launches it TOWARD the fort (bug #15)', () => {
ok(windowStub.ISLE_PAGE,
'ISLE_PAGE state handle missing — pre-fix build (expected RED on 2d9c8f47)');
const st = windowStub.ISLE_PAGE.getState();
const ab = st.birds[st.activeBird];
ok(ab && ab.state === 'aiming', 'no bird waiting on the sling');
// where the bird is actually drawn: inverse of the renderer transform
const cam = st.camera;
const sx = (ab.x - cam.x) * cam.zoom + 640;
const sy = (ab.y - cam.y) * cam.zoom + 360;
const canvas = els.get('game');
// press on the visible bird, pull it back (left + slightly down), release
canvas.dispatch('mousedown', { clientX: sx, clientY: sy, preventDefault() {} });
windowStub.dispatch('mousemove', { clientX: sx - 110, clientY: sy + 50, preventDefault() {} });
windowStub.dispatch('mouseup', {});
const st2 = windowStub.ISLE_PAGE.getState();
const fired = st2.birds[st2.activeBird];
ok(fired && fired.state === 'flying', 'release did not launch the bird (grab mapping off?)');
ok(fired.vx > 100,
`bird launched the wrong way: vx=${fired.vx.toFixed(0)} (the fort is to the RIGHT of the sling at x>${st2.level.sling.x})`);
});
// Bug #14: "sling never actually lets go of bird when the bird is shot
// out." With the bird in flight, the sling's bands must hang relaxed —
// they must not keep ending at the flying bird's position. RED on
// 2d9c8f47: drawSling drew the taut band to bird.x/bird.y for ANY active
// bird, so the rubber banded tracked the projectile for its whole flight.
test('release: sling bands do not track the flying bird (bug #14)', () => {
ok(windowStub.ISLE_PAGE,
'ISLE_PAGE state handle missing — pre-fix build (expected RED on 2d9c8f47)');
let st = windowStub.ISLE_PAGE.getState();
let b = st.birds[st.activeBird];
// wait (up to 10s) until a bird is loaded on the sling — an earlier
// arm's shot may still be in flight / settling
let guard = 0;
while (b && b.state !== 'aiming' && guard++ < 600) {
pumpFrames(1);
st = windowStub.ISLE_PAGE.getState();
b = st.birds[st.activeBird];
}
ok(b && b.state === 'aiming', 'no bird waiting on the sling to fire');
// fire it with the standard pull-back gesture
const cam = st.camera;
const sx = (b.x - cam.x) * cam.zoom + 640;
const sy = (b.y - cam.y) * cam.zoom + 360;
const canvas = els.get('game');
canvas.dispatch('mousedown', { clientX: sx, clientY: sy, preventDefault() {} });
windowStub.dispatch('mousemove', { clientX: sx - 110, clientY: sy + 50, preventDefault() {} });
windowStub.dispatch('mouseup', {});
pumpFrames(30); // 0.5 s: mid-flight and well clear of the sling
st = windowStub.ISLE_PAGE.getState();
b = st.birds[st.activeBird];
ok(b && b.state === 'flying', 'no bird in flight to check the release');
const sling = st.level.sling;
ok(Math.hypot(b.x - sling.x, b.y - sling.y) > 250,
'bird still within 250px of the sling — check would be ambiguous');
canvas._lineTos.length = 0;
pumpFrames(1); // one drawn frame with the bird mid-flight
// draw() renders the state AFTER that frame's physics step — re-read
st = windowStub.ISLE_PAGE.getState();
const fb = st.birds[st.activeBird];
const attached = canvas._lineTos.some(
([x, y]) => Math.hypot(x - (fb.x - 6), y - fb.y) < 12 || Math.hypot(x - (fb.x + 6), y - fb.y) < 12);
ok(!attached,
`a sling band was still drawn to the flying bird at (${fb.x.toFixed(0)},${fb.y.toFixed(0)}) — the sling never let go`);
});
// ------------------------------------------------------------------ report
console.log('\n== ' + passed + ' passed, ' + failed + ' failed ==');
if (failed) {
for (const f of failures) console.log(' - ' + f.name + ': ' + f.err.message.split('\n')[0]);
process.exit(1);
}

View file

@ -1,439 +0,0 @@
/* ISLE SLINGERS — headless simulation tests (node, zero dependencies).
* Run: node test/sim.test.mjs (or `npm test`)
*
* Verifies the pure core: syntax, level data integrity, determinism,
* numeric stability, trajectory-preview fidelity, ability behaviour,
* win/lose rules, and that every level is clearable by a perfect archer.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ISLE from '../js/physics/index.js'; // physics module aggregator (was js/core.js)
import levelsModule from '../js/levels/index.js'; // levels module aggregator (was js/levels.js)
const { LEVELS } = levelsModule;
const HERE = dirname(fileURLToPath(import.meta.url));
// 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 = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(' PASS ' + name);
} catch (e) {
failed++;
failures.push({ name, err: e });
console.log(' FAIL ' + name + ' :: ' + e.message);
}
}
function ok(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
function close(a, b, tol, msg) {
ok(Number.isFinite(a) && Number.isFinite(b), (msg || 'value') + ' not finite: ' + a + ' vs ' + b);
if (Math.abs(a - b) > tol) throw new Error((msg || 'close') + ': ' + a + ' != ' + b + ' (tol ' + tol + ')');
}
function noNaN(state, where) {
const check = (v, ctx) => {
if (v != null && !Number.isFinite(v)) throw new Error(where + ' non-finite at ' + ctx);
};
for (const b of state.birds) { check(b.x, 'bird.x'); check(b.y, 'bird.y'); check(b.vx, 'bird.vx'); check(b.vy, 'bird.vy'); }
for (const bl of state.blocks) { check(bl.x, 'block.x'); check(bl.y, 'block.y'); check(bl.vx, 'block.vx'); check(bl.vy, 'block.vy'); }
for (const e of state.enemies) { check(e.x, 'enemy.x'); check(e.y, 'enemy.y'); check(e.vx, 'enemy.vx'); check(e.vy, 'enemy.vy'); }
}
function simSeconds(state, secs, dt = 1 / 120) {
const n = Math.round(secs / dt);
for (let i = 0; i < n; i++) ISLE.step(state, dt);
}
// A greedy "skilled player" AI: for each living enemy, evaluate candidate
// shots through the REAL physics on a cloned state, pick the shot that
// advances the board (kill the target, or the best damage/position),
// commit it, repeat. Two-stage search: a coarse full-power angle sweep,
// then a fine angle x pull ladder around the best coarse candidates —
// the player can pull at any power and any angle, so a full-power-only
// 2°-grid search models a player much too weakly.
// If a level cannot be cleared this way it is not clearable by a player
// either — the AI sees exactly what the player sees.
function cloneState(s) { return JSON.parse(JSON.stringify(s)); }
function aliveCount(s) { let n = 0; for (const e of s.enemies) if (!e.dead) n++; return n; }
// cheap exposure metric: number of blocks the straight line sling->enemy
// crosses. Fewer = the enemy is more directly shootable.
function lineBlocked(s, e) {
const sx = s.level.sling.x, sy = s.level.sling.y;
let n = 0;
for (const b of s.blocks) {
if (b.hp <= 0) continue;
// slab test of segment against AABB (expanded by nothing; blocks are thin enough)
const dx = e.x - sx, dy = e.y - sy;
const tMinX = (b.x - b.w / 2 - sx) / dx, tMaxX = (b.x + b.w / 2 - sx) / dx;
const tMinY = (b.y - b.h / 2 - sy) / dy, tMaxY = (b.y + b.h / 2 - sy) / dy;
const t0 = Math.max(Math.min(tMinX, tMaxX), Math.min(tMinY, tMaxY));
const t1 = Math.min(Math.max(tMinX, tMaxX), Math.max(tMinY, tMaxY));
if (t1 > Math.max(t0, 0) && t1 < 1) n++;
}
return n;
}
function evalShot(state, deg, pull, targetId) {
const cl = cloneState(state);
const th = deg * Math.PI / 180;
const v = ISLE.MAX_PULL * ISLE.LAUNCH_POWER * pull;
const drag = {
x: cl.level.sling.x - (Math.cos(th) * v) / ISLE.LAUNCH_POWER,
y: cl.level.sling.y + (Math.sin(th) * v) / ISLE.LAUNCH_POWER
};
const bird = cl.birds[cl.activeBird];
if (!bird) return null;
// wait for the previous bird to finish before the next shot (mirrors the
// in-game flow where the next slinger loads once the last one is spent)
let wait = 0;
while (wait < 3 && cl.activeBird >= 0 && cl.birds[cl.activeBird].state === 'flying') {
ISLE.step(cl, 1 / 120);
wait += 1 / 120;
}
const bird2 = cl.birds[cl.activeBird];
if (!ISLE.launch(cl, drag) || !bird2) return null;
simSeconds(cl, 5);
const target = cl.enemies[targetId];
const destroyed = cl.blocks.reduce((a, b) => a + (b.hp <= 0 ? 1 : 0), 0) -
state.blocks.reduce((a, b) => a + (b.hp <= 0 ? 1 : 0), 0);
const score = (target && target.dead ? 1000 : 0) +
(aliveCount(state) - aliveCount(cl)) * 50 + destroyed * 5;
const endDist = Math.hypot(bird2.x - target.x, bird2.y - target.y);
return { score, cl, endDist, destroyed };
}
const PULL_LADDER = [1, 0.85, 0.7, 0.55, 0.4];
function bestShot(state, targetId, opts = {}) {
const scoreOf = (r) => opts.approach
? r.score - r.cl.enemies[targetId].hp * 500 - r.endDist
: r.score;
// coarse pass: 2° steps at full power
const coarse = [];
for (let deg = 4; deg <= 90; deg += 2) {
const r = evalShot(state, deg, 1, targetId);
if (r) coarse.push({ deg, r });
}
coarse.sort((a, b) => scoreOf(b.r) - scoreOf(a.r));
let best = null;
const consider = (r) => {
const s = scoreOf(r);
if (best === null || s > best.s) best = { s, r };
};
// refine the top-3 coarse angles: ±3° at 0.5°, each across the pull
// ladder (players pull partially all the time)
for (const c of coarse.slice(0, 3)) {
for (let deg = c.deg - 3; deg <= c.deg + 3 + 1e-9; deg += 0.5) {
for (const pull of PULL_LADDER) {
const r = evalShot(state, deg, pull, targetId);
if (r) consider(r);
}
}
}
if (best && best.s <= 0 && !opts.allowZero) best = null;
return best ? best.r : null;
}
function clearLevel(levelDef) {
let state = ISLE.createGame(levelDef);
let guard = 0;
while (aliveCount(state) > 0 && ISLE.activeBird(state) && guard++ < 10) {
// aim at the most exposed living enemy (fewest blocks in the line of fire)
let target = null;
let bestBlock = Infinity;
for (const e of state.enemies) {
if (e.dead) continue;
const n = lineBlocked(state, e);
if (n < bestBlock) { bestBlock = n; target = e; }
}
// prefer shots that kill the target; otherwise take the shot that gets
// closest to it (usually breaches the shield in its line of fire).
// Approach scores are negative by construction, so the <= 0 gate only
// applies to real kill/demolition scores.
let kill = bestShot(state, target.id, { allowZero: false });
// a "kill-branch" shot is only worth a bird if it actually advances
// the target or the board — a +5 score from an unrelated block break
// is a wasted bird (this is how L1 used to die: 3 birds spent, the
// mogg left at 0.06 hp for a 4th bird that does not exist)
if (kill) {
const t = kill.cl.enemies[target.id];
const othersKilled = aliveCount(state) - aliveCount(kill.cl) - (t.dead ? 1 : 0);
if (!(t.dead || t.hp < state.enemies[target.id].hp || othersKilled > 0)) kill = null;
}
const best = kill || bestShot(state, target.id, { allowZero: true, approach: true });
if (!best) break;
if (!kill && best.endDist > 300 && best.destroyed === 0) break; // can't even connect
state = best.cl;
}
return state;
}
console.log('\n== ISLE SLINGERS headless sim ==');
// ------------------------------------------------------------------ 1. syntax
test('all JS files parse', () => {
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
}
// 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');
}
});
// --------------------------------------------- 1b. DOM wiring (page level)
// The physics tests import only core/levels and are blind to the page: this
// arm asserts every element id referenced by js/*.js (getElementById and
// querySelector('#id')) exists in index.html. This is the class that killed
// the boot on first browser load (null element -> TypeError -> dead page).
test('DOM wiring: every id referenced by js/ exists in index.html', () => {
const html = readFileSync(join(HERE, '..', 'index.html'), 'utf8');
const ids = new Set();
let m;
const idRe = /id="([^"]+)"/g;
while ((m = idRe.exec(html))) ids.add(m[1]);
const refs = [];
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
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(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();
ok(missing.length === 0, 'ids referenced by js/ but missing from index.html: ' + missing.join(', '));
});
// ---------------------------------------------------------- 2. level integrity
test('level data integrity (5 levels, ordered difficulty)', () => {
ok(LEVELS.length >= 3, 'need at least 3 levels, got ' + LEVELS.length);
for (let i = 0; i < LEVELS.length; i++) {
const lv = LEVELS[i];
ok(lv.birds.length >= 2, `L${lv.id}: needs birds`);
ok(lv.enemies.length >= 2, `L${lv.id}: needs enemies`);
ok(lv.blocks.length >= 3, `L${lv.id}: needs blocks`);
ok(lv.stars.length === 3 && lv.stars[0] < lv.stars[1] < lv.stars[2], `L${lv.id}: star thresholds`);
for (const b of lv.blocks) {
ok(b.x > 200 && b.x < 2380, `L${lv.id} block x in world`);
ok(b.y - b.h / 2 >= 0, `L${lv.id} block above ground plane`);
ok(['wood', 'ice', 'stone'].includes(b.mat), `L${lv.id} block mat`);
}
for (const e of lv.enemies) {
const r = ISLE.ENEMY_TYPES[e.kind].r;
const bottom = e.y + r;
ok(bottom <= 902, `L${lv.id} enemy not below ground (${bottom})`);
// supported: either resting on the ground OR its bottom sits on a flat block top
const onGround = bottom >= 898;
const onBlock = lv.blocks.some((b) => Math.abs((b.y - b.h / 2) - bottom) < 6 &&
e.x > b.x - b.w / 2 - 4 && e.x < b.x + b.w / 2 + 4);
ok(onGround || onBlock, `L${lv.id} enemy unsupported at (${e.x},${e.y}) bottom=${bottom}`);
}
}
// increasing difficulty: total enemy hp should not decrease
const hp = LEVELS.map((l) => l.enemies.reduce((a, e) => a + ISLE.ENEMY_TYPES[e.kind].hp, 0));
for (let i = 1; i < hp.length; i++) ok(hp[i] >= hp[i - 1], 'enemy HP budget should not decrease');
});
// ------------------------------------------------------------- 3. determinism
test('determinism: identical inputs -> identical states', () => {
function run() {
const s = ISLE.createGame(LEVELS[0]);
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = 900; ab.vy = -700; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 6);
return JSON.stringify(s.birds.map((b) => [b.x, b.y, b.state])) + '|' + s.score + '|' +
JSON.stringify(s.blocks.map((b) => [Math.round(b.x), Math.round(b.y), b.hp]));
}
ok(run() === run(), 'two runs diverged');
});
// ------------------------------------------------------- 4. numeric stability
test('stability: 15s of physics after a full-power 45deg shot stays finite', () => {
const s = ISLE.createGame(LEVELS[4]); // heaviest structure
const ab = ISLE.activeBird(s);
const v = ISLE.MAX_PULL * ISLE.LAUNCH_POWER * 0.7;
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = v * Math.cos(Math.PI / 4); ab.vy = -v * Math.sin(Math.PI / 4);
ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 15);
noNaN(s, 'stability');
for (const bl of s.blocks) ok(bl.x >= -1 && bl.x <= 2401, 'block x escaped world: ' + bl.x);
});
// ---------------------------------------------------- 5. trajectory fidelity
test('trajectory preview tracks the real flight (empty air)', () => {
const bare = { ...LEVELS[0], blocks: [], enemies: [], birds: ['wren'] };
const s = ISLE.createGame(bare);
const v = { vx: 1150, vy: -760 };
const pts = ISLE.trajectory(s, s.level.sling.x, s.level.sling.y, v.vx, v.vy, 46);
ok(pts.length >= 40, 'preview too short');
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = v.vx; ab.vy = v.vy; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
// live sim runs at 1/120; preview at 1/60 -> sample live every 2 steps
for (let i = 0; i < pts.length - 1 && i < 40; i++) {
simSeconds(s, 2 / 120);
const p = pts[i];
const d = Math.hypot(ab.x - p.x, ab.y - p.y);
ok(d < 40, `preview drift at step ${i}: ${d.toFixed(1)}px`);
}
});
// ------------------------------------------------------------ 6. abilities
test('abilities: each slinger has a distinct, usable mid-flight move', () => {
const cases = [
{ kind: 'wren', check: (s, ab) => ab.vx > 1200 },
{ kind: 'zippy', check: (s, ab) => ab.vy > 600 },
];
for (const c of cases) {
const s = ISLE.createGame({ ...LEVELS[0], blocks: [], enemies: [], birds: [c.kind] });
const ab = ISLE.activeBird(s);
ab.x = s.level.sling.x; ab.y = s.level.sling.y;
ab.vx = 600; ab.vy = -500; ab.state = 'flying'; ab.launchT = s.time;
s.launched++;
simSeconds(s, 0.4); // past the ability cooldown
ok(ISLE.triggerAbility(s), c.kind + ': ability should trigger');
c.check(s, ab);
ok(!ISLE.triggerAbility(s), c.kind + ': ability is one-shot');
}
// boulder quake damages nearby enemies without a direct hit
const s2 = ISLE.createGame({
...LEVELS[0], blocks: [], birds: ['boulder'],
enemies: [{ x: 700, y: 850, kind: 'mogg' }]
});
const ab2 = ISLE.activeBird(s2);
ab2.x = 600; ab2.y = 800; ab2.vx = 300; ab2.vy = 0; ab2.state = 'flying'; ab2.launchT = s2.time;
s2.launched++;
simSeconds(s2, 0.4);
ISLE.triggerAbility(s2);
ok(s2.enemies[0].hp < 1, 'quake should damage the nearby mogg');
});
// ------------------------------------------------------- 7. rules & scoring
test('win/lose: clearing all enemies wins; running dry loses', () => {
const winLvl = { ...LEVELS[0], blocks: [], enemies: [{ x: 900, y: 876, kind: 'mogg' }], birds: ['wren', 'wren'] };
let s = ISLE.createGame(winLvl);
const winShot = bestShot(s, 0);
ok(winShot, 'win lvl: found a killing shot');
s = winShot.cl;
simSeconds(s, 2);
ok(s.status === 'won', 'should win, got ' + s.status);
ok(s.score >= ISLE.BIRD_BONUS, 'win should include unused-bird bonus (score ' + s.score + ')');
const loseLvl = { ...LEVELS[0], blocks: [], enemies: [{ x: 1400, y: 876, kind: 'king' }], birds: ['wren'] };
let s2 = ISLE.createGame(loseLvl);
// pick the single hardest-hitting angle (max damage to the king)
let hitShot = null;
for (let deg = 4; deg <= 90; deg += 2) {
const r = evalShot(s2, deg, 1, 0);
if (!r) continue;
if (!hitShot || r.cl.enemies[0].hp < hitShot.cl.enemies[0].hp) hitShot = r;
}
ok(hitShot && hitShot.cl.enemies[0].hp < 3, 'lose lvl: shot should connect with the king');
s2 = hitShot.cl;
simSeconds(s2, 2);
ok(s2.status === 'lost', 'king (hp3) should survive one wren and lose the level, got ' + s2.status);
ok(!s2.enemies[0].dead, 'king should survive a single wren hit');
});
// ------------------------------------------------- 8. perfect-archer clears
for (const lv of LEVELS) {
test(`level ${lv.id} "${lv.name}" is clearable by a perfect archer`, () => {
const s = clearLevel(lv);
const alive = s.enemies.filter((e) => !e.dead);
ok(alive.length === 0, 'enemies survived the perfect archer: ' + alive.map((e) => e.kind).join(','));
noNaN(s, `L${lv.id} clear`);
console.log(` (L${lv.id}: score ${s.score}, birds used ${s.launched}/${lv.birds.length}, stars ${ISLE.starsFor(s)}/3)`);
});
}
// --------------------------------- 9. rest & framing (reported bugs #12-13)
// Bug #12: every fort must sit STILL when no one touches it. The original
// box-pair solver pushed resting blocks toward each other instead of apart,
// so each level's structure self-destructed within seconds — "objects
// glitching like crazy, bouncing around on their own." (The pre-fix probe
// measured 21-108px/s positional drift and 34-112px/s block speeds on a
// level with zero input.)
test('fort rest: every level sits still when untouched (15s idle)', () => {
const dt = 1 / 120;
for (const lv of LEVELS) {
const s = ISLE.createGame(lv);
let maxV = 0;
for (let t = 0; t < 15 * 120; t++) {
ISLE.step(s, dt);
if (t % 120 === 119) {
for (const bl of s.blocks) if (bl.hp > 0) maxV = Math.max(maxV, Math.hypot(bl.vx, bl.vy));
for (const e of s.enemies) if (!e.dead) maxV = Math.max(maxV, Math.hypot(e.vx, e.vy));
}
}
ok(maxV < 20, `L${lv.id}: structure still moving at idle (max ${maxV.toFixed(1)} px/s)`);
const p0 = s.blocks.filter((b) => b.hp > 0).map((b) => [b.x, b.y]);
for (let t = 0; t < 120; t++) ISLE.step(s, dt);
let drift = 0;
s.blocks.forEach((b, i) => {
if (b.hp > 0) drift = Math.max(drift, Math.hypot(b.x - p0[i][0], b.y - p0[i][1]));
});
ok(drift < 0.5, `L${lv.id}: structure drifted ${drift.toFixed(2)}px in 1s of idle`);
}
});
// Bug #13: while aiming, the view must contain the sling AND the whole
// action zone. The old camera framed only the sling (fort off-screen) or,
// mid-flight, only the bird (sling off-screen) — no single framing showed
// launch point and target, so "have to guess where to sling the birds."
test('aim framing: sling and full action zone visible while aiming (all levels)', () => {
for (const lv of LEVELS) {
const s = ISLE.createGame(lv);
ISLE.step(s, 1 / 120); // first frame snaps the camera onto its target
const c = s.camera;
const halfW = (ISLE.VIEW.w / 2) / c.zoom;
const actionRight = Math.max(
400,
...s.blocks.filter((b) => b.hp > 0).map((b) => b.x + b.w / 2),
...s.enemies.filter((e) => !e.dead).map((e) => e.x + e.r)
);
ok(c.zoom > 0.3 && c.zoom <= 1, `L${lv.id}: zoom out of range (${c.zoom.toFixed(2)})`);
ok(lv.sling.x >= c.x - halfW && lv.sling.x <= c.x + halfW,
`L${lv.id}: sling (${lv.sling.x}) outside view [${(c.x - halfW).toFixed(0)}, ${(c.x + halfW).toFixed(0)}]`);
ok(actionRight <= c.x + halfW,
`L${lv.id}: action right edge (${actionRight.toFixed(0)}) outside view right (${(c.x + halfW).toFixed(0)})`);
}
});
// ------------------------------------------------------------------ summary
console.log(`\n== ${passed} passed, ${failed} failed ==`);
if (failed) {
console.log('FAILURES:');
for (const f of failures) console.log(' - ' + f.name + ': ' + f.err.message);
process.exit(1);
}

View file

@ -1,96 +0,0 @@
// test/topple.test.mjs -- S-3: board tip/topple physics
// Q1 (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);