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:
@@ -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";
|
||||
Reference in New Issue
Block a user