import { Renderer } from './engine/Renderer.js'; import { TextRenderer } from './engine/TextRenderer.js'; import { Input } from './engine/Input.js'; import { ParticleSystem } from './engine/ParticleSystem.js'; import { Effects } from './engine/Effects.js'; import { SoundSystem } from './engine/SoundSystem.js'; import { PICKUP_TYPES } from './game/Pickup.js'; import { NetworkClient } from './net/NetworkClient.js'; import { NetEntities } from './net/NetEntities.js'; const WORLD_W = 1920; const WORLD_H = 1080; const INTERP_DELAY = 60; const canvas = document.getElementById('game'); const renderer = new Renderer(canvas); const textRenderer = new TextRenderer(renderer.gl); const envScheme = String(import.meta.env.VITE_REVERB_SCHEME || '').toLowerCase(); const wsScheme = ['wss', 'https'].includes(envScheme) ? 'wss' : (window.location.protocol === 'https:' ? 'wss' : 'ws'); const wsPort = import.meta.env.VITE_REVERB_PORT || (window.location.port || (wsScheme === 'wss' ? '443' : '80')); const networking = new NetworkClient({ scheme: wsScheme, host: import.meta.env.VITE_REVERB_HOST || window.location.hostname, port: wsPort, key: import.meta.env.VITE_REVERB_KEY || 'game-local-key', }); const entities = new NetEntities(); const viewport = { width: 0, height: 0 }; const resizeToWindow = () => { viewport.width = window.innerWidth; viewport.height = window.innerHeight; const scale = window.devicePixelRatio || 1; const deviceWidth = Math.round(viewport.width * scale); const deviceHeight = Math.round(viewport.height * scale); renderer.resize(deviceWidth, deviceHeight, viewport.width, viewport.height); }; window.addEventListener('resize', resizeToWindow); window.addEventListener('orientationchange', resizeToWindow); resizeToWindow(); const input = new Input(canvas, viewport); const isMobile = input.isMobile; const fullscreenButton = document.getElementById('fullscreen'); const updateFullscreenLabel = () => { fullscreenButton.textContent = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'; }; const toggleFullscreen = () => { if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } else { document.documentElement.requestFullscreen().catch(() => {}); } }; fullscreenButton.addEventListener('click', toggleFullscreen); document.addEventListener('keydown', (event) => { if (event.key === 'f' || event.key === 'F') { toggleFullscreen(); } }); document.addEventListener('fullscreenchange', updateFullscreenLabel); const particles = new ParticleSystem(); const effects = new Effects(); const sounds = new SoundSystem(); let me = { id: localStorage.getItem('zombies.id') || randId(), name: localStorage.getItem('zombies.name') || 'Player', room: localStorage.getItem('zombies.room') || randId(), }; localStorage.setItem('zombies.id', me.id); const camera = { x: 0, y: 0 }; const updateCamera = (cx, cy) => { const targetX = cx - viewport.width / 2; const targetY = cy - viewport.height / 2; camera.x = Math.min(Math.max(0, targetX), Math.max(0, WORLD_W - viewport.width)); camera.y = Math.min(Math.max(0, targetY), Math.max(0, WORLD_H - viewport.height)); }; let autoAim = isMobile; let muted = false; let autoAimNotice = 0; let muteNotice = 0; let gameStarted = false; let lastEventSeq = -1; let lastMuzzle = 0; let shakeIntensity = 0; let flashAlpha = 0; let flashColor = [1, 1, 1, 1]; let damageFlash = 0; const bloodPools = []; const addBloodPool = (x, y, size = 1) => { if (bloodPools.length >= 45) bloodPools.shift(); const radius = (12 + Math.random() * 10) * Math.max(0.6, size / 24); const blobs = []; for (let i = 0; i < 7; i += 1) { const angle = Math.random() * Math.PI * 2; const dist = Math.random() * 0.55; blobs.push({ x: Math.cos(angle) * dist, y: Math.sin(angle) * dist, size: 0.35 + Math.random() * 0.45, alpha: 0.7 + Math.random() * 0.3, }); } const droplets = []; for (let i = 0; i < 5; i += 1) { const angle = Math.random() * Math.PI * 2; const dist = 0.45 + Math.random() * 0.6; droplets.push({ x: Math.cos(angle) * dist, y: Math.sin(angle) * dist, size: 0.12 + Math.random() * 0.16, }); } bloodPools.push({ x, y, r: radius, alpha: 0.5 + Math.random() * 0.15, blobs, droplets, life: 0, maxLife: 8 + Math.random() * 4, }); }; const drawBloodPools = (dt) => { for (let i = bloodPools.length - 1; i >= 0; i -= 1) { const pool = bloodPools[i]; pool.life += dt; if (pool.life >= pool.maxLife) { bloodPools.splice(i, 1); continue; } let alpha = pool.alpha; if (pool.life > 1.2) { alpha = pool.alpha * Math.max(0, 1 - (pool.life - 1.2) / (pool.maxLife - 1.2)); } const r = pool.r * Math.min(1, 0.4 + pool.life * 5); for (const blob of pool.blobs) { const w = r * blob.size; renderer.drawTexture( renderer.whiteTexture, pool.x + blob.x * r * 2 - w / 2, pool.y + blob.y * r * 2 - w / 2, w, w, [0.35, 0.045, 0.045, alpha * blob.alpha], ); } const coreW = r * 0.7; renderer.drawTexture(renderer.whiteTexture, pool.x - coreW / 2, pool.y - coreW / 2, coreW, coreW, [0.14, 0.02, 0.02, alpha * 0.85]); for (const drop of pool.droplets) { const w = r * drop.size; renderer.drawTexture( renderer.whiteTexture, pool.x + drop.x * r * 2 - w / 2, pool.y + drop.y * r * 2 - w / 2, w, w, [0.42, 0.055, 0.055, alpha * 0.8], ); } } }; const embers = []; const initEmbers = () => { embers.length = 0; for (let i = 0; i < 70; i += 1) { embers.push({ x: Math.random() * WORLD_W, y: Math.random() * WORLD_H, size: 1.5 + Math.random() * 2.5, vx: (Math.random() - 0.5) * 24, vy: -(18 + Math.random() * 40), flicker: Math.random() * Math.PI * 2, }); } }; const drawEmbers = (dt, t) => { if (embers.length === 0) initEmbers(); for (const ember of embers) { ember.x += ember.vx * dt + Math.sin(t * 2 + ember.flicker) * 12 * dt; ember.y += ember.vy * dt; if (ember.y < -12) { ember.y = WORLD_H + 12; ember.x = Math.random() * WORLD_W; } else if (ember.x < -12) { ember.x = WORLD_W + 12; } else if (ember.x > WORLD_W + 12) { ember.x = -12; } const alpha = 0.3 + 0.35 * Math.abs(Math.sin(t * 3 + ember.flicker)); renderer.drawTexture(renderer.whiteTexture, ember.x - ember.size / 2, ember.y - ember.size / 2, ember.size, ember.size, [1, 0.68, 0.25, alpha]); } }; const addShake = (amount) => { shakeIntensity = Math.min(26, shakeIntensity + amount); }; const addFlash = (color, alpha) => { flashColor = color; flashAlpha = Math.max(flashAlpha, alpha); }; const playerDamageImpact = (x, y) => { effects.glow(x, y, { radius: 80, life: 0.3, color: [1, 0.25, 0.2, 1], additive: true, }); effects.ring(x, y, { endRadius: 90, width: 4, life: 0.35, color: [1, 0.3, 0.25, 1], additive: true, }); }; const playerDeathExplosion = (x, y) => { effects.ring(x, y, { endRadius: 240, width: 10, life: 0.9, color: [1, 0.4, 0.2, 1], additive: true, }); effects.ring(x, y, { endRadius: 140, width: 5, life: 0.7, color: [1, 1, 1, 1], additive: true, }); effects.glow(x, y, { radius: 160, life: 0.7, color: [1, 0.45, 0.2, 1], additive: true, }); particles.burst(x, y, { count: 40, speed: 280, life: 0.6, size: 6, color: [1, 1, 1, 1], gravity: 200, shape: 'glow', additive: true, }); particles.burst(x, y, { count: 30, speed: 180, life: 1, size: 8, color: [1, 0.35, 0.1, 1], gravity: 100, shape: 'glow', additive: true, }); }; const hitSpark = (x, y, color) => { particles.burst(x, y, { count: 6, speed: 170, life: 0.22, size: 3, color: color || [1, 0.9, 0.45, 1], gravity: 130, shape: 'glow', additive: true, }); effects.ring(x, y, { endRadius: 24, width: 2, life: 0.16, color: color || [1, 0.85, 0.4, 1], additive: true, }); }; const emitPortal = (x, y, color, scale = 1) => { const rgb = color.slice(0, 3); effects.glow(x, y, { radius: 70 * scale, life: 0.45, color: rgb, additive: true, }); effects.glow(x, y, { radius: 36 * scale, life: 0.3, color: [1, 1, 1, 1], additive: true, }); effects.ring(x, y, { endRadius: 70 * scale, width: 3 * scale, life: 0.4, color: rgb, additive: true, }); particles.burst(x, y, { count: Math.round(14 * scale), speed: 150 * scale, life: 0.45, size: 4 * scale, color: rgb, gravity: 0, shape: 'glow', additive: true, }); particles.burst(x, y, { count: Math.round(6 * scale), speed: 70 * scale, life: 0.5, size: 5 * scale, color: [1, 1, 1, 1], gravity: 0, shape: 'glow', additive: true, }); }; const floatingMessages = []; const addMessage = (text, x, y, life = 1.5, screen = false) => { floatingMessages.push({ text, x, y, t: 0, life, screen }); }; const pickupColor = (type) => { if (!PICKUP_TYPES[type]) return [1, 1, 1, 1]; return PICKUP_TYPES[type].color; }; const pickupName = (type) => { if (!PICKUP_TYPES[type]) return type; return PICKUP_TYPES[type].name; }; const killCrash = (x, y, color, big) => { addBloodPool(x, y, big ? 40 : 24); effects.glow(x, y, { radius: 60 + (big ? 50 : 20), life: 0.5, color, additive: true, }); effects.ring(x, y, { endRadius: 60 + (big ? 70 : 34), width: big ? 4 : 3, life: 0.5, color, additive: true, }); particles.burst(x, y, { count: big ? 26 : 18, speed: big ? 150 : 170, life: 0.5, size: big ? 6 : 5, color, gravity: 400, drag: 2, shape: 'glow', additive: true, }); particles.burst(x, y, { count: big ? 12 : 8, speed: 60, life: 0.35, size: 5, color: [1, 1, 1, 1], gravity: 200, shape: 'glow', additive: true, }); }; const bossApproach = (x, y) => { sounds.setBossMode(true); sounds.bossEntrance(); emitPortal(x, y, [0.45, 0.05, 0.1, 1], 3.5); effects.ring(x, y, { endRadius: Math.max(WORLD_W, WORLD_H) * 0.6, width: 10, life: 0.8, color: [1, 0.3, 0.25, 1], additive: true, }); effects.ring(x, y, { endRadius: Math.max(WORLD_W, WORLD_H) * 0.35, width: 4, life: 0.6, color: [1, 1, 1, 1], additive: true, }); const arcColor = [0.9, 0.3, 0.3, 1]; for (let i = 0; i < 5; i += 1) { effects.arc(x, -60, Math.random() * WORLD_W, Math.random() * WORLD_H * 0.4, { segments: 10, width: 3, life: 0.35, color: arcColor, jitter: 40, additive: true, }); } addMessage('BOSS APPROACHES', viewport.width / 2, viewport.height / 3, 3, true); }; const bossDefeated = (x, y) => { sounds.setBossMode(false); addShake(16); addFlash([1, 1, 1], 0.55); addMessage('BOSS DEFEATED', viewport.width / 2, viewport.height / 3, 3, true); effects.ring(x, y, { endRadius: 320, width: 12, life: 0.8, color: [1, 0.5, 0.2, 1], additive: true, }); effects.ring(x, y, { endRadius: 190, width: 6, life: 0.6, color: [1, 1, 1, 1], additive: true, }); for (let i = 0; i < 8; i += 1) { const angle = (i / 8) * Math.PI * 2; effects.arc(x, y, x + Math.cos(angle) * 220, y + Math.sin(angle) * 220, { segments: 9, width: 3, life: 0.4, color: [1, 0.7, 0.4, 1], jitter: 30, additive: true, }); } particles.burst(x, y, { count: 70, speed: 340, life: 0.9, size: 8, color: [0.45, 0.05, 0.1, 1], gravity: 300, }); particles.burst(x, y, { count: 50, speed: 220, life: 0.7, size: 6, color: [1, 0.6, 0.1, 1], gravity: 200, }); }; const pickupFizz = (x, y, type) => { const color = pickupColor(type).slice(0, 3); effects.glow(x, y, { radius: 80, life: 0.45, color, additive: true, }); effects.ring(x, y, { endRadius: 80, width: 4, life: 0.45, color: [1, 1, 1, 1], additive: true, }); particles.burst(x, y, { count: 12, speed: 140, life: 0.5, size: 5, color, gravity: 0, shape: 'glow', additive: true, }); particles.burst(x, y, { count: 8, speed: 260, life: 0.35, size: 3, color: [1, 1, 1, 1], gravity: 0, }); }; const maxFizz = (x, y) => { particles.burst(x, y - 24, { count: 26, speed: 200, spread: Math.PI * 2, life: 0.9, size: 5, color: [1, 0.85, 0.25, 1], gravity: 220, }); particles.burst(x, y - 24, { count: 10, speed: 320, spread: Math.PI * 2, life: 0.5, size: 3, color: [1, 0.6, 0.1, 1], gravity: 60, }); }; const Lobby = (() => { const el = { lobby: document.getElementById('lobby'), status: document.getElementById('lobby-status'), players: document.getElementById('lobby-players'), name: document.getElementById('name'), room: document.getElementById('room'), join: document.getElementById('lobby-join'), confirm: document.getElementById('lobby-join-confirm'), host: document.getElementById('lobby-host'), joinForm: document.getElementById('join-form'), start: document.getElementById('lobby-start'), prejoin: document.getElementById('prejoin'), inroom: document.getElementById('inroom'), roomCode: document.getElementById('room-code'), roomCopy: document.getElementById('room-copy'), gameover: document.getElementById('gameover'), scores: document.getElementById('gameover-scores'), wait: document.getElementById('gameover-wait'), again: document.getElementById('gameover-again'), }; el.name.value = me.name; el.room.value = localStorage.getItem('zombies.room') || ''; const setStatus = (text, error = false) => { el.status.textContent = text; el.status.classList.toggle('error', error); }; const showPreJoin = () => { el.prejoin.classList.remove('hidden'); el.inroom.classList.add('hidden'); el.players.textContent = ''; }; const showInRoom = () => { el.prejoin.classList.add('hidden'); el.inroom.classList.remove('hidden'); }; const refreshPlayers = (players) => { if (!players || players.length === 0) { el.players.textContent = ''; return; } const my = players.find((p) => p.id === me.id) || null; el.players.textContent = `In room: ${players.map((p) => `${p.name}${p.host ? ' (host)' : ''}${p.id === me.id ? ' (you)' : ''}`).join(', ')}`; el.roomCode.textContent = me.room; showInRoom(); if (players.length === 1) { setStatus('Room ready — share the code to let friends in.'); } else if (my && my.host) { setStatus('Everyone is in — press Start when ready.'); } else { setStatus('Waiting for the host to start...'); } }; const setStartEnabled = (enabled) => { el.start.disabled = !enabled; el.start.style.display = enabled ? '' : 'none'; }; const show = (visible) => { el.lobby.classList.toggle('hidden', !visible); }; const hideGameOver = () => { el.gameover.classList.remove('show'); }; const showGameOver = (players) => { const scores = [...players] .sort((a, b) => b.score - a.score) .map((p) => `${p.name}: ${p.score}${p.id === me.id ? ' (you)' : ''}${p.host ? ' (host)' : ''}`) .join('\n'); const my = players.find((p) => p.id === me.id) || null; el.scores.textContent = scores; if (my && my.host) { el.again.style.display = ''; el.wait.textContent = ''; } else { el.again.style.display = 'none'; el.wait.textContent = 'Waiting for the host to play again...'; } el.gameover.classList.add('show'); }; const joinNow = (asHost) => { me.name = (el.name.value || 'Player').trim().slice(0, 16); localStorage.setItem('zombies.name', me.name); localStorage.setItem('zombies.room', me.room); if (asHost) { networking.create(me.room, { id: me.id, name: me.name }); } else { networking.join(me.room, { id: me.id, name: me.name }); } setStatus('Connecting...'); }; const hostNow = () => { me.room = randId(); joinNow(true); }; const joinWithCode = () => { me.room = (el.room.value || '').trim().slice(0, 16); if (!me.room) { setStatus('Enter a room code to join.', true); return; } joinNow(false); }; el.host.addEventListener('click', hostNow); el.join.addEventListener('click', () => { el.joinForm.classList.remove('hidden'); el.room.focus(); }); el.confirm.addEventListener('click', joinWithCode); el.start.addEventListener('click', () => { networking.start(); }); el.roomCopy.addEventListener('click', async () => { const ok = await copyText(me.room); setStatus(ok ? `Room code ${me.room} copied.` : 'Copy failed - select the code manually.', !ok); }); el.room.addEventListener('keydown', (event) => { if (event.key === 'Enter') joinWithCode(); }); networking.onStateChange = ({ type, connected }) => { if (type === 'connection') { setStatus(connected ? 'Connected - host a room or join one.' : 'Disconnected - reconnecting...'); } }; networking.onRoomError = ({ code } = {}) => { const messages = { room_not_found: 'Room code not found - check the code with the host and try again.', room_exists: 'That code is taken by another host - use your own code.', room_not_open: 'That game is already in progress - join a lobby room instead.', room_full: 'That room is full - try another code.', }; setStatus(messages[code] || 'Could not join that room.', true); networking.abandonRoom(); showPreJoin(); }; el.again.addEventListener('click', () => { networking.reset(); hideGameOver(); }); el.name.focus(); return { setStatus, refreshPlayers, setStartEnabled, show, hostNow, joinWithCode, hideGameOver, showGameOver, showPreJoin }; })(); function copyText(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text).then( () => true, () => false, ); } const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch { ok = false; } document.body.removeChild(ta); return Promise.resolve(ok); } function randId() { const chars = 'abcdefghjkmnpqrstuvwxyz23456789'; let out = ''; for (let i = 0; i < 5; i += 1) { out += chars[Math.floor(Math.random() * chars.length)]; } return out; } const mouseWorld = () => ({ x: camera.x + input.mouse.x, y: camera.y + input.mouse.y, }); const interpolate = (now) => { const { prev, prevAt, curr, currAt } = networking; if (!curr) return null; let alpha = 0; if (prev && curr.seq > prev.seq) { const span = currAt - prevAt; if (span > 0) { alpha = Math.min(1, Math.max(0, (now - INTERP_DELAY - prevAt) / span)); } } const prevPlayers = new Map(); for (const p of prev?.players || []) prevPlayers.set(p.id, p); const prevEntities = new Map(); for (const e of prev?.entities || []) prevEntities.set(`${e.d}#${e.eid}`, e); const players = curr.players.map((p) => { const o = prevPlayers.get(p.id); if (!o) return { ...p }; return { ...p, x: o.x + (p.x - o.x) * alpha, y: o.y + (p.y - o.y) * alpha, }; }); const list = curr.entities.map((e) => { const key = `${e.d}#${e.eid}`; const o = prevEntities.get(key); if (!o) return { ...e }; return { ...e, x: o.x + (e.x - o.x) * alpha, y: o.y + (e.y - o.y) * alpha, }; }); return { players, entities: list }; }; const processEvents = (events) => { for (const event of events) { const { k: kind, x, y, d } = event; switch (kind) { case 'portal': emitPortal(x, y, d.color || [1, 1, 1], d.scale || 1); break; case 'boss': bossApproach(x, y); break; case 'kill': killCrash(x, y, d.color || [1, 1, 1], d.big || d.boss); sounds.death(Boolean(d.big || d.boss)); if (d.boss) { bossDefeated(x, y); } break; case 'bullethit': hitSpark(x, y, d.color); sounds.hit(); break; case 'playerdamage': playerDamageImpact(x, y); sounds.damage(); if (d.id === me.id) { addShake(4); damageFlash = 0.5; } break; case 'playerdeath': playerDeathExplosion(x, y); sounds.explosion(); if (d.id === me.id) { addShake(20); sounds.playGameOverMusic(); } else { addFlash([1, 0.4, 0.2], 0.15); } break; case 'bloodpool': addBloodPool(x, y, d.s || 24); break; case 'pickup': pickupFizz(x, y, d.type); if (d.id === me.id) { sounds.pickup(); addMessage(pickupName(d.type), x, y - 32); } break; case 'pickupmax': maxFizz(x, y); if (d.id === me.id) { addMessage(`${pickupName(d.type)} MAX`, x, y - 32, 1); } break; case 'gameover': sounds.setBossMode(false); break; default: break; } } }; const drawScreenEffects = (state, my) => { renderer.setViewOffset( (Math.random() - 0.5) * 2 * shakeIntensity, (Math.random() - 0.5) * 2 * shakeIntensity, ); const boss = (state.entities || []).find((entity) => entity.d === 'boss'); if (boss && boss.hp >= 0) { const barW = Math.min(360, viewport.width * 0.4); const barH = 14; const bx = viewport.width / 2 - barW / 2; const by = 16; renderer.drawTexture(renderer.whiteTexture, bx - 2, by - 2, barW + 4, barH + 4, [0, 0, 0, 0.6]); renderer.drawTexture(renderer.whiteTexture, bx, by, barW, barH, [0.45, 0.08, 0.12, 0.9]); const hpRatio = Math.max(0, boss.hp / boss.mhp); renderer.drawTexture(renderer.whiteTexture, bx, by, barW * hpRatio, barH, [1, 0.25, 0.3, 1]); } if (damageFlash > 0) { renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, viewport.height, [ 0.85, 0.05, 0.1, Math.min(0.35, damageFlash * 0.4), ]); } if (my && my.hp === 1 && !my.dead) { const pulse = 0.1 + 0.08 * Math.sin(performance.now() * 0.006); const l = 12; renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, l, [0.85, 0.1, 0.15, pulse]); renderer.drawTexture(renderer.whiteTexture, 0, viewport.height - l, viewport.width, l, [0.85, 0.1, 0.15, pulse]); renderer.drawTexture(renderer.whiteTexture, 0, 0, l, viewport.height, [0.85, 0.1, 0.15, pulse]); renderer.drawTexture(renderer.whiteTexture, viewport.width - l, 0, l, viewport.height, [0.85, 0.1, 0.15, pulse]); } if (flashAlpha > 0) { renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, viewport.height, [ flashColor[0], flashColor[1], flashColor[2], Math.min(0.8, flashAlpha), ]); } }; const drawWorldMessages = (t) => { for (let i = floatingMessages.length - 1; i >= 0; i -= 1) { const message = floatingMessages[i]; if (message.screen) continue; message.t += t; if (message.t >= message.life) { floatingMessages.splice(i, 1); continue; } textRenderer.draw(renderer, message.text, message.x - message.text.length * 5.5, message.y - message.t * 40, { size: 20, fillStyle: '#fff', }); } }; const drawScreenMessages = (t) => { for (let i = floatingMessages.length - 1; i >= 0; i -= 1) { const message = floatingMessages[i]; if (!message.screen) continue; message.t += t; if (message.t >= message.life) { floatingMessages.splice(i, 1); continue; } const entry = textRenderer.measure(message.text, { size: 32, fillStyle: '#fff' }); textRenderer.draw(renderer, message.text, viewport.width / 2 - entry.width / 2, 200 - message.t * 20, { size: 32, fillStyle: '#fff', }); } }; const drawHud = (state, my, now, t) => { if (!my) return; textRenderer.draw(renderer, `Score: ${my.score}`, 12, 12, { size: 20, fillStyle: '#fff' }); textRenderer.draw(renderer, `HP: ${my.hp}`, 12, 40, { size: 20, fillStyle: '#fff' }); if (autoAim) { const aaEntry = textRenderer.measure('AUTO-AIM', { size: 14, fillStyle: '#4fe3ff' }); textRenderer.draw(renderer, 'AUTO-AIM', viewport.width - aaEntry.width - 12, 12, { size: 14, fillStyle: '#4fe3ff', }); } let uy = 68; for (const type of ['rapid', 'damage', 'multishot', 'speed']) { const level = my.up?.[type] ?? 0; if (level <= 0) continue; const { name, color } = PICKUP_TYPES[type]; renderer.drawTexture(renderer.whiteTexture, 12, uy, 12, 12, [...color.slice(0, 3), 0.5]); textRenderer.draw(renderer, `${name} x${level}${level >= 3 ? ' (MAX)' : ''}`, 30, uy + 2, { size: 14, fillStyle: '#fff', }); uy += 20; } if (muteNotice > 0) { muteNotice -= t; const notice = muted ? 'Sound OFF' : 'Sound ON'; const entry = textRenderer.measure(notice, { size: 22, fillStyle: '#fff' }); textRenderer.draw(renderer, notice, viewport.width / 2 - entry.width / 2, viewport.height / 2 - 60, { size: 22, fillStyle: '#fff', }); } if (autoAimNotice > 0) { autoAimNotice -= t; const notice = autoAim ? 'Auto-aim ON' : 'Auto-aim OFF'; const entry = textRenderer.measure(notice, { size: 22, fillStyle: '#fff' }); textRenderer.draw(renderer, notice, viewport.width / 2 - entry.width / 2, viewport.height / 2 - 20, { size: 22, fillStyle: '#4fe3ff', }); } void now; }; let lastTime = performance.now(); const gameLoop = (now) => { requestAnimationFrame(gameLoop); const rawDt = Math.min((now - lastTime) / 1000, 0.1); lastTime = now; const t = now / 1000; shakeIntensity = shakeIntensity > 0.2 ? shakeIntensity * Math.max(0, 1 - rawDt * 7) : 0; flashAlpha = Math.max(0, flashAlpha - rawDt * 2.4); damageFlash = Math.max(0, damageFlash - rawDt * 2); if (input.pressed.has('m')) { input.clearPressed(); muted = sounds.toggleMuted(); muteNotice = 1.2; } if (!isMobile && input.pressed.has('q')) { input.clearPressed(); autoAim = !autoAim; autoAimNotice = 1.2; } renderer.clear(0, 0, 0, 1); const snapshot = networking.curr; const frame = snapshot ? interpolate(now) : null; const own = frame ? frame.players.find((p) => p.id === me.id) || null : null; if (own) { entities.setTarget(own.x, own.y); updateCamera(own.x, own.y); } else { updateCamera(WORLD_W / 2, WORLD_H / 2); } if (own && input.mouseDown && !own.dead) { if (now - lastMuzzle > 170) { lastMuzzle = now; const { aimX, aimY } = own; particles.burst(own.x + aimX * 22, own.y + aimY * 22, { count: 3, speed: 120, life: 0.14, size: 4, color: [1, 0.85, 0.3, 1], gravity: 0, direction: Math.atan2(aimY, aimX), spread: 0.4, }); sounds.shoot(); } } renderer.setViewOffset(camera.x, camera.y); drawBloodPools(rawDt); drawEmbers(rawDt, t); if (!snapshot) { particles.update(rawDt); particles.draw(renderer); effects.update(rawDt); effects.draw(renderer); drawWorldMessages(rawDt); renderer.setViewOffset(0, 0); drawScreenEffects(snapshot || { players: [], entities: [] }, null); return; } if (snapshot.state === 'lobby') { const my = snapshot.players.find((p) => p.id === me.id) || null; Lobby.refreshPlayers(snapshot.players); Lobby.setStartEnabled(Boolean(my && my.host)); Lobby.hideGameOver(); Lobby.show(true); gameStarted = false; } const boss = entities.draw(frame.entities, frame.players, renderer, textRenderer, t); particles.update(rawDt); particles.draw(renderer); effects.update(rawDt); effects.draw(renderer); drawWorldMessages(rawDt); if (snapshot.seq !== lastEventSeq) { lastEventSeq = snapshot.seq; processEvents(snapshot.events || []); } if (snapshot.state === 'playing' && !gameStarted) { gameStarted = true; floatingMessages.length = 0; bloodPools.length = 0; sounds.stingerStart(); sounds.startMusic(); Lobby.show(false); } if (snapshot.state === 'playing') { sounds.setIntensity(Math.min(1, snapshot.elapsed / 120)); } if (snapshot.state === 'over') { sounds.setBossMode(false); Lobby.showGameOver(snapshot.players); } renderer.setViewOffset(0, 0); const mySnapshot = frame.players.find((p) => p.id === me.id) || frame.players[0] || null; drawScreenMessages(rawDt); drawScreenEffects(snapshot, mySnapshot); drawHud(snapshot, mySnapshot, now, rawDt); const mouse = mouseWorld(); if (snapshot.state === 'playing' && own && !own.dead) { networking.sendInput({ id: me.id, keys: [...input.keys], aim: { x: mouse.x - own.x, y: mouse.y - own.y }, fire: input.mouseDown, autoAim, }); } else { networking.stopSendingInput(); } void boss; }; Lobby.show(true); Lobby.showPreJoin(); networking.connect(); window.addEventListener('beforeunload', () => { networking.leave(); }); requestAnimationFrame(gameLoop);