259 lines
8.2 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|