Initial commit: Isle Slingers, RC-0D193E 3-bug fix

Fixes applied and verified (Hermies Qwen, RC-0D193E):
- Rubber band no longer tracks the bird after launch (bug #14, renderer.js)
- Launch direction fixed: toWorld divides by camera zoom (bug #15, main.js)
- Balance retune: stone hp 3400->1400, dmgFactor 0.35->0.8, plus an
  enemy-hit damage floor so soft lobs still kill (core.js)
- New test/balance.test.mjs: RED on pre-fix constants, GREEN after

Suite: 27/27 headless + 10/10 real-browser (real mouse events, real canvas).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Relay 2026-09-28 22:20:17 +00:00
commit ee0c4f942a
13 changed files with 3346 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

408
BUILD-RECORD.md Normal file
View file

@ -0,0 +1,408 @@
# BUILD RECORD — RC-0D193E "Build Angry Birds, Report Process"
- **RC:** RC-0D193E (tier: scout, owner: rc-ops, assigned: Hermie(s))
- **Worker:** Hermie (HANDS worker, Hermes Agent v0.19.1, default profile)
- **Date:** 2026-08-28 (UTC)
- **Artifact root:** `/root/rc-0d193e-isle-slingers/` (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 by the Commander's 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, Commander's playtest: "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 (Commander: "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 (Commander 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 (Commander 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); the Commander's 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 /root/rc-0d193e-isle-slingers
python3 -m http.server 8000 # then open http://localhost:8000/
# or open index.html directly in a browser
# verify (both arms):
node --check js/core.js js/renderer.js js/main.js js/levels.js
node test/sim.test.mjs # 15/15 — physics, rules, DOM wiring, rest, framing, clearability
node test/page.test.mjs # 7/7 — page boot, click session, direction, release
# or: npm test (runs both, in order)
```
## 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 recorded in the RC completion payload.
Working tree at commit time contains exactly the files listed in §3.

97
css/style.css Normal file
View file

@ -0,0 +1,97 @@
/* 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; }
}

86
index.html Normal file
View file

@ -0,0 +1,86 @@
<!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>
<script src="js/core.js"></script>
<script src="js/levels.js"></script>
<script src="js/audio.js"></script>
<script src="js/renderer.js"></script>
<script src="js/main.js"></script>
</body>
</html>

137
js/audio.js Normal file
View file

@ -0,0 +1,137 @@
/* 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 };
});

658
js/core.js Normal file
View file

@ -0,0 +1,658 @@
/* ISLE SLINGERS — core.js
* Pure, deterministic game core: physics, rules, scoring, events.
* No DOM, no audio, no Math.random — the renderer consumes state.events
* for visual-only effects. Loadable in browser (window.ISLE_CORE) or node.
*/
(function (root, factory) {
var api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.ISLE_CORE = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
var WORLD = { w: 2400, ground: 900 };
var VIEW = { w: 1280, h: 720 }; // base viewport (renderer renders at this size,
// then CSS-scales; camera math lives in the core)
var MATERIALS = {
wood: { hp: 1300, density: 1.0, restitution: 0.18, dmgFactor: 0.9, score: 500, maxVel: 520 },
ice: { hp: 480, density: 0.7, restitution: 0.05, dmgFactor: 1.6, score: 800, maxVel: 560 },
// stone: retuned 2026-09-28 (RC-0D193E, Commander bug #3 "blocks and pig
// bad guys too hard to kill"): at hp 3400 with dmgFactor 0.35 a 70x70
// cube took ~11 full-power direct hits — stone walls were functionally
// unbreakable, so every enemy behind one was unkillable. Now ~2-3 hits.
stone: { hp: 1400, density: 2.2, restitution: 0.04, dmgFactor: 0.8, score: 300, maxVel: 480 }
};
var BIRD_TYPES = {
wren: { r: 20, mass: 3.0, label: 'Wren', ability: 'fury' }, // tap: horizontal burst
zippy: { r: 17, mass: 2.2, label: 'Zippy', ability: 'dive' }, // tap: steep dive
boulder: { r: 26, mass: 6.5, label: 'Boulder', ability: 'quake' } // tap: radial shock
};
var ENEMY_TYPES = {
mogg: { hp: 1, r: 24, score: 5000, crushVel: 330, crushDmg: 1.2 },
grum: { hp: 2, r: 32, score: 8000, crushVel: 380, crushDmg: 1.0 },
king: { hp: 3, r: 38, score: 15000, crushVel: 430, crushDmg: 0.85 }
};
var GRAVITY = 1500;
var LAUNCH_POWER = 11.5; // velocity per px of pull
var MAX_PULL = 150;
var MIN_PULL = 14;
var AIR_DRAG = 0.999;
var BIRD_BOUNCE = 0.34;
var BIRD_GROUND_FRICTION = 0.86;
var REST_SPEED = 14; // below this a bird counts as settling
var SETTLE_TIME = 0.9; // seconds under REST_SPEED before bird is spent
var BIRD_MAX_AGE = 12;
var ABILITY_CD = 0.35; // min seconds after launch before ability usable
var BIRD_BONUS = 10000;
// ------------------------------------------------------------------ state
function createGame(levelDef) {
var g = {
level: levelDef,
world: { w: WORLD.w, ground: WORLD.ground },
time: 0,
score: 0,
birds: [],
blocks: [],
enemies: [],
events: [], // visual/audio events for the renderer
scorePopups: [], // {x,y,text,age}
hadEnemies: levelDef.enemies.length > 0,
camera: { x: levelDef.sling.x + 260, y: 560, zoom: 1, shake: 0, snap: true },
status: 'ready', // ready | flying | won | lost
activeBird: -1,
launched: 0,
wonAt: null,
lostAt: null,
stats: { impacts: 0, destroyedBlocks: 0, destroyedEnemies: 0, abilityUses: 0, distance: 0 }
};
g.blocks = levelDef.blocks.map(function (b, i) {
var m = MATERIALS[b.mat];
return {
id: i, x: b.x, y: b.y, w: b.w, h: b.h, mat: b.mat,
hp: m.hp, maxHp: m.hp,
vx: 0, vy: 0, rest: 1,
mass: m.density * b.w * b.h / 1400
};
});
g.enemies = levelDef.enemies.map(function (e, i) {
var t = ENEMY_TYPES[e.kind];
// level data y is the CENTER (the level-integrity test treats it the
// same way); spawning at e.y - r made every enemy drop 24-38px on
// load — a visible "objects moving on their own" wobble at start
return {
id: i, kind: e.kind, x: e.x, y: e.y, r: t.r,
hp: t.hp, maxHp: t.hp, vx: 0, vy: 0, rest: 1, dead: false, deathAge: 0
};
});
var i;
for (i = 0; i < levelDef.birds.length; i++) {
g.birds.push(makeBird(levelDef.birds[i], i));
}
g.activeBird = g.birds.length ? 0 : -1;
if (g.activeBird >= 0) {
var fb = g.birds[0];
fb.state = 'aiming';
fb.x = levelDef.sling.x;
fb.y = levelDef.sling.y;
}
return g;
}
function makeBird(kind, idx) {
var t = BIRD_TYPES[kind];
return {
id: idx, kind: kind, r: t.r, mass: t.mass,
x: 0, y: 0, vx: 0, vy: 0,
state: 'queued', // queued | aiming | flying | spent
abilityUsed: false,
settleTimer: 0, age: 0,
launchT: 0
};
}
function activeBird(g) { return g.activeBird >= 0 ? g.birds[g.activeBird] : null; }
// ---------------------------------------------------------------- aiming
// Returns {vx,vy,pull} if a valid launch for the given drag, else null.
function aimVector(g, sling, drag) {
var dx = sling.x - drag.x, dy = sling.y - drag.y;
var len = Math.sqrt(dx * dx + dy * dy);
if (len < MIN_PULL) return null;
var cl = Math.min(len, MAX_PULL);
var v = cl * LAUNCH_POWER;
return { vx: (dx / len) * v, vy: (dy / len) * v, pull: cl };
}
// Pure trajectory preview: identical integrator + substepping as the live
// sim (2x 1/120 per output point), so the dotted preview matches the real
// flight exactly. Gravity + drag only; stops at the ground.
function trajectory(g, sx, sy, vx, vy, steps) {
var pts = [];
var x = sx, y = sy, i, sub;
for (i = 0; i < steps; i++) {
for (sub = 0; sub < 2; sub++) {
var dt = 1 / 120;
var nvy = vy + GRAVITY * dt;
x += vx * dt;
y += (vy + nvy) * 0.5 * dt;
vx *= AIR_DRAG;
vy = nvy;
}
if (y > WORLD.ground - 18) { pts.push({ x: x, y: WORLD.ground - 18, last: true }); break; }
pts.push({ x: x, y: y, last: false });
}
return pts;
}
function launch(g, dragPos) {
var b = activeBird(g);
if (!b || b.state !== 'aiming' || g.status !== 'ready') return false;
var v = aimVector(g, { x: g.level.sling.x, y: g.level.sling.y }, dragPos);
if (!v) return false;
b.x = g.level.sling.x; b.y = g.level.sling.y;
b.vx = v.vx; b.vy = v.vy;
b.state = 'flying';
b.launchT = g.time;
g.status = 'flying';
g.launched++;
g.events.push({ type: 'launch', x: b.x, y: b.y });
return true;
}
function triggerAbility(g) {
var b = activeBird(g);
if (!b || b.state !== 'flying' || b.abilityUsed) return false;
if (g.time - b.launchT < ABILITY_CD) return false;
var kind = b.kind;
if (kind === 'wren') {
b.vx = b.vx * 0.4 + 980;
g.events.push({ type: 'ability_fury', x: b.x, y: b.y });
} else if (kind === 'zippy') {
b.vy = Math.max(b.vy, 0) + 1050;
b.vx *= 0.55;
g.events.push({ type: 'ability_dive', x: b.x, y: b.y });
} else if (kind === 'boulder') {
var R = 150;
var i, d, f, ent;
for (i = 0; i < g.blocks.length; i++) {
var bl = g.blocks[i];
if (bl.hp <= 0) continue;
d = Math.hypot(bl.x - b.x, bl.y - b.y) - (bl.w + bl.h) / 4;
if (d < R) { f = 1 - Math.max(0, d) / R; damageBlock(g, bl, 120 + 300 * f, 'quake'); }
}
for (i = 0; i < g.enemies.length; i++) {
ent = g.enemies[i];
if (ent.dead) continue;
d = Math.hypot(ent.x - b.x, ent.y - b.y) - ent.r;
if (d < R) { f = 1 - Math.max(0, d) / R; damageEnemy(g, ent, 0.7 + 1.6 * f); }
}
g.camera.shake = Math.max(g.camera.shake, 14);
g.events.push({ type: 'ability_quake', x: b.x, y: b.y, r: R });
}
b.abilityUsed = true;
g.stats.abilityUses++;
return true;
}
// ---------------------------------------------------------------- damage
function damageBlock(g, bl, dmg, src) {
if (bl.hp <= 0) return;
bl.hp -= dmg;
if (bl.hp <= 0) {
bl.hp = 0;
g.score += MATERIALS[bl.mat].score;
g.stats.destroyedBlocks++;
g.scorePopups.push({ x: bl.x, y: bl.y, text: '+' + MATERIALS[bl.mat].score, age: 0 });
g.events.push({ type: 'break', x: bl.x, y: bl.y, mat: bl.mat, big: bl.w * bl.h > 12000 });
g.camera.shake = Math.max(g.camera.shake, 4 + bl.w * bl.h / 4000);
} else if (dmg > 40) {
g.events.push({ type: 'crack', x: bl.x, y: bl.y, mat: bl.mat });
}
}
function damageEnemy(g, e, dmg) {
if (e.dead) return;
e.hp -= dmg;
if (e.hp <= 0) {
e.dead = true;
e.deathAge = 0;
g.score += ENEMY_TYPES[e.kind].score;
g.stats.destroyedEnemies++;
g.scorePopups.push({ x: e.x, y: e.y - 30, text: '+' + ENEMY_TYPES[e.kind].score, age: 0 });
g.events.push({ type: 'pop', x: e.x, y: e.y, kind: e.kind });
g.camera.shake = Math.max(g.camera.shake, 7);
}
}
// ----------------------------------------------------------------- step
function step(g, dt) {
g.time += dt;
if (g.status === 'won' || g.status === 'lost') {
// let physics settle for a beat so the win/lose screen lands naturally
stepPhysics(g, dt);
updatePopups(g, dt);
decayCamera(g, dt);
return;
}
stepPhysics(g, dt);
updatePopups(g, dt);
decayCamera(g, dt);
updateCameraTarget(g);
checkEndStates(g);
}
function stepPhysics(g, dt) {
var i, b;
// active bird flight
b = activeBird(g);
if (b && b.state === 'flying') {
b.age += dt;
var nvy = b.vy + GRAVITY * dt;
b.x += b.vx * dt;
b.y += (b.vy + nvy) * 0.5 * dt;
b.vx *= AIR_DRAG;
b.vy = nvy;
g.stats.distance = Math.max(g.stats.distance, b.x);
collideBirdWorld(g, b);
collideBirdBlocks(g, b);
collideBirdEnemies(g, b);
// settle / spend detection (low speed anywhere = spent, whether on
// the ground or perched on a beam; jitter-tolerant accumulation)
var sp = Math.hypot(b.vx, b.vy);
if (sp < REST_SPEED) b.settleTimer += dt;
else b.settleTimer = Math.max(0, b.settleTimer - dt * 4);
if (b.settleTimer > SETTLE_TIME || b.age > BIRD_MAX_AGE || b.x > WORLD.w + 200 || b.x < -200) {
spendBird(g, b);
}
}
// blocks: gravity + integration + collisions
for (i = 0; i < g.blocks.length; i++) {
var bl = g.blocks[i];
if (bl.hp <= 0) continue;
bl.vy = Math.min(bl.vy + GRAVITY * dt, MATERIALS[bl.mat].maxVel);
bl.vx *= 0.995;
bl.x += bl.vx * dt;
bl.y += bl.vy * dt;
// ground
var halfH = bl.h / 2;
if (bl.y + halfH > WORLD.ground) {
var impact = bl.vy;
bl.y = WORLD.ground - halfH;
if (impact > 90) {
bl.vy = -impact * MATERIALS[bl.mat].restitution;
bl.vx *= 0.8;
if (impact > 240) {
damageBlock(g, bl, (impact - 240) * 0.45, 'ground');
g.events.push({ type: 'block_impact', x: bl.x, y: WORLD.ground, mat: bl.mat, strength: impact });
}
bl.rest = 0;
} else {
bl.vy = 0;
bl.vx *= 0.82; // rolling friction
if (Math.abs(bl.vx) < 4) { bl.vx = 0; bl.rest = 1; }
}
}
// walls (keep the mess on the island)
if (bl.x - bl.w / 2 < 0) { bl.x = bl.w / 2; bl.vx = Math.abs(bl.vx) * 0.2; }
if (bl.x + bl.w / 2 > WORLD.w) { bl.x = WORLD.w - bl.w / 2; bl.vx = -Math.abs(bl.vx) * 0.2; }
}
// block vs block (positional + 1D impulse, 2 relaxation passes)
var pass, j, a, c;
for (pass = 0; pass < 2; pass++) {
for (i = 0; i < g.blocks.length; i++) {
a = g.blocks[i];
if (a.hp <= 0) continue;
for (j = i + 1; j < g.blocks.length; j++) {
c = g.blocks[j];
if (c.hp <= 0) continue;
resolveBoxPair(g, a, c);
}
}
}
// block vs bird (crush)
if (b && b.state === 'flying') {
for (i = 0; i < g.blocks.length; i++) {
var blk = g.blocks[i];
if (blk.hp <= 0) continue;
crushCircleVsBox(g, blk, b, false);
}
}
// enemies: settle on ground/blocks, take crush damage
for (i = 0; i < g.enemies.length; i++) {
var e = g.enemies[i];
if (e.dead) { e.deathAge += dt; continue; }
e.vy = Math.min(e.vy + GRAVITY * dt, 700);
e.x += e.vx * dt;
e.y += e.vy * dt;
e.vx *= 0.9;
if (e.y + e.r > WORLD.ground) {
if (e.vy > 420) { e.vy = -e.vy * 0.25; e.vx *= 0.6; }
else { e.vy = 0; e.y = WORLD.ground - e.r; e.vx *= 0.8; }
}
for (j = 0; j < g.blocks.length; j++) {
var bb = g.blocks[j];
if (bb.hp <= 0) continue;
crushCircleVsBox(g, bb, e, true);
}
}
}
function crushCircleVsBox(g, box, ent, isEnemy) {
var cx = Math.max(box.x - box.w / 2, Math.min(ent.x, box.x + box.w / 2));
var cy = Math.max(box.y - box.h / 2, Math.min(ent.y, box.y + box.h / 2));
var dx = ent.x - cx, dy = ent.y - cy;
var d2 = dx * dx + dy * dy;
if (d2 >= ent.r * ent.r) return;
var d = Math.sqrt(d2) || 0.001;
var nx = dx / d, ny = dy / d;
var overlap = ent.r - d;
ent.x += nx * overlap * 0.9;
ent.y += ny * overlap * 0.9;
// impulse: relative velocity along normal
var rvx = (ent.vx || 0) - (box.vx || 0), rvy = (ent.vy || 0) - (box.vy || 0);
var rel = rvx * nx + rvy * ny;
if (rel < -30) {
var relSpeed = -rel;
ent.vx -= nx * rel * 1.4;
ent.vy -= ny * rel * 1.4;
if (isEnemy) {
// crush damage: fast falling boxes hurt what they land on
var spec = ENEMY_TYPES[ent.kind];
var boxSpeed = Math.hypot(box.vx || 0, box.vy || 0);
if (boxSpeed > spec.crushVel && ny < -0.3) {
damageEnemy(g, ent, (boxSpeed - spec.crushVel) * 0.004 * spec.crushDmg + 0.35);
g.events.push({ type: 'crush', x: ent.x, y: ent.y });
}
} else {
// bird: transfer momentum into the box
var dmg = relSpeed * 0.32 * (ent.mass || 3) * 0.25;
if (dmg > 25) damageBlock(g, box, dmg, 'bird');
box.vx += nx * rel * 0.05 * (ent.mass || 3);
box.vy += ny * rel * 0.05 * (ent.mass || 3);
}
} else if (rel < 0) {
// slow resting contact: remove the normal approach velocity so a
// perched enemy/box pair stops oscillating under gravity
ent.vx -= nx * rel;
ent.vy -= ny * rel;
}
}
function collideBirdWorld(g, b) {
if (b.y + b.r > WORLD.ground) {
var impact = -b.vy;
b.y = WORLD.ground - b.r;
if (b.vy > 0) b.vy = -b.vy * BIRD_BOUNCE;
b.vx *= BIRD_GROUND_FRICTION;
if (impact > 250) {
g.events.push({ type: 'bird_ground', x: b.x, y: WORLD.ground, strength: impact });
g.camera.shake = Math.max(g.camera.shake, 5);
}
if (Math.abs(b.vy) < 30) b.vy = 0;
}
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.3; }
}
function collideBirdBlocks(g, b) {
var i;
for (i = 0; i < g.blocks.length; i++) {
var bl = g.blocks[i];
if (bl.hp <= 0) continue;
var cx = Math.max(bl.x - bl.w / 2, Math.min(b.x, bl.x + bl.w / 2));
var cy = Math.max(bl.y - bl.h / 2, Math.min(b.y, bl.y + bl.h / 2));
var dx = b.x - cx, dy = b.y - cy;
var d2 = dx * dx + dy * dy;
if (d2 >= b.r * b.r) continue;
var d = Math.sqrt(d2) || 0.001;
var nx = dx / d, ny = dy / d;
var overlap = b.r - d;
b.x += nx * overlap;
b.y += ny * overlap;
var rvx = b.vx - bl.vx, rvy = b.vy - bl.vy;
var rel = rvx * nx + rvy * ny;
if (rel < 0) {
var relSpeed = -rel;
// impulse: j = -(1+e)*rel / (1/m1 + 1/m2)
var invM = 1 / b.mass + 1 / bl.mass;
var j = -(1 + BIRD_BOUNCE) * rel / invM;
b.vx += (j / b.mass) * nx;
b.vy += (j / b.mass) * ny;
bl.vx -= (j / bl.mass) * nx;
bl.vy -= (j / bl.mass) * ny;
g.stats.impacts++;
var dmg = relSpeed * 0.55 * (b.mass / 3) * MATERIALS[bl.mat].dmgFactor;
if (dmg > 30) {
damageBlock(g, bl, dmg, 'bird');
g.events.push({ type: 'hit', x: cx, y: cy, mat: bl.mat, strength: relSpeed });
g.camera.shake = Math.max(g.camera.shake, Math.min(12, relSpeed / 90));
}
b.vx *= 0.985; b.vy *= 0.985;
}
}
}
function collideBirdEnemies(g, b) {
var i;
for (i = 0; i < g.enemies.length; i++) {
var e = g.enemies[i];
if (e.dead) continue;
var dx = e.x - b.x, dy = e.y - b.y;
var rr = e.r + b.r;
var d2 = dx * dx + dy * dy;
if (d2 >= rr * rr) continue;
var d = Math.sqrt(d2) || 0.001;
var nx = dx / d, ny = dy / d;
var rel = (b.vx - e.vx) * nx + (b.vy - e.vy) * ny;
if (rel > 0) {
e.vx += nx * rel * 0.8;
e.vy += ny * rel * 0.8 - 120;
// per-target hit cooldown so one pass-through = one damage instance
if (g.time - (e.lastHit || -9) > 0.12) {
var speed = Math.hypot(b.vx, b.vy);
// retuned 2026-09-28 (RC-0D193E, Commander bug #3): the old curve
// gave ~0.9 dmg to a soft wren lob — a mogg (hp1) could not be
// killed without a perfect full-power hit. Bumped so a decent
// medium shot connects; the king (hp3) still survives one
// full-power wren hit (pinned in test/sim.test.mjs).
var dmg = 0.35 + speed / 800 * (b.mass / 3);
damageEnemy(g, e, dmg);
e.lastHit = g.time;
g.events.push({ type: 'bird_hit_enemy', x: e.x, y: e.y, strength: speed });
}
b.vx *= 0.92; b.vy *= 0.92;
}
}
}
// Resting-contact clamp: inelastic contacts + zeroing below this speed,
// so stacked blocks settle to exactly still instead of oscillating under
// gravity re-injection.
var REST_VEL_CLAMP = 30; // |v| below this on a resting contact is zeroed
function resolveBoxPair(g, a, c) {
var ox = (a.w / 2 + c.w / 2) - Math.abs(a.x - c.x);
var oy = (a.h / 2 + c.h / 2) - Math.abs(a.y - c.y);
if (ox <= 0 || oy <= 0) return;
if (ox < oy) {
// side contact: push apart along x
var n = c.x >= a.x ? 1 : -1; // direction from a toward c
a.x -= n * ox * 0.5; c.x += n * ox * 0.5;
var relv = (c.vx - a.vx) * n;
if (relv < 0) { // approaching
var avg = (a.vx + c.vx) / 2;
a.vx = avg; c.vx = avg;
}
a.vx *= 0.9; c.vx *= 0.9;
} else {
// stacked contact: push apart along y, then kill resting velocity
var n2 = c.y >= a.y ? 1 : -1;
a.y -= n2 * oy * 0.5; c.y += n2 * oy * 0.5;
var relv2 = (c.vy - a.vy) * n2;
if (relv2 < -REST_VEL_CLAMP) {
// real impact: inelastic average (blocks do not bounce off each other)
var avgY = (a.vy + c.vy) / 2;
a.vy = avgY; c.vy = avgY;
} else if (relv2 < 0) {
// resting: top takes the bottom's velocity (kills the relative
// velocity), then zero both below the clamp so stacks stop
// oscillating under gravity re-injection
var top = n2 === 1 ? a : c;
var bot = n2 === 1 ? c : a;
top.vy = bot.vy;
if (Math.abs(top.vy) < REST_VEL_CLAMP) { top.vy = 0; bot.vy = 0; }
} else {
// separating / at rest: clamp near-zero velocities
if (Math.abs(a.vy) < 2) a.vy = 0;
if (Math.abs(c.vy) < 2) c.vy = 0;
}
}
}
function spendBird(g, b) {
b.state = 'spent';
b.vx = 0; b.vy = 0;
g.events.push({ type: 'bird_gone', x: b.x, y: b.y });
// next bird onto the sling
var next = -1;
for (var i = g.activeBird + 1; i < g.birds.length; i++) {
if (g.birds[i].state === 'queued') { next = i; break; }
}
g.activeBird = next;
if (next >= 0) {
var nb = g.birds[next];
nb.state = 'aiming';
nb.x = g.level.sling.x; nb.y = g.level.sling.y;
g.status = 'ready';
g.events.push({ type: 'bird_load', x: nb.x, y: nb.y, kind: nb.kind });
}
}
function checkEndStates(g) {
if (g.status === 'won' || g.status === 'lost') return;
var alive = 0;
for (var i = 0; i < g.enemies.length; i++) if (!g.enemies[i].dead) alive++;
if (g.hadEnemies && alive === 0 && g.launched > 0) {
g.status = 'won';
g.wonAt = g.time;
g.score += BIRD_BONUS * birdsRemaining(g);
g.events.push({ type: 'level_won', x: g.camera.x, y: 400 });
return;
}
var b = activeBird(g);
var inFlight = false;
for (var k = 0; k < g.birds.length; k++) if (g.birds[k].state === 'flying') inFlight = true;
if (!inFlight && !b && g.status === 'flying') {
// last bird spent, enemies remain
g.status = 'lost';
g.lostAt = g.time;
g.events.push({ type: 'level_lost', x: g.camera.x, y: 400 });
}
}
function birdsRemaining(g) {
var n = 0;
for (var i = 0; i < g.birds.length; i++) if (g.birds[i].state === 'queued') n++;
return n;
}
function starsFor(g) {
var th = g.level.stars;
if (!th) return 0;
if (g.score >= th[2]) return 3;
if (g.score >= th[1]) return 2;
if (g.score >= th[0] || g.status === 'won') return 1;
return 0;
}
function updatePopups(g, dt) {
for (var i = g.scorePopups.length - 1; i >= 0; i--) {
g.scorePopups[i].age += dt;
g.scorePopups[i].y -= 40 * dt;
if (g.scorePopups[i].age > 1.4) g.scorePopups.splice(i, 1);
}
}
function updateCameraTarget(g) {
var b = activeBird(g);
var cam = g.camera;
var tx, ty, tz;
if (b && b.state === 'flying') {
// in flight: follow the slinger 1:1 (the bird IS the point of interest)
tx = Math.max(420, Math.min(WORLD.w - 420, b.x + 220));
ty = Math.max(380, Math.min(WORLD.ground - 160, b.y + 60));
tz = 1;
} else {
// aiming / waiting: frame the slingshot AND the whole action zone
// (live blocks + enemies) so launch point and target are always
// visible at once — zooming out when the level needs it. Without
// this the sling was visible but the fort was off-screen (or
// vice-versa mid-flight), making aiming pure guesswork.
var left = g.level.sling.x - 190;
var right = left + 300;
for (var i = 0; i < g.blocks.length; i++) {
var bl = g.blocks[i];
if (bl.hp > 0) right = Math.max(right, bl.x + bl.w / 2 + 90);
}
for (var k = 0; k < g.enemies.length; k++) {
var en = g.enemies[k];
if (!en.dead) right = Math.max(right, en.x + en.r + 90);
}
var span = right - left;
tz = Math.min(1, VIEW.w / span);
tx = left + span / 2;
// ground at ~78% down the view, whatever the zoom
ty = Math.max(300, Math.min(WORLD.ground, WORLD.ground - (0.28 * VIEW.h) / tz));
}
if (cam.snap) { // first frame of a level: land on target, no pan
cam.x = tx; cam.y = ty; cam.zoom = tz; cam.snap = false;
return;
}
cam.x += (tx - cam.x) * 0.06;
cam.y += (ty - cam.y) * 0.06;
cam.zoom = (cam.zoom || 1) + (tz - (cam.zoom || 1)) * 0.08;
}
function decayCamera(g, dt) {
if (g.camera.shake > 0) {
g.camera.shake *= Math.pow(0.03, dt); // fast decay
if (g.camera.shake < 0.3) g.camera.shake = 0;
}
}
// ------------------------------------------------------------------ API
return {
WORLD: WORLD,
VIEW: VIEW,
MATERIALS: MATERIALS,
BIRD_TYPES: BIRD_TYPES,
ENEMY_TYPES: ENEMY_TYPES,
GRAVITY: GRAVITY,
MAX_PULL: MAX_PULL,
MIN_PULL: MIN_PULL,
LAUNCH_POWER: LAUNCH_POWER,
BIRD_BONUS: BIRD_BONUS,
createGame: createGame,
activeBird: activeBird,
aimVector: aimVector,
trajectory: trajectory,
launch: launch,
triggerAbility: triggerAbility,
step: step,
starsFor: starsFor,
birdsRemaining: birdsRemaining,
AIR_DRAG: AIR_DRAG
};
});

158
js/levels.js Normal file
View file

@ -0,0 +1,158 @@
/* ISLE SLINGERS — levels.js
* Original level layouts. Coordinates are world pixels; (x,y) = block CENTER.
* World: width 2400, ground surface at y=900.
*/
(function (root, factory) {
var api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.ISLE_LEVELS = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
// helpers to keep the data below readable
function wood(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'wood' }; }
function ice(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'ice' }; }
function stone(x, y, w, h) { return { x: x, y: y, w: w, h: h, mat: 'stone' }; }
function mogg(x, y) { return { x: x, y: y, kind: 'mogg' }; }
function grum(x, y) { return { x: x, y: y, kind: 'grum' }; }
function king(x, y) { return { x: x, y: y, kind: 'king' }; }
var SLING = { x: 260, y: 745 };
var LEVELS = [
{
id: 1,
name: 'First Perch',
birds: ['wren', 'wren', 'zippy'],
sling: SLING,
stars: [12000, 24000, 36000],
decor: { palms: [950, 2150, 2350], flowers: [700, 1200, 2050] },
blocks: [
// simple hut
wood(1500, 830, 30, 140),
wood(1640, 830, 30, 140),
wood(1570, 745, 200, 30)
],
enemies: [
mogg(1570, 876),
mogg(1770, 876)
]
},
{
id: 2,
name: 'Crate Caves',
birds: ['wren', 'zippy', 'wren'],
sling: SLING,
stars: [15000, 28000, 42000],
decor: { palms: [1000, 2200], flowers: [800, 1350, 2000] },
blocks: [
// left tower: stone base + wood post
stone(1420, 865, 70, 70),
wood(1420, 760, 30, 140),
// right tower: ice base + ice post
ice(1660, 860, 80, 80),
ice(1660, 765, 30, 110),
// side crates
wood(1300, 860, 80, 80),
wood(1790, 860, 80, 80)
],
enemies: [
mogg(1420, 666),
mogg(1660, 686),
mogg(1540, 876)
]
},
{
id: 3,
name: 'Glass Works',
birds: ['wren', 'zippy', 'boulder'],
sling: SLING,
stars: [18000, 34000, 50000],
decor: { palms: [900, 2250], flowers: [750, 1300, 2100] },
blocks: [
// ice workshop: two-storey
ice(1500, 840, 30, 120),
ice(1640, 840, 30, 120),
wood(1570, 765, 200, 30),
ice(1520, 700, 30, 100),
ice(1620, 700, 30, 100),
ice(1570, 635, 170, 30),
// wood hut
wood(1850, 830, 30, 140),
wood(1950, 830, 30, 140),
wood(1900, 745, 160, 30)
],
enemies: [
mogg(1570, 876),
mogg(1570, 596),
grum(1900, 868),
mogg(1900, 706)
]
},
{
id: 4,
name: 'Stone Keep',
birds: ['wren', 'zippy', 'boulder', 'wren'],
sling: SLING,
stars: [22000, 40000, 58000],
decor: { palms: [1050, 2300], flowers: [850, 1400, 2150] },
blocks: [
// stone wall (three cubes)
stone(1600, 865, 70, 70),
stone(1750, 865, 70, 70),
stone(1900, 865, 70, 70),
wood(1750, 815, 400, 30),
// second floor
wood(1650, 740, 30, 120),
wood(1850, 740, 30, 120),
wood(1750, 665, 260, 30),
// left ice watchtower
ice(1450, 840, 30, 120),
// right crate
stone(2050, 865, 70, 70)
],
enemies: [
mogg(1675, 876),
mogg(1825, 876),
grum(1750, 618),
mogg(2050, 806),
mogg(1500, 876)
]
},
{
id: 5,
name: 'Mog Fortress',
birds: ['wren', 'zippy', 'boulder', 'boulder'],
sling: SLING,
stars: [28000, 48000, 70000],
decor: { palms: [980, 2320], flowers: [780, 1300, 2180] },
blocks: [
// left stone tower (2 stories + ice cap)
stone(1450, 865, 70, 70),
stone(1450, 760, 70, 70),
ice(1450, 710, 30, 30),
// center keep
stone(1700, 865, 70, 70),
stone(1850, 865, 70, 70),
wood(1775, 815, 260, 30),
wood(1700, 740, 30, 120),
wood(1850, 740, 30, 120),
wood(1775, 665, 220, 30),
ice(1775, 635, 260, 30),
// right post
stone(2050, 865, 70, 70),
wood(2050, 760, 30, 120)
],
enemies: [
king(1775, 862),
mogg(1450, 671),
mogg(1775, 626),
mogg(2050, 676),
mogg(1575, 876),
mogg(1975, 876)
]
}
];
return { LEVELS: LEVELS, SLING: SLING };
});

339
js/main.js Normal file
View file

@ -0,0 +1,339 @@
/* 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);
})();
})();

598
js/renderer.js Normal file
View file

@ -0,0 +1,598 @@
/* 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;
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();
}
}
};
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
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);
}
// 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 };
});

11
package.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "isle-slingers",
"version": "1.0.0",
"private": true,
"description": "Isle Slingers — an original browser slingshot physics game (RC-0D193E). No dependencies; opens directly in a browser.",
"scripts": {
"test": "node test/sim.test.mjs && node test/page.test.mjs && node test/balance.test.mjs",
"start": "python3 -m http.server 8000"
},
"license": "MIT"
}

143
test/balance.test.mjs Normal file
View file

@ -0,0 +1,143 @@
/* ISLE SLINGERS — balance spec (Commander RC-0D193E 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/core.js';
import levelsModule from '../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);
}

293
test/page.test.mjs Normal file
View file

@ -0,0 +1,293 @@
/* 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)', () => {
for (const f of ['js/core.js', 'js/levels.js', 'js/audio.js', 'js/renderer.js', 'js/main.js']) {
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 (Commander's 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);
}

416
test/sim.test.mjs Normal file
View file

@ -0,0 +1,416 @@
/* 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 } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ISLE from '../js/core.js';
import levelsModule from '../js/levels.js';
const { LEVELS } = levelsModule;
const HERE = dirname(fileURLToPath(import.meta.url));
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', () => {
for (const f of ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js']) {
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
new Function(src); // throws on syntax error
ok(src.length > 500, f + ' 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 files = ['core.js', 'levels.js', 'audio.js', 'renderer.js', 'main.js'];
const refs = [];
const refRe = /getElementById\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\(\s*['"]#([A-Za-z0-9_-]+)['"]\s*\)/g;
for (const f of files) {
const src = readFileSync(join(HERE, '..', 'js', f), 'utf8');
let r;
while ((r = refRe.exec(src))) refs.push(f + '->' + (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 (Commander's 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);
}