diff --git a/packages/boop-game/src/game/boop.ts b/packages/boop-game/src/game/boop.ts index 27d815f..320a7f9 100644 --- a/packages/boop-game/src/game/boop.ts +++ b/packages/boop-game/src/game/boop.ts @@ -3,9 +3,12 @@ import { Part, MutableSignal, createRegion, + createPartPool, createPart, + moveToRegion, isCellOccupied as isCellOccupiedUtil, getPartAtPosition, + applyAlign, } from 'boardgame-core'; const BOARD_SIZE = 6; @@ -15,20 +18,45 @@ const WIN_LENGTH = 3; export type PlayerType = 'white' | 'black'; export type PieceType = 'kitten' | 'cat'; export type WinnerType = PlayerType | 'draw' | null; +export type RegionId = 'board' | 'white-kitten' | 'white-cat' | 'black-kitten' | 'black-cat'; export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>; -type PieceSupply = { supply: number; placed: number }; - type Player = { id: PlayerType; - kitten: PieceSupply; - cat: PieceSupply; + kittenPool: ReturnType>; + catPool: ReturnType>; + graduatedCount: number; // 已毕业但未放置的大猫数量 }; type PlayerData = Record; -export function createInitialState() { +export type BoopState = { + board: ReturnType; + pieces: Record; + currentPlayer: PlayerType; + winner: WinnerType; + players: PlayerData; +}; + +function createPlayer(id: PlayerType): Player { + return { + id, + kittenPool: createPartPool( + { regionId: `${id}-kitten`, player: id, pieceType: 'kitten' as PieceType }, + MAX_PIECES_PER_PLAYER, + `${id}-kitten` + ), + catPool: createPartPool( + { regionId: `${id}-cat`, player: id, pieceType: 'cat' as PieceType }, + MAX_PIECES_PER_PLAYER, // 预创建 MAX_PIECES_PER_PLAYER 只猫 + `${id}-cat` + ), + graduatedCount: 0, + }; +} + +export function createInitialState(): BoopState { return { board: createRegion('board', [ { name: 'x', min: 0, max: BOARD_SIZE - 1 }, @@ -44,31 +72,14 @@ export function createInitialState() { }; } -function createPlayer(id: PlayerType): Player { - return { - id, - kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 }, - cat: { supply: 0, placed: 0 }, - }; -} - -export type BoopState = ReturnType; -const registration = createGameCommandRegistry(); +export type BoopStateType = ReturnType; +const registration = createGameCommandRegistry(); export const registry = registration.registry; -export function getPlayer(host: MutableSignal, player: PlayerType): Player { +export function getPlayer(host: MutableSignal, player: PlayerType): Player { return host.value.players[player]; } -export function decrementSupply(player: Player, pieceType: PieceType) { - player[pieceType].supply--; - player[pieceType].placed++; -} - -export function incrementSupply(player: Player, pieceType: PieceType, count?: number) { - player[pieceType].supply += count ?? 1; -} - registration.add('setup', async function() { const {context} = this; while (true) { @@ -108,9 +119,16 @@ registration.add('turn ', async function(cmd) { } const playerData = getPlayer(this.context, player); - const supply = playerData[pieceType].supply; - if (supply <= 0) { - return `No ${pieceType}s left in ${player}'s supply.`; + if (pieceType === 'kitten') { + if (playerData.kittenPool.remaining() <= 0) { + return `No kittens left in ${player}'s supply.`; + } + } else { + // Can place cat if pool has remaining OR graduatedCount > 0 + const availableCats = playerData.catPool.remaining() + playerData.graduatedCount; + if (availableCats <= 0) { + return `No cats available for ${player}. Graduate some kittens first.`; + } } return null; }, @@ -120,7 +138,7 @@ registration.add('turn ', async function(cmd) { const pieceType = type === 'cat' ? 'cat' : 'kitten'; placePiece(this.context, row, col, turnPlayer, pieceType); - await applyBoops(this.context, row, col, pieceType); + applyBoops(this.context, row, col, pieceType); const graduatedLines = checkGraduation(this.context, turnPlayer); if (graduatedLines.length > 0) { @@ -138,18 +156,14 @@ registration.add('turn ', async function(cmd) { 'graduate ', (command) => { const [row, col] = command.params as [number, number]; - const posKey = `${row},${col}`; - const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey); + const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col); if (!part) return `No kitten at (${row}, ${col}).`; return null; }, this.context.value.currentPlayer ); const [row, col] = graduateCmd.params as [number, number]; - const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!; - removePieceFromBoard(this.context, part); - const playerData = getPlayer(this.context, turnPlayer); - incrementSupply(playerData, 'cat', 1); + graduatePiece(this.context, row, col, turnPlayer); } } @@ -163,40 +177,59 @@ function isValidMove(row: number, col: number): boolean { return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; } -export function getBoardRegion(host: MutableSignal) { +export function getBoardRegion(host: MutableSignal) { return host.value.board; } -export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { +export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]); } -export function getPartAt(host: MutableSignal, row: number, col: number): BoopPart | null { +export function getPartAt(host: MutableSignal, row: number, col: number): BoopPart | null { return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null; } -export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType, pieceType: PieceType) { +export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType, pieceType: PieceType) { const board = getBoardRegion(host); const playerData = getPlayer(host, player); - const count = playerData[pieceType].placed + 1; - const piece = createPart<{ player: PlayerType; pieceType: PieceType }>( - { regionId: 'board', position: [row, col], player, pieceType }, - `${player}-${pieceType}-${count}` - ); + let piece: BoopPart; + + if (pieceType === 'kitten') { + const drawn = playerData.kittenPool.draw(); + if (!drawn) throw new Error(`No kitten available in ${player}'s supply`); + piece = drawn; + moveToRegion(piece, null, board, [row, col]); + } else { + // Try to use graduated count first + if (playerData.graduatedCount > 0) { + // Create a new cat piece (graduated) + const count = playerData.catPool.remaining() + playerData.graduatedCount; + piece = createPart<{ player: PlayerType; pieceType: PieceType }>( + { regionId: 'board', position: [row, col], player, pieceType }, + `${player}-cat-graduated-${count}` + ); + playerData.graduatedCount--; + } else { + const drawn = playerData.catPool.draw(); + if (!drawn) throw new Error(`No cat available in ${player}'s supply`); + piece = drawn; + moveToRegion(piece, null, board, [row, col]); + } + } + host.produce(s => { s.pieces[piece.id] = piece; - board.childIds.push(piece.id); - board.partMap[`${row},${col}`] = piece.id; }); - decrementSupply(playerData, pieceType); } -export async function applyBoops(host: MutableSignal, placedRow: number, placedCol: number, placedType: PieceType) { +export function applyBoops(host: MutableSignal, placedRow: number, placedCol: number, placedType: PieceType) { + const board = getBoardRegion(host); const pieces = host.value.pieces; const piecesArray = Object.values(pieces); const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = []; + const piecesOffBoard: BoopPart[] = []; for (const part of piecesArray) { const [r, c] = part.position; @@ -215,49 +248,66 @@ export async function applyBoops(host: MutableSignal, placedRow: numb } } - await host.produceAsync(state => { - const board = state.board; - const currentPieces = state.pieces; - + host.produce(state => { for (const { part, dr, dc } of piecesToBoop) { const [r, c] = part.position; const newRow = r + dr; const newCol = c + dc; if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) { - const pt = part.pieceType; - const pl = part.player; - const playerData = state.players[pl]; - // Remove piece from board - board.childIds = board.childIds.filter(id => id !== part.id); - delete board.partMap[part.position.join(',')]; - delete currentPieces[part.id]; - playerData[pt].placed--; - playerData[pt].supply++; + // Mark for removal + piecesOffBoard.push(part); + delete state.pieces[part.id]; + state.board.childIds = state.board.childIds.filter(id => id !== part.id); continue; } - // Check if target cell is occupied - const targetPosKey = `${newRow},${newCol}`; - if (board.partMap[targetPosKey]) continue; + if (isCellOccupied(host, newRow, newCol)) continue; - // Move piece to new position - delete board.partMap[part.position.join(',')]; - part.position = [newRow, newCol]; - board.partMap[targetPosKey] = part.id; + moveToRegion(part, board, board, [newRow, newCol]); } }); + + // Return booped pieces to pools outside of produce + for (const part of piecesOffBoard) { + const playerData = getPlayer(host, part.player); + const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool; + pool.return(part); + } } -export function removePieceFromBoard(host: MutableSignal, part: BoopPart) { +export function graduatePiece(host: MutableSignal, row: number, col: number, player: PlayerType) { + const pieces = host.value.pieces; + const part = Object.values(pieces).find(p => p.player === player && p.pieceType === 'kitten' && p.position[0] === row && p.position[1] === col); + if (!part) return; + + const board = getBoardRegion(host); + const playerData = getPlayer(host, player); + host.produce(state => { - const board = state.board; - const playerData = state.players[part.player]; - board.childIds = board.childIds.filter(id => id !== part.id); - delete board.partMap[part.position.join(',')]; + // Remove from board delete state.pieces[part.id]; - playerData[part.pieceType].placed--; + state.board.childIds = state.board.childIds.filter(id => id !== part.id); }); + + // Return kitten to supply + playerData.kittenPool.return(part); + + // Increment graduated count (available cats to place) + playerData.graduatedCount++; +} + +export function removePieceFromBoard(host: MutableSignal, part: BoopPart) { + const board = getBoardRegion(host); + const playerData = getPlayer(host, part.player); + const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool; + + host.produce(state => { + delete state.pieces[part.id]; + state.board.childIds = state.board.childIds.filter(id => id !== part.id); + }); + + pool.return(part); } const DIRECTIONS: [number, number][] = [ @@ -311,7 +361,7 @@ export function hasWinningLine(positions: number[][]): boolean { return false; } -export function checkGraduation(host: MutableSignal, player: PlayerType): number[][][] { +export function checkGraduation(host: MutableSignal, player: PlayerType): number[][][] { const pieces = host.value.pieces; const piecesArray = Object.values(pieces); const posSet = new Set(); @@ -331,7 +381,7 @@ export function checkGraduation(host: MutableSignal, player: PlayerTy return winningLines; } -export function processGraduation(host: MutableSignal, player: PlayerType, lines: number[][][]) { +export function processGraduation(host: MutableSignal, player: PlayerType, lines: number[][][]) { const allPositions = new Set(); for (const line of lines) { for (const [r, c] of line) { @@ -339,27 +389,22 @@ export function processGraduation(host: MutableSignal, player: Player } } - const board = getBoardRegion(host); const pieces = host.value.pieces; const partsToRemove = Object.values(pieces).filter( p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`) ); for (const part of partsToRemove) { - removePieceFromBoard(host, part); + graduatePiece(host, part.position[0], part.position[1], player); } - - const count = partsToRemove.length; - const playerData = getPlayer(host, player); - incrementSupply(playerData, 'cat', count); } -export function countPiecesOnBoard(host: MutableSignal, player: PlayerType): number { +export function countPiecesOnBoard(host: MutableSignal, player: PlayerType): number { const pieces = host.value.pieces; return Object.values(pieces).filter(p => p.player === player).length; } -export function checkWinner(host: MutableSignal): WinnerType { +export function checkWinner(host: MutableSignal): WinnerType { const pieces = host.value.pieces; const piecesArray = Object.values(pieces); @@ -375,7 +420,7 @@ export function checkWinner(host: MutableSignal): WinnerType { // 命令构建器 export const commands = { - play: (player: PlayerType, row: number, col: number, type?: PieceType) => + play: (player: PlayerType, row: number, col: number, type?: PieceType) => `play ${player} ${row} ${col}${type ? ` ${type}` : ''}`, turn: (player: PlayerType) => `turn ${player}`, graduate: (row: number, col: number) => `graduate ${row} ${col}`, diff --git a/packages/boop-game/src/scenes/BoardRenderer.ts b/packages/boop-game/src/scenes/BoardRenderer.ts new file mode 100644 index 0000000..d040638 --- /dev/null +++ b/packages/boop-game/src/scenes/BoardRenderer.ts @@ -0,0 +1,109 @@ +import Phaser from 'phaser'; +import type { BoopState, PlayerType } from '@/game/boop'; +import type { ReadonlySignal } from '@preact/signals-core'; + +const BOARD_SIZE = 6; +const CELL_SIZE = 80; +const BOARD_OFFSET = { x: 80, y: 100 }; + +export { BOARD_SIZE, CELL_SIZE, BOARD_OFFSET }; + +export class BoardRenderer { + private container: Phaser.GameObjects.Container; + private gridGraphics: Phaser.GameObjects.Graphics; + private turnText: Phaser.GameObjects.Text; + private infoText: Phaser.GameObjects.Text; + + constructor(private scene: Phaser.Scene) { + this.container = this.scene.add.container(0, 0); + this.gridGraphics = this.scene.add.graphics(); + this.drawGrid(); + + this.turnText = this.scene.add.text( + BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, + BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30, + '', + { + fontSize: '22px', + fontFamily: 'Arial', + color: '#4b5563', + } + ).setOrigin(0.5); + + this.infoText = this.scene.add.text( + BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, + BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60, + 'Click to place kitten. Cats win with 3 in a row!', + { + fontSize: '16px', + fontFamily: 'Arial', + color: '#6b7280', + } + ).setOrigin(0.5); + } + + private drawGrid(): void { + const g = this.gridGraphics; + g.lineStyle(2, 0x6b7280); + + for (let i = 1; 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, + ); + g.lineBetween( + BOARD_OFFSET.x, + BOARD_OFFSET.y + i * CELL_SIZE, + BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE, + BOARD_OFFSET.y + i * CELL_SIZE, + ); + } + + g.strokePath(); + + this.scene.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', { + fontSize: '32px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + } + + updateTurnText(player: PlayerType, state: BoopState): void { + const current = player === 'white' ? state.players.white : state.players.black; + const catsAvailable = current.catPool.remaining() + current.graduatedCount; + + this.turnText.setText( + `${player.toUpperCase()}'s turn | Kittens: ${current.kittenPool.remaining()} | Cats: ${catsAvailable}` + ); + } + + setupInput( + state: ReadonlySignal, + onCellClick: (row: number, col: number) => void + ): void { + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; + const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; + + const zone = this.scene.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive(); + + zone.on('pointerdown', () => { + const isOccupied = !!state.value.board.partMap[`${row},${col}`]; + if (!isOccupied && !state.value.winner) { + onCellClick(row, col); + } + }); + } + } + } + + destroy(): void { + this.container.destroy(); + this.gridGraphics.destroy(); + this.turnText.destroy(); + this.infoText.destroy(); + } +} diff --git a/packages/boop-game/src/scenes/GameScene.ts b/packages/boop-game/src/scenes/GameScene.ts index 55ce5d1..136f405 100644 --- a/packages/boop-game/src/scenes/GameScene.ts +++ b/packages/boop-game/src/scenes/GameScene.ts @@ -1,31 +1,18 @@ import Phaser from 'phaser'; -import type {BoopState, BoopPart, PlayerType, PieceType} from '@/game/boop'; +import type { BoopState, PlayerType, PieceType } from '@/game/boop'; import { GameHostScene } from 'boardgame-phaser'; -import { spawnEffect, type Spawner } from 'boardgame-phaser'; -import type { ReadonlySignal } from '@preact/signals-core'; -import {commands} from "@/game/boop"; -import {MutableSignal} from "boardgame-core"; - -const BOARD_SIZE = 6; -const CELL_SIZE = 80; -const BOARD_OFFSET = { x: 80, y: 100 }; +import { commands } from '@/game/boop'; +import { BoardRenderer } from './BoardRenderer'; +import { createPieceSpawner } from './PieceSpawner'; +import { SupplyUI } from './SupplyUI'; +import { PieceTypeSelector } from './PieceTypeSelector'; +import { WinnerOverlay } from './WinnerOverlay'; export class GameScene extends GameHostScene { - private boardContainer!: Phaser.GameObjects.Container; - private gridGraphics!: Phaser.GameObjects.Graphics; - private turnText!: Phaser.GameObjects.Text; - private infoText!: Phaser.GameObjects.Text; - private winnerOverlay?: Phaser.GameObjects.Container; - private whiteSupplyContainer!: Phaser.GameObjects.Container; - private blackSupplyContainer!: Phaser.GameObjects.Container; - private whiteSupplyBg!: Phaser.GameObjects.Rectangle; - private blackSupplyBg!: Phaser.GameObjects.Rectangle; - private whiteSupplyText!: Phaser.GameObjects.Text; - private blackSupplyText!: Phaser.GameObjects.Text; - private pieceTypeSelector!: Phaser.GameObjects.Container; - private selectedPieceType: PieceType = 'kitten'; - private kittenButton!: Phaser.GameObjects.Container; - private catButton!: Phaser.GameObjects.Container; + private boardRenderer!: BoardRenderer; + private supplyUI!: SupplyUI; + private pieceTypeSelector!: PieceTypeSelector; + private winnerOverlay!: WinnerOverlay; constructor() { super('GameScene'); @@ -34,437 +21,51 @@ export class GameScene extends GameHostScene { create(): void { super.create(); - this.boardContainer = this.add.container(0, 0); - this.gridGraphics = this.add.graphics(); - this.drawGrid(); - this.createSupplyUI(); + // 初始化 UI 组件 + this.boardRenderer = new BoardRenderer(this); + this.supplyUI = new SupplyUI(this); + this.pieceTypeSelector = new PieceTypeSelector(this); + this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame()); - this.disposables.add(spawnEffect(new BoopPartSpawner(this))); + // 设置棋子生成器 + this.disposables.add(createPieceSpawner(this)); + // 设置输入处理 + this.boardRenderer.setupInput(this.gameHost.state, (row, col) => { + this.handleCellClick(row, col); + }); + + // 监听状态变化 this.watch(() => { const winner = this.state.winner; if (winner) { - this.showWinner(winner); - } else if (this.winnerOverlay) { - this.winnerOverlay.destroy(); - this.winnerOverlay = undefined; + this.winnerOverlay.show(winner); + } else { + this.winnerOverlay.hide(); } }); this.watch(() => { const currentPlayer = this.state.currentPlayer; - this.updateTurnText(currentPlayer); - this.updateSupplyUI(); - this.updatePieceTypeSelector(); + this.boardRenderer.updateTurnText(currentPlayer, this.state); + this.supplyUI.update(this.state); + this.pieceTypeSelector.update(this.state); }); - this.createPieceTypeSelector(); - this.setupInput(); + // 设置棋子类型选择器回调 + // 可以在这里添加类型改变时的额外逻辑 } - private setupInput(): void { - for (let row = 0; row < BOARD_SIZE; row++) { - for (let col = 0; col < BOARD_SIZE; col++) { - const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; - const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; - - const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive(); - - zone.on('pointerdown', () => { - if (this.state.winner) return; - if (this.isCellOccupied(row, col)) return; - - const cmd = commands.play(this.state.currentPlayer, row, col, this.selectedPieceType); - const error = this.gameHost.onInput(cmd); - if (error) { - console.warn('Invalid move:', error); - } - }); - } + private handleCellClick(row: number, col: number): void { + const selectedType = this.pieceTypeSelector.getSelectedType(); + const cmd = commands.play(this.state.currentPlayer, row, col, selectedType); + const error = this.gameHost.onInput(cmd); + if (error) { + console.warn('Invalid move:', error); } } - private drawGrid(): void { - const g = this.gridGraphics; - g.lineStyle(2, 0x6b7280); - - for (let i = 1; 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, - ); - g.lineBetween( - BOARD_OFFSET.x, - BOARD_OFFSET.y + i * CELL_SIZE, - BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE, - BOARD_OFFSET.y + i * CELL_SIZE, - ); - } - - g.strokePath(); - - this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 50, 'Boop Game', { - fontSize: '32px', - fontFamily: 'Arial', - color: '#1f2937', - }).setOrigin(0.5); - - this.turnText = this.add.text( - BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, - BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30, - '', - { - fontSize: '22px', - fontFamily: 'Arial', - color: '#4b5563', - } - ).setOrigin(0.5); - - this.infoText = this.add.text( - BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, - BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60, - 'Click to place kitten. Cats win with 3 in a row!', - { - fontSize: '16px', - fontFamily: 'Arial', - color: '#6b7280', - } - ).setOrigin(0.5); - - this.updateTurnText(this.state.currentPlayer); - } - - private createSupplyUI(): void { - const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2; - const uiY = BOARD_OFFSET.y - 20; - - // 白色玩家容器 - this.whiteSupplyContainer = this.add.container(boardCenterX - 150, uiY); - this.whiteSupplyBg = this.add.rectangle(0, 0, 120, 50, 0x000000); - this.whiteSupplyText = this.add.text(0, 0, '', { - fontSize: '16px', - fontFamily: 'Arial', - color: '#ffffff', - align: 'center', - }).setOrigin(0.5); - this.whiteSupplyContainer.add([this.whiteSupplyBg, this.whiteSupplyText]); - this.whiteSupplyContainer.setDepth(100); - - // 黑色玩家容器 - this.blackSupplyContainer = this.add.container(boardCenterX + 150, uiY); - this.blackSupplyBg = this.add.rectangle(0, 0, 120, 50, 0x333333); - this.blackSupplyText = this.add.text(0, 0, '', { - fontSize: '16px', - fontFamily: 'Arial', - color: '#ffffff', - align: 'center', - }).setOrigin(0.5); - this.blackSupplyContainer.add([this.blackSupplyBg, this.blackSupplyText]); - this.blackSupplyContainer.setDepth(100); - - this.updateSupplyUI(); - } - - private updateSupplyUI(): void { - const white = this.state.players.white; - const black = this.state.players.black; - - this.whiteSupplyText.setText( - `⚪ WHITE\n🐾 ${white.kitten.supply} | 🐱 ${white.cat.supply}` - ); - - this.blackSupplyText.setText( - `⚫ BLACK\n🐾 ${black.kitten.supply} | 🐱 ${black.cat.supply}` - ); - - // 高亮当前玩家(使用动画) - const isWhiteTurn = this.state.currentPlayer === 'white'; - - // 停止之前的动画 - this.tweens.killTweensOf(this.whiteSupplyContainer); - this.tweens.killTweensOf(this.blackSupplyContainer); - - if (isWhiteTurn) { - // 白色玩家弹跳 + 脉冲 - this.whiteSupplyBg.setFillStyle(0xfbbf24); - this.whiteSupplyContainer.setScale(1); - this.tweens.add({ - targets: this.whiteSupplyContainer, - scale: 1.15, - duration: 200, - ease: 'Back.easeOut', - yoyo: true, - hold: 400, - onComplete: () => { - this.tweens.add({ - targets: this.whiteSupplyContainer, - alpha: 0.6, - duration: 600, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1, - }); - }, - }); - this.blackSupplyBg.setFillStyle(0x333333); - this.blackSupplyContainer.setAlpha(1); - this.blackSupplyContainer.setScale(1); - } else { - // 黑色玩家弹跳 + 脉冲 - this.blackSupplyBg.setFillStyle(0xfbbf24); - this.blackSupplyContainer.setScale(1); - this.tweens.add({ - targets: this.blackSupplyContainer, - scale: 1.15, - duration: 200, - ease: 'Back.easeOut', - yoyo: true, - hold: 400, - onComplete: () => { - this.tweens.add({ - targets: this.blackSupplyContainer, - alpha: 0.6, - duration: 600, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1, - }); - }, - }); - this.whiteSupplyBg.setFillStyle(0x000000); - this.whiteSupplyContainer.setAlpha(1); - this.whiteSupplyContainer.setScale(1); - } - } - - private createPieceTypeSelector(): void { - const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2; - const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100; - - this.pieceTypeSelector = this.add.container(boardCenterX, selectorY); - - // 标签文字 - const label = this.add.text(-160, 0, '放置:', { - fontSize: '18px', - fontFamily: 'Arial', - color: '#4b5563', - }).setOrigin(0.5, 0.5); - - // 小猫按钮 - this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50); - // 大猫按钮 - this.catButton = this.createPieceButton('cat', '🐱 大猫', 70); - - this.pieceTypeSelector.add([label, this.kittenButton, this.catButton]); - this.updatePieceTypeSelector(); - } - - private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container { - const container = this.add.container(xOffset, 0); - - const bg = this.add.rectangle(0, 0, 100, 40, 0xe5e7eb) - .setStrokeStyle(2, 0x9ca3af); - - const textObj = this.add.text(0, 0, text, { - fontSize: '16px', - fontFamily: 'Arial', - color: '#1f2937', - }).setOrigin(0.5); - - container.add([bg, textObj]); - - // 使按钮可交互 - bg.setInteractive({ useHandCursor: true }); - bg.on('pointerdown', () => { - this.selectedPieceType = type; - this.updatePieceTypeSelector(); - }); - - // 存储引用以便后续更新 - if (type === 'kitten') { - this.kittenButton = container; - } else { - this.catButton = container; - } - - return container; - } - - private updatePieceTypeSelector(): void { - if (!this.kittenButton || !this.catButton) return; - - const white = this.state.players.white; - const black = this.state.players.black; - const currentPlayer = this.state.players[this.state.currentPlayer]; - const isWhiteTurn = this.state.currentPlayer === 'white'; - - // 更新按钮状态 - const kittenAvailable = currentPlayer.kitten.supply > 0; - const catAvailable = currentPlayer.cat.supply > 0; - - // 更新小猫按钮 - const kittenBg = this.kittenButton.list[0] as Phaser.GameObjects.Rectangle; - const kittenText = this.kittenButton.list[1] as Phaser.GameObjects.Text; - const isKittenSelected = this.selectedPieceType === 'kitten'; - - kittenBg.setFillStyle(isKittenSelected ? 0xfbbf24 : (kittenAvailable ? 0xe5e7eb : 0xd1d5db)); - kittenText.setText(`🐾 小猫 (${currentPlayer.kitten.supply})`); - - // 更新大猫按钮 - const catBg = this.catButton.list[0] as Phaser.GameObjects.Rectangle; - const catText = this.catButton.list[1] as Phaser.GameObjects.Text; - const isCatSelected = this.selectedPieceType === 'cat'; - - catBg.setFillStyle(isCatSelected ? 0xfbbf24 : (catAvailable ? 0xe5e7eb : 0xd1d5db)); - catText.setText(`🐱 大猫 (${currentPlayer.cat.supply})`); - - // 如果选中的类型不可用,切换到可用的 - if (!kittenAvailable && this.selectedPieceType === 'kitten' && catAvailable) { - this.selectedPieceType = 'cat'; - this.updatePieceTypeSelector(); - } else if (!catAvailable && this.selectedPieceType === 'cat' && kittenAvailable) { - this.selectedPieceType = 'kitten'; - this.updatePieceTypeSelector(); - } - } - - private updateTurnText(player: PlayerType): void { - if (this.turnText) { - const whitePieces = this.state.players.white; - const blackPieces = this.state.players.black; - const current = player === 'white' ? whitePieces : blackPieces; - - this.turnText.setText( - `${player.toUpperCase()}'s turn | Kittens: ${current.kitten.supply} | Cats: ${current.cat.supply}` - ); - } - } - - private showWinner(winner: PlayerType | 'draw' | null): void { - if (this.winnerOverlay) { - this.winnerOverlay.destroy(); - } - - this.winnerOverlay = this.add.container(); - - const text = winner === 'draw' ? "It's a draw!" : winner ? `${winner.toUpperCase()} wins!` : ''; - - 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 }); - - bg.on('pointerdown', () => { - this.gameHost.setup('setup'); - }); - - 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: '40px', - fontFamily: 'Arial', - color: '#fbbf24', - }, - ).setOrigin(0.5); - - this.winnerOverlay.add(winText); - - this.tweens.add({ - targets: winText, - scale: 1.2, - duration: 500, - yoyo: true, - repeat: 1, - }); - } - - private isCellOccupied(row: number, col: number): boolean { - return !!this.state.board.partMap[`${row},${col}`]; - } -} - -class BoopPartSpawner implements Spawner { - constructor(public readonly scene: GameHostScene) {} - - *getData() { - for (const part of Object.values(this.scene.state.pieces)) { - yield part; - } - } - - getKey(part: BoopPart): string { - return part.id; - } - - onUpdate(part: BoopPart, obj: Phaser.GameObjects.Container): void { - const [row, col] = part.position; - const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; - const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; - - // 使用 tween 动画平滑移动棋子 - this.scene.tweens.add({ - targets: obj, - x: x, - y: y, - duration: 200, - ease: 'Power2', - }); - } - - onSpawn(part: BoopPart) { - const [row, col] = part.position; - const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; - const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; - - const container = this.scene.add.container(x, y); - - const isCat = part.pieceType === 'cat'; - const baseColor = part.player === 'white' ? 0xffffff : 0x333333; - const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff; - - // 绘制圆形背景 - const circle = this.scene.add.circle(0, 0, CELL_SIZE * 0.4, baseColor) - .setStrokeStyle(3, strokeColor); - - // 添加文字标识 - const text = isCat ? '🐱' : '🐾'; - const textObj = this.scene.add.text(0, 0, text, { - fontSize: `${isCat ? 40 : 32}px`, - fontFamily: 'Arial', - }).setOrigin(0.5); - - container.add([circle, textObj]); - - // 添加落子动画 - container.setScale(0); - this.scene.addTweenInterruption(this.scene.tweens.add({ - targets: container, - scale: 1, - duration: 200, - ease: 'Back.easeOut', - })); - - return container; - } - - onDespawn(obj: Phaser.GameObjects.Container) { - this.scene.tweens.add({ - targets: obj, - alpha: 0, - scale: 0.5, - duration: 200, - ease: 'Back.easeIn', - onComplete: () => obj.destroy(), - }); + private restartGame(): void { + this.gameHost.setup('setup'); } } diff --git a/packages/boop-game/src/scenes/PieceSpawner.ts b/packages/boop-game/src/scenes/PieceSpawner.ts new file mode 100644 index 0000000..a81880e --- /dev/null +++ b/packages/boop-game/src/scenes/PieceSpawner.ts @@ -0,0 +1,80 @@ +import Phaser from 'phaser'; +import type { BoopState, BoopPart } from '@/game/boop'; +import { GameHostScene, spawnEffect, type Spawner } from 'boardgame-phaser'; +import { BOARD_OFFSET, CELL_SIZE } from './BoardRenderer'; + +class BoopPartSpawner implements Spawner { + constructor(public readonly scene: GameHostScene) {} + + *getData() { + for (const part of Object.values(this.scene.state.pieces)) { + yield part; + } + } + + getKey(part: BoopPart): string { + return part.id; + } + + onUpdate(part: BoopPart, obj: Phaser.GameObjects.Container): void { + const [row, col] = part.position; + const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; + const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; + + this.scene.tweens.add({ + targets: obj, + x: x, + y: y, + duration: 200, + ease: 'Power2', + }); + } + + onSpawn(part: BoopPart) { + const [row, col] = part.position; + const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; + const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; + + const container = this.scene.add.container(x, y); + + const isCat = part.pieceType === 'cat'; + const baseColor = part.player === 'white' ? 0xffffff : 0x333333; + const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff; + + const circle = this.scene.add.circle(0, 0, CELL_SIZE * 0.4, baseColor) + .setStrokeStyle(3, strokeColor); + + const text = isCat ? '🐱' : '🐾'; + const textObj = this.scene.add.text(0, 0, text, { + fontSize: `${isCat ? 40 : 32}px`, + fontFamily: 'Arial', + }).setOrigin(0.5); + + container.add([circle, textObj]); + + container.setScale(0); + this.scene.addTweenInterruption(this.scene.tweens.add({ + targets: container, + scale: 1, + duration: 200, + ease: 'Back.easeOut', + })); + + return container; + } + + onDespawn(obj: Phaser.GameObjects.Container) { + this.scene.tweens.add({ + targets: obj, + alpha: 0, + scale: 0.5, + duration: 200, + ease: 'Back.easeIn', + onComplete: () => obj.destroy(), + }); + } +} + +export function createPieceSpawner(scene: GameHostScene) { + return spawnEffect(new BoopPartSpawner(scene)); +} diff --git a/packages/boop-game/src/scenes/PieceTypeSelector.ts b/packages/boop-game/src/scenes/PieceTypeSelector.ts new file mode 100644 index 0000000..2798fa9 --- /dev/null +++ b/packages/boop-game/src/scenes/PieceTypeSelector.ts @@ -0,0 +1,106 @@ +import Phaser from 'phaser'; +import type { BoopState, PlayerType, PieceType } from '@/game/boop'; +import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer'; + +export class PieceTypeSelector { + private container: Phaser.GameObjects.Container; + private kittenButton: Phaser.GameObjects.Container; + private catButton: Phaser.GameObjects.Container; + private selectedType: PieceType = 'kitten'; + private typeChangeCallback?: (type: PieceType) => void; + + constructor(private scene: Phaser.Scene) { + const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2; + const selectorY = BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 100; + + this.container = this.scene.add.container(boardCenterX, selectorY); + + const label = this.scene.add.text(-160, 0, '放置:', { + fontSize: '18px', + fontFamily: 'Arial', + color: '#4b5563', + }).setOrigin(0.5, 0.5); + + this.kittenButton = this.createPieceButton('kitten', '🐾 小猫', -50); + this.catButton = this.createPieceButton('cat', '🐱 大猫', 70); + + this.container.add([label, this.kittenButton, this.catButton]); + } + + private createPieceButton(type: PieceType, text: string, xOffset: number): Phaser.GameObjects.Container { + const container = this.scene.add.container(xOffset, 0); + + const bg = this.scene.add.rectangle(0, 0, 100, 40, 0xe5e7eb) + .setStrokeStyle(2, 0x9ca3af); + + const textObj = this.scene.add.text(0, 0, text, { + fontSize: '16px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + + container.add([bg, textObj]); + + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => { + this.selectedType = type; + this.typeChangeCallback?.(this.selectedType); + }); + + return container; + } + + onTypeChanged(callback: (type: PieceType) => void): void { + this.typeChangeCallback = callback; + } + + getSelectedType(): PieceType { + return this.selectedType; + } + + update(state: BoopState): void { + const currentPlayer = state.players[state.currentPlayer]; + const kittenAvailable = currentPlayer.kittenPool.remaining() > 0; + const catsAvailable = currentPlayer.catPool.remaining() + currentPlayer.graduatedCount > 0; + + this.updateButton( + this.kittenButton, + kittenAvailable, + this.selectedType === 'kitten', + `🐾 小猫 (${currentPlayer.kittenPool.remaining()})` + ); + + this.updateButton( + this.catButton, + catsAvailable, + this.selectedType === 'cat', + `🐱 大猫 (${currentPlayer.catPool.remaining() + currentPlayer.graduatedCount})` + ); + + // 自动切换到可用类型 + if (!kittenAvailable && this.selectedType === 'kitten' && catsAvailable) { + this.selectedType = 'cat'; + this.typeChangeCallback?.(this.selectedType); + } else if (!catsAvailable && this.selectedType === 'cat' && kittenAvailable) { + this.selectedType = 'kitten'; + this.typeChangeCallback?.(this.selectedType); + } + } + + private updateButton( + button: Phaser.GameObjects.Container, + available: boolean, + isSelected: boolean, + text: string + ): void { + const bg = button.list[0] as Phaser.GameObjects.Rectangle; + const textObj = button.list[1] as Phaser.GameObjects.Text; + + bg.setFillStyle(isSelected ? 0xfbbf24 : (available ? 0xe5e7eb : 0xd1d5db)); + textObj.setText(text); + } + + destroy(): void { + this.container.destroy(); + } +} diff --git a/packages/boop-game/src/scenes/SupplyUI.ts b/packages/boop-game/src/scenes/SupplyUI.ts new file mode 100644 index 0000000..e91027a --- /dev/null +++ b/packages/boop-game/src/scenes/SupplyUI.ts @@ -0,0 +1,121 @@ +import Phaser from 'phaser'; +import type { BoopState, PlayerType } from '@/game/boop'; +import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer'; + +export class SupplyUI { + private whiteContainer: Phaser.GameObjects.Container; + private blackContainer: Phaser.GameObjects.Container; + private whiteBg: Phaser.GameObjects.Rectangle; + private blackBg: Phaser.GameObjects.Rectangle; + private whiteText: Phaser.GameObjects.Text; + private blackText: Phaser.GameObjects.Text; + + constructor(private scene: Phaser.Scene) { + const boardCenterX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2; + const uiY = BOARD_OFFSET.y - 20; + + // 白色玩家容器 + this.whiteContainer = this.scene.add.container(boardCenterX - 150, uiY); + this.whiteBg = this.scene.add.rectangle(0, 0, 120, 50, 0x000000); + this.whiteText = this.scene.add.text(0, 0, '', { + fontSize: '16px', + fontFamily: 'Arial', + color: '#ffffff', + align: 'center', + }).setOrigin(0.5); + this.whiteContainer.add([this.whiteBg, this.whiteText]); + this.whiteContainer.setDepth(100); + + // 黑色玩家容器 + this.blackContainer = this.scene.add.container(boardCenterX + 150, uiY); + this.blackBg = this.scene.add.rectangle(0, 0, 120, 50, 0x333333); + this.blackText = this.scene.add.text(0, 0, '', { + fontSize: '16px', + fontFamily: 'Arial', + color: '#ffffff', + align: 'center', + }).setOrigin(0.5); + this.blackContainer.add([this.blackBg, this.blackText]); + this.blackContainer.setDepth(100); + } + + update(state: BoopState): void { + const white = state.players.white; + const black = state.players.black; + + const whiteCatsAvailable = white.catPool.remaining() + white.graduatedCount; + const blackCatsAvailable = black.catPool.remaining() + black.graduatedCount; + + this.whiteText.setText( + `⚪ WHITE\n🐾 ${white.kittenPool.remaining()} | 🐱 ${whiteCatsAvailable}` + ); + + this.blackText.setText( + `⚫ BLACK\n🐾 ${black.kittenPool.remaining()} | 🐱 ${blackCatsAvailable}` + ); + + this.updateHighlight(state.currentPlayer); + } + + private updateHighlight(currentPlayer: PlayerType): void { + const isWhiteTurn = currentPlayer === 'white'; + + this.scene.tweens.killTweensOf(this.whiteContainer); + this.scene.tweens.killTweensOf(this.blackContainer); + + if (isWhiteTurn) { + this.whiteBg.setFillStyle(0xfbbf24); + this.whiteContainer.setScale(1); + this.scene.tweens.add({ + targets: this.whiteContainer, + scale: 1.15, + duration: 200, + ease: 'Back.easeOut', + yoyo: true, + hold: 400, + onComplete: () => { + this.scene.tweens.add({ + targets: this.whiteContainer, + alpha: 0.6, + duration: 600, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + }, + }); + this.blackBg.setFillStyle(0x333333); + this.blackContainer.setAlpha(1); + this.blackContainer.setScale(1); + } else { + this.blackBg.setFillStyle(0xfbbf24); + this.blackContainer.setScale(1); + this.scene.tweens.add({ + targets: this.blackContainer, + scale: 1.15, + duration: 200, + ease: 'Back.easeOut', + yoyo: true, + hold: 400, + onComplete: () => { + this.scene.tweens.add({ + targets: this.blackContainer, + alpha: 0.6, + duration: 600, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + }, + }); + this.whiteBg.setFillStyle(0x000000); + this.whiteContainer.setAlpha(1); + this.whiteContainer.setScale(1); + } + } + + destroy(): void { + this.whiteContainer.destroy(); + this.blackContainer.destroy(); + } +} diff --git a/packages/boop-game/src/scenes/WinnerOverlay.ts b/packages/boop-game/src/scenes/WinnerOverlay.ts new file mode 100644 index 0000000..428354e --- /dev/null +++ b/packages/boop-game/src/scenes/WinnerOverlay.ts @@ -0,0 +1,72 @@ +import Phaser from 'phaser'; +import type { BoopState, WinnerType } from '@/game/boop'; +import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer'; + +export class WinnerOverlay { + private overlay?: Phaser.GameObjects.Container; + private tweens: Phaser.Tweens.TweenManager; + + constructor( + private scene: Phaser.Scene, + private onRestart: () => void + ) { + this.tweens = scene.tweens; + } + + show(winner: WinnerType): void { + if (this.overlay) { + this.overlay.destroy(); + } + + this.overlay = this.scene.add.container(); + + const text = winner === 'draw' ? "It's a draw!" : winner ? `${winner.toUpperCase()} wins!` : ''; + + const bg = this.scene.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 }); + + bg.on('pointerdown', () => { + this.onRestart(); + }); + + this.overlay.add(bg); + + const winText = this.scene.add.text( + BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, + BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2, + text, + { + fontSize: '40px', + fontFamily: 'Arial', + color: '#fbbf24', + }, + ).setOrigin(0.5); + + this.overlay.add(winText); + + this.tweens.add({ + targets: winText, + scale: 1.2, + duration: 500, + yoyo: true, + repeat: 1, + }); + } + + hide(): void { + if (this.overlay) { + this.overlay.destroy(); + this.overlay = undefined; + } + } + + destroy(): void { + this.hide(); + } +} diff --git a/packages/boop-game/src/scenes/index.ts b/packages/boop-game/src/scenes/index.ts new file mode 100644 index 0000000..5286101 --- /dev/null +++ b/packages/boop-game/src/scenes/index.ts @@ -0,0 +1,6 @@ +export { GameScene } from './GameScene'; +export { BoardRenderer, BOARD_SIZE, CELL_SIZE, BOARD_OFFSET } from './BoardRenderer'; +export { createPieceSpawner } from './PieceSpawner'; +export { SupplyUI } from './SupplyUI'; +export { PieceTypeSelector } from './PieceTypeSelector'; +export { WinnerOverlay } from './WinnerOverlay';