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
+38 -69
View File
@@ -1,30 +1,6 @@
import { ReactiveScene } from 'boardgame-phaser';
import Phaser from 'phaser';
import { ReactiveScene } from "boardgame-phaser";
/** 菜单场景配置 */
const MENU_CONFIG = {
colors: {
title: '#1f2937',
buttonText: '#ffffff',
buttonBg: 0x3b82f6,
buttonBgHover: 0x2563eb,
subtitle: '#6b7280',
},
fontSize: {
title: '48px',
button: '24px',
subtitle: '16px',
},
button: {
width: 200,
height: 80,
},
positions: {
titleY: -120,
buttonY: 40,
subtitleY: 160,
},
} as const;
import { MENU_CONFIG, ANIMATIONS } from "@/config";
export class MenuScene extends ReactiveScene {
private titleText!: Phaser.GameObjects.Text;
@@ -33,7 +9,7 @@ export class MenuScene extends ReactiveScene {
private startButtonText!: Phaser.GameObjects.Text;
constructor() {
super('MenuScene');
super("MenuScene");
}
create(): void {
@@ -56,24 +32,21 @@ export class MenuScene extends ReactiveScene {
/** 创建标题文本 */
private createTitle(center: { x: number; y: number }): void {
this.titleText = this.add.text(
center.x,
center.y + MENU_CONFIG.positions.titleY,
'Onitama',
{
this.titleText = this.add
.text(center.x, center.y + MENU_CONFIG.positions.titleY, "Onitama", {
fontSize: MENU_CONFIG.fontSize.title,
fontFamily: 'Arial',
fontFamily: "Arial",
color: MENU_CONFIG.colors.title,
}
).setOrigin(0.5);
})
.setOrigin(0.5);
// 标题入场动画
this.titleText.setScale(0);
this.tweens.add({
targets: this.titleText,
scale: 1,
duration: 600,
ease: 'Back.easeOut',
duration: ANIMATIONS.menuTitle,
ease: "Back.easeOut",
});
}
@@ -81,29 +54,23 @@ export class MenuScene extends ReactiveScene {
private createStartButton(center: { x: number; y: number }): void {
const { button, colors } = MENU_CONFIG;
this.startButtonBg = this.add.rectangle(
0,
0,
button.width,
button.height,
colors.buttonBg
).setOrigin(0.5).setInteractive({ useHandCursor: true });
this.startButtonBg = this.add
.rectangle(0, 0, button.width, button.height, colors.buttonBg)
.setOrigin(0.5)
.setInteractive({ useHandCursor: true });
this.startButtonText = this.add.text(
0,
0,
'Start Game',
{
this.startButtonText = this.add
.text(0, 0, "Start Game", {
fontSize: MENU_CONFIG.fontSize.button,
fontFamily: 'Arial',
fontFamily: "Arial",
color: colors.buttonText,
}
).setOrigin(0.5);
})
.setOrigin(0.5);
this.startButtonContainer = this.add.container(
center.x,
center.y + MENU_CONFIG.positions.buttonY,
[this.startButtonBg, this.startButtonText]
[this.startButtonBg, this.startButtonText],
);
// 按钮交互
@@ -112,45 +79,47 @@ export class MenuScene extends ReactiveScene {
/** 设置按钮交互效果 */
private setupButtonInteraction(): void {
this.startButtonBg.on('pointerover', () => {
this.startButtonBg.on("pointerover", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBgHover);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1.05,
duration: 100,
duration: ANIMATIONS.buttonHover,
});
});
this.startButtonBg.on('pointerout', () => {
this.startButtonBg.on("pointerout", () => {
this.startButtonBg.setFillStyle(MENU_CONFIG.colors.buttonBg);
this.tweens.add({
targets: this.startButtonContainer,
scale: 1,
duration: 100,
duration: ANIMATIONS.buttonHover,
});
});
this.startButtonBg.on('pointerdown', () => {
this.startButtonBg.on("pointerdown", () => {
this.startGame();
});
}
/** 创建副标题 */
private createSubtitle(center: { x: number; y: number }): void {
this.add.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
'Click to start playing',
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: 'Arial',
color: MENU_CONFIG.colors.subtitle,
}
).setOrigin(0.5);
this.add
.text(
center.x,
center.y + MENU_CONFIG.positions.subtitleY,
"Click to start playing",
{
fontSize: MENU_CONFIG.fontSize.subtitle,
fontFamily: "Arial",
color: MENU_CONFIG.colors.subtitle,
},
)
.setOrigin(0.5);
}
/** 开始游戏 */
private async startGame(): Promise<void> {
await this.sceneController.launch('OnitamaScene');
await this.sceneController.launch("OnitamaScene");
}
}
+113 -112
View File
@@ -1,16 +1,36 @@
import Phaser from 'phaser';
import type { OnitamaState, Pawn } from '@/game/onitama';
import {getAvailableMoves, prompts} from '@/game/onitama';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect } from 'boardgame-phaser';
import type { MutableSignal } from 'boardgame-core';
import { GameHostScene, spawnEffect } from "boardgame-phaser";
import type { OnitamaState, Pawn } from "@/game/onitama";
import type { HighlightData } from "@/spawners/HighlightSpawner";
import type { OnitamaUIState } from "@/state";
import type { MutableSignal } from "boardgame-core";
import type Phaser from "phaser";
import {
PawnSpawner, CardSpawner, BOARD_OFFSET, CELL_SIZE, CARD_WIDTH, CARD_HEIGHT, boardToScreen, BOARD_SIZE,
HighlightSpawner
} from '@/spawners';
import type { HighlightData } from '@/spawners/HighlightSpawner';
import {createUIState, clearSelection, selectPiece, selectCard, createValidMoves} from '@/state';
import type { OnitamaUIState, ValidMove } from '@/state';
COLORS,
FONTS,
ANIMATIONS,
MENU_BUTTON,
getBoardCenter,
getCardLabelPosition,
colorToStr,
} from "@/config";
import { prompts } from "@/game/onitama";
import {
PawnSpawner,
CardSpawner,
BOARD_OFFSET,
CELL_SIZE,
BOARD_SIZE,
boardToScreen,
HighlightSpawner,
} from "@/spawners";
import {
createUIState,
clearSelection,
selectPiece,
selectCard,
} from "@/state";
export class OnitamaScene extends GameHostScene<OnitamaState> {
private boardContainer!: Phaser.GameObjects.Container;
@@ -26,7 +46,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
public uiState!: MutableSignal<OnitamaUIState>;
constructor() {
super('OnitamaScene');
super("OnitamaScene");
}
create(): void {
@@ -59,16 +79,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
});
// Info text
this.infoText = this.add.text(
40,
BOARD_OFFSET.y,
'',
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#4b5563',
}
);
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
// Update info text when UI state changes
this.addEffect(() => {
@@ -86,41 +97,30 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
}
private createCardLabels(): void {
const boardLeft = BOARD_OFFSET.x;
const boardTop = BOARD_OFFSET.y;
const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE;
const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE;
// Red cards label - 棋盘下方
const redLabel = this.add.text(
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
boardBottom + 40,
"RED",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#ef4444',
}
).setOrigin(0.5, 0);
this.cardLabelContainers.set('red', redLabel);
const redPos = getCardLabelPosition("red");
const redLabel = this.add
.text(redPos.x, redPos.y, "RED", {
...FONTS.cardLabel,
color: colorToStr(COLORS.red),
})
.setOrigin(redPos.originX, redPos.originY);
this.cardLabelContainers.set("red", redLabel);
// Black cards label - 棋盘上方
const blackLabel = this.add.text(
boardLeft + (BOARD_SIZE * CELL_SIZE) / 2,
boardTop - 40,
"BLACK",
{
fontSize: '16px',
fontFamily: 'Arial',
color: '#3b82f6',
}
).setOrigin(0.5, 1);
this.cardLabelContainers.set('black', blackLabel);
const blackPos = getCardLabelPosition("black");
const blackLabel = this.add
.text(blackPos.x, blackPos.y, "BLACK", {
...FONTS.cardLabel,
color: colorToStr(COLORS.black),
})
.setOrigin(blackPos.originX, blackPos.originY);
this.cardLabelContainers.set("black", blackLabel);
}
private updateInfoText(): void {
const currentPlayer = this.state.currentPlayer;
if (this.state.winner) {
this.infoText.setText(`${this.state.winner} wins!`);
} else {
@@ -130,35 +130,26 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
private drawBoard(): void {
const g = this.gridGraphics;
g.lineStyle(2, 0x6b7280);
g.lineStyle(2, COLORS.gridLine);
for (let i = 0; i <= BOARD_SIZE; i++) {
g.lineBetween(
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y,
BOARD_OFFSET.x + i * CELL_SIZE,
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
);
g.lineBetween(
BOARD_OFFSET.x,
BOARD_OFFSET.y + i * CELL_SIZE,
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
BOARD_OFFSET.y + i * CELL_SIZE
BOARD_OFFSET.y + i * CELL_SIZE,
);
}
g.strokePath();
this.add.text(
40,
40,
'Onitama',
{
fontSize: '28px',
fontFamily: 'Arial',
color: '#1f2937',
}
);
this.add.text(40, 40, "Onitama", FONTS.title);
}
private setupInput(): void {
@@ -167,9 +158,11 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
for (let col = 0; col < BOARD_SIZE; col++) {
const pos = boardToScreen(col, row);
const zone = this.add.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE).setInteractive();
const zone = this.add
.zone(pos.x, pos.y, CELL_SIZE, CELL_SIZE)
.setInteractive();
zone.on('pointerdown', () => {
zone.on("pointerdown", () => {
if (this.state.winner) return;
this.handleCellClick(col, row);
});
@@ -179,7 +172,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
private handleCellClick(x: number, y: number): void {
const pawn = this.getPawnAtPosition(x, y);
if(pawn?.owner !== this.state.currentPlayer){
if (pawn?.owner !== this.state.currentPlayer) {
return;
}
selectPiece(this.uiState, x, y);
@@ -188,8 +181,9 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
public onCardClick(cardId: string): void {
// 只能选择当前玩家的手牌
const currentPlayer = this.state.currentPlayer;
const playerCards = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards;
const playerCards =
currentPlayer === "red" ? this.state.redCards : this.state.blackCards;
if (!playerCards.includes(cardId)) {
return;
}
@@ -208,7 +202,13 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
});
}
private executeMove(move: { card: string; fromX: number; fromY: number; toX: number; toY: number }): void {
private executeMove(move: {
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}): void {
const error = this.gameHost.tryAnswerPrompt(
prompts.move,
this.state.currentPlayer,
@@ -216,10 +216,10 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
move.fromX,
move.fromY,
move.toX,
move.toY
move.toY,
);
if (error) {
console.warn('Invalid move:', error);
console.warn("Invalid move:", error);
}
}
@@ -231,39 +231,42 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
/** 创建菜单按钮 */
private createMenuButton(): void {
const buttonX = 680;
const buttonY = 40;
this.menuButtonBg = this.add.rectangle(buttonX, buttonY, 120, 40, 0x6b7280)
this.menuButtonBg = this.add
.rectangle(
MENU_BUTTON.x,
MENU_BUTTON.y,
MENU_BUTTON.width,
MENU_BUTTON.height,
COLORS.menuButton,
)
.setInteractive({ useHandCursor: true });
this.menuButtonText = this.add.text(buttonX, buttonY, 'Menu', {
fontSize: '18px',
fontFamily: 'Arial',
color: '#ffffff',
}).setOrigin(0.5);
this.menuButtonText = this.add
.text(MENU_BUTTON.x, MENU_BUTTON.y, "Menu", FONTS.menuButton)
.setOrigin(0.5);
this.menuButtonContainer = this.add.container(buttonX, buttonY, [
this.menuButtonBg,
this.menuButtonText,
]);
this.menuButtonContainer = this.add.container(
MENU_BUTTON.x,
MENU_BUTTON.y,
[this.menuButtonBg, this.menuButtonText],
);
this.menuButtonBg.on('pointerover', () => {
this.menuButtonBg.setFillStyle(0x4b5563);
this.menuButtonBg.on("pointerover", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButtonHover);
});
this.menuButtonBg.on('pointerout', () => {
this.menuButtonBg.setFillStyle(0x6b7280);
this.menuButtonBg.on("pointerout", () => {
this.menuButtonBg.setFillStyle(COLORS.menuButton);
});
this.menuButtonBg.on('pointerdown', () => {
this.menuButtonBg.on("pointerdown", () => {
this.goToMenu();
});
}
/** 跳转到菜单场景 */
private async goToMenu(): Promise<void> {
await this.sceneController.launch('MenuScene');
await this.sceneController.launch("MenuScene");
}
private showWinner(winner: string): void {
@@ -273,40 +276,38 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
this.winnerOverlay = this.add.container();
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
const text = winner === "draw" ? "It's a draw!" : `${winner} wins!`;
const center = getBoardCenter();
const boardWidth = BOARD_SIZE * CELL_SIZE;
const boardHeight = BOARD_SIZE * CELL_SIZE;
const bg = this.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE,
BOARD_SIZE * CELL_SIZE,
0x000000,
0.6
).setInteractive({ useHandCursor: true });
const bg = this.add
.rectangle(
center.x,
center.y,
boardWidth,
boardHeight,
COLORS.overlayBg,
0.6,
)
.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
bg.on("pointerdown", () => {
this.gameHost.start();
});
this.winnerOverlay.add(bg);
const winText = this.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
text,
{
fontSize: '36px',
fontFamily: 'Arial',
color: '#fbbf24',
}
).setOrigin(0.5);
const winText = this.add
.text(center.x, center.y, text, FONTS.winner)
.setOrigin(0.5);
this.winnerOverlay.add(winText);
this.tweens.add({
targets: winText,
scale: 1.2,
duration: 500,
duration: ANIMATIONS.winnerPulse,
yoyo: true,
repeat: 1,
});
+2 -2
View File
@@ -1,2 +1,2 @@
export { OnitamaScene } from './OnitamaScene';
export { MenuScene } from './MenuScene';
export { OnitamaScene } from "./OnitamaScene";
export { MenuScene } from "./MenuScene";