<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Deterministic Roguelike (Demo)</title>
<style>
:root{
/* Force dark mode only as requested */
color-scheme: dark;
--bg: light-dark(#0b1220, #0b1220);
--surface: light-dark(#0f1724, #111216);
--muted: light-dark(#94a3b8, #94a3b8);
--primary: #3B82F6;
--secondary: #8B5CF6;
--warning: #F59E0B;
--error: #EF4444;
--text: #e6eef8;
--accent: var(--secondary);
--radius: 6px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
--ui-z: 1000;
}
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);}
*{box-sizing:border-box}
/* Layout */
.app{
display:flex;flex-direction:column;height:100vh;gap:12px;padding:16px;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
}
/* Game area */
.viewport{
position:relative;flex:1;display:flex;align-items:center;justify-content:center;
background:linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.01));
border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.03);
}
canvas#gameCanvas{display:block;width:100%;height:100%;background:transparent}
/* HUD overlay */
.hud{position:absolute;inset:0;pointer-events:none;z-index:var(--ui-z);}
.hud .layer{position:absolute;pointer-events:auto}
/* Top-left health */
.health{left:16px;top:16px;width:220px}
.health .bar{width:200px;height:20px;background:rgba(255,255,255,0.04);border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,0.03);display:flex;align-items:center;padding:4px}
.health .fill{height:100%;background:linear-gradient(90deg,var(--primary),var(--accent));width:50%;transition:width 220ms ease}
.health .text{margin-left:8px;color:var(--text);font-weight:600;font-size:14px}
/* Top-center timer */
.timer{top:16px;left:50%;transform:translateX(-50%);text-align:center}
.timer .big{font-size:18px;font-weight:700;letter-spacing:-0.02em}
.timer .lvl{font-size:12px;color:var(--muted)}
/* Top-right gold & weapons */
.top-right{right:16px;top:16px;text-align:right}
.gold{display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,255,255,0.02);border-radius:6px;border:1px solid rgba(255,255,255,0.03)}
.gold .icon{width:18px;height:18px;border-radius:4px;background:var(--warning)}
.weapons{display:flex;flex-direction:column;gap:6px;margin-top:10px}
.weapon{width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.03);display:flex;align-items:center;justify-content:center;font-size:12px}
/* Bottom full-width exp bar */
.exp{left:0;right:0;bottom:12px;padding:0 16px}
.exp .barwrap{height:10px;background:rgba(255,255,255,0.03);border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,0.03)}
.exp .fill{height:100%;background:linear-gradient(90deg,var(--secondary),var(--primary));width:30%;transition:width 220ms ease}
/* Passives bottom-right */
.passives{right:16px;bottom:16px;display:flex;gap:8px}
.passive{width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center}
/* Level-up modal */
.modal-backdrop{position:absolute;inset:0;background:rgba(2,6,23,0.6);display:flex;align-items:center;justify-content:center;z-index:2000}
.modal{background:var(--surface);padding:18px;border-radius:10px;min-width:320px;border:1px solid rgba(255,255,255,0.04)}
.modal h3{margin:0 0 8px 0;font-size:18px}
.options{display:flex;gap:10px}
.option{flex:1;padding:10px;border-radius:8px;background:linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.01));border:1px solid rgba(255,255,255,0.03);cursor:pointer}
.option:hover{outline:2px solid rgba(59,130,246,0.12)}
.option .title{font-weight:600}
.option .desc{font-size:13px;color:var(--muted)}
/* System messages */
.sys{position:fixed;left:16px;bottom:120px;background:rgba(0,0,0,0.4);padding:8px 12px;border-radius:6px;font-size:13px}
/* Minimal buttons */
button.btn{appearance:none;border:0;background:var(--primary);color:white;padding:8px 12px;border-radius:6px;cursor:pointer}
button.btn.secondary{background:var(--secondary)}
/* Accessibility */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
</style>
</head>
<body>
<div class="app">
<div class="viewport" id="viewport">
<canvas id="gameCanvas" width="1280" height="720" aria-hidden="true"></canvas>
<div class="hud" id="hud">
<div class="layer health" id="hud-health">
<div class="bar">
<div class="fill" id="hp-fill" style="width:100%"></div>
<div class="text" id="hp-text">100 / 100</div>
</div>
</div>
<div class="layer timer" id="hud-timer">
<div class="big" id="timer-text">00:00</div>
<div class="lvl" id="level-text">Lv.1</div>
</div>
<div class="layer top-right" id="hud-top-right">
<div class="gold" id="gold-box"><div class="icon" aria-hidden></div><div id="gold-text">0</div></div>
<div class="weapons" id="weapons-list" aria-hidden></div>
</div>
<div class="layer exp" id="hud-exp">
<div class="barwrap"><div class="fill" id="exp-fill" style="width:10%"></div></div>
</div>
<div class="layer passives" id="hud-passives"></div>
</div>
<!-- Level-up modal template -->
<template id="tmpl-levelup">
<div class="modal-backdrop">
<div class="modal" role="dialog" aria-modal="true">
<h3>Level Up! Choose an upgrade</h3>
<div class="options"></div>
<div style="margin-top:12px;text-align:right"><button class="btn" data-action="close">Close</button></div>
</div>
</div>
</template>
<!-- System messages -->
<div id="sys-msg" class="sys" style="display:none"></div>
</div>
<div style="display:flex;gap:8px;align-items:center;justify-content:flex-end">
<button id="btn-save" class="btn secondary">Save Now</button>
<button id="btn-load" class="btn">Load</button>
<div style="flex:1"></div>
<div style="font-size:13px;color:var(--muted)">Session demo — determinism & systems implemented</div>
</div>
</div>
<script type="module">
// Wait for DOM
document.addEventListener('DOMContentLoaded', () => {
// Application code in module scope
class Utils {
static clamp(v,min,max){return Math.max(min,Math.min(max,v))}
static length(x,y){return Math.hypot(x,y)}
static normalize(x,y){const l=Math.hypot(x,y);return l>0?{x:x/l,y:y/l}:{x:0,y:0}}
static lerp(a,b,t){return a + (b-a)*t}
static now(){return performance.now()}
static floor(v){return Math.floor(v)}
}
// Seeded RNG using SplitMix64 -> xorshift64* outputs mapped to [0,1)
class SeededRNG {
constructor(seed64){this._state = BigInt(seed64) & ((1n<<64n)-1n)}
// splitmix64 next
nextU64(){
this._state = (this._state + 0x9E3779B97F4A7C15n) & ((1n<<64n)-1n);
let z = this._state;
z = (z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n & ((1n<<64n)-1n);
z = (z ^ (z >> 27n)) * 0x94D049BB133111EBn & ((1n<<64n)-1n);
z = z ^ (z >> 31n);
return z & ((1n<<64n)-1n);
}
next(){ // float in [0,1)
const u = this.nextU64();
// take top 53 bits
const mant = Number(u >> 11n) & ((1<<53)-1);
return mant / Math.pow(2,53);
}
nextBetween(a,b){return a + (b-a)*this.next()}
// deterministic shuffle using Fisher-Yates
shuffle(array){
for(let i=array.length-1;i>0;i--){const j = Math.floor(this.next()*(i+1));[array[i],array[j]]=[array[j],array[i]]}
}
weightedChoice(weights){ // weights array of numbers
const total = weights.reduce((s,w)=>s+w,0);
if(total <= 0) return 0;
let r = this.next()*total; for(let i=0;i<weights.length;i++){r -= weights[i]; if(r<=0) return i} return weights.length-1
}
}
// Spatial Hash with cell_size 8 units
class SpatialHash {
constructor(cellSize=8){
this.cellSize = cellSize;
this._cells = new Map();
}
_key(ix,iy){return ix+','+iy}
_cellCoords(pos){return [Math.floor(pos.x/this.cellSize), Math.floor(pos.y/this.cellSize)];}
clear(){this._cells.clear()}
insert(entity){const [ix,iy]=this._cellCoords(entity.pos);const k=this._key(ix,iy);if(!this._cells.has(k))this._cells.set(k,[]);this._cells.get(k).push(entity)}
query(pos,radius){
const minx = Math.floor((pos.x - radius)/this.cellSize);
const maxx = Math.floor((pos.x + radius)/this.cellSize);
const miny = Math.floor((pos.y - radius)/this.cellSize);
const maxy = Math.floor((pos.y + radius)/this.cellSize);
const out = [];
for(let x=minx;x<=maxx;x++)for(let y=miny;y<=maxy;y++){const k=this._key(x,y);const list=this._cells.get(k);if(list)out.push(...list)}
return out;
}
}
// Entity factories and IDs
let globalIdCounter = 1n;
function nextId(){return Number(globalIdCounter++)}
// Game constants
const FIXED_DELTA = 1/60; // required
const FIXED_DELTA_MS = FIXED_DELTA*1000;
const GAME_DURATION = 1200; // seconds (20 minutes)
const SPATIAL_CELL = 8; // required
// Default world
const DEFAULT_WORLD = { width:1920, height:1080 };
const CAMERA_RADIUS = 300; // arbitrary camera radius for spawn calculations
const SPAWN_MARGIN = 50;
// Application main
class GameState {
constructor(seed){
this.schema_version = 2;
this.rng_seed = BigInt(seed ?? Date.now());
this.game_frame = 0n; // u64
this.game_time = 0.0;
this.player = createDefaultPlayer();
this.enemies = []; // stable array iteration
this.projectiles = [];
this.gems = [];
this.spatial_hash = new SpatialHash(SPATIAL_CELL);
this.pending_spawns = [];
this.is_simulation_paused = false;
this.is_level_up_pending = false;
this.accumulator = 0;
this.gold = 0;
this.logs = [];
}
toSerializable(){
return {
schema_version: this.schema_version,
rng_seed: String(this.rng_seed),
game_frame: String(this.game_frame),
player: this.player,
enemies: this.enemies.map(e=>({id:e.id,pos:e.pos,hp:e.hp,base_hp:e.base_hp,enemy_type:e.enemy_type})),
gold: this.gold,
unlocked_items: []
}
}
}
function createDefaultPlayer(){
return {
id: nextId(),
pos:{x: DEFAULT_WORLD.width/2, y: DEFAULT_WORLD.height/2},
speed: 120, // units / second
health: 100, max_health: 100,
weapons: [createDefaultWeapon(1)],
passives: [],
collision_radius: 0.5,
invuln_timer: 0,
experience: 0,
level: 1,
input_buffer: [ {x:0,y:0}, {x:0,y:0}, {x:0,y:0} ]
}
}
function createDefaultWeapon(id){
return {
id: id,
weapon_id: id,
last_fired_time: -9999,
cooldown: 0.25,
range: 400,
projectile_speed: 400,
base_damage: 10,
base_pierces: 1,
type: 'projectile'
}
}
function createEnemy(type, pos, baseHp=10, baseDamage=5, baseSpeed=40, rng){
const id = nextId();
// scale stats per game minutes deterministic: simple formula
const minutes = (Number(app.state.game_time) / 60) || 0;
const hp = baseHp * Math.pow(1.0 + minutes * 0.08, 1.6);
const damage = baseDamage * Math.pow(1.0 + minutes * 0.05, 1.2);
const speed = baseSpeed * (1 + minutes*0.01);
return {
id: id,
pos:{x:pos.x,y:pos.y},
hp: hp,
base_hp: baseHp,
damage: damage,
base_damage: baseDamage,
speed: speed,
base_speed: baseSpeed,
stuck_time: 0,
last_pos: {x:pos.x,y:pos.y},
enemy_type: type
}
}
function createProjectile(ownerId,pos,velocity,speed,range,basePierces){
const nowFrame = Number(app.state.game_frame);
return {
id: nextId(),
pos:{x:pos.x,y:pos.y},
velocity:{x:velocity.x,y:velocity.y},
speed:speed,
range:range,
lifetime: range / speed,
collision_radius: 0.3,
base_pierces: basePierces,
times_hit:0,
spawn_frame: nowFrame
}
}
function createGem(pos, base_exp=10){
const id = nextId();
const minutes = Number(app.state.game_time) / 60;
const exp_value = Math.floor(base_exp * (1 + minutes * 0.02));
return {
id: id,
pos:{x:pos.x,y:pos.y},
exp_value: exp_value,
pickup_state: 'idle',
pickup_range: 4.0,
spawn_frame: Number(app.state.game_frame)
}
}
// Application class
class Application {
constructor(){
// DOM refs
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.hud = document.getElementById('hud');
this.hpFill = document.getElementById('hp-fill');
this.hpText = document.getElementById('hp-text');
this.timerText = document.getElementById('timer-text');
this.levelText = document.getElementById('level-text');
this.goldText = document.getElementById('gold-text');
this.expFill = document.getElementById('exp-fill');
this.weaponsList = document.getElementById('weapons-list');
this.passivesBox = document.getElementById('hud-passives');
this.sysMsg = document.getElementById('sys-msg');
// validation checks for required capabilities
this._validateApiSupport();
// initial state & rng
const seed = 123456789n; // fixed for demo reproducibility; could be randomized
this.state = new GameState(seed);
this.state.rng_seed = BigInt(seed);
this.rng = new SeededRNG(this.state.rng_seed + BigInt(this.state.game_frame));
// timers & profiling
this.lastRAF = performance.now();
this._running = true;
this._accumulator = 0;
this._lastFixedTime = performance.now();
this._frameTimes = [];
// camera & world
this.world = Object.assign({}, DEFAULT_WORLD);
this.camera = { x: this.state.player.pos.x, y: this.state.player.pos.y, radius: CAMERA_RADIUS };
// input
this.keys = new Set();
this.gamepadIndex = null;
// UI templates
this.tmplLevelup = document.getElementById('tmpl-levelup');
// bind events
this._bindUI();
this._bindInput();
// Start initial demo content: spawn a few enemies deterministically
this._seedInitialWave();
// fixed update checks
this._enforceConstraints();
// start loop
requestAnimationFrame(this._loop.bind(this));
// autosave timer
this._setupAutosave();
}
_validateApiSupport(){
// Check for required browser APIs used
const missing = [];
if(!window.requestAnimationFrame) missing.push('requestAnimationFrame');
if(!window.localStorage) missing.push('localStorage');
if(!window.crypto || !window.crypto.subtle) missing.push('crypto.subtle (required for checksum)');
if(!window.performance) missing.push('performance');
if(missing.length){
const msg = 'Unsupported browser APIs: ' + missing.join(', ');
console.error(msg);
this._showSys(msg,true);
}
}
_enforceConstraints(){
// Fixed delta
if(Math.abs(FIXED_DELTA - (1/60)) > 1e-9){
const msg = 'Fixed timestep violation: FIXED_DELTA must be 1/60s';
console.error(msg);this._showSys(msg,true);
}
// Spatial hash cell size
if(this.state.spatial_hash.cellSize !== SPATIAL_CELL){
const msg = 'Spatial hash misconfiguration: cell_size must be 8';console.error(msg);this._showSys(msg,true);
}
}
_bindUI(){
document.getElementById('btn-save').addEventListener('click',()=>this.saveToLocalStorage());
document.getElementById('btn-load').addEventListener('click',()=>this.loadFromLocalStorage());
}
_bindInput(){
window.addEventListener('keydown', (e)=>{this.keys.add(e.key);});
window.addEventListener('keyup', (e)=>{this.keys.delete(e.key);});
window.addEventListener('gamepadconnected',(e)=>{this.gamepadIndex = e.gamepad.index;console.log('Gamepad connected',e.gamepad)});
window.addEventListener('gamepaddisconnected',(e)=>{if(this.gamepadIndex===e.gamepad.index)this.gamepadIndex=null});
}
_seedInitialWave(){
// schedule an initial deterministic wave
this.scheduleWave(8, [1,1,1], 2);
}
_setupAutosave(){
setInterval(()=>{this.saveToLocalStorage().catch(err=>console.warn('Autosave failed',err))}, 60*1000);
}
_loop(now){
if(!this._running) return;
const deltaMs = Math.min(100, now - this.lastRAF);
this.lastRAF = now;
this._accumulator += deltaMs;
// Fixed update loop using accumulator in ms
while(this._accumulator >= FIXED_DELTA_MS){
if(!this.state.is_simulation_paused){
this._updateFixed(FIXED_DELTA);
}
this._accumulator -= FIXED_DELTA_MS;
}
const interp = this._accumulator / FIXED_DELTA_MS;
this._render(interp);
// performance tracking
const frameTime = deltaMs;
this._frameTimes.push(frameTime);
if(this._frameTimes.length>120) this._frameTimes.shift();
requestAnimationFrame(this._loop.bind(this));
}
_updateFixed(dt){
// UpdateStep (strict order enforced)
// 1. collect_inputs -> input_buffer[game_frame % 3]
const idx = Number(this.state.game_frame % 3n);
const input = this._collectInputs();
this.state.player.input_buffer[idx] = input;
// 2. player.update
this._updatePlayer(input, dt);
// 3. spatial_hash.clear(); insert player
this.state.spatial_hash.clear();
this.state.spatial_hash.insert({pos:this.state.player, id:this.state.player.id});
// 4. Enemies update
for(let i=0;i<this.state.enemies.length;i++){
const e = this.state.enemies[i];
this._enemyAIUpdate(e, dt);
this.state.spatial_hash.insert(e);
}
// 5. Projectiles update
for(let i=this.state.projectiles.length-1;i>=0;i--){
const p = this.state.projectiles[i];
p.pos.x += p.velocity.x * dt;
p.pos.y += p.velocity.y * dt;
this.state.spatial_hash.insert(p);
// lifetime check
const age = (Number(this.state.game_frame) - p.spawn_frame) * FIXED_DELTA;
if(age >= p.lifetime || (p.base_pierces - p.times_hit) <= 0){
this.state.projectiles.splice(i,1);
}
}
// 6. weapon_system.update_all
this._weaponUpdateAll();
// 7. collision_system.resolve_all
this._resolveCollisions();
// 8. gem_system.update_collection
this._updateGems();
// 9. spawn_system.execute_pending
this._executePendingSpawns();
// 10. level up pending
if(this._checkLevelUp()){ this._triggerLevelUp(); }
// 11. increment frame/time
this.state.game_frame += 1n;
this.state.game_time = Number(this.state.game_frame) * FIXED_DELTA;
// enforce game_duration
if(this.state.game_time >= GAME_DURATION){
this._endRun();
}
}
_collectInputs(){
// merge keyboard and gamepad into a direction vector, apply gamepad deadzone
let x = 0, y = 0;
if(this.keys.has('ArrowUp')||this.keys.has('w')||this.keys.has('W')) y -= 1;
if(this.keys.has('ArrowDown')||this.keys.has('s')||this.keys.has('S')) y += 1;
if(this.keys.has('ArrowLeft')||this.keys.has('a')||this.keys.has('A')) x -= 1;
if(this.keys.has('ArrowRight')||this.keys.has('d')||this.keys.has('D')) x += 1;
// gamepad axes
const gp = navigator.getGamepads ? navigator.getGamepads()[this.gamepadIndex] : null;
if(gp && gp.axes && gp.axes.length>=2){
const gx = gp.axes[0] || 0; const gy = gp.axes[1] || 0;
const dead = 0.15;
const mag = Math.hypot(gx,gy);
if(mag > dead){ x += gx; y += gy; }
}
// normalize
const len = Math.hypot(x,y);
if(len > 0){ x /= len; y /= len; }
return {x: x, y: y};
}
_updatePlayer(input, dt){
// movement using fixed timestep physics
if(input.x !== 0 || input.y !== 0){
const dir = Utils.normalize(input.x, input.y);
this.state.player.pos.x += dir.x * this.state.player.speed * dt;
this.state.player.pos.y += dir.y * this.state.player.speed * dt;
// keep in world bounds
this.state.player.pos.x = Utils.clamp(this.state.player.pos.x, 0, this.world.width);
this.state.player.pos.y = Utils.clamp(this.state.player.pos.y, 0, this.world.height);
}
// invuln timer
if(this.state.player.invuln_timer > 0){ this.state.player.invuln_timer = Math.max(0, this.state.player.invuln_timer - dt); }
}
_enemyAIUpdate(enemy, dt){
// Always seek player
const pv = this.state.player.pos;
let dx = pv.x - enemy.pos.x; let dy = pv.y - enemy.pos.y;
const desired = Utils.normalize(dx,dy);
desired.x *= enemy.speed; desired.y *= enemy.speed;
// separation: query neighbors within 24 units
const neighbors = this.state.spatial_hash.query(enemy.pos, 24).filter(n=>n !== enemy && n.enemy_type !== undefined);
let sepX = 0, sepY = 0;
for(const n of neighbors){
const ox = enemy.pos.x - n.pos.x; const oy = enemy.pos.y - n.pos.y; const d = Math.hypot(ox,oy) || 0.001;
if(d < 24){ sepX += ox/d * (24 - d); sepY += oy/d * (24 - d); }
}
sepX *= 0.5; sepY *= 0.5;
// wall avoidance simple force away from borders
let wallX = 0, wallY = 0;
const margin = 30;
if(enemy.pos.x < margin) wallX += (margin - enemy.pos.x)/margin * 30;
if(enemy.pos.x > this.world.width - margin) wallX -= (enemy.pos.x - (this.world.width - margin))/margin * 30;
if(enemy.pos.y < margin) wallY += (margin - enemy.pos.y)/margin * 30;
if(enemy.pos.y > this.world.height - margin) wallY -= (enemy.pos.y - (this.world.height - margin))/margin * 30;
let fx = desired.x + sepX + wallX; let fy = desired.y + sepY + wallY;
// clamp magnitude to speed
const mag = Math.hypot(fx,fy);
if(mag > enemy.speed && mag>0){ fx = fx / mag * enemy.speed; fy = fy / mag * enemy.speed; }
enemy.pos.x += fx * dt; enemy.pos.y += fy * dt;
// stuck detection
const moved = Math.hypot(enemy.pos.x - enemy.last_pos.x, enemy.pos.y - enemy.last_pos.y);
if(moved < 0.1){ enemy.stuck_time += dt; if(enemy.stuck_time > 2.0){ const dir = Utils.normalize(this.state.player.pos.x - enemy.pos.x, this.state.player.pos.y - enemy.pos.y); enemy.pos.x += dir.x * 1.5; enemy.pos.y += dir.y * 1.5; enemy.stuck_time = 0; }} else { enemy.stuck_time = 0; }
enemy.last_pos.x = enemy.pos.x; enemy.last_pos.y = enemy.pos.y;
}
_weaponUpdateAll(){
const game_time = this.state.game_time;
const player = this.state.player;
for(const w of player.weapons){
if(game_time - w.last_fired_time >= w.cooldown){
// find nearest enemy in range (iterate all enemies every frame)
let nearest = null; let nd = Infinity;
for(const e of this.state.enemies){
const d = Math.hypot(e.pos.x - player.pos.x, e.pos.y - player.pos.y);
if(d <= w.range && d < nd){ nearest = e; nd = d; }
}
if(nearest){
// fire projectile
const rngSeed = Number(this.state.rng_seed + BigInt(this.state.game_frame) + BigInt(w.id));
const rr = new SeededRNG(BigInt(rngSeed));
const dmgMult = 1.0; // no passives in demo
const randFactor = rr.nextBetween(0.9,1.1);
const finalDamage = Math.floor(w.base_damage * (1 + dmgMult) * randFactor);
// projectile velocity toward nearest
const dir = Utils.normalize(nearest.pos.x - player.pos.x, nearest.pos.y - player.pos.y);
const vel = {x: dir.x * w.projectile_speed, y: dir.y * w.projectile_speed};
const proj = createProjectile(w.id, {x:player.pos.x, y:player.pos.y}, vel, w.projectile_speed, w.range, w.base_pierces);
proj.damage = finalDamage;
this.state.projectiles.push(proj);
w.last_fired_time = game_time;
}
}
}
}
_resolveCollisions(){
// projectiles vs enemies
for(let i=this.state.projectiles.length-1;i>=0;i--){
const p = this.state.projectiles[i];
const candidates = this.state.spatial_hash.query(p.pos, 16);
for(const c of candidates){
if(c.enemy_type === undefined) continue;
const e = c;
const d = Math.hypot(e.pos.x - p.pos.x, e.pos.y - p.pos.y);
if(d <= (p.collision_radius + 0.5)){
// hit
e.hp -= p.damage || 0;
p.times_hit += 1;
if(e.hp <= 0){
// spawn gem
this.state.gems.push(createGem({x:e.pos.x,y:e.pos.y}, 8));
const idx = this.state.enemies.indexOf(e);
if(idx>=0) this.state.enemies.splice(idx,1);
}
if((p.base_pierces - p.times_hit) <= 0){ this.state.projectiles.splice(i,1); break; }
}
}
}
// enemies colliding with player
for(const e of this.state.enemies){
const d = Math.hypot(e.pos.x - this.state.player.pos.x, e.pos.y - this.state.player.pos.y);
if(d <= (this.state.player.collision_radius + 0.5)){
if(this.state.player.invuln_timer <= 0){
this.state.player.health -= e.damage;
this.state.player.invuln_timer = 0.5; // invulnerability frames
if(this.state.player.health <= 0){ this._gameOver(); }
}
}
}
}
_updateGems(){
for(let i=this.state.gems.length-1;i>=0;i--){
const g = this.state.gems[i];
const d = Math.hypot(g.pos.x - this.state.player.pos.x, g.pos.y - this.state.player.pos.y);
if(d < 0.5){ // manual pickup
this.state.player.experience += g.exp_value; this.state.gems.splice(i,1);
} else if(d < g.pickup_range){
// magnetic
g.pos.x = Utils.lerp(g.pos.x, this.state.player.pos.x, 0.15);
g.pos.y = Utils.lerp(g.pos.y, this.state.player.pos.y, 0.15);
}
}
}
_executePendingSpawns(){
if(this.state.pending_spawns.length===0) return;
// check scheduled_frame
const gf = Number(this.state.game_frame);
for(let i=this.state.pending_spawns.length-1;i>=0;i--){
const cmd = this.state.pending_spawns[i];
if(gf >= cmd.scheduled_frame){
// spawn enemy
const e = createEnemy(cmd.type, {x:cmd.pos.x,y:cmd.pos.y});
this.state.enemies.push(e);
this.state.pending_spawns.splice(i,1);
}
}
}
_checkLevelUp(){
const p = this.state.player; const needed = 10 + p.level*10; // simple progression
if(p.experience >= needed){ return true; }
return false;
}
_triggerLevelUp(){
this.state.is_simulation_paused = true;
this.state.is_level_up_pending = true;
// deterministic options: use seeded RNG based on seed + frame
const seed = Number(this.state.rng_seed + this.state.game_frame + 9999n);
const rr = new SeededRNG(BigInt(seed));
const options = [
{id:'hp_up', title:'Max HP +10', desc:'Increase max health by 10'},
{id:'dmg_up', title:'Damage +2', desc:'Increase weapon damage'},
{id:'speed_up', title:'Speed +10%', desc:'Move faster'},
{id:'exp_up', title:'Exp +20%', desc:'Gain more experience'}
];
rr.shuffle(options);
const opts = options.slice(0,3); // always 3
const tpl = this.tmplLevelup.content.cloneNode(true);
const backdrop = tpl.querySelector('.modal-backdrop');
const optsEl = tpl.querySelector('.options');
opts.forEach(o=>{
const b = document.createElement('div'); b.className='option'; b.tabIndex=0;
const t = document.createElement('div'); t.className='title'; t.textContent=o.title; b.appendChild(t);
const d = document.createElement('div'); d.className='desc'; d.textContent=o.desc; b.appendChild(d);
b.addEventListener('click', ()=>{ this._applyLevelUpChoice(o); backdrop.remove(); });
optsEl.appendChild(b);
});
// fallback auto-choose after 10s
const autoChoose = setTimeout(()=>{ if(this.state.is_level_up_pending){ this._applyLevelUpChoice(opts[0]); backdrop.remove(); } }, 10000);
backdrop.querySelector('[data-action="close"]').addEventListener('click', ()=>{ clearTimeout(autoChoose); backdrop.remove(); this.state.is_simulation_paused=false; this.state.is_level_up_pending=false; });
document.body.appendChild(backdrop);
}
_applyLevelUpChoice(option){
// apply effect deterministically
const p = this.state.player;
if(option.id === 'hp_up'){ p.max_health += 10; p.health = Math.min(p.health + 10, p.max_health); }
if(option.id === 'dmg_up'){ for(const w of p.weapons) w.base_damage += 2; }
if(option.id === 'speed_up'){ p.speed *= 1.10; }
if(option.id === 'exp_up'){ p.experience = Math.floor(p.experience * 1.2); }
// log level-up choice
this.state.logs.push({frame: String(this.state.game_frame), level: p.level, chosen_upgrade: option.id});
p.level += 1; p.experience = 0; // reset exp
this.state.is_simulation_paused = false; this.state.is_level_up_pending = false;
}
scheduleWave(spawn_count, enemy_weights, spawns_per_second){
// schedule deterministic spawn commands per plan
const wave_size = spawn_count;
for(let i=0;i<wave_size;i++){
const angleSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i));
const rr1 = new SeededRNG(BigInt(angleSeed));
const angle = rr1.next() * Math.PI * 2;
const distSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i) + 1000n);
const rr2 = new SeededRNG(BigInt(distSeed));
let distance = this.camera.radius + SPAWN_MARGIN + rr2.next() * 5;
// ensure outside camera
if(distance <= this.camera.radius + SPAWN_MARGIN) distance = this.camera.radius + SPAWN_MARGIN + 1;
const spawn_pos = {x: this.state.player.pos.x + Math.cos(angle)*distance, y: this.state.player.pos.y + Math.sin(angle)*distance};
const typeSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i) + 2000n);
const rr3 = new SeededRNG(BigInt(typeSeed));
const idx = rr3.weightedChoice(enemy_weights);
const enemy_type = idx+1;
const delay = i * (1.0 / spawns_per_second);
const scheduled_frame = Number(this.state.game_frame) + Math.round(delay / FIXED_DELTA);
this.state.pending_spawns.push({pos:spawn_pos,type:enemy_type,delay:delay,scheduled_frame:scheduled_frame});
}
}
_endRun(){
this._showSys('Run complete — saving...', false);
this.saveToLocalStorage();
this._running = false;
}
_gameOver(){
this._showSys('Game Over — saving...', false);
this.saveToLocalStorage();
this._running = false;
}
async saveToLocalStorage(){
try{
const payload = this.state.toSerializable();
const str = JSON.stringify(payload);
let checksum = '';
if(window.crypto && window.crypto.subtle){
const data = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
checksum = Array.from(new Uint8Array(hash)).map(b=>b.toString(16).padStart(2,'0')).join('');
} else {
// fallback checksum
let s=0; for(let i=0;i<str.length;i++) s=(s+str.charCodeAt(i))&0xFFFFFFFF; checksum = s.toString(16);
this._showSys('Warning: crypto.subtle unavailable - using weak checksum', true);
}
const saveObj = {schema_version: payload.schema_version, payload: payload, checksum: checksum};
localStorage.setItem('vampire_survivors_save_v2', JSON.stringify(saveObj));
this._showSys('Saved', false);
return true;
}catch(err){console.error('Save failed',err); this._showSys('Save failed: '+err.message,true); return false}
}
async loadFromLocalStorage(){
try{
const raw = localStorage.getItem('vampire_survivors_save_v2');
if(!raw) { this._showSys('No save found, creating new.', true); return this._createDefaultSave(); }
const obj = JSON.parse(raw);
if(obj.schema_version !== 2){ throw new Error('Unsupported schema_version'); }
const payload = obj.payload; const checksum = obj.checksum;
// validate checksum
const str = JSON.stringify(payload);
if(window.crypto && window.crypto.subtle){
const data = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
const ch = Array.from(new Uint8Array(hash)).map(b=>b.toString(16).padStart(2,'0')).join('');
if(ch !== checksum) throw new Error('Checksum mismatch');
}
// basic validation
if(typeof payload.gold !== 'number' || payload.gold < 0) throw new Error('Invalid gold');
if(!Array.isArray(payload.unlocked_items)) throw new Error('Invalid unlocked_items');
// apply load stubs (demo only applies some fields)
this.state.gold = payload.gold;
this._showSys('Save loaded', false);
return true;
}catch(err){ console.error('Load failed',err); this._showSys('Save corrupted - new save created', true); this._createDefaultSave(); return false }
}
_createDefaultSave(){
this.state.gold = 0;
// persisted minimal
const payload = {schema_version:2, rng_seed:String(this.state.rng_seed), game_frame:String(this.state.game_frame), player:this.state.player, enemies:[], gold:0, unlocked_items:[]};
localStorage.setItem('vampire_survivors_save_v2', JSON.stringify({schema_version:2, payload:payload, checksum:'0'}));
this._showSys('Default save created', true);
}
_showSys(msg,error=false){
this.sysMsg.style.display='block'; this.sysMsg.textContent = msg; this.sysMsg.style.background = error? 'rgba(239,68,68,0.12)': 'rgba(0,0,0,0.4)';
setTimeout(()=>{ this.sysMsg.style.display='none'; }, 3000);
}
_render(interp){
// basic camera following player
const c = this.camera; c.x = this.state.player.pos.x; c.y = this.state.player.pos.y;
const ctx = this.ctx; const canvas = this.canvas;
// clear
ctx.clearRect(0,0,canvas.width,canvas.height);
// transform world->canvas
ctx.save();
const scaleX = canvas.width / Math.min(canvas.width, canvas.height) * (canvas.width/1280);
// for demo, simple transform: center on player
ctx.translate(canvas.width/2 - (c.x), canvas.height/2 - (c.y));
// draw background grid
ctx.fillStyle = '#071023'; ctx.fillRect(c.x - canvas.width/2, c.y - canvas.height/2, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.02)'; ctx.lineWidth = 1;
for(let gx=0;gx<=this.world.width;gx+=64){ ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,this.world.height); ctx.stroke(); }
for(let gy=0;gy<=this.world.height;gy+=64){ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(this.world.width,gy); ctx.stroke(); }
// draw gems
for(const g of this.state.gems){ ctx.fillStyle = '#F59E0B'; ctx.beginPath(); ctx.arc(g.pos.x, g.pos.y, 6,0,Math.PI*2); ctx.fill(); }
// draw enemies
for(const e of this.state.enemies){ ctx.fillStyle = '#8b5cf6'; ctx.beginPath(); ctx.arc(e.pos.x, e.pos.y, 10,0,Math.PI*2); ctx.fill(); }
// draw projectiles
for(const p of this.state.projectiles){ ctx.fillStyle = '#3B82F6'; ctx.beginPath(); ctx.arc(p.pos.x, p.pos.y, 4,0,Math.PI*2); ctx.fill(); }
// draw player with pulsing opacity when invuln
const p = this.state.player;
let alpha = 1.0;
if(p.invuln_timer > 0){ alpha = 0.5 + 0.5*Math.sin(Date.now()/80); }
ctx.globalAlpha = alpha;
ctx.fillStyle = '#3B82F6'; ctx.beginPath(); ctx.arc(p.pos.x, p.pos.y, 12,0,Math.PI*2); ctx.fill(); ctx.globalAlpha = 1;
ctx.restore();
// HUD updates
this.hpFill.style.width = ((this.state.player.health/this.state.player.max_health)*100)+'%';
this.hpText.textContent = Math.max(0,Math.floor(this.state.player.health)) + ' / ' + Math.floor(this.state.player.max_health);
const mm = Math.floor(this.state.game_time/60); const ss = Math.floor(this.state.game_time%60).toString().padStart(2,'0');
this.timerText.textContent = `${mm}:${ss}`;
this.levelText.textContent = `Lv.${this.state.player.level}`;
this.goldText.textContent = String(this.state.gold);
// exp fill fraction
const needed = 10 + this.state.player.level*10; const frac = Math.min(1, this.state.player.experience/needed);
this.expFill.style.width = (frac*100)+'%';
// weapons list render minimal
this.weaponsList.innerHTML = '';
for(const w of this.state.player.weapons){ const el = document.createElement('div'); el.className='weapon'; el.textContent = 'W'+w.weapon_id; this.weaponsList.appendChild(el); }
// passives
this.passivesBox.innerHTML = '';
for(const p of this.state.player.passives){ const el = document.createElement('div'); el.className='passive'; el.textContent='P'; this.passivesBox.appendChild(el); }
}
}
// Instantiate and expose
window.app = new Application();
// Expose some utilities for testing/debugging
window.appUtils = { FIXED_DELTA, SPATIAL_CELL };
});
</script>
</body>
</html>