refactor: more regicide stuff

This commit is contained in:
hypercross 2026-04-06 17:17:04 +08:00
parent a0412ddd88
commit cedb69e55e
5 changed files with 360 additions and 28 deletions

View File

@ -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[] = [];

View File

@ -251,6 +251,16 @@ async function counterattack(game: RegicideGame, cardIds: string[]) {
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.currentPlayed = null;
});
}

View File

@ -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<RegicideState> {
private promptDisplay!: PromptDisplay;
// 追踪当前卡牌容器,用于点击检测
public cardContainers: Phaser.GameObjects.Container[] = [];
// 卡牌选择状态(反击阶段)
private selectedCardIds: Set<string> = new Set();
constructor() {
super('RegicideGameScene');
@ -22,6 +26,7 @@ export class GameScene extends GameHostScene<RegicideState> {
create(): void {
super.create();
setGameSceneRef(this);
// 创建 UI 组件
this.gameUI = new GameUI(this);
@ -67,12 +72,11 @@ export class GameScene extends GameHostScene<RegicideState> {
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<RegicideState> {
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<RegicideState> {
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<RegicideState> {
}
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);
}
}
// 敌人生成器

View File

@ -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);
}
});
}

View File

@ -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<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, gameScene: { new(): Phaser.Scene } }) {
const gameHost = useComputed(() => {
@ -22,6 +66,20 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
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,10 +98,103 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
</div>
{/* 底部控制栏 */}
<div class="p-4 bg-gray-900 border-t border-gray-700 flex justify-between items-center">
<div class="p-4 bg-gray-900 border-t border-gray-700 flex flex-col gap-3">
{/* 牌堆信息 */}
<div class="flex items-center justify-center gap-6 text-sm">
<div class="flex items-center gap-1.5">
<span class="text-lg">🏰</span>
<span class="text-gray-400">:</span>
<span class="text-purple-400 font-bold">{deckInfo.value.castleDeck}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🍺</span>
<span class="text-gray-400">:</span>
<span class="text-amber-400 font-bold">{deckInfo.value.tavernDeck}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🗑</span>
<span class="text-gray-400">:</span>
<span class="text-gray-300 font-bold">{deckInfo.value.discardPile}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🃏</span>
<span class="text-gray-400">:</span>
<span class="text-blue-400 font-bold">{deckInfo.value.hand}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">💀</span>
<span class="text-gray-400">:</span>
<span class="text-red-400 font-bold">{deckInfo.value.defeatedEnemies}/12</span>
</div>
<div class="flex items-center gap-1.5">
<span class="text-lg">🃏</span>
<span class="text-gray-400">:</span>
<span class="text-green-400 font-bold">{2 - deckInfo.value.jestersUsed}/2</span>
</div>
</div>
{/* 反击阶段信息 + 按钮 */}
<div class="flex justify-between items-center">
<div class="text-sm text-gray-400">
Regicide - 12
</div>
{/* 反击阶段信息面板 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && (
<div class="flex items-center gap-4">
<div class="text-sm text-gray-300">
<span class="text-yellow-400 font-bold">💥 </span>
<span class="ml-2">: </span>
<span class="text-red-400 font-bold">{counterattackInfo.value.requiredValue}</span>
<span class="ml-2">| : </span>
<span class={counterattackInfo.value.canWin ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
{counterattackInfo.value.maxHandValue}
</span>
</div>
{counterattackInfo.value.selectedCards.length > 0 && (
<div class="text-sm text-gray-300">
<span>: </span>
<span class="text-blue-400 font-bold">{counterattackInfo.value.selectedCards.length}</span>
<span class="ml-2">: </span>
<span class={counterattackInfo.value.canSubmit ? 'text-green-400 font-bold' : 'text-orange-400 font-bold'}>
{counterattackInfo.value.totalValue}
</span>
</div>
)}
{counterattackInfo.value.error && (
<div class="text-sm text-red-400">
{counterattackInfo.value.error}
</div>
)}
</div>
)}
<div class="flex gap-2">
{/* 反击确认按钮 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && counterattackInfo.value.selectedCards.length > 0 && (
<button
onClick={handleSubmitCounterattack}
disabled={!counterattackInfo.value.canSubmit}
class={`px-4 py-2 rounded font-medium transition-colors ${
counterattackInfo.value.canSubmit
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
</button>
)}
{/* 认输按钮 */}
{counterattackInfo.value.phase === 'enemyCounterattack' && !counterattackInfo.value.canWin && (
<button
onClick={handleSurrender}
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors font-medium"
>
🏳
</button>
)}
<button
onClick={handleReset}
class="px-6 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors font-medium"
@ -52,5 +203,7 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
</button>
</div>
</div>
</div>
</div>
);
}