diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index ab8f34b..a245b6a 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -1,12 +1,270 @@ 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: unknown): void {} + init(data: { client: SocketClient; worldState: WorldState }): void { + this.client = data.client; + this.worldState = data.worldState; + } + create(): void { - this.add.text(100, 100, 'Game scene loaded', { color: '#ffffff' }); + 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]` + ); } }