fix(security & perf): implement bearer session tokens, salted pbkdf2, object pooling and background caching
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
// Web Audio API Audio System with Voice Polyphony Limiting & SFX Throttling
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Musical note frequency lookup
|
||||
const N = {
|
||||
D2: 73.42, E2: 82.41, F2: 87.31, G2: 98.00, A2: 110.00, Bb2: 116.54, B2: 123.47,
|
||||
C3: 130.81, D3: 146.83, Eb3: 155.56, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, Bb3: 233.08, B3: 246.94,
|
||||
C4: 261.63, Csharp4: 277.18, D4: 293.66, Eb4: 311.13, E4: 329.63, F4: 349.23, Fsharp4: 369.99, G4: 392.00, Gsharp4: 415.30, A4: 440.00, Bb4: 466.16, B4: 493.88,
|
||||
C5: 523.25, Csharp5: 554.37, D5: 587.33, Eb5: 622.25, E5: 659.25, F5: 698.46, Fsharp5: 739.99, G5: 783.99, A5: 880.00, Bb5: 932.33, B5: 987.77,
|
||||
C6: 1046.50
|
||||
};
|
||||
|
||||
const THEMES = {
|
||||
day: {
|
||||
tempo: 230,
|
||||
bassType: 'triangle',
|
||||
leadType: 'sine',
|
||||
bassVol: 0.10,
|
||||
leadVol: 0.08,
|
||||
bass: [
|
||||
N.A2, null, N.E3, null, N.A2, null, N.E3, null,
|
||||
N.D3, null, N.A3, null, N.E3, null, N.G3, null
|
||||
],
|
||||
lead: [
|
||||
N.A4, null, N.C5, N.E5, null, N.D5, N.C5, N.B4,
|
||||
N.A4, null, N.C5, N.A4, null, N.B4, N.C5, N.B4
|
||||
]
|
||||
},
|
||||
night: {
|
||||
tempo: 280,
|
||||
bassType: 'sine',
|
||||
leadType: 'triangle',
|
||||
bassVol: 0.12,
|
||||
leadVol: 0.07,
|
||||
bass: [
|
||||
N.C3, null, N.G3, null, N.A2, null, N.E3, null,
|
||||
N.F3, null, N.C4, null, N.G3, null, N.B3, null
|
||||
],
|
||||
lead: [
|
||||
N.C5, null, N.Eb5, N.G5, null, N.F5, null, N.D5,
|
||||
N.Eb5, null, N.C5, null, N.G4, null, N.Bb4, null
|
||||
]
|
||||
},
|
||||
pool: {
|
||||
tempo: 210,
|
||||
bassType: 'triangle',
|
||||
leadType: 'sine',
|
||||
bassVol: 0.11,
|
||||
leadVol: 0.09,
|
||||
bass: [
|
||||
N.G2, null, N.D3, N.G3, N.C3, null, N.G3, N.C4,
|
||||
N.D3, null, N.A3, N.D4, N.G3, null, N.B3, null
|
||||
],
|
||||
lead: [
|
||||
N.B4, N.D5, null, N.G5, N.Fsharp5, N.D5, null, N.B4,
|
||||
N.C5, N.E5, null, N.A5, N.G5, N.E5, N.D5, null
|
||||
]
|
||||
},
|
||||
fog: {
|
||||
tempo: 310,
|
||||
bassType: 'sawtooth',
|
||||
leadType: 'triangle',
|
||||
bassVol: 0.07,
|
||||
leadVol: 0.07,
|
||||
bass: [
|
||||
N.D2, null, N.A2, null, N.D2, null, N.Bb2, null,
|
||||
N.A2, null, N.F2, null, N.G2, null, N.A2, null
|
||||
],
|
||||
lead: [
|
||||
N.D5, null, null, N.F5, null, N.E5, null, N.Csharp5,
|
||||
N.D5, null, N.A4, null, null, N.F5, N.E5, null
|
||||
]
|
||||
},
|
||||
roof: {
|
||||
tempo: 190,
|
||||
bassType: 'triangle',
|
||||
leadType: 'sawtooth',
|
||||
bassVol: 0.11,
|
||||
leadVol: 0.06,
|
||||
bass: [
|
||||
N.F3, N.F3, N.C4, N.F3, N.Bb3, N.Bb3, N.F4, N.Bb3,
|
||||
N.C3, N.C3, N.G3, N.C4, N.F3, null, N.C4, null
|
||||
],
|
||||
lead: [
|
||||
N.F4, N.A4, N.C5, null, N.D5, N.F5, null, N.C5,
|
||||
N.Bb4, N.D5, N.F5, null, N.A5, N.G5, N.F5, null
|
||||
]
|
||||
},
|
||||
boss: {
|
||||
tempo: 165,
|
||||
bassType: 'sawtooth',
|
||||
leadType: 'square',
|
||||
bassVol: 0.09,
|
||||
leadVol: 0.06,
|
||||
bass: [
|
||||
N.E2, N.E2, N.G2, N.E2, N.Bb2, N.A2, N.G2, N.E2,
|
||||
N.E2, N.E2, N.B2, N.Bb2, N.A2, N.G2, N.E2, N.D3
|
||||
],
|
||||
lead: [
|
||||
N.E5, N.E5, N.G5, null, N.Bb5, N.A5, N.G5, N.E5,
|
||||
null, N.B5, N.Bb5, N.A5, null, N.G5, N.F5, N.E5
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
class AudioSystem {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.muted = false;
|
||||
|
||||
// Music State
|
||||
this.musicInterval = null;
|
||||
this.musicPlaying = false;
|
||||
this.musicStep = 0;
|
||||
this.currentThemeName = 'day';
|
||||
|
||||
// Polyphony & Voice Limiting State
|
||||
this.maxGlobalVoices = 24;
|
||||
this.activeVoiceCount = 0;
|
||||
this.activeVoicesPerKey = new Map();
|
||||
this.lastPlayTime = new Map();
|
||||
|
||||
// Per-sound configuration (polyphony cap & throttle cooldown ms)
|
||||
this.sfxLimits = {
|
||||
shoot: { maxVoices: 4, minIntervalMs: 40 },
|
||||
hit: { maxVoices: 4, minIntervalMs: 35 },
|
||||
splat: { maxVoices: 4, minIntervalMs: 35 },
|
||||
chomp: { maxVoices: 3, minIntervalMs: 65 },
|
||||
coin: { maxVoices: 4, minIntervalMs: 35 },
|
||||
sun: { maxVoices: 3, minIntervalMs: 50 },
|
||||
plant: { maxVoices: 3, minIntervalMs: 40 },
|
||||
explosion: { maxVoices: 3, minIntervalMs: 80 },
|
||||
sizzle: { maxVoices: 2, minIntervalMs: 100 },
|
||||
mower: { maxVoices: 2, minIntervalMs: 150 },
|
||||
strike: { maxVoices: 3, minIntervalMs: 50 },
|
||||
btn: { maxVoices: 2, minIntervalMs: 30 }
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.muted = !this.muted;
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
// Check voice limits and throttling
|
||||
canPlayVoice(key) {
|
||||
if (this.muted) return false;
|
||||
this.init();
|
||||
if (!this.ctx) return false;
|
||||
|
||||
const now = performance.now();
|
||||
const cfg = this.sfxLimits[key] || { maxVoices: 4, minIntervalMs: 40 };
|
||||
|
||||
// 1. Throttle check
|
||||
const last = this.lastPlayTime.get(key) || 0;
|
||||
if (now - last < cfg.minIntervalMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Global polyphony limit
|
||||
if (this.activeVoiceCount >= this.maxGlobalVoices) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Per-sound polyphony limit
|
||||
const currentActive = this.activeVoicesPerKey.get(key) || 0;
|
||||
if (currentActive >= cfg.maxVoices) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Register timestamp
|
||||
this.lastPlayTime.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerVoice(key, durationSec) {
|
||||
this.activeVoiceCount++;
|
||||
const current = this.activeVoicesPerKey.get(key) || 0;
|
||||
this.activeVoicesPerKey.set(key, current + 1);
|
||||
|
||||
const release = () => {
|
||||
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
||||
const cur = this.activeVoicesPerKey.get(key) || 1;
|
||||
this.activeVoicesPerKey.set(key, Math.max(0, cur - 1));
|
||||
};
|
||||
|
||||
setTimeout(release, Math.ceil(durationSec * 1000) + 20);
|
||||
}
|
||||
|
||||
playTone(freq, type, duration, startVol = 0.2, endVol = 0.001) {
|
||||
if (this.muted) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
try {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
|
||||
gain.gain.setValueAtTime(startVol, this.ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(endVol, this.ctx.currentTime + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(this.ctx.currentTime + duration);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playShoot() {
|
||||
if (!this.canPlayVoice('shoot')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.11;
|
||||
this.registerVoice('shoot', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(620, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(120, ctx.currentTime + dur);
|
||||
gain.gain.setValueAtTime(0.24, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playHit() {
|
||||
if (!this.canPlayVoice('hit')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.08;
|
||||
this.registerVoice('hit', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(180, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + dur);
|
||||
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playSplat() {
|
||||
if (!this.canPlayVoice('splat')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.09;
|
||||
this.registerVoice('splat', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(280, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + dur);
|
||||
gain.gain.setValueAtTime(0.22, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playSun() {
|
||||
if (!this.canPlayVoice('sun')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
this.registerVoice('sun', 0.35);
|
||||
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, idx) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.05);
|
||||
gain.gain.setValueAtTime(0.18, ctx.currentTime + idx * 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + idx * 0.05 + 0.2);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(ctx.currentTime + idx * 0.05);
|
||||
osc.stop(ctx.currentTime + idx * 0.05 + 0.2);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playPlant() {
|
||||
if (!this.canPlayVoice('plant')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.15;
|
||||
this.registerVoice('plant', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(220, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1);
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playChomp() {
|
||||
if (!this.canPlayVoice('chomp')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.1;
|
||||
this.registerVoice('chomp', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(140, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + dur);
|
||||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playExplosion() {
|
||||
if (!this.canPlayVoice('explosion')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.5;
|
||||
this.registerVoice('explosion', dur);
|
||||
|
||||
const bufferSize = Math.floor(ctx.sampleRate * dur);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
const noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(800, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + dur);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.5, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
noise.start();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playMower() {
|
||||
if (!this.canPlayVoice('mower')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 1.2;
|
||||
this.registerVoice('mower', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(90, ctx.currentTime);
|
||||
osc.frequency.linearRampToValueAtTime(240, ctx.currentTime + dur);
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playCoin() {
|
||||
if (!this.canPlayVoice('coin')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.3;
|
||||
this.registerVoice('coin', dur);
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(987.77, ctx.currentTime); // B5
|
||||
osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.06); // E6
|
||||
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + dur);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playSizzle() {
|
||||
if (!this.canPlayVoice('sizzle')) return;
|
||||
try {
|
||||
const ctx = this.ctx;
|
||||
const dur = 0.8;
|
||||
this.registerVoice('sizzle', dur);
|
||||
|
||||
const bufferSize = Math.floor(ctx.sampleRate * dur);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
const noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(1400, ctx.currentTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(300, ctx.currentTime + dur);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + dur);
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
noise.start();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playMine() {
|
||||
this.playExplosion();
|
||||
this.playTone(550, 'sine', 0.15, 0.3, 0.01);
|
||||
}
|
||||
|
||||
playVault() {
|
||||
if (this.muted) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
try {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(180, this.ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(620, this.ctx.currentTime + 0.3);
|
||||
gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(this.ctx.currentTime + 0.3);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playGulp() {
|
||||
this.playTone(180, 'sine', 0.18, 0.35, 0.01);
|
||||
setTimeout(() => this.playTone(120, 'sine', 0.22, 0.3, 0.01), 90);
|
||||
}
|
||||
|
||||
playStrike() {
|
||||
if (!this.canPlayVoice('strike')) return;
|
||||
this.registerVoice('strike', 0.2);
|
||||
this.playTone(240, 'square', 0.1, 0.3, 0.01);
|
||||
this.playTone(120, 'sawtooth', 0.2, 0.35, 0.01);
|
||||
}
|
||||
|
||||
playHugeWave() {
|
||||
this.playTone(180, 'sawtooth', 0.6, 0.35, 0.01);
|
||||
}
|
||||
|
||||
playButtonClick() {
|
||||
if (!this.canPlayVoice('btn')) return;
|
||||
this.registerVoice('btn', 0.06);
|
||||
this.playTone(480, 'sine', 0.05, 0.18, 0.01);
|
||||
}
|
||||
|
||||
playWin() {
|
||||
if (this.muted) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
const fanfare = [392, 523, 659, 784, 1046];
|
||||
fanfare.forEach((f, i) => {
|
||||
setTimeout(() => this.playTone(f, 'triangle', 0.35, 0.3, 0.01), i * 160);
|
||||
});
|
||||
}
|
||||
|
||||
playLose() {
|
||||
if (this.muted) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
const doom = [400, 360, 320, 280, 220];
|
||||
doom.forEach((f, i) => {
|
||||
setTimeout(() => this.playTone(f, 'sawtooth', 0.4, 0.3, 0.01), i * 200);
|
||||
});
|
||||
}
|
||||
|
||||
// Background Music (Multi-Biome Procedural Chiptune Synthesizer)
|
||||
startMusic(themeName = 'day') {
|
||||
const targetTheme = THEMES[themeName] ? themeName : 'day';
|
||||
|
||||
if (this.musicPlaying && this.currentThemeName === targetTheme && this.musicInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentThemeName = targetTheme;
|
||||
this.musicPlaying = true;
|
||||
this.init();
|
||||
|
||||
if (this.musicInterval) {
|
||||
clearInterval(this.musicInterval);
|
||||
this.musicInterval = null;
|
||||
}
|
||||
|
||||
this.musicStep = 0;
|
||||
const theme = THEMES[this.currentThemeName];
|
||||
|
||||
this.musicInterval = setInterval(() => {
|
||||
if (!this.musicPlaying || this.muted) return;
|
||||
const b = theme.bass[this.musicStep % theme.bass.length];
|
||||
const l = theme.lead[this.musicStep % theme.lead.length];
|
||||
if (b) {
|
||||
this.playTone(b, theme.bassType, (theme.tempo / 1000) * 1.05, theme.bassVol, 0.001);
|
||||
}
|
||||
if (l) {
|
||||
this.playTone(l, theme.leadType, (theme.tempo / 1000) * 0.95, theme.leadVol, 0.001);
|
||||
}
|
||||
this.musicStep++;
|
||||
}, theme.tempo);
|
||||
}
|
||||
|
||||
setTheme(themeName) {
|
||||
if (this.musicPlaying) {
|
||||
this.startMusic(themeName);
|
||||
} else {
|
||||
this.currentThemeName = THEMES[themeName] ? themeName : 'day';
|
||||
}
|
||||
}
|
||||
|
||||
getTheme() {
|
||||
return this.currentThemeName;
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
this.musicPlaying = false;
|
||||
if (this.musicInterval) {
|
||||
clearInterval(this.musicInterval);
|
||||
this.musicInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMusic() {
|
||||
if (this.musicPlaying) {
|
||||
this.stopMusic();
|
||||
return false;
|
||||
} else {
|
||||
this.startMusic(this.currentThemeName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
isMusicPlaying() {
|
||||
return this.musicPlaying;
|
||||
}
|
||||
}
|
||||
|
||||
// Instantiate singleton instance
|
||||
const audioInstance = new AudioSystem();
|
||||
window.AudioSystem = AudioSystem;
|
||||
window.PvZAudio = audioInstance;
|
||||
})();
|
||||
+56
-6
@@ -2,9 +2,26 @@
|
||||
// Falls back to localStorage if server is unavailable.
|
||||
window.PvZAuth = (function () {
|
||||
const CURRENT_USER_KEY = 'pvz_current_user';
|
||||
const TOKEN_KEY = 'pvz_token';
|
||||
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
|
||||
const API_BASE = '/api/auth';
|
||||
|
||||
/* ─── Session Token Helpers ─── */
|
||||
function getToken() {
|
||||
try { return localStorage.getItem(TOKEN_KEY) || null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
try { localStorage.removeItem(TOKEN_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
/* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */
|
||||
function hashPassword(str) {
|
||||
if (!str) return '';
|
||||
@@ -83,14 +100,33 @@ window.PvZAuth = (function () {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_DATA_KEY) || 'null'); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
/* ─── Non-blocking server call ─── */
|
||||
async function apiPost(endpoint, body) {
|
||||
/* ─── Non-blocking server call with Bearer Token Authorization ─── */
|
||||
async function apiPost(endpoint, body = {}) {
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Attach token in JSON body as well for maximum compatibility
|
||||
const payload = (typeof body === 'object' && body !== null && token)
|
||||
? { token, ...body }
|
||||
: body;
|
||||
|
||||
const resp = await fetch(`${API_BASE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
// Clear invalid or expired session token
|
||||
clearToken();
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
return { success: false, status: 401, message: json.message || 'Сессия истекла или недействительна' };
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
} catch (e) {
|
||||
@@ -146,6 +182,9 @@ window.PvZAuth = (function () {
|
||||
return !u || u === 'Гость';
|
||||
},
|
||||
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
getDefaultData,
|
||||
validateUsername,
|
||||
validatePassword,
|
||||
@@ -175,6 +214,9 @@ window.PvZAuth = (function () {
|
||||
|
||||
if (res && res.success && res.data) {
|
||||
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
|
||||
if (res.token) {
|
||||
setToken(res.token);
|
||||
}
|
||||
_cachedData = mergeUserData(res.data);
|
||||
localSave(_cachedData);
|
||||
|
||||
@@ -182,7 +224,7 @@ window.PvZAuth = (function () {
|
||||
window._pvzEngine.loadUserData();
|
||||
}
|
||||
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||
return { success: true, username: cleanUser, data: _cachedData };
|
||||
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
||||
} else {
|
||||
const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль';
|
||||
if (typeof callback === 'function') callback(false, null, errorMsg);
|
||||
@@ -214,6 +256,9 @@ window.PvZAuth = (function () {
|
||||
|
||||
if (res && res.success) {
|
||||
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
|
||||
if (res.token) {
|
||||
setToken(res.token);
|
||||
}
|
||||
_cachedData = mergeUserData(res.data || initData);
|
||||
localSave(_cachedData);
|
||||
|
||||
@@ -221,7 +266,7 @@ window.PvZAuth = (function () {
|
||||
window._pvzEngine.loadUserData();
|
||||
}
|
||||
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||
return { success: true, username: cleanUser, data: _cachedData };
|
||||
return { success: true, username: cleanUser, token: res.token, data: _cachedData };
|
||||
} else {
|
||||
const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации';
|
||||
if (typeof callback === 'function') callback(false, null, errorMsg);
|
||||
@@ -230,6 +275,7 @@ window.PvZAuth = (function () {
|
||||
},
|
||||
|
||||
loginAsGuest() {
|
||||
clearToken();
|
||||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
||||
_cachedData = null;
|
||||
localSave(null);
|
||||
@@ -239,9 +285,13 @@ window.PvZAuth = (function () {
|
||||
logout() {
|
||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||
const user = this.getCurrentUser();
|
||||
const token = getToken();
|
||||
if (user !== 'Гость' && _cachedData) {
|
||||
apiPost('save', { username: user, data: _cachedData });
|
||||
}
|
||||
if (token) {
|
||||
apiPost('logout', { token });
|
||||
}
|
||||
this.loginAsGuest();
|
||||
},
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ window.BossSystem = class BossSystem {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.isActive = false;
|
||||
this.isActive = true;
|
||||
this.health = this.maxHealth;
|
||||
this.stompTimer = 8.0;
|
||||
this.ballTimer = 14.0;
|
||||
|
||||
+86
-29
@@ -1,10 +1,63 @@
|
||||
// Particle Object Pool to eliminate GC churn during large waves
|
||||
window.ParticlePool = class ParticlePool {
|
||||
constructor(initialCapacity = 150) {
|
||||
this.pool = [];
|
||||
for (let i = 0; i < initialCapacity; i++) {
|
||||
this.pool.push({
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
color: '#ffffff',
|
||||
size: 3,
|
||||
life: 0,
|
||||
maxLife: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(x, y, vx, vy, color = '#8bc34a', size = 3, life = 0.35) {
|
||||
const p = this.pool.length > 0 ? this.pool.pop() : { x: 0, y: 0, vx: 0, vy: 0, color: '#ffffff', size: 3, life: 0, maxLife: 0 };
|
||||
p.x = x;
|
||||
p.y = y;
|
||||
p.vx = vx;
|
||||
p.vy = vy;
|
||||
p.color = color;
|
||||
p.size = size;
|
||||
p.life = life;
|
||||
p.maxLife = life;
|
||||
return p;
|
||||
}
|
||||
|
||||
release(p) {
|
||||
if (!p) return;
|
||||
p.life = 0;
|
||||
this.pool.push(p);
|
||||
}
|
||||
|
||||
releaseAll(particles) {
|
||||
if (!Array.isArray(particles)) return;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
this.release(particles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.pool = [];
|
||||
}
|
||||
};
|
||||
|
||||
// Combat & Collision Resolution System (with Bowling Ricochet & Coin Drops)
|
||||
window.CombatSystem = class CombatSystem {
|
||||
constructor() {
|
||||
this.particlePool = new window.ParticlePool(150);
|
||||
this.particles = [];
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.particlePool) {
|
||||
this.particlePool.releaseAll(this.particles);
|
||||
}
|
||||
this.particles = [];
|
||||
}
|
||||
|
||||
@@ -12,45 +65,50 @@ window.CombatSystem = class CombatSystem {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 40 + Math.random() * 80;
|
||||
this.particles.push({
|
||||
this.particles.push(this.particlePool.get(
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
Math.cos(angle) * speed,
|
||||
Math.sin(angle) * speed,
|
||||
color,
|
||||
size: 3 + Math.random() * 3,
|
||||
life: 0.35,
|
||||
maxLife: 0.35
|
||||
});
|
||||
3 + Math.random() * 3,
|
||||
0.35
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
createExplosionParticles(x, y) {
|
||||
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242'];
|
||||
for (let i = 0; i < 35; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 50 + Math.random() * 200;
|
||||
const colors = ['#ff5722', '#ff9800', '#ffeb3b', '#424242'];
|
||||
this.particles.push({
|
||||
this.particles.push(this.particlePool.get(
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: 5 + Math.random() * 8,
|
||||
life: 0.65,
|
||||
maxLife: 0.65
|
||||
});
|
||||
Math.cos(angle) * speed,
|
||||
Math.sin(angle) * speed,
|
||||
colors[Math.floor(Math.random() * colors.length)],
|
||||
5 + Math.random() * 8,
|
||||
0.65
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, gameState) {
|
||||
// 1. Update Particles
|
||||
for (const p of this.particles) {
|
||||
// 1. Update Particles in-place (eliminating GC allocations)
|
||||
let writeIdx = 0;
|
||||
for (let i = 0; i < this.particles.length; i++) {
|
||||
const p = this.particles[i];
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.life -= dt;
|
||||
if (p.life > 0) {
|
||||
this.particles[writeIdx++] = p;
|
||||
} else {
|
||||
this.particlePool.release(p);
|
||||
}
|
||||
}
|
||||
this.particles = this.particles.filter(p => p.life > 0);
|
||||
this.particles.length = writeIdx;
|
||||
|
||||
// 2. Projectiles vs Zombies
|
||||
for (const proj of gameState.projectiles) {
|
||||
@@ -233,16 +291,15 @@ window.CombatSystem = class CombatSystem {
|
||||
// Tree of Wisdom Cheat: Daisies
|
||||
if (gameState.tree && gameState.tree.cheats && gameState.tree.cheats.daisies) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.particles.push({
|
||||
x: zombie.x + (Math.random() - 0.5) * 30,
|
||||
y: zombie.y + 10 + (Math.random() - 0.5) * 16,
|
||||
vx: 0,
|
||||
vy: -8,
|
||||
color: '#ffffff',
|
||||
size: 4,
|
||||
life: 2.5,
|
||||
maxLife: 2.5
|
||||
});
|
||||
this.particles.push(this.particlePool.get(
|
||||
zombie.x + (Math.random() - 0.5) * 30,
|
||||
zombie.y + 10 + (Math.random() - 0.5) * 16,
|
||||
0,
|
||||
-8,
|
||||
'#ffffff',
|
||||
4,
|
||||
2.5
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+74
-16
@@ -9,13 +9,32 @@ window.GridSystem = class GridSystem {
|
||||
|
||||
this.graves = [];
|
||||
this.animTime = 0;
|
||||
this.bgCanvas = null;
|
||||
this.bgCtx = null;
|
||||
this.bgDirty = true;
|
||||
this.initBiome('day');
|
||||
}
|
||||
|
||||
initBackgroundCache() {
|
||||
if (!this.bgCanvas) {
|
||||
if (typeof document !== 'undefined') {
|
||||
this.bgCanvas = document.createElement('canvas');
|
||||
this.bgCanvas.width = 900;
|
||||
this.bgCanvas.height = 600;
|
||||
this.bgCtx = this.bgCanvas.getContext('2d');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateBackground() {
|
||||
this.bgDirty = true;
|
||||
}
|
||||
|
||||
initBiome(biome = 'day') {
|
||||
this.biome = biome;
|
||||
this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5;
|
||||
this.cellH = (biome === 'pool' || biome === 'fog') ? 80 : 96;
|
||||
this.invalidateBackground();
|
||||
|
||||
this.grid = [];
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
@@ -148,8 +167,25 @@ window.GridSystem = class GridSystem {
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx, hoverCell, selectedPlantType) {
|
||||
// 1. Draw Lawn Tiles or Pool
|
||||
get theme() {
|
||||
return this._theme || 'classic';
|
||||
}
|
||||
|
||||
set theme(val) {
|
||||
if (this._theme !== val) {
|
||||
this._theme = val;
|
||||
this.invalidateBackground();
|
||||
}
|
||||
}
|
||||
|
||||
renderBackgroundCache() {
|
||||
this.initBackgroundCache();
|
||||
if (!this.bgCtx) return;
|
||||
|
||||
const ctx = this.bgCtx;
|
||||
ctx.clearRect(0, 0, 900, 600);
|
||||
|
||||
// 1. Draw Static Lawn Tiles or Pool Base
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
const isWater = this.isWaterCell(r);
|
||||
|
||||
@@ -180,18 +216,9 @@ window.GridSystem = class GridSystem {
|
||||
ctx.lineTo(x + 60, y + this.cellH);
|
||||
ctx.stroke();
|
||||
} else if (isWater) {
|
||||
// Pool Water
|
||||
// Pool Water Base
|
||||
ctx.fillStyle = isEven ? '#0288d1' : '#039be5';
|
||||
ctx.fillRect(x, y, this.cellW, this.cellH);
|
||||
|
||||
// Subtle animated water ripples
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3;
|
||||
ctx.moveTo(x + 10, y + 20 + waveOff);
|
||||
ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff);
|
||||
ctx.stroke();
|
||||
} else if (this.theme === 'cyberpunk') {
|
||||
// Cyberpunk Grid Tiles
|
||||
ctx.fillStyle = isEven ? '#120326' : '#1d063d';
|
||||
@@ -249,13 +276,44 @@ window.GridSystem = class GridSystem {
|
||||
// 2b. Decorative Neighbour Fence along top edge (y: 86..108)
|
||||
this.drawNeighbourFence(ctx);
|
||||
|
||||
// 4. Draw Gravestones in Night Mode
|
||||
this.bgDirty = false;
|
||||
}
|
||||
|
||||
draw(ctx, hoverCell, selectedPlantType) {
|
||||
// 1. Offscreen Canvas Cached Static Background (Lawn, Porch/Chimney, Fence, Water Base)
|
||||
if (this.bgDirty || !this.bgCanvas) {
|
||||
this.renderBackgroundCache();
|
||||
}
|
||||
if (this.bgCanvas) {
|
||||
ctx.drawImage(this.bgCanvas, 0, 0);
|
||||
}
|
||||
|
||||
// 1b. Dynamic Animated Water Ripples (Pool & Fog)
|
||||
if (this.biome === 'pool' || this.biome === 'fog') {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
||||
ctx.lineWidth = 1.5;
|
||||
for (let r = 2; r <= 3; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const x = this.startX + c * this.cellW;
|
||||
const y = this.startY + r * this.cellH;
|
||||
const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, y + 20 + waveOff);
|
||||
ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 2. Draw Gravestones in Night Mode
|
||||
for (const grave of this.graves) {
|
||||
const bounds = this.getCellBounds(grave.col, grave.row);
|
||||
this.drawGravestone(ctx, bounds.centerX, bounds.centerY);
|
||||
}
|
||||
|
||||
// 5. Hover effect
|
||||
// 3. Hover effect
|
||||
if (hoverCell && selectedPlantType) {
|
||||
const bounds = this.getCellBounds(hoverCell.col, hoverCell.row);
|
||||
const isWater = this.isWaterCell(hoverCell.row);
|
||||
@@ -288,7 +346,7 @@ window.GridSystem = class GridSystem {
|
||||
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
}
|
||||
|
||||
// 6. Draw Lawnmowers & Pool Cleaners
|
||||
// 4. Draw Lawnmowers & Pool Cleaners
|
||||
for (const mower of this.lawnmowers) {
|
||||
if (mower.used) continue;
|
||||
if (mower.isCleaner) {
|
||||
@@ -298,7 +356,7 @@ window.GridSystem = class GridSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Volumetric Fog Layer
|
||||
// 5. Volumetric Fog Layer
|
||||
if (this.biome === 'fog') {
|
||||
this.drawFogOverlay(ctx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user