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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { OnitamaScene } from './OnitamaScene';
|
||||
export { MenuScene } from './MenuScene';
|
||||
export { OnitamaScene } from "./OnitamaScene";
|
||||
export { MenuScene } from "./MenuScene";
|
||||
|
||||
Reference in New Issue
Block a user