Files
dflike/client/src/scenes/GameScene.ts
T
root 02a8fc7ce8 fix: prevent async race in sprite creation and emoji lifecycle leak
Two bugs fixed:

1. NPC sprites not fully rendering: createEntitySprite is async but was
   called without await from handleStateUpdate. Concurrent state updates
   triggered multiple simultaneous creation calls for the same entity.
   The second call composited before textures loaded, producing empty
   spritesheets. Fixed with a creatingEntities guard set.

2. Interaction emojis not appearing after first use: animationend
   listener was attached during removal (when animation had already
   completed), so it never fired. Ghost entries in activeEmojis map
   blocked future emoji creation. Fixed by self-cleaning via
   animationend listener attached at creation time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:18:20 +00:00

392 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 creatingEntities: Set<number> = new Set();
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.creatingEntities.has(entity.id)) {
this.creatingEntities.add(entity.id);
this.createEntitySprite(entity).finally(() => {
this.creatingEntities.delete(entity.id);
});
}
}
// 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]`);
}
}
}