02a8fc7ce8
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>
392 lines
13 KiB
TypeScript
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]`);
|
|
}
|
|
}
|
|
}
|