refactor(onitama): decouple rendering logic into dedicated renderers

Introduces a centralized configuration system and extracts visual
creation logic from spawners into specialized renderer classes:
- CardRenderer
- HighlightRenderer
- PawnRenderer
- SelectionRenderer

This refactor improves separation of concerns by moving Phaser-specific
drawing and animation code out of the spawner/container logic and into
reusable renderer components.
This commit is contained in:
2026-04-19 11:55:18 +08:00
parent c7ef992082
commit b28da1cad3
16 changed files with 1006 additions and 515 deletions
@@ -0,0 +1,104 @@
import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
export interface CardRenderOptions {
card: Card;
}
/**
* Renderer for card game objects
* Extracts visual creation logic from CardContainer
*/
export class CardRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render card visuals into a container
* @param container - The container to add visuals to
* @param options - Card rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: CardRenderOptions,
): void {
const { card } = options;
// Create background rectangle
const bg = this.scene.add
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
.setStrokeStyle(2, COLORS.cardStroke);
container.add(bg);
// Create title text
const title = this.scene.add
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
.setOrigin(0.5);
container.add(title);
// Create move candidate grid
this.renderMoveGrid(container, card);
// Create starting player text
const playerText = this.scene.add
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
.setOrigin(0.5);
container.add(playerText);
}
/**
* Render the 5x5 grid showing move candidates
*/
private renderMoveGrid(
container: Phaser.GameObjects.Container,
card: Card,
): void {
const grid = this.scene.add.graphics();
const { cellSize, gridSize } = CARD_GRID;
const gridWidth = gridSize * cellSize;
const gridHeight = gridSize * cellSize;
const gridStartX = -gridWidth / 2;
const gridStartY = -gridHeight / 2 + 20;
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
const x = gridStartX + col * cellSize;
const y = gridStartY + row * cellSize;
const centerX = x + cellSize / 2;
const centerY = y + cellSize / 2;
const radius = cellSize / 3;
// Center position marker
if (row === 2 && col === 2) {
grid.fillStyle(COLORS.cardCenter, 1);
grid.fillCircle(centerX, centerY, radius);
} else {
// Check if this cell is a move candidate
const isTarget = card.moveCandidates.some(
(m) => m.dx === col - 2 && m.dy === 2 - row,
);
if (isTarget) {
grid.fillStyle(COLORS.cardTarget, 0.6);
grid.fillCircle(centerX, centerY, radius);
}
}
}
}
container.add(grid);
}
/**
* Create a standalone card visual at the specified position
* Useful for previews or temporary displays
*/
createStandalone(
x: number,
y: number,
options: CardRenderOptions,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, options);
return container;
}
}
@@ -0,0 +1,103 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS } from "@/config";
export interface HighlightRenderOptions {
x: number;
y: number;
}
/**
* Renderer for move target highlight visuals
* Extracts visual creation logic from HighlightSpawner
*/
export class HighlightRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render highlight visuals into a container
* @param container - The container to add visuals to
* @param options - Highlight rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: HighlightRenderOptions,
): void {
const { x, y } = options;
// Set container position
container.setPosition(x, y);
// Outer circle (animated pulse)
const outerCircle = this.scene.add.circle(
0,
0,
CELL_SIZE / 3,
COLORS.black,
0.2,
);
container.add(outerCircle);
// Inner circle
const innerCircle = this.scene.add.circle(
0,
0,
CELL_SIZE / 4,
COLORS.black,
0.4,
);
container.add(innerCircle);
// Store references for animation
container.setData("outerCircle", outerCircle);
container.setData("innerCircle", innerCircle);
}
/**
* Create a standalone highlight visual at the specified position
* Useful for previews or temporary displays
*/
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, { x, y });
return container;
}
/**
* Setup pulse animations for highlight circles
* @param container - The highlight container
*/
setupPulseAnimations(container: Phaser.GameObjects.Container): void {
const outerCircle = container.getData("outerCircle") as
| Phaser.GameObjects.Arc
| undefined;
const innerCircle = container.getData("innerCircle") as
| Phaser.GameObjects.Arc
| undefined;
if (!outerCircle || !innerCircle) return;
// Inner circle pulse
this.scene.tweens.add({
targets: [outerCircle, innerCircle],
scale: 1.2,
alpha: 0.6,
duration: 600,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
// Outer circle staggered pulse
this.scene.tweens.add({
targets: outerCircle,
scale: 1.3,
alpha: 0.3,
duration: 800,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
delay: 200,
});
}
}
@@ -0,0 +1,59 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, FONTS } from "@/config";
export type PawnType = "master" | "student";
export type PawnOwner = "red" | "black";
export interface PawnRenderOptions {
owner: PawnOwner;
type: PawnType;
}
/**
* Renderer for pawn game objects
* Extracts visual creation logic from PawnContainer
*/
export class PawnRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Render pawn visuals into a container
* @param container - The container to add visuals to
* @param options - Pawn rendering options
*/
render(
container: Phaser.GameObjects.Container,
options: PawnRenderOptions,
): void {
const { owner, type } = options;
// Create background circle
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
const circle = this.scene.add
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
.setStrokeStyle(2, COLORS.pawnStroke);
container.add(circle);
// Create label text
const label = type === "master" ? "M" : "S";
const text = this.scene.add
.text(0, 0, label, FONTS.pawnLabel)
.setOrigin(0.5);
container.add(text);
}
/**
* Create a standalone pawn visual (circle + text) at the specified position
* Useful for previews or temporary displays
*/
createStandalone(
x: number,
y: number,
options: PawnRenderOptions,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.render(container, options);
return container;
}
}
@@ -0,0 +1,122 @@
import { GameObjects } from "phaser";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, ANIMATIONS } from "@/config";
export interface SelectionRenderOptions {
x: number;
y: number;
}
/**
* Renderer for pawn selection ring visuals
* Extracts selection ring creation and animation logic from PawnContainer
*/
export class SelectionRenderer {
constructor(private readonly scene: OnitamaScene) {}
/**
* Create a selection ring visual
* @param parent - The parent container or game object to add the ring to
* @returns The selection ring game object
*/
create(
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
): Phaser.GameObjects.Arc {
const ring = this.scene.add
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
.setStrokeStyle(3, COLORS.highlightStroke, 1)
.setAlpha(0);
// Add to parent at index 0 (behind other visuals)
if (parent instanceof GameObjects.Container) {
parent.addAt(ring, 0);
}
return ring;
}
/**
* Show selection with fade-in and pulse animation
* @param ring - The selection ring to animate
* @returns Cleanup function to stop animations
*/
show(ring: Phaser.GameObjects.Arc): () => void {
if (!ring.active) return () => {};
let pulseTween: Phaser.Tweens.Tween | null = null;
const tweens = this.scene.tweens;
// Fade in animation
const fadeIn = tweens.add({
targets: ring,
alpha: 0.8,
duration: ANIMATIONS.selectionFadeIn,
ease: "Power2",
onComplete: () => {
// Start pulse animation after fade-in completes
pulseTween = tweens.add({
targets: ring,
scale: 1.15,
alpha: 0.6,
duration: ANIMATIONS.selectionPulse,
ease: "Sine.easeInOut",
yoyo: true,
repeat: -1,
});
this.scene.addTweenInterruption(pulseTween);
},
});
this.scene.addTweenInterruption(fadeIn);
// Return cleanup function
return () => {
if (pulseTween) {
pulseTween.stop();
pulseTween = null;
}
tweens.killTweensOf(ring);
};
}
/**
* Hide selection with fade-out animation
* @param ring - The selection ring to animate
* @param onComplete - Callback when animation completes
*/
hide(ring: Phaser.GameObjects.Arc, onComplete?: () => void): void {
if (!ring.active) {
onComplete?.();
return;
}
const tweens = this.scene.tweens;
// Stop any existing tweens on this ring
tweens.killTweensOf(ring);
// Fade out animation
tweens.add({
targets: ring,
alpha: 0,
scale: 0.9,
duration: ANIMATIONS.selectionFadeOut,
ease: "Power2",
onComplete: () => {
ring.destroy();
onComplete?.();
},
});
}
/**
* Create a standalone selection ring at the specified position
* Useful for previews or temporary displays
*/
createStandalone(x: number, y: number): Phaser.GameObjects.Container {
const container = this.scene.add.container(x, y);
this.create(container);
return container;
}
}
@@ -0,0 +1,11 @@
export { PawnRenderer } from "./PawnRenderer";
export type { PawnRenderOptions, PawnType, PawnOwner } from "./PawnRenderer";
export { CardRenderer } from "./CardRenderer";
export type { CardRenderOptions } from "./CardRenderer";
export { HighlightRenderer } from "./HighlightRenderer";
export type { HighlightRenderOptions } from "./HighlightRenderer";
export { SelectionRenderer } from "./SelectionRenderer";
export type { SelectionRenderOptions } from "./SelectionRenderer";