escape/src/game/scenes/GameScene.js

259 lines
8.2 KiB
JavaScript

import Phaser from 'phaser';
import { GameMap } from '../../engine/GameMap.js';
import { Player } from '../../engine/Player.js';
import { Entity } from '../../engine/Entity.js';
import { Raycaster } from '../../engine/Raycaster.js';
import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
import { getItemDef, getKeyDef } from '../../engine/registry.js';
import {
RENDER_WIDTH,
RENDER_HEIGHT,
ROT_SPEED,
MOUSE_SENSITIVITY,
WEAPON_FIRE_COOLDOWN,
} from '../../engine/constants.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
const VIEW_TEXTURE = 'view';
/**
* The playable scene. Owns the world (map + player + entities), renders the
* raycast view into a CanvasTexture each frame, and drives input. The HUD lives
* in a separate parallel scene that reads `player` from here.
*/
export class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
this.entities = [];
this.yawDelta = 0; // accumulated mouse-look for the current frame
this.lastShot = 0;
}
create() {
// Level was loaded (async) by BootScene and stashed in the registry.
const level = this.registry.get('currentLevel') ?? getDefaultLevel();
this.map = new GameMap(level);
this.player = new Player(level.playerStart);
this.entities = level.entities.map((s) => new Entity(s));
// Decorative item props (plants, lamps, …): rendered as billboards, never
// shootable or collectable; blocking ones are handled by GameMap.isBlocked.
this.props = (level.items ?? []).flatMap((s) => {
const def = getItemDef(s.id);
if (!def) {
console.warn(`[level] Unknown item id "${s.id}" skipped.`);
return [];
}
return [{ x: s.x, y: s.y, def, active: true, isEnemy: false }];
});
// Carry health/ammo/score forward from the previous level, if any.
const carry = this.registry.get('carryStats');
if (carry) {
this.player.health = carry.health;
this.player.ammo = carry.ammo;
this.player.score = carry.score;
}
// Completion tracking: a level ends when the player reaches an exit, or —
// if it has no exit — when every guard is defeated.
this.hasExit = this.entities.some((e) => e.type === 'exit');
this.hadEnemies = this.entities.some((e) => e.isEnemy);
this.advancing = false;
// Off-screen low-res canvas we paint the world into, then upscale.
if (this.textures.exists(VIEW_TEXTURE)) {
this.textures.remove(VIEW_TEXTURE); // clean slate on scene restart
}
this.viewTexture = this.textures.createCanvas(
VIEW_TEXTURE,
RENDER_WIDTH,
RENDER_HEIGHT,
);
this.ctx = this.viewTexture.context;
this.ctx.imageSmoothingEnabled = false;
this.add
.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, VIEW_TEXTURE)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
this.raycaster = new Raycaster(this.ctx);
this.sprites = new SpriteRenderer(this.ctx);
this.setupInput();
}
setupInput() {
const kb = this.input.keyboard;
this.keys = kb.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE,E');
// Click to capture the mouse; once captured, clicks fire.
this.input.on('pointerdown', () => {
if (!this.input.mouse?.locked) {
this.input.mouse?.requestPointerLock();
} else {
this.fire();
}
});
// Accumulate horizontal mouse movement while pointer is locked.
this.input.on('pointermove', (pointer) => {
if (this.input.mouse?.locked) {
this.yawDelta += pointer.movementX * MOUSE_SENSITIVITY;
}
});
}
/** Probe a couple of points ahead of the player for a door and use it.
* Locked doors flash a "you need the … key" message instead. */
tryUseDoor() {
const { cam } = this.player;
for (const dist of [1.0, 1.75]) {
const cx = Math.floor(cam.posX + cam.dirX * dist);
const cy = Math.floor(cam.posY + cam.dirY * dist);
const result = this.map.useDoor(cx, cy, this.player.keys);
if (result.used) return;
if (result.locked) {
const keyName = getKeyDef(result.locked)?.name ?? `${result.locked} key`;
this.game.events.emit('door-locked', keyName);
return;
}
}
}
fire() {
if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return;
this.lastShot = this.time.now;
const result = this.player.shoot(this.map, this.entities);
if (!result.fired) {
this.game.events.emit('player-empty');
return;
}
if (result.killed) this.player.score += result.hit.def.points ?? 100;
this.game.events.emit('player-fired', result);
}
update(_time, deltaMs) {
const dt = deltaMs / 1000;
// --- Build movement intent from keyboard + accumulated mouse look. ---
const k = this.keys;
const forward =
(k.W.isDown || k.UP.isDown ? 1 : 0) - (k.S.isDown || k.DOWN.isDown ? 1 : 0);
const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0);
// Arrow left/right turn when not strafing with mouse.
const keyTurn =
((k.RIGHT.isDown ? 1 : 0) - (k.LEFT.isDown ? 1 : 0)) * ROT_SPEED * dt;
const input = {
forward,
strafe,
rotate: keyTurn + this.yawDelta,
};
this.yawDelta = 0;
this.player.update(this.map, input, dt);
// Keyboard fire fallback (Space), useful before grabbing the mouse.
if (k.SPACE.isDown) this.fire();
// E uses the door ahead (open, or close an open one).
if (Phaser.Input.Keyboard.JustDown(k.E)) this.tryUseDoor();
const { cam } = this.player;
this.map.updateDoors(deltaMs, {
x: Math.floor(cam.posX),
y: Math.floor(cam.posY),
});
for (const e of this.entities) e.update(deltaMs);
this.handlePickups();
// No-exit levels finish when the last guard falls.
if (
!this.hasExit &&
this.hadEnemies &&
!this.entities.some((e) => e.isEnemy && e.active)
) {
this.completeLevel();
}
// --- Render the world. ---
this.raycaster.render(this.map, this.player.cam);
this.sprites.render(
this.player.cam,
[...this.entities, ...this.props],
this.raycaster.zBuffer,
);
this.viewTexture.refresh(); // upload the freshly drawn canvas to the GPU
}
/** Walk-over pickups: collect nearby active items; reaching an exit ends the
* level. */
handlePickups() {
const { cam } = this.player;
for (const e of this.entities) {
if (e.isEnemy || !e.active) continue;
if (Math.hypot(e.x - cam.posX, e.y - cam.posY) >= 0.4) continue;
if (e.type === 'exit') {
this.completeLevel();
return;
}
e.collect();
this.applyPickup(e);
this.game.events.emit('player-pickup', e.def.name ?? e.type);
}
}
/** Advance to the next campaign level, or win the game after the last one.
* Guarded so it only fires once even though the trigger is checked per-frame
* while the next level's file is being fetched. */
async completeLevel() {
if (this.advancing) return;
this.advancing = true;
const campaign = this.registry.get('campaign');
const next = campaign.index + 1;
const isLast = campaign.playtest || next >= campaign.files.length;
if (isLast) {
this.game.events.emit('game-won', this.player.score);
this.scene.pause();
return;
}
// Carry health/ammo/score into the next level for a real campaign feel.
this.registry.set('carryStats', {
health: this.player.health,
ammo: this.player.ammo,
score: this.player.score,
});
this.game.events.emit('level-complete');
const level = (await fetchLevel(campaign.files[next])) ?? getDefaultLevel();
campaign.index = next;
this.registry.set('currentLevel', level);
this.scene.restart();
}
/** Apply a collected pickup's data-driven effect (see pickups.json). */
applyPickup(e) {
const def = e.def;
switch (def.effect) {
case 'ammo':
this.player.ammo += def.amount ?? 10;
break;
case 'health':
this.player.health = Math.min(100, this.player.health + (def.amount ?? 25));
break;
case 'score':
this.player.score += def.amount ?? 100;
break;
case 'key':
if (def.key) this.player.keys.add(def.key);
break;
}
}
}