Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example-games/gym/GymRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const GYM_SCENE_CATALOGUE: GymSceneEntry[] = [
sceneKey: GYM_DECK_RNG_KEY,
title: 'Deck & Seeded RNG',
description:
'Create, shuffle, and draw from decks using deterministic seeded randomness. Verify reproducible sequences across runs.',
'Displays all 52 cards face-up in a compact grid. Shuffle with a deterministic seed to see reproducible card arrangements.',
},
{
sceneKey: GYM_HAND_PILE_KEY,
Expand Down
307 changes: 97 additions & 210 deletions example-games/gym/scenes/GymDeckRngScene.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,48 @@
/**
* GymDeckRngScene -- Demonstrates deck lifecycle, deterministic
* seeded randomness, and card flip animations.
* GymDeckRngScene -- Demonstrates a full deck displayed face-up in a
* compact grid, with deterministic seeded randomness via shuffle.
*
* Features:
* - Create and shuffle a standard 52-card deck
* - Draw cards with visible state changes and flip animation
* - Enter a seed to reproduce identical shuffle/draw sequences
* - Reset the deck to start over
* - Flip animation using flipCard helper with reduced-motion fallback
* - All 52 cards displayed face-up in a compact grid on scene load
* - Shuffle button re-shuffles and re-renders the entire grid
* - Seed adjustment controls to reproduce identical shuffle sequences
* - Cards rendered using SLL zone anchors for consistent layout
*
* @module example-games/gym/scenes/GymDeckRngScene
*/

import { GymSceneBase } from './GymSceneBase';
import { GYM_DECK_RNG_KEY } from '../GymRegistry';
import { createStandardDeck, shuffleArray } from '../../../src/card-system/Deck';
import type { Card } from '../../../src/card-system/Card';
import { createSeededRng } from '../../../src/core-engine/SeededRng';
import { flipCard } from '../../../src/ui/flipCard';
import { dealCard } from '../../../src/ui/dealCard';
import { GameEventEmitter } from '../../../src/core-engine';
import { GAME_W } from '../../../src/ui/constants';
import { GAME_W, CARD_W, CARD_H } from '../../../src/ui/constants';
import { preloadCardAssets, getCardTexture, ensureCardTextureFallbacks } from '../../../src/ui/CardTextureHelpers';

/** Default seed for deterministic demonstrations. */
const DEFAULT_SEED = 42;

/** Grid layout: 8 columns, up to 7 rows (52 cards = 6 full rows + 4 in row 7). */
const GRID_COLUMNS = 8;

/** Horizontal gap between cards in the grid (pixels). */
const GRID_GAP_X = 4;

/** Vertical gap between cards in the grid (pixels). */
const GRID_GAP_Y = 4;

export class GymDeckRngScene extends GymSceneBase {
private deck: ReturnType<typeof createStandardDeck> = [];
private drawn: ReturnType<typeof createStandardDeck> = [];
private deck: Card[] = [];
private seed: number = DEFAULT_SEED;
private rng: ReturnType<typeof createSeededRng> = createSeededRng(DEFAULT_SEED);

// UI elements
private seedText!: Phaser.GameObjects.Text;
private deckCountText!: Phaser.GameObjects.Text;
private drawnCountText!: Phaser.GameObjects.Text;
private logTexts: Phaser.GameObjects.Text[] = [];
private eventLog: string[] = [];
private lastDrawnSprite: Phaser.GameObjects.Image | null = null;
private lastDrawnCard: ReturnType<typeof createStandardDeck>[0] | null = null;
private flipAnimActive: boolean = false;
private statusText!: Phaser.GameObjects.Text;

// Grid card sprites — tracked so they can be destroyed on shuffle
private cardSprites: Phaser.GameObjects.Image[] = [];
private cardLabels: Phaser.GameObjects.Text[] = [];

constructor() {
super({ key: GYM_DECK_RNG_KEY });
Expand All @@ -62,11 +65,11 @@ export class GymDeckRngScene extends GymSceneBase {
this.initHelp([
{
heading: 'Overview',
body: 'Demonstrates deck lifecycle and deterministic seeded randomness with flip and deal animations.'
body: 'Displays all 52 cards face-up in a compact grid, shuffled with the default seed (42) on load.'
},
{
heading: 'Controls',
body: '[ -1 ] / [ +1 ]: Adjust seed.\n[ Reset Seed ]: Restore default seed.\n[ Shuffle ]: Shuffle the deck with the current seed.\n[ Draw ]: Draw the top card (with flip animation).\n[ Flip Last ]: Flip the last drawn card (animation demo).\n[ Deal ]: Deal a card with arc animation.\n[ Reset ]: Reset deck to unshuffled state.\n\nTip: Press "?" (or click the ? button) to toggle this help.'
body: '[ -1 ] / [ +1 ]: Adjust seed and re-shuffle the deck.\n[ Reset Seed ]: Restore default seed (42) and re-shuffle.\n[ Shuffle ]: Re-shuffle using a random seed.\n\nTip: Using the same seed always produces the same card order.'
}
]);

Expand All @@ -85,225 +88,109 @@ export class GymDeckRngScene extends GymSceneBase {
this.addButton(cx + 180, y, '[ -1 ]', () => this.adjustSeed(-1));
this.addButton(cx + 240, y, '[ +1 ]', () => this.adjustSeed(1));
this.addButton(cx + 310, y, '[ Reset Seed ]', () => this.resetSeed());
this.addButton(cx + 450, y, '[ Shuffle ]', () => this.shuffleDeck());
this.addButton(cx + 560, y, '[ Draw ]', () => this.drawCard());

y += 28;
this.addButton(cx + 50, y, '[ Flip Last ]', () => this.flipLastDrawn());
this.addButton(cx + 210, y, '[ Deal ]', () => this.dealCardAction());
this.addButton(cx + 350, y, '[ Reset ]', () => this.resetDeck());
this.addButton(cx + 450, y, '[ Shuffle ]', () => {
this.seed = Math.floor(Math.random() * 100000);
this.seedText.setText(String(this.seed));
this.shuffleAndRedraw();
});

// ── Status ───────────────────────────────────────────
this.deckCountText = this.addLabel(cx + 100, y, 'Deck: 0 cards', { fontSize: '16px', color: '#88ff88' });
this.drawnCountText = this.addLabel(cx + 400, y, 'Drawn: 0 cards', { fontSize: '16px', color: '#88ff88' });
this.statusText = this.addLabel(cx + 600, y, '52 cards displayed', { fontSize: '16px', color: '#88ff88' });

// ── Card display area (positioned via SLL cardDisplay zone) ──
const cardDisplay = this.getGymAnchor('cardDisplay', 'center');
const cardDisplayY = cardDisplay?.y ?? 280;
this.addLabel(cardDisplay?.x ?? cx, cardDisplayY - 40, '── Last Drawn Card ──', { fontSize: '12px', color: '#669966' }).setOrigin(0.5);

// ── Event log area (positioned below card display) ──────
this.addLabel(cardDisplay?.x ?? cx, cardDisplayY + 140, '── Event Log ──', { fontSize: '12px', color: '#669966' }).setOrigin(0.5);

// Initialize deck
this.resetDeck();
// ── Initialize deck, shuffle with default seed, and render ──
this.seed = DEFAULT_SEED;
this.shuffleAndRedraw();
}

// ── Actions ──────────────────────────────────────────────

private adjustSeed(delta: number): void {
this.seed = Math.max(0, this.seed + delta);
this.seedText.setText(String(this.seed));
this.logEvent(`Seed changed to ${this.seed}`);
this.shuffleAndRedraw();
}

private resetSeed(): void {
this.seed = DEFAULT_SEED;
this.seedText.setText(String(this.seed));
this.logEvent(`Seed reset to ${DEFAULT_SEED}`);
this.shuffleAndRedraw();
}

private shuffleDeck(): void {
/**
* Shuffle the deck with the current seed and re-render the full grid.
*/
private shuffleAndRedraw(): void {
this.rng = createSeededRng(this.seed);
this.deck = createStandardDeck();
shuffleArray(this.deck, this.rng);
this.drawn = [];
this.clearLastDrawnSprite();
this.updateStatus();
this.logEvent(`Shuffled deck with seed=${this.seed}`);
this.clearGridSprites();
this.renderFullDeckGrid();
}

private drawCard(): void {
if (this.deck.length === 0) {
this.logEvent('Cannot draw: deck is empty');
return;
}
const card = this.deck.pop()!;
this.drawn.push(card);
this.clearLastDrawnSprite();
// Show the back (card.faceUp is false by default)
this.showCardSprite(card);
this.updateStatus();
this.logEvent(`Drew ${card.rank} of ${card.suit} (${this.deck.length} remaining)`);
}
// ── Grid rendering ───────────────────────────────────────

private flipLastDrawn(): void {
if (this.drawn.length === 0) {
this.logEvent('No drawn card to flip');
return;
}
if (this.flipAnimActive) {
this.logEvent('Flip animation already active');
return;
}
if (!this.lastDrawnSprite || !this.lastDrawnCard) {
this.logEvent('No visible card sprite to flip');
return;
}

const sprite = this.lastDrawnSprite;
const card = this.lastDrawnCard;

// Toggle face state
card.faceUp = !card.faceUp;
const newTexture = getCardTexture(card);

if (this.reducedMotion) {
// Instant texture swap for reduced motion
sprite.setTexture(newTexture);
this.logEvent(`Flipped card (instant, reduced-motion) -> ${newTexture}`);
} else {
// Animated flip
this.flipAnimActive = true;
flipCard({
scene: this,
target: sprite,
newTexture,
duration: 300,
onComplete: () => {
this.flipAnimActive = false;
this.logEvent(`Flipped card (animated) -> ${newTexture}`);
},
});
}
}

private dealCardAction(): void {
if (this.deck.length === 0) {
this.logEvent('Cannot deal: deck is empty');
return;
}
const card = this.deck.pop()!;
this.drawn.push(card);
this.clearLastDrawnSprite();

// Create a sprite at the deck position and deal it to the drawn area
/**
* Render all cards in the deck as a compact face-up grid within the
* cardDisplay SLL zone. Cards are scaled down to fit the available space.
*/
private renderFullDeckGrid(): void {
const cardDisplay = this.getGymAnchor('cardDisplay', 'center');
const destX = cardDisplay?.x ?? (GAME_W / 2 + 80);
const destY = cardDisplay?.y ?? 180;

const sprite = this.add.image(destX - 280, destY - 80, getCardTexture(card));
this.lastDrawnSprite = sprite;
this.lastDrawnCard = card;

if (this.reducedMotion) {
// Instant placement for reduced motion
sprite.setPosition(destX, destY);
this.updateStatus();
this.logEvent(`Dealt ${card.rank} of ${card.suit} (instant, reduced-motion)`);
} else {
const gameEvents = new GameEventEmitter();
gameEvents.on('card:dealt', () => {
this.updateStatus();
this.logEvent(`Dealt ${card.rank} of ${card.suit} (animated)`);
gameEvents.removeAllListeners();
});
dealCard({
scene: this,
target: sprite,
destX,
destY,
sourceX: destX - 280,
sourceY: destY - 80,
duration: 400,
gameEvents,
cardId: `${card.rank}${card.suit}`,
});
// Update immediately for responsiveness
this.updateStatus();
const centerX = cardDisplay?.x ?? GAME_W / 2;
// Shift the grid down within the cardDisplay zone to clear the header/controls
const centerY = (cardDisplay?.y ?? 270) + 100;

// Full-scale cards (48×65px), 8 columns = ~412px wide
const cardScale = 1.0;
const scaledCardW = CARD_W * cardScale;
const scaledCardH = CARD_H * cardScale;
const stepX = scaledCardW + GRID_GAP_X;
const stepY = scaledCardH + GRID_GAP_Y;

// Calculate the top-left origin of the grid so it's centered
const totalWidth = GRID_COLUMNS * stepX - GRID_GAP_X;
const totalRows = Math.ceil(this.deck.length / GRID_COLUMNS);
const totalHeight = totalRows * stepY - GRID_GAP_Y;
const gridStartX = centerX - totalWidth / 2 + scaledCardW / 2;
const gridStartY = centerY - totalHeight / 2 + scaledCardH / 2;

for (let i = 0; i < this.deck.length; i++) {
const card = this.deck[i];
card.faceUp = true; // Ensure all cards are face-up

const col = i % GRID_COLUMNS;
const row = Math.floor(i / GRID_COLUMNS);
const x = gridStartX + col * stepX;
const y = gridStartY + row * stepY;

const texture = getCardTexture(card);
const sprite = this.add.image(x, y, texture);
sprite.setScale(cardScale);
this.cardSprites.push(sprite);

// Small label below each card for easy identification
const label = this.add.text(x, y + scaledCardH / 2 + 10, `${card.rank}${card.suit[0]}`, {
fontSize: '8px',
color: '#88aacc',
fontFamily: 'monospace',
}).setOrigin(0.5, 0);
this.cardLabels.push(label);
}
}

private resetDeck(): void {
this.rng = createSeededRng(this.seed);
this.deck = createStandardDeck();
this.drawn = [];
this.clearLastDrawnSprite();
this.updateStatus();
this.logEvent(`Deck reset (unshuffled, seed=${this.seed})`);
this.statusText.setText(`${this.deck.length} cards displayed · seed=${this.seed}`);
}

// ── Card sprite helpers ──────────────────────────────────

private showCardSprite(card: ReturnType<typeof createStandardDeck>[0]): void {
const cardDisplay = this.getGymAnchor('cardDisplay', 'center');
const spriteX = cardDisplay?.x ?? (GAME_W / 2 + 80);
const spriteY = cardDisplay?.y ?? 180;
const texture = getCardTexture(card);
const sprite = this.add.image(spriteX, spriteY, texture);
// Label below
const label = this.add.text(spriteX, spriteY + 50, `${card.rank}${card.suit}`, {
fontSize: '14px',
color: '#ffffff',
fontFamily: 'monospace',
}).setOrigin(0.5);

// Store references; we clean up old sprites when drawing new ones
if (this.lastDrawnSprite) {
this.lastDrawnSprite.destroy();
}
this.lastDrawnSprite = sprite;
this.lastDrawnCard = card;

// Clean up label on scene shutdown
this.events.once('shutdown', () => {
try { label.destroy(); } catch (_) { /* ignore */ }
/**
* Destroy all card sprites and labels in the grid.
*/
private clearGridSprites(): void {
for (const sprite of this.cardSprites) {
try { sprite.destroy(); } catch (_) { /* ignore */ }
});
}

private clearLastDrawnSprite(): void {
if (this.lastDrawnSprite) {
try { this.lastDrawnSprite.destroy(); } catch (_) { /* ignore */ }
this.lastDrawnSprite = null;
}
this.lastDrawnCard = null;
}

// ── UI helpers ───────────────────────────────────────────

private updateStatus(): void {
this.deckCountText.setText(`Deck: ${this.deck.length} cards`);
this.drawnCountText.setText(`Drawn: ${this.drawn.length} cards`);
}
this.cardSprites = [];

private logEvent(msg: string): void {
this.eventLog.push(msg);
if (this.eventLog.length > 12) this.eventLog.shift();

// Remove old log texts
for (const t of this.logTexts) t.destroy();
this.logTexts = [];

const cardDisplay = this.getGymAnchor('cardDisplay', 'center');
const baseY = cardDisplay?.y ? cardDisplay.y + 100 : 280;
for (let i = 0; i < this.eventLog.length; i++) {
const txt = this.add.text(40, baseY + i * 18, this.eventLog[i], {
fontSize: '12px',
color: '#aaddaa',
fontFamily: 'monospace',
});
this.logTexts.push(txt);
for (const label of this.cardLabels) {
try { label.destroy(); } catch (_) { /* ignore */ }
}
this.cardLabels = [];
}
}
Loading
Loading