Files
dflike/client/src/scenes/GameScene.ts
T
2026-03-07 13:07:40 +00:00

388 lines
13 KiB
TypeScript

import Phaser from 'phaser';
import type { SocketClient } from '../network/SocketClient.js';
import { CharacterCompositor } from '../sprites/CharacterCompositor.js';
import { PortraitCompositor } from '../sprites/PortraitCompositor.js';
import { NpcInfoPanel } from '../ui/NpcInfoPanel.js';
import { InteractionEmojiManager } from '../ui/InteractionEmoji.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<number, EntitySprite> = new Map();
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private wasd!: Record<string, Phaser.Input.Keyboard.Key>;
private mode: 'avatar' | 'camera' | 'follow' = 'camera';
private modeText!: Phaser.GameObjects.Text;
private moveThrottle = 0;
private followTargetIndex = 0;
private followThrottle = 0;
private portraitCompositor!: PortraitCompositor;
private npcInfoPanel!: NpcInfoPanel;
private emojiManager!: InteractionEmojiManager;
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);
this.portraitCompositor = new PortraitCompositor();
this.npcInfoPanel = new NpcInfoPanel();
this.emojiManager = new InteractionEmojiManager();
// 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', () => {
const prevMode = this.mode;
if (this.mode === 'camera') {
this.mode = 'avatar';
} else if (this.mode === 'avatar') {
this.mode = 'follow';
} else {
this.mode = 'camera';
}
this.client.sendInput({ type: 'toggle-mode' });
this.updateModeText();
// Panel visibility
if (this.mode === 'follow') {
this.showFollowPanel();
} else if (prevMode === 'follow') {
this.npcInfoPanel.hide();
}
});
// 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<void> {
for (const entity of entities) {
if (!this.entitySprites.has(entity.id) && entity.appearance) {
await this.createEntitySprite(entity);
}
}
}
private async createEntitySprite(entity: EntityState): Promise<void> {
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', 'up', 'right'];
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', 'up', 'right'];
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<number>();
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 interaction emojis
const entityPositions = new Map<number, { screenX: number; screenY: number }>();
const cam = this.cameras.main;
for (const entity of update.entities) {
const es = this.entitySprites.get(entity.id);
if (es) {
const screenX = (es.sprite.x - cam.scrollX) * cam.zoom;
const screenY = (es.sprite.y - cam.scrollY) * cam.zoom;
entityPositions.set(entity.id, { screenX, screenY });
}
}
this.emojiManager.update(update.entities, cam, entityPositions);
// Live-update the follow panel if visible
if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) {
const npcIds = this.getNpcIds();
const followedId = npcIds[this.followTargetIndex];
const followed = this.entitySprites.get(followedId);
if (followed) {
this.npcInfoPanel.updateInfo(followed.lastState);
}
}
}
update(_time: number, delta: number): void {
this.handleCameraAndMovement(delta);
}
private handleCameraAndMovement(delta: number): void {
const cam = this.cameras.main;
const speed = 6.25; // 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 if (this.mode === 'avatar') {
// 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;
}
}
// 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);
}
}
} else {
// Follow mode — track an NPC
this.followThrottle -= delta;
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
// Clamp index to valid range
if (this.followTargetIndex >= npcIds.length) {
this.followTargetIndex = 0;
}
// Cycle NPCs with left/right (throttled)
if (this.followThrottle <= 0) {
if (left) {
this.followTargetIndex = (this.followTargetIndex - 1 + npcIds.length) % npcIds.length;
this.followThrottle = 150;
this.updateModeText();
this.updateFollowPanel();
} else if (right) {
this.followTargetIndex = (this.followTargetIndex + 1) % npcIds.length;
this.followThrottle = 150;
this.updateModeText();
this.updateFollowPanel();
}
}
// Center camera on followed NPC
const targetId = npcIds[this.followTargetIndex];
const targetSprite = this.entitySprites.get(targetId);
if (targetSprite) {
cam.centerOn(targetSprite.sprite.x, targetSprite.sprite.y);
}
}
}
private async showFollowPanel(): Promise<void> {
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
const targetId = npcIds[this.followTargetIndex];
const es = this.entitySprites.get(targetId);
if (!es) return;
const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance);
this.npcInfoPanel.show(es.lastState, portraitUrl);
}
private async updateFollowPanel(): Promise<void> {
const npcIds = this.getNpcIds();
if (npcIds.length === 0) return;
const targetId = npcIds[this.followTargetIndex];
const es = this.entitySprites.get(targetId);
if (!es) return;
const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance);
this.npcInfoPanel.updatePortrait(portraitUrl);
this.npcInfoPanel.updateInfo(es.lastState);
}
private getNpcIds(): number[] {
return [...this.entitySprites.entries()]
.filter(([, es]) => !es.lastState.playerControlled)
.map(([id]) => id)
.sort((a, b) => a - b);
}
private updateModeText(): void {
if (this.mode === 'follow') {
const npcIds = this.getNpcIds();
const targetId = npcIds[this.followTargetIndex];
const es = targetId != null ? this.entitySprites.get(targetId) : undefined;
const label = es?.lastState.name ?? (targetId != null ? `NPC #${targetId}` : 'none');
this.modeText.setText(`Mode: FOLLOW: ${label} [TAB to toggle]`);
} else {
this.modeText.setText(`Mode: ${this.mode.toUpperCase()} [TAB to toggle]`);
}
}
}