diff --git a/packages/onitama-game/src/config/index.ts b/packages/onitama-game/src/config/index.ts new file mode 100644 index 0000000..55382fa --- /dev/null +++ b/packages/onitama-game/src/config/index.ts @@ -0,0 +1,221 @@ +/** + * Centralized configuration for Onitama game + * All layout, style, and game constants should be defined here + */ + +import { Display } from "phaser"; + +// Board configuration +export const BOARD_SIZE = 5; +export const CELL_SIZE = 80; +export const BOARD_OFFSET = { x: 240, y: 240 } as const; + +// Card configuration +export const CARD_WIDTH = 80; +export const CARD_HEIGHT = 120; +export const CARD_SPACING = 100; +export const CARD_LABEL_OFFSET = 40; // Distance from board edge + +// Menu configuration +export 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; + +// UI colors +export const COLORS = { + red: 0xef4444, + black: 0x3b82f6, + highlight: 0xfbbf24, + highlightStroke: 0xf59e0b, + gridLine: 0x6b7280, + pawnStroke: 0x1f2937, + cardBg: 0xf9fafb, + cardStroke: 0x6b7280, + cardCenter: 0x3b82f6, + cardTarget: 0xef4444, + textDark: "#1f2937", + textGray: "#6b7280", + textInfo: "#4b5563", + winnerGold: "#fbbf24", + overlayBg: 0x000000, + menuButton: 0x6b7280, + menuButtonHover: 0x4b5563, +} as const; + +// Font configuration +export const FONTS = { + title: { + fontSize: "28px", + fontFamily: "Arial", + color: COLORS.textDark, + }, + info: { + fontSize: "16px", + fontFamily: "Arial", + color: COLORS.textInfo, + }, + cardTitle: { + fontSize: "12px", + fontFamily: "Arial", + color: COLORS.textDark, + }, + cardPlayer: { + fontSize: "10px", + fontFamily: "Arial", + color: COLORS.textGray, + }, + pawnLabel: { + fontSize: "24px", + fontFamily: "Arial", + color: "#ffffff", + }, + cardLabel: { + fontSize: "16px", + fontFamily: "Arial", + }, + winner: { + fontSize: "36px", + fontFamily: "Arial", + color: COLORS.winnerGold, + }, + menuButton: { + fontSize: "18px", + fontFamily: "Arial", + color: "#ffffff", + }, +} as const; + +// Menu button configuration +export const MENU_BUTTON = { + x: 680, + y: 40, + width: 120, + height: 40, +} as const; + +// Animation durations (in ms) +export const ANIMATIONS = { + pawnSpawn: 300, + pawnDespawn: 300, + pawnMove: 400, + cardSpawn: 300, + cardDespawn: 200, + cardMove: 600, + cardRotate: 400, + highlightSpawn: 250, + highlightDespawn: 200, + selectionFadeIn: 200, + selectionFadeOut: 150, + selectionPulse: 500, + menuTitle: 600, + buttonHover: 100, + winnerPulse: 500, + clickFeedback: 150, +} as const; + +// Grid cell size for card move visualization +export const CARD_GRID = { + cellSize: 14, + gridSize: 5, +} as const; + +// Helper function to convert board coordinates to screen coordinates +export function boardToScreen( + boardX: number, + boardY: number, +): { x: number; y: number } { + return { + x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2, + y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2, + }; +} + +// Helper function to get board center +export function getBoardCenter(): { x: number; y: number } { + return { + x: BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, + y: BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, + }; +} + +// Helper function to get card label position +export function getCardLabelPosition(position: "red" | "black"): { + x: number; + y: number; + originX: number; + originY: number; +} { + const boardLeft = BOARD_OFFSET.x; + const boardTop = BOARD_OFFSET.y; + const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE; + const centerX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2; + + if (position === "red") { + return { + x: centerX, + y: boardBottom + CARD_LABEL_OFFSET, + originX: 0.5, + originY: 0, + }; + } else { + return { + x: centerX, + y: boardTop - CARD_LABEL_OFFSET, + originX: 0.5, + originY: 1, + }; + } +} + +// Helper function to get card position +export function getCardPosition( + position: "red" | "black" | "spare", + index: number, +): { x: number; y: number } { + const boardLeft = BOARD_OFFSET.x; + const boardTop = BOARD_OFFSET.y; + const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2; + const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE; + const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2; + + if (position === "red") { + return { + x: boardCenterX - (index - 0.5) * CARD_SPACING, + y: boardBottom + CARD_HEIGHT / 2 + CARD_LABEL_OFFSET, + }; + } else if (position === "black") { + return { + x: boardCenterX - (index - 0.5) * CARD_SPACING, + y: boardTop - CARD_HEIGHT / 2 - CARD_LABEL_OFFSET, + }; + } else { + return { + x: boardLeft - CARD_WIDTH / 2 - CARD_LABEL_OFFSET, + y: boardCenterY, + }; + } +} + +export function colorToStr(hex: number) { + return Display.Color.ValueToColor(hex).toString(); +} diff --git a/packages/onitama-game/src/renderers/CardRenderer.ts b/packages/onitama-game/src/renderers/CardRenderer.ts new file mode 100644 index 0000000..f2a21e8 --- /dev/null +++ b/packages/onitama-game/src/renderers/CardRenderer.ts @@ -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; + } +} diff --git a/packages/onitama-game/src/renderers/HighlightRenderer.ts b/packages/onitama-game/src/renderers/HighlightRenderer.ts new file mode 100644 index 0000000..56dbf2d --- /dev/null +++ b/packages/onitama-game/src/renderers/HighlightRenderer.ts @@ -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, + }); + } +} diff --git a/packages/onitama-game/src/renderers/PawnRenderer.ts b/packages/onitama-game/src/renderers/PawnRenderer.ts new file mode 100644 index 0000000..c45f4ea --- /dev/null +++ b/packages/onitama-game/src/renderers/PawnRenderer.ts @@ -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; + } +} diff --git a/packages/onitama-game/src/renderers/SelectionRenderer.ts b/packages/onitama-game/src/renderers/SelectionRenderer.ts new file mode 100644 index 0000000..6793d2a --- /dev/null +++ b/packages/onitama-game/src/renderers/SelectionRenderer.ts @@ -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; + } +} diff --git a/packages/onitama-game/src/renderers/index.ts b/packages/onitama-game/src/renderers/index.ts new file mode 100644 index 0000000..d253e16 --- /dev/null +++ b/packages/onitama-game/src/renderers/index.ts @@ -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"; diff --git a/packages/onitama-game/src/scenes/MenuScene.ts b/packages/onitama-game/src/scenes/MenuScene.ts index b6021b2..77290a4 100644 --- a/packages/onitama-game/src/scenes/MenuScene.ts +++ b/packages/onitama-game/src/scenes/MenuScene.ts @@ -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 { - await this.sceneController.launch('OnitamaScene'); + await this.sceneController.launch("OnitamaScene"); } } diff --git a/packages/onitama-game/src/scenes/OnitamaScene.ts b/packages/onitama-game/src/scenes/OnitamaScene.ts index e7db5c1..af990c2 100644 --- a/packages/onitama-game/src/scenes/OnitamaScene.ts +++ b/packages/onitama-game/src/scenes/OnitamaScene.ts @@ -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 { private boardContainer!: Phaser.GameObjects.Container; @@ -26,7 +46,7 @@ export class OnitamaScene extends GameHostScene { public uiState!: MutableSignal; constructor() { - super('OnitamaScene'); + super("OnitamaScene"); } create(): void { @@ -59,16 +79,7 @@ export class OnitamaScene extends GameHostScene { }); // 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 { } 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 { 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 { 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 { 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 { 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 { }); } - 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 { 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 { /** 创建菜单按钮 */ 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 { - await this.sceneController.launch('MenuScene'); + await this.sceneController.launch("MenuScene"); } private showWinner(winner: string): void { @@ -273,40 +276,38 @@ export class OnitamaScene extends GameHostScene { 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, }); diff --git a/packages/onitama-game/src/scenes/index.ts b/packages/onitama-game/src/scenes/index.ts index c34ddd4..8545aea 100644 --- a/packages/onitama-game/src/scenes/index.ts +++ b/packages/onitama-game/src/scenes/index.ts @@ -1,2 +1,2 @@ -export { OnitamaScene } from './OnitamaScene'; -export { MenuScene } from './MenuScene'; +export { OnitamaScene } from "./OnitamaScene"; +export { MenuScene } from "./MenuScene"; diff --git a/packages/onitama-game/src/spawners/CardSpawner.ts b/packages/onitama-game/src/spawners/CardSpawner.ts index 099aa57..9ab3c68 100644 --- a/packages/onitama-game/src/spawners/CardSpawner.ts +++ b/packages/onitama-game/src/spawners/CardSpawner.ts @@ -1,18 +1,18 @@ -import Phaser from 'phaser'; -import type { Card } from '@/game/onitama'; -import type { Spawner } from 'boardgame-phaser'; -import type { OnitamaScene } from '@/scenes/OnitamaScene'; -import { BOARD_OFFSET, CELL_SIZE } from './PawnSpawner'; import { effect } from "@preact/signals-core"; -export const CARD_WIDTH = 80; -export const CARD_HEIGHT = 120; -const BOARD_SIZE = 5; -const CARD_SPACING = 100; // 确保每张卡牌至少 80x80 区域 +import type { Card } from "@/game/onitama"; +import type { OnitamaScene } from "@/scenes/OnitamaScene"; +import type { Spawner } from "boardgame-phaser"; + +import { CARD_WIDTH, CARD_HEIGHT, ANIMATIONS, getCardPosition } from "@/config"; +import { CardRenderer } from "@/renderers"; + +// Re-export for backward compatibility +export { CARD_WIDTH, CARD_HEIGHT } from "@/config"; export interface CardSpawnData { cardId: string; - position: 'red' | 'black' | 'spare'; + position: "red" | "black" | "spare"; index: number; } @@ -25,11 +25,18 @@ export class CardContainer extends Phaser.GameObjects.Container { private highlightTween: Phaser.Tweens.Tween | null = null; private _cardId: string; private _rotation: number = 0; + private cardRenderer: CardRenderer; - constructor(scene: OnitamaScene, cardId: string, card: Card, rotation: number = 0) { + constructor( + scene: OnitamaScene, + cardId: string, + card: Card, + rotation: number = 0, + ) { super(scene, 0, 0); this._cardId = cardId; this._rotation = rotation; + this.cardRenderer = new CardRenderer(scene); // 将容器添加到场景 scene.add.existing(this); @@ -37,11 +44,16 @@ export class CardContainer extends Phaser.GameObjects.Container { // 应用旋转 this.angle = rotation; - // 创建卡牌视觉 - this.createCardVisual(card); + // 使用 CardRenderer 创建卡牌视觉 + this.cardRenderer.render(this, { card }); // 使卡牌可点击 - const hitArea = new Phaser.Geom.Rectangle(-CARD_WIDTH / 2, -CARD_HEIGHT / 2, CARD_WIDTH, CARD_HEIGHT); + const hitArea = new Phaser.Geom.Rectangle( + -CARD_WIDTH / 2, + -CARD_HEIGHT / 2, + CARD_WIDTH, + CARD_HEIGHT, + ); this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); // 添加场景 effect 监听高亮状态变化 @@ -57,8 +69,8 @@ export class CardContainer extends Phaser.GameObjects.Container { const tween = this.scene.tweens.add({ targets: this, angle: { from: currentAngle, to: rotation }, - duration: 400, - ease: 'Back.easeOut', + duration: ANIMATIONS.cardRotate, + ease: "Back.easeOut", }); (this.scene as OnitamaScene).addTweenInterruption(tween); } else { @@ -76,29 +88,28 @@ export class CardContainer extends Phaser.GameObjects.Container { if (!this.highlightRect) { // 创建高亮边框(初始透明) - this.highlightRect = (this.scene as OnitamaScene).add.rectangle( - 0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0 - ) + this.highlightRect = (this.scene as OnitamaScene).add + .rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0) .setStrokeStyle(lineWidth, color) .setAlpha(0) .setDepth(-1); this.addAt(this.highlightRect, 0); // 淡入动画 - const fadeIn = this.scene.tweens.add({ + this.scene.tweens.add({ targets: this.highlightRect, alpha: 1, scale: 1.05, - duration: 200, - ease: 'Power2', + duration: ANIMATIONS.selectionFadeIn, + ease: "Power2", onComplete: () => { // 淡入完成后开始脉冲动画 this.highlightTween = this.scene.tweens.add({ targets: this.highlightRect, alpha: 0.7, lineWidth: lineWidth + 1, - duration: 500, - ease: 'Sine.easeInOut', + duration: ANIMATIONS.selectionPulse, + ease: "Sine.easeInOut", yoyo: true, repeat: -1, }); @@ -114,8 +125,8 @@ export class CardContainer extends Phaser.GameObjects.Container { targets: this.highlightRect, alpha: 0.7, lineWidth: lineWidth + 1, - duration: 500, - ease: 'Sine.easeInOut', + duration: ANIMATIONS.selectionPulse, + ease: "Sine.easeInOut", yoyo: true, repeat: -1, }); @@ -139,8 +150,8 @@ export class CardContainer extends Phaser.GameObjects.Container { targets: this.highlightRect, alpha: 0, scale: 0.95, - duration: 150, - ease: 'Power2', + duration: ANIMATIONS.selectionFadeOut, + ease: "Power2", onComplete: () => { // 淡出完成后销毁矩形 this.highlightRect?.destroy(); @@ -164,117 +175,43 @@ export class CardContainer extends Phaser.GameObjects.Container { }); // 在容器销毁时清理 effect - this.on('destroy', () => { + this.on("destroy", () => { dispose(); }); } - - /** - * 创建卡牌视觉元素 - */ - private createCardVisual(card: Card): void { - const bg = (this.scene as OnitamaScene).add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb, 1) - .setStrokeStyle(2, 0x6b7280); - this.add(bg); - - const title = (this.scene as OnitamaScene).add.text(0, -CARD_HEIGHT / 2 + 16, card.id, { - fontSize: '12px', - fontFamily: 'Arial', - color: '#1f2937', - }).setOrigin(0.5); - this.add(title); - - const grid = (this.scene as OnitamaScene).add.graphics(); - const cellSize = 14; - const gridWidth = 5 * cellSize; - const gridHeight = 5 * cellSize; - const gridStartX = -gridWidth / 2; - const gridStartY = -gridHeight / 2 + 20; - - for (let row = 0; row < 5; row++) { - for (let col = 0; col < 5; col++) { - const x = gridStartX + col * cellSize; - const y = gridStartY + row * cellSize; - - if (row === 2 && col === 2) { - grid.fillStyle(0x3b82f6, 1); - grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); - } else { - const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row); - if (isTarget) { - grid.fillStyle(0xef4444, 0.6); - grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); - } - } - } - } - this.add(grid); - - const playerText = (this.scene as OnitamaScene).add.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, { - fontSize: '10px', - fontFamily: 'Arial', - color: '#6b7280', - }).setOrigin(0.5); - this.add(playerText); - } } export class CardSpawner implements Spawner { private previousData = new Map(); + private cardRenderer: CardRenderer; - constructor(public readonly scene: OnitamaScene) {} + constructor(public readonly scene: OnitamaScene) { + this.cardRenderer = new CardRenderer(scene); + } *getData(): Iterable { const state = this.scene.state; // 红方卡牌 for (let i = 0; i < state.redCards.length; i++) { - yield { cardId: state.redCards[i], position: 'red', index: i }; + yield { cardId: state.redCards[i], position: "red", index: i }; } // 黑方卡牌 for (let i = 0; i < state.blackCards.length; i++) { - yield { cardId: state.blackCards[i], position: 'black', index: i }; + yield { cardId: state.blackCards[i], position: "black", index: i }; } // 备用卡牌 - if(state.spareCard) - yield { cardId: state.spareCard, position: 'spare', index: 0 }; + if (state.spareCard) { + yield { cardId: state.spareCard, position: "spare", index: 0 }; + } } getKey(data: CardSpawnData): string { return data.cardId; } - private getCardPosition(data: CardSpawnData): { x: number, y: number } { - const boardLeft = BOARD_OFFSET.x; - const boardTop = BOARD_OFFSET.y; - const boardRight = BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE; - const boardCenterX = boardLeft + (BOARD_SIZE * CELL_SIZE) / 2; - const boardBottom = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE; - - if (data.position === 'red') { - // 红方卡牌在棋盘下方,水平排列 - return { - x: boardCenterX - (data.index - 0.5) * CARD_SPACING, - y: boardBottom + CARD_HEIGHT / 2 + 40, - }; - } else if (data.position === 'black') { - // 黑方卡牌在棋盘上方,水平排列 - return { - x: boardCenterX - (data.index - 0.5) * CARD_SPACING, - y: boardTop - CARD_HEIGHT / 2 - 40, - }; - } else { - // 备用卡牌在棋盘左侧,垂直居中 - const boardCenterY = boardTop + (BOARD_SIZE * CELL_SIZE) / 2; - return { - x: boardLeft - CARD_WIDTH / 2 - 40, - y: boardCenterY, - }; - } - } - private hasPositionChanged(data: CardSpawnData): boolean { const prev = this.previousData.get(data.cardId); if (!prev) return true; @@ -282,13 +219,13 @@ export class CardSpawner implements Spawner { } onUpdate(data: CardSpawnData, obj: CardContainer): void { - const isBlackTurn = this.scene.state.currentPlayer === 'black'; + const isBlackTurn = this.scene.state.currentPlayer === "black"; // 检查卡牌是否需要旋转 let targetRotation = 0; - if (data.position === 'black') { + if (data.position === "black") { targetRotation = -180; // 黑方卡牌始终旋转 - } else if (data.position === 'spare' && isBlackTurn) { + } else if (data.position === "spare" && isBlackTurn) { targetRotation = -180; // 备用卡牌在黑方回合旋转 } @@ -302,15 +239,15 @@ export class CardSpawner implements Spawner { return; } - const pos = this.getCardPosition(data); + const pos = getCardPosition(data.position, data.index); // 播放移动动画并添加中断 const tween = this.scene.tweens.add({ targets: obj, x: pos.x, y: pos.y, - duration: 600, - ease: 'Back.easeOut', + duration: ANIMATIONS.cardMove, + ease: "Back.easeOut", }); this.scene.addTweenInterruption(tween); @@ -323,39 +260,46 @@ export class CardSpawner implements Spawner { this.previousData.set(data.cardId, { ...data }); // 返回空容器 const emptyContainer = new CardContainer(this.scene, data.cardId, { - id: data.cardId, regionId: '', position: [], + id: data.cardId, + regionId: "", + position: [], moveCandidates: [], - startingPlayer: 'red' + startingPlayer: "red", } as Card); return emptyContainer; } // 计算初始旋转角度 - const isBlackTurn = this.scene.state.currentPlayer === 'black'; + const isBlackTurn = this.scene.state.currentPlayer === "black"; let initialRotation = 0; - if (data.position === 'black') { + if (data.position === "black") { initialRotation = -180; - } else if (data.position === 'spare' && isBlackTurn) { + } else if (data.position === "spare" && isBlackTurn) { initialRotation = -180; } - const container = new CardContainer(this.scene, data.cardId, card, initialRotation); - const pos = this.getCardPosition(data); + const container = new CardContainer( + this.scene, + data.cardId, + card, + initialRotation, + ); + const pos = getCardPosition(data.position, data.index); container.x = pos.x; container.y = pos.y; // 设置悬停效果 - container.on('pointerover', () => { + container.on("pointerover", () => { if (this.scene.uiState.value.selectedCard !== data.cardId) { container.setAlpha(0.8); } }); - container.on('pointerout', () => { + container.on("pointerout", () => { container.setAlpha(1); }); - container.on('pointerdown', () => { + container.on("pointerdown", () => { this.scene.onCardClick(data.cardId); }); @@ -364,8 +308,8 @@ export class CardSpawner implements Spawner { const tween = this.scene.tweens.add({ targets: container, alpha: 1, - duration: 300, - ease: 'Power2', + duration: ANIMATIONS.cardSpawn, + ease: "Power2", }); this.scene.addTweenInterruption(tween); @@ -378,8 +322,8 @@ export class CardSpawner implements Spawner { targets: obj, alpha: 0, scale: 0.8, - duration: 200, - ease: 'Power2', + duration: ANIMATIONS.cardDespawn, + ease: "Power2", onComplete: () => obj.destroy(), }); this.scene.addTweenInterruption(tween); diff --git a/packages/onitama-game/src/spawners/HighlightSpawner.ts b/packages/onitama-game/src/spawners/HighlightSpawner.ts index 6e41683..4dd3bec 100644 --- a/packages/onitama-game/src/spawners/HighlightSpawner.ts +++ b/packages/onitama-game/src/spawners/HighlightSpawner.ts @@ -1,8 +1,11 @@ -import Phaser from 'phaser'; -import type { Spawner } from 'boardgame-phaser'; -import type { OnitamaScene } from '@/scenes/OnitamaScene'; -import {getAvailableMoves} from "boardgame-core/samples/onitama"; -import {boardToScreen, CELL_SIZE} from './PawnSpawner'; +import { getAvailableMoves } from "boardgame-core/samples/onitama"; +import { Geom } from "phaser"; + +import type { OnitamaScene } from "@/scenes/OnitamaScene"; +import type { Spawner } from "boardgame-phaser"; + +import { boardToScreen, CELL_SIZE, ANIMATIONS } from "@/config"; +import { HighlightRenderer } from "@/renderers"; export interface HighlightData { key: string; @@ -15,8 +18,15 @@ export interface HighlightData { toY: number; } -export class HighlightSpawner implements Spawner { - constructor(public readonly scene: OnitamaScene) {} +export class HighlightSpawner implements Spawner< + HighlightData, + Phaser.GameObjects.Container +> { + private renderer: HighlightRenderer; + + constructor(public readonly scene: OnitamaScene) { + this.renderer = new HighlightRenderer(scene); + } *getData(): Iterable { const state = this.scene.state; @@ -31,10 +41,12 @@ export class HighlightSpawner implements Spawner { + container.on("pointerdown", () => { // 点击时的反馈动画 this.scene.tweens.add({ targets: container, scale: 1.5, alpha: 0.8, - duration: 150, - ease: 'Power2', + duration: ANIMATIONS.clickFeedback, + ease: "Power2", yoyo: true, }); @@ -145,11 +121,10 @@ export class HighlightSpawner implements Spawner obj.destroy(), }); this.scene.addTweenInterruption(despawnTween); } } - diff --git a/packages/onitama-game/src/spawners/PawnSpawner.ts b/packages/onitama-game/src/spawners/PawnSpawner.ts index edc197c..dbe9964 100644 --- a/packages/onitama-game/src/spawners/PawnSpawner.ts +++ b/packages/onitama-game/src/spawners/PawnSpawner.ts @@ -1,43 +1,41 @@ -import Phaser from 'phaser'; -import type { Pawn } from '@/game/onitama'; -import type { Spawner } from 'boardgame-phaser'; -import type { OnitamaScene } from '@/scenes/OnitamaScene'; -import type { OnitamaUIState } from '@/state'; import { effect } from "@preact/signals-core"; +import { GameObjects } from "phaser"; -export const CELL_SIZE = 80; -export const BOARD_OFFSET = { x: 240, y: 240 }; -export const BOARD_SIZE = 5; +import type { Pawn } from "@/game/onitama"; +import type { OnitamaScene } from "@/scenes/OnitamaScene"; +import type { Spawner } from "boardgame-phaser"; -export function boardToScreen(boardX: number, boardY: number): { x: number; y: number } { - return { - x: BOARD_OFFSET.x + boardX * CELL_SIZE + CELL_SIZE / 2, - y: BOARD_OFFSET.y + (BOARD_SIZE - 1 - boardY) * CELL_SIZE + CELL_SIZE / 2, - }; -} +import { boardToScreen, ANIMATIONS } from "@/config"; +import { PawnRenderer, SelectionRenderer } from "@/renderers"; + +// Re-export for backward compatibility +export { CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from "@/config"; /** * 继承自 Phaser.GameObjects.Container 的棋子容器类 * 管理棋子视觉元素和选中状态 */ -export class PawnContainer extends Phaser.GameObjects.Container { +export class PawnContainer extends GameObjects.Container { private selectionRing: Phaser.GameObjects.Arc | null = null; - private selectionTween: Phaser.Tweens.Tween | null = null; private _position: [number, number]; - private _owner: 'red' | 'black'; - private _type: 'master' | 'student'; + private _owner: "red" | "black"; + private _type: "master" | "student"; + private pawnRenderer: PawnRenderer; + private selectionRenderer: SelectionRenderer; constructor(scene: OnitamaScene, pawn: Pawn) { super(scene, 0, 0); this._owner = pawn.owner; this._type = pawn.type; this._position = pawn.position as [number, number]; + this.pawnRenderer = new PawnRenderer(scene); + this.selectionRenderer = new SelectionRenderer(scene); // 将容器添加到场景 scene.add.existing(this); - // 创建棋子视觉 - this.createPawnVisual(); + // 使用 PawnRenderer 创建棋子视觉 + this.pawnRenderer.render(this, { owner: this._owner, type: this._type }); // 添加选中状态监听 this.addSelectionEffect(scene); @@ -50,33 +48,8 @@ export class PawnContainer extends Phaser.GameObjects.Container { if (!this.active) return; if (!this.selectionRing) { - // 创建选中光环(初始透明) - this.selectionRing = (this.scene as OnitamaScene).add.arc( - 0, 0, CELL_SIZE / 3 + 5, 0, 360, false, 0xfbbf24, 0 - ) - .setStrokeStyle(3, 0xf59e0b, 1) - .setAlpha(0); - this.addAt(this.selectionRing, 0); - - // 淡入动画 - const fadeIn = this.scene.tweens.add({ - targets: this.selectionRing, - alpha: 0.8, - duration: 200, - ease: 'Power2', - onComplete: () => { - // 淡入完成后开始脉冲动画 - this.selectionTween = this.scene.tweens.add({ - targets: this.selectionRing, - scale: 1.15, - alpha: 0.6, - duration: 500, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1, - }); - }, - }); + this.selectionRing = this.selectionRenderer.create(this); + this.selectionRenderer.show(this.selectionRing); } } @@ -85,25 +58,8 @@ export class PawnContainer extends Phaser.GameObjects.Container { */ hideSelection(): void { if (this.selectionRing) { - // 停止所有动画 - if (this.selectionTween) { - this.selectionTween.stop(); - this.selectionTween = null; - } - this.scene.tweens.killTweensOf(this.selectionRing); - - // 淡出动画 - this.scene.tweens.add({ - targets: this.selectionRing, - alpha: 0, - scale: 0.9, - duration: 150, - ease: 'Power2', - onComplete: () => { - // 淡出完成后销毁 - this.selectionRing?.destroy(); - this.selectionRing = null; - }, + this.selectionRenderer.hide(this.selectionRing, () => { + this.selectionRing = null; }); } } @@ -114,8 +70,9 @@ export class PawnContainer extends Phaser.GameObjects.Container { private addSelectionEffect(scene: OnitamaScene): void { const dispose = effect(() => { const uiState = scene.uiState.value; - const isSelected = uiState.selectedPiece?.x === this._position[0] && - uiState.selectedPiece?.y === this._position[1]; + const isSelected = + uiState.selectedPiece?.x === this._position[0] && + uiState.selectedPiece?.y === this._position[1]; if (isSelected) { this.showSelection(); @@ -124,7 +81,7 @@ export class PawnContainer extends Phaser.GameObjects.Container { } }); - this.on('destroy', () => { + this.on("destroy", () => { dispose(); }); } @@ -132,7 +89,10 @@ export class PawnContainer extends Phaser.GameObjects.Container { /** * 更新棋子位置 */ - updatePosition(newPosition: [number, number], animated: boolean = false): void { + updatePosition( + newPosition: [number, number], + animated: boolean = false, + ): void { this._position = newPosition; const targetPos = boardToScreen(newPosition[0], newPosition[1]); @@ -141,8 +101,8 @@ export class PawnContainer extends Phaser.GameObjects.Container { targets: this, x: targetPos.x, y: targetPos.y, - duration: 400, - ease: 'Back.easeOut', + duration: ANIMATIONS.pawnMove, + ease: "Back.easeOut", }); (this.scene as OnitamaScene).addTweenInterruption(tween); } else { @@ -150,24 +110,6 @@ export class PawnContainer extends Phaser.GameObjects.Container { this.y = targetPos.y; } } - - /** - * 创建棋子视觉元素 - */ - private createPawnVisual(): void { - const bgColor = this._owner === 'red' ? 0xef4444 : 0x3b82f6; - const circle = (this.scene as OnitamaScene).add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) - .setStrokeStyle(2, 0x1f2937); - this.add(circle); - - const label = this._type === 'master' ? 'M' : 'S'; - const text = (this.scene as OnitamaScene).add.text(0, 0, label, { - fontSize: '24px', - fontFamily: 'Arial', - color: '#ffffff', - }).setOrigin(0.5); - this.add(text); - } } export class PawnSpawner implements Spawner { @@ -177,7 +119,7 @@ export class PawnSpawner implements Spawner { *getData() { for (const pawn of Object.values(this.scene.state.pawns)) { - if (pawn.regionId === 'board') { + if (pawn.regionId === "board") { yield pawn; } } @@ -214,8 +156,8 @@ export class PawnSpawner implements Spawner { const tween = this.scene.tweens.add({ targets: container, scale: 1, - duration: 300, - ease: 'Back.easeOut', + duration: ANIMATIONS.pawnSpawn, + ease: "Back.easeOut", }); this.scene.addTweenInterruption(tween); @@ -228,8 +170,8 @@ export class PawnSpawner implements Spawner { targets: obj, scale: 0, alpha: 0, - duration: 300, - ease: 'Back.easeIn', + duration: ANIMATIONS.pawnDespawn, + ease: "Back.easeIn", onComplete: () => obj.destroy(), }); diff --git a/packages/onitama-game/src/spawners/index.ts b/packages/onitama-game/src/spawners/index.ts index cb2d05a..7e536ac 100644 --- a/packages/onitama-game/src/spawners/index.ts +++ b/packages/onitama-game/src/spawners/index.ts @@ -1,3 +1,14 @@ -export { PawnSpawner, CELL_SIZE, BOARD_OFFSET, BOARD_SIZE, boardToScreen } from './PawnSpawner'; -export { CardSpawner, CARD_WIDTH, CARD_HEIGHT, type CardSpawnData } from './CardSpawner'; -export { HighlightSpawner, type HighlightData } from './HighlightSpawner'; +export { PawnSpawner } from "./PawnSpawner"; +export { CardSpawner, type CardSpawnData } from "./CardSpawner"; +export { HighlightSpawner, type HighlightData } from "./HighlightSpawner"; + +// Re-export config constants for backward compatibility +// New code should import these directly from '@/config' +export { + CELL_SIZE, + BOARD_OFFSET, + BOARD_SIZE, + boardToScreen, + CARD_WIDTH, + CARD_HEIGHT, +} from "@/config"; diff --git a/packages/onitama-game/src/state/index.ts b/packages/onitama-game/src/state/index.ts index 7659856..3b533e1 100644 --- a/packages/onitama-game/src/state/index.ts +++ b/packages/onitama-game/src/state/index.ts @@ -1,2 +1,8 @@ -export { createUIState, clearSelection, selectPiece, selectCard, createValidMoves } from './ui'; -export type { OnitamaUIState, ValidMove } from './ui'; +export { + createUIState, + clearSelection, + selectPiece, + selectCard, + createValidMoves, +} from "./ui"; +export type { OnitamaUIState, ValidMove } from "./ui"; diff --git a/packages/onitama-game/src/state/ui.ts b/packages/onitama-game/src/state/ui.ts index 47852a8..b5bed34 100644 --- a/packages/onitama-game/src/state/ui.ts +++ b/packages/onitama-game/src/state/ui.ts @@ -1,5 +1,8 @@ -import { MutableSignal, mutableSignal, computed, ReadonlySignal } from 'boardgame-core'; -import {getAvailableMoves, OnitamaState} from "boardgame-core/samples/onitama"; +import { mutableSignal, computed } from "boardgame-core"; +import { getAvailableMoves } from "boardgame-core/samples/onitama"; + +import type { MutableSignal, ReadonlySignal } from "boardgame-core"; +import type { OnitamaState } from "boardgame-core/samples/onitama"; export interface ValidMove { card: string; @@ -22,18 +25,26 @@ export function createUIState(): MutableSignal { }); } -export function createValidMoves(state: ReadonlySignal, ui: ReadonlySignal){ +export function createValidMoves( + state: ReadonlySignal, + ui: ReadonlySignal, +) { return computed(() => { - return getAvailableMoves(state.value, state.value.currentPlayer) - .filter(move => { - const {selectedCard, selectedPiece} = ui.value; - return selectedPiece?.x === move.fromX && selectedPiece?.y === move.fromY && selectedCard === move.card; - }) + return getAvailableMoves(state.value, state.value.currentPlayer).filter( + (move) => { + const { selectedCard, selectedPiece } = ui.value; + return ( + selectedPiece?.x === move.fromX && + selectedPiece?.y === move.fromY && + selectedCard === move.card + ); + }, + ); }); } export function clearSelection(uiState: MutableSignal): void { - uiState.produce(state => { + uiState.produce((state) => { state.selectedPiece = null; state.selectedCard = null; }); @@ -42,13 +53,13 @@ export function clearSelection(uiState: MutableSignal): void { export function selectPiece( uiState: MutableSignal, x: number, - y: number + y: number, ): void { - uiState.produce(state => { + uiState.produce((state) => { // 如果点击已选中的棋子,取消选择 - if(state.selectedPiece?.x === x && state.selectedPiece?.y === y){ + if (state.selectedPiece?.x === x && state.selectedPiece?.y === y) { state.selectedPiece = null; - }else{ + } else { state.selectedPiece = { x, y }; } }); @@ -56,9 +67,9 @@ export function selectPiece( export function selectCard( uiState: MutableSignal, - card: string + card: string, ): void { - uiState.produce(state => { + uiState.produce((state) => { // 如果点击已选中的卡牌,取消选择 if (state.selectedCard === card) { state.selectedCard = null; diff --git a/packages/onitama-game/src/ui/App.tsx b/packages/onitama-game/src/ui/App.tsx index 4a7dddc..71a4290 100644 --- a/packages/onitama-game/src/ui/App.tsx +++ b/packages/onitama-game/src/ui/App.tsx @@ -1,25 +1,37 @@ -import { h } from 'preact'; -import {PhaserGame, PhaserScene } from 'boardgame-phaser'; -import {MenuScene} from "@/scenes/MenuScene"; -import {useMemo} from "preact/hooks"; -import * as gameModule from '../game/onitama'; -import {OnitamaScene} from "@/scenes/OnitamaScene"; -import {createGameHost, type GameModule} from "boardgame-core"; -import type {OnitamaState} from "@/game/onitama"; +import { createGameHost, type GameModule } from "boardgame-core"; +import { PhaserGame, PhaserScene } from "boardgame-phaser"; +import { useMemo } from "preact/hooks"; + +import * as gameModule from "../game/onitama"; + +import type { OnitamaState } from "@/game/onitama"; + +import { MenuScene } from "@/scenes/MenuScene"; +import { OnitamaScene } from "@/scenes/OnitamaScene"; export default function App() { - const gameHost = useMemo(() => createGameHost(gameModule as unknown as GameModule), []); - const gameScene = useMemo(() => new OnitamaScene(), []); - const menuScene = useMemo(() => new MenuScene(), []); + const gameHost = useMemo( + () => createGameHost(gameModule as unknown as GameModule), + [], + ); + const gameScene = useMemo(() => new OnitamaScene(), []); + const menuScene = useMemo(() => new MenuScene(), []); - return ( -
-
- - - - -
-
- ); + return ( +
+
+ + + + +
+
+ ); }