initial commit

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

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
const MAX_RINGS = 40;
const MAX_GLOWS = 60;
const MAX_STREAKS = 90;
const MAX_ARCS = 28;
const easeOut = (t) => 1 - (1 - t) * (1 - t);
export class Effects {
constructor() {
this.rings = [];
this.glows = [];
this.streaks = [];
this.arcs = [];
}
ring(x, y, {
endRadius = 140,
width = 5,
life = 0.5,
color = [1, 1, 1, 1],
additive = true,
} = {}) {
if (this.rings.length >= MAX_RINGS) this.rings.shift();
this.rings.push({
x, y,
radius: endRadius * 0.12,
endRadius,
width,
life,
maxLife: life,
color,
additive,
});
}
glow(x, y, {
radius = 90,
life = 0.35,
color = [1, 1, 1, 1],
additive = true,
} = {}) {
if (this.glows.length >= MAX_GLOWS) this.glows.shift();
this.glows.push({
x, y,
radius: radius * 0.2,
endRadius: radius,
life,
maxLife: life,
color,
additive,
});
}
streak(x, y, angle, length, {
width = 3,
life = 0.25,
fade = true,
color = [1, 1, 1, 1],
additive = true,
tail = 0.7,
} = {}) {
if (this.streaks.length >= MAX_STREAKS) this.streaks.shift();
this.streaks.push({
x: x - Math.cos(angle) * length * tail,
y: y - Math.sin(angle) * length * tail,
angle,
length,
width,
life,
maxLife: life,
fade,
color,
additive,
tail,
});
}
arc(x1, y1, x2, y2, {
segments = 8,
width = 3,
life = 0.3,
color = [1, 1, 1, 1],
jitter = 26,
additive = true,
} = {}) {
if (this.arcs.length >= MAX_ARCS) this.arcs.shift();
this.arcs.push({
x1, y1, x2, y2,
segments,
width,
life,
maxLife: life,
color,
jitter,
additive,
seed: Math.random() * 100,
});
}
update(dt) {
const tick = (list) => {
for (let i = list.length - 1; i >= 0; i -= 1) {
list[i].life -= dt;
if (list[i].life <= 0) list.splice(i, 1);
}
};
tick(this.rings);
tick(this.glows);
tick(this.streaks);
tick(this.arcs);
}
draw(renderer) {
for (const list of [this.glows, this.rings, this.streaks, this.arcs]) {
const additive = list.filter((effect) => effect.additive);
const normal = list.filter((effect) => !effect.additive);
if (additive.length > 0) renderer.setBlendMode('additive');
for (const effect of additive) this._drawEffect(renderer, effect);
if (additive.length > 0) renderer.setBlendMode('normal');
for (const effect of normal) this._drawEffect(renderer, effect);
}
}
_drawEffect(renderer, effect) {
const t = 1 - effect.life / effect.maxLife;
const alpha = effect.life / effect.maxLife;
if (effect.x1 !== undefined) {
this._drawArc(renderer, effect, t, alpha);
} else if (effect.angle !== undefined) {
this._drawStreak(renderer, effect, t, alpha);
} else if (effect.width !== undefined) {
this._drawRing(renderer, effect, t, alpha);
} else {
this._drawGlow(renderer, effect, t, alpha);
}
}
_drawGlow(renderer, effect, t, alpha) {
const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t);
renderer.drawTexture(
renderer.glowTexture,
effect.x - radius,
effect.y - radius,
radius * 2,
radius * 2,
[
effect.color[0],
effect.color[1],
effect.color[2],
effect.color[3] * alpha,
],
);
}
_drawRing(renderer, effect, t, alpha) {
const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t);
const fade = Math.min(1, alpha * 1.3);
renderer.drawTexture(
renderer.ringTexture,
effect.x - radius,
effect.y - radius,
radius * 2,
radius * 2,
[
effect.color[0],
effect.color[1],
effect.color[2],
Math.max(0, effect.color[3] * fade),
],
);
}
_drawStreak(renderer, effect, t, alpha) {
const progress = easeOut(t);
const len = effect.length * (1 - progress * (1 - effect.tail));
const fade = effect.fade ? alpha : 1;
renderer.drawTexture(
renderer.glowTexture,
effect.x,
effect.y,
len,
effect.width,
[
effect.color[0],
effect.color[1],
effect.color[2],
effect.color[3] * fade,
],
effect.angle,
);
}
_drawArc(renderer, effect, t, alpha) {
const { x1, y1, x2, y2, segments, width, jitter, seed, color } = effect;
const wobble = 0.45 + 0.55 * Math.abs(Math.sin(seed + t * 34));
const amp = jitter * alpha * wobble;
let prevX = x1;
let prevY = y1;
for (let i = 1; i <= segments; i += 1) {
const u = i / segments;
const edge = (i === segments) ? 0 : 1;
const px = x1 + (x2 - x1) * u + (Math.random() - 0.5) * 2 * amp * edge;
const py = y1 + (y2 - y1) * u + (Math.random() - 0.5) * 2 * amp * edge;
const len = Math.hypot(px - prevX, py - prevY);
if (len > 0) {
renderer.drawTexture(
renderer.whiteTexture,
prevX,
prevY,
len,
width,
[color[0], color[1], color[2], color[3] * alpha],
Math.atan2(py - prevY, px - prevX),
);
}
prevX = px;
prevY = py;
}
}
clear() {
this.rings.length = 0;
this.glows.length = 0;
this.streaks.length = 0;
this.arcs.length = 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Vector2D } from './Vector2D.js';
export class GameObject {
constructor(x = 0, y = 0) {
this.pos = new Vector2D(x, y);
this.velocity = new Vector2D(0, 0);
this.width = 0;
this.height = 0;
}
update(dt = 1 / 60) {
this.pos = this.velocity.mulS(dt).addV(this.pos);
}
intersect(other) {
return (
Math.abs(this.pos.x - other.pos.x) * 2 < this.width + other.width &&
Math.abs(this.pos.y - other.pos.y) * 2 < this.height + other.height
);
}
draw() {}
remove(container) {
if (!container) return;
const index = container.indexOf(this);
if (index !== -1) container.splice(index, 1);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Vector2D } from './Vector2D.js';
export class Input {
constructor(canvas, viewport) {
this.keys = new Set();
this.pressed = new Set();
this.mouse = new Vector2D(viewport.width / 2, viewport.height / 2);
this.mouseDown = false;
this.mouseClicked = false;
window.addEventListener('keydown', (event) => {
this.keys.add(event.key.toLowerCase());
if (!event.repeat) {
this.pressed.add(event.key.toLowerCase());
}
if (event.key === ' ') {
event.preventDefault();
if (
document.activeElement &&
document.activeElement.tagName === 'BUTTON' &&
typeof document.activeElement.blur === 'function'
) {
document.activeElement.blur();
}
}
});
window.addEventListener('keyup', (event) => {
this.keys.delete(event.key.toLowerCase());
});
window.addEventListener('blur', () => {
this.keys.clear();
this.pressed.clear();
this.mouseDown = false;
});
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * viewport.width;
this.mouse.y = ((event.clientY - rect.top) / rect.height) * viewport.height;
});
canvas.addEventListener('mousedown', (event) => {
if (event.button === 0) {
this.mouseDown = true;
this.mouseClicked = true;
}
});
window.addEventListener('mouseup', () => {
this.mouseDown = false;
});
}
clearPressed() {
this.pressed.clear();
this.mouseClicked = false;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Vector2D } from './Vector2D.js';
const MAX_PARTICLES = 450;
export class ParticleSystem {
constructor() {
this.particles = [];
}
burst(
x,
y,
{
count = 12,
speed = 150,
direction = 0,
spread = Math.PI * 2,
life = 0.4,
size = 4,
color = [1, 1, 1, 1],
gravity = 0,
drag = 0,
shape = 'square',
additive = false,
endSize = null,
spin = 0,
} = {},
) {
for (let i = 0; i < count; i++) {
if (this.particles.length >= MAX_PARTICLES) return;
const angle = direction + (Math.random() - 0.5) * spread;
const magnitude = speed * (0.4 + Math.random() * 0.8);
const velocity = new Vector2D(Math.cos(angle), Math.sin(angle)).mulS(magnitude);
const lifetime = life * (0.6 + Math.random() * 0.6);
const startSize = size * (0.6 + Math.random() * 0.8);
this.particles.push({
pos: new Vector2D(x, y),
velocity,
gravity,
drag,
life: lifetime,
maxLife: lifetime,
size: startSize,
endSize: endSize === null ? startSize * 0.15 : endSize * (0.6 + Math.random() * 0.8),
color: [...color],
shape,
additive,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * (spin * 2),
});
}
}
update(dt) {
const { particles } = this;
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
particle.pos = particle.velocity.mulS(dt).addV(particle.pos);
particle.velocity.y += particle.gravity * dt;
if (particle.drag > 0) {
particle.velocity = particle.velocity.mulS(Math.max(0, 1 - particle.drag * dt));
}
particle.angle += particle.spin * dt;
particle.life -= dt;
if (particle.life <= 0) {
particles.splice(i, 1);
}
}
}
draw(renderer) {
const additive = this.particles.filter((particle) => particle.additive);
const normal = this.particles.filter((particle) => !particle.additive);
if (additive.length > 0) renderer.setBlendMode('additive');
for (const particle of additive) this._drawParticle(renderer, particle);
if (additive.length > 0) renderer.setBlendMode('normal');
for (const particle of normal) this._drawParticle(renderer, particle);
}
_drawParticle(renderer, particle) {
const alpha = Math.max(0, particle.life / particle.maxLife);
const progress = 1 - alpha;
const size = particle.size + (particle.endSize - particle.size) * progress;
const color = [
particle.color[0],
particle.color[1],
particle.color[2],
particle.color[3] * alpha,
];
const texture = particle.shape === 'glow' ? renderer.glowTexture : renderer.whiteTexture;
renderer.drawTexture(
texture,
particle.pos.x - size / 2,
particle.pos.y - size / 2,
size,
size,
color,
particle.angle,
);
}
clear() {
this.particles.length = 0;
}
}
+251
View File
@@ -0,0 +1,251 @@
import { Texture } from './Texture.js';
const VERTEX_SOURCE = `#version 300 es
layout(location = 0) in vec2 aPosition;
uniform mat4 uProjection;
uniform mat4 uModel;
out vec2 vTexCoord;
void main() {
vTexCoord = aPosition;
gl_Position = uProjection * uModel * vec4(aPosition, 0.0, 1.0);
}
`;
const FRAGMENT_SOURCE = `#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 outColor;
uniform sampler2D uSampler;
uniform vec4 uColor;
void main() {
outColor = texture(uSampler, vTexCoord) * uColor;
}
`;
const QUAD_POSITIONS = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
const QUAD_INDICES = new Uint16Array([0, 1, 2, 2, 1, 3]);
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile error: ${message}`);
}
return shader;
}
export class Renderer {
constructor(canvas) {
this.canvas = canvas;
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
antialias: true,
});
if (!gl) {
throw new Error('WebGL2 is not supported by this browser.');
}
this.gl = gl;
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SOURCE);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SOURCE);
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(this.program);
throw new Error(`Program link error: ${message}`);
}
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
this.projectionLocation = gl.getUniformLocation(this.program, 'uProjection');
this.modelLocation = gl.getUniformLocation(this.program, 'uModel');
this.colorLocation = gl.getUniformLocation(this.program, 'uColor');
this.samplerLocation = gl.getUniformLocation(this.program, 'uSampler');
this._centeredProjection = new Float32Array(16);
this._modelMatrix = new Float32Array(16);
this._setupQuad();
this.resize(canvas.width, canvas.height);
gl.enable(gl.BLEND);
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
this.setBlendMode('normal');
gl.useProgram(this.program);
gl.uniform1i(this.samplerLocation, 0);
}
setBlendMode(mode) {
this._blendMode = mode;
const { gl } = this;
if (mode === 'additive') {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
} else {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
}
clear(r = 0, g = 0, b = 0, a = 0) {
const { gl } = this;
gl.clearColor(r, g, b, a);
gl.clear(gl.COLOR_BUFFER_BIT);
}
createTextureFromImage(image) {
const texture = new Texture(this.gl, image.width, image.height);
texture.uploadImage(image);
return texture;
}
get whiteTexture() {
if (!this._whiteTexture) {
this._whiteTexture = new Texture(this.gl, 1, 1);
}
return this._whiteTexture;
}
get glowTexture() {
if (!this._glowTexture) {
this._glowTexture = this._buildRadialTexture(64, (ctx, size) => {
const gradient = ctx.createRadialGradient(
size / 2, size / 2, 0,
size / 2, size / 2, size / 2,
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
gradient.addColorStop(0.35, 'rgba(255, 255, 255, 0.6)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
});
}
return this._glowTexture;
}
get ringTexture() {
if (!this._ringTexture) {
this._ringTexture = this._buildRadialTexture(128, (ctx, size) => {
ctx.clearRect(0, 0, size, size);
ctx.lineWidth = size * 0.06;
ctx.strokeStyle = '#fff';
ctx.beginPath();
ctx.arc(size / 2, size / 2, size * 0.42, 0, Math.PI * 2);
ctx.stroke();
const gradient = ctx.createRadialGradient(
size / 2, size / 2, size * 0.28,
size / 2, size / 2, size * 0.52,
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size * 0.5, 0, Math.PI * 2);
ctx.fill();
});
}
return this._ringTexture;
}
_buildRadialTexture(size, draw) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
draw(ctx, size);
const texture = new Texture(this.gl, size, size);
texture.uploadCanvas(canvas);
return texture;
}
drawTexture(texture, x, y, width, height, color = [1, 1, 1, 1], angle = 0) {
const { gl } = this;
this._setModelMatrix(x, y, width, height, angle);
gl.useProgram(this.program);
gl.uniformMatrix4fv(this.projectionLocation, false, this._centeredProjection);
gl.uniformMatrix4fv(this.modelLocation, false, this._modelMatrix);
gl.uniform4f(this.colorLocation, ...color);
gl.bindTexture(gl.TEXTURE_2D, texture.handle);
gl.bindVertexArray(this._quadVao);
gl.drawElements(gl.TRIANGLES, QUAD_INDICES.length, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null);
gl.bindTexture(gl.TEXTURE_2D, null);
}
resize(deviceWidth, deviceHeight, worldWidth = deviceWidth, worldHeight = deviceHeight) {
this.canvas.width = deviceWidth;
this.canvas.height = deviceHeight;
this.gl.viewport(0, 0, deviceWidth, deviceHeight);
this._worldWidth = worldWidth;
this._worldHeight = worldHeight;
this._syncProjection(0, 0, worldWidth, worldHeight);
}
setViewOffset(dx = 0, dy = 0) {
this._syncProjection(dx, dy, this._worldWidth, this._worldHeight);
}
_setupQuad() {
const { gl } = this;
this._quadVao = gl.createVertexArray();
gl.bindVertexArray(this._quadVao);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, QUAD_POSITIONS, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, QUAD_INDICES, gl.STATIC_DRAW);
gl.bindVertexArray(null);
}
_setModelMatrix(x, y, width, height, angle = 0) {
const m = this._modelMatrix;
if (angle === 0) {
m[0] = width; m[1] = 0; m[2] = 0; m[3] = 0;
m[4] = 0; m[5] = height; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = x; m[13] = y; m[14] = 0; m[15] = 1;
return;
}
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const cx = x + width / 2;
const cy = y + height / 2;
m[0] = width * cos; m[1] = width * sin; m[2] = 0; m[3] = 0;
m[4] = -height * sin; m[5] = height * cos; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = cx; m[13] = cy; m[14] = 0; m[15] = 1;
}
_syncProjection(left, top, width, height) {
const m = this._centeredProjection;
m[0] = 2 / width; m[1] = 0; m[2] = 0; m[3] = 0;
m[4] = 0; m[5] = -2 / height; m[6] = 0; m[7] = 0;
m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0;
m[12] = -1 - (2 * left) / width; m[13] = 1 + (2 * top) / height; m[14] = 0; m[15] = 1;
}
}
+372
View File
@@ -0,0 +1,372 @@
export class SoundSystem {
constructor({ muted = false } = {}) {
this.muted = muted;
this.context = null;
this.master = null;
this.delaySend = null;
this.musicOn = false;
this.musicTimer = null;
this._musicStep = 0;
this._nextNoteTime = 0;
this._baseStepDur = 60 / 132 / 4;
this._stepDur = this._baseStepDur;
this._intensity = 0;
this._bossMode = false;
this._stepsPerBar = 16;
this._progression = [
{ root: 73.42, chord: [146.83, 174.61, 220] },
{ root: 58.27, chord: [116.54, 146.83, 174.61] },
{ root: 87.31, chord: [174.61, 220, 261.63] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
];
this._bossProgression = [
{ root: 41.2, chord: [82.41, 98, 123.47] },
{ root: 65.41, chord: [130.81, 164.81, 196] },
{ root: 55, chord: [110, 130.81, 164.81] },
{ root: 73.42, chord: [146.83, 174.61, 220] },
];
this._arpPattern = [0, 1, 2, 1, 0, 2, 1, 2, 0, 1, 2, 1, 0, 2, 1, 2];
this._leadPattern = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[587.33, 0, 698.46, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 587.33, 0, 523.25, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 440, 0, 523.25, 0, 587.33, 0, 0, 0],
];
this._leadPatternB = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[659.26, 0, 880, 0, 987.77, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 0, 0],
[0, 0, 0, 0, 587.33, 0, 659.26, 0, 698.46, 0, 659.26, 0, 587.33, 0, 0, 0],
];
this._bassPattern = [1, 0, 1, 0, 1, 0, 1, 0];
this._barPlan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar: -1 };
const unlock = () => {
if (!this.context) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
this.context = new AudioContext();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.context.destination);
const delay = this.context.createDelay(0.6);
delay.delayTime.value = 0.31;
const feedback = this.context.createGain();
feedback.gain.value = 0.35;
const wet = this.context.createGain();
wet.gain.value = 0.22;
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
wet.connect(this.master);
this.delaySend = delay;
}
if (this.context.state === 'suspended') {
this.context.resume().catch(() => {});
}
};
window.addEventListener('keydown', unlock, { once: false });
window.addEventListener('pointerdown', unlock, { once: false });
}
startMusic() {
if (this.musicOn) return;
this.musicOn = true;
this._musicStep = 0;
this._nextNoteTime = this.context ? this.context.currentTime + 0.1 : 0;
if (this.musicTimer) clearInterval(this.musicTimer);
this.musicTimer = setInterval(() => this._scheduleMusic(), 100);
}
stopMusic() {
this.musicOn = false;
if (this.musicTimer) {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
}
setIntensity(intensity) {
const clamped = Math.max(0, Math.min(1, intensity));
this._intensity = clamped;
this._stepDur = this._baseStepDur * (1 - 0.35 * clamped);
}
setBossMode(on) {
this._bossMode = on;
}
_scheduleMusic() {
if (!this.context || !this.musicOn) return;
const aheadTime = this.context.currentTime + 0.5;
const totalSteps = this._stepsPerBar * this._progression.length;
while (this._nextNoteTime < aheadTime) {
try {
this._scheduleStep(this._musicStep, this._nextNoteTime);
} catch (error) {
console.error('Music schedule error', error);
}
this._nextNoteTime += this._stepDur;
this._musicStep = (this._musicStep + 1) % totalSteps;
}
}
_rollBarPlan(bar) {
const plan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar };
if (bar === 1) {
const roll = Math.random();
if (roll < 0.2) {
plan.fill = true;
} else if (roll < 0.32) {
plan.breakdown = true;
}
} else if (bar === 2 || bar === 3) {
plan.leadVariant = Math.random() < 0.35 ? 1 : 0;
plan.lift = Math.random() < 0.25;
}
return plan;
}
_scheduleStep(step, time) {
const delay = time - this.context.currentTime;
const bar = Math.floor(step / this._stepsPerBar) % this._progression.length;
const beat = step % this._stepsPerBar;
const barDur = this._stepDur * this._stepsPerBar;
const intensity = this._intensity;
const drumGain = 1 + intensity * 0.6;
if (beat === 0 || this._barPlan.bar !== bar) {
this._barPlan = this._rollBarPlan(bar);
}
const plan = this._barPlan;
const fill = plan.fill;
const breakdown = plan.breakdown;
const boss = this._bossMode;
let { root, chord } = boss
? this._bossProgression[bar]
: this._progression[bar];
if (plan.lift && bar === 2 && !boss) {
chord = [174.61, 207.65, 261.63];
}
if (boss && beat === 0) {
this.tone(27.5, 'sine', barDur, 0.06, 27.5, delay);
}
if (beat === 0) {
for (const note of chord) {
this.tone(note, 'sawtooth', barDur, 0.02 + intensity * 0.01, note, delay);
}
}
const kickFreq = boss ? 44 : 55;
const snareFreq = boss ? 700 : 1800;
if (!fill && !breakdown) {
if (beat % 4 === 2 && this._bassPattern[Math.floor(beat / 4)]) {
this.tone(root, 'square', this._stepDur * 1.5, 0.055, root, delay);
}
if (intensity > 0.4 && beat % 2 === 0) {
this.tone(root * 2, 'square', this._stepDur * 0.9, 0.02, root * 2, delay);
}
if (beat % 4 === 0) {
this.tone(kickFreq, 'sine', 0.14, 0.1 * drumGain, Math.max(28, kickFreq - 18), delay);
}
if (beat === 4 || beat === 12) {
this.noise(0.1, 0.07 * drumGain, snareFreq, delay);
}
if (beat % 4 === 2) {
this.noise(0.03, 0.02 * drumGain, 9000, delay);
}
if (beat % 4 === 3 || beat % 4 === 1) {
this.noise(0.02, 0.012 * drumGain, 10000, delay);
}
if (beat % 4 === 1 || beat % 4 === 3) {
const arp = chord[this._arpPattern[beat]];
this.tone(arp, 'triangle', 0.1, 0.018, arp, delay, this.delaySend);
this.tone(arp * 2, 'triangle', 0.12, 0.012, arp * 2, delay);
}
} else if (fill) {
if (beat % 2 === 0) {
this.tone(55, 'sine', 0.1, 0.06, 34, delay);
}
if (beat >= 12) {
this.noise(0.06, 0.045, 1500, delay);
}
if (beat % 2 === 1) {
this.noise(0.03, 0.018, 8500, delay);
}
if (beat % 3 === 0) {
this.noise(0.05, 0.012, 400 + beat * 160, delay);
}
const index = beat >= 12 ? (beat - 12) % 3 : Math.floor(beat / 2) % 3;
const note = chord[index] * (beat >= 12 ? 4 : 2);
this.tone(note, 'triangle', 0.09, 0.02, note, delay);
} else if (breakdown) {
if (beat === 0 || beat === 8) {
this.tone(55, 'sine', 0.18, 0.08, 32, delay);
}
}
if (!fill && !breakdown) {
const lead = (plan.leadVariant === 1 ? this._leadPatternB : this._leadPattern)[bar][beat];
if (lead > 0) {
this.tone(lead, 'sawtooth', 0.22, 0.03, lead, delay, this.delaySend);
}
}
}
setMuted(muted) {
this.muted = muted;
if (this.master && this.context) {
this.master.gain.setTargetAtTime(muted ? 0 : 0.5, this.context.currentTime, 0.01);
}
}
toggleMuted() {
this.setMuted(!this.muted);
return this.muted;
}
tone(freq, type, duration, gain, glideTo = freq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
if (!Number.isFinite(freq) || !Number.isFinite(glideTo)) return;
const { context } = this;
const start = context.currentTime + delay;
const oscillator = context.createOscillator();
const shape = context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(Math.max(1, freq), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), start + duration);
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.008);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(shape);
shape.connect(bus || this.master);
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
noise(duration, gain, filterFreq, delay = 0, bus = this.master) {
if (!this.context || this.muted) return;
const { context } = this;
const start = context.currentTime + delay;
const bufferSize = Math.max(1, Math.ceil(context.sampleRate * duration));
const buffer = context.createBuffer(1, bufferSize, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i += 1) {
data[i] = Math.random() * 2 - 1;
}
const source = context.createBufferSource();
source.buffer = buffer;
const filter = context.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1.2;
const shape = context.createGain();
shape.gain.setValueAtTime(0.0001, start);
shape.gain.exponentialRampToValueAtTime(gain, start + 0.005);
shape.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(filter);
filter.connect(shape);
shape.connect(bus || this.master);
source.start(start);
source.stop(start + duration + 0.02);
}
shoot() {
this.tone(240, 'square', 0.1, 0.075, 85);
this.noise(0.05, 0.028, 3200);
}
hit() {
this.noise(0.12, 0.16, 1500);
this.tone(180, 'triangle', 0.1, 0.12, 120);
}
death(big = false) {
if (big) {
this.noise(0.35, 0.22, 700);
this.tone(120, 'sawtooth', 0.3, 0.2, 45);
this.tone(90, 'sine', 0.4, 0.24, 30);
return;
}
this.noise(0.2, 0.14, 500);
this.tone(140, 'triangle', 0.22, 0.16, 55);
}
damage() {
this.tone(160, 'sawtooth', 0.2, 0.18, 80);
this.noise(0.15, 0.1, 900);
}
explosion() {
this.noise(0.7, 0.3, 400);
this.tone(200, 'sawtooth', 0.6, 0.22, 30);
this.tone(60, 'sine', 0.8, 0.3, 20);
}
pickup() {
this.tone(660, 'sine', 0.12, 0.14, 660);
this.tone(990, 'sine', 0.18, 0.14, 990, 0.09);
}
stingerStart() {
this.tone(392, 'triangle', 0.12, 0.1);
this.tone(523, 'triangle', 0.12, 0.1, undefined, 0.1);
this.tone(659, 'triangle', 0.3, 0.12, undefined, 0.2);
}
bossEntrance() {
this.tone(27.5, 'sine', 1.6, 0.22, 27.5);
this.tone(110, 'sawtooth', 0.5, 0.1, 55);
this.noise(0.5, 0.12, 250);
this.tone(82.41, 'square', 0.25, 0.08, 82.41, 0.7);
this.tone(61.74, 'square', 0.4, 0.08, 61.74, 0.95);
}
playGameOverMusic() {
this.stopMusic();
this._playGameOverSeq();
}
_playGameOverSeq() {
if (!this.context) return;
const now = this.context.currentTime;
const t = (offset) => Math.max(0, offset - now);
this.tone(55, 'sine', 8, 0.05, 51, t(0));
this.tone(55, 'sine', 8, 0.03, 55, t(4));
const chords = [
[110, 164.81, 220],
[98, 146.83, 196],
[87.31, 130.81, 174.61],
[82.41, 123.47, 164.81],
];
chords.forEach((chord, i) => {
const start = 0.2 + i * 1.5;
for (const note of chord) {
this.tone(note, 'sawtooth', 1.5, 0.018, note, t(start));
}
if (i === 0 || i === 2) {
this.tone(55, 'sine', 0.14, 0.09, 32, t(start));
}
});
const melody = [880, 783.99, 698.46, 659.26, 587.33, 523.25, 493.88, 440];
melody.forEach((note, i) => {
this.tone(note, 'triangle', 0.7, 0.032, note, t(0.5 + i * 0.55), this.delaySend);
});
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Texture } from './Texture.js';
export class TextRenderer {
constructor(gl) {
this.gl = gl;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this._cachedKey = null;
this._cachedEntry = null;
}
draw(renderer, text, x, y, options = {}) {
const entry = this._resolve(text, options);
renderer.drawTexture(entry.texture, x, y, entry.width, entry.height);
return entry;
}
measure(text, options = {}) {
return this._resolve(text, options);
}
_resolve(text, options = {}) {
const key = this._makeKey(text, options);
if (this._cachedKey !== key) {
this._flushCache();
this._cachedKey = key;
this._cachedEntry = this._rasterize(text, options);
}
return this._cachedEntry;
}
_makeKey(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) {
return `${text}|${size}|${font}|${fillStyle}|${bold}`;
}
_rasterize(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) {
const { ctx, canvas } = this;
const pad = 4;
const fontString = `${bold ? 'bold ' : ''}${size}px ${font}`;
ctx.font = fontString;
const textWidth = Math.ceil(ctx.measureText(text).width);
const width = textWidth + pad * 2;
const height = Math.ceil(size * 1.3) + pad * 2;
const scale = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.ceil(width * scale));
canvas.height = Math.max(1, Math.ceil(height * scale));
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.font = fontString;
ctx.fillStyle = fillStyle;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillText(text, pad, pad + size);
const texture = new Texture(this.gl, canvas.width, canvas.height);
texture.uploadCanvas(canvas);
return { texture, width, height };
}
_flushCache() {
if (this._cachedEntry) {
this._cachedEntry.texture.dispose();
this._cachedEntry = null;
}
}
}
+56
View File
@@ -0,0 +1,56 @@
export class Texture {
constructor(gl, width = 1, height = 1) {
this.gl = gl;
this.width = width;
this.height = height;
this.handle = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([255, 255, 255, 255]),
);
this._configureSampling();
gl.bindTexture(gl.TEXTURE_2D, null);
}
uploadImage(image) {
const { gl } = this;
this.width = image.width;
this.height = image.height;
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
}
uploadCanvas(canvas) {
const { gl } = this;
this.width = canvas.width;
this.height = canvas.height;
gl.bindTexture(gl.TEXTURE_2D, this.handle);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.bindTexture(gl.TEXTURE_2D, null);
}
_configureSampling() {
const { gl } = this;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
dispose() {
this.gl.deleteTexture(this.handle);
this.handle = null;
}
}
+67
View File
@@ -0,0 +1,67 @@
export class Vector2D {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
mulS(scalar) {
return new Vector2D(this.x * scalar, this.y * scalar);
}
mulV(vector) {
return new Vector2D(this.x * vector.x, this.y * vector.y);
}
divS(scalar) {
return new Vector2D(this.x / scalar, this.y / scalar);
}
divV(vector) {
return new Vector2D(this.x / vector.x, this.y / vector.y);
}
addS(scalar) {
return new Vector2D(this.x + scalar, this.y + scalar);
}
addV(vector) {
return new Vector2D(this.x + vector.x, this.y + vector.y);
}
subS(scalar) {
return new Vector2D(this.x - scalar, this.y - scalar);
}
subV(vector) {
return new Vector2D(this.x - vector.x, this.y - vector.y);
}
dot(vector) {
return this.x * vector.x + this.y * vector.y;
}
abs() {
return new Vector2D(Math.abs(this.x), Math.abs(this.y));
}
length() {
return Math.sqrt(this.dot(this));
}
lengthSqrt() {
return this.dot(this);
}
normalize() {
const length = this.length();
this.x /= length;
this.y /= length;
return this;
}
rotate(angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return new Vector2D(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
}
}