Delivered by Hermies Qwen, gated and deployed by Relay. - Board tip/topple: full continuous rotation when a board's center of mass passes its support edge (Q1: smooth pivot swing, no discrete snap). Falling boards deal impact damage and can kill enemies in their fall path. - Falling-object impact damage: blocks and birds can now kill enemies from vertical drops and side plows (velocity-aware crush gate), chain momentum transfers correctly through stacks (mass-weighted contact), repeated impacts finish tougher enemies. - Landed-bird physics: a bird resting on a board remains a real physical body under continuous gravity (Q2: no lifetime timer). If its platform is destroyed it falls, damages what it lands on (chain reaction), and can kill enemies. - Repo modularized: folders = modules, subfolders = submodules, architecture.md + MODULES.md at root, an agents.md card in every module and submodule (17 total). - Test suite grown from 27 to 47 (20 new RED-watched tests for the above behaviors), all green. Golden differential against the original core confirmed the only behavioral changes are the intended ones. Live at http://10.244.136.121:8093/, independently re-verified by Relay (full suite re-run, module structure check, headless Chromium smoke) before this push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
/* 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);
|
|
})();
|
|
})();
|