fertig-classic-games/src/games/wolfenstein/WolfensteinGame.js

494 lines
21 KiB
JavaScript

// 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();
}
}