From 02a8fc7ce807ecea7efe5d756d1e2af946170137 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 13:18:20 +0000 Subject: [PATCH] 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 --- client/src/scenes/GameScene.ts | 8 ++++++-- client/src/ui/InteractionEmoji.ts | 22 ++++++++-------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 6af1ad8..f12bfc5 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -17,6 +17,7 @@ export class GameScene extends Phaser.Scene { private compositor!: CharacterCompositor; private worldState!: WorldState; private entitySprites: Map = new Map(); + private creatingEntities: Set = new Set(); private cursors!: Phaser.Types.Input.Keyboard.CursorKeys; private wasd!: Record; 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); + }); } } diff --git a/client/src/ui/InteractionEmoji.ts b/client/src/ui/InteractionEmoji.ts index f224724..1d9e52c 100644 --- a/client/src/ui/InteractionEmoji.ts +++ b/client/src/ui/InteractionEmoji.ts @@ -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); }