<!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>
Comments
No comments yet. Be the first!
Please login to leave a comment.