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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user