diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleOverlayManager.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleOverlayManager.ts index 3de6c909..d413e5a1 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleOverlayManager.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleOverlayManager.ts @@ -8,6 +8,7 @@ import { createOverlayBackground, createOverlayButton, createOverlayMenuButton, dismissOverlay as sharedDismissOverlay, } from '../../../src/ui'; +import { createHudText } from '../../../src/ui/Renderer/adapters/BeleagueredCastleAdapter'; export class BeleagueredCastleOverlayManager { @@ -50,10 +51,13 @@ export class BeleagueredCastleOverlayManager { }).setOrigin(0.5).setDepth(BUTTON_DEPTH); overlayObjects.push(title); - const stats = this.scene.add.text(GAME_W / 2, GAME_H / 2 - 20, - `Moves: ${this.state.moveCount} Time: ${mm}:${ss}`, { - fontSize: '22px', color: '#aaccaa', fontFamily: FONT_FAMILY, align: 'center', - }).setOrigin(0.5).setDepth(BUTTON_DEPTH); + const stats = createHudText(this.scene, GAME_W / 2, GAME_H / 2 - 20, + `Moves: ${this.state.moveCount} Time: ${mm}:${ss}`, '#aaccaa', { + fontSize: '22px', + originX: 0.5, + originY: 0.5, + }); + stats.setDepth(BUTTON_DEPTH); overlayObjects.push(stats); const newGameBtn = createOverlayButton(this.scene, GAME_W / 2 - 150, GAME_H / 2 + 50, '[ New Game ]', BUTTON_DEPTH); @@ -76,9 +80,13 @@ export class BeleagueredCastleOverlayManager { const { objects: overlayObjects } = createOverlayBackground(this.scene, { depth: OVERLAY_DEPTH, alpha: 0.75 }); - const title = this.scene.add.text(GAME_W / 2, GAME_H / 2 - 60, 'No Productive Moves Available', { - fontSize: '34px', color: '#ff8888', fontFamily: FONT_FAMILY, fontStyle: 'bold', - }).setOrigin(0.5).setDepth(BUTTON_DEPTH); + const title = createHudText(this.scene, GAME_W / 2, GAME_H / 2 - 60, + 'No Productive Moves Available', '#ff8888', { + fontSize: '34px', + originX: 0.5, + originY: 0.5, + }); + title.setDepth(BUTTON_DEPTH); overlayObjects.push(title); const undoBtn = createOverlayButton(this.scene, GAME_W / 2 - 180, GAME_H / 2 + 30, '[ Undo Last ]', BUTTON_DEPTH); diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts index bafa1f05..1734e1df 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts @@ -5,7 +5,8 @@ import Phaser from 'phaser'; import type { BeleagueredCastleState } from '../BeleagueredCastleState'; import { FOUNDATION_COUNT, TABLEAU_COUNT } from '../BeleagueredCastleState'; import { cardTextureKey } from '../../../src/ui'; -import { GAME_W, GAME_H, FONT_FAMILY, createSceneTitle, createSceneMenuButton } from '../../../src/ui'; +import { GAME_W, GAME_H, createSceneTitle, createSceneMenuButton } from '../../../src/ui'; +import { createBcHudText, createActionButton } from '../../../src/ui/Renderer/adapters/BeleagueredCastleAdapter'; import { BC_CARD_W, BC_CARD_H, CARD_GAP, CASCADE_OFFSET_Y, DRAG_DEPTH, DEAL_STAGGER, ANIM_DURATION, SNAP_BACK_DURATION, @@ -42,8 +43,8 @@ export class BeleagueredCastleRenderer { private moveCountText!: Phaser.GameObjects.Text; private timerText!: Phaser.GameObjects.Text; private seedText!: Phaser.GameObjects.Text; - private undoButton!: Phaser.GameObjects.Text; - private redoButton!: Phaser.GameObjects.Text; + private undoButton!: Phaser.GameObjects.Container; + private redoButton!: Phaser.GameObjects.Container; // Callbacks onUndoClick?: () => void; @@ -66,8 +67,8 @@ export class BeleagueredCastleRenderer { get moveText(): Phaser.GameObjects.Text { return this.moveCountText; } get timer(): Phaser.GameObjects.Text { return this.timerText; } get seedDisplay(): Phaser.GameObjects.Text { return this.seedText; } - get undoBtn(): Phaser.GameObjects.Text { return this.undoButton; } - get redoBtn(): Phaser.GameObjects.Text { return this.redoButton; } + get undoBtn(): Phaser.GameObjects.Container { return this.undoButton; } + get redoBtn(): Phaser.GameObjects.Container { return this.redoButton; } // ── UI creation ───────────────────────────────────────── createTitle(): void { @@ -114,40 +115,22 @@ export class BeleagueredCastleRenderer { } createHUD(seed: number): void { - this.moveCountText = this.scene.add.text(20, GAME_H - 28, 'Moves: 0', { - fontSize: '20px', color: '#aaccaa', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); - - this.timerText = this.scene.add.text(GAME_W / 2, GAME_H - 28, '00:00', { - fontSize: '20px', color: '#aaccaa', fontFamily: FONT_FAMILY, - }).setOrigin(0.5, 0.5); - - this.seedText = this.scene.add.text(GAME_W - 20, GAME_H - 28, `Seed: ${seed}`, { - fontSize: '18px', color: '#668866', fontFamily: FONT_FAMILY, - }).setOrigin(1, 0.5); - - this.undoButton = this.scene.add.text(GAME_W - 220, this.layout.headerY, '[ Undo ]', { - fontSize: '18px', color: '#557755', fontFamily: FONT_FAMILY, - }).setOrigin(0.5).setInteractive({ useHandCursor: true }); - this.undoButton.on('pointerdown', () => this.onUndoClick?.()); - this.undoButton.on('pointerover', () => { - if (this.onUndoClick) this.undoButton.setColor('#88ff88'); - }); - this.undoButton.on('pointerout', () => this.refreshUndoRedoButtons(false, false)); - - this.redoButton = this.scene.add.text(GAME_W - 140, this.layout.headerY, '[ Redo ]', { - fontSize: '18px', color: '#557755', fontFamily: FONT_FAMILY, - }).setOrigin(0.5).setInteractive({ useHandCursor: true }); - this.redoButton.on('pointerdown', () => this.onRedoClick?.()); - this.redoButton.on('pointerover', () => { - if (this.onRedoClick) this.redoButton.setColor('#88ff88'); + this.moveCountText = createBcHudText(this.scene, 20, GAME_H - 28, 'Moves: 0', '#aaccaa', { fontSize: '20px' }); + + this.timerText = createBcHudText(this.scene, GAME_W / 2, GAME_H - 28, '00:00', '#aaccaa', { fontSize: '20px' }); + + this.seedText = createBcHudText(this.scene, GAME_W - 20, GAME_H - 28, `Seed: ${seed}`, '#668866', { + fontSize: '18px', + originX: 1, }); - this.redoButton.on('pointerout', () => this.refreshUndoRedoButtons(false, false)); + + this.undoButton = createActionButton(this.scene, GAME_W - 220, this.layout.headerY, 60, 'Undo', () => this.onUndoClick?.()); + this.redoButton = createActionButton(this.scene, GAME_W - 140, this.layout.headerY, 60, 'Redo', () => this.onRedoClick?.()); } refreshUndoRedoButtons(canUndo: boolean, canRedo: boolean): void { - this.undoButton.setColor(canUndo ? '#aaccaa' : '#557755'); - this.redoButton.setColor(canRedo ? '#aaccaa' : '#557755'); + this.undoButton.setAlpha(canUndo ? 1 : 0.5); + this.redoButton.setAlpha(canRedo ? 1 : 0.5); } // ── Foundation rendering ──────────────────────────────── diff --git a/example-games/golf/scenes/GolfOverlayManager.ts b/example-games/golf/scenes/GolfOverlayManager.ts index 1a0488b6..ac578254 100644 --- a/example-games/golf/scenes/GolfOverlayManager.ts +++ b/example-games/golf/scenes/GolfOverlayManager.ts @@ -6,10 +6,13 @@ import type { TranscriptRecorder } from '../GameTranscript'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import type { SoundManager, GameEventEmitter } from '../../../src/core-engine'; +import { GAME_W, GAME_H } from '../../../src/ui'; import { - GAME_W, GAME_H, FONT_FAMILY, - createOverlayBackground, createOverlayButton, createOverlayMenuButton, -} from '../../../src/ui'; + createActionButton, + createGolfHudText, + createGolfMenuButton, + createOverlayBackground, +} from '../../../src/ui/Renderer/adapters/GolfAdapter'; import { SFX_KEYS } from './GolfConstants'; import type { GolfSession } from '../GolfGame'; @@ -64,35 +67,36 @@ export class GolfOverlayManager { ); const winnerText = results.winnerIndex === 0 ? 'You Win!' : 'AI Wins!'; - this.scene.add - .text( - GAME_W / 2, - GAME_H / 2 - 50, - `${winnerText}\n\nYou: ${results.scores[0]} pts\nAI: ${results.scores[1]} pts`, - { - fontSize: '28px', - color: '#ffffff', - fontFamily: FONT_FAMILY, - align: 'center', - }, - ) - .setOrigin(0.5) - .setDepth(11); + createGolfHudText( + this.scene, + GAME_W / 2, + GAME_H / 2 - 50, + `${winnerText}\n\nYou: ${results.scores[0]} pts\nAI: ${results.scores[1]} pts`, + '#ffffff', + { fontSize: '28px', originX: 0.5, align: 'center' }, + ); // Play again button - const btn = createOverlayButton( - this.scene, GAME_W / 2 - 85, GAME_H / 2 + 85, '[ Play Again ]', + createActionButton( + this.scene, + GAME_W / 2 - 85, + GAME_H / 2 + 85, + 170, + '[ Play Again ]', + () => { + this.soundManager?.play(SFX_KEYS.UI_CLICK); + this.gameEvents.emit('ui-interaction', { + elementId: 'play-again', + action: 'click', + }); + this.scene.scene.restart(); + }, + { depth: 11 }, ); - btn.on('pointerdown', () => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - this.gameEvents.emit('ui-interaction', { - elementId: 'play-again', - action: 'click', - }); - this.scene.scene.restart(); - }); // Menu button - createOverlayMenuButton(this.scene, GAME_W / 2 + 85, GAME_H / 2 + 85); + createGolfMenuButton(this.scene, GAME_W / 2 + 85, GAME_H / 2 + 85, 80, { + depth: 11, + }); } } diff --git a/example-games/golf/scenes/GolfRenderer.ts b/example-games/golf/scenes/GolfRenderer.ts index af7f0227..58151f38 100644 --- a/example-games/golf/scenes/GolfRenderer.ts +++ b/example-games/golf/scenes/GolfRenderer.ts @@ -4,11 +4,13 @@ import { scoreVisibleCards, scoreGrid } from '../GolfScoring'; import type { GolfSession } from '../GolfGame'; +import { GAME_W, GAME_H } from '../../../src/ui'; import { - GAME_W, GAME_H, FONT_FAMILY, + createGolfHudText, getCardTexture, - createSceneTitle, createSceneMenuButton, -} from '../../../src/ui'; + createSceneTitle, + createSceneMenuButton, +} from '../../../src/ui/Renderer/adapters/GolfAdapter'; import { GOLF_CARD_H, CARD_GAP, GRID_ROWS, @@ -61,21 +63,23 @@ export class GolfRenderer { // Player labels above each grid const gridH = GRID_ROWS * GOLF_CARD_H + (GRID_ROWS - 1) * CARD_GAP; - this.humanLabel = this.scene.add - .text(this.layout.humanGridCenterX, this.layout.humanGridCenterY - gridH / 2 - 24, 'You', { - fontSize: '24px', - color: '#ffffff', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); - - this.aiLabel = this.scene.add - .text(this.layout.aiGridCenterX, this.layout.aiGridCenterY - gridH / 2 - 24, 'AI', { - fontSize: '24px', - color: '#cccccc', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + this.humanLabel = createGolfHudText( + this.scene, + this.layout.humanGridCenterX, + this.layout.humanGridCenterY - gridH / 2 - 24, + 'You', + '#ffffff', + { fontSize: '24px', originX: 0.5 }, + ); + + this.aiLabel = createGolfHudText( + this.scene, + this.layout.aiGridCenterX, + this.layout.aiGridCenterY - gridH / 2 - 24, + 'AI', + '#cccccc', + { fontSize: '24px', originX: 0.5 }, + ); } createPiles( @@ -89,13 +93,14 @@ export class GolfRenderer { this.stockSprite.on('pointerdown', onStockClick); } - this.scene.add - .text(this.layout.stockPileCenterX, this.layout.stockPileCenterY + GOLF_CARD_H / 2 + 16, 'Stock', { - fontSize: '16px', - color: '#aaccaa', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + createGolfHudText( + this.scene, + this.layout.stockPileCenterX, + this.layout.stockPileCenterY + GOLF_CARD_H / 2 + 16, + 'Stock', + '#aaccaa', + { fontSize: '16px', originX: 0.5 }, + ); // Discard pile (lower center) this.discardSprite = this.scene.add.image(this.layout.discardPileCenterX, this.layout.discardPileCenterY, 'card_back'); @@ -104,13 +109,14 @@ export class GolfRenderer { this.discardSprite.on('pointerdown', onDiscardClick); } - this.scene.add - .text(this.layout.discardPileCenterX, this.layout.discardPileCenterY + GOLF_CARD_H / 2 + 16, 'Discard', { - fontSize: '16px', - color: '#aaccaa', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + createGolfHudText( + this.scene, + this.layout.discardPileCenterX, + this.layout.discardPileCenterY + GOLF_CARD_H / 2 + 16, + 'Discard', + '#aaccaa', + { fontSize: '16px', originX: 0.5 }, + ); } createGrids(onHumanCardClick: (index: number) => void): void { @@ -137,40 +143,44 @@ export class GolfRenderer { // Scores below each grid const gridH = GRID_ROWS * GOLF_CARD_H + (GRID_ROWS - 1) * CARD_GAP; - this.humanScoreText = this.scene.add - .text(this.layout.humanGridCenterX, this.layout.humanGridCenterY + gridH / 2 + 24, 'Score: 0', { - fontSize: '22px', - color: '#ffffff', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); - - this.aiScoreText = this.scene.add - .text(this.layout.aiGridCenterX, this.layout.aiGridCenterY + gridH / 2 + 24, 'Score: 0', { - fontSize: '22px', - color: '#cccccc', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + this.humanScoreText = createGolfHudText( + this.scene, + this.layout.humanGridCenterX, + this.layout.humanGridCenterY + gridH / 2 + 24, + 'Score: 0', + '#ffffff', + { fontSize: '22px', originX: 0.5 }, + ); + + this.aiScoreText = createGolfHudText( + this.scene, + this.layout.aiGridCenterX, + this.layout.aiGridCenterY + gridH / 2 + 24, + 'Score: 0', + '#cccccc', + { fontSize: '22px', originX: 0.5 }, + ); // Turn indicator above the stock pile in center - this.turnText = this.scene.add - .text(this.layout.stockPileCenterX, this.layout.stockPileCenterY - GOLF_CARD_H / 2 - 24, '', { - fontSize: '20px', - color: '#ffdd44', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + this.turnText = createGolfHudText( + this.scene, + this.layout.stockPileCenterX, + this.layout.stockPileCenterY - GOLF_CARD_H / 2 - 24, + '', + '#ffdd44', + { fontSize: '20px', originX: 0.5 }, + ); } createInstructions(): void { - this.instructionText = this.scene.add - .text(GAME_W / 2, GAME_H - 18, '', { - fontSize: '18px', - color: '#88aa88', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5); + this.instructionText = createGolfHudText( + this.scene, + GAME_W / 2, + GAME_H - 18, + '', + '#88aa88', + { fontSize: '18px', originX: 0.5 }, + ); } // ── Grid layout helpers ───────────────────────────────── diff --git a/example-games/golf/scenes/GolfReplayController.ts b/example-games/golf/scenes/GolfReplayController.ts index 4026f53d..cecd3a1c 100644 --- a/example-games/golf/scenes/GolfReplayController.ts +++ b/example-games/golf/scenes/GolfReplayController.ts @@ -5,10 +5,13 @@ import type { Card, Rank, Suit } from '../../../src/card-system/Card'; import { scoreVisibleCards } from '../GolfScoring'; import type { BoardSnapshot, CardSnapshot } from '../GameTranscript'; +import { GAME_W, GAME_H } from '../../../src/ui'; import { - GAME_W, GAME_H, FONT_FAMILY, - createOverlayBackground, createOverlayButton, dismissOverlay, -} from '../../../src/ui'; + createActionButton, + createGolfHudText, + createOverlayBackground, + dismissOverlay, +} from '../../../src/ui/Renderer/adapters/GolfAdapter'; import type { GolfSession } from '../GolfGame'; import type { GolfRenderer } from './GolfRenderer'; @@ -177,15 +180,15 @@ export class GolfReplayController { const boxTop = GAME_H / 2 - 210; // Title - const title = this.scene.add - .text(centerX, boxTop + 30, 'Interactive Takeover', { - fontSize: '26px', - color: '#ffdd44', - fontFamily: FONT_FAMILY, - fontStyle: 'bold', - }) - .setOrigin(0.5) - .setDepth(21); + const title = createGolfHudText( + this.scene, + centerX, + boxTop + 30, + 'Interactive Takeover', + '#ffdd44', + { fontSize: '26px', originX: 0.5 }, + ); + title.setDepth(21); this.takeoverOverlayObjects.push(title); // Gather debug info @@ -207,16 +210,15 @@ export class GolfReplayController { `Last action: ${options.lastAction}`, ]; - const info = this.scene.add - .text(centerX, boxTop + 90, infoLines.join('\n'), { - fontSize: '16px', - color: '#cccccc', - fontFamily: FONT_FAMILY, - align: 'center', - lineSpacing: 6, - }) - .setOrigin(0.5, 0) - .setDepth(21); + const info = createGolfHudText( + this.scene, + centerX, + boxTop + 90, + infoLines.join('\n'), + '#cccccc', + { fontSize: '16px', originX: 0.5, originY: 0, align: 'center', lineSpacing: 6 }, + ); + info.setDepth(21); this.takeoverOverlayObjects.push(info); // Helper to destroy overlay and mark interactive mode @@ -233,50 +235,50 @@ export class GolfReplayController { const buttonSpacing = 170; // "Human plays next" button - const humanBtn = createOverlayButton( + const humanBtn = createActionButton( this.scene, centerX - buttonSpacing, buttonY, + 200, '[ Human plays next ]', - 21, - { fontSize: '16px' }, + () => { + dismissAndAct(() => this.enableInteractiveMode({ nextPlayer: 0 })); + }, + { fontSize: '16px', depth: 21 }, ); - humanBtn.on('pointerdown', () => { - dismissAndAct(() => this.enableInteractiveMode({ nextPlayer: 0 })); - }); this.takeoverOverlayObjects.push(humanBtn); // "AI plays next" button - const aiBtn = createOverlayButton( + const aiBtn = createActionButton( this.scene, centerX, buttonY, + 200, '[ AI plays next ]', - 21, - { fontSize: '16px' }, + () => { + dismissAndAct(() => this.enableInteractiveMode({ nextPlayer: 1 })); + }, + { fontSize: '16px', depth: 21 }, ); - aiBtn.on('pointerdown', () => { - dismissAndAct(() => this.enableInteractiveMode({ nextPlayer: 1 })); - }); this.takeoverOverlayObjects.push(aiBtn); // "Resume replay" button - const resumeBtn = createOverlayButton( + const resumeBtn = createActionButton( this.scene, centerX + buttonSpacing, buttonY, + 200, '[ Resume replay ]', - 21, - { fontSize: '16px' }, + () => { + dismissAndAct(() => { + gameEvents.emit( + 'resume-replay' as Parameters[0], + {} as never, + ); + }); + }, + { fontSize: '16px', depth: 21 }, ); - resumeBtn.on('pointerdown', () => { - dismissAndAct(() => { - gameEvents.emit( - 'resume-replay' as Parameters[0], - {} as never, - ); - }); - }); this.takeoverOverlayObjects.push(resumeBtn); } } diff --git a/example-games/sushi-go/scenes/SushiGoOverlayManager.ts b/example-games/sushi-go/scenes/SushiGoOverlayManager.ts index e95ab1a4..a329efa8 100644 --- a/example-games/sushi-go/scenes/SushiGoOverlayManager.ts +++ b/example-games/sushi-go/scenes/SushiGoOverlayManager.ts @@ -6,10 +6,12 @@ import { GAME_W, GAME_H, FONT_FAMILY, - createOverlayButton, - createOverlayMenuButton, OverlayManager, } from '../../../src/ui'; +import { + createActionButton, + createSushiGoMenuButton, +} from '../../../src/ui/Renderer/adapters/SushiGoAdapter'; import { scoreTableauBreakdown } from '../SushiGoScoring'; import type { SushiGoSession, RoundResult } from '../SushiGoGame'; import { getWinnerIndex } from '../SushiGoGame'; @@ -92,12 +94,19 @@ export class SushiGoOverlayManager { .setDepth(11); this.overlayManager.add(text); - const btn = createOverlayButton(this.scene, GAME_W / 2, buttonY, '[ Next Round ]'); - btn.on('pointerdown', () => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - this.overlayManager.dismiss(); - onNextRound(); - }); + const btn = createActionButton( + this.scene, + GAME_W / 2 - 100, + buttonY - 16, + 200, + 'Next Round', + () => { + this.soundManager?.play(SFX_KEYS.UI_CLICK); + this.overlayManager.dismiss(); + onNextRound(); + }, + { depth: 11 }, + ); this.overlayManager.add(btn); } @@ -195,14 +204,21 @@ export class SushiGoOverlayManager { .setDepth(11); this.overlayManager.add(text); - const playBtn = createOverlayButton(this.scene, GAME_W / 2 - 80, buttonY, '[ Play Again ]'); - playBtn.on('pointerdown', () => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - onRestart(); - }); + const playBtn = createActionButton( + this.scene, + GAME_W / 2 - 140, + buttonY - 16, + 120, + 'Play Again', + () => { + this.soundManager?.play(SFX_KEYS.UI_CLICK); + onRestart(); + }, + { depth: 11 }, + ); this.overlayManager.add(playBtn); - const menuBtn = createOverlayMenuButton(this.scene, GAME_W / 2 + 80, buttonY); + const menuBtn = createSushiGoMenuButton(this.scene, GAME_W / 2 + 20, buttonY - 16, 120, { depth: 11 }); this.overlayManager.add(menuBtn); } diff --git a/src/ui/Renderer/adapters/BeleagueredCastleAdapter.ts b/src/ui/Renderer/adapters/BeleagueredCastleAdapter.ts new file mode 100644 index 00000000..0ed58b75 --- /dev/null +++ b/src/ui/Renderer/adapters/BeleagueredCastleAdapter.ts @@ -0,0 +1,65 @@ +/** + * Beleaguered Castle Adapter – bridges Beleaguered Castle scene rendering + * to the shared Renderer API. + * + * Re-exports shared helpers (`createHudText`, `createActionButton`) and + * provides Beleaguered Castle–specific defaults for HUD text so that + * `BeleagueredCastleRenderer` and `BeleagueredCastleOverlayManager` can + * use engine-standard patterns without duplicating styling logic. + * + * @module BeleagueredCastleAdapter + */ + +import Phaser from 'phaser'; +import { + createHudText as sharedCreateHudText, + createActionButton as sharedCreateActionButton, + HudTextOptions, + ActionButtonOptions, +} from '../index'; +import { FONT_FAMILY } from '../../../ui/constants'; + +// Re-export shared helpers so callers can import from a single adapter module. +export { sharedCreateHudText as createHudText }; +export { sharedCreateActionButton as createActionButton }; +export type { HudTextOptions, ActionButtonOptions }; + +/** Default depth for HUD UI elements in Beleaguered Castle. */ +const BC_DEPTH_HUD = 1000; + +/** + * Create a HUD text element styled for Beleaguered Castle. + * + * This is a thin wrapper around `createHudText` that applies the BC + * default font family and depth convention so that renderer code + * doesn't need to repeat styling parameters. + * + * @param scene - The Phaser scene. + * @param x - X position. + * @param y - Y position. + * @param text - Initial text content. + * @param color - CSS colour string (e.g. '#aaccaa'). + * @param options - Optional overrides (font size, origin, etc.). + * @returns A Phaser.Text object with depth DEPTH_HUD. + */ +export function createBcHudText( + scene: Phaser.Scene, + x: number, + y: number, + text: string, + color: string, + options?: { fontSize?: string } & HudTextOptions, +): Phaser.GameObjects.Text { + const textObj = sharedCreateHudText(scene, x, y, text, color, { + fontFamily: FONT_FAMILY, + ...options, + }); + try { + textObj.setDepth(BC_DEPTH_HUD); + } catch { + // Depth may not be available in headless / test environments. + } + return textObj; +} + +export const BC_ADAPTER_VERSION = '1.0.0'; diff --git a/src/ui/Renderer/adapters/GolfAdapter.ts b/src/ui/Renderer/adapters/GolfAdapter.ts new file mode 100644 index 00000000..6bb39cdf --- /dev/null +++ b/src/ui/Renderer/adapters/GolfAdapter.ts @@ -0,0 +1,110 @@ +/** + * Golf Adapter – bridges Golf scene rendering to the shared Renderer API. + * + * Re-exports shared helpers (`createHudText`, `createActionButton`, + * `createHudContainer`) and provides Golf-specific defaults for HUD text + * so that `GolfRenderer`, `GolfOverlayManager`, and `GolfReplayController` + * can use engine-standard patterns without duplicating styling logic. + * + * @module GolfAdapter + */ + +import Phaser from 'phaser'; +import { + createHudText as sharedCreateHudText, + createActionButton as sharedCreateActionButton, + createHudContainer as sharedCreateHudContainer, + HudTextOptions, + ActionButtonOptions, +} from '../index'; +import { + getCardTexture as sharedGetCardTexture, +} from '../../CardTextureHelpers'; +import { + createSceneTitle as sharedCreateSceneTitle, + createSceneMenuButton as sharedCreateSceneMenuButton, +} from '../../SceneHeader'; +import { + createOverlayBackground as sharedCreateOverlayBackground, + dismissOverlay as sharedDismissOverlay, +} from '../../Overlay'; +import { FONT_FAMILY } from '../../../ui/constants'; + +// Re-export shared helpers so callers can import from a single adapter module. +export { sharedCreateHudText as createHudText }; +export { sharedCreateActionButton as createActionButton }; +export { sharedCreateHudContainer as createHudContainer }; +export { sharedGetCardTexture as getCardTexture }; +export { sharedCreateSceneTitle as createSceneTitle }; +export { sharedCreateSceneMenuButton as createSceneMenuButton }; +export { sharedCreateOverlayBackground as createOverlayBackground }; +export { sharedDismissOverlay as dismissOverlay }; +export type { HudTextOptions, ActionButtonOptions }; + +/** Default depth for HUD UI elements in Golf. */ +const GOLF_DEPTH_HUD = 1000; + +/** + * Create a HUD text element styled for Golf. + * + * This is a thin wrapper around `createHudText` that applies the Golf + * default font family and depth convention so that renderer code + * doesn't need to repeat styling parameters. + * + * @param scene - The Phaser scene. + * @param x - X position. + * @param y - Y position. + * @param text - Initial text content. + * @param color - CSS colour string (e.g. '#aaccaa'). + * @param options - Optional overrides (font size, origin, etc.). + * @returns A Phaser.Text object with depth DEPTH_HUD. + */ +export function createGolfHudText( + scene: Phaser.Scene, + x: number, + y: number, + text: string, + color: string, + options?: { fontSize?: string } & HudTextOptions, +): Phaser.GameObjects.Text { + const textObj = sharedCreateHudText(scene, x, y, text, color, { + fontFamily: FONT_FAMILY, + ...options, + }); + try { + textObj.setDepth(GOLF_DEPTH_HUD); + } catch { + // Depth may not be available in headless / test environments. + } + return textObj; +} + +// --------------------------------------------------------------------------- +// Menu button helper +// --------------------------------------------------------------------------- + +/** + * Create a "Menu" action button that navigates back to the GameSelectorScene. + * + * Wraps `createActionButton` with Golf's overlay conventions. + * + * @param scene - The Phaser scene. + * @param x - X position (left edge of the button). + * @param y - Y position (top edge of the button). + * @param width - Button width in pixels. + * @param options - Optional styling overrides forwarded to `createActionButton`. + * @returns A Phaser.Container containing the menu button. + */ +export function createGolfMenuButton( + scene: Phaser.Scene, + x: number, + y: number, + width: number, + options?: ActionButtonOptions, +): Phaser.GameObjects.Container { + return sharedCreateActionButton(scene, x, y, width, 'Menu', () => { + scene.scene.start('GameSelectorScene'); + }, options); +} + +export const GOLF_ADAPTER_VERSION = '1.0.0'; diff --git a/src/ui/Renderer/adapters/SushiGoAdapter.ts b/src/ui/Renderer/adapters/SushiGoAdapter.ts new file mode 100644 index 00000000..961fdd0a --- /dev/null +++ b/src/ui/Renderer/adapters/SushiGoAdapter.ts @@ -0,0 +1,56 @@ +/** + * Sushi Go Adapter – bridges Sushi Go overlay rendering to the shared + * Renderer API. + * + * Re-exports shared helpers (`createActionButton`) so that + * SushiGoOverlayManager can use engine-standard patterns for overlay + * buttons without duplicating styling logic. + * + * @module SushiGoAdapter + */ + +import Phaser from 'phaser'; +import { + createActionButton as sharedCreateActionButton, + ActionButtonOptions, +} from '../index'; + +// --------------------------------------------------------------------------- +// Re-exports +// --------------------------------------------------------------------------- + +/** + * Create an action button using the shared renderer helper. + * + * @see sharedCreateActionButton + */ +export { sharedCreateActionButton as createActionButton }; +export type { ActionButtonOptions }; + +// --------------------------------------------------------------------------- +// Overlay button helpers +// --------------------------------------------------------------------------- + +/** + * Create a menu button that navigates back to the GameSelectorScene. + * + * Wraps `createActionButton` with Sushi Go's overlay conventions. + * + * @param scene - The Phaser scene. + * @param x - X position (left edge of the button). + * @param y - Y position (top edge of the button). + * @param width - Button width in pixels. + * @param options - Optional styling overrides forwarded to `createActionButton`. + * @returns A Phaser.Container containing the menu button. + */ +export function createSushiGoMenuButton( + scene: Phaser.Scene, + x: number, + y: number, + width: number, + options?: ActionButtonOptions, +): Phaser.GameObjects.Container { + return sharedCreateActionButton(scene, x, y, width, 'Menu', () => { + scene.scene.start('GameSelectorScene'); + }, options); +} diff --git a/src/ui/Renderer/index.ts b/src/ui/Renderer/index.ts index 105d883a..b833a16b 100644 --- a/src/ui/Renderer/index.ts +++ b/src/ui/Renderer/index.ts @@ -28,6 +28,8 @@ export type { export interface ActionButtonOptions { /** Height of the button in pixels. Defaults to the scene's layout value or 32. */ height?: number; + /** Display depth. Defaults to the Container's default (0). */ + depth?: number; /** Background fill colour. Defaults to 0x554422. */ fillColor?: number; /** Background fill alpha. Defaults to 0.8. */ @@ -49,6 +51,10 @@ export interface HudTextOptions { /** Override the default origin (default is (0, 0.5)). */ originX?: number; originY?: number; + /** Text alignment ('left', 'center', 'right'). */ + align?: Phaser.Types.GameObjects.Text.TextStyle['align']; + /** Extra line spacing in pixels. */ + lineSpacing?: number; } // --------------------------------------------------------------------------- @@ -190,13 +196,21 @@ export function createHudText( const fontSize = options?.fontSize ?? '16px'; const originX = options?.originX ?? 0; const originY = options?.originY ?? 0.5; - - return scene.add.text(x, y, text, { + const baseStyle: Phaser.Types.GameObjects.Text.TextStyle = { fontSize, fontStyle: 'bold', color, fontFamily, - }).setOrigin(originX, originY); + }; + if (options?.align !== undefined) { + (baseStyle as Phaser.Types.GameObjects.Text.TextStyle).align = options.align; + } + if (options?.lineSpacing !== undefined) { + (baseStyle as Phaser.Types.GameObjects.Text.TextStyle).lineSpacing = + options.lineSpacing; + } + + return scene.add.text(x, y, text, baseStyle).setOrigin(originX, originY); } // --------------------------------------------------------------------------- @@ -298,6 +312,7 @@ export function createActionButton( options?: ActionButtonOptions, ): Phaser.GameObjects.Container { const height = options?.height ?? 32; + const depth: number | undefined = options?.depth; const fillColor: number = options?.fillColor ?? 0x554422; const fillAlpha: number = options?.fillAlpha ?? 0.8; const strokeColor: number = options?.strokeColor ?? 0xaa8855; @@ -306,6 +321,9 @@ export function createActionButton( const disabled: boolean = options?.disabled ?? false; const container = scene.add.container(x + width / 2, y + height / 2); + if (depth !== undefined) { + container.setDepth(depth); + } const bg = scene.add.rectangle(0, 0, width, height, fillColor, fillAlpha); bg.setStrokeStyle(1, strokeColor); diff --git a/tests/golf/GolfOverlay.browser.test.ts b/tests/golf/GolfOverlay.browser.test.ts index f9abff59..d21da845 100644 --- a/tests/golf/GolfOverlay.browser.test.ts +++ b/tests/golf/GolfOverlay.browser.test.ts @@ -117,37 +117,38 @@ function clickAtGameCoords( // Ensure ScaleManager bounds are up to date before computing coords scale.refresh(); - // Convert game coords to page coords (inverse of ScaleManager.transformX/Y) - // transformX(pageX) = (pageX - canvasBounds.left) * displayScale.x - // => pageX = gameX / displayScale.x + canvasBounds.left + // Convert game-world coords to page/screen coords using Phaser's scale + // transform methods which handle all DPR and viewport scaling. + // transformX/Y converts page coords to game coords, so we need the + // inverse. For a scale that maps page→game via: + // gameX = (pageX - canvasBounds.left) * displayScale.x + // The inverse is: + // pageX = gameX / displayScale.x + canvasBounds.left const pageX = gameX / scale.displayScale.x + scale.canvasBounds.left; const pageY = gameY / scale.displayScale.y + scale.canvasBounds.top; - const eventInit: MouseEventInit = { - clientX: pageX, - clientY: pageY, - screenX: pageX, - screenY: pageY, - bubbles: true, - cancelable: true, - button: 0, - buttons: 1, + // Phaser 4 RC7's MouseManager natively listens for native DOM `mousedown` + // and `mouseup` events (not `pointerdown`/`pointerup`). Synthetic + // PointerEvents dispatched via dispatchEvent do NOT auto-generate the + // corresponding MouseEvent, so we must dispatch MouseEvent directly. + const dispatch = (type: string, buttons: number) => { + const e = new MouseEvent(type, { + clientX: Math.round(pageX), + clientY: Math.round(pageY), + screenX: Math.round(pageX), + screenY: Math.round(pageY), + button: 0, + buttons, + bubbles: true, + cancelable: true, + }); + canvas.dispatchEvent(e); }; - // Dispatch mousedown, then patch pageX/pageY (not in MouseEventInit typedef - // but Phaser reads event.pageX; browsers auto-compute it from clientX but - // we set it explicitly for robustness in synthetic events). - const down = new MouseEvent('mousedown', eventInit); - Object.defineProperty(down, 'pageX', { value: pageX }); - Object.defineProperty(down, 'pageY', { value: pageY }); - canvas.dispatchEvent(down); - - const up = new MouseEvent('mouseup', { ...eventInit, buttons: 0 }); - Object.defineProperty(up, 'pageX', { value: pageX }); - Object.defineProperty(up, 'pageY', { value: pageY }); - canvas.dispatchEvent(up); + dispatch('mousedown', 1); + dispatch('mouseup', 0); } /** @@ -177,19 +178,36 @@ describe('Golf overlay button tests', () => { forceEndScreen(scene); await waitFrames(3); - // Find text objects with overlay button labels - const texts = scene.children.list.filter( - (child: Phaser.GameObjects.GameObject) => - child instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; + // Helper: find a container that contains a Text child with the given label. + const findContainerByText = ( + label: string, + ): Phaser.GameObjects.Container | undefined => { + return scene.children.list.find( + (child: Phaser.GameObjects.GameObject) => + child instanceof Phaser.GameObjects.Container && + (child as Phaser.GameObjects.Container).list.some( + (c: Phaser.GameObjects.GameObject) => + c instanceof Phaser.GameObjects.Text && c.text === label, + ), + ) as Phaser.GameObjects.Container | undefined; + }; - const playAgainBtn = texts.find((t) => t.text === '[ Play Again ]'); - const menuBtn = texts.find((t) => t.text === '[ Menu ]'); + const playAgainBtn = findContainerByText('[ Play Again ]'); + const menuBtn = findContainerByText('Menu'); expect(playAgainBtn).toBeDefined(); expect(menuBtn).toBeDefined(); - expect(playAgainBtn!.input?.enabled).toBe(true); - expect(menuBtn!.input?.enabled).toBe(true); + // Buttons are interactive containers (the container itself is the hit target) + const playBg = (playAgainBtn!.list as Phaser.GameObjects.GameObject[]).find( + (c) => c instanceof Phaser.GameObjects.Rectangle, + ); + const menuBg = (menuBtn!.list as Phaser.GameObjects.GameObject[]).find( + (c) => c instanceof Phaser.GameObjects.Rectangle, + ); + expect(playBg).toBeDefined(); + expect(menuBg).toBeDefined(); + expect((playBg as Phaser.GameObjects.Rectangle).input?.enabled).toBe(true); + expect((menuBg as Phaser.GameObjects.Rectangle).input?.enabled).toBe(true); }); it('should restart the scene when "Play Again" is clicked via DOM pointer event', async () => { @@ -203,31 +221,44 @@ describe('Golf overlay button tests', () => { // Wait for the end screen to render and Phaser to process the frame await waitFrames(5); - // Find the "Play Again" button to get its coordinates - const texts = scene.children.list.filter( - (child: Phaser.GameObjects.GameObject) => - child instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; - const playAgainBtn = texts.find((t) => t.text === '[ Play Again ]'); + // Helper: find a container that contains a Text child with the given label + // and return the interactive Rectangle (background) inside it. + const findButtonContainer = ( + label: string, + ): Phaser.GameObjects.Container | undefined => { + return scene.children.list.find( + (child: Phaser.GameObjects.GameObject) => + child instanceof Phaser.GameObjects.Container && + (child as Phaser.GameObjects.Container).list.some( + (c: Phaser.GameObjects.GameObject) => + c instanceof Phaser.GameObjects.Text && c.text === label, + ), + ) as Phaser.GameObjects.Container | undefined; + }; + + // Find the "Play Again" button container. + // createActionButton places the container at (x + width/2, y + height/2) + // with the Rectangle at local (0, 0) — world pos = container pos. + const playAgainBtn = findButtonContainer('[ Play Again ]'); expect(playAgainBtn).toBeDefined(); - // Click at the button's game-world position through the DOM + // Click at the button's world position through the DOM. + // This routes through Phaser's full input pipeline (hit-testing, depth + // sorting, topOnly filtering) so the full system is exercised. clickAtGameCoords(game, playAgainBtn!.x, playAgainBtn!.y); - // Wait for restart: Phaser queues scene.restart() to the next tick. + // Wait for restart: scene.restart() destroys the old scene and creates + // a new one. We wait for the session object to change as proof that + // a fresh scene was created. await waitForCondition(() => { const activeScene = game!.scene.getScene('GolfScene'); const maybeSession = getSceneInternals(activeScene).session; - return maybeSession && maybeSession !== originalSession; + return Boolean(maybeSession && maybeSession !== originalSession); }, 15_000); await waitFrames(2); - // Verify: new session was created (different object reference) - const newScene = game.scene.getScene('GolfScene')!; - const newSession = getSceneInternals(newScene).session; - expect(newSession).not.toBe(originalSession); - // Verify: the scene is in initial state, not in round-ended + const newScene = game.scene.getScene('GolfScene')!; expect(getSceneInternals(newScene).phaseManager.current).toBe('waiting-for-draw'); // Verify: overlay buttons no longer exist diff --git a/tests/sushi-go/SushiGoOverlay.browser.test.ts b/tests/sushi-go/SushiGoOverlay.browser.test.ts index e9ccc6c9..f5504848 100644 --- a/tests/sushi-go/SushiGoOverlay.browser.test.ts +++ b/tests/sushi-go/SushiGoOverlay.browser.test.ts @@ -36,6 +36,60 @@ function waitFrames(n: number): Promise { }); } +describe('Sushi Go round-score overlay', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + }); + + it('renders Next Round button above overlay background depth', async () => { + game = await bootGame(); + const scene = game.scene.getScene('SushiGoScene') as any; + + const fakeRoundResult = { + round: 1, + tableauScores: [9, 8], + tableauBreakdowns: [ + { tempura: 0, sashimi: 0, dumpling: 0, nigiri: 0, chopsticks: 0, puddingCount: 0 }, + { tempura: 0, sashimi: 0, dumpling: 0, nigiri: 0, chopsticks: 0, puddingCount: 0 }, + ], + makiCounts: [0, 0], + makiBonuses: [0, 0], + roundScores: [9, 8], + puddingCounts: [0, 0], + puddingBonuses: [0, 0], + }; + + scene.overlayManager.showRoundScoreOverlay(fakeRoundResult, () => {}); + await waitFrames(3); + + // Find the "Next Round" button container + const containers = scene.children.list.filter( + (child: Phaser.GameObjects.GameObject) => child instanceof Phaser.GameObjects.Container, + ) as Phaser.GameObjects.Container[]; + + const findButtonLabel = (container: Phaser.GameObjects.Container, label: string): boolean => { + return (container as any).list?.some( + (child: any) => child instanceof Phaser.GameObjects.Text && child.text === label, + ); + }; + + const nextRoundBtn = containers.find((c) => findButtonLabel(c, 'Next Round')); + expect(nextRoundBtn).toBeDefined(); + + // The button must be above the overlay background depth (10) + expect(nextRoundBtn!.depth).toBeGreaterThanOrEqual(11); + + // Verify the background rectangle is interactive + const bg = (nextRoundBtn as any).list?.find( + (child: any) => child instanceof Phaser.GameObjects.Rectangle, + ); + expect(bg?.input?.enabled).toBe(true); + }); +}); + describe('Sushi Go game-over overlay', () => { let game: Phaser.Game | null = null; @@ -68,17 +122,31 @@ describe('Sushi Go game-over overlay', () => { await waitFrames(3); - const texts = scene.children.list.filter( - (child: Phaser.GameObjects.GameObject) => child instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; + // Action buttons are Containers with Text children (migrated to shared Renderer API). + const containers = scene.children.list.filter( + (child: Phaser.GameObjects.GameObject) => child instanceof Phaser.GameObjects.Container, + ) as Phaser.GameObjects.Container[]; + + const findButtonLabel = (container: Phaser.GameObjects.Container, label: string): boolean => { + return (container as any).list?.some( + (child: any) => child instanceof Phaser.GameObjects.Text && child.text === label, + ); + }; - const playAgainBtn = texts.find((t) => t.text === '[ Play Again ]'); - const menuBtn = texts.find((t) => t.text === '[ Menu ]'); + const playAgainBtn = containers.find((c) => findButtonLabel(c, 'Play Again')); + const menuBtn = containers.find((c) => findButtonLabel(c, 'Menu')); expect(playAgainBtn).toBeDefined(); expect(menuBtn).toBeDefined(); - expect(playAgainBtn!.input?.enabled).toBe(true); - expect(menuBtn!.input?.enabled).toBe(true); + // Verify the background rectangle is interactive + const playBg = (playAgainBtn as any).list?.find( + (child: any) => child instanceof Phaser.GameObjects.Rectangle, + ); + const menuBg = (menuBtn as any).list?.find( + (child: any) => child instanceof Phaser.GameObjects.Rectangle, + ); + expect(playBg?.input?.enabled).toBe(true); + expect(menuBg?.input?.enabled).toBe(true); }); it('displays correct final totals including pudding bonuses when provided', async () => { diff --git a/tests/ui/renderer.test.ts b/tests/ui/renderer.test.ts index 3e807a7e..452c1235 100644 --- a/tests/ui/renderer.test.ts +++ b/tests/ui/renderer.test.ts @@ -66,6 +66,8 @@ function createMockScene(): Phaser.Scene { _depth: 0, setDepth: vi.fn(function (this: any, d: number) { this._depth = d; return c; }), setScale: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), add: vi.fn(function (this: any, obj: any) { children.push(obj); return c; }), remove: vi.fn(), }; @@ -290,6 +292,8 @@ function createMockSceneWithTextures(textureExists = true): Phaser.Scene { _depth: 0, setDepth: vi.fn(function (this: any, d: number) { this._depth = d; return container; }), setScale: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), add: vi.fn(function (this: any, obj: any) { children.push(obj); return container; }), remove: vi.fn(), };