fix: prevent orphaned accessory sprites from race conditions

Two bugs caused accessories to render independently of their NPC:

1. spawnEntities ran concurrently with state updates, allowing duplicate
   sprites to be created for the same entity. The orphaned sprite would
   animate in place but never move. Fixed by deferring onStateUpdate
   registration until initial spawn completes.

2. CharacterCompositor tracked load intent (Set) rather than load
   completion (Promise), so concurrent preloads could composite before
   shared textures finished loading — producing sprites with missing
   skin layers. Fixed by using a Map<string, Promise> so concurrent
   callers await the same per-file load promise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 17:03:47 +00:00
parent 29c8c5fd8c
commit b42362e041
2 changed files with 31 additions and 25 deletions
+6 -7
View File
@@ -130,13 +130,12 @@ export class GameScene extends Phaser.Scene {
}).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);
};
// Spawn initial entities, then listen for state updates
this.spawnEntities(this.worldState.entities).then(() => {
this.client.onStateUpdate = (update: StateUpdate) => {
this.handleStateUpdate(update);
};
});
this.client.onPlayerLeft = (_data) => {
// Entity removal will be handled by next state update
+25 -18
View File
@@ -15,7 +15,7 @@ function appearanceKey(appearance: Appearance): string {
export class CharacterCompositor {
private scene: Phaser.Scene;
private loadedImages: Set<string> = new Set();
private loadingImages: Map<string, Promise<void>> = new Map();
constructor(scene: Phaser.Scene) {
this.scene = scene;
@@ -35,33 +35,32 @@ export class CharacterCompositor {
// Already composited
if (this.scene.textures.exists(key)) return key;
// Collect all image paths needed
const imagesToLoad: { key: string; path: string }[] = [];
// Collect all image keys needed and ensure each is loaded
const skinKey = `skin_${appearance.skinId}`;
if (!this.scene.textures.exists(skinKey) && !this.loadedImages.has(skinKey)) {
imagesToLoad.push({ key: skinKey, path: this.skinPath(appearance.skinId) });
this.loadedImages.add(skinKey);
const pendingLoads: Promise<void>[] = [];
if (!this.scene.textures.exists(skinKey)) {
if (!this.loadingImages.has(skinKey)) {
this.loadingImages.set(skinKey, this.loadImage(skinKey, this.skinPath(appearance.skinId)));
}
pendingLoads.push(this.loadingImages.get(skinKey)!);
}
for (const slot of ACCESSORY_SLOTS) {
const fileId = appearance.accessories[slot];
if (!fileId) continue;
const accKey = `acc_${slot}_${fileId}`;
if (!this.scene.textures.exists(accKey) && !this.loadedImages.has(accKey)) {
imagesToLoad.push({ key: accKey, path: this.accessoryPath(slot, fileId) });
this.loadedImages.add(accKey);
if (!this.scene.textures.exists(accKey)) {
if (!this.loadingImages.has(accKey)) {
this.loadingImages.set(accKey, this.loadImage(accKey, this.accessoryPath(slot, fileId)));
}
pendingLoads.push(this.loadingImages.get(accKey)!);
}
}
// Load any missing images
if (imagesToLoad.length > 0) {
await new Promise<void>((resolve) => {
for (const img of imagesToLoad) {
this.scene.load.image(img.key, img.path);
}
this.scene.load.once('complete', resolve);
this.scene.load.start();
});
// Wait for all pending loads to complete
if (pendingLoads.length > 0) {
await Promise.all(pendingLoads);
}
// Composite onto offscreen canvas
@@ -96,6 +95,14 @@ export class CharacterCompositor {
return key;
}
private loadImage(key: string, path: string): Promise<void> {
return new Promise<void>((resolve) => {
this.scene.load.image(key, path);
this.scene.load.once(`filecomplete-image-${key}`, () => resolve());
this.scene.load.start();
});
}
getKey(appearance: Appearance): string {
return appearanceKey(appearance);
}