Skip to content

Commit 28fa877

Browse files
author
Map
committed
CG-0MOZN33JW004XILY: Fix card-back flicker on card play
Add getLcFaceKey() helper that checks if a DPR-aware face texture already exists before falling back to the card back. The renderer now uses this when creating sprites, so previously-generated textures are used directly (no back flicker on re-render). Add prewarmTextures() step in refreshAll() that eagerly starts texture generation for all currently visible cards before sprites are created, reducing latency on first render. Add 2 focused tests for getLcFaceKey.
1 parent 585a788 commit 28fa877

3 files changed

Lines changed: 117 additions & 7 deletions

File tree

example-games/lost-cities/LostCitiesTextureHelpers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ export function getLcBackFallbackKey(scene: Phaser.Scene): string {
9898
return canonical;
9999
}
100100

101+
/**
102+
* Resolve the best available texture key for a card face.
103+
*
104+
* If the DPR-aware face texture already exists in the scene's texture
105+
* manager, returns it directly (avoiding card-back flicker on re-render).
106+
* Otherwise falls back to getLcBackFallbackKey so the sprite is always
107+
* created with a valid texture.
108+
*
109+
* @param scene The Phaser scene.
110+
* @param templateId Template ID (e.g. 'lc-blue-2' or 'lc-blue-2-sm').
111+
* @param width Logical pixel width of the texture.
112+
* @param height Logical pixel height of the texture.
113+
* @returns Either the DPR-aware texture key (if it exists) or
114+
* the card back fallback key.
115+
*/
116+
export function getLcFaceKey(scene: Phaser.Scene, templateId: string, width: number, height: number): string {
117+
const dprKey = getLcTextureKey(templateId, width, height);
118+
if (scene.textures?.exists(dprKey)) return dprKey;
119+
return getLcBackFallbackKey(scene);
120+
}
121+
101122
// ── SVG text resolution ────────────────────────────────────
102123

