import Phaser from 'phaser'; import type { SocketClient } from '../network/SocketClient.js'; import { CharacterCompositor } from '../sprites/CharacterCompositor.js'; import type { WorldState, StateUpdate, EntityState, Appearance } from '@dflike/shared'; import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction } from '@dflike/shared'; interface EntitySprite { sprite: Phaser.GameObjects.Sprite; lastState: EntityState; } export class GameScene extends Phaser.Scene { private client!: SocketClient; private compositor!: CharacterCompositor; private worldState!: WorldState; private entitySprites: Map = new Map(); private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private wasd!: Record; private mode: 'avatar' | 'camera' = 'camera'; private modeText!: Phaser.GameObjects.Text; private moveThrottle = 0; constructor() { super({ key: 'GameScene' }); } init(data: { client: SocketClient; worldState: WorldState }): void { this.client = data.client; this.worldState = data.worldState; } create(): void { this.compositor = new CharacterCompositor(this); // Draw tile grid this.drawWorld(); // Setup camera const worldPixelW = this.worldState.worldWidth * TILE_SIZE; const worldPixelH = this.worldState.worldHeight * TILE_SIZE; this.cameras.main.setBounds(0, 0, worldPixelW, worldPixelH); // Input this.cursors = this.input.keyboard!.createCursorKeys(); this.wasd = { W: this.input.keyboard!.addKey('W'), A: this.input.keyboard!.addKey('A'), S: this.input.keyboard!.addKey('S'), D: this.input.keyboard!.addKey('D'), }; // Tab to toggle mode this.input.keyboard!.addKey('TAB').on('down', () => { this.mode = this.mode === 'avatar' ? 'camera' : 'avatar'; this.client.sendInput({ type: 'toggle-mode' }); this.updateModeText(); }); // UI text this.modeText = this.add.text(10, 10, '', { fontSize: '16px', color: '#ffffff', backgroundColor: '#000000aa', padding: { x: 8, y: 4 }, }).setScrollFactor(0).setDepth(1000); this.updateModeText(); // Spawn initial entities this.spawnEntities(this.worldState.entities); // Listen for state updates this.client.onStateUpdate = (update: StateUpdate) => { this.handleStateUpdate(update); }; this.client.onPlayerLeft = (_data) => { // Entity removal will be handled by next state update }; } private drawWorld(): void { const { worldWidth, worldHeight, obstacles, pointsOfInterest } = this.worldState; // Ground tiles const graphics = this.add.graphics(); for (let x = 0; x < worldWidth; x++) { for (let y = 0; y < worldHeight; y++) { const shade = ((x + y) % 2 === 0) ? 0x2d5a27 : 0x326b2e; graphics.fillStyle(shade, 1); graphics.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } // Obstacles for (const obs of obstacles) { graphics.fillStyle(0x555555, 1); graphics.fillRect(obs.x * TILE_SIZE, obs.y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } // Points of interest for (const poi of pointsOfInterest) { const color = poi.type === 'food' ? 0xcc8833 : 0x3355cc; graphics.fillStyle(color, 1); graphics.fillRect( poi.position.x * TILE_SIZE + 8, poi.position.y * TILE_SIZE + 8, TILE_SIZE - 16, TILE_SIZE - 16, ); } } private async spawnEntities(entities: EntityState[]): Promise { for (const entity of entities) { if (!this.entitySprites.has(entity.id) && entity.appearance) { await this.createEntitySprite(entity); } } } private async createEntitySprite(entity: EntityState): Promise { const textureKey = await this.compositor.preloadAppearance(entity.appearance); // Create animations for this texture this.createAnimations(textureKey); const sprite = this.add.sprite( entity.position.x * TILE_SIZE + TILE_SIZE / 2, entity.position.y * TILE_SIZE + TILE_SIZE / 2, textureKey, 0, ); this.entitySprites.set(entity.id, { sprite, lastState: entity }); this.playAnimation(sprite, textureKey, entity.movement.direction, entity.movement.state); } private createAnimations(textureKey: string): void { const dirNames = ['down', 'left', 'right', 'up']; for (let dir = 0; dir < 4; dir++) { const walkKey = `${textureKey}_walk_${dirNames[dir]}`; const idleKey = `${textureKey}_idle_${dirNames[dir]}`; if (!this.anims.exists(walkKey)) { this.anims.create({ key: walkKey, frames: this.anims.generateFrameNumbers(textureKey, { start: dir * SPRITE_COLS + 1, end: dir * SPRITE_COLS + SPRITE_COLS - 1, }), frameRate: 8, repeat: -1, }); } if (!this.anims.exists(idleKey)) { this.anims.create({ key: idleKey, frames: [{ key: textureKey, frame: dir * SPRITE_COLS }], frameRate: 1, repeat: 0, }); } } } private playAnimation(sprite: Phaser.GameObjects.Sprite, textureKey: string, direction: number, state: string): void { const dirNames = ['down', 'left', 'right', 'up']; const dirName = dirNames[direction] ?? 'down'; const animKey = state === 'walking' ? `${textureKey}_walk_${dirName}` : `${textureKey}_idle_${dirName}`; if (sprite.anims.currentAnim?.key !== animKey) { sprite.play(animKey); } } private handleStateUpdate(update: StateUpdate): void { const activeIds = new Set(); for (const entity of update.entities) { activeIds.add(entity.id); const existing = this.entitySprites.get(entity.id); if (existing) { // Interpolation target const targetX = entity.position.x * TILE_SIZE + TILE_SIZE / 2; const targetY = entity.position.y * TILE_SIZE + TILE_SIZE / 2; // Smooth movement via tween const dx = Math.abs(existing.sprite.x - targetX); const dy = Math.abs(existing.sprite.y - targetY); if (dx > 1 || dy > 1) { this.tweens.add({ targets: existing.sprite, x: targetX, y: targetY, duration: 200, ease: 'Linear', }); } // Update animation const textureKey = this.compositor.getKey(entity.appearance); this.playAnimation(existing.sprite, textureKey, entity.movement.direction, entity.movement.state); existing.lastState = entity; } else if (entity.appearance) { this.createEntitySprite(entity); } } // Remove entities no longer in update for (const [id, es] of this.entitySprites) { if (!activeIds.has(id)) { es.sprite.destroy(); this.entitySprites.delete(id); } } } update(_time: number, delta: number): void { this.handleCameraAndMovement(delta); } private handleCameraAndMovement(delta: number): void { const cam = this.cameras.main; const speed = 5; // tiles per second for camera pan const left = this.cursors.left?.isDown || this.wasd.A.isDown; const right = this.cursors.right?.isDown || this.wasd.D.isDown; const up = this.cursors.up?.isDown || this.wasd.W.isDown; const down = this.cursors.down?.isDown || this.wasd.S.isDown; if (this.mode === 'camera') { // Pan camera freely const panSpeed = speed * TILE_SIZE; if (left) cam.scrollX -= panSpeed * delta / 1000; if (right) cam.scrollX += panSpeed * delta / 1000; if (up) cam.scrollY -= panSpeed * delta / 1000; if (down) cam.scrollY += panSpeed * delta / 1000; } else { // Avatar mode — send movement input (throttled) this.moveThrottle -= delta; if (this.moveThrottle <= 0) { let dx = 0, dy = 0; if (left) dx = -1; else if (right) dx = 1; else if (up) dy = -1; else if (down) dy = 1; if (dx !== 0 || dy !== 0) { this.client.sendInput({ type: 'move', direction: { dx, dy } }); this.moveThrottle = 150; // ms between moves } } // Follow own entity const myEntityId = this.client.entityId; if (myEntityId != null) { const mySprite = this.entitySprites.get(myEntityId); if (mySprite) { cam.centerOn(mySprite.sprite.x, mySprite.sprite.y); } } } } private updateModeText(): void { this.modeText.setText( `Mode: ${this.mode.toUpperCase()} [TAB to toggle]` ); } }