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; } } }