From 93df418554c58d072c57f6714964ba6ce7f1df47 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:22:53 +0000 Subject: [PATCH 1/7] docs: add follow mode highlight & command legend design Co-Authored-By: Claude Opus 4.6 --- .../2026-03-07-follow-highlight-design.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/plans/2026-03-07-follow-highlight-design.md diff --git a/docs/plans/2026-03-07-follow-highlight-design.md b/docs/plans/2026-03-07-follow-highlight-design.md new file mode 100644 index 0000000..f2ad0f4 --- /dev/null +++ b/docs/plans/2026-03-07-follow-highlight-design.md @@ -0,0 +1,48 @@ +# Follow Mode Highlight & Command Legend + +## Problem + +With many NPCs on screen, it's hard to identify which NPC you're following. Follow mode also lacks a command legend (camera mode has one). + +## Design + +### Highlight Effect + +- Phaser `postFX.addGlow(0xff0000, outlineSize)` on the followed NPC's sprite +- Toggle on/off with `1` key (only in follow mode) +- Default: OFF +- When cycling NPCs (left/right), move glow from old sprite to new sprite (if enabled) +- Clear glow when switching back to camera mode + +### Mode-Specific Key Bindings + +The `1` key already gates on camera mode (`if (this.mode !== 'camera') return`). Add a parallel follow-mode branch: + +- Camera mode `1`: Spawn NPC (existing) +- Follow mode `1`: Toggle highlight + +### Follow Mode Command Legend + +Reuse existing `CommandPanel` class with a second instance: + +| Key | Label | +|-----|-------| +| ← → | Cycle NPC | +| 1 | Highlight | +| TAB | Camera Mode | + +- Title: "FOLLOW" (matching camera panel's "COMMANDS" style) +- Show when entering follow mode, hide when leaving +- Same position/styling as camera mode command panel + +### State + +- `highlightEnabled: boolean` on GameScene (default `false`) +- `followCommandPanel: CommandPanel` on GameScene (separate instance) + +### Data Flow + +1. Enter follow mode → show follow command panel, hide camera command panel +2. Press `1` in follow mode → toggle `highlightEnabled`, apply/remove glow on current sprite +3. Cycle NPC → if highlight enabled, remove glow from old sprite, add to new sprite +4. Exit follow mode → clear glow, reset `highlightEnabled`, hide follow command panel From 0c93915339a041b53d86d1997df491da5301e057 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:23:48 +0000 Subject: [PATCH 2/7] docs: add follow highlight implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-07-follow-highlight-plan.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/plans/2026-03-07-follow-highlight-plan.md diff --git a/docs/plans/2026-03-07-follow-highlight-plan.md b/docs/plans/2026-03-07-follow-highlight-plan.md new file mode 100644 index 0000000..884eb83 --- /dev/null +++ b/docs/plans/2026-03-07-follow-highlight-plan.md @@ -0,0 +1,266 @@ +# Follow Mode Highlight & Command Legend — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a toggleable red glow highlight on the followed NPC and a follow-mode command legend panel. + +**Architecture:** Parameterize `CommandPanel` to accept title and commands. Add highlight state and glow management to `GameScene`. Bind `1` key to toggle highlight in follow mode. + +**Tech Stack:** Phaser 3 postFX (glow), HTML/CSS command panel (existing) + +--- + +### Task 1: Parameterize CommandPanel constructor + +**Files:** +- Modify: `client/src/ui/CommandPanel.ts` + +**Step 1: Update constructor to accept title and commands** + +Change the constructor signature and replace hardcoded values: + +```typescript +export class CommandPanel { + private outerFrame: HTMLDivElement; + private listContainer: HTMLDivElement; + private visible = false; + + constructor(title = 'COMMANDS', commands: Command[] = [{ key: '1', label: 'Spawn NPC' }]) { +``` + +Replace line 90: +```typescript + title.textContent = `\u25C6 ${title} \u25C6`; +``` + +Replace line 108 (the hardcoded `addCommand` call) with a loop: +```typescript + for (const cmd of commands) { + this.addCommand(cmd); + } +``` + +**Step 2: Update camera panel instantiation in GameScene** + +In `GameScene.ts` line 48, pass explicit args to preserve current behavior: + +```typescript + this.commandPanel = new CommandPanel('COMMANDS', [{ key: '1', label: 'Spawn NPC' }]); +``` + +**Step 3: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds with no errors. + +**Step 4: Commit** + +```bash +git add client/src/ui/CommandPanel.ts client/src/scenes/GameScene.ts +git commit -m "refactor: parameterize CommandPanel title and commands" +``` + +--- + +### Task 2: Add follow mode command panel + +**Files:** +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add followCommandPanel property** + +After line 32, add: +```typescript + private followCommandPanel!: CommandPanel; +``` + +**Step 2: Create follow panel in create()** + +After line 49 (`this.commandPanel.show()`), add: +```typescript + this.followCommandPanel = new CommandPanel('FOLLOW', [ + { key: '\u2190 \u2192', label: 'Cycle NPC' }, + { key: '1', label: 'Highlight' }, + { key: 'TAB', label: 'Camera Mode' }, + ]); +``` + +**Step 3: Wire follow panel visibility into TAB handler** + +In the TAB key handler (lines 68-92), update panel visibility logic. + +When entering follow mode (line 79-81), replace: +```typescript + if (this.mode === 'follow') { + this.showFollowPanel(); + this.commandPanel.hide(); + } +``` +with: +```typescript + if (this.mode === 'follow') { + this.showFollowPanel(); + this.commandPanel.hide(); + this.followCommandPanel.show(); + } +``` + +When entering camera mode (lines 82-84), add follow panel hide: +```typescript + } else if (prevMode === 'follow') { + this.npcInfoPanel.hide(); + this.followCommandPanel.hide(); + } +``` + +The existing camera command panel show/hide block (lines 86-91) remains unchanged. + +**Step 4: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds. + +**Step 5: Commit** + +```bash +git add client/src/scenes/GameScene.ts +git commit -m "feat: add follow mode command legend panel" +``` + +--- + +### Task 3: Add highlight toggle and glow effect + +**Files:** +- Modify: `client/src/scenes/GameScene.ts` + +**Step 1: Add highlight state property** + +After the `followCommandPanel` property, add: +```typescript + private highlightEnabled = false; + private highlightedSpriteId: number | null = null; +``` + +**Step 2: Add glow helper methods** + +After `updateModeText()` method, add: + +```typescript + private applyHighlight(entityId: number): void { + const es = this.entitySprites.get(entityId); + if (!es) return; + this.clearHighlight(); + es.sprite.postFX.addGlow(0xff0000, 2, 0, false, 0.1, 4); + this.highlightedSpriteId = entityId; + } + + private clearHighlight(): void { + if (this.highlightedSpriteId != null) { + const es = this.entitySprites.get(this.highlightedSpriteId); + if (es) { + es.sprite.postFX.clear(); + } + this.highlightedSpriteId = null; + } + } +``` + +Note: `addGlow(color, outerStrength, innerStrength, knockout, quality, distance)` — we use a moderate outer strength of 2 with distance 4 for a visible but not overwhelming red outline. Adjust if needed after visual testing. + +**Step 3: Add follow-mode branch to the ONE key handler** + +In the `ONE` key handler (lines 95-101), change: +```typescript + this.input.keyboard!.addKey('ONE').on('down', () => { + if (this.mode !== 'camera') return; +``` +to: +```typescript + this.input.keyboard!.addKey('ONE').on('down', () => { + if (this.mode === 'follow') { + this.highlightEnabled = !this.highlightEnabled; + if (this.highlightEnabled) { + const npcIds = this.getNpcIds(); + const targetId = npcIds[this.followTargetIndex]; + if (targetId != null) this.applyHighlight(targetId); + } else { + this.clearHighlight(); + } + return; + } + if (this.mode !== 'camera') return; +``` + +**Step 4: Move glow when cycling NPCs** + +In the follow mode NPC cycling section (lines 344-356), after each index change and before `this.updateModeText()`, add glow transfer: + +For the `left` branch, after `this.followTargetIndex = ...`: +```typescript + if (this.highlightEnabled) { + this.applyHighlight(npcIds[this.followTargetIndex]); + } +``` + +Same for the `right` branch. + +**Step 5: Clear highlight when exiting follow mode** + +In the TAB handler, in the `else if (prevMode === 'follow')` block, add: +```typescript + this.clearHighlight(); + this.highlightEnabled = false; +``` + +**Step 6: Handle entity removal edge case** + +In `handleStateUpdate`, in the entity removal loop (lines 257-262), after `es.sprite.destroy()`, clear highlight if the removed entity was highlighted: + +```typescript + if (id === this.highlightedSpriteId) { + this.highlightedSpriteId = null; + } +``` + +**Step 7: Verify client builds** + +Run: `npm -w client run build` +Expected: Build succeeds. + +**Step 8: Commit** + +```bash +git add client/src/scenes/GameScene.ts +git commit -m "feat: add toggleable red glow highlight on followed NPC" +``` + +--- + +### Task 4: Manual visual verification + +**Step 1: Start server and client** + +Run in two terminals: +- `npm -w server run dev` +- `npm -w client run dev` + +**Step 2: Verify camera mode** + +- Confirm command legend shows "COMMANDS" with "1 Spawn NPC" +- Press `1` to spawn NPCs +- Confirm no follow command panel visible + +**Step 3: Verify follow mode** + +- Press TAB to enter follow mode +- Confirm command legend switches to "FOLLOW" with three commands +- Press left/right to cycle NPCs +- Press `1` to toggle highlight — confirm red glow appears around followed NPC +- Cycle NPCs — confirm glow follows to new NPC +- Press `1` again — confirm glow disappears +- Press TAB to return to camera mode — confirm follow panel hides, camera panel shows + +**Step 4: Commit any adjustments** + +If glow parameters need tuning (color, strength, distance), adjust in `applyHighlight()` and commit. From 9b55a563ed00f84f10648c7abfadb80bb7c0b7f3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:25:11 +0000 Subject: [PATCH 3/7] refactor: parameterize CommandPanel title and commands Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 2 +- client/src/ui/CommandPanel.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index f4ca8ad..f2fedec 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -45,7 +45,7 @@ export class GameScene extends Phaser.Scene { this.portraitCompositor = new PortraitCompositor(); this.npcInfoPanel = new NpcInfoPanel(); this.emojiManager = new InteractionEmojiManager(); - this.commandPanel = new CommandPanel(); + this.commandPanel = new CommandPanel('COMMANDS', [{ key: '1', label: 'Spawn NPC' }]); this.commandPanel.show(); // Start in camera mode, so show immediately // Draw tile grid diff --git a/client/src/ui/CommandPanel.ts b/client/src/ui/CommandPanel.ts index 14e639f..e1973b9 100644 --- a/client/src/ui/CommandPanel.ts +++ b/client/src/ui/CommandPanel.ts @@ -21,7 +21,7 @@ export class CommandPanel { private listContainer: HTMLDivElement; private visible = false; - constructor() { + constructor(title = 'COMMANDS', commands: Command[] = [{ key: '1', label: 'Spawn NPC' }]) { // Outer frame (doubled border, same as NpcInfoPanel) this.outerFrame = document.createElement('div'); this.outerFrame.id = 'command-panel'; @@ -76,8 +76,8 @@ export class CommandPanel { container.appendChild(shine); // Title - const title = document.createElement('div'); - title.style.cssText = ` + const titleEl = document.createElement('div'); + titleEl.style.cssText = ` font-size: 9px; color: ${EB.textMuted}; text-align: center; @@ -87,8 +87,8 @@ export class CommandPanel { position: relative; z-index: 2; `; - title.textContent = '\u25C6 COMMANDS \u25C6'; - container.appendChild(title); + titleEl.textContent = `\u25C6 ${title} \u25C6`; + container.appendChild(titleEl); // Commands list this.listContainer = document.createElement('div'); @@ -104,8 +104,10 @@ export class CommandPanel { this.outerFrame.appendChild(container); document.body.appendChild(this.outerFrame); - // Add default commands - this.addCommand({ key: '1', label: 'Spawn NPC' }); + // Add commands + for (const cmd of commands) { + this.addCommand(cmd); + } } private addCommand(cmd: Command): void { From 764d4d7355736a7168c8009a9de15ca276b619c5 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:26:15 +0000 Subject: [PATCH 4/7] feat: add follow mode command legend panel Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index f2fedec..c6657d9 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -30,6 +30,7 @@ export class GameScene extends Phaser.Scene { private npcInfoPanel!: NpcInfoPanel; private emojiManager!: InteractionEmojiManager; private commandPanel!: CommandPanel; + private followCommandPanel!: CommandPanel; constructor() { super({ key: 'GameScene' }); @@ -47,6 +48,11 @@ export class GameScene extends Phaser.Scene { this.emojiManager = new InteractionEmojiManager(); this.commandPanel = new CommandPanel('COMMANDS', [{ key: '1', label: 'Spawn NPC' }]); this.commandPanel.show(); // Start in camera mode, so show immediately + this.followCommandPanel = new CommandPanel('FOLLOW', [ + { key: '\u2190 \u2192', label: 'Cycle NPC' }, + { key: '1', label: 'Highlight' }, + { key: 'TAB', label: 'Camera Mode' }, + ]); // Draw tile grid this.drawWorld(); @@ -79,8 +85,10 @@ export class GameScene extends Phaser.Scene { if (this.mode === 'follow') { this.showFollowPanel(); this.commandPanel.hide(); + this.followCommandPanel.show(); } else if (prevMode === 'follow') { this.npcInfoPanel.hide(); + this.followCommandPanel.hide(); } // Command panel visibility From 76ea7afb3b55b8f0cb8eaa479d2ee12092cac4ee Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:27:37 +0000 Subject: [PATCH 5/7] feat: add toggleable red glow highlight on followed NPC Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index c6657d9..fbfadda 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -31,6 +31,8 @@ export class GameScene extends Phaser.Scene { private emojiManager!: InteractionEmojiManager; private commandPanel!: CommandPanel; private followCommandPanel!: CommandPanel; + private highlightEnabled = false; + private highlightedSpriteId: number | null = null; constructor() { super({ key: 'GameScene' }); @@ -89,6 +91,8 @@ export class GameScene extends Phaser.Scene { } else if (prevMode === 'follow') { this.npcInfoPanel.hide(); this.followCommandPanel.hide(); + this.clearHighlight(); + this.highlightEnabled = false; } // Command panel visibility @@ -101,6 +105,17 @@ export class GameScene extends Phaser.Scene { // Spawn NPC command (camera mode only) this.input.keyboard!.addKey('ONE').on('down', () => { + if (this.mode === 'follow') { + this.highlightEnabled = !this.highlightEnabled; + if (this.highlightEnabled) { + const npcIds = this.getNpcIds(); + const targetId = npcIds[this.followTargetIndex]; + if (targetId != null) this.applyHighlight(targetId); + } else { + this.clearHighlight(); + } + return; + } if (this.mode !== 'camera') return; const cam = this.cameras.main; const centerTileX = Math.floor((cam.scrollX + cam.width / 2) / TILE_SIZE); @@ -265,6 +280,9 @@ export class GameScene extends Phaser.Scene { for (const [id, es] of this.entitySprites) { if (!activeIds.has(id)) { es.sprite.destroy(); + if (id === this.highlightedSpriteId) { + this.highlightedSpriteId = null; + } this.entitySprites.delete(id); } } @@ -354,11 +372,17 @@ export class GameScene extends Phaser.Scene { if (left) { this.followTargetIndex = (this.followTargetIndex - 1 + npcIds.length) % npcIds.length; this.followThrottle = 150; + if (this.highlightEnabled) { + this.applyHighlight(npcIds[this.followTargetIndex]); + } this.updateModeText(); this.updateFollowPanel(); } else if (right) { this.followTargetIndex = (this.followTargetIndex + 1) % npcIds.length; this.followThrottle = 150; + if (this.highlightEnabled) { + this.applyHighlight(npcIds[this.followTargetIndex]); + } this.updateModeText(); this.updateFollowPanel(); } @@ -414,4 +438,22 @@ export class GameScene extends Phaser.Scene { this.modeText.setText(`Mode: ${this.mode.toUpperCase()} [TAB to toggle]`); } } + + private applyHighlight(entityId: number): void { + const es = this.entitySprites.get(entityId); + if (!es) return; + this.clearHighlight(); + es.sprite.postFX.addGlow(0xff0000, 2, 0, false, 0.1, 4); + this.highlightedSpriteId = entityId; + } + + private clearHighlight(): void { + if (this.highlightedSpriteId != null) { + const es = this.entitySprites.get(this.highlightedSpriteId); + if (es) { + es.sprite.postFX.clear(); + } + this.highlightedSpriteId = null; + } + } } From 29c8c5fd8c93596d8ccd4380a26810094c69314b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 16:33:54 +0000 Subject: [PATCH 6/7] fix: give each CommandPanel a unique DOM id Both camera and follow panels had id='command-panel'. Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 4 ++-- client/src/ui/CommandPanel.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index fbfadda..4b9a0c6 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -48,9 +48,9 @@ export class GameScene extends Phaser.Scene { this.portraitCompositor = new PortraitCompositor(); this.npcInfoPanel = new NpcInfoPanel(); this.emojiManager = new InteractionEmojiManager(); - this.commandPanel = new CommandPanel('COMMANDS', [{ key: '1', label: 'Spawn NPC' }]); + this.commandPanel = new CommandPanel('command-panel', 'COMMANDS', [{ key: '1', label: 'Spawn NPC' }]); this.commandPanel.show(); // Start in camera mode, so show immediately - this.followCommandPanel = new CommandPanel('FOLLOW', [ + this.followCommandPanel = new CommandPanel('follow-command-panel', 'FOLLOW', [ { key: '\u2190 \u2192', label: 'Cycle NPC' }, { key: '1', label: 'Highlight' }, { key: 'TAB', label: 'Camera Mode' }, diff --git a/client/src/ui/CommandPanel.ts b/client/src/ui/CommandPanel.ts index e1973b9..a23b220 100644 --- a/client/src/ui/CommandPanel.ts +++ b/client/src/ui/CommandPanel.ts @@ -21,10 +21,10 @@ export class CommandPanel { private listContainer: HTMLDivElement; private visible = false; - constructor(title = 'COMMANDS', commands: Command[] = [{ key: '1', label: 'Spawn NPC' }]) { + constructor(id = 'command-panel', title = 'COMMANDS', commands: Command[] = [{ key: '1', label: 'Spawn NPC' }]) { // Outer frame (doubled border, same as NpcInfoPanel) this.outerFrame = document.createElement('div'); - this.outerFrame.id = 'command-panel'; + this.outerFrame.id = id; this.outerFrame.style.cssText = ` position: fixed; bottom: 16px; From b42362e041ee7ce6a2ebb8f072a0449d277a47c6 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 17:03:47 +0000 Subject: [PATCH 7/7] fix: prevent orphaned accessory sprites from race conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 so concurrent callers await the same per-file load promise. Co-Authored-By: Claude Opus 4.6 --- client/src/scenes/GameScene.ts | 13 ++++--- client/src/sprites/CharacterCompositor.ts | 43 +++++++++++++---------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index 4b9a0c6..3845cde 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -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 diff --git a/client/src/sprites/CharacterCompositor.ts b/client/src/sprites/CharacterCompositor.ts index 463cf55..1732391 100644 --- a/client/src/sprites/CharacterCompositor.ts +++ b/client/src/sprites/CharacterCompositor.ts @@ -15,7 +15,7 @@ function appearanceKey(appearance: Appearance): string { export class CharacterCompositor { private scene: Phaser.Scene; - private loadedImages: Set = new Set(); + private loadingImages: Map> = 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[] = []; + + 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((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 { + return new Promise((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); }