feat(integrations): add retro doom web engine and agy proxy nginx config
This commit is contained in:
Executable
+728
@@ -0,0 +1,728 @@
|
||||
/**
|
||||
* DOOM 3D - Retro Sound Synthesizer & Music Sequencer (Web Audio API)
|
||||
* Generates authentic 90s DOOM-inspired sound effects and the iconic E1M1 metal riff.
|
||||
*/
|
||||
|
||||
class SoundSystem {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.masterGain = null;
|
||||
this.sfxGain = null;
|
||||
this.musicGain = null;
|
||||
this.initialized = false;
|
||||
|
||||
this.sfxVolume = 0.8;
|
||||
this.musicVolume = 0.5;
|
||||
this.masterVolume = 1.0;
|
||||
|
||||
this.musicPlaying = false;
|
||||
this.musicStep = 0;
|
||||
this.musicTimer = null;
|
||||
this.musicBpm = 135;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
this.ctx = new AudioContext();
|
||||
|
||||
this.masterGain = this.ctx.createGain();
|
||||
this.masterGain.gain.value = this.masterVolume;
|
||||
this.masterGain.connect(this.ctx.destination);
|
||||
|
||||
this.sfxGain = this.ctx.createGain();
|
||||
this.sfxGain.gain.value = this.sfxVolume;
|
||||
this.sfxGain.connect(this.masterGain);
|
||||
|
||||
this.musicGain = this.ctx.createGain();
|
||||
this.musicGain.gain.value = this.musicVolume;
|
||||
this.musicGain.connect(this.masterGain);
|
||||
|
||||
this.initialized = true;
|
||||
this.startMusic();
|
||||
} catch (e) {
|
||||
console.warn('Web Audio API not supported or blocked:', e);
|
||||
}
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
if (!this.initialized) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
setMasterVolume(val) {
|
||||
this.masterVolume = Math.max(0, Math.min(1, val));
|
||||
if (this.masterGain) this.masterGain.gain.setValueAtTime(this.masterVolume, this.ctx.currentTime);
|
||||
}
|
||||
|
||||
setSfxVolume(val) {
|
||||
this.sfxVolume = Math.max(0, Math.min(1, val));
|
||||
if (this.sfxGain) this.sfxGain.gain.setValueAtTime(this.sfxVolume, this.ctx.currentTime);
|
||||
}
|
||||
|
||||
setMusicVolume(val) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, val));
|
||||
if (this.musicGain) this.musicGain.gain.setValueAtTime(this.musicVolume, this.ctx.currentTime);
|
||||
}
|
||||
|
||||
// Helper: Create White Noise buffer
|
||||
createNoiseBuffer(duration = 1) {
|
||||
if (!this.ctx) return null;
|
||||
const bufferSize = this.ctx.sampleRate * duration;
|
||||
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Distortion curve for heavy metal tone
|
||||
makeDistortionCurve(amount = 50) {
|
||||
const k = typeof amount === 'number' ? amount : 50;
|
||||
const n_samples = 44100;
|
||||
const curve = new Float32Array(n_samples);
|
||||
const deg = Math.PI / 180;
|
||||
for (let i = 0; i < n_samples; ++i) {
|
||||
const x = (i * 2) / n_samples - 1;
|
||||
curve[i] = ((3 + k) * x * 20 * deg) / (Math.PI + k * Math.abs(x));
|
||||
}
|
||||
return curve;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// WEAPON SOUND EFFECTS
|
||||
// ----------------------------------------------------
|
||||
|
||||
playFist() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
// Swoosh
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(120, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(40, t + 0.15);
|
||||
gain.gain.setValueAtTime(0.6, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.15);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.16);
|
||||
|
||||
// Impact punch
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.12);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(800, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(100, t + 0.12);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(0.8, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.12);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playPistol() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
// Punch osc
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(320, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(45, t + 0.18);
|
||||
oGain.gain.setValueAtTime(0.7, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.18);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.19);
|
||||
|
||||
// Noise crack
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.2);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(1800, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(300, t + 0.2);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(0.9, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.2);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playShotgun() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
// Low boom
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(200, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(30, t + 0.35);
|
||||
oGain.gain.setValueAtTime(1.0, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.35);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.36);
|
||||
|
||||
// Heavy blast noise
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.35);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(2800, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(400, t + 0.35);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(1.2, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.35);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
|
||||
// Pump action slide (clack-clack) after 0.35s
|
||||
setTimeout(() => {
|
||||
if (!this.ctx) return;
|
||||
const t2 = this.ctx.currentTime;
|
||||
const p1 = this.ctx.createOscillator();
|
||||
const pGain = this.ctx.createGain();
|
||||
p1.type = 'square';
|
||||
p1.frequency.setValueAtTime(400, t2);
|
||||
p1.frequency.setValueAtTime(650, t2 + 0.06);
|
||||
pGain.gain.setValueAtTime(0.3, t2);
|
||||
pGain.gain.exponentialRampToValueAtTime(0.01, t2 + 0.15);
|
||||
p1.connect(pGain);
|
||||
pGain.connect(this.sfxGain);
|
||||
p1.start(t2);
|
||||
p1.stop(t2 + 0.16);
|
||||
}, 320);
|
||||
}
|
||||
|
||||
playChaingun() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(260 + Math.random() * 40, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(50, t + 0.12);
|
||||
oGain.gain.setValueAtTime(0.65, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.12);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.13);
|
||||
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.12);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(2200, t);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(0.7, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.12);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playRocketLaunch() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
// Swoosh tone
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(100, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(450, t + 0.25);
|
||||
osc.frequency.exponentialRampToValueAtTime(120, t + 0.5);
|
||||
oGain.gain.setValueAtTime(0.7, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.5);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.51);
|
||||
|
||||
// Rocket booster hiss
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.4);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(800, t);
|
||||
filter.frequency.linearRampToValueAtTime(1600, t + 0.4);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(0.8, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.4);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playExplosion(volumeMultiplier = 1.0) {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
// Sub bass boom
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(160, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(25, t + 0.7);
|
||||
oGain.gain.setValueAtTime(1.2 * volumeMultiplier, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.7);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.71);
|
||||
|
||||
// Crumbling explosion noise
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.8);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1400, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(120, t + 0.8);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(1.4 * volumeMultiplier, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.8);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playPlasma() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(950, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(180, t + 0.16);
|
||||
oGain.gain.setValueAtTime(0.7, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.16);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.17);
|
||||
|
||||
const osc2 = this.ctx.createOscillator();
|
||||
const oGain2 = this.ctx.createGain();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.setValueAtTime(1400, t);
|
||||
osc2.frequency.exponentialRampToValueAtTime(300, t + 0.14);
|
||||
oGain2.gain.setValueAtTime(0.5, t);
|
||||
oGain2.gain.exponentialRampToValueAtTime(0.01, t + 0.14);
|
||||
osc2.connect(oGain2);
|
||||
oGain2.connect(this.sfxGain);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.15);
|
||||
}
|
||||
|
||||
playBFGCharge() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(100, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(1200, t + 0.8);
|
||||
gain.gain.setValueAtTime(0.1, t);
|
||||
gain.gain.linearRampToValueAtTime(0.9, t + 0.8);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.82);
|
||||
}
|
||||
|
||||
playBFGFire() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
|
||||
// Big apocalyptic energy discharge
|
||||
const osc = this.ctx.createOscillator();
|
||||
const oGain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(800, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(60, t + 0.9);
|
||||
oGain.gain.setValueAtTime(1.3, t);
|
||||
oGain.gain.exponentialRampToValueAtTime(0.01, t + 0.9);
|
||||
osc.connect(oGain);
|
||||
oGain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.92);
|
||||
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(1.0);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(3000, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, t + 1.0);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(1.5, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 1.0);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MONSTER & WORLD SOUND EFFECTS
|
||||
// ----------------------------------------------------
|
||||
|
||||
playEnemyAlert(type = 'zombie') {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
if (type === 'pinky' || type === 'caco') {
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(140, t);
|
||||
osc.frequency.linearRampToValueAtTime(220, t + 0.15);
|
||||
osc.frequency.exponentialRampToValueAtTime(60, t + 0.4);
|
||||
gain.gain.setValueAtTime(0.7, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.4);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.42);
|
||||
} else {
|
||||
// Zombie / Imp grunt
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(220, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(90, t + 0.28);
|
||||
gain.gain.setValueAtTime(0.6, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.28);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
playEnemyPain() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(180, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(70, t + 0.18);
|
||||
gain.gain.setValueAtTime(0.6, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.18);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.19);
|
||||
}
|
||||
|
||||
playEnemyDeath() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(130, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(30, t + 0.45);
|
||||
gain.gain.setValueAtTime(0.8, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.45);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.46);
|
||||
|
||||
// Gurgle
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.35);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(600, t);
|
||||
const nGain = this.ctx.createGain();
|
||||
nGain.gain.setValueAtTime(0.7, t);
|
||||
nGain.gain.exponentialRampToValueAtTime(0.01, t + 0.35);
|
||||
noise.connect(filter);
|
||||
filter.connect(nGain);
|
||||
nGain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playFireball() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.3);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(1200, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(300, t + 0.3);
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.8, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.3);
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
playItemPickup(type = 'ammo') {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
|
||||
if (type === 'health' || type === 'armor') {
|
||||
osc.frequency.setValueAtTime(440, t);
|
||||
osc.frequency.setValueAtTime(554.37, t + 0.06);
|
||||
osc.frequency.setValueAtTime(659.25, t + 0.12);
|
||||
gain.gain.setValueAtTime(0.5, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.25);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.26);
|
||||
} else if (type === 'weapon' || type === 'key') {
|
||||
// Fanfare chime
|
||||
osc.frequency.setValueAtTime(330, t);
|
||||
osc.frequency.setValueAtTime(440, t + 0.08);
|
||||
osc.frequency.setValueAtTime(660, t + 0.16);
|
||||
osc.frequency.setValueAtTime(880, t + 0.24);
|
||||
gain.gain.setValueAtTime(0.7, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.45);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.46);
|
||||
} else {
|
||||
// Ammo / click
|
||||
osc.frequency.setValueAtTime(600, t);
|
||||
osc.frequency.setValueAtTime(900, t + 0.05);
|
||||
gain.gain.setValueAtTime(0.4, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.12);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.13);
|
||||
}
|
||||
}
|
||||
|
||||
playDoor(open = true) {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'sawtooth';
|
||||
const startFreq = open ? 90 : 150;
|
||||
const endFreq = open ? 160 : 70;
|
||||
osc.frequency.setValueAtTime(startFreq, t);
|
||||
osc.frequency.linearRampToValueAtTime(endFreq, t + 0.6);
|
||||
gain.gain.setValueAtTime(0.4, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.6);
|
||||
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(450, t);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.62);
|
||||
}
|
||||
|
||||
playPlayerPain() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(200, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(80, t + 0.22);
|
||||
gain.gain.setValueAtTime(0.9, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.22);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.23);
|
||||
}
|
||||
|
||||
playSlimeHurt() {
|
||||
if (!this.ctx) return;
|
||||
const t = this.ctx.currentTime;
|
||||
const noise = this.ctx.createBufferSource();
|
||||
noise.buffer = this.createNoiseBuffer(0.2);
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(3200, t);
|
||||
filter.Q.setValueAtTime(3, t);
|
||||
const gain = this.ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.5, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.2);
|
||||
noise.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.sfxGain);
|
||||
noise.start(t);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// E1M1 RETRO SYNTHESIZER SOUNDTRACK
|
||||
// ----------------------------------------------------
|
||||
|
||||
startMusic() {
|
||||
if (this.musicPlaying) return;
|
||||
this.musicPlaying = true;
|
||||
this.musicStep = 0;
|
||||
|
||||
// E1M1 Notes in Hz (E Minor riff)
|
||||
const E1 = 41.20;
|
||||
const E2 = 82.41;
|
||||
const G2 = 98.00;
|
||||
const A2 = 110.00;
|
||||
const Bb2 = 116.54;
|
||||
const B2 = 123.47;
|
||||
const D3 = 146.83;
|
||||
const E3 = 164.81;
|
||||
|
||||
// 32-step bass riff pattern for classic E1M1
|
||||
// E E E3 E E D3 E E | C3 E E Bb2 E E B2 C3
|
||||
const riffNotes = [
|
||||
E2, E2, E3, E2, E2, D3, E2, E2,
|
||||
E3, E2, E2, D3, E2, E2, Bb2, B2,
|
||||
E2, E2, E3, E2, E2, D3, E2, E2,
|
||||
E3, E2, E2, G2, E2, A2, Bb2, B2
|
||||
];
|
||||
|
||||
const stepDuration = (60 / this.musicBpm) / 4; // 16th notes
|
||||
|
||||
const scheduleStep = () => {
|
||||
if (!this.musicPlaying || !this.ctx) return;
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
const note = riffNotes[this.musicStep % riffNotes.length];
|
||||
const isHeavyBeat = (this.musicStep % 4 === 0);
|
||||
const isSnareBeat = (this.musicStep % 8 === 4);
|
||||
|
||||
// Synth Distortion Bass/Guitar
|
||||
const osc = this.ctx.createOscillator();
|
||||
const dist = this.ctx.createWaveShaper();
|
||||
const filter = this.ctx.createBiquadFilter();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(note, now);
|
||||
dist.curve = this.makeDistortionCurve(30);
|
||||
dist.oversample = '4x';
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1400, now);
|
||||
filter.frequency.exponentialRampToValueAtTime(350, now + stepDuration * 0.9);
|
||||
|
||||
gain.gain.setValueAtTime(0.35, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + stepDuration * 0.95);
|
||||
|
||||
osc.connect(dist);
|
||||
dist.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.musicGain);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + stepDuration);
|
||||
|
||||
// Kick Drum on quarter notes
|
||||
if (isHeavyBeat) {
|
||||
const kick = this.ctx.createOscillator();
|
||||
const kGain = this.ctx.createGain();
|
||||
kick.type = 'sine';
|
||||
kick.frequency.setValueAtTime(140, now);
|
||||
kick.frequency.exponentialRampToValueAtTime(35, now + 0.12);
|
||||
kGain.gain.setValueAtTime(0.7, now);
|
||||
kGain.gain.exponentialRampToValueAtTime(0.01, now + 0.12);
|
||||
kick.connect(kGain);
|
||||
kGain.connect(this.musicGain);
|
||||
kick.start(now);
|
||||
kick.stop(now + 0.13);
|
||||
}
|
||||
|
||||
// Snare Drum on 2 and 4
|
||||
if (isSnareBeat) {
|
||||
const snare = this.ctx.createBufferSource();
|
||||
snare.buffer = this.createNoiseBuffer(0.15);
|
||||
const sFilter = this.ctx.createBiquadFilter();
|
||||
sFilter.type = 'highpass';
|
||||
sFilter.frequency.setValueAtTime(1000, now);
|
||||
const sGain = this.ctx.createGain();
|
||||
sGain.gain.setValueAtTime(0.45, now);
|
||||
sGain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
|
||||
snare.connect(sFilter);
|
||||
sFilter.connect(sGain);
|
||||
sGain.connect(this.musicGain);
|
||||
snare.start(now);
|
||||
}
|
||||
|
||||
// Closed Hi-hat every 8th note
|
||||
if (this.musicStep % 2 === 0) {
|
||||
const hat = this.ctx.createBufferSource();
|
||||
hat.buffer = this.createNoiseBuffer(0.04);
|
||||
const hFilter = this.ctx.createBiquadFilter();
|
||||
hFilter.type = 'highpass';
|
||||
hFilter.frequency.setValueAtTime(7000, now);
|
||||
const hGain = this.ctx.createGain();
|
||||
hGain.gain.setValueAtTime(0.15, now);
|
||||
hGain.gain.exponentialRampToValueAtTime(0.01, now + 0.04);
|
||||
hat.connect(hFilter);
|
||||
hFilter.connect(hGain);
|
||||
hGain.connect(this.musicGain);
|
||||
hat.start(now);
|
||||
}
|
||||
|
||||
this.musicStep++;
|
||||
this.musicTimer = setTimeout(scheduleStep, stepDuration * 1000);
|
||||
};
|
||||
|
||||
scheduleStep();
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
this.musicPlaying = false;
|
||||
if (this.musicTimer) {
|
||||
clearTimeout(this.musicTimer);
|
||||
this.musicTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleMusic() {
|
||||
if (this.musicPlaying) {
|
||||
this.stopMusic();
|
||||
return false;
|
||||
} else {
|
||||
this.startMusic();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.SoundSystem = SoundSystem;
|
||||
Executable
+689
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* DOOM 3D - Enemies & Monster AI System
|
||||
* 5 Iconic Doom Demons: Zombieman, Shotgunner, Imp, Pinky Demon, Cacodemon.
|
||||
* 3D Billboards, multi-state animations, line-of-sight tracking, attacks, and blood FX.
|
||||
*/
|
||||
|
||||
class DoomEnemies {
|
||||
constructor(engine, player, sound, map) {
|
||||
this.engine = engine;
|
||||
this.player = player;
|
||||
this.sound = sound;
|
||||
this.map = map;
|
||||
|
||||
this.enemies = [];
|
||||
this.monsterProjectiles = [];
|
||||
this.textures = {};
|
||||
|
||||
this.generateMonsterTextures();
|
||||
this.spawnMapEnemies();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// PROCEDURAL HIGH-DEFINITION RETRO SPRITE TEXTURES
|
||||
// ----------------------------------------------------
|
||||
|
||||
generateMonsterTextures() {
|
||||
this.textures.zombie = this.createZombieSprites();
|
||||
this.textures.shotgunner = this.createShotgunnerSprites();
|
||||
this.textures.imp = this.createImpSprites();
|
||||
this.textures.pinky = this.createPinkySprites();
|
||||
this.textures.caco = this.createCacoSprites();
|
||||
}
|
||||
|
||||
createZombieSprites() {
|
||||
return {
|
||||
walk1: this.drawHumanoidSprite('#3d6b2c', '#a89078', '#ff3300', 'rifle', 1),
|
||||
walk2: this.drawHumanoidSprite('#3d6b2c', '#a89078', '#ff3300', 'rifle', 2),
|
||||
attack: this.drawHumanoidSprite('#3d6b2c', '#a89078', '#ffea00', 'rifle_fire', 1),
|
||||
pain: this.drawHumanoidSprite('#882222', '#c06060', '#ffffff', 'rifle', 1),
|
||||
dead: this.drawDeadCorpseSprite('#3d6b2c', '#990000')
|
||||
};
|
||||
}
|
||||
|
||||
createShotgunnerSprites() {
|
||||
return {
|
||||
walk1: this.drawHumanoidSprite('#22252a', '#9098a0', '#ff0000', 'shotgun', 1),
|
||||
walk2: this.drawHumanoidSprite('#22252a', '#9098a0', '#ff0000', 'shotgun', 2),
|
||||
attack: this.drawHumanoidSprite('#22252a', '#9098a0', '#ffaa00', 'shotgun_fire', 1),
|
||||
pain: this.drawHumanoidSprite('#6b1a1a', '#aa4444', '#ffffff', 'shotgun', 1),
|
||||
dead: this.drawDeadCorpseSprite('#22252a', '#bb0000')
|
||||
};
|
||||
}
|
||||
|
||||
createImpSprites() {
|
||||
return {
|
||||
walk1: this.drawImpSprite(1),
|
||||
walk2: this.drawImpSprite(2),
|
||||
attack: this.drawImpSprite(3), // casting fireball
|
||||
pain: this.drawImpSprite(4),
|
||||
dead: this.drawDeadCorpseSprite('#6e4428', '#aa1100')
|
||||
};
|
||||
}
|
||||
|
||||
createPinkySprites() {
|
||||
return {
|
||||
walk1: this.drawPinkySprite(1),
|
||||
walk2: this.drawPinkySprite(2),
|
||||
attack: this.drawPinkySprite(3), // wide jaws bite
|
||||
pain: this.drawPinkySprite(4),
|
||||
dead: this.drawDeadCorpseSprite('#a82838', '#880000')
|
||||
};
|
||||
}
|
||||
|
||||
createCacoSprites() {
|
||||
return {
|
||||
walk1: this.drawCacoSprite(1),
|
||||
walk2: this.drawCacoSprite(2),
|
||||
attack: this.drawCacoSprite(3), // blue lightning maw
|
||||
pain: this.drawCacoSprite(4),
|
||||
dead: this.drawDeadCorpseSprite('#c41c1c', '#00d4ff')
|
||||
};
|
||||
}
|
||||
|
||||
drawHumanoidSprite(armorColor, skinColor, eyeColor, weaponType, frame) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 160;
|
||||
c.height = 180;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Shadow under feet
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(80, 165, 38, 12, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Boots / Legs
|
||||
ctx.fillStyle = '#181b16';
|
||||
if (frame === 1) {
|
||||
ctx.fillRect(58, 105, 20, 58);
|
||||
ctx.fillRect(82, 105, 20, 58);
|
||||
} else {
|
||||
ctx.fillRect(48, 105, 22, 58);
|
||||
ctx.fillRect(90, 105, 22, 58);
|
||||
}
|
||||
|
||||
// Torso / Tactical Armor Vest
|
||||
ctx.fillStyle = armorColor;
|
||||
ctx.fillRect(50, 48, 60, 60);
|
||||
ctx.fillStyle = '#1c201a';
|
||||
ctx.fillRect(56, 54, 48, 18); // Chest plate detail
|
||||
ctx.fillRect(54, 94, 52, 10); // Utility belt
|
||||
|
||||
// Muscular / Zombie Flesh Head
|
||||
ctx.fillStyle = skinColor;
|
||||
ctx.fillRect(62, 16, 36, 34);
|
||||
|
||||
// Glowing Demonic Eyes
|
||||
ctx.fillStyle = eyeColor;
|
||||
ctx.fillRect(68, 26, 8, 7);
|
||||
ctx.fillRect(84, 26, 8, 7);
|
||||
|
||||
// Sinister Mouth
|
||||
ctx.fillStyle = '#330000';
|
||||
ctx.fillRect(72, 38, 16, 6);
|
||||
|
||||
// Zombie Helmet / Hair
|
||||
ctx.fillStyle = '#33382e';
|
||||
ctx.fillRect(58, 10, 44, 12);
|
||||
|
||||
// Weapon
|
||||
if (weaponType.includes('rifle')) {
|
||||
ctx.fillStyle = '#111316';
|
||||
ctx.fillRect(75, 62, 60, 14);
|
||||
ctx.fillStyle = '#444c54';
|
||||
ctx.fillRect(82, 58, 24, 8);
|
||||
|
||||
if (weaponType === 'rifle_fire') {
|
||||
ctx.fillStyle = '#ffea00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(142, 69, 20, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(142, 69, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
} else if (weaponType.includes('shotgun')) {
|
||||
ctx.fillStyle = '#1a1c20';
|
||||
ctx.fillRect(70, 64, 70, 18);
|
||||
ctx.fillStyle = '#7a3e1d'; // Wooden pump
|
||||
ctx.fillRect(92, 70, 24, 14);
|
||||
|
||||
if (weaponType === 'shotgun_fire') {
|
||||
ctx.fillStyle = '#ffaa00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(148, 73, 26, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(148, 73, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
drawImpSprite(pose) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 160;
|
||||
c.height = 180;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(80, 165, 42, 12, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Brown Spiky Demon Body
|
||||
ctx.fillStyle = pose === 4 ? '#8a2d1d' : '#6b4124';
|
||||
// Torso
|
||||
ctx.fillRect(52, 45, 56, 62);
|
||||
|
||||
// Demonic Spikes on shoulders & head
|
||||
ctx.fillStyle = '#e8d8b8';
|
||||
ctx.fillRect(42, 38, 12, 14);
|
||||
ctx.fillRect(106, 38, 12, 14);
|
||||
ctx.fillRect(56, 8, 10, 18);
|
||||
ctx.fillRect(94, 8, 10, 18);
|
||||
|
||||
// Head
|
||||
ctx.fillStyle = pose === 4 ? '#9c3622' : '#7c4d2d';
|
||||
ctx.fillRect(58, 18, 44, 35);
|
||||
|
||||
// Glowing Fiery Red Eyes
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(66, 28, 8, 8);
|
||||
ctx.fillRect(86, 28, 8, 8);
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.fillRect(68, 30, 4, 4);
|
||||
ctx.fillRect(88, 30, 4, 4);
|
||||
|
||||
// Sharp Demonic Fangs
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.fillRect(68 + i * 7, 42, 4, 6);
|
||||
}
|
||||
|
||||
// Legs with clawed hooves
|
||||
ctx.fillStyle = '#523018';
|
||||
if (pose === 1) {
|
||||
ctx.fillRect(52, 107, 22, 56);
|
||||
ctx.fillRect(86, 107, 22, 56);
|
||||
} else if (pose === 2) {
|
||||
ctx.fillRect(44, 107, 22, 56);
|
||||
ctx.fillRect(94, 107, 22, 56);
|
||||
} else {
|
||||
ctx.fillRect(50, 107, 24, 56);
|
||||
ctx.fillRect(86, 107, 24, 56);
|
||||
}
|
||||
|
||||
// Fireball casting pose
|
||||
if (pose === 3) {
|
||||
// Glowing Fireball in hands
|
||||
ctx.fillStyle = '#ff5500';
|
||||
ctx.beginPath();
|
||||
ctx.arc(125, 75, 24, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(125, 75, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
drawPinkySprite(frame) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 180;
|
||||
c.height = 180;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(90, 165, 52, 14, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Massive Demonic Muscle Beast (Pinky)
|
||||
ctx.fillStyle = frame === 4 ? '#781822' : '#b82e44';
|
||||
ctx.fillRect(40, 40, 100, 70);
|
||||
|
||||
// Demon Horns
|
||||
ctx.fillStyle = '#e6d8c8';
|
||||
ctx.fillRect(28, 22, 18, 24);
|
||||
ctx.fillRect(134, 22, 18, 24);
|
||||
|
||||
// Huge Demon Jaws & Snout
|
||||
ctx.fillStyle = '#8a1d2e';
|
||||
ctx.fillRect(55, 50, 70, 55);
|
||||
|
||||
// Glowing Yellow Eyes
|
||||
ctx.fillStyle = '#ffff33';
|
||||
ctx.fillRect(60, 55, 12, 10);
|
||||
ctx.fillRect(108, 55, 12, 10);
|
||||
|
||||
// Terrifying Sharp Teeth
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ctx.fillRect(62 + i * 10, 85, 6, 12);
|
||||
if (frame === 3) {
|
||||
// Open jaw
|
||||
ctx.fillRect(62 + i * 10, 70, 6, 8);
|
||||
}
|
||||
}
|
||||
|
||||
// Heavy Stomping Legs
|
||||
ctx.fillStyle = '#6e1422';
|
||||
if (frame === 1) {
|
||||
ctx.fillRect(42, 110, 32, 54);
|
||||
ctx.fillRect(106, 110, 32, 54);
|
||||
} else {
|
||||
ctx.fillRect(30, 110, 32, 54);
|
||||
ctx.fillRect(118, 110, 32, 54);
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
drawCacoSprite(frame) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 180;
|
||||
c.height = 180;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Giant Red Floating Sphere Demon
|
||||
ctx.fillStyle = '#c41c1c';
|
||||
ctx.beginPath();
|
||||
ctx.arc(90, 90, 72, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spikes on head
|
||||
ctx.fillStyle = '#6e0c0c';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = Math.PI + (i * Math.PI) / 7;
|
||||
const hx = 90 + Math.cos(angle) * 70;
|
||||
const hy = 90 + Math.sin(angle) * 70;
|
||||
ctx.fillRect(hx - 6, hy - 6, 12, 12);
|
||||
}
|
||||
|
||||
// Massive Emerald Eye
|
||||
ctx.fillStyle = '#00ff55';
|
||||
ctx.beginPath();
|
||||
ctx.arc(90, 65, 24, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#003311';
|
||||
ctx.beginPath();
|
||||
ctx.arc(90, 65, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Gaping Maw
|
||||
ctx.fillStyle = '#111116';
|
||||
ctx.fillRect(50, 105, 80, 34);
|
||||
|
||||
if (frame === 3) {
|
||||
// Blue Lightning Ball inside mouth
|
||||
ctx.fillStyle = '#00d4ff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(90, 122, 22, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Teeth
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (let i = 0; i < 7; i++) {
|
||||
ctx.fillRect(55 + i * 10, 105, 6, 10);
|
||||
ctx.fillRect(55 + i * 10, 128, 6, 10);
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
drawDeadCorpseSprite(bodyColor, bloodColor) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 160;
|
||||
c.height = 90;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Blood Pool on Floor
|
||||
ctx.fillStyle = bloodColor;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(80, 58, 68, 24, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Collapsed Demonic Body
|
||||
ctx.fillStyle = bodyColor;
|
||||
ctx.fillRect(35, 36, 95, 28);
|
||||
|
||||
// Skull / Slain Head
|
||||
ctx.fillStyle = '#d0a080';
|
||||
ctx.fillRect(20, 42, 20, 18);
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// SPAWNING ENEMIES
|
||||
// ----------------------------------------------------
|
||||
|
||||
spawnMapEnemies() {
|
||||
const spawns = (this.map && this.map.spawns) ? this.map.spawns.enemies : (window.game && window.game.map ? window.game.map.spawns.enemies : []);
|
||||
if (!spawns || spawns.length === 0) return;
|
||||
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
this.createEnemy(s.type, s.x, s.y, s.z);
|
||||
}
|
||||
}
|
||||
|
||||
createEnemy(type, x, y, z) {
|
||||
let maxHp = 30;
|
||||
let speed = 4.5;
|
||||
let scaleW = 2.4;
|
||||
let scaleH = 2.6;
|
||||
let attackRange = 28;
|
||||
let isFlying = false;
|
||||
|
||||
if (type === 'zombie') {
|
||||
maxHp = 30;
|
||||
speed = 3.6;
|
||||
attackRange = 30;
|
||||
} else if (type === 'shotgunner') {
|
||||
maxHp = 45;
|
||||
speed = 4.0;
|
||||
attackRange = 24;
|
||||
} else if (type === 'imp') {
|
||||
maxHp = 65;
|
||||
speed = 4.6;
|
||||
attackRange = 35;
|
||||
} else if (type === 'pinky') {
|
||||
maxHp = 140;
|
||||
speed = 7.5;
|
||||
attackRange = 2.8;
|
||||
scaleW = 2.8;
|
||||
scaleH = 2.8;
|
||||
} else if (type === 'caco') {
|
||||
maxHp = 220;
|
||||
speed = 4.0;
|
||||
attackRange = 35;
|
||||
scaleW = 3.4;
|
||||
scaleH = 3.4;
|
||||
isFlying = true;
|
||||
}
|
||||
|
||||
// Sprite Material - depthTest true, depthWrite false for proper transparency
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: this.textures[type].walk1,
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
const spawnY = isFlying ? (y + 3.5) : (y + scaleH / 2);
|
||||
sprite.position.set(x, spawnY, z);
|
||||
sprite.scale.set(scaleW, scaleH, 1);
|
||||
this.engine.scene.add(sprite);
|
||||
|
||||
const enemyObj = {
|
||||
type,
|
||||
mesh: sprite,
|
||||
material: spriteMat,
|
||||
hp: maxHp,
|
||||
maxHp: maxHp,
|
||||
speed: speed,
|
||||
scaleW: scaleW,
|
||||
scaleH: scaleH,
|
||||
radius: scaleW * 0.45,
|
||||
attackRange: attackRange,
|
||||
isFlying: isFlying,
|
||||
state: 'idle',
|
||||
stateTimer: 0,
|
||||
animTimer: 0,
|
||||
animFrame: 1,
|
||||
attackCooldown: 0.8 + Math.random() * 0.8,
|
||||
isDead: false,
|
||||
takeDamage: (dmg, hitPos) => this.damageEnemy(enemyObj, dmg, hitPos)
|
||||
};
|
||||
|
||||
this.enemies.push(enemyObj);
|
||||
return enemyObj;
|
||||
}
|
||||
|
||||
damageEnemy(e, amount, hitPos) {
|
||||
if (e.isDead) return;
|
||||
|
||||
e.hp -= amount;
|
||||
if (this.sound) this.sound.playEnemyPain();
|
||||
|
||||
// Alert enemy and nearby monsters!
|
||||
e.state = 'chase';
|
||||
this.alertNearbyMonsters(e.mesh.position, 35);
|
||||
|
||||
if (e.hp <= 0) {
|
||||
this.killEnemy(e);
|
||||
} else {
|
||||
// Pain stagger frame
|
||||
e.state = 'pain';
|
||||
e.stateTimer = 0.22;
|
||||
e.material.map = this.textures[e.type].pain;
|
||||
}
|
||||
}
|
||||
|
||||
killEnemy(e) {
|
||||
e.isDead = true;
|
||||
e.state = 'dead';
|
||||
e.mesh.scale.set(e.scaleW * 1.3, e.scaleH * 0.5, 1);
|
||||
if (!e.isFlying) {
|
||||
e.mesh.position.y = Math.max(0.1, e.mesh.position.y - e.scaleH * 0.35);
|
||||
}
|
||||
e.material.map = this.textures[e.type].dead;
|
||||
if (this.sound) this.sound.playEnemyDeath();
|
||||
|
||||
// Drop loot
|
||||
if (e.type === 'shotgunner') {
|
||||
if (window.game && window.game.items) {
|
||||
window.game.items.spawnItem('shotgun', e.mesh.position.x, e.mesh.position.y, e.mesh.position.z);
|
||||
}
|
||||
} else if (e.type === 'zombie') {
|
||||
if (window.game && window.game.items && Math.random() > 0.4) {
|
||||
window.game.items.spawnItem('ammo_clip', e.mesh.position.x, e.mesh.position.y, e.mesh.position.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alertNearbyMonsters(pos, radius) {
|
||||
for (let i = 0; i < this.enemies.length; i++) {
|
||||
const other = this.enemies[i];
|
||||
if (!other.isDead && other.state === 'idle') {
|
||||
if (other.mesh.position.distanceTo(pos) < radius) {
|
||||
other.state = 'chase';
|
||||
if (this.sound) this.sound.playEnemyAlert(other.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ENEMY AI & PROJECTILES UPDATE LOOP
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
const playerPos = this.player.position;
|
||||
|
||||
// 1. Update Demon AI
|
||||
for (let i = 0; i < this.enemies.length; i++) {
|
||||
const e = this.enemies[i];
|
||||
if (e.isDead) continue;
|
||||
|
||||
const dist = e.mesh.position.distanceTo(playerPos);
|
||||
|
||||
// Alert enemy if player is close or in line of sight
|
||||
if (e.state === 'idle' && dist < 28) {
|
||||
e.state = 'chase';
|
||||
if (this.sound) this.sound.playEnemyAlert(e.type);
|
||||
}
|
||||
|
||||
// State Handling
|
||||
if (e.state === 'pain') {
|
||||
e.stateTimer -= delta;
|
||||
if (e.stateTimer <= 0) {
|
||||
e.state = 'chase';
|
||||
}
|
||||
} else if (e.state === 'attack') {
|
||||
e.stateTimer -= delta;
|
||||
if (e.stateTimer <= 0) {
|
||||
e.state = 'chase';
|
||||
e.attackCooldown = 1.2 + Math.random() * 0.8;
|
||||
}
|
||||
} else if (e.state === 'chase') {
|
||||
e.attackCooldown -= delta;
|
||||
|
||||
// Move towards player
|
||||
const dir = new THREE.Vector3().subVectors(playerPos, e.mesh.position);
|
||||
if (!e.isFlying) dir.y = 0;
|
||||
dir.normalize();
|
||||
|
||||
// Check collision with other enemies to prevent stacking
|
||||
const moveDist = e.speed * delta;
|
||||
e.mesh.position.addScaledVector(dir, moveDist);
|
||||
|
||||
// Animation walking frames
|
||||
e.animTimer += delta * 6;
|
||||
if (e.animTimer >= 1.0) {
|
||||
e.animTimer = 0;
|
||||
e.animFrame = e.animFrame === 1 ? 2 : 1;
|
||||
e.material.map = (e.animFrame === 1) ? this.textures[e.type].walk1 : this.textures[e.type].walk2;
|
||||
}
|
||||
|
||||
// Attack Player if within range and cooldown is ready
|
||||
if (dist <= e.attackRange && e.attackCooldown <= 0) {
|
||||
this.triggerEnemyAttack(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update Monster Flying Projectiles (Fireballs & Plasma balls)
|
||||
for (let i = this.monsterProjectiles.length - 1; i >= 0; i--) {
|
||||
const p = this.monsterProjectiles[i];
|
||||
p.mesh.position.addScaledVector(p.velocity, delta);
|
||||
p.light.position.copy(p.mesh.position);
|
||||
p.life -= delta;
|
||||
|
||||
// Check hit against player
|
||||
const pDist = p.mesh.position.distanceTo(this.player.position);
|
||||
if (pDist < 1.4) {
|
||||
this.player.takeDamage(p.damage, p.mesh.position);
|
||||
this.engine.createExplosionFX(p.mesh.position, p.isCaco ? 0x00d4ff : 0xff5500);
|
||||
this.destroyMonsterProjectile(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (p.life <= 0) {
|
||||
this.destroyMonsterProjectile(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerEnemyAttack(e) {
|
||||
e.state = 'attack';
|
||||
e.stateTimer = 0.5;
|
||||
e.material.map = this.textures[e.type].attack;
|
||||
|
||||
const playerPos = this.player.position;
|
||||
|
||||
if (e.type === 'pinky') {
|
||||
// Melee Bite Attack
|
||||
if (e.mesh.position.distanceTo(playerPos) < 3.2) {
|
||||
this.player.takeDamage(18, e.mesh.position);
|
||||
}
|
||||
} else if (e.type === 'zombie') {
|
||||
// Hitscan rifle shot
|
||||
if (this.sound) this.sound.playPistol();
|
||||
this.engine.createMuzzleLight(e.mesh.position, 0xffcc00);
|
||||
this.player.takeDamage(8, e.mesh.position);
|
||||
} else if (e.type === 'shotgunner') {
|
||||
// Hitscan shotgun blast
|
||||
if (this.sound) this.sound.playShotgun();
|
||||
this.engine.createMuzzleLight(e.mesh.position, 0xff9900);
|
||||
this.player.takeDamage(18, e.mesh.position);
|
||||
} else if (e.type === 'imp') {
|
||||
// Launch Fireball projectile
|
||||
this.spawnFireball(e.mesh.position, playerPos, 16, 22.0);
|
||||
} else if (e.type === 'caco') {
|
||||
// Launch Blue Lightning Plasma projectile
|
||||
this.spawnCacoBall(e.mesh.position, playerPos, 28, 25.0);
|
||||
}
|
||||
}
|
||||
|
||||
spawnFireball(from, to, damage, speed) {
|
||||
const geo = new THREE.SphereGeometry(0.35, 8, 8);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xff4400 });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.copy(from);
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
const light = new THREE.PointLight(0xff5500, 3.0, 10, 2);
|
||||
light.position.copy(from);
|
||||
this.engine.scene.add(light);
|
||||
|
||||
const target = new THREE.Vector3(to.x, to.y + 1.2, to.z);
|
||||
const dir = new THREE.Vector3().subVectors(target, from).normalize();
|
||||
|
||||
this.monsterProjectiles.push({
|
||||
mesh,
|
||||
light,
|
||||
velocity: dir.multiplyScalar(speed),
|
||||
damage,
|
||||
life: 4.0,
|
||||
isCaco: false
|
||||
});
|
||||
}
|
||||
|
||||
spawnCacoBall(from, to, damage, speed) {
|
||||
const geo = new THREE.SphereGeometry(0.45, 8, 8);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0x00d4ff });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.copy(from);
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
const light = new THREE.PointLight(0x00d4ff, 3.5, 12, 2);
|
||||
light.position.copy(from);
|
||||
this.engine.scene.add(light);
|
||||
|
||||
const target = new THREE.Vector3(to.x, to.y + 1.2, to.z);
|
||||
const dir = new THREE.Vector3().subVectors(target, from).normalize();
|
||||
|
||||
this.monsterProjectiles.push({
|
||||
mesh,
|
||||
light,
|
||||
velocity: dir.multiplyScalar(speed),
|
||||
damage,
|
||||
life: 4.0,
|
||||
isCaco: true
|
||||
});
|
||||
}
|
||||
|
||||
destroyMonsterProjectile(index) {
|
||||
const p = this.monsterProjectiles[index];
|
||||
this.engine.scene.remove(p.mesh);
|
||||
this.engine.scene.remove(p.light);
|
||||
this.monsterProjectiles.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomEnemies = DoomEnemies;
|
||||
Executable
+852
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* DOOM 3D - Procedural Texture Generator, Particle System & Three.js Engine Setup
|
||||
* High performance WebGL engine with authentic 90s DOOM textures and visual effects.
|
||||
*/
|
||||
|
||||
class DoomEngine {
|
||||
constructor(canvasContainer) {
|
||||
this.container = canvasContainer;
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.renderer = null;
|
||||
this.clock = new THREE.Clock();
|
||||
|
||||
// Procedural Textures Cache
|
||||
this.textures = {};
|
||||
this.materials = {};
|
||||
|
||||
// Particle System
|
||||
this.particles = [];
|
||||
this.decals = [];
|
||||
this.animatedTextures = [];
|
||||
|
||||
// Lights
|
||||
this.lights = [];
|
||||
this.dynamicLights = [];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 1. Scene & Atmospheric Fog
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x1a0505);
|
||||
this.scene.fog = new THREE.FogExp2(0x1a0505, 0.015);
|
||||
|
||||
// 2. Camera
|
||||
const aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
|
||||
this.camera.position.set(0, 1.8, 0);
|
||||
|
||||
// 3. Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
this.renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
|
||||
// 4. Generate Procedural Textures & Materials
|
||||
this.generateTextures();
|
||||
this.buildSkybox();
|
||||
|
||||
// 5. Global Lighting
|
||||
this.setupLighting();
|
||||
|
||||
// 6. Window Resize Handler
|
||||
window.addEventListener('resize', () => this.onResize());
|
||||
}
|
||||
|
||||
onResize() {
|
||||
if (!this.camera || !this.renderer) return;
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
setupLighting() {
|
||||
// Ambient base light (dark demonic tone)
|
||||
const ambient = new THREE.AmbientLight(0x382222, 0.9);
|
||||
this.scene.add(ambient);
|
||||
this.lights.push(ambient);
|
||||
|
||||
// Sun / Directional red overhead light
|
||||
const sun = new THREE.DirectionalLight(0xff6644, 0.8);
|
||||
sun.position.set(30, 60, 20);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.width = 1024;
|
||||
sun.shadow.mapSize.height = 1024;
|
||||
sun.shadow.camera.near = 0.5;
|
||||
sun.shadow.camera.far = 200;
|
||||
sun.shadow.camera.left = -50;
|
||||
sun.shadow.camera.right = 50;
|
||||
sun.shadow.camera.top = 50;
|
||||
sun.shadow.camera.bottom = -50;
|
||||
this.scene.add(sun);
|
||||
this.lights.push(sun);
|
||||
}
|
||||
|
||||
addFlickeringLight(x, y, z, color = 0xffaa44, intensity = 2.0, distance = 25) {
|
||||
const light = new THREE.PointLight(color, intensity, distance, 2);
|
||||
light.position.set(x, y, z);
|
||||
this.scene.add(light);
|
||||
this.dynamicLights.push({
|
||||
light,
|
||||
baseIntensity: intensity,
|
||||
flickerSpeed: 10 + Math.random() * 20,
|
||||
phase: Math.random() * 10
|
||||
});
|
||||
return light;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// PROCEDURAL TEXTURE GENERATION
|
||||
// ----------------------------------------------------
|
||||
|
||||
generateTextures() {
|
||||
this.textures.metal = this.createMetalTexture();
|
||||
this.textures.tech = this.createTechScreenTexture();
|
||||
this.textures.brick = this.createBrickTexture();
|
||||
this.textures.hex = this.createHexMetalTexture();
|
||||
this.textures.grate = this.createGratingTexture();
|
||||
this.textures.slime = this.createSlimeTexture();
|
||||
this.textures.doorRegular = this.createDoorTexture('normal');
|
||||
this.textures.doorBlue = this.createDoorTexture('blue');
|
||||
this.textures.doorRed = this.createDoorTexture('red');
|
||||
this.textures.doorYellow = this.createDoorTexture('yellow');
|
||||
this.textures.ceiling = this.createCeilingTexture();
|
||||
this.textures.barrel = this.createBarrelTexture();
|
||||
|
||||
// Standard Doom Materials
|
||||
this.materials.metal = new THREE.MeshStandardMaterial({ map: this.textures.metal, roughness: 0.6, metalness: 0.4 });
|
||||
this.materials.tech = new THREE.MeshStandardMaterial({ map: this.textures.tech, roughness: 0.4, metalness: 0.2, emissive: 0x112233, emissiveIntensity: 0.4 });
|
||||
this.materials.brick = new THREE.MeshStandardMaterial({ map: this.textures.brick, roughness: 0.85, metalness: 0.1 });
|
||||
this.materials.hex = new THREE.MeshStandardMaterial({ map: this.textures.hex, roughness: 0.5, metalness: 0.6 });
|
||||
this.materials.grate = new THREE.MeshStandardMaterial({ map: this.textures.grate, roughness: 0.7, metalness: 0.5 });
|
||||
this.materials.slime = new THREE.MeshStandardMaterial({ map: this.textures.slime.texture, roughness: 0.2, metalness: 0.1, emissive: 0x00ff22, emissiveIntensity: 0.6 });
|
||||
this.materials.doorRegular = new THREE.MeshStandardMaterial({ map: this.textures.doorRegular, roughness: 0.5, metalness: 0.5 });
|
||||
this.materials.doorBlue = new THREE.MeshStandardMaterial({ map: this.textures.doorBlue, roughness: 0.5, metalness: 0.5, emissive: 0x0044ff, emissiveIntensity: 0.3 });
|
||||
this.materials.doorRed = new THREE.MeshStandardMaterial({ map: this.textures.doorRed, roughness: 0.5, metalness: 0.5, emissive: 0xff0022, emissiveIntensity: 0.3 });
|
||||
this.materials.doorYellow = new THREE.MeshStandardMaterial({ map: this.textures.doorYellow, roughness: 0.5, metalness: 0.5, emissive: 0xffbb00, emissiveIntensity: 0.3 });
|
||||
this.materials.ceiling = new THREE.MeshStandardMaterial({ map: this.textures.ceiling, roughness: 0.7, metalness: 0.3 });
|
||||
this.materials.barrel = new THREE.MeshStandardMaterial({ map: this.textures.barrel, roughness: 0.6, metalness: 0.3 });
|
||||
}
|
||||
|
||||
createMetalTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Steel base
|
||||
ctx.fillStyle = '#484440';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Heavy steel panel borders
|
||||
ctx.strokeStyle = '#22201e';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(4, 4, 248, 248);
|
||||
ctx.strokeRect(10, 10, 114, 114);
|
||||
ctx.strokeRect(132, 10, 114, 114);
|
||||
ctx.strokeRect(10, 132, 114, 114);
|
||||
ctx.strokeRect(132, 132, 114, 114);
|
||||
|
||||
// Rivets / Bolts
|
||||
ctx.fillStyle = '#1c1b18';
|
||||
const rivetPoints = [
|
||||
[16, 16], [118, 16], [16, 118], [118, 118],
|
||||
[138, 16], [240, 16], [138, 118], [240, 118],
|
||||
[16, 138], [118, 138], [16, 240], [118, 240],
|
||||
[138, 138], [240, 138], [138, 240], [240, 240]
|
||||
];
|
||||
rivetPoints.forEach(([x, y]) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#6a6560';
|
||||
ctx.fillRect(x - 1, y - 1, 2, 2);
|
||||
ctx.fillStyle = '#1c1b18';
|
||||
});
|
||||
|
||||
// Grime and scratches
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
||||
for (let i = 0; i < 600; i++) {
|
||||
ctx.fillRect(Math.random() * 256, Math.random() * 256, Math.random() * 3 + 1, Math.random() * 3 + 1);
|
||||
}
|
||||
|
||||
// Rust patches
|
||||
ctx.fillStyle = 'rgba(120, 60, 20, 0.25)';
|
||||
ctx.fillRect(10, 110, 80, 14);
|
||||
ctx.fillRect(150, 10, 20, 90);
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createTechScreenTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Dark tech casing
|
||||
ctx.fillStyle = '#202428';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Terminal Screen 1 (Top Blue Terminal)
|
||||
ctx.fillStyle = '#081420';
|
||||
ctx.fillRect(16, 16, 224, 100);
|
||||
ctx.strokeStyle = '#3a4450';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(16, 16, 224, 100);
|
||||
|
||||
// Oscilloscope / Data wave
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (let x = 20; x < 230; x += 5) {
|
||||
const y = 66 + Math.sin(x * 0.1) * 20 + (Math.random() * 6 - 3);
|
||||
if (x === 20) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Terminal Text lines
|
||||
ctx.fillStyle = '#44ff88';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.fillText('UAC MAINFRAME // LEVEL 1', 24, 34);
|
||||
ctx.fillStyle = '#00aaff';
|
||||
ctx.fillText('SYSTEM STATUS: CRITICAL', 24, 48);
|
||||
ctx.fillStyle = '#ff4444';
|
||||
ctx.fillText('REACTOR OVERHEAT: 88.4%', 24, 98);
|
||||
|
||||
// Lower Control Panel with LED buttons and sliders
|
||||
ctx.fillStyle = '#16181b';
|
||||
ctx.fillRect(16, 128, 224, 112);
|
||||
ctx.strokeStyle = '#40454c';
|
||||
ctx.strokeRect(16, 128, 224, 112);
|
||||
|
||||
// Blinking LEDs
|
||||
const ledColors = ['#ff0000', '#00ff00', '#ffff00', '#00e1ff', '#ff00ff'];
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 7; col++) {
|
||||
ctx.fillStyle = ledColors[(row * 7 + col) % ledColors.length];
|
||||
ctx.fillRect(28 + col * 28, 142 + row * 26, 14, 14);
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(28 + col * 28, 142 + row * 26, 14, 14);
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createBrickTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Dark demonic brick mortar
|
||||
ctx.fillStyle = '#180e0c';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Red/brown stone bricks
|
||||
const brickH = 32;
|
||||
const brickW = 64;
|
||||
const rows = 8;
|
||||
const cols = 4;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const offset = (r % 2) * (brickW / 2);
|
||||
for (let c = -1; c <= cols; c++) {
|
||||
const x = c * brickW + offset + 2;
|
||||
const y = r * brickH + 2;
|
||||
const shade = 70 + Math.floor(Math.random() * 40);
|
||||
const redShade = shade + 40;
|
||||
ctx.fillStyle = `rgb(${redShade}, ${Math.floor(shade * 0.4)}, ${Math.floor(shade * 0.35)})`;
|
||||
ctx.fillRect(x, y, brickW - 4, brickH - 4);
|
||||
|
||||
// Brick cracks & texture
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.2)';
|
||||
ctx.fillRect(x + Math.random() * 20, y + 4, Math.random() * 25 + 5, 2);
|
||||
ctx.fillRect(x + 4, y + Math.random() * 15, 2, Math.random() * 10 + 4);
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createHexMetalTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#26292d';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Hexagonal grid
|
||||
const size = 32;
|
||||
const h = size * Math.sqrt(3);
|
||||
|
||||
const drawHex = (x, y, radius) => {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i;
|
||||
const hx = x + radius * Math.cos(angle);
|
||||
const hy = y + radius * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(hx, hy);
|
||||
else ctx.lineTo(hx, hy);
|
||||
}
|
||||
ctx.closePath();
|
||||
};
|
||||
|
||||
for (let row = -1; row < 10; row++) {
|
||||
for (let col = -1; col < 10; col++) {
|
||||
const x = col * size * 1.5;
|
||||
const y = row * h + ((col % 2) * h / 2);
|
||||
ctx.fillStyle = (row + col) % 2 === 0 ? '#34383e' : '#2b2f34';
|
||||
drawHex(x, y, size * 0.9);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#181a1d';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Center glowing dot
|
||||
if ((row + col) % 3 === 0) {
|
||||
ctx.fillStyle = '#ff6600';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createGratingTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 128;
|
||||
c.height = 128;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#1c1c1f';
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Grate lines
|
||||
ctx.fillStyle = '#3a3a40';
|
||||
for (let i = 0; i < 128; i += 16) {
|
||||
ctx.fillRect(i, 0, 4, 128);
|
||||
ctx.fillRect(0, i, 128, 4);
|
||||
}
|
||||
// Shadow pits
|
||||
ctx.fillStyle = '#0a0a0c';
|
||||
for (let x = 4; x < 128; x += 16) {
|
||||
for (let y = 4; y < 128; y += 16) {
|
||||
ctx.fillRect(x + 2, y + 2, 8, 8);
|
||||
}
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createSlimeTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
|
||||
const animObj = {
|
||||
canvas: c,
|
||||
ctx: ctx,
|
||||
texture: tex,
|
||||
time: 0,
|
||||
update: (delta) => {
|
||||
animObj.time += delta * 2;
|
||||
const t = animObj.time;
|
||||
// Radioactive green gradient
|
||||
ctx.fillStyle = '#154408';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Bubbles and ripples
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const bx = 128 + Math.cos(t * 0.8 + i * 1.3) * 90 + Math.sin(i * 4) * 20;
|
||||
const by = 128 + Math.sin(t * 0.9 + i * 1.5) * 90 + Math.cos(i * 3) * 20;
|
||||
const rad = 10 + Math.sin(t * 3 + i) * 6;
|
||||
|
||||
const grad = ctx.createRadialGradient(bx, by, 2, bx, by, rad);
|
||||
grad.addColorStop(0, '#88ff22');
|
||||
grad.addColorStop(0.7, '#22aa00');
|
||||
grad.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(bx, by, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Swirling slime streaks
|
||||
ctx.strokeStyle = 'rgba(100, 255, 30, 0.3)';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < 256; x += 10) {
|
||||
const y = 128 + Math.sin(x * 0.05 + t) * 40 + Math.cos(x * 0.02 - t) * 30;
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
tex.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
this.animatedTextures.push(animObj);
|
||||
return animObj;
|
||||
}
|
||||
|
||||
createDoorTexture(type = 'normal') {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 512;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Blast door base
|
||||
ctx.fillStyle = '#3a3a40';
|
||||
ctx.fillRect(0, 0, 256, 512);
|
||||
|
||||
// Outer heavy steel frame
|
||||
ctx.strokeStyle = '#18181a';
|
||||
ctx.lineWidth = 12;
|
||||
ctx.strokeRect(6, 6, 244, 500);
|
||||
|
||||
// Center seam split
|
||||
ctx.strokeStyle = '#111';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(128, 12);
|
||||
ctx.lineTo(128, 500);
|
||||
ctx.stroke();
|
||||
|
||||
// Hazard Stripes at top & bottom
|
||||
const drawHazard = (yStart, h) => {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(12, yStart, 232, h);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#ffaa00';
|
||||
ctx.fillRect(12, yStart, 232, h);
|
||||
ctx.fillStyle = '#111';
|
||||
for (let x = -50; x < 300; x += 30) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yStart);
|
||||
ctx.lineTo(x + 20, yStart);
|
||||
ctx.lineTo(x - 10, yStart + h);
|
||||
ctx.lineTo(x - 30, yStart + h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
drawHazard(12, 40);
|
||||
drawHazard(460, 40);
|
||||
|
||||
// Center Keycard Lock Indicator
|
||||
ctx.fillStyle = '#1e2024';
|
||||
ctx.fillRect(88, 220, 80, 72);
|
||||
ctx.strokeStyle = '#555';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(88, 220, 80, 72);
|
||||
|
||||
if (type === 'blue') {
|
||||
ctx.fillStyle = '#0088ff';
|
||||
ctx.fillRect(96, 230, 64, 52);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText('BLUE', 110, 260);
|
||||
} else if (type === 'red') {
|
||||
ctx.fillStyle = '#ff1122';
|
||||
ctx.fillRect(96, 230, 64, 52);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText('RED', 114, 260);
|
||||
} else if (type === 'yellow') {
|
||||
ctx.fillStyle = '#ffbb00';
|
||||
ctx.fillRect(96, 230, 64, 52);
|
||||
ctx.fillStyle = '#111111';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText('YELW', 110, 260);
|
||||
} else {
|
||||
// Normal door UAC symbol
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = 'bold 16px monospace';
|
||||
ctx.fillText('UAC', 112, 262);
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
return tex;
|
||||
}
|
||||
|
||||
createCeilingTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#222428';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Ceiling grid
|
||||
ctx.strokeStyle = '#121316';
|
||||
ctx.lineWidth = 6;
|
||||
ctx.strokeRect(0, 0, 256, 256);
|
||||
|
||||
// Fluorescent light strip
|
||||
ctx.fillStyle = '#e8f4ff';
|
||||
ctx.fillRect(40, 112, 176, 32);
|
||||
ctx.strokeStyle = '#556677';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(40, 112, 176, 32);
|
||||
|
||||
// Light grill
|
||||
ctx.strokeStyle = '#8899aa';
|
||||
for (let x = 50; x < 210; x += 15) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 112);
|
||||
ctx.lineTo(x, 144);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
return tex;
|
||||
}
|
||||
|
||||
createBarrelTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Toxic green barrel metal
|
||||
ctx.fillStyle = '#3a6624';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
|
||||
// Metal ribs
|
||||
ctx.fillStyle = '#224412';
|
||||
ctx.fillRect(0, 20, 256, 16);
|
||||
ctx.fillRect(0, 120, 256, 16);
|
||||
ctx.fillRect(0, 220, 256, 16);
|
||||
|
||||
// Biohazard / Radiation Symbol
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 70, 30, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 70, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(128, 70, 25, angle - 0.5, angle + 0.5);
|
||||
ctx.lineTo(128, 70);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Toxic Slime leak
|
||||
ctx.fillStyle = '#88ff00';
|
||||
ctx.fillRect(80, 140, 16, 80);
|
||||
ctx.fillRect(160, 160, 22, 60);
|
||||
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// RETRO DOOM SKYBOX
|
||||
// ----------------------------------------------------
|
||||
|
||||
buildSkybox() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 1024;
|
||||
c.height = 512;
|
||||
const ctx = c.getContext('2d');
|
||||
|
||||
// Dark apocalyptic red/purple gradient
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
grad.addColorStop(0, '#100208');
|
||||
grad.addColorStop(0.3, '#4a0814');
|
||||
grad.addColorStop(0.65, '#9e1e12');
|
||||
grad.addColorStop(0.85, '#e04a1b');
|
||||
grad.addColorStop(1, '#ff8833');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
|
||||
// Stars
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (let i = 0; i < 150; i++) {
|
||||
ctx.fillRect(Math.random() * 1024, Math.random() * 250, Math.random() * 2 + 1, Math.random() * 2 + 1);
|
||||
}
|
||||
|
||||
// Burning Hell Moon
|
||||
const moonGrad = ctx.createRadialGradient(750, 140, 10, 750, 140, 60);
|
||||
moonGrad.addColorStop(0, '#ffffff');
|
||||
moonGrad.addColorStop(0.3, '#ffcc44');
|
||||
moonGrad.addColorStop(0.7, '#ff3300');
|
||||
moonGrad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = moonGrad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(750, 140, 60, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Jagged Volcanic Mountains silhouette
|
||||
ctx.fillStyle = '#1c0406';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 512);
|
||||
let curY = 360;
|
||||
for (let x = 0; x <= 1024; x += 32) {
|
||||
curY = 320 + Math.sin(x * 0.02) * 60 + Math.cos(x * 0.05) * 40 + (Math.random() * 20 - 10);
|
||||
ctx.lineTo(x, curY);
|
||||
}
|
||||
ctx.lineTo(1024, 512);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
const skyTexture = new THREE.CanvasTexture(c);
|
||||
skyTexture.mapping = THREE.EquirectangularReflectionMapping;
|
||||
|
||||
// Big Dome Sphere
|
||||
const skyGeo = new THREE.SphereGeometry(400, 32, 24);
|
||||
const skyMat = new THREE.MeshBasicMaterial({
|
||||
map: skyTexture,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false
|
||||
});
|
||||
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
|
||||
this.scene.add(skyMesh);
|
||||
this.skyboxMesh = skyMesh;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// PARTICLE & EXPLOSION ENGINE
|
||||
// ----------------------------------------------------
|
||||
|
||||
spawnBloodBurst(pos, normal = new THREE.Vector3(0, 1, 0), count = 18, color = 0xcc0000) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size = 0.08 + Math.random() * 0.12;
|
||||
const geo = new THREE.BoxGeometry(size, size, size);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: color });
|
||||
const p = new THREE.Mesh(geo, mat);
|
||||
p.position.copy(pos);
|
||||
|
||||
const vel = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 6 + normal.x * 3,
|
||||
Math.random() * 5 + 2,
|
||||
(Math.random() - 0.5) * 6 + normal.z * 3
|
||||
);
|
||||
|
||||
this.scene.add(p);
|
||||
this.particles.push({
|
||||
mesh: p,
|
||||
velocity: vel,
|
||||
gravity: -16,
|
||||
lifetime: 0.6 + Math.random() * 0.4,
|
||||
age: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawnSparks(pos, normal = new THREE.Vector3(0, 1, 0), count = 12) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const size = 0.04 + Math.random() * 0.05;
|
||||
const geo = new THREE.BoxGeometry(size, size, size);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xffcc33 });
|
||||
const p = new THREE.Mesh(geo, mat);
|
||||
p.position.copy(pos);
|
||||
|
||||
const vel = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 8 + normal.x * 4,
|
||||
Math.random() * 6 + 1 + normal.y * 3,
|
||||
(Math.random() - 0.5) * 8 + normal.z * 4
|
||||
);
|
||||
|
||||
this.scene.add(p);
|
||||
this.particles.push({
|
||||
mesh: p,
|
||||
velocity: vel,
|
||||
gravity: -12,
|
||||
lifetime: 0.25 + Math.random() * 0.25,
|
||||
age: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawnExplosion(pos, radius = 3.5, color = 0xff6600) {
|
||||
// 1. Expanding Core Sphere
|
||||
const geo = new THREE.SphereGeometry(0.5, 12, 8);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.9 });
|
||||
const core = new THREE.Mesh(geo, mat);
|
||||
core.position.copy(pos);
|
||||
this.scene.add(core);
|
||||
|
||||
// 2. Dynamic Explosion Flash Light
|
||||
const expLight = new THREE.PointLight(color, 6.0, radius * 4, 1.5);
|
||||
expLight.position.copy(pos);
|
||||
this.scene.add(expLight);
|
||||
|
||||
this.particles.push({
|
||||
mesh: core,
|
||||
light: expLight,
|
||||
isExplosionCore: true,
|
||||
maxRadius: radius,
|
||||
lifetime: 0.45,
|
||||
age: 0
|
||||
});
|
||||
|
||||
// 3. Smoke & Fire Debris
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const size = 0.2 + Math.random() * 0.35;
|
||||
const dGeo = new THREE.BoxGeometry(size, size, size);
|
||||
const dMat = new THREE.MeshBasicMaterial({
|
||||
color: Math.random() > 0.4 ? 0xff4400 : 0x444444,
|
||||
transparent: true,
|
||||
opacity: 0.9
|
||||
});
|
||||
const d = new THREE.Mesh(dGeo, dMat);
|
||||
d.position.copy(pos);
|
||||
|
||||
const vel = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 14,
|
||||
Math.random() * 10 + 2,
|
||||
(Math.random() - 0.5) * 14
|
||||
);
|
||||
|
||||
this.scene.add(d);
|
||||
this.particles.push({
|
||||
mesh: d,
|
||||
velocity: vel,
|
||||
gravity: -8,
|
||||
lifetime: 0.7 + Math.random() * 0.5,
|
||||
age: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawnPlasmaImpact(pos, normal = new THREE.Vector3(0, 1, 0), color = 0x00d4ff) {
|
||||
const light = new THREE.PointLight(color, 3.0, 8, 2);
|
||||
light.position.copy(pos);
|
||||
this.scene.add(light);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const size = 0.08 + Math.random() * 0.08;
|
||||
const geo = new THREE.BoxGeometry(size, size, size);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: color });
|
||||
const p = new THREE.Mesh(geo, mat);
|
||||
p.position.copy(pos);
|
||||
|
||||
const vel = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 7 + normal.x * 3,
|
||||
Math.random() * 5 + 1,
|
||||
(Math.random() - 0.5) * 7 + normal.z * 3
|
||||
);
|
||||
|
||||
this.scene.add(p);
|
||||
this.particles.push({
|
||||
mesh: p,
|
||||
velocity: vel,
|
||||
gravity: -5,
|
||||
lifetime: 0.3 + Math.random() * 0.2,
|
||||
age: 0
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.scene.remove(light);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// UPDATE GAME LOOP
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
// 1. Update animated textures (Toxic Slime)
|
||||
for (let i = 0; i < this.animatedTextures.length; i++) {
|
||||
this.animatedTextures[i].update(delta);
|
||||
}
|
||||
|
||||
// 2. Flickering dynamic lights
|
||||
const t = this.clock.getElapsedTime();
|
||||
for (let i = 0; i < this.dynamicLights.length; i++) {
|
||||
const dl = this.dynamicLights[i];
|
||||
const noise = Math.sin(t * dl.flickerSpeed + dl.phase) * 0.35 + (Math.random() * 0.2 - 0.1);
|
||||
dl.light.intensity = Math.max(0.2, dl.baseIntensity + noise);
|
||||
}
|
||||
|
||||
// 3. Keep Skybox centered on player camera
|
||||
if (this.skyboxMesh && this.camera) {
|
||||
this.skyboxMesh.position.copy(this.camera.position);
|
||||
}
|
||||
|
||||
// 4. Update Particle physics & lifetimes
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i];
|
||||
p.age += delta;
|
||||
|
||||
if (p.age >= p.lifetime) {
|
||||
this.scene.remove(p.mesh);
|
||||
if (p.light) this.scene.remove(p.light);
|
||||
this.particles.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const progress = p.age / p.lifetime;
|
||||
|
||||
if (p.isExplosionCore) {
|
||||
const scale = 1 + progress * (p.maxRadius * 2);
|
||||
p.mesh.scale.set(scale, scale, scale);
|
||||
p.mesh.material.opacity = 1.0 - progress;
|
||||
if (p.light) {
|
||||
p.light.intensity = 6.0 * (1.0 - progress);
|
||||
}
|
||||
} else if (p.velocity) {
|
||||
p.velocity.y += p.gravity * delta;
|
||||
p.mesh.position.addScaledVector(p.velocity, delta);
|
||||
if (p.mesh.material.transparent) {
|
||||
p.mesh.material.opacity = 1.0 - progress;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomEngine = DoomEngine;
|
||||
Executable
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* DOOM 3D - Authentic Retro Status Bar & HUD
|
||||
* LED segmented numbers, animated Doomguy face canvas reacting to HP/pain/godmode,
|
||||
* weapon slot matrix, keycard inventory, ticker notifications, and damage screen flashes.
|
||||
*/
|
||||
|
||||
class DoomHUD {
|
||||
constructor(player) {
|
||||
this.player = player;
|
||||
|
||||
// Container elements
|
||||
this.hudContainer = document.getElementById('doom-hud');
|
||||
this.damageOverlay = document.getElementById('damage-flash');
|
||||
this.ticker = document.getElementById('hud-ticker');
|
||||
this.faceCanvas = document.getElementById('doomguy-face');
|
||||
this.faceCtx = this.faceCanvas ? this.faceCanvas.getContext('2d') : null;
|
||||
|
||||
// Doomguy Face animation states
|
||||
this.faceLookDir = 0; // -1 = left, 0 = forward, 1 = right
|
||||
this.faceTimer = 0;
|
||||
this.painTimer = 0;
|
||||
this.grinTimer = 0;
|
||||
|
||||
// Notification ticker
|
||||
this.tickerTimer = 0;
|
||||
|
||||
// Flash states
|
||||
this.flashAlpha = 0;
|
||||
this.flashColor = 'rgba(255, 0, 0, ';
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.faceCanvas) {
|
||||
this.faceCanvas.width = 64;
|
||||
this.faceCanvas.height = 72;
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(text) {
|
||||
if (!this.ticker) return;
|
||||
this.ticker.innerText = text.toUpperCase();
|
||||
this.ticker.style.opacity = '1';
|
||||
this.tickerTimer = 3.5;
|
||||
}
|
||||
|
||||
triggerDamageFlash(color = 'red') {
|
||||
this.flashAlpha = 0.55;
|
||||
this.painTimer = 0.5;
|
||||
if (color === 'green') {
|
||||
this.flashColor = 'rgba(50, 255, 0, ';
|
||||
} else if (color === 'yellow') {
|
||||
this.flashColor = 'rgba(255, 220, 0, ';
|
||||
} else {
|
||||
this.flashColor = 'rgba(220, 0, 0, ';
|
||||
}
|
||||
}
|
||||
|
||||
triggerGrin() {
|
||||
this.grinTimer = 1.2;
|
||||
}
|
||||
|
||||
showGameOver() {
|
||||
const overlay = document.getElementById('game-over-modal');
|
||||
if (overlay) overlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hideGameOver() {
|
||||
const overlay = document.getElementById('game-over-modal');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// DOOMGUY FACE RENDERING (Dynamic Procedural Pixel Art)
|
||||
// ----------------------------------------------------
|
||||
|
||||
renderDoomguyFace(delta) {
|
||||
if (!this.faceCtx) return;
|
||||
const ctx = this.faceCtx;
|
||||
ctx.clearRect(0, 0, 64, 72);
|
||||
|
||||
const hp = this.player.health;
|
||||
const isDead = this.player.isDead;
|
||||
const godMode = this.player.godMode;
|
||||
|
||||
// Update look direction timer
|
||||
this.faceTimer += delta;
|
||||
if (this.faceTimer > 1.8) {
|
||||
this.faceTimer = 0;
|
||||
const r = Math.random();
|
||||
this.faceLookDir = r < 0.3 ? -1 : (r > 0.7 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (this.painTimer > 0) this.painTimer -= delta;
|
||||
if (this.grinTimer > 0) this.grinTimer -= delta;
|
||||
|
||||
// 1. Background / Skin Base
|
||||
let skinTone = '#d49b78';
|
||||
if (hp <= 40) skinTone = '#c28766';
|
||||
if (hp <= 20) skinTone = '#b07255';
|
||||
if (isDead) skinTone = '#808a70'; // Pale zombie/corpse skin
|
||||
|
||||
// Jaw / Head Contour
|
||||
ctx.fillStyle = skinTone;
|
||||
ctx.fillRect(12, 10, 40, 52);
|
||||
ctx.fillRect(16, 62, 32, 6);
|
||||
|
||||
// Brown Hair & Crewcut
|
||||
ctx.fillStyle = '#4a2c18';
|
||||
ctx.fillRect(10, 4, 44, 14);
|
||||
ctx.fillRect(8, 12, 6, 20);
|
||||
ctx.fillRect(50, 12, 6, 20);
|
||||
|
||||
if (isDead) {
|
||||
// Dead Doomguy: X-eyes or blank white eyes + blood
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fillRect(18, 30, 10, 4);
|
||||
ctx.fillRect(36, 30, 10, 4);
|
||||
|
||||
ctx.fillStyle = '#aa0000';
|
||||
ctx.fillRect(26, 44, 12, 18);
|
||||
ctx.fillRect(16, 52, 32, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Eyes
|
||||
const eyeOffset = this.faceLookDir * 2;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(18, 28, 10, 8);
|
||||
ctx.fillRect(36, 28, 10, 8);
|
||||
|
||||
// Pupils
|
||||
if (godMode) {
|
||||
// Glowing Yellow God Mode Eyes
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.fillRect(18, 28, 10, 8);
|
||||
ctx.fillRect(36, 28, 10, 8);
|
||||
} else {
|
||||
ctx.fillStyle = '#3a6688'; // Blue eyes
|
||||
ctx.fillRect(21 + eyeOffset, 30, 4, 4);
|
||||
ctx.fillRect(39 + eyeOffset, 30, 4, 4);
|
||||
}
|
||||
|
||||
// Eyebrows
|
||||
ctx.fillStyle = '#3a2010';
|
||||
if (this.painTimer > 0) {
|
||||
// Pain / Anguished eyebrows
|
||||
ctx.fillRect(16, 24, 12, 3);
|
||||
ctx.fillRect(36, 24, 12, 3);
|
||||
} else if (this.grinTimer > 0) {
|
||||
// Evil smirk eyebrows
|
||||
ctx.fillRect(16, 26, 12, 3);
|
||||
ctx.fillRect(36, 23, 12, 3);
|
||||
} else {
|
||||
// Normal stern eyebrows
|
||||
ctx.fillRect(16, 25, 12, 3);
|
||||
ctx.fillRect(36, 25, 12, 3);
|
||||
}
|
||||
|
||||
// 3. Nose
|
||||
ctx.fillStyle = '#a66e4e';
|
||||
ctx.fillRect(29, 36, 6, 12);
|
||||
if (hp <= 60) {
|
||||
// Bloody nose
|
||||
ctx.fillStyle = '#aa0000';
|
||||
ctx.fillRect(29, 44, 6, 6);
|
||||
ctx.fillRect(31, 50, 3, 6);
|
||||
}
|
||||
|
||||
// 4. Mouth / Expressions
|
||||
if (this.painTimer > 0) {
|
||||
// Ouch open screaming mouth
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fillRect(24, 52, 16, 10);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(26, 52, 12, 3);
|
||||
} else if (this.grinTimer > 0) {
|
||||
// Evil grin
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fillRect(22, 53, 20, 6);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(24, 54, 16, 3);
|
||||
} else if (hp <= 30) {
|
||||
// Bruised grimace
|
||||
ctx.fillStyle = '#441111';
|
||||
ctx.fillRect(24, 54, 16, 4);
|
||||
} else {
|
||||
// Classic Doomguy smirk
|
||||
ctx.fillStyle = '#6b3e28';
|
||||
ctx.fillRect(24, 54, 16, 4);
|
||||
}
|
||||
|
||||
// 5. Blood wounds for low health
|
||||
if (hp <= 80) {
|
||||
ctx.fillStyle = '#aa0000';
|
||||
ctx.fillRect(44, 18, 4, 8); // Forehead cut
|
||||
}
|
||||
if (hp <= 40) {
|
||||
ctx.fillStyle = '#aa0000';
|
||||
ctx.fillRect(14, 38, 8, 10); // Cheek wound
|
||||
ctx.fillRect(40, 56, 6, 8);
|
||||
}
|
||||
if (hp <= 20) {
|
||||
ctx.fillStyle = '#880000';
|
||||
ctx.fillRect(16, 28, 14, 10); // Black eye
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// UPDATE STATUS BAR METRICS
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
// 1. Ticker fadeout
|
||||
if (this.tickerTimer > 0) {
|
||||
this.tickerTimer -= delta;
|
||||
if (this.tickerTimer <= 0 && this.ticker) {
|
||||
this.ticker.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Damage Screen Flash fadeout
|
||||
if (this.flashAlpha > 0) {
|
||||
this.flashAlpha = Math.max(0, this.flashAlpha - delta * 2.0);
|
||||
if (this.damageOverlay) {
|
||||
this.damageOverlay.style.backgroundColor = `${this.flashColor}${this.flashAlpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Render Animated Doomguy Face
|
||||
this.renderDoomguyFace(delta);
|
||||
|
||||
// 4. Update HUD Numerical Values
|
||||
const currentWeapon = (window.game && window.game.weapons) ? window.game.weapons.current : null;
|
||||
const ammoCount = (currentWeapon && currentWeapon.ammoType) ? this.player.ammo[currentWeapon.ammoType] : '---';
|
||||
|
||||
const ammoElem = document.getElementById('stat-ammo');
|
||||
const hpElem = document.getElementById('stat-health');
|
||||
const armorElem = document.getElementById('stat-armor');
|
||||
|
||||
if (ammoElem) ammoElem.innerText = ammoCount;
|
||||
if (hpElem) hpElem.innerText = `${Math.max(0, this.player.health)}%`;
|
||||
if (armorElem) armorElem.innerText = `${this.player.armor}%`;
|
||||
|
||||
// 5. Update ARMS Numbers (1-7)
|
||||
if (window.game && window.game.weapons) {
|
||||
const weapons = window.game.weapons;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const armSlot = document.getElementById(`arm-slot-${i + 1}`);
|
||||
if (armSlot) {
|
||||
const isUnlocked = weapons.unlocked[i];
|
||||
const isCurrent = (weapons.currentIndex === i);
|
||||
armSlot.className = `arm-num ${isUnlocked ? 'active' : 'inactive'} ${isCurrent ? 'selected' : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Update KEYS Indicators (Blue, Red, Yellow)
|
||||
const blueKeyElem = document.getElementById('key-blue');
|
||||
const redKeyElem = document.getElementById('key-red');
|
||||
const yellowKeyElem = document.getElementById('key-yellow');
|
||||
|
||||
if (blueKeyElem) blueKeyElem.className = `key-slot ${this.player.keysInventory.blue ? 'has-key' : ''}`;
|
||||
if (redKeyElem) redKeyElem.className = `key-slot ${this.player.keysInventory.red ? 'has-key' : ''}`;
|
||||
if (yellowKeyElem) yellowKeyElem.className = `key-slot ${this.player.keysInventory.yellow ? 'has-key' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomHUD = DoomHUD;
|
||||
Executable
+335
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* DOOM 3D - Pickups, Keycards & Explosive Barrels System
|
||||
* 3D rotating items, bobbing animations, and chain reaction explosive toxic barrels.
|
||||
*/
|
||||
|
||||
class DoomItems {
|
||||
constructor(engine, player, sound, map) {
|
||||
this.engine = engine;
|
||||
this.player = player;
|
||||
this.sound = sound;
|
||||
this.map = map;
|
||||
|
||||
this.items = [];
|
||||
this.barrels = [];
|
||||
this.itemTextures = {};
|
||||
|
||||
this.generateItemTextures();
|
||||
this.spawnMapItems();
|
||||
this.spawnMapBarrels();
|
||||
}
|
||||
|
||||
generateItemTextures() {
|
||||
this.itemTextures.medikit = this.createMedikitTexture();
|
||||
this.itemTextures.armorGreen = this.createArmorTexture('#22aa22');
|
||||
this.itemTextures.armorBlue = this.createArmorTexture('#0088ff');
|
||||
this.itemTextures.ammoClip = this.createAmmoTexture('CLIP');
|
||||
this.itemTextures.ammoShells = this.createAmmoTexture('SHELLS');
|
||||
this.itemTextures.ammoRocket = this.createAmmoTexture('ROCKETS');
|
||||
this.itemTextures.ammoCell = this.createAmmoTexture('CELLS');
|
||||
}
|
||||
|
||||
createMedikitTexture() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = '#eeeeee';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
ctx.fillStyle = '#cc0000';
|
||||
// Red cross
|
||||
ctx.fillRect(24, 10, 16, 44);
|
||||
ctx.fillRect(10, 24, 44, 16);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
return tex;
|
||||
}
|
||||
|
||||
createArmorTexture(color) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(8, 8, 48, 48);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(18, 18, 28, 28);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
return tex;
|
||||
}
|
||||
|
||||
createAmmoTexture(label) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = '#3a3a30';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
ctx.fillStyle = '#ddaa00';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText(label, 6, 36);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// SPAWNING ITEMS & KEYCARDS
|
||||
// ----------------------------------------------------
|
||||
|
||||
spawnMapItems() {
|
||||
const spawns = (this.map && this.map.spawns) ? this.map.spawns.items : (window.game && window.game.map ? window.game.map.spawns.items : []);
|
||||
if (!spawns || spawns.length === 0) return;
|
||||
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
this.spawnItem(s.type, s.x, s.y, s.z);
|
||||
}
|
||||
}
|
||||
|
||||
spawnItem(type, x, y, z) {
|
||||
let mesh = null;
|
||||
let light = null;
|
||||
let scale = 1.0;
|
||||
|
||||
if (type.startsWith('key_')) {
|
||||
// 3D Keycard with colored glowing point light
|
||||
const colorName = type.replace('key_', '');
|
||||
let colorHex = 0x0088ff;
|
||||
if (colorName === 'red') colorHex = 0xff1122;
|
||||
if (colorName === 'yellow') colorHex = 0xffbb00;
|
||||
|
||||
const geo = new THREE.BoxGeometry(0.8, 1.2, 0.15);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colorHex,
|
||||
emissive: colorHex,
|
||||
emissiveIntensity: 0.8
|
||||
});
|
||||
mesh = new THREE.Mesh(geo, mat);
|
||||
light = new THREE.PointLight(colorHex, 2.5, 6, 2);
|
||||
light.position.set(x, y + 0.6, z);
|
||||
this.engine.scene.add(light);
|
||||
} else if (type === 'soulsphere') {
|
||||
// Blue glowing soulsphere
|
||||
const geo = new THREE.SphereGeometry(0.6, 16, 16);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x00d4ff,
|
||||
emissive: 0x00d4ff,
|
||||
emissiveIntensity: 0.9,
|
||||
transparent: true,
|
||||
opacity: 0.85
|
||||
});
|
||||
mesh = new THREE.Mesh(geo, mat);
|
||||
light = new THREE.PointLight(0x00d4ff, 3.5, 10, 2);
|
||||
light.position.set(x, y + 0.6, z);
|
||||
this.engine.scene.add(light);
|
||||
} else if (type === 'medikit') {
|
||||
const geo = new THREE.BoxGeometry(0.8, 0.6, 0.8);
|
||||
const mat = new THREE.MeshStandardMaterial({ map: this.itemTextures.medikit });
|
||||
mesh = new THREE.Mesh(geo, mat);
|
||||
} else if (type.startsWith('armor_')) {
|
||||
const isBlue = type.includes('blue');
|
||||
const geo = new THREE.BoxGeometry(0.9, 0.9, 0.9);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
map: isBlue ? this.itemTextures.armorBlue : this.itemTextures.armorGreen,
|
||||
emissive: isBlue ? 0x0044ff : 0x008800,
|
||||
emissiveIntensity: 0.3
|
||||
});
|
||||
mesh = new THREE.Mesh(geo, mat);
|
||||
} else {
|
||||
// Ammo / Weapon box
|
||||
const geo = new THREE.BoxGeometry(0.7, 0.5, 0.7);
|
||||
const mat = new THREE.MeshStandardMaterial({ map: this.itemTextures.ammoClip });
|
||||
mesh = new THREE.Mesh(geo, mat);
|
||||
}
|
||||
|
||||
mesh.position.set(x, y + 0.4, z);
|
||||
mesh.castShadow = true;
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
const itemObj = {
|
||||
type,
|
||||
mesh,
|
||||
light,
|
||||
baseY: y + 0.4,
|
||||
rotationSpeed: 1.5,
|
||||
bobTimer: Math.random() * 5,
|
||||
isCollected: false
|
||||
};
|
||||
|
||||
this.items.push(itemObj);
|
||||
return itemObj;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// EXPLOSIVE TOXIC BARRELS
|
||||
// ----------------------------------------------------
|
||||
|
||||
spawnMapBarrels() {
|
||||
const spawns = (this.map && this.map.spawns) ? this.map.spawns.barrels : (window.game && window.game.map ? window.game.map.spawns.barrels : []);
|
||||
if (!spawns || spawns.length === 0) return;
|
||||
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
this.spawnBarrel(s.x, s.y, s.z);
|
||||
}
|
||||
}
|
||||
|
||||
spawnBarrel(x, y, z) {
|
||||
const geo = new THREE.CylinderGeometry(0.55, 0.55, 1.4, 16);
|
||||
const mat = this.engine.materials.barrel;
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(x, y + 0.7, z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
// Add to map colliders
|
||||
const min = new THREE.Vector3(x - 0.55, y, z - 0.55);
|
||||
const max = new THREE.Vector3(x + 0.55, y + 1.4, z + 0.55);
|
||||
const box = new THREE.Box3(min, max);
|
||||
this.engine.scene.colliders = this.engine.scene.colliders || [];
|
||||
|
||||
const barrelObj = {
|
||||
mesh,
|
||||
box,
|
||||
hp: 20,
|
||||
isExploded: false,
|
||||
takeDamage: (dmg) => this.damageBarrel(barrelObj, dmg)
|
||||
};
|
||||
|
||||
this.barrels.push(barrelObj);
|
||||
return barrelObj;
|
||||
}
|
||||
|
||||
damageBarrel(b, amount) {
|
||||
if (b.isExploded) return;
|
||||
b.hp -= amount;
|
||||
if (b.hp <= 0) {
|
||||
this.explodeBarrel(b);
|
||||
}
|
||||
}
|
||||
|
||||
explodeBarrel(b) {
|
||||
b.isExploded = true;
|
||||
const pos = b.mesh.position;
|
||||
|
||||
this.sound.playExplosion(1.5);
|
||||
this.engine.spawnExplosion(pos, 5.5, 0x88ff00); // Toxic radioactive green blast!
|
||||
|
||||
// Remove mesh
|
||||
this.engine.scene.remove(b.mesh);
|
||||
|
||||
// Area Damage & Chain Reactions
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.applySplashDamage(pos, 180, 5.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ITEM COLLECTION & UPDATE
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
const playerPos = this.player.position;
|
||||
|
||||
for (let i = this.items.length - 1; i >= 0; i--) {
|
||||
const item = this.items[i];
|
||||
if (item.isCollected) continue;
|
||||
|
||||
// Rotation & Bobbing Animation
|
||||
item.bobTimer += delta * 3.0;
|
||||
item.mesh.rotation.y += item.rotationSpeed * delta;
|
||||
item.mesh.position.y = item.baseY + Math.sin(item.bobTimer) * 0.15;
|
||||
if (item.light) {
|
||||
item.light.position.copy(item.mesh.position);
|
||||
}
|
||||
|
||||
// Player proximity pickup check
|
||||
const dist = item.mesh.position.distanceTo(playerPos);
|
||||
if (dist < 1.6) {
|
||||
this.collectItem(item, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectItem(item, index) {
|
||||
let collected = false;
|
||||
const type = item.type;
|
||||
|
||||
if (type === 'medikit') {
|
||||
collected = this.player.heal(25);
|
||||
if (collected) this.sound.playItemPickup('health');
|
||||
} else if (type === 'soulsphere') {
|
||||
collected = this.player.heal(100, true);
|
||||
if (collected) {
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('SOULSPHERE! +100 HP');
|
||||
}
|
||||
} else if (type === 'armor_green') {
|
||||
collected = this.player.addArmor(100);
|
||||
if (collected) this.sound.playItemPickup('armor');
|
||||
} else if (type === 'armor_blue') {
|
||||
collected = this.player.addArmor(200, true);
|
||||
if (collected) this.sound.playItemPickup('armor');
|
||||
} else if (type === 'ammo_clip') {
|
||||
collected = this.player.addAmmo('bullets', 10);
|
||||
if (collected) this.sound.playItemPickup('ammo');
|
||||
} else if (type === 'ammo_box') {
|
||||
collected = this.player.addAmmo('bullets', 50);
|
||||
if (collected) this.sound.playItemPickup('ammo');
|
||||
} else if (type === 'ammo_shells') {
|
||||
collected = this.player.addAmmo('shells', 8);
|
||||
if (collected) this.sound.playItemPickup('ammo');
|
||||
} else if (type === 'ammo_rocket') {
|
||||
collected = this.player.addAmmo('rockets', 5);
|
||||
if (collected) this.sound.playItemPickup('ammo');
|
||||
} else if (type === 'ammo_cell') {
|
||||
collected = this.player.addAmmo('cells', 40);
|
||||
if (collected) this.sound.playItemPickup('ammo');
|
||||
} else if (type === 'shotgun') {
|
||||
this.player.addAmmo('shells', 8);
|
||||
if (window.game && window.game.weapons) window.game.weapons.unlockWeapon(2);
|
||||
collected = true;
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('YOU GOT THE SHOTGUN!');
|
||||
} else if (type === 'chaingun') {
|
||||
this.player.addAmmo('bullets', 40);
|
||||
if (window.game && window.game.weapons) window.game.weapons.unlockWeapon(3);
|
||||
collected = true;
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('YOU GOT THE CHAINGUN!');
|
||||
} else if (type === 'rocket_launcher') {
|
||||
this.player.addAmmo('rockets', 5);
|
||||
if (window.game && window.game.weapons) window.game.weapons.unlockWeapon(4);
|
||||
collected = true;
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('YOU GOT THE ROCKET LAUNCHER!');
|
||||
} else if (type === 'plasma') {
|
||||
this.player.addAmmo('cells', 50);
|
||||
if (window.game && window.game.weapons) window.game.weapons.unlockWeapon(5);
|
||||
collected = true;
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('YOU GOT THE PLASMA RIFLE!');
|
||||
} else if (type === 'bfg') {
|
||||
this.player.addAmmo('cells', 100);
|
||||
if (window.game && window.game.weapons) window.game.weapons.unlockWeapon(6);
|
||||
collected = true;
|
||||
this.sound.playItemPickup('weapon');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification('YOU GOT THE BFG 9000! OH YES.');
|
||||
} else if (type.startsWith('key_')) {
|
||||
const keyColor = type.replace('key_', '');
|
||||
this.player.keysInventory[keyColor] = true;
|
||||
collected = true;
|
||||
this.sound.playItemPickup('key');
|
||||
if (window.game && window.game.hud) window.game.hud.showNotification(`PICKED UP THE ${keyColor.toUpperCase()} KEYCARD`);
|
||||
}
|
||||
|
||||
if (collected) {
|
||||
item.isCollected = true;
|
||||
this.engine.scene.remove(item.mesh);
|
||||
if (item.light) this.engine.scene.remove(item.light);
|
||||
this.items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomItems = DoomItems;
|
||||
+6
File diff suppressed because one or more lines are too long
Executable
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* DOOM 3D - Master Game Manager & Application Entry Point
|
||||
* Orchestrates loop, state transitions, settings, audio sync, and rendering.
|
||||
*/
|
||||
|
||||
class DoomGame {
|
||||
constructor() {
|
||||
this.container = document.getElementById('game-container');
|
||||
this.state = 'start'; // 'start', 'playing', 'paused', 'gameover'
|
||||
|
||||
this.sound = new SoundSystem();
|
||||
this.engine = new DoomEngine(this.container);
|
||||
this.map = new DoomMap(this.engine, this.sound);
|
||||
this.player = new DoomPlayer(this.engine, this.sound);
|
||||
this.weapons = new DoomWeapons(this.engine, this.player, this.sound);
|
||||
this.enemies = new DoomEnemies(this.engine, this.player, this.sound, this.map);
|
||||
this.items = new DoomItems(this.engine, this.player, this.sound, this.map);
|
||||
this.hud = new DoomHUD(this.player);
|
||||
this.mobile = new DoomMobileControls(this.player);
|
||||
|
||||
this.lastTime = performance.now();
|
||||
this.crtFilterEnabled = true;
|
||||
|
||||
this.setupUI();
|
||||
this.loop = this.loop.bind(this);
|
||||
requestAnimationFrame(this.loop);
|
||||
}
|
||||
|
||||
setupUI() {
|
||||
// Start Game Button
|
||||
const startBtn = document.getElementById('btn-start-game');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', () => {
|
||||
this.startGame();
|
||||
});
|
||||
}
|
||||
|
||||
// Restart Game Button
|
||||
const restartBtn = document.getElementById('btn-restart');
|
||||
if (restartBtn) {
|
||||
restartBtn.addEventListener('click', () => {
|
||||
this.restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
// Pause Menu Resume Button
|
||||
const resumeBtn = document.getElementById('btn-resume');
|
||||
if (resumeBtn) {
|
||||
resumeBtn.addEventListener('click', () => {
|
||||
this.resumeGame();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings Modal Open/Close
|
||||
const settingsBtn = document.getElementById('btn-settings');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const closeSettingsBtn = document.getElementById('btn-close-settings');
|
||||
|
||||
if (settingsBtn && settingsModal) {
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
settingsModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
if (closeSettingsBtn && settingsModal) {
|
||||
closeSettingsBtn.addEventListener('click', () => {
|
||||
settingsModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Settings Sliders
|
||||
const sfxSlider = document.getElementById('slider-sfx');
|
||||
const musicSlider = document.getElementById('slider-music');
|
||||
const sensSlider = document.getElementById('slider-sens');
|
||||
const crtCheck = document.getElementById('toggle-crt');
|
||||
|
||||
if (sfxSlider) {
|
||||
sfxSlider.addEventListener('input', (e) => {
|
||||
this.sound.setSfxVolume(parseFloat(e.target.value));
|
||||
});
|
||||
}
|
||||
if (musicSlider) {
|
||||
musicSlider.addEventListener('input', (e) => {
|
||||
this.sound.setMusicVolume(parseFloat(e.target.value));
|
||||
});
|
||||
}
|
||||
if (sensSlider) {
|
||||
sensSlider.addEventListener('input', (e) => {
|
||||
this.player.mouseSensitivity = parseFloat(e.target.value) * 0.002;
|
||||
});
|
||||
}
|
||||
if (crtCheck) {
|
||||
crtCheck.addEventListener('change', (e) => {
|
||||
this.toggleCRTFilter(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// ESC key to toggle pause
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Escape') {
|
||||
if (this.state === 'playing') {
|
||||
this.pauseGame();
|
||||
} else if (this.state === 'paused') {
|
||||
this.resumeGame();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startGame() {
|
||||
this.state = 'playing';
|
||||
const startOverlay = document.getElementById('start-screen');
|
||||
if (startOverlay) startOverlay.classList.add('hidden');
|
||||
|
||||
this.sound.init();
|
||||
this.player.requestPointerLock();
|
||||
this.hud.showNotification('ENTER THE TECH BASE. RIP AND TEAR!');
|
||||
}
|
||||
|
||||
pauseGame() {
|
||||
this.state = 'paused';
|
||||
this.player.exitPointerLock();
|
||||
const pauseOverlay = document.getElementById('pause-menu');
|
||||
if (pauseOverlay) pauseOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
resumeGame() {
|
||||
this.state = 'playing';
|
||||
const pauseOverlay = document.getElementById('pause-menu');
|
||||
if (pauseOverlay) pauseOverlay.classList.add('hidden');
|
||||
this.player.requestPointerLock();
|
||||
}
|
||||
|
||||
restartGame() {
|
||||
this.hud.hideGameOver();
|
||||
this.player.respawn(this.map.spawns.player);
|
||||
this.state = 'playing';
|
||||
this.player.requestPointerLock();
|
||||
this.hud.showNotification('RESPAWNED! GET READY TO FIGHT.');
|
||||
}
|
||||
|
||||
toggleCRTFilter(enabled) {
|
||||
this.crtFilterEnabled = enabled;
|
||||
const scanlineElem = document.getElementById('crt-scanlines');
|
||||
if (scanlineElem) {
|
||||
if (enabled) scanlineElem.classList.remove('hidden');
|
||||
else scanlineElem.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MAIN GAME LOOP
|
||||
// ----------------------------------------------------
|
||||
|
||||
loop(currentTime) {
|
||||
requestAnimationFrame(this.loop);
|
||||
|
||||
const delta = Math.min(0.1, (currentTime - this.lastTime) / 1000.0);
|
||||
this.lastTime = currentTime;
|
||||
|
||||
if (this.state === 'playing') {
|
||||
// Update all game subsystems
|
||||
this.player.update(delta);
|
||||
this.map.update(delta);
|
||||
this.weapons.update(delta);
|
||||
this.enemies.update(delta);
|
||||
this.items.update(delta);
|
||||
this.engine.update(delta);
|
||||
this.hud.update(delta);
|
||||
}
|
||||
|
||||
// Always render 3D Scene
|
||||
this.engine.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Global bootstrap
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.game = new DoomGame();
|
||||
});
|
||||
Executable
+458
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* DOOM 3D - Multi-Level 3D Map & Architecture
|
||||
* Multi-story buildings, stairs, ramps, catwalks, toxic slime pits,
|
||||
* elevators, flickering lights, and keycard-locked blast doors.
|
||||
*/
|
||||
|
||||
class DoomMap {
|
||||
constructor(engine, sound) {
|
||||
this.engine = engine;
|
||||
this.sound = sound;
|
||||
|
||||
// Level Geometry Colliders
|
||||
this.colliders = [];
|
||||
this.slimeZones = [];
|
||||
this.doors = [];
|
||||
this.elevators = [];
|
||||
this.spawns = {
|
||||
player: new THREE.Vector3(0, 0, 0),
|
||||
enemies: [],
|
||||
items: [],
|
||||
barrels: []
|
||||
};
|
||||
|
||||
this.buildMap();
|
||||
}
|
||||
|
||||
getColliders() {
|
||||
return this.colliders;
|
||||
}
|
||||
|
||||
isInSlimePit(pos) {
|
||||
for (let i = 0; i < this.slimeZones.length; i++) {
|
||||
const sz = this.slimeZones[i];
|
||||
if (pos.x >= sz.min.x && pos.x <= sz.max.x &&
|
||||
pos.z >= sz.min.z && pos.z <= sz.max.z &&
|
||||
pos.y >= sz.min.y - 0.5 && pos.y <= sz.max.y + 1.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to add Box Mesh + Collider Box
|
||||
addBlock(x, y, z, w, h, d, material, castShadow = true, receiveShadow = true) {
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
const mesh = new THREE.Mesh(geo, material);
|
||||
mesh.position.set(x, y + h / 2, z);
|
||||
mesh.castShadow = castShadow;
|
||||
mesh.receiveShadow = receiveShadow;
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
// AABB collider
|
||||
const min = new THREE.Vector3(x - w / 2, y, z - d / 2);
|
||||
const max = new THREE.Vector3(x + w / 2, y + h, z + d / 2);
|
||||
const box = new THREE.Box3(min, max);
|
||||
this.colliders.push(box);
|
||||
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// Helper for Stairs (multiple small step boxes)
|
||||
addStaircase(startX, startY, startZ, width, totalHeight, depth, numSteps, dir = 'z') {
|
||||
const stepH = totalHeight / numSteps;
|
||||
const stepD = depth / numSteps;
|
||||
|
||||
for (let i = 0; i < numSteps; i++) {
|
||||
const h = stepH * (i + 1);
|
||||
let curX = startX;
|
||||
let curY = startY;
|
||||
let curZ = startZ;
|
||||
|
||||
if (dir === 'z') {
|
||||
curZ = startZ + (i * stepD);
|
||||
this.addBlock(curX, curY, curZ, width, h, stepD + 0.1, this.engine.materials.metal);
|
||||
} else if (dir === '-z') {
|
||||
curZ = startZ - (i * stepD);
|
||||
this.addBlock(curX, curY, curZ, width, h, stepD + 0.1, this.engine.materials.metal);
|
||||
} else if (dir === 'x') {
|
||||
curX = startX + (i * stepD);
|
||||
this.addBlock(curX, curY, curZ, stepD + 0.1, h, width, this.engine.materials.metal);
|
||||
} else if (dir === '-x') {
|
||||
curX = startX - (i * stepD);
|
||||
this.addBlock(curX, curY, curZ, stepD + 0.1, h, width, this.engine.materials.metal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// LEVEL CONSTRUCTION (Inspired by E1M1 & E1M2)
|
||||
// ----------------------------------------------------
|
||||
|
||||
buildMap() {
|
||||
const mat = this.engine.materials;
|
||||
|
||||
// ==========================================
|
||||
// 1. STARTING ROOM (Hangar / Briefing Room)
|
||||
// ==========================================
|
||||
// Floor
|
||||
this.addBlock(0, -1, 0, 24, 1, 24, mat.metal);
|
||||
// Ceiling
|
||||
this.addBlock(0, 7, 0, 24, 1, 24, mat.ceiling);
|
||||
// North Wall (with door opening in middle)
|
||||
this.addBlock(-8, 0, -12, 8, 7, 2, mat.tech);
|
||||
this.addBlock(8, 0, -12, 8, 7, 2, mat.tech);
|
||||
this.addBlock(0, 5, -12, 8, 2, 2, mat.tech); // door lintel
|
||||
// South Wall
|
||||
this.addBlock(0, 0, 12, 24, 7, 2, mat.brick);
|
||||
// West Wall
|
||||
this.addBlock(-12, 0, 0, 2, 7, 24, mat.tech);
|
||||
// East Wall
|
||||
this.addBlock(12, 0, 0, 2, 7, 24, mat.metal);
|
||||
|
||||
// Starting Room Pillars & Consoles
|
||||
this.addBlock(-5, 0, -4, 2, 7, 2, mat.hex);
|
||||
this.addBlock(5, 0, -4, 2, 7, 2, mat.hex);
|
||||
this.addBlock(0, 0, 6, 6, 1.4, 2, mat.tech);
|
||||
|
||||
// Flickering light in start room
|
||||
this.engine.addFlickeringLight(0, 5.5, 0, 0xffeedd, 2.2, 18);
|
||||
|
||||
// Spawn points in start room
|
||||
this.spawns.player.set(0, 0, 6);
|
||||
this.spawns.items.push({ type: 'medikit', x: -9, y: 0, z: -8 });
|
||||
this.spawns.items.push({ type: 'armor_green', x: 9, y: 0, z: -8 });
|
||||
this.spawns.items.push({ type: 'shotgun', x: 0, y: 0, z: 2 });
|
||||
this.spawns.items.push({ type: 'ammo_shells', x: -2, y: 0, z: 2 });
|
||||
this.spawns.items.push({ type: 'ammo_shells', x: 2, y: 0, z: 2 });
|
||||
|
||||
// Door 1: Start Room to Main Corridor (Sliding blast door)
|
||||
this.createDoor(0, 0, -12, 7.8, 5, 1.2, 'normal', null);
|
||||
|
||||
// ==========================================
|
||||
// 2. CENTRAL CORRIDOR & CROSSROAD
|
||||
// ==========================================
|
||||
// Floor & Ceiling
|
||||
this.addBlock(0, -1, -26, 12, 1, 26, mat.grate);
|
||||
this.addBlock(0, 7, -26, 12, 1, 26, mat.ceiling);
|
||||
// Walls
|
||||
this.addBlock(-6, 0, -26, 1, 7, 26, mat.metal);
|
||||
this.addBlock(6, 0, -26, 1, 7, 26, mat.metal);
|
||||
this.engine.addFlickeringLight(0, 5.5, -24, 0xff8833, 2.0, 16);
|
||||
|
||||
this.spawns.enemies.push({ type: 'zombie', x: -2, y: 0, z: -20 });
|
||||
this.spawns.enemies.push({ type: 'zombie', x: 2, y: 0, z: -24 });
|
||||
this.spawns.items.push({ type: 'ammo_clip', x: 4, y: 0, z: -28 });
|
||||
|
||||
// ==========================================
|
||||
// 3. TOXIC SLIME ARENA & COURTYARD (Outdoor)
|
||||
// ==========================================
|
||||
// Floor around slime
|
||||
this.addBlock(-25, -1, -65, 60, 1, 50, mat.brick);
|
||||
// Toxic Slime Basin (recessed pool in middle)
|
||||
this.addBlock(-25, -2.2, -65, 34, 1, 26, mat.slime);
|
||||
// Slime Hazard Zone definition
|
||||
this.slimeZones.push(new THREE.Box3(
|
||||
new THREE.Vector3(-42, -2.5, -78),
|
||||
new THREE.Vector3(-8, 0.2, -52)
|
||||
));
|
||||
|
||||
// Stepping stone platforms across the slime river
|
||||
this.addBlock(-32, -0.8, -65, 4, 1.2, 4, mat.hex);
|
||||
this.addBlock(-25, -0.8, -65, 4, 1.2, 4, mat.hex);
|
||||
this.addBlock(-18, -0.8, -65, 4, 1.2, 4, mat.hex);
|
||||
|
||||
// Slime pit lighting
|
||||
this.engine.addFlickeringLight(-25, 2.0, -65, 0x33ff22, 3.5, 30);
|
||||
|
||||
// Courtyard Perimeter Walls
|
||||
this.addBlock(-55, 0, -65, 2, 12, 50, mat.brick); // West high wall
|
||||
this.addBlock(5, 0, -65, 2, 12, 50, mat.brick); // East high wall
|
||||
this.addBlock(-25, 0, -90, 60, 12, 2, mat.brick); // North high wall
|
||||
|
||||
// Blue Keycard Island on the slime pit!
|
||||
this.spawns.items.push({ type: 'key_blue', x: -25, y: 0.6, z: -65 });
|
||||
this.spawns.enemies.push({ type: 'imp', x: -35, y: 0, z: -80 });
|
||||
this.spawns.enemies.push({ type: 'imp', x: -15, y: 0, z: -80 });
|
||||
this.spawns.enemies.push({ type: 'shotgunner', x: -25, y: 0, z: -84 });
|
||||
this.spawns.barrels.push({ x: -10, y: 0, z: -75 });
|
||||
this.spawns.barrels.push({ x: -8, y: 0, z: -75 });
|
||||
this.spawns.barrels.push({ x: -38, y: 0, z: -75 });
|
||||
|
||||
// ==========================================
|
||||
// 4. MULTI-STORY TECH TOWER & CATWALKS (Level 2 & 3)
|
||||
// ==========================================
|
||||
// Staircase leading from Courtyard (y=0) to 2nd Floor Catwalk (y=6)
|
||||
this.addStaircase(-2, 0, -45, 6, 6, 16, 15, '-z');
|
||||
|
||||
// 2nd Floor Catwalk (y=6)
|
||||
this.addBlock(20, 5, -65, 28, 1, 50, mat.grate);
|
||||
this.addBlock(20, 15, -65, 28, 1, 50, mat.ceiling);
|
||||
// Catwalk Railing/Balcony facing courtyard (with opening for elevator at z=-45)
|
||||
this.addBlock(6, 6, -72, 1, 1.8, 34, mat.metal);
|
||||
this.addBlock(6, 6, -37, 1, 1.8, 16, mat.metal);
|
||||
// East Wall
|
||||
this.addBlock(34, 6, -65, 2, 9, 50, mat.tech);
|
||||
// North Wall
|
||||
this.addBlock(20, 6, -90, 30, 9, 2, mat.tech);
|
||||
|
||||
// Catwalk Monsters & Items
|
||||
this.spawns.enemies.push({ type: 'shotgunner', x: 18, y: 6, z: -55 });
|
||||
this.spawns.enemies.push({ type: 'pinky', x: 22, y: 6, z: -75 });
|
||||
this.spawns.items.push({ type: 'chaingun', x: 28, y: 6, z: -80 });
|
||||
this.spawns.items.push({ type: 'ammo_box', x: 28, y: 6, z: -76 });
|
||||
|
||||
// Catwalk Light
|
||||
this.engine.addFlickeringLight(20, 11, -65, 0x4488ff, 2.8, 22);
|
||||
|
||||
// ==========================================
|
||||
// 5. BLUE KEYCARD ARMORED VAULT (Secret Armory)
|
||||
// ==========================================
|
||||
// Door at North of Catwalk (x=20, z=-90)
|
||||
this.createDoor(20, 6, -90, 8, 6, 1.2, 'blue', 'blue');
|
||||
|
||||
// Secret Armory Room behind Blue Door
|
||||
this.addBlock(20, 5, -105, 20, 1, 28, mat.hex);
|
||||
this.addBlock(20, 15, -105, 20, 1, 28, mat.ceiling);
|
||||
this.addBlock(10, 6, -105, 1, 9, 28, mat.tech);
|
||||
this.addBlock(30, 6, -105, 1, 9, 28, mat.tech);
|
||||
this.addBlock(20, 6, -119, 20, 9, 2, mat.tech);
|
||||
|
||||
// Armory Rewards
|
||||
this.spawns.items.push({ type: 'plasma', x: 20, y: 6, z: -110 });
|
||||
this.spawns.items.push({ type: 'ammo_cell', x: 16, y: 6, z: -110 });
|
||||
this.spawns.items.push({ type: 'ammo_cell', x: 24, y: 6, z: -110 });
|
||||
this.spawns.items.push({ type: 'soulsphere', x: 20, y: 6, z: -115 });
|
||||
this.spawns.items.push({ type: 'key_red', x: 20, y: 6, z: -105 }); // Red Keycard inside armory!
|
||||
|
||||
this.engine.addFlickeringLight(20, 12, -105, 0x00aaff, 3.2, 18);
|
||||
|
||||
// ==========================================
|
||||
// 6. NUCLEAR REACTOR CHAMBER (Guarded by Red Door)
|
||||
// ==========================================
|
||||
// Door leading from West Courtyard (x=-55, z=-65)
|
||||
this.createDoor(-55, 0, -65, 1.2, 6, 8, 'red', 'red');
|
||||
|
||||
// Nuclear Reactor Room
|
||||
this.addBlock(-80, -1, -65, 48, 1, 48, mat.metal);
|
||||
this.addBlock(-80, 16, -65, 48, 1, 48, mat.ceiling);
|
||||
this.addBlock(-104, 0, -65, 2, 16, 48, mat.brick);
|
||||
this.addBlock(-80, 0, -89, 48, 16, 2, mat.brick);
|
||||
this.addBlock(-80, 0, -41, 48, 16, 2, mat.brick);
|
||||
|
||||
// Central Glowing Reactor Core
|
||||
this.addBlock(-80, 0, -65, 10, 12, 10, mat.slime);
|
||||
this.slimeZones.push(new THREE.Box3(
|
||||
new THREE.Vector3(-86, -1, -71),
|
||||
new THREE.Vector3(-74, 1, -59)
|
||||
));
|
||||
this.engine.addFlickeringLight(-80, 7, -65, 0x00ff22, 4.5, 35);
|
||||
|
||||
// Reactor Boss Monsters & Flying Cacodemons!
|
||||
this.spawns.enemies.push({ type: 'caco', x: -75, y: 5, z: -60 });
|
||||
this.spawns.enemies.push({ type: 'caco', x: -85, y: 7, z: -70 });
|
||||
this.spawns.enemies.push({ type: 'pinky', x: -90, y: 0, z: -65 });
|
||||
this.spawns.enemies.push({ type: 'shotgunner', x: -70, y: 0, z: -50 });
|
||||
|
||||
// Reactor Loot & Yellow Keycard
|
||||
this.spawns.items.push({ type: 'rocket_launcher', x: -92, y: 0, z: -65 });
|
||||
this.spawns.items.push({ type: 'ammo_rocket', x: -92, y: 0, z: -61 });
|
||||
this.spawns.items.push({ type: 'ammo_rocket', x: -92, y: 0, z: -69 });
|
||||
this.spawns.items.push({ type: 'armor_blue', x: -80, y: 0, z: -80 });
|
||||
this.spawns.items.push({ type: 'key_yellow', x: -80, y: 0, z: -50 });
|
||||
|
||||
// ==========================================
|
||||
// 7. FINAL BOSS ARENA / EXIT VAULT (Yellow Door)
|
||||
// ==========================================
|
||||
// Yellow Blast Door at North of Reactor room (x=-80, z=-89)
|
||||
this.createDoor(-80, 0, -89, 8, 6, 1.2, 'yellow', 'yellow');
|
||||
|
||||
// BFG Sanctum & Exit Chamber
|
||||
this.addBlock(-80, -1, -115, 32, 1, 48, mat.brick);
|
||||
this.addBlock(-80, 18, -115, 32, 1, 48, mat.ceiling);
|
||||
this.addBlock(-96, 0, -115, 2, 18, 48, mat.brick);
|
||||
this.addBlock(-64, 0, -115, 2, 18, 48, mat.brick);
|
||||
this.addBlock(-80, 0, -139, 32, 18, 2, mat.tech);
|
||||
|
||||
// BFG 9000 Altar
|
||||
this.addBlock(-80, 0, -125, 4, 1.5, 4, mat.hex);
|
||||
this.spawns.items.push({ type: 'bfg', x: -80, y: 1.8, z: -125 });
|
||||
this.spawns.items.push({ type: 'ammo_cell', x: -75, y: 0, z: -125 });
|
||||
this.spawns.items.push({ type: 'ammo_cell', x: -85, y: 0, z: -125 });
|
||||
this.spawns.items.push({ type: 'soulsphere', x: -80, y: 0, z: -132 });
|
||||
|
||||
// Final Arena Demonic Horde
|
||||
this.spawns.enemies.push({ type: 'caco', x: -80, y: 6, z: -120 });
|
||||
this.spawns.enemies.push({ type: 'pinky', x: -75, y: 0, z: -115 });
|
||||
this.spawns.enemies.push({ type: 'pinky', x: -85, y: 0, z: -115 });
|
||||
this.spawns.enemies.push({ type: 'imp', x: -72, y: 0, z: -130 });
|
||||
this.spawns.enemies.push({ type: 'imp', x: -88, y: 0, z: -130 });
|
||||
|
||||
this.engine.addFlickeringLight(-80, 10, -125, 0xff0022, 4.0, 30);
|
||||
|
||||
// Motorized Elevator Platform in Courtyard (moves between ground y=0 and catwalk y=6)
|
||||
this.createElevator(12, 0, -45, 6, 6, -0.6, 5.2, 2.5);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// INTERACTIVE DOORS
|
||||
// ----------------------------------------------------
|
||||
|
||||
createDoor(x, y, z, w, h, d, type = 'normal', requiredKey = null) {
|
||||
let mat = this.engine.materials.doorRegular;
|
||||
if (type === 'blue') mat = this.engine.materials.doorBlue;
|
||||
if (type === 'red') mat = this.engine.materials.doorRed;
|
||||
if (type === 'yellow') mat = this.engine.materials.doorYellow;
|
||||
|
||||
const geo = new THREE.BoxGeometry(w, h, d);
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(x, y + h / 2, z);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
const min = new THREE.Vector3(x - w / 2, y, z - d / 2);
|
||||
const max = new THREE.Vector3(x + w / 2, y + h, z + d / 2);
|
||||
const box = new THREE.Box3(min, max);
|
||||
this.colliders.push(box);
|
||||
|
||||
const doorObj = {
|
||||
mesh,
|
||||
box,
|
||||
x, y, z, w, h, d,
|
||||
type,
|
||||
requiredKey,
|
||||
state: 'closed', // 'closed', 'opening', 'open', 'closing'
|
||||
openHeight: h * 0.95,
|
||||
currentOffset: 0,
|
||||
autoCloseTimer: 0
|
||||
};
|
||||
|
||||
this.doors.push(doorObj);
|
||||
return doorObj;
|
||||
}
|
||||
|
||||
interactWithDoors(playerPos, keysInventory) {
|
||||
for (let i = 0; i < this.doors.length; i++) {
|
||||
const door = this.doors[i];
|
||||
const dist = playerPos.distanceTo(door.mesh.position);
|
||||
if (dist < 5.5) {
|
||||
if (door.requiredKey && !keysInventory[door.requiredKey]) {
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.showNotification(`YOU NEED THE ${door.requiredKey.toUpperCase()} KEY`);
|
||||
}
|
||||
if (this.sound) this.sound.playSlimeHurt();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (door.state === 'closed' || door.state === 'closing') {
|
||||
door.state = 'opening';
|
||||
if (this.sound) this.sound.playDoor(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MOTORIZED ELEVATOR PLATFORMS
|
||||
// ----------------------------------------------------
|
||||
|
||||
createElevator(x, y, z, w, d, minY, maxY, speed = 2.0) {
|
||||
const geo = new THREE.BoxGeometry(w, 0.8, d);
|
||||
const mesh = new THREE.Mesh(geo, this.engine.materials.hex);
|
||||
mesh.position.set(x, y + 0.4, z);
|
||||
mesh.receiveShadow = true;
|
||||
this.engine.scene.add(mesh);
|
||||
|
||||
const min = new THREE.Vector3(x - w / 2, y, z - d / 2);
|
||||
const max = new THREE.Vector3(x + w / 2, y + 0.8, z + d / 2);
|
||||
const box = new THREE.Box3(min, max);
|
||||
this.colliders.push(box);
|
||||
|
||||
this.elevators.push({
|
||||
mesh,
|
||||
box,
|
||||
x, z, w, d,
|
||||
currentY: y,
|
||||
minY,
|
||||
maxY,
|
||||
speed,
|
||||
dir: 1, // 1 = up, -1 = down
|
||||
pauseTimer: 0
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// MAP GAME LOOP UPDATE
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
// 1. Update Doors
|
||||
for (let i = 0; i < this.doors.length; i++) {
|
||||
const door = this.doors[i];
|
||||
if (door.state === 'opening') {
|
||||
door.currentOffset += delta * 4.0;
|
||||
if (door.currentOffset >= door.openHeight) {
|
||||
door.currentOffset = door.openHeight;
|
||||
door.state = 'open';
|
||||
door.autoCloseTimer = 5.0; // stay open for 5 sec
|
||||
}
|
||||
door.mesh.position.y = (door.y + door.h / 2) + door.currentOffset;
|
||||
// Move collider out of way
|
||||
door.box.min.y = door.y + door.currentOffset;
|
||||
door.box.max.y = door.y + door.h + door.currentOffset;
|
||||
} else if (door.state === 'open') {
|
||||
door.autoCloseTimer -= delta;
|
||||
if (door.autoCloseTimer <= 0) {
|
||||
door.state = 'closing';
|
||||
if (this.sound) this.sound.playDoor(false);
|
||||
}
|
||||
} else if (door.state === 'closing') {
|
||||
door.currentOffset -= delta * 4.0;
|
||||
if (door.currentOffset <= 0) {
|
||||
door.currentOffset = 0;
|
||||
door.state = 'closed';
|
||||
}
|
||||
door.mesh.position.y = (door.y + door.h / 2) + door.currentOffset;
|
||||
door.box.min.y = door.y + door.currentOffset;
|
||||
door.box.max.y = door.y + door.h + door.currentOffset;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update Motorized Elevators
|
||||
for (let i = 0; i < this.elevators.length; i++) {
|
||||
const el = this.elevators[i];
|
||||
if (el.pauseTimer > 0) {
|
||||
el.pauseTimer -= delta;
|
||||
continue;
|
||||
}
|
||||
|
||||
el.currentY += el.dir * el.speed * delta;
|
||||
if (el.currentY >= el.maxY) {
|
||||
el.currentY = el.maxY;
|
||||
el.dir = -1;
|
||||
el.pauseTimer = 3.0;
|
||||
} else if (el.currentY <= el.minY) {
|
||||
el.currentY = el.minY;
|
||||
el.dir = 1;
|
||||
el.pauseTimer = 3.0;
|
||||
}
|
||||
|
||||
el.mesh.position.y = el.currentY + 0.4;
|
||||
el.box.min.y = el.currentY;
|
||||
el.box.max.y = el.currentY + 0.8;
|
||||
|
||||
// Lift player if player is on elevator
|
||||
if (window.game && window.game.player) {
|
||||
const p = window.game.player;
|
||||
if (p.position.x >= el.box.min.x && p.position.x <= el.box.max.x &&
|
||||
p.position.z >= el.box.min.z && p.position.z <= el.box.max.z &&
|
||||
Math.abs(p.position.y - el.box.max.y) < 0.25) {
|
||||
p.position.y = el.box.max.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomMap = DoomMap;
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* DOOM 3D - Mobile Touch Controls
|
||||
* Virtual thumbstick for movement, swipe aiming, and action buttons.
|
||||
*/
|
||||
|
||||
class DoomMobileControls {
|
||||
constructor(player) {
|
||||
this.player = player;
|
||||
this.container = document.getElementById('mobile-controls');
|
||||
this.joystickBase = document.getElementById('joystick-base');
|
||||
this.joystickKnob = document.getElementById('joystick-knob');
|
||||
this.lookZone = document.getElementById('mobile-look-zone');
|
||||
|
||||
this.touchMoveId = null;
|
||||
this.touchLookId = null;
|
||||
|
||||
this.joystickCenter = { x: 0, y: 0 };
|
||||
this.maxRadius = 50;
|
||||
|
||||
this.lastLookX = 0;
|
||||
this.lastLookY = 0;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
if (isTouch && this.container) {
|
||||
this.container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.setupJoystick();
|
||||
this.setupLookZone();
|
||||
this.setupActionButtons();
|
||||
}
|
||||
|
||||
setupJoystick() {
|
||||
if (!this.joystickBase || !this.joystickKnob) return;
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (this.touchMoveId === null) {
|
||||
this.touchMoveId = touch.identifier;
|
||||
const rect = this.joystickBase.getBoundingClientRect();
|
||||
this.joystickCenter.x = rect.left + rect.width / 2;
|
||||
this.joystickCenter.y = rect.top + rect.height / 2;
|
||||
this.handleJoystickMove(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (touch.identifier === this.touchMoveId) {
|
||||
this.handleJoystickMove(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (touch.identifier === this.touchMoveId) {
|
||||
this.touchMoveId = null;
|
||||
this.joystickKnob.style.transform = 'translate(-50%, -50%)';
|
||||
this.player.keys.forward = false;
|
||||
this.player.keys.backward = false;
|
||||
this.player.keys.left = false;
|
||||
this.player.keys.right = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.joystickBase.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', onTouchEnd, { passive: false });
|
||||
window.addEventListener('touchcancel', onTouchEnd, { passive: false });
|
||||
}
|
||||
|
||||
handleJoystickMove(clientX, clientY) {
|
||||
const dx = clientX - this.joystickCenter.x;
|
||||
const dy = clientY - this.joystickCenter.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
const clampedDist = Math.min(dist, this.maxRadius);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const knobX = Math.cos(angle) * clampedDist;
|
||||
const knobY = Math.sin(angle) * clampedDist;
|
||||
|
||||
this.joystickKnob.style.transform = `translate(calc(-50% + ${knobX}px), calc(-50% + ${knobY}px))`;
|
||||
|
||||
// Normalize inputs (-1 to 1)
|
||||
const normX = knobX / this.maxRadius;
|
||||
const normY = knobY / this.maxRadius;
|
||||
|
||||
const threshold = 0.25;
|
||||
this.player.keys.forward = normY < -threshold;
|
||||
this.player.keys.backward = normY > threshold;
|
||||
this.player.keys.left = normX < -threshold;
|
||||
this.player.keys.right = normX > threshold;
|
||||
}
|
||||
|
||||
setupLookZone() {
|
||||
if (!this.lookZone) return;
|
||||
|
||||
this.lookZone.addEventListener('touchstart', (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (this.touchLookId === null) {
|
||||
this.touchLookId = touch.identifier;
|
||||
this.lastLookX = touch.clientX;
|
||||
this.lastLookY = touch.clientY;
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('touchmove', (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (touch.identifier === this.touchLookId) {
|
||||
const dx = touch.clientX - this.lastLookX;
|
||||
const dy = touch.clientY - this.lastLookY;
|
||||
|
||||
this.lastLookX = touch.clientX;
|
||||
this.lastLookY = touch.clientY;
|
||||
|
||||
// Apply rotation to player camera
|
||||
const sensitivity = this.player.mouseSensitivity * 1.5;
|
||||
this.player.euler.y -= dx * sensitivity;
|
||||
this.player.euler.x -= dy * sensitivity;
|
||||
|
||||
const maxPitch = Math.PI / 2 - 0.02;
|
||||
this.player.euler.x = Math.max(-maxPitch, Math.min(maxPitch, this.player.euler.x));
|
||||
this.player.camera.quaternion.setFromEuler(this.player.euler);
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
const endLook = (e) => {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const touch = e.changedTouches[i];
|
||||
if (touch.identifier === this.touchLookId) {
|
||||
this.touchLookId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('touchend', endLook, { passive: false });
|
||||
window.addEventListener('touchcancel', endLook, { passive: false });
|
||||
}
|
||||
|
||||
setupActionButtons() {
|
||||
const btnFire = document.getElementById('btn-mobile-fire');
|
||||
const btnJump = document.getElementById('btn-mobile-jump');
|
||||
const btnUse = document.getElementById('btn-mobile-use');
|
||||
const btnNext = document.getElementById('btn-mobile-next');
|
||||
const btnPrev = document.getElementById('btn-mobile-prev');
|
||||
|
||||
if (btnFire) {
|
||||
btnFire.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.player.keys.shoot = true;
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.triggerAttack();
|
||||
}
|
||||
});
|
||||
btnFire.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
this.player.keys.shoot = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (btnJump) {
|
||||
btnJump.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.player.keys.jump = true;
|
||||
});
|
||||
btnJump.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
this.player.keys.jump = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (btnUse) {
|
||||
btnUse.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.player.checkInteractions();
|
||||
});
|
||||
}
|
||||
|
||||
if (btnNext) {
|
||||
btnNext.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.nextWeapon();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (btnPrev) {
|
||||
btnPrev.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.prevWeapon();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomMobileControls = DoomMobileControls;
|
||||
Executable
+498
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* DOOM 3D - First Person Player Controller & Physics
|
||||
* Smooth movement, Pointer Lock look, step-climbing physics, stats, inventory and cheat codes.
|
||||
*/
|
||||
|
||||
class DoomPlayer {
|
||||
constructor(engine, sound) {
|
||||
this.engine = engine;
|
||||
this.sound = sound;
|
||||
this.camera = engine.camera;
|
||||
|
||||
// Position & Physics
|
||||
this.position = new THREE.Vector3(0, 2.0, 0);
|
||||
this.velocity = new THREE.Vector3();
|
||||
this.euler = new THREE.Euler(0, 0, 0, 'YXZ');
|
||||
this.isGrounded = false;
|
||||
this.height = 1.8;
|
||||
this.radius = 0.5;
|
||||
this.stepHeight = 0.95;
|
||||
|
||||
// Speeds
|
||||
this.walkSpeed = 11.0;
|
||||
this.runSpeed = 19.0;
|
||||
this.jumpForce = 12.0;
|
||||
this.gravity = 26.0;
|
||||
this.mouseSensitivity = 0.0022;
|
||||
|
||||
// Controls Input State
|
||||
this.keys = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
left: false,
|
||||
right: false,
|
||||
sprint: false,
|
||||
jump: false,
|
||||
interact: false,
|
||||
shoot: false
|
||||
};
|
||||
this.isPointerLocked = false;
|
||||
|
||||
// Player Stats
|
||||
this.maxHealth = 100;
|
||||
this.health = 100;
|
||||
this.maxArmor = 100;
|
||||
this.armor = 0;
|
||||
this.isDead = false;
|
||||
this.godMode = false;
|
||||
this.noclip = false;
|
||||
|
||||
// Ammo Reserves
|
||||
this.ammo = {
|
||||
bullets: 50,
|
||||
shells: 0,
|
||||
rockets: 0,
|
||||
cells: 0
|
||||
};
|
||||
this.maxAmmo = {
|
||||
bullets: 200,
|
||||
shells: 50,
|
||||
rockets: 50,
|
||||
cells: 300
|
||||
};
|
||||
|
||||
// Keys & Inventory
|
||||
this.keysInventory = {
|
||||
blue: false,
|
||||
red: false,
|
||||
yellow: false
|
||||
};
|
||||
|
||||
// Head Bobbing
|
||||
this.bobTimer = 0;
|
||||
this.bobOffset = new THREE.Vector2();
|
||||
|
||||
// Slime Damage Timer
|
||||
this.slimeTimer = 0;
|
||||
|
||||
// Cheat Input Buffer
|
||||
this.cheatBuffer = '';
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Keyboard down
|
||||
window.addEventListener('keydown', (e) => {
|
||||
this.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Keyboard up
|
||||
window.addEventListener('keyup', (e) => {
|
||||
this.onKeyUp(e);
|
||||
});
|
||||
|
||||
// Mouse move (Pointer Lock)
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.isPointerLocked || this.isDead) return;
|
||||
const movementX = e.movementX || e.mozMovementX || e.webkitMovementX || 0;
|
||||
const movementY = e.movementY || e.mozMovementY || e.webkitMovementY || 0;
|
||||
|
||||
this.euler.y -= movementX * this.mouseSensitivity;
|
||||
this.euler.x -= movementY * this.mouseSensitivity;
|
||||
|
||||
// Clamp pitch look angle to [-89 deg, +89 deg]
|
||||
const maxPitch = Math.PI / 2 - 0.02;
|
||||
this.euler.x = Math.max(-maxPitch, Math.min(maxPitch, this.euler.x));
|
||||
|
||||
this.camera.quaternion.setFromEuler(this.euler);
|
||||
});
|
||||
|
||||
// Pointer lock state changes
|
||||
document.addEventListener('pointerlockchange', () => {
|
||||
this.isPointerLocked = (document.pointerLockElement === this.engine.renderer.domElement);
|
||||
});
|
||||
|
||||
// Canvas click request pointer lock
|
||||
this.engine.renderer.domElement.addEventListener('click', () => {
|
||||
if (!this.isPointerLocked && !this.isDead) {
|
||||
this.requestPointerLock();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse click to shoot
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0 && this.isPointerLocked && !this.isDead) {
|
||||
this.keys.shoot = true;
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.triggerAttack();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (e.button === 0) {
|
||||
this.keys.shoot = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestPointerLock() {
|
||||
try {
|
||||
this.engine.renderer.domElement.requestPointerLock();
|
||||
if (this.sound) this.sound.resume();
|
||||
} catch (e) {
|
||||
console.warn('Pointer lock request error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
exitPointerLock() {
|
||||
try {
|
||||
document.exitPointerLock();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
const code = e.code;
|
||||
if (code === 'KeyW' || code === 'ArrowUp') this.keys.forward = true;
|
||||
if (code === 'KeyS' || code === 'ArrowDown') this.keys.backward = true;
|
||||
if (code === 'KeyA' || code === 'ArrowLeft') this.keys.left = true;
|
||||
if (code === 'KeyD' || code === 'ArrowRight') this.keys.right = true;
|
||||
if (code === 'ShiftLeft' || code === 'ShiftRight') this.keys.sprint = true;
|
||||
if (code === 'Space') {
|
||||
this.keys.jump = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
if (code === 'KeyE' || code === 'Space') {
|
||||
this.keys.interact = true;
|
||||
this.checkInteractions();
|
||||
}
|
||||
|
||||
// Weapon hotkeys (1-7)
|
||||
if (window.game && window.game.weapons) {
|
||||
if (code === 'Digit1') window.game.weapons.selectWeapon(0); // Fists
|
||||
if (code === 'Digit2') window.game.weapons.selectWeapon(1); // Pistol
|
||||
if (code === 'Digit3') window.game.weapons.selectWeapon(2); // Shotgun
|
||||
if (code === 'Digit4') window.game.weapons.selectWeapon(3); // Chaingun
|
||||
if (code === 'Digit5') window.game.weapons.selectWeapon(4); // Rocket Launcher
|
||||
if (code === 'Digit6') window.game.weapons.selectWeapon(5); // Plasma Gun
|
||||
if (code === 'Digit7') window.game.weapons.selectWeapon(6); // BFG 9000
|
||||
}
|
||||
|
||||
// Classic DOOM Cheats
|
||||
this.checkCheatCode(e.key);
|
||||
}
|
||||
|
||||
onKeyUp(e) {
|
||||
const code = e.code;
|
||||
if (code === 'KeyW' || code === 'ArrowUp') this.keys.forward = false;
|
||||
if (code === 'KeyS' || code === 'ArrowDown') this.keys.backward = false;
|
||||
if (code === 'KeyA' || code === 'ArrowLeft') this.keys.left = false;
|
||||
if (code === 'KeyD' || code === 'ArrowRight') this.keys.right = false;
|
||||
if (code === 'ShiftLeft' || code === 'ShiftRight') this.keys.sprint = false;
|
||||
if (code === 'Space') this.keys.jump = false;
|
||||
if (code === 'KeyE') this.keys.interact = false;
|
||||
}
|
||||
|
||||
checkCheatCode(char) {
|
||||
if (!char || char.length > 1) return;
|
||||
this.cheatBuffer = (this.cheatBuffer + char.toLowerCase()).slice(-8);
|
||||
|
||||
// IDDQD (God Mode)
|
||||
if (this.cheatBuffer.endsWith('iddqd')) {
|
||||
this.godMode = !this.godMode;
|
||||
this.health = 100;
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.showNotification(this.godMode ? 'DEGNAMELESS INVULNERABILITY ON' : 'DEGNAMELESS INVULNERABILITY OFF');
|
||||
}
|
||||
if (this.sound) this.sound.playItemPickup('weapon');
|
||||
}
|
||||
// IDKFA (All Weapons, Full Ammo & Keys)
|
||||
else if (this.cheatBuffer.endsWith('idkfa')) {
|
||||
this.ammo.bullets = this.maxAmmo.bullets;
|
||||
this.ammo.shells = this.maxAmmo.shells;
|
||||
this.ammo.rockets = this.maxAmmo.rockets;
|
||||
this.ammo.cells = this.maxAmmo.cells;
|
||||
this.armor = 200;
|
||||
this.keysInventory.blue = true;
|
||||
this.keysInventory.red = true;
|
||||
this.keysInventory.yellow = true;
|
||||
if (window.game && window.game.weapons) {
|
||||
window.game.weapons.unlocked = [true, true, true, true, true, true, true];
|
||||
}
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.showNotification('VERY HAPPY AMMO ADDED');
|
||||
}
|
||||
if (this.sound) this.sound.playItemPickup('weapon');
|
||||
}
|
||||
// IDCLIP (Noclip)
|
||||
else if (this.cheatBuffer.endsWith('idclip')) {
|
||||
this.noclip = !this.noclip;
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.showNotification(this.noclip ? 'NOCLIP ON' : 'NOCLIP OFF');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkInteractions() {
|
||||
if (!window.game || !window.game.map) return;
|
||||
window.game.map.interactWithDoors(this.position, this.keysInventory);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// DAMAGE & COMBAT LOGIC
|
||||
// ----------------------------------------------------
|
||||
|
||||
takeDamage(amount, sourcePos = null) {
|
||||
if (this.isDead || this.godMode) return;
|
||||
|
||||
// Armor absorbs 1/3 to 1/2 of damage
|
||||
let dmgToHealth = amount;
|
||||
if (this.armor > 0) {
|
||||
const absorb = Math.min(this.armor, Math.floor(amount * 0.5));
|
||||
this.armor -= absorb;
|
||||
dmgToHealth = amount - absorb;
|
||||
}
|
||||
|
||||
this.health = Math.max(0, this.health - dmgToHealth);
|
||||
|
||||
if (this.sound) {
|
||||
this.sound.playPlayerPain();
|
||||
}
|
||||
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.triggerDamageFlash();
|
||||
}
|
||||
|
||||
if (this.health <= 0) {
|
||||
this.die();
|
||||
}
|
||||
}
|
||||
|
||||
heal(amount, isMega = false) {
|
||||
if (this.isDead) return false;
|
||||
const cap = isMega ? 200 : this.maxHealth;
|
||||
if (this.health >= cap) return false;
|
||||
this.health = Math.min(cap, this.health + amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
addArmor(amount, isMega = false) {
|
||||
if (this.isDead) return false;
|
||||
const cap = isMega ? 200 : this.maxArmor;
|
||||
if (this.armor >= cap) return false;
|
||||
this.armor = Math.min(cap, this.armor + amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
addAmmo(type, count) {
|
||||
if (!this.ammo[type]) return false;
|
||||
if (this.ammo[type] >= this.maxAmmo[type]) return false;
|
||||
this.ammo[type] = Math.min(this.maxAmmo[type], this.ammo[type] + count);
|
||||
return true;
|
||||
}
|
||||
|
||||
die() {
|
||||
this.isDead = true;
|
||||
this.health = 0;
|
||||
if (window.game && window.game.hud) {
|
||||
window.game.hud.showGameOver();
|
||||
}
|
||||
}
|
||||
|
||||
respawn(spawnPos) {
|
||||
this.isDead = false;
|
||||
this.health = 100;
|
||||
this.armor = 0;
|
||||
this.ammo.bullets = 50;
|
||||
this.ammo.shells = 0;
|
||||
this.ammo.rockets = 0;
|
||||
this.ammo.cells = 0;
|
||||
this.position.copy(spawnPos || new THREE.Vector3(0, 2, 0));
|
||||
this.velocity.set(0, 0, 0);
|
||||
this.euler.set(0, 0, 0, 'YXZ');
|
||||
this.camera.quaternion.setFromEuler(this.euler);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// PHYSICS & COLLISION UPDATE
|
||||
// ----------------------------------------------------
|
||||
|
||||
update(delta) {
|
||||
if (this.isDead) {
|
||||
// Death cam sinking to floor
|
||||
if (this.camera.position.y > this.position.y - 1.2) {
|
||||
this.camera.position.y -= delta * 3;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Calculate horizontal movement direction relative to camera yaw
|
||||
const moveDir = new THREE.Vector3();
|
||||
if (this.keys.forward) moveDir.z -= 1;
|
||||
if (this.keys.backward) moveDir.z += 1;
|
||||
if (this.keys.left) moveDir.x -= 1;
|
||||
if (this.keys.right) moveDir.x += 1;
|
||||
|
||||
if (moveDir.lengthSq() > 0) {
|
||||
moveDir.normalize();
|
||||
// Rotate move direction by camera's horizontal yaw (euler.y)
|
||||
moveDir.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.euler.y);
|
||||
}
|
||||
|
||||
// Speed
|
||||
const curSpeed = this.keys.sprint ? this.runSpeed : this.walkSpeed;
|
||||
const targetVelX = moveDir.x * curSpeed;
|
||||
const targetVelZ = moveDir.z * curSpeed;
|
||||
|
||||
// Smooth acceleration/damping
|
||||
this.velocity.x += (targetVelX - this.velocity.x) * Math.min(1, delta * 14);
|
||||
this.velocity.z += (targetVelZ - this.velocity.z) * Math.min(1, delta * 14);
|
||||
|
||||
// 2. Jump & Gravity
|
||||
if (!this.noclip) {
|
||||
if (this.keys.jump && this.isGrounded) {
|
||||
this.velocity.y = this.jumpForce;
|
||||
this.isGrounded = false;
|
||||
}
|
||||
this.velocity.y -= this.gravity * delta;
|
||||
} else {
|
||||
this.velocity.y = 0;
|
||||
if (this.keys.jump) this.position.y += curSpeed * delta;
|
||||
if (this.keys.sprint) this.position.y -= curSpeed * delta;
|
||||
}
|
||||
|
||||
// 3. Collision Resolution against Level Geometry
|
||||
if (!this.noclip && window.game && window.game.map) {
|
||||
this.resolveCollisions(delta);
|
||||
} else {
|
||||
this.position.addScaledVector(this.velocity, delta);
|
||||
}
|
||||
|
||||
// 4. Update Camera position with Head Bobbing
|
||||
const isMoving = (this.keys.forward || this.keys.backward || this.keys.left || this.keys.right) && this.isGrounded;
|
||||
if (isMoving) {
|
||||
const bobSpeed = this.keys.sprint ? 16 : 11;
|
||||
this.bobTimer += delta * bobSpeed;
|
||||
this.bobOffset.x = Math.cos(this.bobTimer * 0.5) * 0.05;
|
||||
this.bobOffset.y = Math.sin(this.bobTimer) * 0.08;
|
||||
} else {
|
||||
this.bobOffset.x *= 0.85;
|
||||
this.bobOffset.y *= 0.85;
|
||||
}
|
||||
|
||||
this.camera.position.set(
|
||||
this.position.x + this.bobOffset.x,
|
||||
this.position.y + this.height + this.bobOffset.y,
|
||||
this.position.z
|
||||
);
|
||||
|
||||
// 5. Slime Pit Hazard Damage
|
||||
if (window.game && window.game.map) {
|
||||
if (window.game.map.isInSlimePit(this.position)) {
|
||||
this.slimeTimer += delta;
|
||||
if (this.slimeTimer >= 0.6) {
|
||||
this.slimeTimer = 0;
|
||||
this.takeDamage(6);
|
||||
if (this.sound) this.sound.playSlimeHurt();
|
||||
}
|
||||
} else {
|
||||
this.slimeTimer = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveCollisions(delta) {
|
||||
const colliders = window.game.map.getColliders();
|
||||
|
||||
// Horizontal move step X
|
||||
this.position.x += this.velocity.x * delta;
|
||||
this.collideHorizontal(colliders, 'x');
|
||||
|
||||
// Horizontal move step Z
|
||||
this.position.z += this.velocity.z * delta;
|
||||
this.collideHorizontal(colliders, 'z');
|
||||
|
||||
// Vertical move step Y
|
||||
this.position.y += this.velocity.y * delta;
|
||||
this.collideVertical(colliders);
|
||||
}
|
||||
|
||||
collideHorizontal(colliders, axis) {
|
||||
const pMinX = this.position.x - this.radius;
|
||||
const pMaxX = this.position.x + this.radius;
|
||||
const pMinZ = this.position.z - this.radius;
|
||||
const pMaxZ = this.position.z + this.radius;
|
||||
const pMinY = this.position.y;
|
||||
const pMaxY = this.position.y + this.height;
|
||||
|
||||
for (let i = 0; i < colliders.length; i++) {
|
||||
const box = colliders[i];
|
||||
// Check AABB overlap
|
||||
if (pMaxX > box.min.x && pMinX < box.max.x &&
|
||||
pMaxZ > box.min.z && pMinZ < box.max.z &&
|
||||
pMaxY > box.min.y && pMinY < box.max.y) {
|
||||
|
||||
// Check if step-climbing is possible (stairs, curbs, low ledges)
|
||||
const stepUp = box.max.y - pMinY;
|
||||
if (stepUp > 0 && stepUp <= this.stepHeight && (this.isGrounded || this.velocity.y <= 1.0)) {
|
||||
this.position.y = box.max.y + 0.02;
|
||||
this.isGrounded = true;
|
||||
this.velocity.y = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Push back on axis
|
||||
if (axis === 'x') {
|
||||
if (this.velocity.x > 0) {
|
||||
this.position.x = box.min.x - this.radius;
|
||||
} else if (this.velocity.x < 0) {
|
||||
this.position.x = box.max.x + this.radius;
|
||||
}
|
||||
this.velocity.x = 0;
|
||||
} else if (axis === 'z') {
|
||||
if (this.velocity.z > 0) {
|
||||
this.position.z = box.min.z - this.radius;
|
||||
} else if (this.velocity.z < 0) {
|
||||
this.position.z = box.max.z + this.radius;
|
||||
}
|
||||
this.velocity.z = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collideVertical(colliders) {
|
||||
const pMinX = this.position.x - this.radius;
|
||||
const pMaxX = this.position.x + this.radius;
|
||||
const pMinZ = this.position.z - this.radius;
|
||||
const pMaxZ = this.position.z + this.radius;
|
||||
const pMinY = this.position.y;
|
||||
const pMaxY = this.position.y + this.height;
|
||||
|
||||
let onGround = false;
|
||||
|
||||
for (let i = 0; i < colliders.length; i++) {
|
||||
const box = colliders[i];
|
||||
if (pMaxX > box.min.x && pMinX < box.max.x &&
|
||||
pMaxZ > box.min.z && pMinZ < box.max.z &&
|
||||
pMaxY > box.min.y && pMinY < box.max.y) {
|
||||
|
||||
if (this.velocity.y < 0) {
|
||||
// Falling onto platform / floor
|
||||
this.position.y = box.max.y;
|
||||
this.velocity.y = 0;
|
||||
onGround = true;
|
||||
} else if (this.velocity.y > 0) {
|
||||
// Hitting ceiling
|
||||
this.position.y = box.min.y - this.height;
|
||||
this.velocity.y = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isGrounded = onGround;
|
||||
}
|
||||
}
|
||||
|
||||
window.DoomPlayer = DoomPlayer;
|
||||
Executable
+1342
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user