diff --git a/packages/regicide-game/README.md b/packages/regicide-game/README.md new file mode 100644 index 0000000..e26e9a1 --- /dev/null +++ b/packages/regicide-game/README.md @@ -0,0 +1,114 @@ +# Regicide - 单人卡牌游戏 + +基于 Phaser 3 + boardgame-core 框架实现的 Regicide 合作卡牌游戏单人模式。 + +## 游戏简介 + +Regicide 是一款合作奇幻卡牌游戏,使用标准扑克牌进行游戏。玩家需要击败12个强大的敌人(4个J、4个Q、4个K)来取得胜利。 + +## 游戏规则 + +### 目标 +按任意顺序击败城堡牌堆中的所有12个敌人(J/Q/K)。 + +### 设置 +- **城堡牌堆**: 12张敌人卡(4J、4Q、4K) +- **酒馆牌堆**: 所有数字牌(2-10)和A +- **初始手牌**: 8张 +- **小丑牌**: 2张(用于重抽手牌) + +### 敌人属性 +| 敌人 | HP | 反击伤害 | 分值 | +|------|----|----------|------| +| J | 20 | 10 | 10 | +| Q | 30 | 15 | 15 | +| K | 40 | 20 | 20 | + +### 花色效果 +- **红桃 ♥**: 从弃牌堆回收卡牌到酒馆牌堆 +- **方片 ♦**: 抽取等量卡牌 +- **梅花 ♣**: 攻击力翻倍 +- **黑桃 ♠**: 减免反击伤害 + +### 游戏流程 +1. **出牌阶段**: 打出一张或多张同点数的牌(总点数≤10) +2. **伤害结算**: 对敌人造成等同于牌面点数的伤害 +3. **反击阶段**: 如果敌人未被击败,它会反击 +4. **抵消反击**: 弃置点数总和≥反击伤害的牌 + +### 特殊规则 +- **敌人免疫**: 每个敌人免疫与其花色相同的卡牌效果 +- **组合牌**: 可打出多张同点数牌,但总点数不能超过10 +- **小丑牌**: 可弃光手牌并重新抽取8张(整局限用2次) + +### 胜利等级 +- 🥇 **金胜利**: 未使用小丑牌获胜 +- 🥈 **银胜利**: 使用1张小丑牌获胜 +- 🥉 **铜胜利**: 使用2张小丑牌获胜 + +## 运行游戏 + +```bash +# 安装依赖 +pnpm install + +# 启动开发服务器 +cd packages/regicide-game +pnpm dev + +# 构建生产版本 +pnpm build + +# 预览生产版本 +pnpm preview +``` + +## 项目结构 + +``` +packages/regicide-game/ +├── src/ +│ ├── game/ +│ │ ├── types.ts # 类型定义 +│ │ ├── card-utils.ts # 卡牌工具函数 +│ │ └── regicide.ts # 游戏逻辑和命令 +│ ├── scenes/ +│ │ ├── GameScene.ts # 主游戏场景 +│ │ └── effects.ts # 特效系统 +│ └── ui/ +│ └── App.tsx # Preact UI组件 +├── index.html +├── package.json +└── vite.config.ts +``` + +## 技术栈 + +- **Phaser 3**: 游戏引擎 +- **boardgame-core**: 状态管理(Preact Signals + Mutative) +- **boardgame-phaser**: Phaser 集成框架 +- **Preact**: UI 组件 +- **TypeScript**: 类型安全 +- **Vite**: 构建工具 +- **Tailwind CSS**: UI 样式 + +## 开发说明 + +### 命令系统 +游戏使用 boardgame-core 的命令系统: +- `setup` - 初始化游戏 +- `play ` - 打出单张卡牌 +- `combo ` - 打出组合牌 +- `yield` - 放弃回合 +- `counterattack ` - 反击时弃牌 +- `useJester` - 使用小丑牌重抽 + +### 状态管理 +游戏状态使用 MutableSignal 响应式系统,通过 `game.produce()` 进行不可变更新。 + +### 场景系统 +GameScene 继承自 GameHostScene,使用 `spawnEffect` 和 `effect()` 实现响应式渲染。 + +## 许可证 + +MIT diff --git a/packages/regicide-game/index.html b/packages/regicide-game/index.html new file mode 100644 index 0000000..a761e78 --- /dev/null +++ b/packages/regicide-game/index.html @@ -0,0 +1,14 @@ + + + + + + + Regicide - Solo Card Game + + +
+
+ + + diff --git a/packages/regicide-game/package.json b/packages/regicide-game/package.json new file mode 100644 index 0000000..7848e16 --- /dev/null +++ b/packages/regicide-game/package.json @@ -0,0 +1,29 @@ +{ + "name": "regicide-game", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@preact/signals-core": "^1.5.1", + "boardgame-core": "link:../../../boardgame-core", + "boardgame-phaser": "workspace:*", + "mutative": "^1.3.0", + "phaser": "^3.80.1", + "preact": "^10.19.3" + }, + "devDependencies": { + "@preact/preset-vite": "^2.8.1", + "@preact/signals": "^2.9.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.3.3", + "vite": "^5.1.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/regicide-game/public/guide.md b/packages/regicide-game/public/guide.md new file mode 100644 index 0000000..506370d --- /dev/null +++ b/packages/regicide-game/public/guide.md @@ -0,0 +1,49 @@ +# Regicide 快速入门指南 + +## 🎯 游戏目标 +击败所有12个敌人(4个J、4个Q、4个K) + +## 🃏 出牌规则 + +### 基本操作 +1. **点击手牌** - 出牌攻击敌人 +2. **反击时点击手牌** - 弃牌抵消反击伤害 + +### 出牌方式 +- **单张牌**: 造成牌面点数伤害 +- **组合牌**: 多张同点数牌,总伤害=点数总和(需≤10) +- **放弃**: 不出牌,直接承受反击伤害 + +### 花色特效 +- ♥ 红桃 - 回收弃牌 +- ♦ 方片 - 抽取新牌 +- ♣ 梅花 - 伤害翻倍 +- ♠ 黑桃 - 减免反击 + +## ⚔️ 敌人信息 + +| 敌人 | 生命值 | 反击伤害 | +|------|--------|----------| +| J | 20 HP | 10 | +| Q | 30 HP | 15 | +| K | 40 HP | 20 | + +**注意**: 每个敌人免疫与其花色相同的卡牌效果! + +## 🃏 小丑牌 +- 可以**弃光手牌**并**重新抽取8张** +- 整局限用**2次** +- 使用次数影响胜利等级 + +## 🏆 胜利等级 +- 🥇 金: 未使用小丑牌 +- 🥈 银: 使用1张小丑牌 +- 🥉 铜: 使用2张小丑牌 + +## 💡 游戏提示 +1. 合理使用花色特效 +2. 保留高点数牌应对强敌 +3. 注意敌人的花色免疫 +4. 谨慎使用小丑牌 + +祝你好运,勇者!⚔️ diff --git a/packages/regicide-game/public/vite.svg b/packages/regicide-game/public/vite.svg new file mode 100644 index 0000000..a761e78 --- /dev/null +++ b/packages/regicide-game/public/vite.svg @@ -0,0 +1,14 @@ + + + + + + + Regicide - Solo Card Game + + +
+
+ + + diff --git a/packages/regicide-game/src/game/card-utils.ts b/packages/regicide-game/src/game/card-utils.ts new file mode 100644 index 0000000..d617bc0 --- /dev/null +++ b/packages/regicide-game/src/game/card-utils.ts @@ -0,0 +1,141 @@ +import type { Card, CardSuit, CardRank, EnemyCard, JesterCard } from './types'; + +// 创建标准扑克牌组 (不含小丑) +export function createStandardDeck(): Card[] { + const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades']; + const ranks: CardRank[] = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; + const deck: Card[] = []; + + for (const suit of suits) { + for (const rank of ranks) { + const value = getCardValue(rank); + deck.push({ + id: `card-${suit}-${rank}`, + suit, + rank, + value, + }); + } + } + + return deck; +} + +// 获取卡牌点数对应的数值 +export function getCardValue(rank: CardRank): number { + if (rank === 'jester') return 0; + if (rank === 'A') return 1; + if (rank === 'J') return 10; + if (rank === 'Q') return 15; + if (rank === 'K') return 20; + return parseInt(rank); +} + +// 创建城堡牌堆 (J/Q/K 作为敌人) +export function createCastleDeck(): EnemyCard[] { + const suits: CardSuit[] = ['hearts', 'diamonds', 'clubs', 'spades']; + const castleCards: EnemyCard[] = []; + + // 4个K (40HP, 20反击) + for (let i = 0; i < 4; i++) { + castleCards.push({ + id: `enemy-K-${i}`, + suit: suits[i], + rank: 'K' as const, + value: 20, + hp: 40, + counterDamage: 20, + immunitySuit: suits[i], + }); + } + + // 4个Q (30HP, 15反击) + for (let i = 0; i < 4; i++) { + castleCards.push({ + id: `enemy-Q-${i}`, + suit: suits[i], + rank: 'Q' as const, + value: 15, + hp: 30, + counterDamage: 15, + immunitySuit: suits[i], + }); + } + + // 4个J (20HP, 10反击) + for (let i = 0; i < 4; i++) { + castleCards.push({ + id: `enemy-J-${i}`, + suit: suits[i], + rank: 'J' as const, + value: 10, + hp: 20, + counterDamage: 10, + immunitySuit: suits[i], + }); + } + + return shuffleDeck(castleCards); +} + +// 创建酒馆牌堆 (数字牌 + A) +export function createTavernDeck(): Card[] { + const deck = createStandardDeck(); + // 过滤出数字牌和A,去除J/Q/K + return deck.filter(card => + card.rank !== 'J' && card.rank !== 'Q' && card.rank !== 'K' + ); +} + +// 创建小丑牌 +export function createJesters(): JesterCard[] { + return [ + { id: 'jester-1', rank: 'jester', used: false }, + { id: 'jester-2', rank: 'jester', used: false }, + ]; +} + +// 洗牌算法 (Fisher-Yates) +export function shuffleDeck(deck: T[]): T[] { + const shuffled = [...deck]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +// 从牌堆顶部抽牌 +export function drawFromDeck(deck: T[], count: number): T[] { + return deck.splice(0, count); +} + +// 获取花色的中文名称 +export function getSuitName(suit: CardSuit | null): string { + if (!suit) return '无花色'; + const names: Record = { + hearts: '红桃 ♥', + diamonds: '方片 ♦', + clubs: '梅花 ♣', + spades: '黑桃 ♠', + }; + return names[suit]; +} + +// 获取花色的颜色 +export function getSuitColor(suit: CardSuit | null): string { + if (!suit) return '#9ca3af'; // 灰色 + return (suit === 'hearts' || suit === 'diamonds') ? '#dc2626' : '#1f2937'; +} + +// 获取卡牌显示文本 +export function getCardDisplay(card: Card | JesterCard): string { + if (card.rank === 'jester') return '🃏'; + const suitSymbols: Record = { + hearts: '♥', + diamonds: '♦', + clubs: '♣', + spades: '♠', + }; + return `${card.rank}${suitSymbols[card.suit as CardSuit]}`; +} diff --git a/packages/regicide-game/src/game/regicide.test.ts b/packages/regicide-game/src/game/regicide.test.ts new file mode 100644 index 0000000..09bd275 --- /dev/null +++ b/packages/regicide-game/src/game/regicide.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createGameHost, GameHost } from 'boardgame-core'; +import { gameModule, createInitialState } from './regicide'; +import type { RegicideState, Card, Enemy } from './types'; + +describe('Regicide Game Module', () => { + let gameHost: GameHost; + let gamePromise: Promise; + + beforeEach(() => { + gameHost = createGameHost(gameModule); + }); + + afterEach(async () => { + // 等待游戏循环完成,然后 dispose + await new Promise(resolve => setTimeout(resolve, 50)); + gameHost.dispose(); + }); + + describe('Initial State', () => { + it('should create valid initial state', () => { + const state = createInitialState(); + + expect(state.castleDeck).toBeDefined(); + expect(state.tavernDeck).toBeDefined(); + expect(state.discardPile).toBeDefined(); + expect(state.currentEnemy).toBeNull(); + expect(state.defeatedEnemies).toEqual([]); + expect(state.hand).toEqual([]); + expect(state.jesters).toHaveLength(2); + expect(state.jestersUsed).toBe(0); + expect(state.phase).toBe('playerTurn'); + expect(state.currentPlayed).toBeNull(); + expect(state.victoryLevel).toBeNull(); + expect(state.isGameOver).toBe(false); + }); + + it('should have jesters with correct structure', () => { + const state = createInitialState(); + + expect(state.jesters[0]).toMatchObject({ + id: 'jester-1', + rank: 'jester', + used: false, + }); + expect(state.jesters[1]).toMatchObject({ + id: 'jester-2', + rank: 'jester', + used: false, + }); + }); + }); + + describe('Setup Command', () => { + it('should initialize game with setup command', async () => { + // 开始游戏(会自动调用 setup 和主循环) + gamePromise = gameHost.start(); + + // 等待游戏进入 prompt 状态(等待玩家出牌) + await waitForPrompt(gameHost); + + // 验证游戏状态 + const state = gameHost.state.value; + expect(state.phase).toBe('playerTurn'); + expect(state.currentEnemy).not.toBeNull(); + expect(state.currentEnemy!.currentHp).toBeGreaterThan(0); + expect(state.hand.length).toBe(8); // 初始手牌数 + expect(state.castleDeck.length).toBeGreaterThan(0); + expect(state.tavernDeck.length).toBeGreaterThan(0); + }); + + it('should shuffle decks during setup', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const state = gameHost.state.value; + + // 验证牌堆已被洗牌(不应该按顺序排列) + const firstFewCards = state.tavernDeck.slice(0, 10); + const hasDifferentValues = firstFewCards.some(card => card.value !== firstFewCards[0].value); + expect(hasDifferentValues).toBe(true); + }); + }); + + describe('Play Card Command', () => { + it('should play a single card and damage enemy', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const state = gameHost.state.value; + const enemy = state.currentEnemy!; + const initialHp = enemy.currentHp; + const card = state.hand[0]; + + // 出牌 - 使用 input 命令格式 + const error = gameHost.onInput(`input play ${card.id}`); + expect(error).toBeNull(); // 验证成功 + + // 等待下一个 prompt + await waitForPrompt(gameHost); + + const newState = gameHost.state.value; + const newEnemy = newState.currentEnemy; + + // 验证敌人受到了伤害或进入反击阶段 + if (newEnemy) { + // HP 应该减少(至少减少了card.value) + const damage = initialHp - newEnemy.currentHp; + expect(damage).toBeGreaterThanOrEqual(card.value); + } + + // 验证卡牌从手牌移除 + expect(newState.hand.find(c => c.id === card.id)).toBeUndefined(); + expect(newState.discardPile.find(c => c.id === card.id)).toBeDefined(); + }); + + it('should fail to play card not in hand', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const stateBefore = gameHost.state.value; + const handSizeBefore = stateBefore.hand.length; + + // 尝试打出不存在的牌 - 应该被 validator 拒绝 + const error = gameHost.onInput('input play non-existent-card'); + // 错误会被 catch 并继续循环,所以返回 null + expect(error).toBeNull(); + + // 等待下一个 prompt(因为命令失败,会再次提示) + await waitForPrompt(gameHost); + + const stateAfter = gameHost.state.value; + // 验证手牌没有变化 + expect(stateAfter.hand.length).toBe(handSizeBefore); + }); + }); + + describe('Counterattack Mechanism', () => { + it('should enter counterattack phase after playing card', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const state = gameHost.state.value; + const enemy = state.currentEnemy!; + + // 出一张牌造成伤害(不一定击败敌人) + const card = state.hand[0]; + gameHost.onInput(`input play ${card.id}`); + + // 等待反击阶段或下一个 prompt + await waitForPrompt(gameHost); + + const counterState = gameHost.state.value; + // 验证进入了反击阶段或敌人被击败 + expect( + counterState.phase === 'enemyCounterattack' || + counterState.phase === 'enemyDefeated' || + counterState.isGameOver || + counterState.phase === 'playerTurn' // 如果敌人被立即击败 + ).toBe(true); + }); + }); + + describe('Game End Conditions', () => { + it('should track game state correctly', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const state = gameHost.state.value; + expect(state.defeatedEnemies).toHaveLength(0); + expect(state.isGameOver).toBe(false); + }); + }); + + describe('Yield Turn', () => { + it('should yield turn', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + // 放弃回合 - 使用 input 命令格式 + const error = gameHost.onInput('input yield'); + + // 等待处理完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + const state = gameHost.state.value; + // yield 命令可能被接受或拒绝,取决于游戏状态 + // 这里只验证游戏仍在运行 + expect(state.isGameOver).toBeDefined(); + }); + }); + + describe('Use Jester', () => { + it('should use jester to redraw hand', async () => { + gamePromise = gameHost.start(); + await waitForPrompt(gameHost); + + const state = gameHost.state.value; + const initialHandSize = state.hand.length; + + // 使用小丑牌 - 使用 input 命令格式 + const error = gameHost.onInput('input useJester'); + + // 等待处理完成 + await new Promise(resolve => setTimeout(resolve, 100)); + + const newState = gameHost.state.value; + // useJester 可能被接受或拒绝 + // 验证游戏仍在运行 + expect(newState.isGameOver).toBeDefined(); + }); + }); +}); + +// 辅助函数 + +function waitForPrompt(gameHost: GameHost): Promise { + return new Promise((resolve) => { + const check = () => { + if (gameHost.activePromptSchema.value !== null) { + resolve(); + } else { + setTimeout(check, 10); + } + }; + check(); + }); +} + +function waitForPromptOrGameOver(gameHost: GameHost): Promise { + return new Promise((resolve) => { + const check = () => { + const state = gameHost.state.value; + if (gameHost.activePromptSchema.value !== null || state.isGameOver) { + resolve(); + } else { + setTimeout(check, 10); + } + }; + check(); + }); +} diff --git a/packages/regicide-game/src/game/regicide.ts b/packages/regicide-game/src/game/regicide.ts new file mode 100644 index 0000000..0376739 --- /dev/null +++ b/packages/regicide-game/src/game/regicide.ts @@ -0,0 +1,447 @@ +import { + createGameCommandRegistry, + IGameContext, + GameModule, +} from 'boardgame-core'; +import type { RegicideState, Enemy, PlayedCards, Card, JesterCard, CardSuit, EnemyCard } from './types'; +import { + createCastleDeck, + createTavernDeck, + createJesters, + shuffleDeck, + drawFromDeck, +} from './card-utils'; + +const INITIAL_HAND_SIZE = 8; +const MAX_COMBINED_VALUE = 10; + +export const registry = createGameCommandRegistry(); + +export function createInitialState(): RegicideState { + return { + castleDeck: [], + tavernDeck: [], + discardPile: [], + currentEnemy: null, + defeatedEnemies: [], + hand: [], + jesters: createJesters(), + jestersUsed: 0, + phase: 'playerTurn', + currentPlayed: null, + victoryLevel: null, + isGameOver: false, + }; +} + +export type RegicideGame = IGameContext; + +// setup 命令 - 初始化游戏 +async function setup(game: RegicideGame) { + const castleDeck = shuffleDeck(createCastleDeck()); + const tavernDeck = shuffleDeck(createTavernDeck()); + const jesters = createJesters(); + const initialHand = drawFromDeck(tavernDeck, INITIAL_HAND_SIZE); + + const firstEnemyCard = castleDeck.shift(); + if (!firstEnemyCard) throw new Error('城堡牌堆为空'); + + const firstEnemy: Enemy = { + ...firstEnemyCard, + currentHp: firstEnemyCard.hp, + isDefeated: false, + }; + + game.produce((state: RegicideState) => { + state.castleDeck = castleDeck; + state.tavernDeck = tavernDeck; + state.discardPile = []; + state.currentEnemy = firstEnemy; + state.defeatedEnemies = []; + state.hand = initialHand; + state.jesters = jesters; + state.jestersUsed = 0; + state.phase = 'playerTurn'; + state.currentPlayed = null; + state.victoryLevel = null; + state.isGameOver = false; + }); + + return game.value; +} + +registry.register('setup', setup); + +// play - 打出单张卡牌 +async function playCard(game: RegicideGame, cardId: string) { + const state = game.value; + + if (state.phase !== 'playerTurn') { + throw new Error('不是玩家回合'); + } + + if (!state.currentEnemy || state.currentEnemy.isDefeated) { + throw new Error('没有活跃的敌人'); + } + + const card = state.hand.find(c => c.id === cardId); + if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`); + + // 计算伤害 + 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)) { + 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); + + if (isDefeated) { + enemy.isDefeated = true; + state.defeatedEnemies.push({ ...enemy }); + state.currentEnemy = null; + state.phase = 'enemyDefeated'; + + if (state.defeatedEnemies.length >= 12) { + state.isGameOver = true; + state.victoryLevel = calculateVictoryLevel(state.jestersUsed); + state.phase = 'gameOver'; + } + } else { + state.phase = 'enemyCounterattack'; + } + + state.currentPlayed = { + cards: [card], + totalDamage: isImmune ? card.value : damage, + suits, + hasJester: false, + }; + }); + + return { damage, remainingHp: Math.max(0, enemy.currentHp), isImmune, isDefeated }; +} + +registry.register('play ', playCard); + +// combo - 打出组合牌 +async function playCombo(game: RegicideGame, cardIds: string[]) { + const state = game.value; + + if (state.phase !== 'playerTurn') throw new Error('不是玩家回合'); + if (cardIds.length < 2) throw new Error('组合牌至少需要2张'); + + const selectedCards: Card[] = []; + for (const cardId of cardIds) { + const card = state.hand.find(c => c.id === cardId); + if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`); + selectedCards.push(card); + } + + const firstRank = selectedCards[0].rank; + if (!selectedCards.every(c => c.rank === firstRank)) { + throw new Error('组合牌必须是相同点数'); + } + + const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0); + if (totalValue > MAX_COMBINED_VALUE) { + throw new Error(`组合牌总点数不能超过 ${MAX_COMBINED_VALUE}`); + } + + 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)) { + 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); + } + + if (isDefeated) { + enemy.isDefeated = true; + state.defeatedEnemies.push({ ...enemy }); + state.currentEnemy = null; + state.phase = 'enemyDefeated'; + + if (state.defeatedEnemies.length >= 12) { + state.isGameOver = true; + state.victoryLevel = calculateVictoryLevel(state.jestersUsed); + state.phase = 'gameOver'; + } + } else { + state.phase = 'enemyCounterattack'; + } + + state.currentPlayed = { + cards: selectedCards, + totalDamage: isImmune ? totalValue : damage, + suits, + hasJester: false, + }; + }); + + return { damage, remainingHp: Math.max(0, enemy.currentHp), isImmune, isDefeated }; +} + +registry.register('combo ', playCombo); + +// yield - 放弃回合 +async function yieldTurn(game: RegicideGame) { + const state = game.value; + + if (state.phase !== 'playerTurn') throw new Error('不是玩家回合'); + + game.produce((state: RegicideState) => { + state.phase = 'enemyCounterattack'; + state.currentPlayed = { + cards: [], + totalDamage: 0, + suits: [], + hasJester: false, + }; + }); + + return { message: '放弃回合,承受反击伤害' }; +} + +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('没有活跃的敌人'); + + const selectedCards: Card[] = []; + for (const cardId of cardIds) { + const card = state.hand.find(c => c.id === cardId); + if (!card) throw new Error(`卡牌 ${cardId} 不在手牌中`); + selectedCards.push(card); + } + + const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0); + const requiredValue = state.currentEnemy.counterDamage; + + if (totalValue < requiredValue) { + game.produce((state: RegicideState) => { + state.isGameOver = true; + state.victoryLevel = null; + state.phase = 'gameOver'; + }); + + return { success: false, required: requiredValue, available: totalValue }; + } + + game.produce((state: RegicideState) => { + for (const card of selectedCards) { + const index = state.hand.findIndex(c => c.id === card.id); + if (index !== -1) { + state.hand.splice(index, 1); + state.discardPile.push(card); + } + } + + const cardsToDraw = Math.min( + INITIAL_HAND_SIZE - state.hand.length, + state.tavernDeck.length + ); + + if (cardsToDraw > 0) { + const newCards = drawFromDeck(state.tavernDeck, cardsToDraw); + state.hand.push(...newCards); + } + + state.phase = 'playerTurn'; + state.currentPlayed = null; + state.currentEnemy = null; + + if (state.castleDeck.length > 0) { + const nextEnemyCard = state.castleDeck.shift(); + if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) { + const nextEnemy: Enemy = { + ...(nextEnemyCard as EnemyCard), + currentHp: (nextEnemyCard as EnemyCard).hp, + isDefeated: false, + }; + state.currentEnemy = nextEnemy; + } + } + }); + + return { success: true, cardsUsed: totalValue, required: requiredValue }; +} + +registry.register('counterattack ', counterattack); + +// useJester - 使用小丑牌重抽 +async function useJester(game: RegicideGame) { + const state = game.value; + + if (state.jestersUsed >= 2) throw new Error('小丑牌已用完'); + + const unusedJester = state.jesters.find(j => !j.used); + if (!unusedJester) throw new Error('没有可用的小丑牌'); + + game.produce((state: RegicideState) => { + state.discardPile.push(...state.hand); + state.hand = []; + + const cardsToDraw = Math.min(INITIAL_HAND_SIZE, state.tavernDeck.length); + const newCards = drawFromDeck(state.tavernDeck, cardsToDraw); + state.hand.push(...newCards); + + const jesterIndex = state.jesters.findIndex(j => j.id === unusedJester.id); + if (jesterIndex !== -1) { + state.jesters[jesterIndex].used = true; + state.jestersUsed++; + } + }); + + return { message: `使用小丑牌,重新抽取 ${INITIAL_HAND_SIZE} 张牌` }; +} + +registry.register('useJester', useJester); + +function calculateVictoryLevel(jestersUsed: number): 'gold' | 'silver' | 'bronze' { + if (jestersUsed === 0) return 'gold'; + if (jestersUsed === 1) return 'silver'; + return 'bronze'; +} + +// start 函数 - 游戏主循环 +export async function start(game: RegicideGame) { + // 首先执行 setup + await setup(game); + + // 主游戏循环 + while (!game.value.isGameOver) { + const state = game.value; + + // 玩家回合 - 等待玩家输入任何命令 + 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 !== ''); + if (parts.length === 0) { + throw '请输入有效的命令'; + } + return { inputStr: parts.join(' ') }; + }, + 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(','); + await playCombo(game, cardIds); + } else if (command === 'yield') { + await yieldTurn(game); + } else if (command === 'useJester') { + await useJester(game); + } else { + // 无效命令,继续等待 + continue; + } + } catch (e) { + // 命令执行失败,继续循环(玩家会再次被提示) + continue; + } + } + + // 敌人反击阶段 + if (state.phase === 'enemyCounterattack') { + // 等待玩家出牌来抵消反击伤害 + if (state.currentEnemy) { + const requiredValue = state.currentEnemy.counterDamage; + const { cardIds } = await game.prompt( + 'counterattack ', + (cardIds: string[]) => { + const selectedCards: Card[] = []; + for (const cardId of cardIds) { + const card = state.hand.find(c => c.id === cardId); + if (!card) { + throw `卡牌 ${cardId} 不在手牌中`; + } + selectedCards.push(card); + } + + const totalValue = selectedCards.reduce((sum, c) => sum + c.value, 0); + if (totalValue < requiredValue) { + throw `牌的总点数 ${totalValue} 不足以抵消反击伤害 ${requiredValue}`; + } + + return { cardIds }; + }, + state.currentEnemy.id + ); + + await counterattack(game, cardIds); + } + } + + // 敌人被击败阶段 + if (state.phase === 'enemyDefeated') { + game.produce((state: RegicideState) => { + // 准备迎接下一个敌人 + if (state.castleDeck.length > 0) { + const nextEnemyCard = state.castleDeck.shift(); + if (nextEnemyCard && 'hp' in nextEnemyCard && 'counterDamage' in nextEnemyCard) { + const nextEnemy: Enemy = { + ...(nextEnemyCard as EnemyCard), + currentHp: (nextEnemyCard as EnemyCard).hp, + isDefeated: false, + }; + state.currentEnemy = nextEnemy; + } + } + state.phase = 'playerTurn'; + state.currentPlayed = null; + }); + } + } + + return game.value.victoryLevel; +} + +export const gameModule: GameModule = { + registry, + createInitialState, + start, +}; + +export type { RegicideState, Card, Enemy, JesterCard } from './types'; diff --git a/packages/regicide-game/src/game/types.ts b/packages/regicide-game/src/game/types.ts new file mode 100644 index 0000000..cb5d3a9 --- /dev/null +++ b/packages/regicide-game/src/game/types.ts @@ -0,0 +1,94 @@ +// 卡牌花色 +export type CardSuit = 'hearts' | 'diamonds' | 'clubs' | 'spades'; + +// 卡牌点数 +export type CardRank = + | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' + | 'J' | 'Q' | 'K' | 'A' + | 'jester'; + +// 基础卡牌 +export interface Card { + id: string; + suit: CardSuit | null; // jester 没有花色 + rank: CardRank; + value: number; // 攻击力/分数值 +} + +// 敌人卡 (J/Q/K) +export interface EnemyCard extends Card { + rank: 'J' | 'Q' | 'K'; + hp: number; + counterDamage: number; + immunitySuit?: CardSuit; // 免疫的花色 +} + +// 小丑牌 +export interface JesterCard { + id: string; + rank: 'jester'; + used: boolean; +} + +// 游戏中的敌人实例 +export interface Enemy extends EnemyCard { + currentHp: number; + isDefeated: boolean; +} + +// 游戏阶段 +export type GamePhase = + | 'playerTurn' // 玩家回合 - 出牌 + | 'enemyCounterattack' // 敌人反击 + | 'enemyDefeated' // 敌人被击败 + | 'gameOver'; // 游戏结束 + +// 游戏结果 +export type VictoryLevel = 'gold' | 'silver' | 'bronze' | null; + +// 打出的卡牌组合 +export interface PlayedCards { + cards: Card[]; + totalDamage: number; + suits: CardSuit[]; + hasJester: boolean; +} + +// Regicide 游戏状态 +export interface RegicideState { + // 牌堆 + castleDeck: Card[]; // 敌人牌堆 (J/Q/K) + tavernDeck: Card[]; // 酒馆牌堆 (数字牌+A) + discardPile: Card[]; // 弃牌堆 + + // 敌人 + currentEnemy: Enemy | null; + defeatedEnemies: Enemy[]; + + // 玩家 + hand: Card[]; + + // 小丑牌 + jesters: JesterCard[]; + jestersUsed: number; + + // 当前回合信息 + phase: GamePhase; + currentPlayed: PlayedCards | null; + + // 游戏结果 + victoryLevel: VictoryLevel; + isGameOver: boolean; + + // 索引签名以满足 Record 约束 + [key: string]: unknown; +} + +// 命令参数类型 +export interface PlayCardCommand { + cardIds: string[]; +} + +export interface CounterattackCommand { + cardIds: string[]; +} diff --git a/packages/regicide-game/tsconfig.json b/packages/regicide-game/tsconfig.json new file mode 100644 index 0000000..d7f3323 --- /dev/null +++ b/packages/regicide-game/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*"] +} diff --git a/packages/regicide-game/tsconfig.node.json b/packages/regicide-game/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/packages/regicide-game/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/regicide-game/vite.config.ts b/packages/regicide-game/vite.config.ts new file mode 100644 index 0000000..8e7f66d --- /dev/null +++ b/packages/regicide-game/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [preact(), tailwindcss()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, +}); diff --git a/packages/regicide-game/vitest.config.ts b/packages/regicide-game/vitest.config.ts new file mode 100644 index 0000000..4a3d0a5 --- /dev/null +++ b/packages/regicide-game/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, + resolve: { + alias: { + '@': '/src', + }, + }, +});