diff --git a/packages/regicide-game/src/game/card-utils.ts b/packages/regicide-game/src/game/card-utils.ts index d617bc0..fbcf013 100644 --- a/packages/regicide-game/src/game/card-utils.ts +++ b/packages/regicide-game/src/game/card-utils.ts @@ -32,6 +32,7 @@ export function getCardValue(rank: CardRank): number { } // 创建城堡牌堆 (J/Q/K 作为敌人) +// TODO 不要全部洗混,把J/Q/K分别洗混再合并,并且J先翻出K最后翻出 export function createCastleDeck(): EnemyCard[] { const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades']; const castleCards: EnemyCard[] = []; diff --git a/packages/regicide-game/src/game/regicide.ts b/packages/regicide-game/src/game/regicide.ts index c2614b9..190a5a1 100644 --- a/packages/regicide-game/src/game/regicide.ts +++ b/packages/regicide-game/src/game/regicide.ts @@ -247,10 +247,20 @@ registry.register('yield', yieldTurn); // counterattack - 反击时弃牌 async function counterattack(game: RegicideGame, cardIds: string[]) { const state = game.value; - + if (state.phase !== 'enemyCounterattack') throw new Error('不是反击阶段'); if (!state.currentEnemy) throw new Error('没有活跃的敌人'); + // 空牌提交 = 认输 + if (cardIds.length === 0) { + game.produce((state: RegicideState) => { + state.isGameOver = true; + state.victoryLevel = null; + state.phase = 'gameOver'; + }); + return { success: false, required: state.currentEnemy.counterDamage, available: 0, isSurrender: true }; + } + const selectedCards: Card[] = []; for (const cardId of cardIds) { const card = state.hand.find(c => c.id === cardId); @@ -408,6 +418,11 @@ export async function start(game: RegicideGame) { const { cardIds } = await game.prompt( prompts.counterattack, (cardIds: string[]) => { + // 允许空牌提交(认输) + if (cardIds.length === 0) { + return { cardIds: [], totalValue: 0 }; + } + const selectedCards: Card[] = []; for (const cardId of cardIds) { const card = state.hand.find(c => c.id === cardId); @@ -418,11 +433,8 @@ export async function start(game: RegicideGame) { } const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0); - if (totalValue < requiredValue) { - throw `牌的总点数 ${totalValue} 不足以抵消反击伤害 ${requiredValue}`; - } - - return { cardIds }; + // 不再抛出错误,允许提交不足的牌,由 counterattack 处理失败逻辑 + return { cardIds, totalValue }; }, state.currentEnemy.id ); @@ -444,9 +456,21 @@ export async function start(game: RegicideGame) { isDefeated: false, }; state.currentEnemy = nextEnemy; + state.phase = 'playerTurn'; + } else { + // 城堡牌堆没有有效的敌人卡牌,游戏胜利 + state.isGameOver = true; + state.victoryLevel = calculateVictoryLevel(state.jestersUsed); + state.currentEnemy = null; + state.phase = 'gameOver'; } + } else { + // 城堡牌堆已空,游戏胜利 + state.isGameOver = true; + state.victoryLevel = calculateVictoryLevel(state.jestersUsed); + state.currentEnemy = null; + state.phase = 'gameOver'; } - state.phase = 'playerTurn'; state.currentPlayed = null; }); } diff --git a/packages/regicide-game/src/scenes/GameScene.ts b/packages/regicide-game/src/scenes/GameScene.ts index d8809d3..2724825 100644 --- a/packages/regicide-game/src/scenes/GameScene.ts +++ b/packages/regicide-game/src/scenes/GameScene.ts @@ -3,6 +3,8 @@ 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'; +import { prompts } from '@/game/regicide'; +import { counterattackInfo, setGameSceneRef, deckInfo } from '@/ui/App'; const GAME_WIDTH = 800; const GAME_HEIGHT = 700; @@ -15,6 +17,8 @@ export class GameScene extends GameHostScene { private promptDisplay!: PromptDisplay; // 追踪当前卡牌容器,用于点击检测 public cardContainers: Phaser.GameObjects.Container[] = []; + // 卡牌选择状态(反击阶段) + private selectedCardIds: Set = new Set(); constructor() { super('RegicideGameScene'); @@ -22,6 +26,7 @@ export class GameScene extends GameHostScene { create(): void { super.create(); + setGameSceneRef(this); // 创建 UI 组件 this.gameUI = new GameUI(this); @@ -67,12 +72,11 @@ export class GameScene extends GameHostScene { 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, @@ -81,13 +85,12 @@ export class GameScene extends GameHostScene { ease: 'Back.easeOut', }); } else { - // 已有卡牌 - 更新位置 view.setPosition(x, HAND_Y); } } }); - // 添加状态效果 + // 状态效果 this.addEffect(() => { this.gameUI.updateEnemyDisplay(this, this.state.currentEnemy); }); @@ -111,27 +114,106 @@ export class GameScene extends GameHostScene { this.promptDisplay.update(schema, player); }); + // 监听阶段变化,更新反击信息 + this.addEffect(() => { + const phase = this.state.phase; + const enemy = this.state.currentEnemy; + const totalValue = this.getTotalSelectedCardValue(); + const required = enemy?.counterDamage ?? 0; + const maxHandValue = this.getMaxHandValue(); + + counterattackInfo.value = { + phase, + selectedCards: Array.from(this.selectedCardIds), + totalValue, + maxHandValue, + requiredValue: required, + canSubmit: this.selectedCardIds.size > 0 && totalValue >= required, + canWin: maxHandValue >= required, + error: null, + }; + + if (phase !== 'enemyCounterattack') { + this.selectedCardIds.clear(); + } + }); + + // 监听牌堆变化,更新牌堆信息 + this.addEffect(() => { + const state = this.state; + deckInfo.value = { + castleDeck: state.castleDeck.length, + tavernDeck: state.tavernDeck.length, + discardPile: state.discardPile.length, + hand: state.hand.length, + defeatedEnemies: state.defeatedEnemies.length, + jestersUsed: state.jestersUsed, + }; + }); + // 设置背景点击 this.setupBackgroundInput(); } private setupBackgroundInput(): void { this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - // 如果点击的是卡牌,不处理 if (this.isPointerOnCard(pointer)) { return; } - // 游戏结束时点击任意位置重新开始 + // 点击空白区域取消选中(反击阶段) + if (this.state.phase === 'enemyCounterattack' && this.selectedCardIds.size > 0) { + this.selectedCardIds.clear(); + for (const container of this.cardContainers) { + const view = container.getData('cardView') as CardView | undefined; + if (view) view.setSelected(false, this); + } + this.updateCounterattackInfo(); + return; + } + if (this.state.isGameOver) { this.gameHost.start(); } else if (!this.state.currentEnemy && this.state.phase === 'playerTurn') { - // 开始游戏 this.gameHost.start(); } }); } + // 公共方法:供 Preact UI 调用提交反击 + submitCounterattack(): void { + const cardIds = Array.from(this.selectedCardIds); + if (cardIds.length === 0) return; + + const error = this.gameHost.tryAnswerPrompt(prompts.counterattack, cardIds); + if (error) { + counterattackInfo.value = { + ...counterattackInfo.value, + error, + }; + } + + this.selectedCardIds.clear(); + for (const container of this.cardContainers) { + const view = container.getData('cardView') as CardView | undefined; + if (view) view.setSelected(false, this); + } + this.updateCounterattackInfo(); + } + + // 公共方法:反击阶段认输 + surrenderCounterattack(): void { + // 直接提交空牌,触发游戏结束 + this.gameHost.tryAnswerPrompt(prompts.counterattack, []); + + this.selectedCardIds.clear(); + for (const container of this.cardContainers) { + const view = container.getData('cardView') as CardView | undefined; + if (view) view.setSelected(false, this); + } + this.updateCounterattackInfo(); + } + private isPointerOnCard(pointer: Phaser.Input.Pointer): boolean { for (const container of this.cardContainers) { if (!container || !container.active) continue; @@ -147,6 +229,48 @@ export class GameScene extends GameHostScene { } return false; } + + // 卡牌点击回调(反击阶段) + onCardClicked(cardView: CardView): void { + const card = cardView.getCard(); + if (this.selectedCardIds.has(card.id)) { + this.selectedCardIds.delete(card.id); + } else { + this.selectedCardIds.add(card.id); + } + this.updateCounterattackInfo(); + } + + private updateCounterattackInfo(): void { + const enemy = this.state.currentEnemy; + const totalValue = this.getTotalSelectedCardValue(); + const required = enemy?.counterDamage ?? 0; + const maxHandValue = this.getMaxHandValue(); + + counterattackInfo.value = { + phase: this.state.phase, + selectedCards: Array.from(this.selectedCardIds), + totalValue, + maxHandValue, + requiredValue: required, + canSubmit: this.selectedCardIds.size > 0 && totalValue >= required, + canWin: maxHandValue >= required, + error: null, + }; + } + + private getTotalSelectedCardValue(): number { + let total = 0; + for (const id of this.selectedCardIds) { + const card = this.state.hand.find(c => c.id === id); + if (card) total += card.value; + } + return total; + } + + private getMaxHandValue(): number { + return this.state.hand.reduce((sum, c) => sum + c.value, 0); + } } // 敌人生成器 diff --git a/packages/regicide-game/src/scenes/views/CardView.ts b/packages/regicide-game/src/scenes/views/CardView.ts index 410293e..955622e 100644 --- a/packages/regicide-game/src/scenes/views/CardView.ts +++ b/packages/regicide-game/src/scenes/views/CardView.ts @@ -14,7 +14,9 @@ export class CardView { private readonly valueText: Phaser.GameObjects.Text; private readonly card: Card; private isHovering = false; + private isSelected = false; private baseY: number; + private selectedBorderColor = 0xfbbf24; // 金色边框表示选中 constructor(scene: GameScene, card: Card, x: number, y: number) { this.card = card; @@ -73,7 +75,7 @@ export class CardView { const scene = this.container.scene as Phaser.Scene; scene.tweens.add({ targets: this.container, - y: this.baseY + offset, + y: this.baseY + offset + (this.isSelected ? -15 : 0), duration: 100, ease: 'Power2', }); @@ -86,13 +88,37 @@ export class CardView { const scene = this.container.scene as Phaser.Scene; scene.tweens.add({ targets: this.container, - y: this.baseY, + y: this.baseY + (this.isSelected ? -15 : 0), duration: 100, ease: 'Power2', }); } } + setSelected(selected: boolean, scene: GameScene): void { + if (this.isSelected === selected) return; + this.isSelected = selected; + + // 更新边框颜色 + const color = selected ? this.selectedBorderColor : getSuitColor(this.card.suit); + this.bg.setStrokeStyle(3, Phaser.Display.Color.HexStringToColor( + selected ? '#fbbf24' : getSuitColor(this.card.suit) + ).color); + + // 更新位置 + const targetY = this.baseY + (selected ? -15 : 0); + scene.tweens.add({ + targets: this.container, + y: targetY, + duration: 100, + ease: 'Power2', + }); + } + + getSelected(): boolean { + return this.isSelected; + } + getCard(): Card { return this.card; } @@ -114,7 +140,11 @@ export class CardView { 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]); + // 切换选中状态 + const newSelected = !this.isSelected; + this.setSelected(newSelected, scene); + // 通知 GameScene 更新选中状态 + scene.onCardClicked(this); } }); } diff --git a/packages/regicide-game/src/ui/App.tsx b/packages/regicide-game/src/ui/App.tsx index 973bb87..b764e19 100644 --- a/packages/regicide-game/src/ui/App.tsx +++ b/packages/regicide-game/src/ui/App.tsx @@ -1,9 +1,53 @@ -import { useComputed } from '@preact/signals'; +import { useComputed, signal } from '@preact/signals'; import { createGameHost, type GameModule } from 'boardgame-core'; import Phaser from 'phaser'; import { h } from 'preact'; import { PhaserGame, PhaserScene } from 'boardgame-phaser'; +// 全局信号:从 GameScene 传递反击阶段的状态到 UI +export const counterattackInfo = signal<{ + phase: string; + selectedCards: string[]; + totalValue: number; + maxHandValue: number; // 手牌最大可提供的点数 + requiredValue: number; + canSubmit: boolean; + canWin: boolean; // 手牌是否足以获胜 + error: string | null; +}>({ + phase: '', + selectedCards: [], + totalValue: 0, + maxHandValue: 0, + requiredValue: 0, + canSubmit: false, + canWin: false, + error: null, +}); + +// 全局信号:牌堆信息 +export const deckInfo = signal<{ + castleDeck: number; // 敌人牌堆剩余 + tavernDeck: number; // 酒馆牌堆剩余 + discardPile: number; // 弃牌堆数量 + hand: number; // 手牌数 + defeatedEnemies: number; // 已击败敌人数 + jestersUsed: number; // 已用小丑牌数 +}>({ + castleDeck: 0, + tavernDeck: 0, + discardPile: 0, + hand: 0, + defeatedEnemies: 0, + jestersUsed: 0, +}); + +// 存储当前场景引用(由 GameScene 在 create 时设置) +let currentGameScene: any = null; +export function setGameSceneRef(scene: any) { + currentGameScene = scene; +} + export default function App>(props: { gameModule: GameModule, gameScene: { new(): Phaser.Scene } }) { const gameHost = useComputed(() => { @@ -18,10 +62,24 @@ export default function App>(props: { gam console.log('Game finished!', result); }; - const label = useComputed(() => + const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? '重新开始' : '开始游戏' ); + // 反击阶段提交 + const handleSubmitCounterattack = () => { + if (currentGameScene && typeof currentGameScene.submitCounterattack === 'function') { + currentGameScene.submitCounterattack(); + } + }; + + // 反击阶段认输 + const handleSurrender = () => { + if (currentGameScene && typeof currentGameScene.surrenderCounterattack === 'function') { + currentGameScene.surrenderCounterattack(); + } + }; + // Phaser 画布配置 const phaserConfig = { type: Phaser.AUTO, @@ -40,16 +98,111 @@ export default function App>(props: { gam {/* 底部控制栏 */} -
-
- ⚔️ Regicide - 击败所有12个敌人 +
+ {/* 牌堆信息 */} +
+
+ 🏰 + 城堡牌堆: + {deckInfo.value.castleDeck} +
+
+ 🍺 + 酒馆牌堆: + {deckInfo.value.tavernDeck} +
+
+ 🗑️ + 弃牌堆: + {deckInfo.value.discardPile} +
+
+ 🃏 + 手牌: + {deckInfo.value.hand} +
+
+ 💀 + 已击败: + {deckInfo.value.defeatedEnemies}/12 +
+
+ 🃏 + 小丑牌: + {2 - deckInfo.value.jestersUsed}/2 +
+
+ + {/* 反击阶段信息 + 按钮 */} +
+
+ ⚔️ Regicide - 击败所有12个敌人 +
+ + {/* 反击阶段信息面板 */} + {counterattackInfo.value.phase === 'enemyCounterattack' && ( +
+
+ 💥 反击阶段 + 需要: + {counterattackInfo.value.requiredValue} + | 手牌最多: + + {counterattackInfo.value.maxHandValue} + +
+ {counterattackInfo.value.selectedCards.length > 0 && ( +
+ 已选: + {counterattackInfo.value.selectedCards.length}张 + 点数: + + {counterattackInfo.value.totalValue} + +
+ )} + {counterattackInfo.value.error && ( +
+ ❌ {counterattackInfo.value.error} +
+ )} +
+ )} + +
+ {/* 反击确认按钮 */} + {counterattackInfo.value.phase === 'enemyCounterattack' && counterattackInfo.value.selectedCards.length > 0 && ( + + )} + + {/* 认输按钮 */} + {counterattackInfo.value.phase === 'enemyCounterattack' && !counterattackInfo.value.canWin && ( + + )} + + +
-
);