'use strict'; /* ============================================================ NEON SURVIVORS — game.js : Game class, loop, states, input ============================================================ */ const ST = { MENU: 'menu', RUN: 'run', PAUSE: 'pause', LEVEL: 'level', CHEST: 'chest', OVER: 'over' }; class Game { constructor(canvas) { this.cv = canvas; this.ctx = canvas.getContext('2d'); this.dpr = Math.min(window.devicePixelRatio || 1, 2); this.state = ST.MENU; // world this.player = null; this.enemies = []; this.projs = []; this.ebullets = []; this.pickups = []; this.parts = []; this.partPool = []; this.texts = []; this.effects = []; this.grid = new SpatialGrid(96); this.cam = { x: 0, y: 0 }; this.time = 0; this.lastDt = 0.016; this.shake = 0; this.hurtFlash = 0; this.partCap = 600; this.over = false; this.playerHidden = false; // flow this.queueLevelUps = 0; this.pendingChests = 0; this.currentChoices = []; this.rerollsLeft = 0; this.goldEarned = 0; this.charId = 'kaito'; this._screenFrom = 'scr-menu'; // developer mode (session-scoped) this.timeScale = 1; this.devGod = false; this.devOneHit = false; this.freezeT = 0; // hit-stop / slow-mo on big kills // director state this.spawnAcc = 0; this.lastBurstMinute = -1; this.eliteIdx = 0; this.bossIdx = 0; this.boss = null; // input this.keys = new Set(); this.touchVec = { x: 0, y: 0 }; this._bindInput(); this._resize(); window.addEventListener('resize', () => this._resize()); document.addEventListener('visibilitychange', () => { if (document.hidden && this.state === ST.RUN && Store.s().autoPause) this.togglePause(true); }); this.applyQuality(); this._last = performance.now(); const loop = (t) => { const dt = Math.min(0.05, (t - this._last) / 1000); this._last = t; this.lastDt = dt; this.frame(dt); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } /* ================= setup ================= */ _resize() { this.dpr = Math.min(window.devicePixelRatio || 1, 2); this.cv.width = innerWidth * this.dpr; this.cv.height = innerHeight * this.dpr; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); R._vig = null; } applyQuality() { const q = Store.s().particles; this.partCap = q === 'low' ? 120 : q === 'med' ? 320 : 650; } applySettings() { Snd.applyVolumes(); UI.els.fpsMeter.classList.toggle('hidden', !Store.s().fps); this.applyQuality(); } /* ================= run lifecycle ================= */ startRun(charId, setup) { setup = setup || {}; this.setup = setup; // deterministic seed for Daily Challenge; null restores Math.random seedRng(setup.seed !== undefined ? setup.seed : null); this.charId = charId || this._charSelDefault(); const stageId = STAGES[setup.stage] ? setup.stage : 'graveyard'; this.stage = STAGES[stageId]; this.endless = !!setup.endless; this.grade = setup.grade | 0; this.daily = !!setup.daily; this.diffMul = (this.stage.hpMul || 1) * (1 + this.grade * 0.32); this.goldMul = (this.stage.goldMul || 1) * (1 + this.grade * 0.15); this.finalBossId = this.stage.plan[this.stage.plan.length - 1].id; this.propAcc = 0; this.lastSurge = -1; // tutorial state (skipped once completed) this._tutStep = Store.data.flags.tutDone ? -1 : (setup.daily ? -1 : 0); this._tutMoved = 0; this.player = new Player(this.charId); this.enemies.length = 0; this.projs.length = 0; this.ebullets.length = 0; this.pickups.length = 0; this.effects.length = 0; this.texts.length = 0; this.time = 0; this.over = false; this.playerHidden = false; this.spawnAcc = 0; this.lastBurstMinute = -1; this.eliteIdx = 0; this.bossIdx = 0; this.boss = null; this.queueLevelUps = 0; this.pendingChests = 0; this.goldEarned = 0; this.rerollsLeft = this.player.rerollMax; this.cam.x = 0; this.cam.y = 0; this.shake = 0; this.hurtFlash = 0; this.state = ST.RUN; UI.showScreen(null); UI.showHud(true); UI.hideBossBar(); Snd.setFlavor(this.stage.music || null); UI.toast(tr('tip_move')); Snd.init(); } _charSelDefault() { return UI._charSel || 'kaito'; } frame(dt) { // developer tools visibility (cheap toggle) UI.els.devBtn.classList.toggle('hidden', !(Store.s().dev && (this.state === ST.RUN || this.state === ST.PAUSE || this.state === ST.LEVEL || this.state === ST.CHEST))); switch (this.state) { case ST.RUN: this.update(dt * (this.timeScale || 1)); R.draw(this); UI.hudUpdate(this, dt); break; case ST.PAUSE: case ST.LEVEL: case ST.CHEST: R.draw(this); // frozen world behind modal break; case ST.OVER: R.draw(this); break; default: this._renderMenuBg(dt); } } update(dt) { // hit-stop: brief slow-mo window after elite/boss kills if (this.freezeT > 0) { this.freezeT -= dt; dt *= 0.12; } const P = this.player; this.time += dt; // ---- movement input ---- let mx = 0, my = 0; const kb = Store.s().keybinds; if (this.keys.has(kb.up) || this.keys.has('ArrowUp')) my -= 1; if (this.keys.has(kb.down) || this.keys.has('ArrowDown')) my += 1; if (this.keys.has(kb.left) || this.keys.has('ArrowLeft')) mx -= 1; if (this.keys.has(kb.right) || this.keys.has('ArrowRight')) mx += 1; // gamepad: left stick + d-pad + edge-triggered A/Start if (typeof navigator !== 'undefined' && navigator.getGamepads) { let gp = null; try { for (const p of navigator.getGamepads()) if (p && p.connected) { gp = p; break; } } catch (e) { /* some browsers throw when no permission */ } if (gp) { const dz = v => (Math.abs(v) > 0.22 ? v : 0); mx += dz(gp.axes[0] || 0); my += dz(gp.axes[1] || 0); const btn = i => !!(gp.buttons[i] && gp.buttons[i].pressed); if (btn(12)) my -= 1; if (btn(13)) my += 1; if (btn(14)) mx -= 1; if (btn(15)) mx += 1; const prev = this._gpPrev || (this._gpPrev = {}); const now0 = btn(0), nowStart = btn(9) || btn(8); if (now0 && !prev.b0) { if (this.state === ST.LEVEL) this.chooseUpgrade(0); else if (this.state === ST.CHEST) this.closeChest(); } if (nowStart && !prev.start) { if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause(); } prev.b0 = now0; prev.start = nowStart; } } mx += this.touchVec.x; my += this.touchVec.y; const ml = len2(mx, my); if (ml > 1) { mx /= ml; my /= ml; } P.x += mx * P.spd * dt; P.y += my * P.spd * dt; if (Math.abs(mx) + Math.abs(my) > 0.01) { const fl = Math.max(0.001, len2(mx, my)); P.faceX = mx / fl; P.faceY = my / fl; } // tutorial: step 0 — learn to move; step 1 — make a level-up choice if (this._tutStep === 0) { UI.showTut('tut_move'); this._tutMoved += len2(mx, my) * P.spd * dt; if (this._tutMoved > 260) { this._tutStep = 1; UI.hideTut(); } } else if (this._tutStep === 1 && this.player.level >= 2) { UI.showTut('tut_choose'); } // regen / timers if (P.regen > 0) P.hp = Math.min(P.maxhp, P.hp + P.regen * dt); if (P.invuln > 0) P.invuln -= dt; if (this.hurtFlash > 0) this.hurtFlash -= dt; if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 26); // systems Sys.fireWeapons(this, dt); Sys.spawnTick(this, dt); Sys.updEnemies(this, dt); Sys.updProjectiles(this, dt); Sys.updEffects(this, dt); Sys.updEBullets(this, dt); Sys.updPickups(this, dt); // particles (pooled) for (let i = this.parts.length - 1; i >= 0; i--) { const p = this.parts[i]; p.life -= dt; if (p.life <= 0) { removeItem(this.parts, i); if (this.partPool.length < 500) this.partPool.push(p); continue; } p.x += p.vx * dt; p.y += p.vy * dt; p.vy += p.grav * dt; const dr = Math.pow(p.drag, dt * 60); p.vx *= dr; p.vy *= dr; } // camera const k = 1 - Math.exp(-8 * dt); this.cam.x += (P.x - this.cam.x) * k; this.cam.y += (P.y - this.cam.y) * k; // music intensity Snd.setIntensity(Sys.musicIntensity(this)); // ---- flow: chests first, then level ups ---- if (this.state === ST.RUN) { if (this.pendingChests > 0) { this.pendingChests--; this._openChestFlow(); return; } if (this.queueLevelUps > 0) { this.queueLevelUps--; this.currentChoices = Sys.makeChoices(this); this.state = ST.LEVEL; UI.hideModal('md-chest'); UI.openLevelUp(this.currentChoices, this.rerollsLeft); return; } } } /* ================= flow helpers ================= */ chooseUpgrade(idx) { if (this.state !== ST.LEVEL) return; const c = this.currentChoices[idx]; if (!c) return; Sys.applyChoice(this, c); this._tutComplete(); this._afterModal(); } /** Finish the tutorial once the player makes their first upgrade choice. */ _tutComplete() { if (this._tutStep === 1 || !Store.data.flags.tutDone) { Store.data.flags.tutDone = true; Store.save(); UI.hideTut(); } this._tutStep = -1; } skipLevelUp() { if (this.state !== ST.LEVEL) return; this.player.gold += 15; this.goldEarned += 15; this._afterModal(); } rerollChoices() { if (this.state !== ST.LEVEL || this.rerollsLeft <= 0) return; this.rerollsLeft--; Snd.play('click'); this.currentChoices = Sys.makeChoices(this); UI.hideModal('md-level'); UI.openLevelUp(this.currentChoices, this.rerollsLeft); } _afterModal() { UI.hideModal('md-level'); if (this.pendingChests > 0) { this.pendingChests--; this._openChestFlow(); return; } if (this.queueLevelUps > 0) { this.queueLevelUps--; this.currentChoices = Sys.makeChoices(this); this.state = ST.LEVEL; UI.openLevelUp(this.currentChoices, this.rerollsLeft); return; } this.state = ST.RUN; } _openChestFlow() { const data = Sys.chestReward(this); this.state = ST.CHEST; UI.hideModal('md-level'); UI.openChest(data); } closeChest() { UI.hideModal('md-chest'); if (this.queueLevelUps > 0) { this.queueLevelUps--; this.currentChoices = Sys.makeChoices(this); this.state = ST.LEVEL; UI.openLevelUp(this.currentChoices, this.rerollsLeft); return; } if (this.pendingChests > 0) { this.pendingChests--; this._openChestFlow(); return; } this.state = ST.RUN; } /* ================= events ================= */ onBossDead(e) { if (e.tid === 'b_wolf' && !Store.data.flags.boss1) { Store.data.flags.boss1 = true; Store.save(); UI.toast(tr('toast_unlock', { c: tr('c_rook') }), 'gold'); } if (this.boss === e) { this.boss = null; UI.hideBossBar(); } if (e.tid === this.finalBossId && !this.endless) { Snd.play('win', true); this.gameOver(true); } else { if (e.tid === this.finalBossId && this.endless) { UI.toast(tr('toast_endless_on'), 'gold'); } this.shakeIt(10); Snd.play('boom', true); } } onPlayerDown() { const P = this.player; if (P.revives > 0) { P.revives--; P.hp = Math.round(P.maxhp * 0.6); P.invuln = 2.2; this.effects.push(new Effect('nova', P.x, P.y, 0.6, { r: 300, col: '#ffe066' })); for (const e of this.enemies) { if (!e.boss && dist2(P.x, P.y, e.x, e.y) < 280 * 280) { Sys.damageEnemy(this, e, 9999, {}); } } UI.toast(tr('toast_revive'), 'gold'); Snd.play('evolve', true); return; } Snd.play('death', true); this.gameOver(false); } gameOver(win) { if (this.over) return; this.over = true; this.state = ST.OVER; const P = this.player; // persist progression Store.data.totals.kills += P.kills; Store.data.totals.runs++; Store.addGold(P.gold); let newRecord = false; if (this.time > Store.data.totals.best && this.time >= 30) { Store.data.totals.best = Math.floor(this.time); newRecord = true; } if (this.time >= 600) Store.data.flags.time10 = true; if (this.time >= 900) Store.data.flags.time15 = true; if (win) Store.data.totals.wins++; // stage / mode records + run history const sec = Math.floor(this.time); const prog = Store.data.progress; if (win) { prog.stageWins[this.stage.id] = (prog.stageWins[this.stage.id] || 0) + 1; prog.gradeWins[this.grade] = (prog.gradeWins[this.grade] || 0) + 1; } if (win || sec > (prog.bestPerStage[this.stage.id] || 0)) { prog.bestPerStage[this.stage.id] = Math.max(prog.bestPerStage[this.stage.id] || 0, win ? this.stage.length : sec); } if (this.endless && sec > (prog.endlessBest || 0)) { prog.endlessBest = sec; newRecord = true; } Store.data.history.unshift({ d: Date.now(), ch: this.charId, st: this.stage.id, en: this.endless ? 1 : 0, g: this.grade, t: sec, k: P.kills, gold: P.gold, lv: P.level, w: win ? 1 : 0 }); if (Store.data.history.length > 20) Store.data.history.length = 20; // daily challenge record if (this.daily) { const today = dailyKey(); const d = Store.data.daily; if (d.date !== today) { d.date = today; d.best = 0; } if (sec > (d.best || 0)) { d.best = sec; UI.toast(tr('toast_daily_record'), 'gold'); } } Store.save(); UI.checkAndToastAch(); this._lbSubmit(win, sec); setTimeout(() => { UI.openOver(win, { time: this.time, kills: P.kills, goldEarned: P.gold, level: P.level, best: Store.data.totals.best, newRecord }); }, win ? 400 : 700); if (win) this.shakeIt(12); } /** Best-effort leaderboard submit (silent when no server configured). */ _lbSubmit(win, sec) { const s = Store.s(); if (!s.lbUrl || sec < 30) return; try { const payload = { name: (Store.data.lbName || 'Anon').slice(0, 16), time: sec, kills: this.player.kills, gold: this.player.gold, lv: this.player.level, stageId: this.stage.id, grade: this.grade, endless: !!this.endless, daily: !!this.daily, win: !!win, day: new Date().toISOString().slice(0, 10).replace(/-/g, '') }; payload.sig = lbSig(payload); fetch(s.lbUrl.replace(/\/$/, '') + '/submit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), keepalive: true }).then(r => { if (r.ok) UI.toast(tr('lb_sent'), 'gold'); }) .catch(() => {}); } catch (e) { /* offline — ignore */ } } togglePause(forcePause) { if (this.state === ST.RUN) { this.state = ST.PAUSE; UI.openPause(this); } else if (this.state === ST.PAUSE && !forcePause) { this.state = ST.RUN; UI.hideModal('md-pause'); } } quitToMenu() { Snd.setFlavor(null); // abandoning a live run still banks gold/kills/time records if (this.player && !this.over && this.time > 5) { const P = this.player; Store.data.totals.kills += P.kills; Store.data.totals.runs++; Store.addGold(P.gold); if (this.time > Store.data.totals.best && this.time >= 30) Store.data.totals.best = Math.floor(this.time); if (this.time >= 600) Store.data.flags.time10 = true; if (this.time >= 900) Store.data.flags.time15 = true; this.over = true; } Store.save(); this.state = ST.MENU; UI.hideModal('md-pause'); UI.hideModal('md-over'); UI.showHud(false); UI.showScreen('scr-menu'); Snd.setIntensity(0.2); } restart() { UI.hideModal('md-over'); this.startRun(this.charId); } onBackFromScreen() { // returning from options/codex/shop/chars/stage screens const visible = ['scr-menu', 'scr-chars', 'scr-stage', 'scr-shop', 'scr-codex', 'scr-options'] .find(id => !UI.els[id].classList.contains('hidden')); if (this.state === ST.MENU || visible === 'scr-menu') { UI.showScreen('scr-menu'); } else if (visible === 'scr-options' && this.state === ST.PAUSE) { UI.showScreen(null); UI.openPause(this); } else { // browsing menus pre-run if (visible === 'scr-stage') { UI.buildChars(); UI.showScreen('scr-chars'); } else UI.showScreen('scr-menu'); } } buyMeta(id) { const def = META_SHOP.find(m => m.id === id); if (!def) return; const rank = Store.rank(id); if (rank >= def.max) return; const cost = metaCost(def, rank); if (!Store.spendGold(cost)) return; Store.data.meta[id] = rank + 1; Store.save(); if (this.player) this.player.recompute(); UI.buildShop(); UI.refreshHeadGold(); UI.checkAndToastAch(); } /* ================= developer mode ================= */ devAddGold(n) { const P = this.player; P.gold += n; this.goldEarned += Math.max(0, n); Snd.play('coin'); } devSetGold(v) { v = Math.max(0, Math.floor(v) || 0); this.goldEarned += Math.max(0, v - this.player.gold); this.player.gold = v; } devAddLevels(n) { const P = this.player; for (let i = 0; i < n; i++) { this.queueLevelUps += P.addXp(P.xpNext); } } devGiveWeapon(id, maxIt) { const P = this.player; let w = P.weaponById(id); if (EVOLVED[id]) { if (!w) P.weapons.push({ id, lvl: 1, timer: 0.2 }); } else if (w) { w.lvl = maxIt ? WEAPONS[id].max : Math.min(WEAPONS[id].max, w.lvl + 1); } else { P.weapons.push({ id, lvl: maxIt ? WEAPONS[id].max : 1, timer: 0.2 }); } Snd.play('chest'); } devGivePassive(id, maxIt) { const P = this.player; P.passives[id] = maxIt ? PASSIVES[id].max : Math.min(PASSIVES[id].max, (P.passives[id] || 0) + 1); P.recompute(); Snd.play('levelup'); } devHeal() { this.player.hp = this.player.maxhp; Snd.play('heal'); } devMagnetAll() { for (const pk of this.pickups) pk.pull = true; Snd.play('heal'); } devNuke(radius) { const P = this.player; const r = radius || 900; this.effects.push(new Effect('nova', P.x, P.y, 0.5, { r, col: '#ffd24a' })); for (const e of this.enemies) { if (dist2(P.x, P.y, e.x, e.y) < r * r) Sys.damageEnemy(this, e, 9999999, {}); } Snd.play('boom'); this.shakeIt(10); } devSkipTime(sec) { this.time += sec; } devSpawnElite() { Sys.spawnElite(this); } devSpawnBoss(id) { if (!BOSSES[id]) return; const pos = Sys.ringPos(this, 520); const b = new Enemy(id, pos.x, pos.y); b.hp *= 1 + Math.floor(this.time / 60) * 0.06; b.maxhp = b.hp; this.enemies.push(b); this.boss = b; UI.showBossBar(b); UI.toast(tr('toast_boss'), 'warn'); Snd.play('roar', true); this.shakeIt(8); } devUnlockAllChars() { for (const id in CHARS) Store.unlockChar(id); Store.save(); UI.toast('🔓 ' + tr('dev_unlockall'), 'gold'); } shakeIt(a) { if (Store.s().shake) this.shake = Math.min(14, this.shake + a); } /* ================= input ================= */ _bindInput() { window.addEventListener('keydown', (e) => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault(); this.keys.add(e.code); Snd.init(); // developer mode shortcuts if (Store.s().dev && e.code === 'Backquote') { UI.toggleDevPanel(); return; } if (e.code === 'Escape' && !UI.els['md-dev'].classList.contains('hidden')) { UI.hideModal('md-dev'); return; } if ((e.code === 'Escape' || e.code === 'KeyP')) { if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause(); else if (this.state === ST.LEVEL) this.skipLevelUp(); else if (this.state === ST.CHEST) this.closeChest(); } if (e.code === 'Digit1') this.chooseUpgrade(0); if (e.code === 'Digit2') this.chooseUpgrade(1); if (e.code === 'Digit3') this.chooseUpgrade(2); if (e.code === 'Digit4') this.chooseUpgrade(3); }); window.addEventListener('keyup', (e) => this.keys.delete(e.code)); // ---- touch joystick ---- const layer = document.getElementById('touchLayer'); const base = document.getElementById('stickBase'); const knob = document.getElementById('stickKnob'); let tid = null, bx = 0, by = 0; const isTouch = ('ontouchstart' in window); window.addEventListener('touchstart', (e) => { Snd.init(); if (!isTouch) return; layer.classList.remove('hidden'); const t = e.changedTouches[0]; tid = t.identifier; bx = t.clientX; by = t.clientY; base.style.display = 'block'; base.style.left = (bx - 55) + 'px'; base.style.top = (by - 55) + 'px'; knob.style.transform = 'translate(-50%,-50%)'; }, { passive: true }); window.addEventListener('touchmove', (e) => { for (const t of e.changedTouches) { if (t.identifier !== tid) continue; let dx = t.clientX - bx, dy = t.clientY - by; const d = len2(dx, dy); const max = 46; if (d > max) { dx = dx / d * max; dy = dy / d * max; } knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; this.touchVec.x = dx / max; this.touchVec.y = dy / max; } }, { passive: true }); const endTouch = (e) => { for (const t of e.changedTouches) { if (t.identifier !== tid) continue; tid = null; this.touchVec.x = 0; this.touchVec.y = 0; base.style.display = 'none'; } }; window.addEventListener('touchend', endTouch); window.addEventListener('touchcancel', endTouch); } /* ================= menu backdrop ================= */ _renderMenuBg(dt) { R.drawMenuScene(this, dt); } }