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>
This commit is contained in:
root
2026-03-07 13:18:20 +00:00
parent d011dcf183
commit 02a8fc7ce8
2 changed files with 14 additions and 16 deletions
+6 -2
View File
@@ -17,6 +17,7 @@ export class GameScene extends Phaser.Scene {
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';
@@ -226,8 +227,11 @@ export class GameScene extends Phaser.Scene {
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);
} else if (entity.appearance && !this.creatingEntities.has(entity.id)) {
this.creatingEntities.add(entity.id);
this.createEntitySprite(entity).finally(() => {
this.creatingEntities.delete(entity.id);
});
}
}
+8 -14
View File
@@ -26,21 +26,9 @@ export class InteractionEmojiManager {
}
}
// Remove emojis for entities no longer emoting
// Let the CSS animation finish naturally before removing the element
// Update positions for active emojis
// (animationend self-cleanup in createEmoji handles removal)
for (const [entityId, el] of this.activeEmojis) {
if (!emoting.has(entityId) && !el.dataset.removing) {
el.dataset.removing = '1';
el.addEventListener('animationend', () => {
el.remove();
this.activeEmojis.delete(entityId);
}, { once: true });
}
}
// Update positions (skip emojis that are fading out)
for (const [entityId, el] of this.activeEmojis) {
if (el.dataset.removing) continue;
const pos = entityPositions.get(entityId);
if (pos) {
el.style.left = `${pos.screenX}px`;
@@ -61,6 +49,12 @@ export class InteractionEmojiManager {
div.style.animation = 'emoji-float 2s ease-out forwards';
div.textContent = outcome === 'positive' ? EMOJI_POSITIVE : EMOJI_NEGATIVE;
// Self-cleanup when animation completes
div.addEventListener('animationend', () => {
div.remove();
this.activeEmojis.delete(entityId);
}, { once: true });
document.body.appendChild(div);
this.activeEmojis.set(entityId, div);
}