From a0412ddd884003530b4fdc543cf3d860722485aa Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 6 Apr 2026 16:35:28 +0800 Subject: [PATCH] refactor: fix regicide, presumably? --- .../regicide-game/src/game/regicide.test.ts | 22 +- packages/regicide-game/src/game/regicide.ts | 103 ++++--- packages/regicide-game/src/game/types.ts | 11 +- packages/regicide-game/src/main.tsx | 13 + .../regicide-game/src/scenes/GameScene.ts | 197 +++++++++++++ packages/regicide-game/src/scenes/effects.ts | 259 ++++++++++++++++++ .../src/scenes/views/CardView.ts | 121 ++++++++ .../src/scenes/views/EnemyView.ts | 110 ++++++++ .../regicide-game/src/scenes/views/GameUI.ts | 171 ++++++++++++ .../src/scenes/views/HandContainer.ts | 83 ++++++ .../src/scenes/views/PromptDisplay.ts | 133 +++++++++ .../regicide-game/src/scenes/views/index.ts | 5 + packages/regicide-game/src/style.css | 26 ++ packages/regicide-game/src/ui/App.tsx | 56 ++++ 14 files changed, 1248 insertions(+), 62 deletions(-) create mode 100644 packages/regicide-game/src/main.tsx create mode 100644 packages/regicide-game/src/scenes/GameScene.ts create mode 100644 packages/regicide-game/src/scenes/effects.ts create mode 100644 packages/regicide-game/src/scenes/views/CardView.ts create mode 100644 packages/regicide-game/src/scenes/views/EnemyView.ts create mode 100644 packages/regicide-game/src/scenes/views/GameUI.ts create mode 100644 packages/regicide-game/src/scenes/views/HandContainer.ts create mode 100644 packages/regicide-game/src/scenes/views/PromptDisplay.ts create mode 100644 packages/regicide-game/src/scenes/views/index.ts create mode 100644 packages/regicide-game/src/style.css create mode 100644 packages/regicide-game/src/ui/App.tsx diff --git a/packages/regicide-game/src/game/regicide.test.ts b/packages/regicide-game/src/game/regicide.test.ts index 09bd275..3990a9f 100644 --- a/packages/regicide-game/src/game/regicide.test.ts +++ b/packages/regicide-game/src/game/regicide.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createGameHost, GameHost } from 'boardgame-core'; -import { gameModule, createInitialState } from './regicide'; +import { gameModule, createInitialState, prompts } from './regicide'; import type { RegicideState, Card, Enemy } from './types'; describe('Regicide Game Module', () => { @@ -14,7 +14,11 @@ describe('Regicide Game Module', () => { afterEach(async () => { // 等待游戏循环完成,然后 dispose await new Promise(resolve => setTimeout(resolve, 50)); + // 等待 gamePromise 结束,suppress 预期的 cancel 错误 + gamePromise?.catch(() => {}); gameHost.dispose(); + // 等待 dispose 完成 + await new Promise(resolve => setTimeout(resolve, 20)); }); describe('Initial State', () => { @@ -92,8 +96,8 @@ describe('Regicide Game Module', () => { const initialHp = enemy.currentHp; const card = state.hand[0]; - // 出牌 - 使用 input 命令格式 - const error = gameHost.onInput(`input play ${card.id}`); + // 出牌 - 使用 tryAnswerPrompt 命令格式 + const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]); expect(error).toBeNull(); // 验证成功 // 等待下一个 prompt @@ -122,7 +126,7 @@ describe('Regicide Game Module', () => { const handSizeBefore = stateBefore.hand.length; // 尝试打出不存在的牌 - 应该被 validator 拒绝 - const error = gameHost.onInput('input play non-existent-card'); + const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'play', ['non-existent-card']); // 错误会被 catch 并继续循环,所以返回 null expect(error).toBeNull(); @@ -145,7 +149,7 @@ describe('Regicide Game Module', () => { // 出一张牌造成伤害(不一定击败敌人) const card = state.hand[0]; - gameHost.onInput(`input play ${card.id}`); + gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [card.id]); // 等待反击阶段或下一个 prompt await waitForPrompt(gameHost); @@ -177,8 +181,8 @@ describe('Regicide Game Module', () => { gamePromise = gameHost.start(); await waitForPrompt(gameHost); - // 放弃回合 - 使用 input 命令格式 - const error = gameHost.onInput('input yield'); + // 放弃回合 - 使用 tryAnswerPrompt 命令格式 + const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'yield', []); // 等待处理完成 await new Promise(resolve => setTimeout(resolve, 100)); @@ -198,8 +202,8 @@ describe('Regicide Game Module', () => { const state = gameHost.state.value; const initialHandSize = state.hand.length; - // 使用小丑牌 - 使用 input 命令格式 - const error = gameHost.onInput('input useJester'); + // 使用小丑牌 - 使用 tryAnswerPrompt 命令格式 + const error = gameHost.tryAnswerPrompt(prompts.playerAction, 'useJester', []); // 等待处理完成 await new Promise(resolve => setTimeout(resolve, 100)); diff --git a/packages/regicide-game/src/game/regicide.ts b/packages/regicide-game/src/game/regicide.ts index 0376739..c2614b9 100644 --- a/packages/regicide-game/src/game/regicide.ts +++ b/packages/regicide-game/src/game/regicide.ts @@ -1,5 +1,6 @@ import { createGameCommandRegistry, + createPromptDef, IGameContext, GameModule, } from 'boardgame-core'; @@ -90,30 +91,33 @@ async function playCard(game: RegicideGame, cardId: string) { // 计算伤害 let damage = card.value; const suits = card.suit ? [card.suit] : []; - + if (suits.includes('clubs')) damage *= 2; let isImmune = false; - if (state.currentEnemy.immunitySuit && suits.includes(state.currentEnemy.immunitySuit)) { + const enemy = state.currentEnemy; + if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) { isImmune = true; damage = card.value; } - const enemy = state.currentEnemy; - enemy.currentHp -= damage; - const isDefeated = enemy.currentHp <= 0; - game.produce((state: RegicideState) => { const cardIndex = state.hand.findIndex(c => c.id === cardId); if (cardIndex !== -1) state.hand.splice(cardIndex, 1); state.discardPile.push(card); - + + // 计算最终伤害并应用到敌人 + let finalDamage = damage; + const e = state.currentEnemy!; + e.currentHp -= finalDamage; + const isDefeated = e.currentHp <= 0; + if (isDefeated) { - enemy.isDefeated = true; - state.defeatedEnemies.push({ ...enemy }); + e.isDefeated = true; + state.defeatedEnemies.push({ ...e }); state.currentEnemy = null; state.phase = 'enemyDefeated'; - + if (state.defeatedEnemies.length >= 12) { state.isGameOver = true; state.victoryLevel = calculateVictoryLevel(state.jestersUsed); @@ -122,16 +126,20 @@ async function playCard(game: RegicideGame, cardId: string) { } else { state.phase = 'enemyCounterattack'; } - + state.currentPlayed = { cards: [card], - totalDamage: isImmune ? card.value : damage, + totalDamage: isImmune ? card.value : finalDamage, suits, hasJester: false, }; }); - return { damage, remainingHp: Math.max(0, enemy.currentHp), isImmune, isDefeated }; + // 从 produce 后的状态读取结果 + const finalEnemy = game.value.defeatedEnemies.find(e => e.id === enemy.id) || game.value.currentEnemy; + const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id); + + return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated }; } registry.register('play ', playCard); @@ -162,32 +170,35 @@ async function playCombo(game: RegicideGame, cardIds: string[]) { let damage = totalValue; const suits = selectedCards.map(c => c.suit).filter((s): s is CardSuit => s !== null); - + if (suits.includes('clubs')) damage *= 2; let isImmune = false; - if (state.currentEnemy!.immunitySuit && suits.includes(state.currentEnemy!.immunitySuit)) { + const enemy = state.currentEnemy!; + if (enemy.immunitySuit && suits.includes(enemy.immunitySuit)) { isImmune = true; damage = totalValue; } - const enemy = state.currentEnemy!; - enemy.currentHp -= damage; - const isDefeated = enemy.currentHp <= 0; - game.produce((state: RegicideState) => { for (const card of selectedCards) { const cardIndex = state.hand.findIndex(c => c.id === card.id); if (cardIndex !== -1) state.hand.splice(cardIndex, 1); state.discardPile.push(card); } - + + // 计算最终伤害并应用到敌人 + let finalDamage = damage; + const e = state.currentEnemy!; + e.currentHp -= finalDamage; + const isDefeated = e.currentHp <= 0; + if (isDefeated) { - enemy.isDefeated = true; - state.defeatedEnemies.push({ ...enemy }); + e.isDefeated = true; + state.defeatedEnemies.push({ ...e }); state.currentEnemy = null; state.phase = 'enemyDefeated'; - + if (state.defeatedEnemies.length >= 12) { state.isGameOver = true; state.victoryLevel = calculateVictoryLevel(state.jestersUsed); @@ -196,16 +207,18 @@ async function playCombo(game: RegicideGame, cardIds: string[]) { } else { state.phase = 'enemyCounterattack'; } - + state.currentPlayed = { cards: selectedCards, - totalDamage: isImmune ? totalValue : damage, + totalDamage: isImmune ? totalValue : finalDamage, suits, hasJester: false, }; }); - return { damage, remainingHp: Math.max(0, enemy.currentHp), isImmune, isDefeated }; + const wasDefeated = game.value.defeatedEnemies.some(e => e.id === enemy.id); + + return { damage, remainingHp: wasDefeated ? 0 : Math.max(0, (game.value.currentEnemy?.currentHp ?? 0)), isImmune, isDefeated: wasDefeated }; } registry.register('combo ', playCombo); @@ -334,6 +347,16 @@ function calculateVictoryLevel(jestersUsed: number): 'gold' | 'silver' | 'bronze return 'bronze'; } + +export const prompts = { + playerAction: createPromptDef<[action: string, cardIds: string[]]>( + 'play ' + ), + counterattack: createPromptDef<[cardIds: string[]]>( + 'counterattack ' + ), +}; + // start 函数 - 游戏主循环 export async function start(game: RegicideGame) { // 首先执行 setup @@ -345,33 +368,27 @@ export async function start(game: RegicideGame) { // 玩家回合 - 等待玩家输入任何命令 if (state.phase === 'playerTurn' && state.currentEnemy && !state.currentEnemy.isDefeated) { - // 使用通用的 input 命令来等待玩家输入 - const { inputStr } = await game.prompt( - 'input [arg2:string] [arg3:string]', - (arg1: string, arg2: string, arg3: string) => { - const parts = [arg1, arg2, arg3].filter(a => a !== undefined && a !== ''); + const { action, cardIds } = await game.prompt( + prompts.playerAction, + (action: string, cardIds: string[]) => { + const parts = cardIds.filter(a => a !== undefined && a !== ''); if (parts.length === 0) { throw '请输入有效的命令'; } - return { inputStr: parts.join(' ') }; + return { action, cardIds: parts }; }, state.currentEnemy.id ); // 解析并执行命令 - const parts = inputStr.split(' '); - const command = parts[0]; - const args = parts.slice(1); - try { - if (command === 'play' && args.length >= 1) { - await playCard(game, args[0]); - } else if (command === 'combo' && args.length >= 1) { - const cardIds = args[0].split(','); + if (action === 'play' && cardIds.length >= 1) { + await playCard(game, cardIds[0]); + } else if (action === 'combo' && cardIds.length >= 2) { await playCombo(game, cardIds); - } else if (command === 'yield') { + } else if (action === 'yield') { await yieldTurn(game); - } else if (command === 'useJester') { + } else if (action === 'useJester') { await useJester(game); } else { // 无效命令,继续等待 @@ -389,7 +406,7 @@ export async function start(game: RegicideGame) { if (state.currentEnemy) { const requiredValue = state.currentEnemy.counterDamage; const { cardIds } = await game.prompt( - 'counterattack ', + prompts.counterattack, (cardIds: string[]) => { const selectedCards: Card[] = []; for (const cardId of cardIds) { diff --git a/packages/regicide-game/src/game/types.ts b/packages/regicide-game/src/game/types.ts index cb5d3a9..baf0918 100644 --- a/packages/regicide-game/src/game/types.ts +++ b/packages/regicide-game/src/game/types.ts @@ -82,13 +82,4 @@ export interface RegicideState { // 索引签名以满足 Record 约束 [key: string]: unknown; -} - -// 命令参数类型 -export interface PlayCardCommand { - cardIds: string[]; -} - -export interface CounterattackCommand { - cardIds: string[]; -} +} \ No newline at end of file diff --git a/packages/regicide-game/src/main.tsx b/packages/regicide-game/src/main.tsx new file mode 100644 index 0000000..f772142 --- /dev/null +++ b/packages/regicide-game/src/main.tsx @@ -0,0 +1,13 @@ +import { h } from 'preact'; +import { GameUI } from 'boardgame-phaser'; +import { gameModule } from './game/regicide'; +import './style.css'; +import App from "@/ui/App"; +import { GameScene } from "@/scenes/GameScene"; + +const ui = new GameUI({ + container: document.getElementById('ui-root')!, + root: , +}); + +ui.mount(); diff --git a/packages/regicide-game/src/scenes/GameScene.ts b/packages/regicide-game/src/scenes/GameScene.ts new file mode 100644 index 0000000..d8809d3 --- /dev/null +++ b/packages/regicide-game/src/scenes/GameScene.ts @@ -0,0 +1,197 @@ +import Phaser from 'phaser'; +import type { RegicideState, Card, Enemy } from '@/game/types'; +import { GameHostScene } from 'boardgame-phaser'; +import { spawnEffect, type Spawner } from 'boardgame-phaser'; +import { GameUI, CardView, EnemyView, PromptDisplay } from './views'; + +const GAME_WIDTH = 800; +const GAME_HEIGHT = 700; +const CENTER_X = GAME_WIDTH / 2; +const HAND_Y = 580; +const CARD_SPACING = 70; + +export class GameScene extends GameHostScene { + private gameUI!: GameUI; + private promptDisplay!: PromptDisplay; + // 追踪当前卡牌容器,用于点击检测 + public cardContainers: Phaser.GameObjects.Container[] = []; + + constructor() { + super('RegicideGameScene'); + } + + create(): void { + super.create(); + + // 创建 UI 组件 + this.gameUI = new GameUI(this); + this.promptDisplay = new PromptDisplay(this); + + // 生成效果 + this.disposables.add(spawnEffect(new EnemySpawner(this))); + + // 手牌管理 - 手动跟踪卡牌容器 + const cardViews = new Map(); + this.addEffect(() => { + const hand = this.state.hand; + const handSize = hand.length; + const spacing = Math.min(CARD_SPACING, 600 / Math.max(1, handSize)); + const startX = CENTER_X - (handSize - 1) * spacing / 2; + + const currentIds = new Set(hand.map(c => c.id)); + + // 移除不存在的卡牌 + for (const [id, view] of cardViews) { + if (!currentIds.has(id)) { + this.tweens.add({ + targets: view.container, + alpha: 0, + scale: 0.5, + y: view.container.y - 100, + duration: 200, + ease: 'Back.easeIn', + onComplete: () => { + const idx = this.cardContainers.indexOf(view.container); + if (idx !== -1) this.cardContainers.splice(idx, 1); + view.destroy(); + }, + }); + cardViews.delete(id); + } + } + + // 添加或更新卡牌 + for (let i = 0; i < handSize; i++) { + const card = hand[i]; + const x = startX + i * spacing; + let view = cardViews.get(card.id); + + if (!view) { + // 新卡牌 - 创建时先放在中心,然后用 tween 动画移到目标位置 + view = new CardView(this, card, CENTER_X, HAND_Y); + cardViews.set(card.id, view); + this.cardContainers.push(view.container); + + // 入场动画:从中心移到目标位置 + this.tweens.add({ + targets: view.container, + x, + y: HAND_Y, + duration: 300, + ease: 'Back.easeOut', + }); + } else { + // 已有卡牌 - 更新位置 + view.setPosition(x, HAND_Y); + } + } + }); + + // 添加状态效果 + this.addEffect(() => { + this.gameUI.updateEnemyDisplay(this, this.state.currentEnemy); + }); + + this.addEffect(() => { + this.gameUI.updatePhaseText(this.state.phase); + }); + + this.addEffect(() => { + if (this.state.isGameOver) { + this.gameUI.showGameOver(this, this.state.victoryLevel); + } else { + this.gameUI.hideGameOver(); + } + }); + + // 监听 activePrompt 信号 + this.addEffect(() => { + const schema = this.gameHost.activePromptSchema?.value; + const player = this.gameHost.activePromptPlayer?.value; + this.promptDisplay.update(schema, player); + }); + + // 设置背景点击 + this.setupBackgroundInput(); + } + + private setupBackgroundInput(): void { + this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + // 如果点击的是卡牌,不处理 + if (this.isPointerOnCard(pointer)) { + return; + } + + // 游戏结束时点击任意位置重新开始 + if (this.state.isGameOver) { + this.gameHost.start(); + } else if (!this.state.currentEnemy && this.state.phase === 'playerTurn') { + // 开始游戏 + this.gameHost.start(); + } + }); + } + + private isPointerOnCard(pointer: Phaser.Input.Pointer): boolean { + for (const container of this.cardContainers) { + if (!container || !container.active) continue; + const bounds = container.getBounds(); + if ( + pointer.x >= bounds.left && + pointer.x <= bounds.right && + pointer.y >= bounds.top && + pointer.y <= bounds.bottom + ) { + return true; + } + } + return false; + } +} + +// 敌人生成器 +class EnemySpawner implements Spawner { + constructor(public readonly scene: GameScene) {} + + *getData() { + if (this.scene.state.currentEnemy && !this.scene.state.currentEnemy.isDefeated) { + yield this.scene.state.currentEnemy; + } + } + + getKey(enemy: Enemy): string { + return enemy.id; + } + + onUpdate(enemy: Enemy, container: Phaser.GameObjects.Container): void { + const view = container.getData('enemyView') as EnemyView | undefined; + if (view) { + view.setPosition(CENTER_X, 200); + view.updateEnemy(enemy); + } + } + + onSpawn(enemy: Enemy): Phaser.GameObjects.Container { + const view = new EnemyView(this.scene, enemy, CENTER_X, 200); + const container = view.container; + container.setData('enemyView', view); + return container; + } + + onDespawn(container: Phaser.GameObjects.Container): void { + const view = container.getData('enemyView') as EnemyView | undefined; + if (view) { + this.scene.tweens.add({ + targets: container, + alpha: 0, + y: container.y - 150, + scale: 0.3, + duration: 400, + ease: 'Back.easeIn', + onComplete: () => view.destroy(), + }); + } else { + container.destroy(); + } + } +} diff --git a/packages/regicide-game/src/scenes/effects.ts b/packages/regicide-game/src/scenes/effects.ts new file mode 100644 index 0000000..fba7cae --- /dev/null +++ b/packages/regicide-game/src/scenes/effects.ts @@ -0,0 +1,259 @@ +import Phaser from 'phaser'; + +// 伤害数字特效 +export function showDamageNumber( + scene: Phaser.Scene, + x: number, + y: number, + damage: number, + color: number = 0xef4444 +): void { + const text = scene.add.text(x, y, `-${damage}`, { + fontSize: '32px', + fontFamily: 'Arial', + color: `#${color.toString(16).padStart(6, '0')}`, + fontStyle: 'bold', + }).setOrigin(0.5); + + scene.tweens.add({ + targets: text, + y: y - 100, + alpha: 0, + scale: 1.5, + duration: 800, + ease: 'Power2', + onComplete: () => text.destroy(), + }); +} + +// 治疗数字特效 +export function showHealNumber( + scene: Phaser.Scene, + x: number, + y: number, + amount: number +): void { + const text = scene.add.text(x, y, `+${amount}`, { + fontSize: '28px', + fontFamily: 'Arial', + color: '#22c55e', + fontStyle: 'bold', + }).setOrigin(0.5); + + scene.tweens.add({ + targets: text, + y: y - 80, + alpha: 0, + duration: 800, + ease: 'Power2', + onComplete: () => text.destroy(), + }); +} + +// 卡牌飞行特效 +export function flyCardToTarget( + scene: Phaser.Scene, + card: Phaser.GameObjects.Container, + targetX: number, + targetY: number, + duration: number = 400, + onComplete?: () => void +): void { + const startX = card.x; + const startY = card.y; + + // 创建飞行路径 + const path = [ + { x: startX, y: startY }, + { x: startX + (targetX - startX) / 2, y: startY - 100 }, + { x: targetX, y: targetY }, + ]; + + scene.tweens.add({ + targets: card, + x: targetX, + y: targetY, + scale: 0.5, + alpha: 0, + duration, + ease: 'Back.easeIn', + onComplete: () => { + if (onComplete) onComplete(); + }, + }); +} + +// 敌人受击特效 +export function enemyHitEffect( + scene: Phaser.Scene, + enemyContainer: Phaser.GameObjects.Container +): void { + // 红色闪烁 + const flash = scene.add.rectangle( + enemyContainer.x, + enemyContainer.y, + 150, + 200, + 0xff0000, + 0.5 + ); + + scene.tweens.add({ + targets: flash, + alpha: 0, + duration: 300, + onComplete: () => flash.destroy(), + }); + + // 震动效果 + scene.tweens.add({ + targets: enemyContainer, + x: enemyContainer.x + 10, + duration: 50, + yoyo: true, + repeat: 3, + }); +} + +// 敌人死亡特效 +export function enemyDeathEffect( + scene: Phaser.Scene, + enemyContainer: Phaser.GameObjects.Container +): void { + // 粒子爆炸 + const particles = scene.add.particles(enemyContainer.x, enemyContainer.y, 'particle', { + speed: { min: 50, max: 150 }, + angle: { min: 0, max: 360 }, + scale: { start: 1, end: 0 }, + lifespan: 1000, + gravityY: 100, + quantity: 20, + emitting: true, + }); + + // 淡出并上升 + scene.tweens.add({ + targets: enemyContainer, + y: enemyContainer.y - 200, + alpha: 0, + scale: 0.5, + duration: 800, + ease: 'Back.easeIn', + onComplete: () => { + enemyContainer.destroy(); + particles.destroy(); + }, + }); +} + +// 抽牌特效 +export function drawCardEffect( + scene: Phaser.Scene, + fromX: number, + fromY: number, + toX: number, + toY: number +): void { + // 创建临时卡牌 + const tempCard = scene.add.rectangle(fromX, fromY, 60, 90, 0x3b82f6, 0.8) + .setStrokeStyle(2, 0x60a5fa); + + scene.tweens.add({ + targets: tempCard, + x: toX, + y: toY, + duration: 300, + ease: 'Back.easeOut', + onComplete: () => tempCard.destroy(), + }); +} + +// 反击警告特效 +export function counterattackWarning( + scene: Phaser.Scene, + x: number, + y: number, + damage: number +): void { + const warning = scene.add.text(x, y, `⚠️ 反击伤害: ${damage}`, { + fontSize: '24px', + fontFamily: 'Arial', + color: '#ef4444', + fontStyle: 'bold', + }).setOrigin(0.5); + + // 闪烁动画 + scene.tweens.add({ + targets: warning, + alpha: 0.3, + duration: 300, + yoyo: true, + repeat: 2, + }); + + // 消失 + scene.time.delayedCall(1500, () => { + scene.tweens.add({ + targets: warning, + alpha: 0, + y: y - 50, + duration: 300, + onComplete: () => warning.destroy(), + }); + }); +} + +// 胜利特效 +export function victoryEffect( + scene: Phaser.Scene, + x: number, + y: number +): void { + // 烟花粒子 + for (let i = 0; i < 3; i++) { + scene.time.delayedCall(i * 300, () => { + const particles = scene.add.particles( + x + (Math.random() - 0.5) * 200, + y + (Math.random() - 0.5) * 100, + 'particle', + { + speed: { min: 30, max: 100 }, + angle: { min: 0, max: 360 }, + scale: { start: 1.5, end: 0 }, + lifespan: 1500, + gravityY: 50, + quantity: 30, + emitting: true, + } + ); + + scene.time.delayedCall(1500, () => particles.destroy()); + }); + } +} + +// 闪烁文本 +export function createFlashingText( + scene: Phaser.Scene, + x: number, + y: number, + text: string, + color: string = '#fbbf24' +): Phaser.GameObjects.Text { + const flashingText = scene.add.text(x, y, text, { + fontSize: '20px', + fontFamily: 'Arial', + color, + fontStyle: 'bold', + }).setOrigin(0.5); + + scene.tweens.add({ + targets: flashingText, + alpha: 0.3, + duration: 500, + yoyo: true, + repeat: -1, + }); + + return flashingText; +} diff --git a/packages/regicide-game/src/scenes/views/CardView.ts b/packages/regicide-game/src/scenes/views/CardView.ts new file mode 100644 index 0000000..410293e --- /dev/null +++ b/packages/regicide-game/src/scenes/views/CardView.ts @@ -0,0 +1,121 @@ +import Phaser from 'phaser'; +import type { Card } from '@/game/types'; +import { getCardDisplay, getSuitColor } from '@/game/card-utils'; +import type { GameScene } from '../GameScene'; +import { prompts } from '@/game/regicide'; + +export const CARD_WIDTH = 80; +export const CARD_HEIGHT = 120; + +export class CardView { + public readonly container: Phaser.GameObjects.Container; + private readonly bg: Phaser.GameObjects.Rectangle; + private readonly text: Phaser.GameObjects.Text; + private readonly valueText: Phaser.GameObjects.Text; + private readonly card: Card; + private isHovering = false; + private baseY: number; + + constructor(scene: GameScene, card: Card, x: number, y: number) { + this.card = card; + this.baseY = y; + this.container = scene.add.container(x, y); + + // 卡牌背景 + this.bg = scene.add.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, 0xf9fafb) + .setStrokeStyle(3, 0x6b7280); + + // 卡牌边框颜色 + const borderColor = getSuitColor(card.suit); + this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor(borderColor).color); + + // 卡牌文本 + const display = getCardDisplay(card); + const color = getSuitColor(card.suit); + this.text = scene.add.text(0, 0, display, { + fontSize: '24px', + fontFamily: 'Arial', + color, + fontStyle: 'bold', + }).setOrigin(0.5); + + // 卡牌数值提示 + this.valueText = scene.add.text(0, -CARD_HEIGHT / 2 + 15, `+${card.value}`, { + fontSize: '12px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + + this.container.add([this.bg, this.valueText, this.text]); + + // 设置交互 + this.bg.setInteractive({ useHandCursor: true }); + this.setupInteraction(scene); + + // 出现动画 + this.container.setScale(0); + scene.tweens.add({ + targets: this.container, + scale: 1, + duration: 200, + ease: 'Back.easeOut', + }); + } + + setPosition(x: number, y: number): void { + this.baseY = y; + this.container.setPosition(x, y); + } + + hover(offset: number = -30): void { + if (!this.isHovering) { + this.isHovering = true; + const scene = this.container.scene as Phaser.Scene; + scene.tweens.add({ + targets: this.container, + y: this.baseY + offset, + duration: 100, + ease: 'Power2', + }); + } + } + + unhover(): void { + if (this.isHovering) { + this.isHovering = false; + const scene = this.container.scene as Phaser.Scene; + scene.tweens.add({ + targets: this.container, + y: this.baseY, + duration: 100, + ease: 'Power2', + }); + } + } + + getCard(): Card { + return this.card; + } + + destroy(): void { + this.container.destroy(); + } + + private setupInteraction(scene: GameScene): void { + this.bg.on('pointerover', () => { + this.hover(); + }); + + this.bg.on('pointerout', () => { + this.unhover(); + }); + + this.bg.on('pointerdown', () => { + if (scene.state.phase === 'playerTurn') { + scene.gameHost.tryAnswerPrompt(prompts.playerAction, 'play', [this.card.id]); + } else if (scene.state.phase === 'enemyCounterattack') { + scene.gameHost.tryAnswerPrompt(prompts.counterattack, [this.card.id]); + } + }); + } +} diff --git a/packages/regicide-game/src/scenes/views/EnemyView.ts b/packages/regicide-game/src/scenes/views/EnemyView.ts new file mode 100644 index 0000000..18a9e86 --- /dev/null +++ b/packages/regicide-game/src/scenes/views/EnemyView.ts @@ -0,0 +1,110 @@ +import Phaser from 'phaser'; +import type { Enemy } from '@/game/types'; +import type { GameScene } from '../GameScene'; + +const ENEMY_CARD_WIDTH = 144; +const ENEMY_CARD_HEIGHT = 180; + +export class EnemyView { + public readonly container: Phaser.GameObjects.Container; + private readonly bg: Phaser.GameObjects.Rectangle; + private readonly nameText: Phaser.GameObjects.Text; + private readonly immunityText: Phaser.GameObjects.Text; + private readonly counterText: Phaser.GameObjects.Text; + private readonly hpText: Phaser.GameObjects.Text; + private enemy: Enemy; + + constructor(scene: GameScene, enemy: Enemy, x: number, y: number) { + this.enemy = enemy; + this.container = scene.add.container(x, y); + + // 敌人卡牌背景 + this.bg = scene.add.rectangle(0, 0, ENEMY_CARD_WIDTH, ENEMY_CARD_HEIGHT, 0x1f2937) + .setStrokeStyle(4, 0xef4444); + + // 敌人名称 + const suitSymbols: Record = { + hearts: '♥', + diamonds: '♦', + clubs: '♣', + spades: '♠', + }; + const suitSymbol = enemy.suit ? suitSymbols[enemy.suit] : ''; + this.nameText = scene.add.text(0, -40, `${enemy.rank} ${suitSymbol}`, { + fontSize: '32px', + fontFamily: 'Arial', + color: '#fbbf24', + fontStyle: 'bold', + }).setOrigin(0.5); + + // 花色免疫提示 + const suitNames: Record = { + hearts: '红桃 ♥', + diamonds: '方片 ♦', + clubs: '梅花 ♣', + spades: '黑桃 ♠', + }; + const immunityText = enemy.immunitySuit + ? `🛡️ 免疫: ${suitNames[enemy.immunitySuit]}` + : ''; + this.immunityText = scene.add.text(0, 0, immunityText, { + fontSize: '14px', + fontFamily: 'Arial', + color: '#9ca3af', + }).setOrigin(0.5); + + // 反击伤害提示 + this.counterText = scene.add.text(0, 30, `⚔️ 反击伤害: ${enemy.counterDamage}`, { + fontSize: '16px', + fontFamily: 'Arial', + color: '#ef4444', + fontStyle: 'bold', + }).setOrigin(0.5); + + // HP提示 + this.hpText = scene.add.text(0, 60, `❤️ ${enemy.hp} HP`, { + fontSize: '16px', + fontFamily: 'Arial', + color: '#22c55e', + fontStyle: 'bold', + }).setOrigin(0.5); + + this.container.add([this.bg, this.nameText, this.immunityText, this.counterText, this.hpText]); + + // 出现动画 + this.container.setScale(0); + scene.tweens.add({ + targets: this.container, + scale: 1, + duration: 400, + ease: 'Back.easeOut', + }); + + // 抖动效果 + scene.tweens.add({ + targets: this.container, + x: x + 5, + duration: 100, + yoyo: true, + repeat: 3, + }); + } + + updateEnemy(enemy: Enemy): void { + this.enemy = enemy; + this.hpText.setText(`❤️ ${enemy.currentHp}/${enemy.hp} HP`); + this.counterText.setText(`⚔️ 反击伤害: ${enemy.counterDamage}`); + } + + setPosition(x: number, y: number): void { + this.container.setPosition(x, y); + } + + getEnemy(): Enemy { + return this.enemy; + } + + destroy(): void { + this.container.destroy(); + } +} diff --git a/packages/regicide-game/src/scenes/views/GameUI.ts b/packages/regicide-game/src/scenes/views/GameUI.ts new file mode 100644 index 0000000..e74e06e --- /dev/null +++ b/packages/regicide-game/src/scenes/views/GameUI.ts @@ -0,0 +1,171 @@ +import Phaser from 'phaser'; +import type { Enemy, VictoryLevel } from '@/game/types'; +import type { GameScene } from '../GameScene'; +import { EnemyView } from './EnemyView'; + +const GAME_WIDTH = 800; +const GAME_HEIGHT = 700; +const CENTER_X = GAME_WIDTH / 2; +const ENEMY_Y = 200; +const BAR_WIDTH = 200; +const BAR_HEIGHT = 20; + +export class GameUI { + public readonly container: Phaser.GameObjects.Container; + private readonly hpBar: Phaser.GameObjects.Graphics; + private readonly infoText: Phaser.GameObjects.Text; + private readonly phaseText: Phaser.GameObjects.Text; + private gameOverOverlay?: Phaser.GameObjects.Container; + private enemyView?: EnemyView; + + constructor(scene: GameScene) { + this.container = scene.add.container(0, 0); + + // 创建HP条 + this.hpBar = scene.add.graphics(); + + // 创建文本 + this.infoText = scene.add.text(CENTER_X, 30, '⚔️ Regicide - 击败所有12个敌人!', { + fontSize: '22px', + fontFamily: 'Arial', + color: '#fbbf24', + }).setOrigin(0.5); + + this.phaseText = scene.add.text(CENTER_X, 60, '点击任意位置开始游戏', { + fontSize: '16px', + fontFamily: 'Arial', + color: '#9ca3af', + }).setOrigin(0.5); + } + + updateEnemyDisplay(scene: GameScene, enemy: Enemy | null): void { + this.hpBar.clear(); + + if (!enemy) { + if (this.enemyView) { + this.enemyView.destroy(); + this.enemyView = undefined; + } + return; + } + + // 更新或创建敌人视图 + if (this.enemyView) { + this.enemyView.updateEnemy(enemy); + } else { + this.enemyView = new EnemyView(scene, enemy, CENTER_X, ENEMY_Y); + } + + // 绘制HP条 + this.drawHpBar(enemy); + } + + updatePhaseText(phase: string): void { + const phaseNames: Record = { + playerTurn: '🎯 你的回合 - 点击卡牌攻击敌人', + enemyCounterattack: '💥 敌人反击! - 点击卡牌抵消伤害', + enemyDefeated: '✨ 敌人被击败! 准备迎战下一个敌人', + gameOver: '🏁 游戏结束', + }; + this.phaseText.setText(phaseNames[phase] || phase); + } + + showGameOver(scene: GameScene, victoryLevel: VictoryLevel): void { + if (this.gameOverOverlay) { + this.gameOverOverlay.destroy(); + } + + this.gameOverOverlay = scene.add.container(); + + // 半透明背景 + const bg = scene.add.rectangle(CENTER_X, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.85) + .setInteractive({ useHandCursor: true }); + + bg.on('pointerdown', () => { + scene.gameHost.start(); + }); + + this.gameOverOverlay.add(bg); + + // 游戏结束文本 + let resultText = '游戏结束'; + let resultColor = '#ef4444'; + + if (victoryLevel === 'gold') { + resultText = '🥇 金胜利! 完美通关!'; + resultColor = '#fbbf24'; + } else if (victoryLevel === 'silver') { + resultText = '🥈 银胜利! 使用1张小丑牌'; + resultColor = '#9ca3af'; + } else if (victoryLevel === 'bronze') { + resultText = '🥉 铜胜利! 使用2张小丑牌'; + resultColor = '#b45309'; + } else { + resultText = '💀 失败! 无法抵消反击伤害'; + } + + const text = scene.add.text(CENTER_X, 250, resultText, { + fontSize: '36px', + fontFamily: 'Arial', + color: resultColor, + }).setOrigin(0.5); + + this.gameOverOverlay.add(text); + + const restartText = scene.add.text(CENTER_X, 350, '点击重新开始', { + fontSize: '24px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + + this.gameOverOverlay.add(restartText); + + // 动画 + scene.tweens.add({ + targets: text, + scale: 1.1, + duration: 500, + yoyo: true, + repeat: 2, + }); + } + + hideGameOver(): void { + if (this.gameOverOverlay) { + this.gameOverOverlay.destroy(); + this.gameOverOverlay = undefined; + } + } + + dispose(): void { + this.hpBar.destroy(); + this.infoText.destroy(); + this.phaseText.destroy(); + if (this.gameOverOverlay) { + this.gameOverOverlay.destroy(); + } + if (this.enemyView) { + this.enemyView.destroy(); + } + this.container.destroy(); + } + + private drawHpBar(enemy: Enemy): void { + const hpPercent = enemy.currentHp / enemy.hp; + const barX = CENTER_X - BAR_WIDTH / 2; + const barY = ENEMY_Y + 100; + + // 背景 + this.hpBar.fillStyle(0x374151); + this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5); + + // 前景 + const hpColor = hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444; + this.hpBar.fillStyle(hpColor); + this.hpBar.fillRoundedRect(barX, barY, BAR_WIDTH * hpPercent, BAR_HEIGHT, 5); + + // 边框 + this.hpBar.lineStyle(2, 0x6b7280); + this.hpBar.strokeRoundedRect(barX, barY, BAR_WIDTH, BAR_HEIGHT, 5); + } +} diff --git a/packages/regicide-game/src/scenes/views/HandContainer.ts b/packages/regicide-game/src/scenes/views/HandContainer.ts new file mode 100644 index 0000000..3c60e6d --- /dev/null +++ b/packages/regicide-game/src/scenes/views/HandContainer.ts @@ -0,0 +1,83 @@ +import Phaser from 'phaser'; +import type { Card } from '@/game/types'; +import { CardView, CARD_WIDTH } from './CardView'; +import type { GameScene } from '../GameScene'; + +const HAND_Y = 580; +const MAX_HAND_WIDTH = 600; +const CARD_SPACING = 70; + +export class HandContainer { + public readonly container: Phaser.GameObjects.Container; + private cardViews: Map = new Map(); + private centerX: number; + private handY: number; + + constructor(scene: GameScene, centerX: number, handY: number = HAND_Y) { + this.container = scene.add.container(0, 0); + this.centerX = centerX; + this.handY = handY; + } + + updateCards(scene: GameScene, cards: Card[]): void { + const currentCardIds = new Set(cards.map(c => c.id)); + + // 移除不在手牌中的卡牌 + for (const [cardId, view] of this.cardViews) { + if (!currentCardIds.has(cardId)) { + this.animateCardExit(scene, view); + this.cardViews.delete(cardId); + } + } + + // 更新或创建卡牌 + const handSize = cards.length; + const spacing = Math.min(CARD_SPACING, MAX_HAND_WIDTH / handSize); + const startX = this.centerX - (handSize - 1) * spacing / 2; + + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + const existingView = this.cardViews.get(card.id); + + if (existingView) { + // 更新位置 + const targetX = startX + i * spacing; + const targetY = this.handY; + existingView.setPosition(targetX, targetY); + } else { + // 创建新卡牌 + const x = startX + i * spacing; + const view = new CardView(scene, card, x, this.handY); + this.cardViews.set(card.id, view); + } + } + } + + getCardView(cardId: string): CardView | undefined { + return this.cardViews.get(cardId); + } + + getAllViews(): CardView[] { + return Array.from(this.cardViews.values()); + } + + dispose(): void { + for (const view of this.cardViews.values()) { + view.destroy(); + } + this.cardViews.clear(); + this.container.destroy(); + } + + private animateCardExit(scene: GameScene, view: CardView): void { + scene.tweens.add({ + targets: view.container, + alpha: 0, + scale: 0.5, + y: view.container.y - 100, + duration: 200, + ease: 'Back.easeIn', + onComplete: () => view.destroy(), + }); + } +} diff --git a/packages/regicide-game/src/scenes/views/PromptDisplay.ts b/packages/regicide-game/src/scenes/views/PromptDisplay.ts new file mode 100644 index 0000000..3ea8485 --- /dev/null +++ b/packages/regicide-game/src/scenes/views/PromptDisplay.ts @@ -0,0 +1,133 @@ +import Phaser from 'phaser'; +import type { CommandSchema } from 'boardgame-core'; +import type { GameScene } from '../GameScene'; + +const GAME_WIDTH = 800; +const CENTER_X = GAME_WIDTH / 2; + +export class PromptDisplay { + public readonly container: Phaser.GameObjects.Container; + private readonly bg: Phaser.GameObjects.Graphics; + private readonly promptText: Phaser.GameObjects.Text; + private readonly playerText: Phaser.GameObjects.Text; + private isVisible = false; + + constructor(scene: GameScene) { + this.container = scene.add.container(CENTER_X, 450); + this.container.setDepth(100); + + // 创建背景 + this.bg = scene.add.graphics(); + + // 提示文本 + this.promptText = scene.add.text(0, -15, '', { + fontSize: '18px', + fontFamily: 'Arial', + color: '#fbbf24', + fontStyle: 'bold', + align: 'center', + }).setOrigin(0.5); + + // 玩家文本 + this.playerText = scene.add.text(0, 15, '', { + fontSize: '14px', + fontFamily: 'Arial', + color: '#9ca3af', + align: 'center', + }).setOrigin(0.5); + + this.container.add([this.bg, this.promptText, this.playerText]); + this.container.setVisible(false); + } + + update(schema: CommandSchema | null, player: string | null): void { + if (!schema) { + if (this.isVisible) { + this.hide(); + } + return; + } + + if (!this.isVisible) { + this.show(); + } + + // 格式化 schema 显示 + const formattedSchema = this.formatSchema(schema); + this.promptText.setText(formattedSchema); + + if (player) { + this.playerText.setText(`等待玩家: ${player}`); + } else { + this.playerText.setText(''); + } + } + + private show(): void { + this.isVisible = true; + this.container.setVisible(true); + this.container.setScale(0); + this.container.setAlpha(0); + + const scene = this.container.scene as Phaser.Scene; + scene.tweens.add({ + targets: this.container, + scale: 1, + alpha: 1, + duration: 200, + ease: 'Back.easeOut', + }); + + this.drawBackground(); + } + + private hide(): void { + if (!this.isVisible) return; + this.isVisible = false; + + const scene = this.container.scene as Phaser.Scene; + scene.tweens.add({ + targets: this.container, + scale: 0.8, + alpha: 0, + duration: 150, + ease: 'Power2', + onComplete: () => this.container.setVisible(false), + }); + } + + private drawBackground(): void { + this.bg.clear(); + + const padding = 20; + const width = Math.max( + this.promptText.width, + this.playerText.width + ) + padding * 2; + const height = 60; + + // 半透明背景 + this.bg.fillStyle(0x1f2937, 0.9); + this.bg.fillRoundedRect(-width / 2, -height / 2, width, height, 10); + + // 边框 + this.bg.lineStyle(2, 0xfbbf24, 0.6); + this.bg.strokeRoundedRect(-width / 2, -height / 2, width, height, 10); + } + + private formatSchema(schema: CommandSchema): string { + // 格式化命令 schema 为可读字符串 + const params = schema.params.map(p => { + return p.variadic ? `<${p.name}...>` : `<${p.name}>`; + }).join(' '); + + return `${schema.name} ${params}`; + } + + destroy(): void { + this.bg.destroy(); + this.promptText.destroy(); + this.playerText.destroy(); + this.container.destroy(); + } +} diff --git a/packages/regicide-game/src/scenes/views/index.ts b/packages/regicide-game/src/scenes/views/index.ts new file mode 100644 index 0000000..7dbd00d --- /dev/null +++ b/packages/regicide-game/src/scenes/views/index.ts @@ -0,0 +1,5 @@ +export { CardView, CARD_WIDTH, CARD_HEIGHT } from './CardView'; +export { EnemyView } from './EnemyView'; +export { HandContainer } from './HandContainer'; +export { GameUI } from './GameUI'; +export { PromptDisplay } from './PromptDisplay'; diff --git a/packages/regicide-game/src/style.css b/packages/regicide-game/src/style.css new file mode 100644 index 0000000..0357b53 --- /dev/null +++ b/packages/regicide-game/src/style.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +body { + margin: 0; + padding: 0; + overflow: hidden; + background-color: #111827; +} + +#app { + width: 100vw; + height: 100vh; +} + +#ui-root { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +#ui-root > * { + pointer-events: auto; +} diff --git a/packages/regicide-game/src/ui/App.tsx b/packages/regicide-game/src/ui/App.tsx new file mode 100644 index 0000000..973bb87 --- /dev/null +++ b/packages/regicide-game/src/ui/App.tsx @@ -0,0 +1,56 @@ +import { useComputed } from '@preact/signals'; +import { createGameHost, type GameModule } from 'boardgame-core'; +import Phaser from 'phaser'; +import { h } from 'preact'; +import { PhaserGame, PhaserScene } from 'boardgame-phaser'; + +export default function App>(props: { gameModule: GameModule, gameScene: { new(): Phaser.Scene } }) { + + const gameHost = useComputed(() => { + const gameHost = createGameHost(props.gameModule); + return { gameHost }; + }); + + const scene = useComputed(() => new props.gameScene()); + + const handleReset = async () => { + const result = await gameHost.value.gameHost.start(); + console.log('Game finished!', result); + }; + + const label = useComputed(() => + gameHost.value.gameHost.status.value === 'running' ? '重新开始' : '开始游戏' + ); + + // Phaser 画布配置 + const phaserConfig = { + type: Phaser.AUTO, + width: 800, + height: 700, + backgroundColor: '#111827', + }; + + return ( +
+ {/* Phaser 游戏场景 */} +
+ + + +
+ + {/* 底部控制栏 */} +
+
+ ⚔️ Regicide - 击败所有12个敌人 +
+ +
+
+ ); +}