Files
zombie-game/resources/js/main.js
T
2026-09-08 22:33:51 +02:00

1102 lines
29 KiB
JavaScript

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 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 muted = false;
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;
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 = '';
const title = document.createElement('div');
title.className = 'lobby-players-title';
title.textContent = 'Players';
el.players.appendChild(title);
for (const p of players) {
const row = document.createElement('div');
row.className = 'lobby-player';
row.textContent = `${p.name}${p.id === me.id ? ' (you)' : ''}${p.host ? ' (host)' : ''}`;
if (p.id === me.id) {
row.classList.add('you');
}
el.players.appendChild(row);
}
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 (my.dead && state.state === 'playing' && state.players.some((p) => p.id !== my.id && !p.dead)) {
const specEntry = textRenderer.measure('SPECTATING', { size: 20, fillStyle: '#8a8a8a' });
textRenderer.draw(renderer, 'SPECTATING', viewport.width / 2 - specEntry.width / 2, 60, {
size: 20,
fillStyle: '#8a8a8a',
});
}
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',
});
}
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;
}
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;
const anchor = own
? (own.dead ? frame.players.find((p) => p.id !== me.id && !p.dead) || own : own)
: null;
if (anchor) {
entities.setTarget(anchor.x, anchor.y);
updateCamera(anchor.x, anchor.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,
});
} else {
networking.stopSendingInput();
}
void boss;
};
Lobby.show(true);
Lobby.showPreJoin();
networking.connect();
window.addEventListener('beforeunload', () => {
networking.leave();
});
requestAnimationFrame(gameLoop);