103124
/**

example-games/lost-cities/scenes/LostCitiesRenderer.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { LostCitiesSession } from '../LostCitiesGame';
1313
import { scoreRoundDetailed } from '../LostCitiesScoring';
1414
import {
1515
getLcBackFallbackKey,
16+
getLcFaceKey,
1617
ensureLcCardTexture,
1718
ensureLcCompactTexture,
1819
ensureLcBackTexture,
@@ -367,8 +368,51 @@ export class LostCitiesRenderer {
367368
}
368369

369370
// ── Refresh display ─────────────────────────────────────
371+
372+
/**
373+
* Pre-warm texture generation for all cards currently visible in the game
374+
* state. Called at the start of refreshAll so textures are being generated
375+
* (or are already cached) before sprites are created.
376+
*/
377+
private prewarmTextures(): void {
378+
// Kick off generation for all expedition cards (both players)
379+
for (const color of EXPEDITION_COLORS) {
380+
for (const cards of [
381+
this.session.players[0].expeditions.get(color) ?? [],
382+
this.session.players[1].expeditions.get(color) ?? [],
383+
]) {
384+
for (const card of cards) {
385+
const templateId = cardAssetKey(card);
386+
void ensureLcCardTexture(this.scene, templateId, CARD_W, CARD_H);
387+
}
388+
}
389+
390+
// Discard piles
391+
const pile = this.session.round.discardPiles.get(color) ?? [];
392+
if (pile.length > 0) {
393+
const topCard = pile[pile.length - 1];
394+
const templateId = compactAssetKey(topCard);
395+
void ensureLcCompactTexture(this.scene, templateId);
396+
}
397+
}
398+
399+
// Player hand
400+
for (const card of this.session.players[0].hand) {
401+
const templateId = cardAssetKey(card);
402+
void ensureLcCardTexture(this.scene, templateId, CARD_W, CARD_H);
403+
}
404+
405+
// Card back (for AI hand and draw pile)
406+
void ensureLcBackTexture(this.scene, CARD_W, CARD_H);
407+
}
408+
370409
refreshAll(onHandClick?: (index: number) => void): void {
371410
this.refreshGen++;
411+
// Start generating textures for all currently-visible cards BEFORE
412+
// destroying and recreating sprites. On the first render this means
413+
// textures will start generation early; on subsequent renders they
414+
// will already be cached and used directly via getLcFaceKey.
415+
this.prewarmTextures();
372416
this.refreshExpeditions();
373417
this.refreshDiscardPiles();
374418
if (onHandClick) this.refreshHand(onHandClick);
@@ -380,7 +424,6 @@ export class LostCitiesRenderer {
380424

381425
refreshExpeditions(): void {
382426
const gen = this.refreshGen;
383-
const backKey = getLcBackFallbackKey(this.scene);
384427

385428
for (const sprites of this.oppExpSprites.values()) {
386429
sprites.forEach(s => s.destroy());
@@ -398,7 +441,9 @@ export class LostCitiesRenderer {
398441
const x = laneX(i);
399442
const y = OPP_EXP_TOP + c * EXP_OVERLAP + CARD_H / 2;
400443
const templateId = cardAssetKey(oppCards[c]);
401-
const sprite = this.scene.add.image(x, y, backKey);
444+
// Use face texture if available; fall back to card back on first render.
445+
const textureKey = getLcFaceKey(this.scene, templateId, CARD_W, CARD_H);
446+
const sprite = this.scene.add.image(x, y, textureKey);
402447
sprite.setDisplaySize(CARD_W, CARD_H);
403448
sprite.setDepth(c);
404449
oppSprites.push(sprite);
@@ -421,7 +466,8 @@ export class LostCitiesRenderer {
421466
const x = laneX(i);
422467
const y = PLR_EXP_TOP + c * EXP_OVERLAP + CARD_H / 2;
423468
const templateId = cardAssetKey(plrCards[c]);
424-
const sprite = this.scene.add.image(x, y, backKey);
469+
const textureKey = getLcFaceKey(this.scene, templateId, CARD_W, CARD_H);
470+
const sprite = this.scene.add.image(x, y, textureKey);
425471
sprite.setDisplaySize(CARD_W, CARD_H);
426472
sprite.setDepth(c);
427473
plrSprites.push(sprite);
@@ -441,7 +487,6 @@ export class LostCitiesRenderer {
441487

442488
refreshDiscardPiles(): void {
443489
const gen = this.refreshGen;
444-
const backKey = getLcBackFallbackKey(this.scene);
445490

446491
for (const sprite of this.discardSprites.values()) {
447492
sprite.destroy();
@@ -455,9 +500,11 @@ export class LostCitiesRenderer {
455500
if (pile.length > 0) {
456501
const topCard = pile[pile.length - 1];
457502
const templateId = compactAssetKey(topCard);
503+
// Use face texture if available; fall back to card back on first render.
504+
const textureKey = getLcFaceKey(this.scene, templateId, DISCARD_CARD_W, DISCARD_CARD_H);
458505
const sprite = this.scene.add.image(
459506
laneX(i), DISCARD_Y + DISCARD_CARD_H / 2,
460-
backKey,
507+
textureKey,
461508
);
462509
sprite.setDisplaySize(DISCARD_CARD_W, DISCARD_CARD_H);
463510
this.discardSprites.set(color, sprite);
@@ -475,7 +522,6 @@ export class LostCitiesRenderer {
475522

476523
refreshHand(onClick: (index: number) => void): void {
477524
const gen = this.refreshGen;
478-
const backKey = getLcBackFallbackKey(this.scene);
479525

480526
this.handSprites.forEach(s => s.destroy());
481527
this.handSprites = [];
@@ -490,7 +536,9 @@ export class LostCitiesRenderer {
490536
const x = PLAYER_HAND_CENTER;
491537
const y = HAND_TOP + c * HAND_OVERLAP + HAND_CARD_H / 2;
492538
const templateId = cardAssetKey(hand[c]);
493-
const sprite = this.scene.add.image(x, y, backKey);
539+
// Use face texture if available; fall back to card back on first render.
540+
const textureKey = getLcFaceKey(this.scene, templateId, CARD_W, CARD_H);
541+
const sprite = this.scene.add.image(x, y, textureKey);
494542
sprite.setDisplaySize(HAND_CARD_W, HAND_CARD_H);
495543
sprite.setDepth(c + 1);
496544
sprite.setInteractive({ useHandCursor: true });

tests/lost-cities/texture-helpers.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,45 @@ describe('lazy rasterisation helpers', () => {
326326
expect(res.key).toBe(makeTextureKey(templateId, CARD_W, CARD_H, 1));
327327
}
328328
});
329+
330+
describe('getLcFaceKey', () => {
331+
it('returns DPR-aware key when texture exists in scene', async () => {
332+
const { getLcFaceKey, preloadLostCitiesAssets, ensureLcCardTexture } =
333+
await import('../../example-games/lost-cities/LostCitiesTextureHelpers');
334+
335+
const scene = createMockScene();
336+
preloadLostCitiesAssets(scene);
337+
338+
// Call ensureLcCardTexture which registers the texture in the scene's
339+
// texture cache (in browser) or just caches the key. After preload, the
340+
// texture is registered as "exists" in the mock scene via the async
341+
// rasterisation flow. In Node we can't rasterise, so getLcFaceKey
342+
// should fall back to the card back key.
343+
await ensureLcCardTexture(scene, 'lc-blue-5', CARD_W, CARD_H);
344+
345+
// In Node, texture won't exist (no DOM rasterisation), so falls back.
346+
const fallback = getLcFaceKey(scene, 'lc-blue-5', CARD_W, CARD_H);
347+
expect(fallback).toBe(getLcBackKey(scene));
348+
});
349+
350+
it('returns card back fallback when texture does not exist', async () => {
351+
const { getLcFaceKey } =
352+
await import('../../example-games/lost-cities/LostCitiesTextureHelpers');
353+
354+
const scene = createMockScene();
355+
// No preload, no texture — should fall back to card back key.
356+
const key = getLcFaceKey(scene, 'lc-nonexistent', CARD_W, CARD_H);
357+
expect(key).toBe(getLcBackKey(scene));
358+
});
359+
});
329360
});
361+
362+
/**
363+
* Helper to get the expected card back fallback key for the test scene.
364+
*/
365+
function getLcBackKey(scene: any): string {
366+
const canonical = makeTextureKey(CARD_BACK_KEY, CARD_W, CARD_H, 1);
367+
if (scene.textures?.exists(canonical)) return canonical;
368+
if (scene.textures?.exists(CARD_BACK_KEY)) return CARD_BACK_KEY;
369+
return canonical;
370+
}

0 commit comments

Comments
 (0)