/* 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); })(); })();