NEON SURVIVORS v1.1 — full-featured bullet-heaven survivor game
Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js)
This commit is contained in:
+739
@@ -0,0 +1,739 @@
|
||||
'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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user