/
CodeByKate
/
lastserver-game
Обзор
Документация
Войти
/
CodeByKate
/
lastserver-game
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
entities.js
749 строк
28 KB
CodeByKate
upload files
21 дек 2025, 21:44
21 дек 2025, 21:44
136e74f
Код
Авторство
О чём код?
/* entities.js Defines players, platforms, level logic and integrates abilities: - both players start same place - inactive player frozen - stronger jumps - decaying platforms with repair by Lera and bug-freeze by Max */ import LeraAbility from './code.js'; import MaxBug from './bugs.js'; export default class Entities { constructor(engine){ this.engine = engine; this.width = engine.width; this.height = engine.height; // Players this.players = { lera: this.createPlayer('Лера', '#2f8cff'), max: this.createPlayer('Макс', '#ff5b5b') }; this.current = 'lera'; // active player: 'lera' or 'max' // Abilities this.leraAbility = new LeraAbility(engine, this); this.maxBug = new MaxBug(engine, this); // Platforms: ladder-style level (greys + one green goal) with staggered decay timers this.platforms = []; this.createTestLevel(); // per-level tracking of how many platforms have fully vanished (used to accelerate decay) this._vanishedCount = 0; // Glitch visual timer this.glitchTimer = 0; // NPCs (populated by levels; ensure default) this._npcs = []; // runtime NPC state this._activeNPC = null; this._npcDialogOpen = false; this._npcKeyHandler = null; this._npcShake = 0; // physics this.gravity = 1400; this.friction = 0.85; // fall threshold for game over this.fallDeathY = 650; } reset(){ // Reset players to shared start and platform states this.players.lera.x = 50; this.players.lera.y = 550; this.players.lera.vx=0; this.players.lera.vy=0; this.players.lera.onGround=true; this.players.max.x = 50; this.players.max.y = 550; this.players.max.vx=0; this.players.max.vy=0; this.players.max.onGround=true; this.platforms.forEach((p,idx)=>{ p.exists = true; p._age = 0; p.alpha = 1; p._started = false; p._jitter = 0; }); this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decisions = 0; this.engine.gameOver = false; this.engine.win = false; this.current = 'lera'; // reset abilities this.leraAbility.reset(); this.maxBug.reset(); // reset NPC runtime state this._activeNPC = null; this._npcDialogOpen = false; this._npcShake = 0; if(this._npcKeyHandler){ window.removeEventListener('keydown', this._npcKeyHandler); this._npcKeyHandler = null; } // ensure NPCs array exists if(!this._npcs) this._npcs = []; } createPlayer(name,color){ return { name, color, x:50, y:550, w:18, h:28, vx:0, vy:0, speed:180, jumpPower:700, // stronger jump (px/s) onGround:true, canUse:false, facing:1 }; } createTestLevel(){ // Build platforms with staggered start delays: top platforms start earlier, bottom ones later. const rand = (a,b)=> a + Math.random()*(b-a); // Define platform rows (from ground up). We set startDelay increasing for lower platforms. const ground = {x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false}; const p1 = {x:40, y:500, w:140, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(40,60), decayDuration:rand(40,60), _age:0, _started:false}; // low -> later const p2 = {x:220, y:420, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(30,50), decayDuration:rand(40,60), _age:0, _started:false}; const p3 = {x:120, y:340, w:100, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(24,40), decayDuration:rand(36,56), _age:0, _started:false}; const p4 = {x:300, y:260, w:110, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(18,30), decayDuration:rand(32,52), _age:0, _started:false}; const p5 = {x:420, y:180, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(12,22), decayDuration:rand(28,48), _age:0, _started:false}; const p6 = {x:520, y:100, w:60, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(10,20), decayDuration:rand(24,44), _age:0, _started:false}; // top -> earlier const goal = {x:600, y:80, w:150, h:20, alpha:1, decaying:false, exists:true, isGoal:true, startDelay:0, decayDuration:0, _age:0, _started:false}; this.platforms = [ground, p1, p2, p3, p4, p5, p6, goal]; this._vanishedCount = 0; } switchPlayer(){ this.current = (this.current === 'lera') ? 'max' : 'lera'; } update(dt, engine){ const keys = engine.keys; const player = this.players[this.current]; // Movement input mapping: Lera uses arrows, Max uses A/D, both use Space for jump let left=false, right=false, jump=false; if(this.current === 'lera'){ left = !!keys['ArrowLeft']; right = !!keys['ArrowRight']; jump = !!(keys[' '] || keys['Space']); } else { left = !!(keys['a'] || keys['A']); right = !!(keys['d'] || keys['D']); jump = !!(keys[' '] || keys['Space']); } if(left){ player.vx = -player.speed; player.facing = -1; } else if(right){ player.vx = player.speed; player.facing = 1; } else { player.vx = 0; } // Jump only when on ground if(jump && player.onGround){ player.vy = -player.jumpPower; player.onGround = false; } // Switch player handling (consume Tab once) if(keys['Tab']){ if(!this._tabLocked){ this.switchPlayer(); this._tabLocked = true; } } else this._tabLocked = false; // Abilities input (consume once via simple locks) // Lera E if((keys['e'] || keys['E']) && !this._eLock){ this._eLock = true; if(this.current === 'lera') this.leraAbility.tryUse(); } else if(!keys['e'] && !keys['E']) this._eLock = false; // Max Q if((keys['q'] || keys['Q']) && !this._qLock){ this._qLock = true; if(this.current === 'max') this.maxBug.tryUse(); } else if(!keys['q'] && !keys['Q']) this._qLock = false; // Physics: active player only player.vy += this.gravity * dt; player.x += player.vx * dt; player.y += player.vy * dt; // World bounds player.x = Math.max(0, Math.min(this.width - player.w, player.x)); if(player.y > this.height - player.h){ player.y = this.height - player.h; player.vy = 0; player.onGround = true; } // Platform collisions (active player) player.onGround = false; for(const p of this.platforms){ if(!p.exists) continue; if(this._aabb(player.x, player.y, player.w, player.h, p.x, p.y, p.w, p.h)){ if(player.vy >= 0 && (player.y + player.h - player.vy*dt) <= p.y + 6){ player.y = p.y - player.h; player.vy = 0; player.onGround = true; } } } // Global steady stability drain: 0.3% of max per second if(this.engine && typeof this.engine.decayTimerMax === 'number' && !this.engine.decayPaused && !this.engine.gameOver && !this.engine.win){ const drainPerSecond = 0.003 * this.engine.decayTimerMax; // 0.3% of max per second this.engine.decayTimer = Math.max(0, this.engine.decayTimer - drainPerSecond * dt); } // Update decaying platforms (they start after startDelay, fade over decayDuration) for(const p of this.platforms){ if(p.decaying && p.exists){ // If Max's bug is active, freeze decay progression if(this.maxBug.active){ p._jitter = 0; continue; } // accumulate total elapsed time p._age = (p._age || 0) + dt; // check if decay should start if(!p._started && p._age >= (p.startDelay || 0)){ p._started = true; p._age = 0; } if(p._started){ const duration = Math.max(0.0001, p.decayDuration || 50); const life = Math.max(0, Math.min(1, p._age / duration)); p.alpha = Math.max(0, 1 - life); // jitter only when actively decaying and alpha < 0.9 if(p.alpha < 0.9){ if(!p._lastJitterTime) p._lastJitterTime = 0; p._lastJitterTime += dt; const freq = 0.12 + Math.random()*0.08; if(p._lastJitterTime >= freq){ p._jitter = (Math.random()*2-1) * (1 + Math.random()*2); p._lastJitterTime = 0; } else { p._jitter = 0; } } else { p._jitter = 0; } if(p.alpha <= 0.005){ p.exists = false; this._vanishedCount++; // each vanished platform reduces stability by 5% of max (cap to avoid huge drops) if(this.engine && typeof this.engine.decayTimerMax === 'number'){ const reduce = Math.round(this.engine.decayTimerMax * 0.05); this.engine.decayTimer = Math.max(0, this.engine.decayTimer - reduce); } } } } else { p._jitter = 0; } } // Goal check: level is complete ONLY when BOTH players are standing on the goal platform simultaneously. for(const p of this.platforms){ if(p.isGoal && p.exists){ const aOnGoal = this._aabb(this.players.lera.x, this.players.lera.y, this.players.lera.w, this.players.lera.h, p.x, p.y-8, p.w, p.h+8); const bOnGoal = this._aabb(this.players.max.x, this.players.max.y, this.players.max.w, this.players.max.h, p.x, p.y-8, p.w, p.h+8); if(aOnGoal && bOnGoal){ engine.win = true; this.glitchTimer = 0.9; } } } // NPC proximity checks: if any NPC within 80px of either player, open dialog // Priority: nearest NPC const allPlayers = [this.players.lera, this.players.max]; let nearest = null; let nearestDist = Infinity; for(const npc of (this._npcs || [])){ if(!npc || !npc.x || !npc.y) continue; for(const pl of allPlayers){ const dx = (pl.x + pl.w/2) - npc.x; const dy = (pl.y + pl.h/2) - npc.y; const d = Math.hypot(dx, dy); if(d < 80 && d < nearestDist){ nearest = npc; nearestDist = d; } } } if(nearest && !this._npcDialogOpen){ this._openNPCDialog(nearest); } else if(!nearest && this._npcDialogOpen){ // If player walks away, close dialog this._closeNPCDialog(); } // Slight NPC shake intensity when dialog open or city is unstable this._npcShake = Math.min(2.4, Math.max(0, this._npcShake + ((this._npcDialogOpen ? 1 : -1) * dt * 6))); // Freeze the inactive player in place (no gravity /movement) const otherKey = (this.current === 'lera') ? 'max' : 'lera'; const other = this.players[otherKey]; other.vx = 0; other.vy = 0; other.onGround = other.onGround || false; // Glitch timer this.glitchTimer = Math.max(0, this.glitchTimer - dt); // Game over by falling if(player.y > this.fallDeathY) engine.gameOver = true; } // NPC dialog open/close handlers _openNPCDialog(npc){ if(!npc || this._npcDialogOpen) return; this._activeNPC = npc; this._npcDialogOpen = true; // Dialog DOM const dlg = document.getElementById('dialog'); const box = document.getElementById('dialog-box'); const textEl = document.getElementById('dialog-text'); const choices = document.getElementById('dialog-choices'); if(dlg && box && textEl && choices){ dlg.classList.remove('hidden'); box.style.pointerEvents = 'auto'; // If this NPC contains a puzzle, render explicit choices and accept 1/2/3 directly if(npc.puzzle){ const lines = npc.text ? npc.text.split('\n') : []; textEl.innerHTML = this._glitchifyText(lines.join('<br/>')); // render choices visually choices.innerHTML = ''; const styleBtn = "display:inline-block;padding:8px 10px;margin:6px;border-radius:6px;background:#081018;color:#cfeefb;font-weight:700"; for(const ch of npc.choices || []){ const div = document.createElement('div'); div.innerHTML = `<div style="${styleBtn}">${ch.id}. ${ch.text}</div>`; choices.appendChild(div); } // helper note const helper = document.createElement('div'); helper.style.marginTop = '8px'; helper.style.fontSize = '12px'; helper.style.color = '#9fdab3'; helper.textContent = 'Нажмите 1 / 2 / 3 — выбор (без Enter).'; choices.appendChild(helper); } else { textEl.innerHTML = this._glitchifyText(npc.text || ''); choices.innerHTML = "<div style='font-size:12px;color:#cfeefb;margin-top:8px'>Space / Esc — закрыть</div>"; } } // Key handler for puzzle choices or close const engine = this.engine; this._npcKeyHandler = (e)=>{ if(npc.puzzle && (e.key === '1' || e.key === '2' || e.key === '3')){ const picked = parseInt(e.key,10); // If this NPC carries a decision (level 4), store choice to engine.decision4 and give feedback if(npc.decision){ if(this.engine) this.engine.decision4 = picked; if(textEl) textEl.innerHTML = `<span style="color:#ffd966;font-weight:700">Вы выбрали: ${picked}</span>`; // small flash to acknowledge if(this.engine) this.engine._triggerFlash('green', 260); setTimeout(()=> this._closeNPCDialog(), 700); return; } // legacy puzzle behavior (original levels): correctness check leads to win or boost if(npc.correctChoice && picked === npc.correctChoice){ // Correct: teleport both players to exit/goal and win the level const goal = this.platforms.find(p=>p.isGoal); if(goal){ this.players.lera.x = goal.x + 8; this.players.lera.y = goal.y - this.players.lera.h; this.players.max.x = goal.x + 8 + 28; this.players.max.y = goal.y - this.players.max.h; } else if(this._levelBackground && this._levelBackground.type === 'pacmaze' && this._levelBackground.exit){ this.players.lera.x = this._levelBackground.exit.x; this.players.lera.y = this._levelBackground.exit.y; this.players.max.x = this._levelBackground.exit.x + 24; this.players.max.y = this._levelBackground.exit.y; } engine.win = true; engine._triggerFlash('green', 700); if(textEl) textEl.innerHTML = '<span style="color:#8ef08e;font-weight:700">Верно!</span>'; } else { // Wrong or non-decision fallback: grant small stability boost (+10% of max) const boost = Math.round(engine.decayTimerMax * 0.10); engine.decayTimer = Math.min(engine.decayTimerMax, engine.decayTimer + boost); engine._triggerFlash('green', 300); if(textEl) textEl.innerHTML = '<span style="color:#ffd966;font-weight:700">Не грусти!</span>'; } // auto-close shortly after a choice setTimeout(()=> this._closeNPCDialog(), 800); } else if(e.key === ' ' || e.key === 'Space' || e.key === 'Escape' || e.key === 'Esc'){ this._closeNPCDialog(); } }; window.addEventListener('keydown', this._npcKeyHandler); // auto-close after 8 seconds if still open this._npcAutoCloseTimer = setTimeout(()=>{ this._closeNPCDialog(); }, 8000); } _closeNPCDialog(){ if(!this._npcDialogOpen) return; this._npcDialogOpen = false; this._activeNPC = null; const dlg = document.getElementById('dialog'); if(dlg) dlg.classList.add('hidden'); if(this._npcKeyHandler){ window.removeEventListener('keydown', this._npcKeyHandler); this._npcKeyHandler = null; } } _glitchifyText(text){ // produce occasional corrupted characters for visual glitch effect const chars = ['@','&','#','%','?','*','§','∆']; return text.split('').map(ch=>{ if(Math.random() < 0.06) return `<span style="opacity:0.85;color:#ffdede">${chars[Math.floor(Math.random()*chars.length)]}</span>`; if(Math.random() < 0.06) return `<span style="opacity:0.85;color:#cfeefb">${ch}</span>`; return ch; }).join(''); } render(ctx, engine){ // Background gradient/grid this._drawBackground(ctx); // If level provides a glitchCity background, render building facades behind platforms if(this._levelBackground && this._levelBackground.type === 'glitchCity'){ const bg = this._levelBackground; // Distant silhouettes ctx.save(); ctx.globalAlpha = 0.16; ctx.fillStyle = '#071028'; const silhouettes = [ {x: 0, w: 60, h: 240, y: this.height - 240}, {x: 720, w: 80, h: 320, y: this.height - 320}, {x: 420, w: 90, h: 200, y: this.height - 200}, {x: 150, w: 70, h: 280, y: this.height - 280} ]; for(const s of silhouettes) ctx.fillRect(s.x, s.y, s.w, s.h); ctx.restore(); // Buildings const bldings = (bg.buildings && bg.buildings.length) ? bg.buildings : [ { x:50, width:200, floors:4, color:'#4A2F1D', litRatio:0.25 }, { x:300, width:200, floors:6, color:'#2f3540', litRatio:0.28 }, { x:550, width:200, floors:8, color:'#11121a', litRatio:0.30 } ]; // stable deterministic pseudo-random for window lights (based on coords) const stable = (x,y)=> { const v = Math.sin(x*12.9898 + y*78.233) * 43758.5453; return v - Math.floor(v); }; for(const b of bldings){ const bx = b.x; const bw = b.width; const floors = b.floors || 5; const bh = Math.min(this.height - 40, floors * 50 + 60); const by = this.height - bh; // facade and roof parapet ctx.save(); ctx.fillStyle = b.color || '#2b2b2b'; ctx.fillRect(bx, by, bw, bh); ctx.fillStyle = '#6d6d6d'; ctx.fillRect(bx - 2, by - 8, bw + 4, 8); // subtle horizontal floor separators (thin, subtle) ctx.strokeStyle = 'rgba(180,180,180,0.06)'; ctx.lineWidth = 1; for(let fy = by + 44; fy < by + bh - 20; fy += 50){ ctx.beginPath(); ctx.moveTo(bx + 4, fy); ctx.lineTo(bx + bw - 4, fy); ctx.stroke(); } // windows grid: static lit pattern (no blinking) using stable function + litRatio const winW = 40, winH = 40; const cols = Math.max(1, Math.floor(bw / (winW + 10))); const leftPad = Math.max(6, Math.round((bw - (cols * (winW + 10) - 10)) / 2)); const litRatio = (typeof b.litRatio === 'number') ? b.litRatio : 0.25; for(let rowY = by + 12; rowY + winH < by + bh - 6; rowY += winH + 10){ for(let c = 0; c < cols; c++){ const wx = bx + leftPad + c * (winW + 10); const wy = rowY; // window background (always black) ctx.fillStyle = '#0b0b0b'; ctx.fillRect(wx, wy, winW, winH); // Decide stable light per-window; approx 20-30% lit depending on building setting const r = stable(wx, wy); if(r < litRatio){ ctx.fillStyle = '#FFD700'; // warm static light ctx.fillRect(wx + 8, wy + 8, winW - 16, winH - 16); } } } ctx.restore(); // Fire escape for left-most building: wider and clearer ladder + rungs if(bx < 120){ ctx.save(); const fx = bx + 36; // ladder x // vertical bar ctx.fillStyle = '#8f9498'; ctx.fillRect(fx - 4, by + 12, 8, bh - 24); // rungs (wider) ctx.fillStyle = '#b7bdc0'; for(let yy = by + 32; yy < by + bh - 12; yy += 28){ ctx.fillRect(fx - 14, yy, 36, 4); } ctx.restore(); } } // subtle glitch artefacts only when fragile platforms exist (kept soft) const anyFragileNearby = this.platforms.some(p => p.decaying && p._started && p.alpha < 0.9); if(anyFragileNearby && Math.random() < 0.35){ ctx.save(); ctx.globalAlpha = 0.06 + Math.random() * 0.06; ctx.fillStyle = '#ffffff'; for(let i=0;i<5;i++){ const gx = 40 + Math.random() * (this.width - 80); const gy = 60 + Math.random() * (this.height - 120); ctx.fillRect(gx, gy, 1 + Math.random()*30, 1 + Math.random()*3); } ctx.restore(); } } // Draw platforms: make them clearly visible (light gray with white stroke and slight shadow) for(const p of this.platforms){ if(!p.exists) continue; ctx.save(); // jitter for decaying platforms only const jitterX = (p.decaying && p._jitter) ? p._jitter : 0; const jitterY = (p.decaying && p._jitter) ? (Math.random()*0.6) : 0; // shadow for depth (subtle) ctx.globalAlpha = 1.0; ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.fillRect(Math.round(p.x + 2), Math.round(p.y + p.h + 2), p.w, 4); // main platform body: light gray ctx.globalAlpha = p.alpha; if(p.isGoal){ ctx.fillStyle = '#00FF00'; } else { ctx.fillStyle = '#CCCCCC'; } ctx.fillRect(Math.round(p.x + jitterX), Math.round(p.y + jitterY), p.w, p.h); // white thin stroke for visibility (2px) if(!p.isGoal){ ctx.lineWidth = 2; ctx.strokeStyle = '#FFFFFF'; ctx.strokeRect(Math.round(p.x + jitterX), Math.round(p.y + jitterY), p.w, p.h); } // visual indicator when Lera is nearby and platform is repairable if(p.decaying && p._started && p.alpha < 1 && this._isLeraNearby(p)){ ctx.save(); ctx.globalAlpha = 0.7; ctx.strokeStyle = '#7ee77e'; ctx.lineWidth = 2; ctx.strokeRect(Math.round(p.x-4), Math.round(p.y-6), p.w+8, p.h+12); ctx.restore(); } ctx.restore(); } // Draw NPCs (behind players but after platforms) — glitched sprites: small grey humanoids with slight shake/alpha if(this._npcs && this._npcs.length){ for(const npc of this._npcs){ if(!npc || typeof npc.x === 'undefined') continue; ctx.save(); // subtle shake when dialog or general instability const shakeX = (Math.random()*2-1) * (this._npcShake * 0.9); const shakeY = (Math.random()*2-1) * (this._npcShake * 0.6); ctx.translate(Math.round(npc.x + shakeX), Math.round(npc.y + shakeY)); // body ctx.globalAlpha = 0.9 - (Math.random()*0.25); ctx.fillStyle = '#909090'; ctx.fillRect(-6, -18, 12, 16); // torso ctx.fillRect(-9, -24, 4, 6); // left head/shoulder glitch ctx.fillRect(5, -24, 4, 6); // right head/shoulder glitch // legs ctx.fillRect(-6, -2, 4, 8); ctx.fillRect(2, -2, 4, 8); // occasional overlay lines to emulate glitch if(Math.random() < 0.18){ ctx.globalAlpha = 0.18 + Math.random()*0.2; ctx.fillStyle = '#b36cff'; ctx.fillRect(-8, -12 + Math.random()*18, 18, 1 + Math.random()*2); } ctx.restore(); } } // Draw players for(const key of ['lera','max']){ const p = this.players[key]; ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.fillStyle = p.color; ctx.fillRect(0,0,p.w,p.h); // eyes ctx.fillStyle = '#071018'; ctx.fillRect(3,8,4,4); ctx.fillRect(p.w-7,8,4,4); // name ctx.fillStyle = '#cfeefb'; ctx.font = '10px monospace'; ctx.fillText(p.name, 0, -4); ctx.restore(); } // Simple glitch overlay when active (max bug) — keep effect global but subtle; real 'shake' is on platforms only if(this.maxBug && this.maxBug.active){ ctx.save(); ctx.globalAlpha = 0.22; ctx.fillStyle = '#b36cff'; for(let y=0;y<this.height;y+=4){ ctx.fillRect(0,y,this.width,1); } ctx.restore(); // slight color shift ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = 0.06; ctx.fillStyle = '#ff7acc'; ctx.fillRect(0,0,this.width,this.height); ctx.restore(); } else if(this.glitchTimer > 0){ ctx.save(); ctx.globalAlpha = Math.min(0.6, this.glitchTimer) * 0.35; ctx.fillStyle = '#b36cff'; ctx.fillRect(0, Math.sin(perf()*30)*4, this.width, 2 + Math.abs(Math.sin(perf()*80))*2); ctx.restore(); } // Draw win message directly on canvas when engine.win is true. if(engine.win){ ctx.save(); ctx.fillStyle = '#bfeebb'; ctx.font = '20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const text = 'Уровень пройден... пока'; ctx.fillText(text, 300, 300); ctx.restore(); } } // Check if Lera (current or not) is near a platform (for repair indicator) _isLeraNearby(p){ const l = this.players.lera; const dx = (l.x + l.w/2) - (p.x + p.w/2); const dy = (l.y + l.h/2) - (p.y + p.h/2); return Math.hypot(dx,dy) < 80; // ~distance threshold } // minimal stub functions to keep API tryRepair(){ /* use LeraAbility directly */ } tryUseBug(){ /* use MaxBug directly */ } _drawBackground(ctx){ // Special backgrounds: codeWasteland primarily for level 4 if(this._levelBackground && this._levelBackground.type === 'codeWasteland'){ ctx.save(); // base deep greenish gradient const g = ctx.createLinearGradient(0,0,0,this.height); g.addColorStop(0,'#07170b'); g.addColorStop(1,'#04100a'); ctx.fillStyle = g; ctx.fillRect(0,0,this.width,this.height); // subtle falling code characters (matrix-like rain, sparse and slow) const chars = "01{}<>/\\[];:=+-_*#@"; const rainCount = Math.max(60, Math.round(80 * (this._levelBackground.rainDensity || 0.9))); ctx.font = '14px monospace'; for(let i=0;i<rainCount;i++){ // use stable-ish pseudo-random by index+time const t = (performance.now()/1000) * 0.3 + i * 0.13; const x = (i * 43) % this.width; const y = Math.abs(Math.sin(t + i) ) * this.height * 0.9 % this.height; const ch = chars.charAt(Math.floor(Math.abs(Math.sin(i + t))*chars.length)); const a = 0.08 + Math.abs(Math.sin(t*1.2 + i))*0.18; ctx.globalAlpha = a * 0.85; ctx.fillStyle = '#6ff08a'; ctx.fillText(ch, Math.round(x), Math.round(y)); } // sand layer (bottom) - characters density heavier; if players fall into sand region they 'sink' const sandY = this._levelBackground.sandAreaY || 460; ctx.fillStyle = 'rgba(2,20,6,0.8)'; ctx.fillRect(0, sandY, this.width, this.height - sandY); // render symbol-sand overlay (animated lines) ctx.globalAlpha = 0.22; ctx.fillStyle = '#2fa64b'; for(let sy = sandY; sy < this.height; sy += 8){ const wob = Math.sin((performance.now()/1000)*0.6 + sy*0.03) * 18; for(let sx = 0; sx < this.width; sx += 14){ const c = (sx + sy) % 7; if((sx + sy) % 3 === 0){ ctx.fillRect(sx + (wob|0), sy + (c%4), 2, 2); } else { ctx.fillText((c%2) ? '.' : ',', sx + (wob|0), sy + 6); } } } // occasional green storm: slight full-screen tint/scanlines if((this._levelBackground.stormTimer || 0) <= 0 && Math.random() < 0.006){ this._levelBackground.stormTimer = 0.9 + Math.random() * 1.2; } if(this._levelBackground.stormTimer > 0){ this._levelBackground.stormTimer = Math.max(0, this._levelBackground.stormTimer - 1/60); ctx.globalAlpha = 0.06 + Math.abs(Math.sin(performance.now()/1000*30))*0.06; ctx.fillStyle = '#1b5b2d'; ctx.fillRect(0,0,this.width,this.height); } ctx.restore(); return; } // default background (kept original) ctx.save(); const g2 = ctx.createLinearGradient(0,0,0,this.height); g2.addColorStop(0,'#08131a'); g2.addColorStop(1,'#05060a'); ctx.fillStyle = g2; ctx.fillRect(0,0,this.width,this.height); ctx.strokeStyle = 'rgba(255,255,255,0.02)'; ctx.lineWidth = 1; for(let x=0;x<this.width;x+=32){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,this.height); ctx.stroke(); } for(let y=0;y<this.height;y+=32){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(this.width,y); ctx.stroke(); } ctx.fillStyle = 'rgba(0,0,0,0.05)'; for(let y=0;y<this.height;y+=2) ctx.fillRect(0,y,this.width,1); ctx.restore(); } _aabb(x1,y1,w1,h1,x2,y2,w2,h2){ return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2; } } function perf(){ return performance.now()/1000; }