1102 lines
29 KiB
JavaScript
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);
|