// Main scene — a thin coordinator, the TotalAnnihilationGame.js role: each // frame it steps the headless sim, replays events for HUD feedback, and // renders the resulting state. It owns no game rules itself. // // Pointer-lock mouse-look is the first code of its kind in this repo (no // existing game uses requestPointerLock/movementX) — isolated here behind a // pointerlockchange listener that doubles as the pause trigger: losing the // lock (click away, Esc) always pauses the sim, so nothing can keep taking // damage while the player isn't actually looking at the screen. import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { compileRules } from './WolfensteinRules.js'; import * as Logic from './WolfensteinLogic.js'; import WolfensteinView, { VIEW_H } from './WolfensteinView.js'; import * as Screens from './WolfensteinScreens.js'; import { makeCamera } from './WolfensteinRaycaster.js'; // Same blue/red/yellow palette as WolfensteinView's DOOR_COLOR_HEX and // WolfensteinEditor's LOCK_COLORS — kept as its own literal here rather than // imported, same "not worth the coupling for a 3-entry table" tradeoff those // two make with each other. const KEY_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 }; const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); const SAVE_KEY = 'wolfenstein-save'; const SAVE_SLOT_COUNT = 4; const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`; const PROGRESS_KEY = 'wolfenstein-progress'; export default class WolfensteinGame extends Phaser.Scene { constructor() { super('WolfensteinGame'); } init(data) { this.gameDef = data.game ?? null; this.testLevel = data.testLevel ?? null; this.returnToEditor = !!data.returnToEditor; } create() { const rulesJson = this.cache.json.get('wolfenstein-rules'); this.campaigns = this.cache.json.get('wolfenstein-campaigns') ?? { campaigns: [] }; this.rules = compileRules(rulesJson); this.phase = 'menu'; this.currentScreen = null; this.state = null; this.view = null; this.meta = null; this._locked = false; this._mouseFireHeld = false; this._lastAutosave = 0; this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE'); this._bindPointerLock(); this.hud = this._buildHud(); this._setHudVisible(false); this.events.once('shutdown', () => this._teardown()); if (this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); } else { this.showMainMenu(); } } // ------------------------------------------------------------- screens swapScreen(builder) { this.currentScreen?.destroy(); this.currentScreen = builder; } showMainMenu() { this.phase = 'menu'; this._setHudVisible(false); this.swapScreen(Screens.mainMenu(this, { hasContinue: !!this._readLocal(SAVE_KEY), onNewGame: () => this.showCampaignSelect(), onContinue: () => this._loadAutosave(), onLoad: () => this.showLoadScreen('load'), onLeave: () => this.scene.start('GameMenu'), })); } showCampaignSelect() { const progress = this._readProgress(); this.swapScreen(Screens.campaignSelect(this, { campaigns: this.campaigns.campaigns, progress, onBack: () => this.showMainMenu(), onPick: (campaignId) => this.showCampaignList(campaignId), })); } showCampaignList(campaignId) { const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); const cleared = this._readProgress()[campaignId] ?? 0; this.swapScreen(Screens.campaignList(this, camp, cleared, { onBack: () => this.showCampaignSelect(), onPick: (missionIndex) => this.startMission(campaignId, missionIndex), })); } showLoadScreen(mode) { this.swapScreen(Screens.saveLoadScreen(this, { mode, slots: this.allSaveSlotMeta(), onBack: () => (this.phase === 'paused' ? this.swapScreen(this._pauseScreen()) : this.showMainMenu()), onPick: (i) => { if (mode === 'save') { this.writeSaveSlot(i); this.showLoadScreen(mode); return; } const restored = this.loadSaveSlot(i); if (restored) this._resumeState(restored); }, onDelete: (i) => { this.deleteSaveSlot(i); this.showLoadScreen(mode); }, })); } _pauseScreen() { return Screens.pause(this, { onResume: () => this._resumePlay(), onSave: () => this.showLoadScreen('save'), onLoad: () => this.showLoadScreen('load'), onExitToEditor: this.returnToEditor ? () => this.scene.start('WolfensteinEditor', { resume: true }) : null, onQuit: () => { this._teardownLevel(); this.showMainMenu(); }, }); } // ------------------------------------------------------------- missions // `carry` is optional — the loadout (weapons/weapon/ammo/health, but // deliberately never keys — see Logic.createState's own doc comment) to // resume with instead of a fresh campaign start. Passed explicitly by // onNext (this._extractCarry of the just-won state) and by _retry // (replaying whatever this SAME mission attempt itself started with, // stored on `this.meta.carry` below — not the failed attempt's live // state, which may have less health/ammo than it started with). Any // other call — picking a mission straight off the campaign list, or a // fresh "New Game" — passes nothing, which _freshCampaignCarry resolves // to fists-only/full-health, matching "starting a new campaign begins // with just fists." async startMission(campaignId, missionIndex, carry = null) { const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); const mission = camp.missions[missionIndex]; const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`); this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex, carry: carry ?? this._freshCampaignCarry() }); } _loadLevelJson(key, path) { return new Promise((resolve) => { if (this.cache.json.has(key)) { resolve(this.cache.json.get(key)); return; } this.load.json(key, path); this.load.once('complete', () => resolve(this.cache.json.get(key))); this.load.start(); }); } /** Fresh-campaign starting loadout: fists only, no ammo, full health — see startMission's doc comment. */ _freshCampaignCarry() { return { weapons: ['fists'], weapon: 'fists', ammo: {}, health: this.rules.constants.playerMaxHealth }; } /** Extracts a carry-over loadout from a live player object (see startMission's `carry` param). */ _extractCarry(p) { return { weapons: p.weapons.slice(), weapon: p.weapon, ammo: { ...p.ammo }, health: p.health }; } _beginLevel(levelJson, meta) { const model = Logic.buildLevelModel(levelJson); this.state = Logic.createState(model, this.rules, meta.carry ?? null); this.meta = meta; this.view?.destroy(); this.view = new WolfensteinView(this, this.rules); this.swapScreen(null); this._setHudVisible(true); this._lastAutosave = this.time.now; this.phase = 'playing'; this._requestLock(); } _resumeState(restored) { this.state = restored; // carry here is only a fallback for a retry launched right after this // resume (see _retry) — the restored save's own player loadout is the // best available approximation of "what this mission started with" // once the original startMission call that began it is long gone. this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex, carry: this._extractCarry(restored.player), }; this.view?.destroy(); this.view = new WolfensteinView(this, this.rules); this.swapScreen(null); this._setHudVisible(true); this._lastAutosave = this.time.now; this.phase = 'playing'; this._requestLock(); } _resumePlay() { this.phase = 'playing'; this.swapScreen(null); this._setHudVisible(true); this._requestLock(); } _teardownLevel() { this.view?.destroy(); this.view = null; this.state = null; this._setHudVisible(false); this._exitLock(); } // ------------------------------------------------------------- input _bindPointerLock() { const canvas = this.sys.game.canvas; this._canvas = canvas; this._onLockChange = () => { const locked = document.pointerLockElement === canvas; this._locked = locked; if (!locked && this.phase === 'playing') { this.phase = 'paused'; this.swapScreen(this._pauseScreen()); } }; document.addEventListener('pointerlockchange', this._onLockChange); this._onMouseMove = (e) => { if (!this._locked || this.phase !== 'playing' || !this.state) return; Logic.queueTurn(this.state, e.movementX * this.rules.constants.mouseSensitivity); }; document.addEventListener('mousemove', this._onMouseMove); this.input.on('pointerdown', () => { if (this.phase !== 'playing') return; if (!this._locked) { this._requestLock(); return; } this._mouseFireHeld = true; }); this.input.on('pointerup', () => { this._mouseFireHeld = false; }); // Scroll up (negative deltaY, same "up = forward" convention the level // editor's zoom-in already uses) cycles to the next owned weapon in // rules.weapons' order (same order the 1-6 keys use), wrapping past the // end back to the start; scroll down goes the other way. this.input.on('wheel', (_pointer, _gameObjects, _dx, dy) => { if (this.phase !== 'playing' || !this.state) return; Logic.cycleWeapon(this.state, this.rules, dy < 0 ? 1 : -1); }); } _requestLock() { this._canvas?.requestPointerLock?.(); } _exitLock() { if (document.pointerLockElement) document.exitPointerLock?.(); } _pollKeys() { if (!this.state) return; const k = this.keys; const forward = (k.W.isDown ? 1 : 0) - (k.S.isDown ? 1 : 0); const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0); Logic.setMoveIntent(this.state, forward, strafe); Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists'); if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); if (Phaser.Input.Keyboard.JustDown(k.THREE)) Logic.switchWeapon(this.state, 'shotgun'); if (Phaser.Input.Keyboard.JustDown(k.FOUR)) Logic.switchWeapon(this.state, 'machinegun'); if (Phaser.Input.Keyboard.JustDown(k.FIVE)) Logic.switchWeapon(this.state, 'gatling'); if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle'); if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) { Logic.openNearestDoor(this.state); // Deliberately silent otherwise (no HUD hint, no "nothing here" // feedback) — see WolfensteinLogic's secret-doors section note for // why: the player has to guess and try, same as pressing Space // against a suspicious wall in the genre this is drawing from. Logic.triggerNearestSecretDoor(this.state); } } // ------------------------------------------------------------- loop update(time, delta) { if (this.phase !== 'playing' || !this.state) return; this._pollKeys(); const events = Logic.step(this.state, this.rules, delta, 4); for (const ev of events) this._onSimEvent(ev); const p = this.state.player; const camera = makeCamera(p.x, p.y, p.angle, this.rules.fov); this.view.render(this.state, camera); this._updateHud(); if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) { this.writeSave(); this._lastAutosave = time; } if (this.state.result === 'won') this._onMissionWon(); else if (this.state.result === 'lost') this._onMissionLost(); } _onSimEvent(ev) { if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`); else if (ev.t === 'doorLocked') this._toast(`Locked — need the ${capitalize(ev.color)} Key`); else if (ev.t === 'secretFound') this._toast('A secret door slides open...'); else if (ev.t === 'weaponFired' && ev.weapon === 'fists') this.view?.triggerFistsSwing(); } _onMissionWon() { this._exitLock(); this._setHudVisible(false); this.phase = 'won'; let hasNext = false; if (this.meta?.mode === 'campaign') { const camp = this.campaigns.campaigns.find((c) => c.id === this.meta.campaignId); this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex); hasNext = this.meta.missionIndex + 1 < camp.missions.length; this.clearSave(); } this.swapScreen(Screens.resultScreen(this, { won: true, missionName: this.state.levelMeta.name, hasNext, onRetry: () => this._retry(), onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1, this._extractCarry(this.state.player)), onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, })); } _onMissionLost() { this._exitLock(); this._setHudVisible(false); this.phase = 'lost'; if (this.meta?.mode === 'campaign') this.clearSave(); this.swapScreen(Screens.resultScreen(this, { won: false, missionName: this.state.levelMeta.name, hasNext: false, onRetry: () => this._retry(), onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, })); } _retry() { if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; } // Replays whatever loadout THIS mission attempt itself started with // (this.meta.carry, set when it began — see startMission/_resumeState), // not the just-died state, which may hold less health/ammo than it did // at the start (and, for a mid-level pickup since then, more weapons — // both wrong to hand back on a failed attempt). if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex, this.meta.carry); } // ------------------------------------------------------------- HUD _buildHud() { const y = GAME_HEIGHT - 70; const objs = {}; objs.bar = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 70, GAME_WIDTH, 140, 0x0a0806, 0.92).setDepth(20); objs.health = this.add.text(80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(21); objs.weapon = this.add.text(GAME_WIDTH / 2, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0.5).setDepth(21); objs.ammo = this.add.text(GAME_WIDTH - 80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(1, 0.5).setDepth(21); objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0); objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15); objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21); // Doors no longer open automatically on approach (see openNearestDoor) — // without this, there's no way to discover that Space/E is the interact key. objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21); // A small keyring above the health readout — one square per color, // hidden until that key is actually picked up this level (see // _updateHud). Flat individually-keyed objects, not a Container/group, // because _setHudVisible below just iterates Object.values(this.hud). objs.key_blue = this.add.rectangle(80, y - 42, 22, 22, KEY_COLORS.blue).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false); objs.key_red = this.add.rectangle(112, y - 42, 22, 22, KEY_COLORS.red).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false); objs.key_yellow = this.add.rectangle(144, y - 42, 22, 22, KEY_COLORS.yellow).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false); return objs; } _setHudVisible(visible) { for (const o of Object.values(this.hud)) o.setVisible(visible); if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); // Keyring starts empty every level (see player.keys in createState) — // _updateHud corrects these each frame, but force them off immediately // rather than flashing "all keys held" for a frame, same reasoning as // doorHint above. this.hud.key_blue.setVisible(false); this.hud.key_red.setVisible(false); this.hud.key_yellow.setVisible(false); } } _updateHud() { const p = this.state.player; this.hud.health.setText(`HP ${Math.ceil(p.health)}`); this.hud.weapon.setText(p.weapon.toUpperCase()); const w = this.rules.weaponById[p.weapon]; this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : ''); this.hud.lockHint.setVisible(!this._locked); this.hud.key_blue.setVisible(p.keys.includes('blue')); this.hud.key_red.setVisible(p.keys.includes('red')); this.hud.key_yellow.setVisible(p.keys.includes('yellow')); const doorInfo = Logic.nearestDoorInfo(this.state); if (!doorInfo) { this.hud.doorHint.setVisible(false); } else if (doorInfo.locked) { this.hud.doorHint.setVisible(true).setColor('#ff5a5a') .setText(`[SPACE/E] Locked — need the ${capitalize(doorInfo.color)} Key`); } else { this.hud.doorHint.setVisible(true).setColor(COLORS.goldHex).setText('[SPACE/E] Open'); } } _toast(msg) { this.hud.toast.setText(msg).setAlpha(1); this.tweens.killTweensOf(this.hud.toast); this.tweens.add({ targets: this.hud.toast, alpha: 0, delay: 900, duration: 400 }); } // ------------------------------------------------------------- progress _readProgress() { try { return JSON.parse(window.localStorage.getItem(PROGRESS_KEY)) ?? {}; } catch { return {}; } } _writeProgress(obj) { try { window.localStorage.setItem(PROGRESS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } } _recordMissionCleared(campaignId, missionIndex) { const prog = this._readProgress(); prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1); this._writeProgress(prog); } // ------------------------------------------------------------- save/load _readLocal(key) { try { return window.localStorage.getItem(key); } catch { return null; } } writeSave() { try { if (this.state && !this.state.result) window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); } catch { /* storage may be unavailable */ } } readSave() { try { const raw = window.localStorage.getItem(SAVE_KEY); return raw ? Logic.deserialize(this.rules, raw) : null; } catch { return null; } } clearSave() { try { window.localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ } } _loadAutosave() { const restored = this.readSave(); if (restored) this._resumeState(restored); } writeSaveSlot(i) { try { const p = this.state.player; const meta = { savedAt: Date.now(), campaignId: this.meta?.campaignId ?? null, missionIndex: this.meta?.missionIndex ?? 0, missionName: this.state.levelMeta.name, health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon, }; window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) })); return true; } catch { return false; } } readSaveSlotMeta(i) { try { const raw = window.localStorage.getItem(saveSlotKey(i)); return raw ? (JSON.parse(raw).meta ?? null) : null; } catch { return null; } } loadSaveSlot(i) { try { const raw = window.localStorage.getItem(saveSlotKey(i)); if (!raw) return null; return Logic.deserialize(this.rules, JSON.parse(raw).raw); } catch { return null; } } deleteSaveSlot(i) { try { window.localStorage.removeItem(saveSlotKey(i)); } catch { /* ignore */ } } allSaveSlotMeta() { const out = []; for (let i = 0; i < SAVE_SLOT_COUNT; i++) out.push(this.readSaveSlotMeta(i)); return out; } hasAnySaveSlot() { return this.allSaveSlotMeta().some(Boolean); } // ------------------------------------------------------------- teardown _teardown() { document.removeEventListener('pointerlockchange', this._onLockChange); document.removeEventListener('mousemove', this._onMouseMove); this._exitLock(); this.view?.destroy(); } }