initial commit

This commit is contained in:
armel committed 2026-09-07 20:37:40 +02:00
commit e8c3f9862a
193 files changed
+29795

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: #000;
}
#game {
display: block;
width: 100vw;
height: 100vh;
background: #000;
}
#fullscreen {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 10;
padding: 8px 14px;
border: 1px solid #333;
border-radius: 4px;
background: #fff;
cursor: pointer;
font: 14px sans-serif;
}
#lobby {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
}
#lobby.hidden {
display: none;
}
#lobby-panel {
width: min(420px, 90vw);
padding: 28px 32px;
border: 1px solid #222;
border-radius: 8px;
background: #0c0c0e;
color: #eee;
font: 15px/1.5 sans-serif;
}
#lobby-panel h1 {
margin: 0 0 4px;
font-size: 34px;
letter-spacing: 6px;
color: #fff;
}
#lobby-panel .sub {
margin: 0 0 20px;
color: #888;
font-size: 13px;
}
#lobby-panel label {
display: block;
margin: 12px 0 4px;
font-size: 13px;
color: #aaa;
}
#lobby-panel input {
width: 100%;
box-sizing: border-box;
padding: 9px 10px;
border: 1px solid #333;
border-radius: 4px;
background: #111;
color: #eee;
font: 14px sans-serif;
}
#lobby-panel .row {
display: flex;
gap: 10px;
}
#lobby-panel .row > div {
flex: 1;
}
.mode {
display: flex;
gap: 10px;
margin-top: 22px;
}
.mode button {
flex: 1;
padding: 11px;
border: 0;
border-radius: 4px;
color: #fff;
font: 15px sans-serif;
font-weight: 600;
cursor: pointer;
}
#lobby-host {
background: #1971c2;
}
#lobby-host:hover {
background: #228be6;
}
#lobby-join {
background: #2f9e44;
}
#lobby-join:hover {
background: #37b24d;
}
#join-form {
margin-top: 14px;
}
#join-form.hidden {
display: none;
}
#lobby-join-confirm {
flex: 0 0 auto;
padding: 9px 18px;
border: 0;
border-radius: 4px;
background: #2f9e44;
color: #fff;
font: 14px sans-serif;
font-weight: 600;
cursor: pointer;
}
#lobby-join-confirm:hover {
background: #37b24d;
}
#prejoin.hidden,
#inroom.hidden {
display: none;
}
.room-code {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
#room-code {
flex: 1;
padding: 9px 10px;
border: 1px solid #333;
border-radius: 4px;
background: #111;
color: #f5c518;
font: 18px monospace;
letter-spacing: 2px;
text-align: center;
}
#room-copy {
padding: 8px 12px;
border: 1px solid #333;
border-radius: 4px;
background: #111;
color: #eee;
font: 13px sans-serif;
cursor: pointer;
}
#room-copy:hover {
background: #1a1a1f;
}
#lobby-start {
width: 100%;
margin-top: 10px;
padding: 11px;
border: 0;
border-radius: 4px;
background: #1971c2;
color: #fff;
font: 15px sans-serif;
font-weight: 600;
cursor: pointer;
}
#lobby-start:disabled {
background: #25252a;
color: #666;
cursor: not-allowed;
}
#lobby-status {
margin-top: 14px;
font-size: 13px;
color: #8a8a8a;
min-height: 18px;
}
#lobby-status.error {
color: #e8590c;
}
#lobby-players {
margin-top: 14px;
font-size: 13px;
color: #bbb;
min-height: 18px;
}
#gameover {
position: fixed;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.78);
}
#gameover.show {
display: flex;
}
#gameover-panel {
width: min(380px, 90vw);
padding: 28px 32px;
border: 1px solid #333;
border-radius: 8px;
background: #0c0c0e;
color: #eee;
text-align: center;
font: 15px/1.5 sans-serif;
}
#gameover-panel h2 {
margin: 0 0 6px;
font-size: 36px;
letter-spacing: 5px;
color: #e03131;
}
#gameover-scores {
margin: 16px 0;
font-size: 14px;
color: #bbb;
min-height: 40px;
}
#gameover-wait {
min-height: 20px;
margin-top: 10px;
font-size: 13px;
color: #8a8a8a;
}
#gameover-again {
width: 100%;
padding: 11px;
border: 0;
border-radius: 4px;
background: #2f9e44;
color: #fff;
font: 15px sans-serif;
font-weight: 600;
cursor: pointer;
}
#gameover-again:hover {
background: #37b24d;
}
+231
View File
@@ -0,0 +1,231 @@
const MAX_RINGS = 40;
const MAX_GLOWS = 60;
const MAX_STREAKS = 90;
const MAX_ARCS = 28;
const easeOut = (t) => 1 - (1 - t) * (1 - t);
export class Effects {
constructor() {
this.rings = [];
this.glows = [];
this.streaks = [];
this.arcs = [];
}
ring(x, y, {
endRadius = 140,
width = 5,
life = 0.5,
color = [1, 1, 1, 1],
additive = true,
} = {}) {
if (this.rings.length >= MAX_RINGS) this.rings.shift();
this.rings.push({
x, y,
radius: endRadius * 0.12,
endRadius,
width,
life,
maxLife: life,
color,
additive,
});
}
glow(x, y, {
radius = 90,
life = 0.35,
color = [1, 1, 1, 1],
additive = true,
} = {}) {
if (this.glows.length >= MAX_GLOWS) this.glows.shift();
this.glows.push({
x, y,
radius: radius * 0.2,
endRadius: radius,
life,
maxLife: life,
color,
additive,
});
}
streak(x, y, angle, length, {
width = 3,
life = 0.25,
fade = true,
color = [1, 1, 1, 1],
additive = true,
tail = 0.7,
} = {}) {
if (this.streaks.length >= MAX_STREAKS) this.streaks.shift();
this.streaks.push({
x: x - Math.cos(angle) * length * tail,
y: y - Math.sin(angle) * length * tail,
angle,
length,
width,
life,
maxLife: life,
fade,
color,
additive,
tail,
});
}
arc(x1, y1, x2, y2, {
segments = 8,
width = 3,
life = 0.3,
color = [1, 1, 1, 1],
jitter = 26,
additive = true,
} = {}) {
if (this.arcs.length >= MAX_ARCS) this.arcs.shift();
this.arcs.push({
x1, y1, x2, y2,
segments,
width,
life,
maxLife: life,
color,
jitter,
additive,
seed: Math.random() * 100,
});
}
update(dt) {
const tick = (list) => {
for (let i = list.length - 1; i >= 0; i -= 1) {
list[i].life -= dt;
if (list[i].life <= 0) list.splice(i, 1);
}
};
tick(this.rings);
tick(this.glows);
tick(this.streaks);
tick(this.arcs);
}
draw(renderer) {
for (const list of [this.glows, this.rings, this.streaks, this.arcs]) {
const additive = list.filter((effect) => effect.additive);
const normal = list.filter((effect) => !effect.additive);
if (additive.length > 0) renderer.setBlendMode('additive');
for (const effect of additive) this._drawEffect(renderer, effect);
if (additive.length > 0) renderer.setBlendMode('normal');
for (const effect of normal) this._drawEffect(renderer, effect);
}
}
_drawEffect(renderer, effect) {
const t = 1 - effect.life / effect.maxLife;
const alpha = effect.life / effect.maxLife;
if (effect.x1 !== undefined) {
this._drawArc(renderer, effect, t, alpha);
} else if (effect.angle !== undefined) {
this._drawStreak(renderer, effect, t, alpha);
} else if (effect.width !== undefined) {
this._drawRing(renderer, effect, t, alpha);
} else {
this._drawGlow(renderer, effect, t, alpha);
}
}
_drawGlow(renderer, effect, t, alpha) {
const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t);
renderer.drawTexture(
renderer.glowTexture,
effect.x - radius,
effect.y - radius,
radius * 2,
radius * 2,
[
effect.color[0],
effect.color[1],
effect.color[2],
effect.color[3] * alpha,
],
);
}
_drawRing(renderer, effect, t, alpha) {
const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t);
const fade = Math.min(1, alpha * 1.3);
renderer.drawTexture(
renderer.ringTexture,
effect.x - radius,
effect.y - radius,
radius * 2,
radius * 2,
[
effect.color[0],
effect.color[1],
effect.color[2],
Math.max(0, effect.color[3] * fade),
],
);
}
_drawStreak(renderer, effect, t, alpha) {
const progress = easeOut(t);
const len = effect.length * (1 - progress * (1 - effect.tail));
const fade = effect.fade ? alpha : 1;
renderer.drawTexture(
renderer.glowTexture,
effect.x,
effect.y,
len,
effect.width,
[
effect.color[0],
effect.color[1],
effect.color[2],
effect.color[3] * fade,
],
effect.angle,
);
}
_drawArc(renderer, effect, t, alpha) {
const { x1, y1, x2, y2, segments, width, jitter, seed, color } = effect;
const wobble = 0.45 + 0.55 * Math.abs(Math.sin(seed + t * 34));
const amp = jitter * alpha * wobble;
let prevX = x1;
let prevY = y1;
for (let i = 1; i <= segments; i += 1) {
const u = i / segments;
const edge = (i === segments) ? 0 : 1;
const px = x1 + (x2 - x1) * u + (Math.random() - 0.5) * 2 * amp * edge;
const py = y1 + (y2 - y1) * u + (Math.random() - 0.5) * 2 * amp * edge;
const len = Math.hypot(px - prevX, py - prevY);
if (len > 0) {
renderer.drawTexture(
renderer.whiteTexture,
prevX,
prevY,
len,
width,
[color[0], color[1], color[2], color[3] * alpha],
Math.atan2(py - prevY, px - prevX),
);
}
prevX = px;
prevY = py;
}
}
clear() {
this.rings.length = 0;
this.glows.length = 0;
this.streaks.length = 0;
this.arcs.length = 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Vector2D } from './Vector2D.js';
export class GameObject {
constructor(x = 0, y = 0) {
this.pos = new Vector2D(x, y);
this.velocity = new Vector2D(0, 0);
this.width = 0;
this.height = 0;
}
update(dt = 1 / 60) {
this.pos = this.velocity.mulS(dt).addV(this.pos);
}
intersect(other) {
return (
Math.abs(this.pos.x - other.pos.x) * 2 < this.width + other.width &&
Math.abs(this.pos.y - other.pos.y) * 2 < this.height + other.height
);
}
draw() {}
remove(container) {
if (!container) return;
const index = container.indexOf(this);
if (index !== -1) container.splice(index, 1);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Vector2D } from './Vector2D.js';
export class Input {
constructor(canvas, viewport) {
this.keys = new Set();
this.pressed = new Set();
this.mouse = new Vector2D(viewport.width / 2, viewport.height / 2);
this.mouseDown = false;
this.mouseClicked = false;
window.addEventListener('keydown', (event) => {
this.keys.add(event.key.toLowerCase());
if (!event.repeat) {
this.pressed.add(event.key.toLowerCase());
}
if (event.key === ' ') {
event.preventDefault();
if (
document.activeElement &&
document.activeElement.tagName === 'BUTTON' &&
typeof document.activeElement.blur === 'function'
) {
document.activeElement.blur();
}
}
});
window.addEventListener('keyup', (event) => {
this.keys.delete(event.key.toLowerCase());
});
window.addEventListener('blur', () => {
this.keys.clear();
this.pressed.clear();
this.mouseDown = false;
});
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * viewport.width;
this.mouse.y = ((event.clientY - rect.top) / rect.height) * viewport.height;
});
canvas.addEventListener('mousedown', (event) => {
if (event.button === 0) {
this.mouseDown = true;
this.mouseClicked = true;
}
});
window.addEventListener('mouseup', () => {
this.mouseDown = false;
});
}
clearPressed() {
this.pressed.clear();
this.mouseClicked = false;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Vector2D } from './Vector2D.js';
const MAX_PARTICLES = 450;
export class ParticleSystem {
constructor() {
this.particles = [];
}
burst(
x,
y,
{
count = 12,
speed = 150,
direction = 0,
spread = Math.PI * 2,
life = 0.4,
size = 4,
color = [1, 1, 1, 1],
gravity = 0,
drag = 0,
shape = 'square',
additive = false,
endSize = null,
spin = 0,
} = {},
) {
for (let i = 0; i < count; i++) {
if (this.particles.length >= MAX_PARTICLES) return;
const angle = direction + (Math.random() - 0.5) * spread;
const magnitude = speed * (0.4 + Math.random() * 0.8);
const velocity = new Vector2D(Math.cos(angle), Math.sin(angle)).mulS(magnitude);
const lifetime = life * (0.6 + Math.random() * 0.6);
const startSize = size * (0.6 + Math.random() * 0.8);
this.particles.push({
pos: new Vector2D(x, y),
velocity,
gravity,
drag,
life: lifetime,
maxLife: lifetime,
size: startSize,
endSize: endSize === null ? startSize * 0.15 : endSize * (0.6 + Math.random() * 0.8),
color: [...color],
shape,
additive,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * (spin * 2),
});
}
}
update(dt) {
const { particles } = this;
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
particle.pos = particle.velocity.mulS(dt).addV(particle.pos);
particle.velocity.y += particle.gravity * dt;
if (particle.drag > 0) {
particle.velocity = particle.velocity.mulS(Math.max(0, 1 - particle.drag * dt));
}
particle.angle += particle.spin * dt;
particle.life -= dt;
if (particle.life <= 0) {
particles.splice(i, 1);
}
}
}
draw(renderer) {
const additive = this.particles.filter((particle) => particle.additive);
const normal = this.particles.filter((particle) => !particle.additive);
if (additive.length > 0) renderer.setBlendMode('additive');
for (const particle of additive) this._drawParticle(renderer, particle);
if (additive.length > 0) renderer.setBlendMode('normal');
for (const particle of normal) this._drawParticle(renderer, particle);
}
_drawParticle(renderer, particle) {
const alpha = Math.max(0, particle.life / particle.maxLife);
const progress = 1 - alpha;
const size = particle.size + (particle.endSize - particle.size) * progress;
const color = [
particle.color[0],
particle.color[1],
particle.color[2],
particle.color[3] * alpha,
];
const texture = particle.shape === 'glow' ? renderer.glowTexture : renderer.whiteTexture;
renderer.drawTexture(
texture,
particle.pos.x - size / 2,
particle.pos.y - size / 2,
size,
size,
color,
particle.angle,
);
}
clear() {
this.particles.length = 0;
}
}
+251
View File
@@ -0,0 +1,251 @@
import { Texture } from './Texture.js';
const VERTEX_SOURCE = `#version 300 es
layout(location = 0) in vec2 aPosition;
uniform mat4 uProjection;
uniform mat4 uModel;
out vec2 vTexCoord;
void main() {
vTexCoord = aPosition;
gl_Position = uProjection * uModel * vec4(aPosition, 0.0, 1.0);
}
`;
const FRAGMENT_SOURCE = `#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 outColor;
uniform sampler2D uSampler;
uniform vec4 uColor;
void main() {
outColor = texture(uSampler, vTexCoord) * uColor;
}
`;
const QUAD_POSITIONS = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
const QUAD_INDICES = new Uint16Array([0, 1, 2, 2, 1, 3]);
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${message}`);
}
return shader;
}
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
antialias: true,
});
if (!gl) {
throw new Error('WebGL2 is not supported by this browser.');
}
this.gl = gl;
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SOURCE);
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(this.program);
throw new Error(`Program link error: ${message}`);
}
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
this.projectionLocation = gl.getUniformLocation(this.program, 'uProjection');
this.modelLocation = gl.getUniformLocation(this.program, 'uModel');
this.colorLocation = gl.getUniformLocation(this.program, 'uColor');
this.samplerLocation = gl.getUniformLocation(this.program, 'uSampler');
this._centeredProjection = new Float32Array(16);
this._modelMatrix = new Float32Array(16);
this._setupQuad();
this.resize(canvas.width, canvas.height);
gl.enable(gl.BLEND);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
this.setBlendMode('normal');
gl.useProgram(this.program);
gl.uniform1i(this.samplerLocation, 0);
}
setBlendMode(mode) {
this._blendMode = mode;
const { gl } = this;
if (mode === 'additive') {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
} else {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
}
clear(r = 0, g = 0, b = 0, a = 0) {
const { gl } = this;
gl.clearColor(r, g, b, a);
gl.clear(gl.COLOR_BUFFER_BIT);
}
createTextureFromImage(image) {
const texture = new Texture(this.gl, image.width, image.height);
texture.uploadImage(image);
return texture;
}
get whiteTexture() {
if (!this._whiteTexture) {
this._whiteTexture = new Texture(this.gl, 1, 1);
}
return this._whiteTexture;
}
get glowTexture() {
if (!this._glowTexture) {
this._glowTexture = this._buildRadialTexture(64, (ctx, size) => {
const gradient = ctx.createRadialGradient(
size / 2, size / 2, 0,
size / 2, size / 2, size / 2,
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.35, 'rgba(255, 255, 255, 0.6)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
});
}
return this._glowTexture;
}
get ringTexture() {
if (!this._ringTexture) {
this._ringTexture = this._buildRadialTexture(128, (ctx, size) => {
ctx.clearRect(0, 0, size, size);
ctx.lineWidth = size * 0.06;
ctx.strokeStyle = '#fff';
ctx.beginPath();
ctx.arc(size / 2, size / 2, size * 0.42, 0, Math.PI * 2);
ctx.stroke();
const gradient = ctx.createRadialGradient(
size / 2, size / 2, size * 0.28,
size / 2, size / 2, size * 0.52,
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size * 0.5, 0, Math.PI * 2);
ctx.fill();
});
}
return this._ringTexture;
}
_buildRadialTexture(size, draw) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
draw(ctx, size);
const texture = new Texture(this.gl, size, size);
texture.uploadCanvas(canvas);
return texture;
}
drawTexture(texture, x, y, width, height, color = [1, 1, 1, 1], angle = 0) {
const { gl } = this;
this._setModelMatrix(x, y, width, height, angle);
gl.useProgram(this.program);
gl.uniformMatrix4fv(this.projectionLocation, false, this._centeredProjection);
gl.uniformMatrix4fv(this.modelLocation, false, this._modelMatrix);
gl.uniform4f(this.colorLocation, ...color);
gl.bindTexture(gl.TEXTURE_2D, texture.handle);
gl.bindVertexArray(this._quadVao);
gl.drawElements(gl.TRIANGLES, QUAD_INDICES.length, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null);
gl.bindTexture(gl.TEXTURE_2D, null);
}
resize(deviceWidth, deviceHeight, worldWidth = deviceWidth, worldHeight = deviceHeight) {
this.canvas.width = deviceWidth;
this.canvas.height = deviceHeight;
this.gl.viewport(0, 0, deviceWidth, deviceHeight);
this._worldWidth = worldWidth;
this._worldHeight = worldHeight;
this._syncProjection(0, 0, worldWidth, worldHeight);
}
setViewOffset(dx = 0, dy = 0) {
this._syncProjection(dx, dy, this._worldWidth, this._worldHeight);
}
_setupQuad() {
const { gl } = this;
this._quadVao = gl.createVertexArray();
gl.bindVertexArray(this._quadVao);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, QUAD_POSITIONS, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, QUAD_INDICES, gl.STATIC_DRAW);
gl.bindVertexArray(null);
}
_setModelMatrix(x, y, width, height, angle = 0) {
const m = this._modelMatrix;
if (angle === 0) {
m[0] = width; m[1] = 0; m[2] = 0; m[3] = 0;
m[4] = 0; m[5] = height; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = x; m[13] = y; m[14] = 0; m[15] = 1;
return;
}
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const cx = x + width / 2;
const cy = y + height / 2;
m[0] = width * cos; m[1] = width * sin; m[2] = 0; m[3] = 0;
m[4] = -height * sin; m[5] = height * cos; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = cx; m[13] = cy; m[14] = 0; m[15] = 1;
}
_syncProjection(left, top, width, height) {
const m = this._centeredProjection;
m[0] = 2 / width; m[1] = 0; m[2] = 0; m[3] = 0;
m[4] = 0; m[5] = -2 / height; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = -1 - (2 * left) / width; m[13] = 1 + (2 * top) / height; m[14] = 0; m[15] = 1;
}
}
+372
View File
@@ -0,0 +1,372 @@
export class SoundSystem {
constructor({ muted = false } = {}) {
this.muted = muted;
this.context = null;
this.master = null;
this.delaySend = null;
this.musicOn = false;
this.musicTimer = null;
this._musicStep = 0;
this._nextNoteTime = 0;
this._baseStepDur = 60 / 132 / 4;
this._stepDur = this._baseStepDur;
this._intensity = 0;
this._bossMode = false;
this._stepsPerBar = 16;
this._progression = [
{ root: 73.42, chord: [146.83, 174.61, 220] },
{ root: 58.27, chord: [116.54, 146.83, 174.61] },
{ root: 87.31, chord: [174.61, 220, 261.63] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
];
this._bossProgression = [
{ root: 41.2, chord: [82.41, 98, 123.47] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
{ root: 55, chord: [110, 130.81, 164.81] },
{ root: 73.42, chord: [146.83, 174.61, 220] },
];
this._arpPattern = [0, 1, 2, 1, 0, 2, 1, 2, 0, 1, 2, 1, 0, 2, 1, 2];
this._leadPattern = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[587.33, 0, 698.46, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 587.33, 0, 523.25, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 440, 0, 523.25, 0, 587.33, 0, 0, 0],
];
this._leadPatternB = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[659.26, 0, 880, 0, 987.77, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 0, 0],
[0, 0, 0, 0, 587.33, 0, 659.26, 0, 698.46, 0, 659.26, 0, 587.33, 0, 0, 0],
];
this._bassPattern = [1, 0, 1, 0, 1, 0, 1, 0];
this._barPlan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar: -1 };
const unlock = () => {
if (!this.context) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
this.context = new AudioContext();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.context.destination);
const delay = this.context.createDelay(0.6);
delay.delayTime.value = 0.31;
const feedback = this.context.createGain();
feedback.gain.value = 0.35;
const wet = this.context.createGain();
wet.gain.value = 0.22;
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
wet.connect(this.master);
this.delaySend = delay;
}
if (this.context.state === 'suspended') {
this.context.resume().catch(() => {});
}
};
window.addEventListener('keydown', unlock, { once: false });
window.addEventListener('pointerdown', unlock, { once: false });
}
startMusic() {
if (this.musicOn) return;
this.musicOn = true;
this._musicStep = 0;
this._nextNoteTime = this.context ? this.context.currentTime + 0.1 : 0;
if (this.musicTimer) clearInterval(this.musicTimer);
this.musicTimer = setInterval(() => this._scheduleMusic(), 100);
}
stopMusic() {
this.musicOn = false;
if (this.musicTimer) {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
}
setIntensity(intensity) {
const clamped = Math.max(0, Math.min(1, intensity));
this._intensity = clamped;
this._stepDur = this._baseStepDur * (1 - 0.35 * clamped);
}
setBossMode(on) {
this._bossMode = on;
}
_scheduleMusic() {
if (!this.context || !this.musicOn) return;
const aheadTime = this.context.currentTime + 0.5;
const totalSteps = this._stepsPerBar * this._progression.length;
while (this._nextNoteTime < aheadTime) {
try {
this._scheduleStep(this._musicStep, this._nextNoteTime);
} catch (error) {
console.error('Music schedule error', error);
}
this._nextNoteTime += this._stepDur;
this._musicStep = (this._musicStep + 1) % totalSteps;
}
}
_rollBarPlan(bar) {
const plan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar };
if (bar === 1) {
const roll = Math.random();
if (roll < 0.2) {
plan.fill = true;
} else if (roll < 0.32) {
plan.breakdown = true;
}
} else if (bar === 2 || bar === 3) {
plan.leadVariant = Math.random() < 0.35 ? 1 : 0;
plan.lift = Math.random() < 0.25;
}
return plan;
}
_scheduleStep(step, time) {
const delay = time - this.context.currentTime;
const bar = Math.floor(step / this._stepsPerBar) % this._progression.length;
const beat = step % this._stepsPerBar;
const barDur = this._stepDur * this._stepsPerBar;
const intensity = this._intensity;
const drumGain = 1 + intensity * 0.6;
if (beat === 0 || this._barPlan.bar !== bar) {
this._barPlan = this._rollBarPlan(bar);
}
const plan = this._barPlan;
const fill = plan.fill;
const breakdown = plan.breakdown;
const boss = this._bossMode;
let { root, chord } = boss
? this._bossProgression[bar]
: this._progression[bar];
if (plan.lift && bar === 2 && !boss) {
chord = [174.61, 207.65, 261.63];
}
if (boss && beat === 0) {
this.tone(27.5, 'sine', barDur, 0.06, 27.5, delay);
}
if (beat === 0) {
for (const note of chord) {
this.tone(note, 'sawtooth', barDur, 0.02 + intensity * 0.01, note, delay);
}
}
const kickFreq = boss ? 44 : 55;
const snareFreq = boss ? 700 : 1800;
if (!fill && !breakdown) {
if (beat % 4 === 2 && this._bassPattern[Math.floor(beat / 4)]) {
this.tone(root, 'square', this._stepDur * 1.5, 0.055, root, delay);
}
if (intensity > 0.4 && beat % 2 === 0) {
this.tone(root * 2, 'square', this._stepDur * 0.9, 0.02, root * 2, delay);
}
if (beat % 4 === 0) {
this.tone(kickFreq, 'sine', 0.14, 0.1 * drumGain, Math.max(28, kickFreq - 18), delay);
}
if (beat === 4 || beat === 12) {
this.noise(0.1, 0.07 * drumGain, snareFreq, delay);
}
if (beat % 4 === 2) {
this.noise(0.03, 0.02 * drumGain, 9000, delay);
}
if (beat % 4 === 3 || beat % 4 === 1) {
this.noise(0.02, 0.012 * drumGain, 10000, delay);
}
if (beat % 4 === 1 || beat % 4 === 3) {
const arp = chord[this._arpPattern[beat]];
this.tone(arp, 'triangle', 0.1, 0.018, arp, delay, this.delaySend);
this.tone(arp * 2, 'triangle', 0.12, 0.012, arp * 2, delay);
}
} else if (fill) {
if (beat % 2 === 0) {
this.tone(55, 'sine', 0.1, 0.06, 34, delay);
}
if (beat >= 12) {
this.noise(0.06, 0.045, 1500, delay);
}
if (beat % 2 === 1) {
this.noise(0.03, 0.018, 8500, delay);
}
if (beat % 3 === 0) {
this.noise(0.05, 0.012, 400 + beat * 160, delay);
}
const index = beat >= 12 ? (beat - 12) % 3 : Math.floor(beat / 2) % 3;
const note = chord[index] * (beat >= 12 ? 4 : 2);
this.tone(note, 'triangle', 0.09, 0.02, note, delay);
} else if (breakdown) {
if (beat === 0 || beat === 8) {
this.tone(55, 'sine', 0.18, 0.08, 32, delay);
}
}
if (!fill && !breakdown) {
const lead = (plan.leadVariant === 1 ? this._leadPatternB : this._leadPattern)[bar][beat];
if (lead > 0) {
this.tone(lead, 'sawtooth', 0.22, 0.03, lead, delay, this.delaySend);
}
}
}
setMuted(muted) {
this.muted = muted;
if (this.master && this.context) {
this.master.gain.setTargetAtTime(muted ? 0 : 0.5, this.context.currentTime, 0.01);
}
}
toggleMuted() {
this.setMuted(!this.muted);
return this.muted;
}
tone(freq, type, duration, gain, glideTo = freq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
if (!Number.isFinite(freq) || !Number.isFinite(glideTo)) return;
const { context } = this;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const shape = context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(Math.max(1, freq), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), start + duration);
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.008);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(shape);
shape.connect(bus || this.master);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
noise(duration, gain, filterFreq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
const { context } = this;
const start = context.currentTime + delay;
const bufferSize = Math.max(1, Math.ceil(context.sampleRate * duration));
const buffer = context.createBuffer(1, bufferSize, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i += 1) {
data[i] = Math.random() * 2 - 1;
}
const source = context.createBufferSource();
source.buffer = buffer;
const filter = context.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1.2;
const shape = context.createGain();
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.005);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(filter);
filter.connect(shape);
shape.connect(bus || this.master);
source.start(start);
source.stop(start + duration + 0.02);
}
shoot() {
this.tone(240, 'square', 0.1, 0.075, 85);
this.noise(0.05, 0.028, 3200);
}
hit() {
this.noise(0.12, 0.16, 1500);
this.tone(180, 'triangle', 0.1, 0.12, 120);
}
death(big = false) {
if (big) {
this.noise(0.35, 0.22, 700);
this.tone(120, 'sawtooth', 0.3, 0.2, 45);
this.tone(90, 'sine', 0.4, 0.24, 30);
return;
}
this.noise(0.2, 0.14, 500);
this.tone(140, 'triangle', 0.22, 0.16, 55);
}
damage() {
this.tone(160, 'sawtooth', 0.2, 0.18, 80);
this.noise(0.15, 0.1, 900);
}
explosion() {
this.noise(0.7, 0.3, 400);
this.tone(200, 'sawtooth', 0.6, 0.22, 30);
this.tone(60, 'sine', 0.8, 0.3, 20);
}
pickup() {
this.tone(660, 'sine', 0.12, 0.14, 660);
this.tone(990, 'sine', 0.18, 0.14, 990, 0.09);
}
stingerStart() {
this.tone(392, 'triangle', 0.12, 0.1);
this.tone(523, 'triangle', 0.12, 0.1, undefined, 0.1);
this.tone(659, 'triangle', 0.3, 0.12, undefined, 0.2);
}
bossEntrance() {
this.tone(27.5, 'sine', 1.6, 0.22, 27.5);
this.tone(110, 'sawtooth', 0.5, 0.1, 55);
this.noise(0.5, 0.12, 250);
this.tone(82.41, 'square', 0.25, 0.08, 82.41, 0.7);
this.tone(61.74, 'square', 0.4, 0.08, 61.74, 0.95);
}
playGameOverMusic() {
this.stopMusic();
this._playGameOverSeq();
}
_playGameOverSeq() {
if (!this.context) return;
const now = this.context.currentTime;
const t = (offset) => Math.max(0, offset - now);
this.tone(55, 'sine', 8, 0.05, 51, t(0));
this.tone(55, 'sine', 8, 0.03, 55, t(4));
const chords = [
[110, 164.81, 220],
[98, 146.83, 196],
[87.31, 130.81, 174.61],
[82.41, 123.47, 164.81],
];
chords.forEach((chord, i) => {
const start = 0.2 + i * 1.5;
for (const note of chord) {
this.tone(note, 'sawtooth', 1.5, 0.018, note, t(start));
}
if (i === 0 || i === 2) {
this.tone(55, 'sine', 0.14, 0.09, 32, t(start));
}
});
const melody = [880, 783.99, 698.46, 659.26, 587.33, 523.25, 493.88, 440];
melody.forEach((note, i) => {
this.tone(note, 'triangle', 0.7, 0.032, note, t(0.5 + i * 0.55), this.delaySend);
});
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Texture } from './Texture.js';
export class TextRenderer {
constructor(gl) {
this.gl = gl;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this._cachedKey = null;
this._cachedEntry = null;
}
draw(renderer, text, x, y, options = {}) {
const entry = this._resolve(text, options);
renderer.drawTexture(entry.texture, x, y, entry.width, entry.height);
return entry;
}
measure(text, options = {}) {
return this._resolve(text, options);
}
_resolve(text, options = {}) {
const key = this._makeKey(text, options);
if (this._cachedKey !== key) {
this._flushCache();
this._cachedKey = key;
this._cachedEntry = this._rasterize(text, options);
}
return this._cachedEntry;
}
_makeKey(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) {
return `${text}|${size}|${font}|${fillStyle}|${bold}`;
}
_rasterize(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) {
const { ctx, canvas } = this;
const pad = 4;
const fontString = `${bold ? 'bold ' : ''}${size}px ${font}`;
ctx.font = fontString;
const textWidth = Math.ceil(ctx.measureText(text).width);
const width = textWidth + pad * 2;
const height = Math.ceil(size * 1.3) + pad * 2;
const scale = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.font = fontString;
ctx.fillStyle = fillStyle;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText(text, pad, pad + size);
const texture = new Texture(this.gl, canvas.width, canvas.height);
texture.uploadCanvas(canvas);
return { texture, width, height };
}
_flushCache() {
if (this._cachedEntry) {
this._cachedEntry.texture.dispose();
this._cachedEntry = null;
}
}
}
+56
View File
@@ -0,0 +1,56 @@
export class Texture {
constructor(gl, width = 1, height = 1) {
this.gl = gl;
this.width = width;
this.height = height;
this.handle = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([255, 255, 255, 255]),
);
this._configureSampling();
gl.bindTexture(gl.TEXTURE_2D, null);
}
uploadImage(image) {
const { gl } = this;
this.width = image.width;
this.height = image.height;
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
}
uploadCanvas(canvas) {
const { gl } = this;
this.width = canvas.width;
this.height = canvas.height;
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.bindTexture(gl.TEXTURE_2D, null);
}
_configureSampling() {
const { gl } = this;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
dispose() {
this.gl.deleteTexture(this.handle);
this.handle = null;
}
}
+67
View File
@@ -0,0 +1,67 @@
export class Vector2D {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
mulS(scalar) {
return new Vector2D(this.x * scalar, this.y * scalar);
}
mulV(vector) {
return new Vector2D(this.x * vector.x, this.y * vector.y);
}
divS(scalar) {
return new Vector2D(this.x / scalar, this.y / scalar);
}
divV(vector) {
return new Vector2D(this.x / vector.x, this.y / vector.y);
}
addS(scalar) {
return new Vector2D(this.x + scalar, this.y + scalar);
}
addV(vector) {
return new Vector2D(this.x + vector.x, this.y + vector.y);
}
subS(scalar) {
return new Vector2D(this.x - scalar, this.y - scalar);
}
subV(vector) {
return new Vector2D(this.x - vector.x, this.y - vector.y);
}
dot(vector) {
return this.x * vector.x + this.y * vector.y;
}
abs() {
return new Vector2D(Math.abs(this.x), Math.abs(this.y));
}
length() {
return Math.sqrt(this.dot(this));
}
lengthSqrt() {
return this.dot(this);
}
normalize() {
const length = this.length();
this.x /= length;
this.y /= length;
return this;
}
rotate(angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return new Vector2D(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Zombie } from './Zombie.js';
export class Boss extends Zombie {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 38,
size = 90,
color = [0.45, 0.05, 0.1, 1],
health = 80,
knockback = 40,
scoreValue = 50,
spawnInterval = 5,
spawnCount = 1,
spawnZombie = () => {},
onDeath = null,
} = {},
) {
super(x, y, target, {
container,
viewport,
speed,
size,
color,
health,
knockback,
scoreValue,
onDeath,
});
this.spawnInterval = spawnInterval;
this.spawnCount = spawnCount;
this.spawnTimer = 3;
this.spawnZombie = spawnZombie;
this._time = 0;
this._hurt = 0;
}
hit(impulse, damage = 1) {
const dead = super.hit(impulse, damage);
this._flash = 0.18;
this._hurt = 0.18;
return dead;
}
update(dt) {
super.update(dt);
this._time += dt;
this._hurt = Math.max(0, this._hurt - dt);
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnTimer = this.spawnInterval;
const minions = this.container.filter(
(obj) => obj instanceof Zombie && !(obj instanceof Boss),
);
if (minions.length < 15) {
for (let i = 0; i < this.spawnCount; i += 1) {
const angle = (i / this.spawnCount) * Math.PI * 2 + Math.random() * 0.6;
this.spawnZombie(
this.pos.x + Math.cos(angle) * 70,
this.pos.y + Math.sin(angle) * 70,
this.target,
this.container,
);
}
}
}
}
draw(renderer) {
this.width = this.size;
this.height = this.size;
const pulse = 1 + 0.05 * Math.sin(this._time * 4);
const hurtRatio = this._hurt / 0.18;
const s = this.size * pulse * (1 + hurtRatio * 0.14);
const hpRatio = Math.max(0, this.health / this.maxHealth);
const color = [
Math.min(1, this.baseColor[0] + (1 - hpRatio) * 0.3),
this.baseColor[1] * hpRatio,
this.baseColor[2] * hpRatio,
1,
];
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s / 2,
this.pos.y - s / 2,
s,
s,
this._flash > 0 ? [1, 1, 1, 1] : color,
);
const core = s * (0.35 + (1 - hpRatio) * 0.4);
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - core / 2,
this.pos.y - core / 2,
core,
core,
[0.12, 0.02, 0.03, 0.9],
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
export class Bullet extends GameObject {
constructor(
x,
y,
direction,
{ container, viewport, speed = 800, size = 8, damage = 1, color = [1, 0.2, 0.2, 1] } = {},
) {
super(x, y);
this.velocity = direction.mulS(speed);
this.container = container;
this.viewport = viewport;
this.size = size;
this.damage = damage;
this.color = color;
this.width = size;
this.height = size;
}
update(dt) {
super.update(dt);
const { width, height } = this.viewport;
if (
this.pos.x < -this.size ||
this.pos.x > width + this.size ||
this.pos.y < -this.size ||
this.pos.y > height + this.size
) {
this.remove(this.container);
}
}
draw(renderer) {
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - this.size / 2,
this.pos.y - this.size / 2,
this.size,
this.size,
this.color,
);
}
}
+263
View File
@@ -0,0 +1,263 @@
import { Zombie } from './Zombie.js';
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
export class Leaper extends Zombie {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 95,
size = 28,
color = [0.15, 0.25, 0.75, 1],
health = 3,
knockback = 240,
scoreValue = 6,
dashRange = 300,
windupTime = 0.35,
dashTime = 0.28,
dashSpeed = 680,
dashCooldown = 1.8,
emergeTime = 0,
} = {},
) {
super(x, y, target, {
container,
viewport,
speed,
size,
color,
health,
knockback,
scoreValue,
emergeTime,
});
this.dashRange = dashRange;
this.windupTime = windupTime;
this.dashTime = dashTime;
this.dashSpeed = dashSpeed;
this.dashCooldown = dashCooldown;
this._dashTimer = 0;
this._windupTimer = 0;
this._dashCooldown = 0;
this._dashDirection = new Vector2D(1, 0);
this._time = 0;
}
update(dt) {
this._time += dt;
if (this._updateEmerge(dt)) return;
this._dashCooldown = Math.max(0, this._dashCooldown - dt);
const delta = this.target.pos.subV(this.pos);
const distance = delta.length();
let move = new Vector2D(0, 0);
if (this._windupTimer > 0) {
this._windupTimer -= dt;
if (this._windupTimer <= 0) {
this._dashTimer = this.dashTime;
this._dashDirection = distance > 1 ? delta.divS(distance) : new Vector2D(1, 0);
}
} else if (this._dashTimer > 0) {
this._dashTimer -= dt;
move = this._dashDirection.mulS(this.dashSpeed);
} else if (distance > 1) {
if (distance < this.dashRange && this._dashCooldown <= 0) {
this._windupTimer = this.windupTime;
} else {
move = delta.divS(distance).mulS(this.speed);
}
}
this.velocity = move.addV(this._hitKnockback);
GameObject.prototype.update.call(this, dt);
this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping));
this._flash = Math.max(0, this._flash - dt);
}
draw(renderer) {
this._drawEmerge(renderer);
const s = this.size * this._emergeSize();
const dashing = this._dashTimer > 0;
const winding = this._windupTimer > 0;
let color = this.baseColor;
if (this._flash > 0) {
color = [1, 1, 1, 1];
} else if (winding || dashing) {
color = [
this.baseColor[0] + (1 - this.baseColor[0]) * 0.8,
this.baseColor[1] + (1 - this.baseColor[1]) * 0.8,
this.baseColor[2],
1,
];
}
const towards = dashing ? this._dashDirection : this.target.pos.subV(this.pos);
const facing = Math.atan2(towards.y, towards.x);
const dirX = Math.cos(facing);
const dirY = Math.sin(facing);
const perpX = -dirY;
const perpY = dirX;
const shade = [color[0] * 0.65, color[1] * 0.65, color[2] * 0.65, 1];
const shadeDark = [color[0] * 0.4, color[1] * 0.4, color[2] * 0.4, 1];
const light = [
Math.min(1, color[0] + 0.25),
Math.min(1, color[1] + 0.25),
Math.min(1, color[2] + 0.25),
1,
];
const drawLimb = (ax, ay, bx, by, thickness, limbColor) => {
const len = Math.hypot(bx - ax, by - ay);
if (len < 1) return;
renderer.drawTexture(
renderer.whiteTexture,
(ax + bx) / 2 - len / 2,
(ay + by) / 2 - thickness / 2,
len,
thickness,
limbColor,
Math.atan2(by - ay, bx - ax),
);
};
const bodyLen = s * (dashing ? 1.45 : winding ? 0.9 : 1.15);
const bodyH = s * (dashing ? 0.42 : winding ? 0.7 : 0.55);
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.72,
this.pos.y - s * 0.26 + s * 0.08,
s * 1.44,
s * 0.52,
[0, 0, 0, 0.35],
);
if (winding) {
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.9,
this.pos.y - s * 0.6,
s * 1.8,
s * 1.2,
[1, 1, 1, 0.3],
);
}
const tailBaseX = this.pos.x - dirX * bodyLen * 0.45;
const tailBaseY = this.pos.y - dirY * bodyLen * 0.45;
const tailSway = dashing ? 0 : Math.sin(this._time * 16) * s * 0.14;
const tailTipX = tailBaseX - dirX * (dashing ? s * 0.6 : s * 0.42) + perpX * tailSway;
const tailTipY = tailBaseY - dirY * (dashing ? s * 0.6 : s * 0.42) + perpY * tailSway;
drawLimb(tailBaseX, tailBaseY, tailTipX, tailTipY, s * 0.11, shadeDark);
drawLimb(
tailTipX, tailTipY,
tailTipX + perpX * tailSway * 0.5 - dirX * s * 0.08,
tailTipY + perpY * tailSway * 0.5 - dirY * s * 0.08,
s * 0.15,
shade,
);
const legL = s * (dashing ? 0.3 : winding ? 0.16 : 0.4);
const legW = s * 0.11;
for (let pair = 0; pair < 2; pair += 1) {
const front = pair === 0;
const baseFx = this.pos.x + dirX * bodyLen * (front ? 0.4 : -0.4);
const baseFy = this.pos.y + dirY * bodyLen * (front ? 0.4 : -0.4);
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const bx = baseFx + perpX * bodyH * 0.5 * sign;
const by = baseFy + perpY * bodyH * 0.5 * sign;
let fx = bx;
let fy = by;
if (dashing) {
fx = bx - dirX * legL;
fy = by - dirY * legL;
} else if (winding) {
fx = bx + perpX * sign * s * 0.1;
fy = by + s * 0.13;
} else {
const run = Math.sin(this._time * 18 + (front ? 0 : Math.PI)) * s * 0.16;
fx = bx + dirX * run * 0.7 + perpX * sign * s * 0.04;
fy = by + legL;
}
drawLimb(bx, by, fx, fy, legW, shadeDark);
}
}
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - bodyLen / 2,
this.pos.y - bodyH / 2,
bodyLen,
bodyH,
color,
facing,
);
const headLen = s * (dashing ? 0.38 : 0.44);
const headH = s * 0.3;
const headCx = this.pos.x + dirX * (bodyLen * 0.5 - s * 0.03);
const headCy = this.pos.y + dirY * (bodyLen * 0.5 - s * 0.03);
renderer.drawTexture(
renderer.whiteTexture,
headCx - headLen / 2,
headCy - headH / 2,
headLen,
headH,
light,
facing,
);
const snoutLen = s * 0.28;
const snoutCx = headCx + dirX * (headLen * 0.5 + snoutLen * 0.4);
const snoutCy = headCy + dirY * (headLen * 0.5 + snoutLen * 0.4);
drawLimb(headCx, headCy, snoutCx, snoutCy, s * 0.18, shade);
const noseS = s * 0.08;
renderer.drawTexture(
renderer.whiteTexture,
snoutCx + dirX * snoutLen * 0.5 - noseS / 2,
snoutCy + dirY * snoutLen * 0.5 - noseS / 2,
noseS,
noseS,
[0.03, 0.05, 0.12, 1],
);
const eyeS = s * 0.08;
const eyeCx = headCx + dirX * headLen * 0.28;
const eyeCy = headCy + dirY * headLen * 0.28;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
renderer.drawTexture(
renderer.whiteTexture,
eyeCx + perpX * eyeS * 1.5 * sign - eyeS / 2,
eyeCy + perpY * eyeS * 1.5 * sign - eyeS / 2,
eyeS,
eyeS,
[0.03, 0.05, 0.12, 1],
);
}
const earS = s * 0.13;
const earBack = dashing || winding ? 0.6 : 1;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const eax = headCx + perpX * earS * 1.55 * sign;
const eay = headCy + perpY * earS * 1.55 * sign;
drawLimb(
eax, eay,
eax - dirX * earS * earBack,
eay - dirY * earS * earBack,
earS * 0.5,
shadeDark,
);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Zombie } from './Zombie.js';
export class MegaTank extends Zombie {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 30,
size = 64,
color = [0.55, 0.28, 0.12, 1],
health = 18,
knockback = 90,
scoreValue = 15,
spawnOnDeath = () => {},
spawnCount = 3,
} = {},
) {
super(x, y, target, {
container,
viewport,
speed,
size,
color,
health,
knockback,
scoreValue,
onDeath: (mega) => {
for (let i = 0; i < spawnCount; i += 1) {
const angle = (i / spawnCount) * Math.PI * 2;
const offset = mega.size + 28;
spawnOnDeath(
mega.pos.x + Math.cos(angle) * offset,
mega.pos.y + Math.sin(angle) * offset,
target,
container,
);
}
},
});
}
}
+163
View File
@@ -0,0 +1,163 @@
import { Witch } from './Witch.js';
import { WitchProjectile } from './WitchProjectile.js';
import { Vector2D } from '../engine/Vector2D.js';
export class MegaWitch extends Witch {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 55,
size = 52,
color = [0.8, 0.15, 0.7, 1],
health = 22,
knockback = 130,
scoreValue = 12,
preferredDistance = 240,
fireInterval = 2,
projectileSpeed = 240,
projectileCount = 3,
projectileSpread = 0.35,
projectileColor = [1, 0.3, 0.3, 1],
novaInterval = 5,
novaCount = 16,
novaProjectileSpeed = 250,
spawnProjectile = (p) => {},
spawnOnDeath = () => {},
spawnCount = 3,
} = {},
) {
super(x, y, target, {
container,
viewport,
speed,
size,
color,
health,
knockback,
scoreValue,
preferredDistance,
fireInterval,
projectileSpeed,
projectileCount,
projectileSpread,
projectileColor,
spawnProjectile,
onDeath: (mega) => {
for (let i = 0; i < spawnCount; i += 1) {
const angle = (i / spawnCount) * Math.PI * 2;
const offset = mega.size + 30;
spawnOnDeath(
mega.pos.x + Math.cos(angle) * offset,
mega.pos.y + Math.sin(angle) * offset,
target,
container,
);
}
},
});
this.novaInterval = novaInterval;
this.novaCount = novaCount;
this.novaProjectileSpeed = novaProjectileSpeed;
this._novaTimer = novaInterval * 0.5;
this._novaRotation = Math.random() * Math.PI * 2;
this._pendingNova = [];
}
update(dt) {
super.update(dt);
if (this.target.health <= 0) return;
for (let i = this._pendingNova.length - 1; i >= 0; i -= 1) {
const entry = this._pendingNova[i];
entry.remaining -= dt;
if (entry.remaining <= 0) {
this._pendingNova.splice(i, 1);
entry.fire();
}
}
this._novaTimer -= dt;
if (this._novaTimer <= 0) {
this._novaTimer = this.novaInterval * (0.85 + Math.random() * 0.3);
this.nova();
}
}
nova() {
const lost = 1 - Math.max(0, this.health / this.maxHealth);
const count = Math.max(6, Math.round(this.novaCount * (0.75 + 0.25 * lost)));
const speed = this.novaProjectileSpeed * (1 + lost * 0.45);
const size = this.size * 0.3;
const color = [1, 0.5, 0.3, 1];
this._novaRotation += 0.36;
const fireRing = (offset) => {
for (let i = 0; i < count; i += 1) {
const angle = (i / count) * Math.PI * 2 + this._novaRotation + offset;
const direction = new Vector2D(Math.cos(angle), Math.sin(angle));
const projectile = new WitchProjectile(this.pos.x, this.pos.y, direction, {
container: this.container,
viewport: this.viewport,
speed,
size,
color,
});
this.spawnProjectile(projectile);
}
};
fireRing(0);
this._pendingNova.push({
remaining: 0.22,
fire: () => fireRing(Math.PI / count),
});
if (lost > 0.34) {
this._pendingNova.push({
remaining: 0.44,
fire: () => fireRing(0),
});
}
this._cast = 0.32;
}
draw(renderer) {
const charge = 1 - Math.max(0, this._novaTimer) / this.novaInterval;
if (charge > 0.72) {
const hp = Math.max(0, this.health / this.maxHealth);
const punch = (charge - 0.72) / 0.28;
const r = this.size * (0.6 + punch * 1.7) * (1.4 - hp * 0.6);
renderer.setBlendMode('additive');
renderer.drawTexture(
renderer.glowTexture,
this.pos.x - r,
this.pos.y - r,
r * 2,
r * 2,
[1, 0.3, 0.15, punch * 0.7],
);
renderer.drawTexture(
renderer.ringTexture,
this.pos.x - r * 1.5,
this.pos.y - r * 1.5,
r * 3,
r * 3,
[1, 0.45, 0.2, punch * 0.9],
);
renderer.drawTexture(
renderer.ringTexture,
this.pos.x - r * 2.1,
this.pos.y - r * 2.1,
r * 4.2,
r * 4.2,
[1, 0.25, 0.1, punch * 0.35],
);
renderer.setBlendMode('normal');
}
super.draw(renderer);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { GameObject } from '../engine/GameObject.js';
export const PICKUP_TYPES = {
rapid: { name: 'Rapid Fire', color: [1, 0.8, 0.2, 1] },
damage: { name: 'Damage +1', color: [1, 0.3, 0.3, 1] },
multishot: { name: 'Multishot', color: [0.2, 0.8, 1, 1] },
speed: { name: 'Bullet Speed', color: [0.6, 0.9, 1, 1] },
};
const PICKUP_KEYS = Object.keys(PICKUP_TYPES);
export const randomPickupType = (types = PICKUP_KEYS) => (
types[Math.floor(Math.random() * types.length)]
);
export class Pickup extends GameObject {
constructor(x, y, type, { container, viewport, life = 10, collectRadius = 46 } = {}) {
super(x, y);
this.type = type;
this.container = container;
this.viewport = viewport;
this.life = life;
this.collectRadius = collectRadius;
this._time = 0;
this.size = 16;
this.width = this.size;
this.height = this.size;
this.color = PICKUP_TYPES[type].color;
}
intersect(other) {
const dx = this.pos.x - other.pos.x;
const dy = this.pos.y - other.pos.y;
return dx * dx + dy * dy <= this.collectRadius * this.collectRadius
|| super.intersect(other);
}
update(dt) {
this._time += dt;
if (this._time >= this.life) {
this.remove(this.container);
}
}
draw(renderer, textRenderer) {
const remaining = this.life - this._time;
let alpha = 1;
if (remaining < 2) {
alpha = 0.3 + 0.7 * Math.max(0, Math.sin(remaining * 6));
}
const pulse = 1 + 0.15 * Math.sin(this._time * 6);
const s = this.size * pulse;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s / 2,
this.pos.y - s / 2,
s,
s,
[...this.color.slice(0, 3), alpha],
);
const label = PICKUP_TYPES[this.type].name;
const opts = { size: 12, fillStyle: '#fff', bold: true };
const entry = textRenderer?.measure(label, opts);
const textHeight = entry ? entry.height : 0;
textRenderer?.draw(
renderer,
label,
this.pos.x - (entry ? entry.width / 2 : label.length * 4.5),
this.pos.y - s / 2 - textHeight - 4,
opts,
);
}
}
+391
View File
@@ -0,0 +1,391 @@
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
import { Bullet } from './Bullet.js';
import { Zombie } from './Zombie.js';
export class Player extends GameObject {
constructor(
{
input,
container,
viewport,
moveSpeed = 420,
fireRate = 0.2,
recoilSpeed = 100,
recoilDamping = 15,
bulletSpeed = 800,
bulletSize = 8,
bulletCount = 1,
bulletSpread = 0.09,
damage = 1,
maxUpgradeLevel = 3,
health = 3,
particles = null,
sounds = null,
size = 24,
autoAim = false,
} = {},
) {
super(viewport.width / 2, viewport.height / 2);
this.input = input;
this.container = container;
this.viewport = viewport;
this.moveSpeed = moveSpeed;
this.fireRate = fireRate;
this.bulletSpeed = bulletSpeed;
this.bulletSize = bulletSize;
this.bulletCount = bulletCount;
this.bulletSpread = bulletSpread;
this.damage = damage;
this.maxUpgradeLevel = maxUpgradeLevel;
this.upgrades = { rapid: 0, damage: 0, multishot: 0, speed: 0 };
this.recoilSpeed = recoilSpeed;
this.recoilDamping = recoilDamping;
this.health = health;
this.maxHealth = health;
this.particles = particles;
this.sounds = sounds;
this.size = size;
this._cooldown = 0;
this._aimDirection = new Vector2D(1, 0);
this._recoil = new Vector2D(0, 0);
this._damageCooldown = 0;
this._time = 0;
this.autoAim = autoAim;
}
update(dt) {
const { width, height } = this.viewport;
this._time += dt;
const movement = new Vector2D(0, 0);
const { keys } = this.input;
if (keys.has('w') || keys.has('arrowup')) movement.y -= 1;
if (keys.has('s') || keys.has('arrowdown')) movement.y += 1;
if (keys.has('a') || keys.has('arrowleft')) movement.x -= 1;
if (keys.has('d') || keys.has('arrowright')) movement.x += 1;
let moveVelocity = new Vector2D(0, 0);
if (movement.lengthSqrt() > 0) {
moveVelocity = movement.normalize().mulS(this.moveSpeed);
}
this.velocity = this._recoil.lengthSqrt() > 1
? moveVelocity.addV(this._recoil)
: moveVelocity;
super.update(dt);
const maxX = Math.max(this.width / 2, 0);
const maxY = Math.max(this.height / 2, 0);
this.pos.x = Math.min(Math.max(this.pos.x, maxX), width - maxX);
this.pos.y = Math.min(Math.max(this.pos.y, maxY), height - maxY);
let aim = this.input.mouse.subV(this.pos);
if (this.autoAim) {
const nearest = this._nearestZombie();
if (nearest) {
const toEnemy = nearest.pos.subV(this.pos);
if (toEnemy.lengthSqrt() > 1) {
aim = toEnemy;
}
}
}
if (aim.lengthSqrt() > 1) {
this._aimDirection = aim.divS(aim.length());
}
this._recoil = this._recoil.mulS(Math.max(0, 1 - dt * this.recoilDamping));
this._damageCooldown = Math.max(0, this._damageCooldown - dt);
this._cooldown -= dt;
if (this._cooldown <= 0) {
this._cooldown = this.fireRate;
this.shoot();
}
}
_nearestZombie() {
let best = null;
let bestDistSq = Infinity;
for (const obj of this.container || []) {
if (!(obj instanceof Zombie)) continue;
const delta = obj.pos.subV(this.pos);
const distSq = delta.lengthSqrt();
if (distSq < bestDistSq) {
bestDistSq = distSq;
best = obj;
}
}
return best;
}
shoot() {
const aimAngle = Math.atan2(this._aimDirection.y, this._aimDirection.x);
this._recoil = this._aimDirection.mulS(-this.recoilSpeed);
this.sounds?.shoot();
this.particles?.burst(this.pos.x, this.pos.y, {
count: 6,
speed: 120,
direction: aimAngle,
spread: 0.7,
life: 0.25,
size: 4,
color: [1, 0.85, 0.3, 1],
gravity: 0,
});
this.particles?.burst(this.pos.x, this.pos.y, {
count: 4,
speed: 260,
direction: aimAngle,
spread: 0.25,
life: 0.14,
size: 5,
color: [1, 1, 1, 1],
gravity: 0,
});
this.particles?.burst(this.pos.x, this.pos.y, {
count: 5,
speed: 70,
spread: Math.PI * 2,
life: 0.4,
size: 4,
color: [0.6, 0.6, 0.6, 1],
gravity: 0,
});
const offset = ((this.bulletCount - 1) / 2) * this.bulletSpread;
for (let i = 0; i < this.bulletCount; i += 1) {
const angle = aimAngle - offset + i * this.bulletSpread;
const direction = new Vector2D(Math.cos(angle), Math.sin(angle));
const bullet = new Bullet(this.pos.x, this.pos.y, direction, {
container: this.container,
viewport: this.viewport,
speed: this.bulletSpeed,
size: this.bulletSize,
damage: this.damage,
});
this.container?.push(bullet);
}
}
applyUpgrade(type) {
if (!(type in this.upgrades)) return false;
if (this.upgrades[type] >= this.maxUpgradeLevel) return false;
this.upgrades[type] += 1;
switch (type) {
case 'rapid':
this.fireRate = Math.max(0.06, this.fireRate * 0.8);
break;
case 'damage':
this.damage += 1;
break;
case 'multishot':
this.bulletCount = Math.min(7, this.bulletCount + 1);
break;
case 'speed':
this.bulletSpeed = Math.min(1800, this.bulletSpeed * 1.15);
break;
default:
break;
}
return true;
}
takeDamage() {
if (this._damageCooldown > 0) return this.health;
this._damageCooldown = 0.5;
this.health -= 1;
this.sounds?.damage();
return this.health;
}
draw(renderer) {
const s = this.size;
this.width = s;
this.height = s;
let alpha = 1;
if (this._damageCooldown > 0) {
alpha = Math.sin(this._time * 30) > 0 ? 1 : 0.25;
}
const moving = this.velocity.lengthSqrt() > 10;
const facing = Math.atan2(this._aimDirection.y, this._aimDirection.x);
const dirX = Math.cos(facing);
const dirY = Math.sin(facing);
const perpX = -dirY;
const perpY = dirX;
const bob = Math.sin(this._time * (moving ? 11 : 2)) * s * (moving ? 0.04 : 0.015);
const lean = facing * 0.35;
const pull = Math.min(1, this._recoil.lengthSqrt() / Math.max(1, this.recoilSpeed));
const kickX = -this._recoil.x * 0.04;
const kickY = -this._recoil.y * 0.04;
const ox = this.pos.x + kickX;
const oy = this.pos.y + kickY + bob;
const armor = [0.17, 0.21, 0.3, alpha];
const armorLight = [0.3, 0.36, 0.48, alpha];
const armorDark = [0.09, 0.11, 0.16, alpha];
const accent = [0.34, 0.96, 1, alpha];
const drawLimb = (ax, ay, bx, by, thickness, limbColor) => {
const len = Math.hypot(bx - ax, by - ay);
if (len < 1) return;
renderer.drawTexture(
renderer.whiteTexture,
(ax + bx) / 2 - len / 2,
(ay + by) / 2 - thickness / 2,
len,
thickness,
limbColor,
Math.atan2(by - ay, bx - ax),
);
};
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.85,
this.pos.y - s * 0.3 + s * 0.14,
s * 1.7,
s * 0.56,
[0, 0, 0, 0.35],
);
const capeX = ox - dirX * s * (0.42 + 0.05 * Math.sin(this._time * (moving ? 6 : 2)));
const capeY = oy - dirY * s * (0.42 + 0.05 * Math.sin(this._time * (moving ? 6 : 2))) + s * 0.05;
renderer.drawTexture(
renderer.whiteTexture,
capeX - s * 0.26,
capeY - s * 0.26,
s * 0.52,
s * 0.52,
armorDark,
facing,
);
const gripX = ox + dirX * s * (0.22 - 0.14 * pull);
const gripY = oy + dirY * s * (0.22 - 0.14 * pull);
drawLimb(ox - dirX * s * 0.08, oy, gripX - dirX * s * 0.14, gripY, s * 0.11, armorDark);
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const step = Math.sin(this._time * 13 + i * Math.PI) * s * (moving ? 0.1 : 0.02);
const hx = ox + perpX * s * 0.14 * sign;
const hy = oy + perpY * s * 0.14 * sign;
renderer.drawTexture(
renderer.whiteTexture,
hx + dirX * step - s * 0.07,
hy + s * 0.14 - s * 0.09,
s * 0.14,
s * 0.18,
i === 0 ? armorDark : armor,
);
}
renderer.drawTexture(
renderer.whiteTexture,
ox - s * 0.25,
oy - s * 0.31,
s * 0.5,
s * 0.62,
armor,
lean * 0.4,
);
renderer.drawTexture(
renderer.whiteTexture,
ox + dirX * s * 0.03 - s * 0.18,
oy - s * 0.25,
s * 0.36,
s * 0.4,
armorLight,
lean * 0.4,
);
renderer.drawTexture(
renderer.whiteTexture,
ox - s * 0.03,
oy - s * 0.08,
s * 0.06,
s * 0.06,
accent,
);
renderer.drawTexture(
renderer.whiteTexture,
ox - s * 0.25,
oy + s * 0.1 - s * 0.03,
s * 0.5,
s * 0.06,
armorDark,
lean * 0.4,
);
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
renderer.drawTexture(
renderer.whiteTexture,
ox + perpX * s * 0.3 * sign - s * 0.11,
oy + perpY * s * 0.3 * sign - s * 0.2 - s * 0.11,
s * 0.22,
s * 0.22,
armorDark,
);
}
const hx = ox + dirX * s * 0.06;
const hy = oy - s * 0.44;
renderer.drawTexture(
renderer.whiteTexture,
hx - s * 0.18,
hy - s * 0.17,
s * 0.36,
s * 0.34,
armor,
lean * 0.4,
);
const visorLen = s * 0.26;
const visorX = hx + dirX * s * 0.09 - visorLen / 2;
const visorY = hy + dirY * s * 0.09 - s * 0.045;
renderer.drawTexture(
renderer.whiteTexture,
visorX - s * 0.04,
visorY - s * 0.04,
visorLen + s * 0.08,
s * 0.17,
[accent[0], accent[1], accent[2], 0.22 * alpha],
facing,
);
renderer.drawTexture(
renderer.whiteTexture,
visorX,
visorY,
visorLen,
s * 0.09,
accent,
facing,
);
const reach = s * (0.5 - 0.18 * pull);
const gunX = ox + dirX * (s * 0.18 + reach * 0.4);
const gunY = oy + dirY * (s * 0.18 + reach * 0.4);
const muzzleX = gunX + dirX * (reach * 0.6 + s * 0.4);
const muzzleY = gunY + dirY * (reach * 0.6 + s * 0.4);
drawLimb(gunX, gunY, muzzleX, muzzleY, s * 0.12, [0.06, 0.08, 0.12, alpha]);
renderer.drawTexture(
renderer.whiteTexture,
muzzleX - s * 0.04,
muzzleY - s * 0.04,
s * 0.08,
s * 0.08,
accent,
);
renderer.drawTexture(
renderer.whiteTexture,
gunX - dirX * s * 0.08 - s * 0.09,
gunY - dirY * s * 0.08 - s * 0.09,
s * 0.18,
s * 0.18,
armorDark,
facing,
);
}
}
+265
View File
@@ -0,0 +1,265 @@
import { Zombie } from './Zombie.js';
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
import { WitchProjectile } from './WitchProjectile.js';
export class Witch extends Zombie {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 85,
size = 28,
color = [0.55, 0.2, 0.8, 1],
health = 5,
knockback = 180,
scoreValue = 4,
preferredDistance = 220,
fireInterval = 2.2,
projectileSpeed = 220,
projectileCount = 1,
projectileSpread = 0.08,
projectileColor = [0.7, 0.2, 1, 1],
emergeTime = 0,
spawnProjectile = (p) => {},
onDeath = null,
} = {},
) {
super(x, y, target, {
container,
viewport,
speed,
size,
color,
health,
knockback,
scoreValue,
emergeTime,
onDeath,
});
this.preferredDistance = preferredDistance;
this.fireInterval = fireInterval;
this.projectileSpeed = projectileSpeed;
this.projectileCount = projectileCount;
this.projectileSpread = projectileSpread;
this.projectileColor = projectileColor;
this.spawnProjectile = spawnProjectile;
this._fireTimer = 0;
this._time = 0;
this._cast = 0;
}
update(dt) {
this._time += dt;
this._cast = Math.max(0, this._cast - dt);
if (this._updateEmerge(dt)) {
return;
}
this._fireTimer -= dt;
const delta = this.target.pos.subV(this.pos);
const distance = delta.length();
let move = new Vector2D(0, 0);
if (distance > 1) {
const dir = delta.divS(distance);
if (distance < this.preferredDistance) {
move = dir.mulS(-this.speed);
} else if (distance > this.preferredDistance + 40) {
move = dir.mulS(this.speed);
}
}
this.velocity = move.addV(this._hitKnockback);
GameObject.prototype.update.call(this, dt);
this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping));
this._flash = Math.max(0, this._flash - dt);
if (this._fireTimer <= 0 && this.target.health > 0 && distance < 480) {
this._fireTimer = this.fireInterval * (0.8 + Math.random() * 0.4);
this.fire();
}
}
_onEmergenceComplete() {
this._fireTimer = this.fireInterval * (0.4 + Math.random() * 0.4);
}
fire() {
this._cast = 0.22;
const aim = this.target.pos.subV(this.pos);
const aimAngle = aim.lengthSqrt() > 1
? Math.atan2(aim.y, aim.x)
: this._time;
const offset = ((this.projectileCount - 1) / 2) * this.projectileSpread;
for (let i = 0; i < this.projectileCount; i += 1) {
const angle = aimAngle - offset + i * this.projectileSpread;
const direction = new Vector2D(Math.cos(angle), Math.sin(angle));
const projectile = new WitchProjectile(this.pos.x, this.pos.y, direction, {
container: this.container,
viewport: this.viewport,
speed: this.projectileSpeed,
color: this.projectileColor,
});
this.spawnProjectile(projectile);
}
}
draw(renderer) {
this._drawEmerge(renderer);
let s = this.size * this._emergeSize();
let bodyColor = this.baseColor;
if (this._flash > 0) {
bodyColor = [1, 1, 1, 1];
} else if (this.maxHealth > 1) {
const hpRatio = this.health / this.maxHealth;
bodyColor = [
this.baseColor[0] + (1 - this.baseColor[0]) * (1 - hpRatio),
this.baseColor[1] * hpRatio,
this.baseColor[2] * hpRatio,
1,
];
}
const bob = Math.sin(this._time * 3.4) * s * 0.09;
const sway = Math.sin(this._time * 1.7) * 0.09;
const castRatio = Math.min(1, this._cast / 0.22);
const charge = Math.max(
0,
Math.min(1, (this.fireInterval - this._fireTimer) / this.fireInterval),
);
const targetSide = this.target.pos.x < this.pos.x ? -1 : 1;
const flightSide = Math.abs(this.velocity.x) > 20
? (this.velocity.x < 0 ? -1 : 1)
: targetSide;
const facing = flightSide;
const lean = facing * s * 0.05;
const drawLimb = (ax, ay, bx, by, thickness, limbColor) => {
const len = Math.hypot(bx - ax, by - ay);
if (len < 1) return;
renderer.drawTexture(
renderer.whiteTexture,
(ax + bx) / 2 - len / 2,
(ay + by) / 2 - thickness / 2,
len,
thickness,
limbColor,
Math.atan2(by - ay, bx - ax),
);
};
const robe = s * 0.9;
const robeW = robe * (1 + Math.sin(this._time * 3.4) * 0.05);
const cy = this.pos.y + bob;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x + lean - robeW / 2,
cy - robe / 2,
robeW,
robe,
bodyColor,
);
const armColor = [bodyColor[0] * 0.72, bodyColor[1] * 0.72, bodyColor[2] * 0.72, 1];
const armW = s * 0.16;
const armLen = s * (0.3 + castRatio * 0.26);
const armSpread = s * (0.36 + castRatio * 0.14);
const armY = cy - robe * 0.2 - castRatio * s * 0.14;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const raise = Math.sin(this._time * 6) * castRatio * s * 0.06;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x + sign * armSpread - armW / 2,
armY - armLen / 2 + sign * raise,
armW,
armLen,
armColor,
);
}
const hatW = s * 0.7;
const hatH = s * 0.9;
const hatCx = this.pos.x + lean + sway * s * 0.14;
const hatCy = cy - robe / 2 - hatH * 0.55 + bob * 0.4;
const hatAngle = sway * 0.8 + castRatio * -0.08;
renderer.drawTexture(
renderer.whiteTexture,
hatCx - hatW / 2,
hatCy - hatH / 2,
hatW,
hatH,
[0.15, 0.08, 0.2, 1],
hatAngle,
);
const brimW = s * 0.98;
const brimH = s * 0.14;
renderer.drawTexture(
renderer.whiteTexture,
hatCx - brimW / 2,
hatCy + hatH * 0.1 - brimH / 2,
brimW,
brimH,
[0.15, 0.08, 0.2, 1],
hatAngle,
);
const seatY = cy + robe * 0.14 + Math.sin(this._time * 2.3) * s * 0.02;
const seatX = this.pos.x + lean;
const handleX = seatX + facing * s * 0.5;
const bristleX = seatX - facing * s * 0.24;
drawLimb(handleX, seatY, bristleX, seatY, s * 0.13, [0.4, 0.3, 0.15, 1]);
drawLimb(bristleX, seatY - s * 0.02, bristleX - facing * s * 0.14, seatY - s * 0.09, s * 0.18, [0.62, 0.48, 0.24, 1]);
if (charge > 0.25) {
const eyeFade = (charge - 0.25) / 0.75;
const eyeY = hatCy + hatH * 0.32;
const eyeSpread = s * 0.13;
const eyeS = s * 0.11;
const eyeCx = this.pos.x + lean + targetSide * s * 0.06;
renderer.setBlendMode('additive');
renderer.drawTexture(
renderer.glowTexture,
eyeCx - eyeSpread - eyeS,
eyeY - eyeS,
eyeS * 2,
eyeS * 2,
[0.85, 0.35, 1, 0.75 * eyeFade],
);
renderer.drawTexture(
renderer.glowTexture,
eyeCx + eyeSpread - eyeS,
eyeY - eyeS,
eyeS * 2,
eyeS * 2,
[0.85, 0.35, 1, 0.75 * eyeFade],
);
renderer.setBlendMode('normal');
}
if (castRatio > 0) {
renderer.setBlendMode('additive');
const fx = this.pos.x;
const fy = hatCy - hatH * 0.72;
renderer.drawTexture(
renderer.glowTexture,
fx - s * 0.5,
fy - s * 0.5,
s,
s,
[0.8, 0.4, 1, 0.9 * castRatio],
);
renderer.setBlendMode('normal');
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
export class WitchProjectile extends GameObject {
constructor(
x,
y,
direction,
{ container, viewport, speed = 220, size = 12, color = [0.7, 0.2, 1, 1] } = {},
) {
super(x, y);
this.velocity = direction.mulS(speed);
this.container = container;
this.viewport = viewport;
this.size = size;
this.color = color;
this.width = size;
this.height = size;
this._time = 0;
}
update(dt) {
super.update(dt);
this._time += dt;
const { width, height } = this.viewport;
if (
this.pos.x < -this.size ||
this.pos.x > width + this.size ||
this.pos.y < -this.size ||
this.pos.y > height + this.size
) {
this.remove(this.container);
}
}
draw(renderer) {
const pulse = 1 + 0.15 * Math.sin(this._time * 12);
const s = this.size * pulse;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s / 2,
this.pos.y - s / 2,
s,
s,
this.color,
);
}
}
+377
View File
@@ -0,0 +1,377 @@
import { GameObject } from '../engine/GameObject.js';
import { Vector2D } from '../engine/Vector2D.js';
export class Zombie extends GameObject {
constructor(
x,
y,
target,
{
container,
viewport,
speed = 110,
size = 24,
color = [0.15, 0.65, 0.2, 1],
health = 1,
knockback = 260,
knockbackDamping = 10,
scoreValue = 1,
emergeTime = 0,
onDeath = null,
} = {},
) {
super(x, y);
this.target = target;
this.container = container;
this.viewport = viewport;
this.speed = speed;
this.size = size;
this.baseColor = [...color];
this.health = health;
this.maxHealth = health;
this.knockback = knockback;
this.knockbackDamping = knockbackDamping;
this.scoreValue = scoreValue;
this.onDeath = onDeath;
this._hitKnockback = new Vector2D(0, 0);
this._flash = 0;
this._time = 0;
this._broken = Math.random() * Math.PI * 2;
this._emergeTotal = emergeTime;
this._emerge = emergeTime;
this.width = size;
this.height = size;
}
die() {
if (this.onDeath) {
this.onDeath(this);
}
this.remove(this.container);
}
hit(impulse, damage = 1) {
this.health -= damage;
this._hitKnockback = impulse.mulS(this.knockback);
this._flash = 0.08;
return this.health <= 0;
}
update(dt) {
this._time += dt;
if (this._updateEmerge(dt)) return;
const delta = this.target.pos.subV(this.pos);
const distance = delta.length();
let chaseVelocity = new Vector2D(0, 0);
if (distance > 1) {
chaseVelocity = delta.divS(distance).mulS(this.speed);
}
this.velocity = chaseVelocity.addV(this._hitKnockback);
super.update(dt);
this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping));
this._flash = Math.max(0, this._flash - dt);
}
_updateEmerge(dt) {
if (this._emerge <= 0) return false;
this._emerge -= dt;
if (this._emerge <= 0) {
this._emerge = 0;
if (this._onEmergenceComplete) this._onEmergenceComplete();
}
return true;
}
_emergeSize() {
if (this._emerge > 0 && this._emergeTotal > 0) {
return 0.3 + 0.7 * (1 - this._emerge / this._emergeTotal);
}
return 1;
}
_drawEmerge(renderer) {
if (this._emerge <= 0) return;
const gear = 1 - this._emerge / this._emergeTotal;
renderer.setBlendMode('additive');
const g = this.size * (3.4 - gear * 1.4);
renderer.drawTexture(
renderer.glowTexture,
this.pos.x - g / 2,
this.pos.y - g / 2,
g,
g,
[this.baseColor[0], this.baseColor[1], this.baseColor[2], 0.5 * (1 - gear)],
);
renderer.setBlendMode('normal');
}
draw(renderer) {
this.width = this.size;
this.height = this.size;
let color = this.baseColor;
if (this._flash > 0) {
color = [1, 1, 1, 1];
} else if (this.maxHealth > 1) {
const hpRatio = this.health / this.maxHealth;
color = [
this.baseColor[0] + (1 - this.baseColor[0]) * (1 - hpRatio),
this.baseColor[1] * hpRatio,
this.baseColor[2] * hpRatio,
1,
];
}
if (this.maxHealth > 1) {
this._drawJuggernaut(renderer, color);
return;
}
this._drawEmerge(renderer);
const s = this.size * this._emergeSize();
const lean = s <= 16;
const facing = Math.atan2(
this.target.pos.y - this.pos.y,
this.target.pos.x - this.pos.x,
);
const cosF = Math.cos(facing);
const sinF = Math.sin(facing);
const walk = Math.sin(this._time * (lean ? 14 : 10) + this._broken);
const shade = [color[0] * 0.6, color[1] * 0.6, color[2] * 0.6, 1];
const shadeDark = [color[0] * 0.38, color[1] * 0.38, color[2] * 0.38, 1];
const light = [
Math.min(1, color[0] + 0.25),
Math.min(1, color[1] + 0.25),
Math.min(1, color[2] + 0.25),
1,
];
const drawLimb = (ax, ay, bx, by, thickness, limbColor) => {
const len = Math.hypot(bx - ax, by - ay);
if (len < 1) return;
renderer.drawTexture(
renderer.whiteTexture,
(ax + bx) / 2 - len / 2,
(ay + by) / 2 - thickness / 2,
len,
thickness,
limbColor,
Math.atan2(by - ay, bx - ax),
);
};
if (!lean) {
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.62,
this.pos.y - s * 0.5 + s * 0.06,
s * 1.24,
s * 0.92,
[0, 0, 0, 0.35],
);
}
const legLen = s * (lean ? 0.32 : 0.4);
const legSpread = s * 0.22;
const hipX = this.pos.x - cosF * s * 0.18;
const hipY = this.pos.y - sinF * s * 0.18;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const step = sign * walk * s * (lean ? 0.3 : 0.18);
const footX = hipX + cosF * legLen - sinF * legSpread * sign + cosF * step;
const footY = hipY + sinF * legLen + cosF * legSpread * sign + sinF * step;
drawLimb(hipX, hipY, footX, footY, s * 0.15, shadeDark);
}
const torsoW = s * (lean ? 0.5 : 0.62);
const torsoH = s * (lean ? 0.46 : 0.55);
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - torsoW / 2,
this.pos.y - torsoH / 2,
torsoW,
torsoH,
color,
);
const armLen = s * 0.5;
const armSpread = s * 0.27;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const shX = this.pos.x - sinF * armSpread * sign;
const shY = this.pos.y + cosF * armSpread * sign - s * 0.02;
const reach = 1 + walk * 0.1;
const handX = shX + cosF * armLen * reach;
const handY = shY + sinF * armLen * reach;
drawLimb(shX, shY, handX, handY, s * 0.17, shade);
}
const headSize = s * (lean ? 0.4 : 0.44);
const headX = this.pos.x;
const headY = this.pos.y - s * (lean ? 0.34 : 0.4) + walk * s * 0.03;
renderer.drawTexture(
renderer.whiteTexture,
headX - headSize / 2,
headY - headSize / 2,
headSize,
headSize,
light,
);
const eyeSize = headSize * 0.2;
const eyeSpread = headSize * 0.24;
const look = lean ? 0.1 : 0.04;
const eyeY = headY + sinF * headSize * look;
renderer.drawTexture(
renderer.whiteTexture,
headX - eyeSize / 2 - sinF * eyeSpread,
eyeY - eyeSize / 2 + cosF * eyeSpread,
eyeSize,
eyeSize,
[0.05, 0.05, 0.05, 1],
);
renderer.drawTexture(
renderer.whiteTexture,
headX - eyeSize / 2 + sinF * eyeSpread,
eyeY - eyeSize / 2 - cosF * eyeSpread,
eyeSize,
eyeSize,
[0.05, 0.05, 0.05, 1],
);
}
_drawJuggernaut(renderer, color) {
const s = this.size;
const facing = Math.atan2(
this.target.pos.y - this.pos.y,
this.target.pos.x - this.pos.x,
);
const cosF = Math.cos(facing);
const sinF = Math.sin(facing);
const walk = Math.sin(this._time * 5 + this._broken);
const shade = [color[0] * 0.6, color[1] * 0.6, color[2] * 0.6, 1];
const shadeDark = [color[0] * 0.38, color[1] * 0.38, color[2] * 0.38, 1];
const light = [
Math.min(1, color[0] + 0.25),
Math.min(1, color[1] + 0.25),
Math.min(1, color[2] + 0.25),
1,
];
const mix = (a, b, t) => a + (b - a) * t;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.78,
this.pos.y - s * 0.55 + s * 0.08,
s * 1.56,
s * 1.14,
[0, 0, 0, 0.35],
);
const footSize = s * 0.16;
const footBaseY = this.pos.y + s * 0.46;
for (let i = 0; i < 2; i += 1) {
const sign = i === 0 ? 1 : -1;
const stepX = -sinF * s * 0.2 * sign + cosF * walk * s * 0.06 * sign;
const stepY = cosF * s * 0.2 * sign + sinF * walk * s * 0.06 * sign;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x + stepX - footSize / 2,
footBaseY + stepY - footSize / 2,
footSize,
footSize,
shadeDark,
);
}
const shellW = s * 1.0;
const shellH = s * 0.95;
const bottomY = this.pos.y + s * 0.4;
const topY = this.pos.y - s * 0.5;
const layers = 6;
for (let i = 0; i < layers; i += 1) {
const u = i / (layers - 1);
const w = shellW * (1 - u * 0.66);
const h = (shellH / layers) * 1.4;
const cy = bottomY - u * (bottomY - topY) + walk * s * 0.02;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - w / 2,
cy - h / 2,
w,
h,
[
mix(shadeDark[0], light[0], u),
mix(shadeDark[1], light[1], u),
mix(shadeDark[2], light[2], u),
1,
],
);
}
for (let i = 0; i < 3; i += 1) {
const rx = this.pos.x + (i - 1) * s * 0.16;
renderer.drawTexture(
renderer.whiteTexture,
rx - s * 0.045,
topY + s * 0.04,
s * 0.09,
shellH * 0.9,
shadeDark,
);
}
const rivet = s * 0.055;
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x - s * 0.44,
bottomY - rivet * 0.4,
rivet,
rivet,
shadeDark,
);
renderer.drawTexture(
renderer.whiteTexture,
this.pos.x + s * 0.44 - rivet,
bottomY - rivet * 0.4,
rivet,
rivet,
shadeDark,
);
const headSize = s * 0.24;
const headX = this.pos.x + cosF * s * 0.52;
const headY = this.pos.y + sinF * s * 0.52;
renderer.drawTexture(
renderer.whiteTexture,
headX - headSize / 2,
headY - headSize / 2,
headSize,
headSize,
light,
);
const eyeSize = headSize * 0.22;
const eyeSpread = headSize * 0.3;
renderer.drawTexture(
renderer.whiteTexture,
headX - eyeSize / 2 - sinF * eyeSpread,
headY - eyeSize / 2 + cosF * eyeSpread,
eyeSize,
eyeSize,
[0.05, 0.05, 0.05, 1],
);
renderer.drawTexture(
renderer.whiteTexture,
headX - eyeSize / 2 + sinF * eyeSpread,
headY - eyeSize / 2 - cosF * eyeSpread,
eyeSize,
eyeSize,
[0.05, 0.05, 0.05, 1],
);
}
}
+1103
View File
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
import { Zombie } from '../game/Zombie.js';
import { MegaTank } from '../game/MegaTank.js';
import { Leaper } from '../game/Leaper.js';
import { Witch } from '../game/Witch.js';
import { MegaWitch } from '../game/MegaWitch.js';
import { Boss } from '../game/Boss.js';
import { Bullet } from '../game/Bullet.js';
import { WitchProjectile } from '../game/WitchProjectile.js';
import { Pickup } from '../game/Pickup.js';
import { Player } from '../game/Player.js';
import { Vector2D } from '../engine/Vector2D.js';
const CLASSES = {
zombie: Zombie,
megatank: MegaTank,
leaper: Leaper,
witch: Witch,
megawitch: MegaWitch,
boss: Boss,
bullet: Bullet,
projectile: WitchProjectile,
};
const PLAYER_SIZE = 24;
export class NetEntities {
constructor() {
this.target = { pos: new Vector2D(960, 540) };
this.views = new Map();
this.players = new Map();
}
setTarget(x, y) {
this.target.pos.x = x;
this.target.pos.y = y;
}
_timeFor(eid, now) {
return now + (eid % 17) * 0.21 + (eid % 5) * 0.07;
}
_key(entity) {
return `${entity.d}#${entity.eid}`;
}
_createView(entity) {
const Ctor = CLASSES[entity.d];
if (entity.d === 'pickup') {
return new Pickup(entity.x, entity.y, entity.t, { container: null, viewport: {}, life: 10 });
}
if (entity.d === 'bullet' || entity.d === 'projectile') {
return new Ctor(entity.x, entity.y, new Vector2D(1, 0), {});
}
return new Ctor(entity.x, entity.y, this.target, {});
}
draw(entities, players, renderer, textRenderer, now) {
const seen = new Set();
let boss = null;
for (const entity of entities) {
const key = this._key(entity);
seen.add(key);
let view = this.views.get(key);
if (!view) {
view = this._createView(entity);
this.views.set(key, view);
}
this._stamp(view, entity, now);
view.draw(renderer, textRenderer);
if (entity.d === 'boss') {
boss = view;
}
}
for (const [key, view] of this.views) {
if (!seen.has(key)) {
this.views.delete(key);
}
}
for (const snapshot of players) {
let view = this.players.get(snapshot.id);
if (!view) {
view = new Player({
input: null,
container: null,
viewport: { width: 1920, height: 1080 },
size: PLAYER_SIZE,
});
this.players.set(snapshot.id, view);
}
this._stampPlayer(view, snapshot, now);
if (!snapshot.dead) {
view.draw(renderer);
}
}
for (const [id, view] of this.players) {
if (!players.some((snapshot) => snapshot.id === id)) {
this.players.delete(id);
}
}
return boss;
}
clear() {
this.views.clear();
this.players.clear();
}
_stamp(view, entity, now) {
view.pos.x = entity.x;
view.pos.y = entity.y;
if (entity.s !== undefined) {
view.size = entity.s;
}
if (entity.c) {
view.baseColor = [entity.c[0], entity.c[1], entity.c[2], 1];
}
if (entity.hp !== undefined && entity.mhp !== undefined) {
view.health = entity.hp;
view.maxHealth = entity.mhp;
}
view._time = this._timeFor(entity.eid, now);
view._flash = 0;
if (entity.et !== undefined) {
view._emergeTotal = entity.et;
view._emerge = entity.er ?? 0;
}
switch (entity.d) {
case 'leaper':
view._dashTimer = entity.dash ? 999 : 0;
view._windupTimer = entity.wind ? 999 : 0;
if (!view._dashDirection) {
view._dashDirection = { x: 1, y: 0 };
}
break;
case 'megawitch':
view._novaTimer = (1 - (entity.charge ?? 0)) * view.novaInterval;
break;
case 'witch':
view._cast = 0;
view._fireTimer = view.fireInterval * (1 - ((entity.eid * 0.37 + now * 0.11) % 1));
break;
case 'boss':
view._hurt = 0;
break;
case 'pickup':
view._time = Math.max(0, 10 - entity.life);
break;
default:
break;
}
}
_stampPlayer(view, snapshot, now) {
view.pos.x = snapshot.x;
view.pos.y = snapshot.y;
const aim = new Vector2D(snapshot.aimX, snapshot.aimY);
if (aim.lengthSqrt() > 0.01) {
view._aimDirection = aim.normalize();
}
view._time = now;
view._damageCooldown = 0;
view.health = snapshot.hp;
view.maxHealth = snapshot.mhp;
}
}
+201
View File
@@ -0,0 +1,201 @@
import { SocketClient } from './SocketClient.js';
const INPUT_RATE = 30;
export class NetworkClient {
constructor(options = {}) {
this.socket = new SocketClient(options);
this.room = null;
this.playerId = null;
this.playerName = null;
this.joined = false;
this.creator = false;
this.prev = null;
this.prevAt = 0;
this.curr = null;
this.currAt = 0;
this.onStateChange = null;
this.onRoomError = null;
this._input = null;
this._inputTimer = null;
this.socket.onConnectionChange = (connected) => {
if (connected && this.joined && this.room) {
if (this.creator) {
this._sendCreate();
} else {
this._sendJoin();
}
}
this.onStateChange?.({ type: 'connection', connected });
};
}
connect() {
this.socket.connect();
}
get connected() {
return this.socket.connected;
}
join(room, { id, name }) {
this._enterRoom(room, { id, name }, false);
}
create(room, { id, name }) {
this._enterRoom(room, { id, name }, true);
}
_enterRoom(room, { id, name }, isCreator) {
const changingRoom = this.room && this.room !== room;
if (changingRoom) {
this.socket.unsubscribe(this._snapshotChannel());
this.socket.unsubscribe(this._inputChannel());
this._sendControl('leave');
}
this.room = room;
this.playerId = id;
this.playerName = name;
this.joined = true;
this.creator = isCreator;
this.prev = null;
this.prevAt = 0;
this.curr = null;
this.currAt = 0;
this.socket.subscribe(this._snapshotChannel(), {
onEvent: (event, payload) => {
if (event === 'snapshot') {
this._ingest(payload);
} else if (event === 'room-error') {
this.onRoomError?.(payload);
}
},
});
this.socket.subscribe(this._inputChannel());
if (isCreator) {
this._sendCreate();
} else {
this._sendJoin();
}
if (!this._inputTimer) {
this._inputTimer = setInterval(() => this._flushInput(), 1000 / INPUT_RATE);
}
}
leave() {
if (!this.joined) return;
this._sendControl('leave');
this.joined = false;
this.creator = false;
this.room = null;
this.prev = null;
this.prevAt = 0;
this.curr = null;
this.currAt = 0;
if (this._inputTimer) {
clearInterval(this._inputTimer);
this._inputTimer = null;
}
}
abandonRoom() {
if (this.room) {
this.socket.unsubscribe(this._snapshotChannel());
this.socket.unsubscribe(this._inputChannel());
}
this.joined = false;
this.creator = false;
this.room = null;
this.prev = null;
this.prevAt = 0;
this.curr = null;
this.currAt = 0;
if (this._inputTimer) {
clearInterval(this._inputTimer);
this._inputTimer = null;
}
}
start() {
this._sendControl('start');
}
reset() {
this._sendControl('reset');
}
sendInput(frame) {
this._input = frame;
}
stopSendingInput() {
this._input = null;
}
_flushInput() {
if (!this._input || !this._connected()) return;
const { id, keys, aim, fire, autoAim } = this._input;
this.socket.trigger('client-input', this._inputChannel(), {
id,
keys,
aim,
fire,
autoAim,
});
}
_connected() {
return this.joined && this.room && this.socket.connected;
}
_snapshotChannel() {
return `game.${this.room}`;
}
_inputChannel() {
return `game.${this.room}.input`;
}
_sendControl(type) {
if (!this._connected()) return;
this.socket.trigger(`client-${type}`, this._inputChannel(), {
type,
id: this.playerId,
name: this.playerName,
});
}
_sendJoin() {
if (!this.room || !this.playerId) return;
this.socket.trigger('client-join', this._inputChannel(), {
type: 'join',
id: this.playerId,
name: this.playerName,
});
}
_sendCreate() {
if (!this.room || !this.playerId) return;
this.socket.trigger('client-create', this._inputChannel(), {
type: 'create',
id: this.playerId,
name: this.playerName,
});
}
_ingest(payload) {
if (this.curr && payload.seq !== undefined && this.curr.seq !== undefined && payload.seq <= this.curr.seq) {
return;
}
this.prev = this.curr;
this.prevAt = this.currAt;
this.curr = payload;
this.currAt = performance.now();
}
}
+155
View File
@@ -0,0 +1,155 @@
const DEFAULT_OPTIONS = {
scheme: 'ws',
host: '127.0.0.1',
port: 8080,
key: 'game-local-key',
client: 'zombie-client',
version: '1.0.0',
};
export class SocketClient {
constructor(options = {}) {
this.opts = { ...DEFAULT_OPTIONS, ...options };
this.socket = null;
this.socketId = null;
this.connected = false;
this.closedByUser = false;
this.retries = 0;
this.handlers = new Map();
this.queue = [];
this.onConnectionChange = null;
}
get url() {
const { scheme, host, port, key, client, version } = this.opts;
return `${scheme}://${host}:${port}/app/${key}?protocol=7&client=${client}&version=${version}&flash=false`;
}
_connect() {
if (this.socket) return;
this.closedByUser = false;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.onopen = () => {
this.retries = 0;
};
socket.onmessage = (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
this._handle(msg);
};
socket.onclose = () => {
this.socket = null;
if (this.socketId) {
this.connected = false;
this.socketId = null;
this.onConnectionChange?.(false);
}
if (this.closedByUser) return;
if (!this.handlers.size) return;
const delay = Math.min(5000, 300 * 2 ** this.retries);
this.retries += 1;
setTimeout(() => this._connect(), delay);
};
socket.onerror = () => {};
}
connect() {
if (this.socket) return;
this._connect();
}
close() {
this.closedByUser = true;
this.handlers.clear();
this.socket?.close();
this.socket = null;
this.connected = false;
this.socketId = null;
}
_handle(msg) {
const { event, channel } = msg;
if (event === 'pusher:connection_established') {
try {
this.socketId = JSON.parse(msg.data).socket_id;
} catch {
this.socketId = null;
}
this.connected = true;
this.onConnectionChange?.(true);
for (const channel of this.handlers.keys()) {
this._sendSubscribe(channel);
}
this.queue = [];
return;
}
if (event === 'pusher_internal:subscription_succeeded') {
if (channel) this.handlers.get(channel)?.onSubscribed?.();
return;
}
if (event === 'pusher:ping') {
this.send({ event: 'pusher:pong', data: {} });
return;
}
if (event === 'pusher:pong') {
return;
}
if (channel && this.handlers.has(channel)) {
let payload = msg.data;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
return;
}
}
this.handlers.get(channel)?.onEvent?.(event, payload);
}
}
_sendSubscribe(channel) {
this.send({ event: 'pusher:subscribe', data: { channel } });
}
subscribe(channel, { onEvent = null, onSubscribed = null } = {}) {
this.handlers.set(channel, { onEvent, onSubscribed });
if (this.connected) {
this._sendSubscribe(channel);
} else {
this.queue.push({ channel });
}
}
unsubscribe(channel) {
this.handlers.delete(channel);
this.queue = this.queue.filter((entry) => entry.channel !== channel);
if (this.connected) {
this.send({ event: 'pusher:unsubscribe', data: { channel } });
}
}
trigger(event, channel, data) {
if (!this.connected) return;
this.send({ event, channel, data });
}
send(payload) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zombies</title>
@vite(['resources/css/game.css', 'resources/js/main.js'])
</head>
<body>
<canvas id="game"></canvas>
<button type="button" id="fullscreen">Fullscreen</button>
<div id="lobby">
<div id="lobby-panel">
<h1>ZOMBIES</h1>
<p class="sub">Co-op survival from your browser.</p>
<div id="prejoin">
<label for="name">Name</label>
<input id="name" type="text" maxlength="16" autocomplete="off" />
<div class="mode">
<button type="button" id="lobby-host">Host a Room</button>
<button type="button" id="lobby-join">Join a Room</button>
</div>
<div id="join-form" class="hidden">
<label for="room">Room code</label>
<div class="row">
<input id="room" type="text" maxlength="16" autocomplete="off" placeholder="e.g. ab7x2" />
<button type="button" id="lobby-join-confirm">Join</button>
</div>
</div>
</div>
<div id="inroom">
<label>Room code</label>
<div class="room-code">
<span id="room-code"></span>
<button type="button" id="room-copy">Copy</button>
</div>
<button type="button" id="lobby-start" disabled>Start Game</button>
</div>
<div id="lobby-status"></div>
<div id="lobby-players"></div>
</div>
</div>
<div id="gameover">
<div id="gameover-panel">
<h2>GAME OVER</h2>
<div id="gameover-scores"></div>
<button type="button" id="gameover-again">Play Again</button>
<div id="gameover-wait"></div>
</div>
</div>
</body>
</html>