/* 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 =
'
' + lv.id + '
' +
'' + lv.name + '
' +
'' + starRow(progress.stars[lv.id] || 0) + '
';
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 += '★';
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 += '';
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);
})();
})();