initial commit
This commit is contained in:
commit
e8c3f9862a
193 files changed
+29795
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user