Compare commits
6 Commits
15fbe7ee08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70050b68e6 | ||
|
|
60b3856a23 | ||
|
|
c6aa60a5cd | ||
|
|
ae89194ecd | ||
|
|
85d2f63466 | ||
|
|
24ee21f24c |
148
README.md
148
README.md
@@ -1,40 +1,98 @@
|
|||||||
# 掼蛋卡牌游戏 (Guandan Card Game)
|
# 掼蛋卡牌游戏 (Guandan Card Game)
|
||||||
|
|
||||||
基于 Godot 4.x 开发的掼蛋卡牌游戏。目前处于训练模式开发阶段。
|
基于 Godot 4.6.3 开发的掼蛋卡牌游戏。**训练模式**已基本可用,联机与回放为后续阶段。
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- **Godot 4.x** (开发使用 4.6.3)
|
- **Godot 4.6.3+** ([godotengine.org](https://godotengine.org/download))
|
||||||
- 安装方式: `winget install GodotEngine.GodotEngine`
|
- 安装命令: `winget install GodotEngine.GodotEngine`
|
||||||
- 或从 [godotengine.org](https://godotengine.org/download) 下载
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd game-cards
|
||||||
|
# Godot 编辑器 → "导入" → 选择 project.godot → F5 运行
|
||||||
|
```
|
||||||
|
|
||||||
|
主场景: `res://src/ui/scenes/main_menu.tscn`
|
||||||
|
|
||||||
|
## 训练模式
|
||||||
|
|
||||||
|
进入"训练室"后由玩家(座位 0)对 3 个 L2 规则 AI,**玩家始终是出牌方**(AI 跟牌或过牌)。每局共 27 张/人,从 2 开始打。
|
||||||
|
|
||||||
|
### 玩法
|
||||||
|
|
||||||
|
1. 鼠标点击手牌:选中(牌上抬 25px + 黄边高亮)
|
||||||
|
2. 双击手牌:直接出该单张
|
||||||
|
3. `出牌` 按钮:按当前选中提交牌型
|
||||||
|
4. `过牌` 按钮:本轮放权(领出者不能过)
|
||||||
|
5. `建议` 按钮:调 L2 AI 给出当前最优出法
|
||||||
|
|
||||||
|
### 牌型支持
|
||||||
|
|
||||||
|
单张 / 对子 / 三张 / 三带二 / 顺子(5+)/ 连对(6+ 双数)/ 钢板(6+ 3 整除)/ 同花顺 / 炸弹 / 火箭
|
||||||
|
|
||||||
|
## UI 特性
|
||||||
|
|
||||||
|
- **手牌自动换行**: `HandArea extends FlowContainer`,27+ 张牌自动多行
|
||||||
|
- **按花色+点数排序**: 渲染前 `_sort_by_rank_and_suit` 统一顺序
|
||||||
|
- **出牌动画**: ghost 节点从原位飞行 + 缩放至桌面中央(0.3s)
|
||||||
|
- **点评文字**: 出牌方 + 牌型 + 张数 + 主阶,0.2s 淡入 → 0.6s 停留 → 0.3s 淡出
|
||||||
|
- **选中上抬**: 选中时 `position.y` -25px tween(0.12s QUAD+EASE_OUT)
|
||||||
|
- **Hint 高亮**: L2 AI 推荐的牌自动高亮(与选中共用 `set_selected`)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
game-cards/
|
game-cards/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── core/ # 业务逻辑层(纯 GDScript,零依赖)
|
│ ├── core/ # 纯逻辑层(零 Godot 依赖)
|
||||||
│ ├── ai/ # AI 模块(L1 基础 / L2 规则 / L3 策略)
|
│ │ ├── card.gd # Card 类(4 字节打包)
|
||||||
│ ├── game/ # 游戏控制器(训练/对战/回放)
|
│ │ ├── deck.gd # Deck 与发牌
|
||||||
│ ├── network/ # 联机层(后期)
|
│ │ ├── game_state.gd # 牌局状态、Phase、Action 日志
|
||||||
│ ├── ui/ # UI 场景和组件
|
│ │ ├── round.gd # 单轮出牌序列
|
||||||
│ │ ├── scenes/ # 主菜单、训练室、牌桌、大厅、观战
|
│ │ ├── hand_evaluator.gd # 牌型判定(含主阶、是否纯炸弹)
|
||||||
│ │ └── components/ # 卡牌、手牌区、出牌区等组件
|
│ │ ├── rule_engine.gd # 牌型比较与合法性
|
||||||
│ └── autoload/ # 全局单例(配置、事件总线、音频)
|
│ │ ├── move_generator.gd # 给定手牌生成所有合法出法
|
||||||
├── tests/ # GUT 单元测试
|
│ │ ├── actions.gd # Action 数据类
|
||||||
├── docs/ # 设计文档和实施计划
|
│ │ ├── rule_config.gd # 规则配置(主阶、级牌等)
|
||||||
└── assets/ # 卡牌图片、UI 素材、音频、字体
|
│ │ └── constants.gd # 牌型枚举与常量
|
||||||
|
│ ├── ai/
|
||||||
|
│ │ ├── base_ai.gd # AI 抽象接口
|
||||||
|
│ │ ├── l1_basic_ai.gd # L1: 贪心最小牌
|
||||||
|
│ │ └── l2_rule_ai.gd # L2: 全枚举选最小压制
|
||||||
|
│ ├── game/
|
||||||
|
│ │ ├── game_controller.gd # 通用回合推进
|
||||||
|
│ │ ├── training_controller.gd # 训练模式:单玩家 vs 3 AI
|
||||||
|
│ │ └── replay_recorder.gd # Action 日志 → 重放
|
||||||
|
│ ├── autoload/
|
||||||
|
│ │ ├── config.gd # 规则与全局配置
|
||||||
|
│ │ ├── event_bus.gd # 全局信号总线
|
||||||
|
│ │ └── audio_manager.gd # 音效播放(未启用)
|
||||||
|
│ └── ui/
|
||||||
|
│ ├── scenes/
|
||||||
|
│ │ ├── main_menu.tscn # 主菜单
|
||||||
|
│ │ └── training_room.tscn # 训练室(1280×720)
|
||||||
|
│ └── components/
|
||||||
|
│ ├── hand_area.tscn # FlowContainer 手牌区
|
||||||
|
│ └── card_node.tscn # 单张卡牌(55×80)
|
||||||
|
├── tests/ # GUT 单元测试(占位)
|
||||||
|
├── docs/superpowers/ # 设计规格与实施计划
|
||||||
|
└── assets/ # 卡牌图片、UI 素材
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行
|
## 关键架构决策
|
||||||
|
|
||||||
### 1. 打开项目
|
1. **`core/` 零 Godot 依赖** — 便于未来用 Rust 移植核心逻辑
|
||||||
|
2. **Card 用 4 字节打包**(suit+rank 占 1 字节,card_id 占 2 字节)— 减少内存与序列化成本
|
||||||
|
3. **GDScript 强类型 Array[Card]** — 编译期类型检查;规避 Godot 4.6.3 `Array.duplicate()` 丢类型 bug,统一用 `for c in src: dst.append(c)` 模式
|
||||||
|
4. **AI 三级** — L1 贪心 / L2 枚举 / L3 策略(待实现)
|
||||||
|
5. **出牌动画用 ghost 节点** — 临时挂到 `get_tree().root` 避免 FlowContainer 重排干扰
|
||||||
|
|
||||||
用 Godot 编辑器打开项目根目录 `game-cards/`。
|
## 配置 Autoload
|
||||||
|
|
||||||
### 2. 配置 Autoload(首次运行)
|
项目设置 → 自动加载 (Autoload):
|
||||||
|
|
||||||
进入 **项目 → 项目设置 → 自动加载 (Autoload)**,依次添加:
|
|
||||||
|
|
||||||
| 名称 | 路径 |
|
| 名称 | 路径 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -42,38 +100,42 @@ game-cards/
|
|||||||
| EventBus | `res://src/autoload/event_bus.gd` |
|
| EventBus | `res://src/autoload/event_bus.gd` |
|
||||||
| AudioManager | `res://src/autoload/audio_manager.gd` |
|
| AudioManager | `res://src/autoload/audio_manager.gd` |
|
||||||
|
|
||||||
### 3. 设置主场景
|
|
||||||
|
|
||||||
**项目 → 项目设置 → 运行 → 主场景** 设为 `res://src/ui/scenes/main_menu.tscn`
|
|
||||||
|
|
||||||
### 4. 启动
|
|
||||||
|
|
||||||
按 **F5** 或点击右上角 **运行项目**。
|
|
||||||
|
|
||||||
## 运行测试
|
## 运行测试
|
||||||
|
|
||||||
### 通过 GUT 运行(推荐)
|
|
||||||
|
|
||||||
1. 在 Godot AssetLib 中安装 **GUT** 插件并启用
|
|
||||||
2. 点击 GUT 面板 → 设置测试目录为 `res://tests`
|
|
||||||
3. 点击 **Run**
|
|
||||||
|
|
||||||
### 通过命令行
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# GUT(推荐)
|
||||||
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests
|
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 当前 `tests/` 仅有占位文件,无实际用例。
|
||||||
|
|
||||||
|
## 已知问题与限制
|
||||||
|
|
||||||
|
- 27+ 张牌的极端出牌(如 27 张单牌连出)`start_x` 可能为负,飞出左边界(实际不可能)
|
||||||
|
- CommentaryLabel y=360-380 与 TableLabel 底部 10px 视觉重叠
|
||||||
|
- L3 策略 AI 尚未实现
|
||||||
|
- 联机 / 观战 / 回放 均为占位
|
||||||
|
|
||||||
## 开发计划
|
## 开发计划
|
||||||
|
|
||||||
| 阶段 | 内容 | 状态 |
|
| 阶段 | 内容 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 训练模式 | 核心逻辑 + L1/L2 AI + 牌型提示 | 进行中 |
|
| 训练模式 | 核心 + L1/L2 AI + UI | **进行中** |
|
||||||
| 单机对战 | 完整四人局 + L3 AI | 待开发 |
|
| L3 策略 AI | 配合/防守/控牌策略 | 待开发 |
|
||||||
| 联机 | Action 同步 + 匹配 + 房间 | 待开发 |
|
| 完整对局 | 4 人完整出完一轮 | 待开发 |
|
||||||
| 观战 | Action 流回放 | 待开发 |
|
| 联机 | Action 同步 + 房间 | 待开发 |
|
||||||
|
| 观战 / 回放 | Action 流式回放 | 待开发 |
|
||||||
|
|
||||||
## 设计文档
|
## 设计文档
|
||||||
|
|
||||||
- [设计规格](./docs/superpowers/specs/2026-05-28-guandan-card-game-design.md)
|
- [主设计规格](./docs/superpowers/specs/2026-05-28-guandan-card-game-design.md)
|
||||||
- [实施计划](./docs/superpowers/plans/2026-05-28-guandan-training-mode.md)
|
- [训练模式实施计划](./docs/superpowers/plans/2026-05-28-guandan-training-mode.md)
|
||||||
|
- [手牌换行 + 出牌动画 + 点评设计](./docs/superpowers/specs/2026-06-02-hand-wrap-and-play-animation-design.md)
|
||||||
|
- [手牌换行 + 出牌动画实施计划](./docs/superpowers/plans/2026-06-02-hand-wrap-and-play-animation.md)
|
||||||
|
|
||||||
|
## 提交约定
|
||||||
|
|
||||||
|
- 单步可工作的 commit
|
||||||
|
- 类型前缀: `feat: / fix: / refactor: / docs: / chore:`
|
||||||
|
- 范围标注: `feat(ui): / fix(core):` 等
|
||||||
|
- 中英文皆可,遵循仓库历史风格(见 `git log --oneline`)
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ func get_partner(player_idx: int) -> int:
|
|||||||
return t.teammate_of(player_idx)
|
return t.teammate_of(player_idx)
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
func get_hand(player_idx: int) -> Array:
|
func get_hand(player_idx: int) -> Array[Card]:
|
||||||
return player_hands[player_idx]
|
return player_hands[player_idx] as Array[Card]
|
||||||
|
|
||||||
func remove_cards_from_hand(player_idx: int, cards: Array) -> void:
|
func remove_cards_from_hand(player_idx: int, cards: Array) -> void:
|
||||||
var hand: Array = player_hands[player_idx]
|
var hand: Array = player_hands[player_idx]
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ static func _gen_pairs(sorted: Array[Card], results: Array[HandEvaluator.Evaluat
|
|||||||
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
||||||
rank_counts[rk].append(c)
|
rank_counts[rk].append(c)
|
||||||
for rk in rank_counts:
|
for rk in rank_counts:
|
||||||
var cards: Array[Card] = rank_counts[rk]
|
var src: Array = rank_counts[rk]
|
||||||
|
var cards: Array[Card] = []
|
||||||
|
for c in src:
|
||||||
|
cards.append(c)
|
||||||
if cards.size() >= 2:
|
if cards.size() >= 2:
|
||||||
var ep := HandEvaluator.evaluate(cards.slice(0, 2), current_rank, config)
|
var ep := HandEvaluator.evaluate(cards.slice(0, 2), current_rank, config)
|
||||||
if ep != null: results.append(ep)
|
if ep != null: results.append(ep)
|
||||||
@@ -57,7 +60,10 @@ static func _gen_triples(sorted: Array[Card], results: Array[HandEvaluator.Evalu
|
|||||||
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
||||||
rank_counts[rk].append(c)
|
rank_counts[rk].append(c)
|
||||||
for rk in rank_counts:
|
for rk in rank_counts:
|
||||||
var cards: Array[Card] = rank_counts[rk]
|
var src: Array = rank_counts[rk]
|
||||||
|
var cards: Array[Card] = []
|
||||||
|
for c in src:
|
||||||
|
cards.append(c)
|
||||||
if cards.size() >= 3:
|
if cards.size() >= 3:
|
||||||
var ep := HandEvaluator.evaluate(cards.slice(0, 3), current_rank, config)
|
var ep := HandEvaluator.evaluate(cards.slice(0, 3), current_rank, config)
|
||||||
if ep != null: results.append(ep)
|
if ep != null: results.append(ep)
|
||||||
@@ -80,7 +86,10 @@ static func _gen_bombs(sorted: Array[Card], results: Array[HandEvaluator.Evaluat
|
|||||||
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
if not rank_counts.has(rk): rank_counts[rk] = [] as Array[Card]
|
||||||
rank_counts[rk].append(c)
|
rank_counts[rk].append(c)
|
||||||
for rk in rank_counts:
|
for rk in rank_counts:
|
||||||
var cards: Array[Card] = rank_counts[rk]
|
var src: Array = rank_counts[rk]
|
||||||
|
var cards: Array[Card] = []
|
||||||
|
for c in src:
|
||||||
|
cards.append(c)
|
||||||
if cards.size() >= 4:
|
if cards.size() >= 4:
|
||||||
var ep := HandEvaluator.evaluate(cards.slice(0, 4), current_rank, config)
|
var ep := HandEvaluator.evaluate(cards.slice(0, 4), current_rank, config)
|
||||||
if ep != null: results.append(ep)
|
if ep != null: results.append(ep)
|
||||||
@@ -120,8 +129,12 @@ static func _find_pairs(hand: Array[Card]) -> Array[Array]:
|
|||||||
if not rank_groups.has(rk): rank_groups[rk] = [] as Array[Card]
|
if not rank_groups.has(rk): rank_groups[rk] = [] as Array[Card]
|
||||||
rank_groups[rk].append(c)
|
rank_groups[rk].append(c)
|
||||||
for rk in rank_groups:
|
for rk in rank_groups:
|
||||||
if rank_groups[rk].size() >= 2:
|
var src: Array = rank_groups[rk]
|
||||||
result.append(rank_groups[rk].slice(0, 2) as Array[Card])
|
if src.size() >= 2:
|
||||||
|
var pair: Array[Card] = []
|
||||||
|
for c in src.slice(0, 2):
|
||||||
|
pair.append(c)
|
||||||
|
result.append(pair)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
static func _card_in(cards: Array[Card], target: Card) -> bool:
|
static func _card_in(cards: Array[Card], target: Card) -> bool:
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ signal card_double_clicked(card_node: CardNode)
|
|||||||
|
|
||||||
var card_data: Card = null
|
var card_data: Card = null
|
||||||
var is_selected: bool = false
|
var is_selected: bool = false
|
||||||
|
var _lift_tween: Tween = null
|
||||||
|
|
||||||
@onready var panel: Panel = $Panel
|
@onready var panel: Panel = $Panel
|
||||||
@onready var card_texture: TextureRect = $Panel/CardTexture
|
@onready var card_texture: TextureRect = $Panel/CardTexture
|
||||||
|
|
||||||
|
const LIFT_OFFSET := -25.0
|
||||||
|
const LIFT_DURATION := 0.12
|
||||||
const RANK_PREFIX := ["", "", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "SJ", "BJ"]
|
const RANK_PREFIX := ["", "", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "SJ", "BJ"]
|
||||||
const SUIT_LETTER := ["S", "H", "C", "D", "SJ", "BJ"]
|
const SUIT_LETTER := ["S", "H", "C", "D", "SJ", "BJ"]
|
||||||
const CARD_TEXTURE_PATH := "res://assets/cards/"
|
const CARD_TEXTURE_PATH := "res://assets/cards/"
|
||||||
@@ -68,7 +71,14 @@ func set_selected(sel: bool) -> void:
|
|||||||
is_selected = sel
|
is_selected = sel
|
||||||
_update_panel()
|
_update_panel()
|
||||||
|
|
||||||
func _on_gui_input(event: InputEvent) -> void:
|
if _lift_tween and _lift_tween.is_valid():
|
||||||
|
_lift_tween.kill()
|
||||||
|
_lift_tween = create_tween()
|
||||||
|
_lift_tween.set_trans(Tween.TRANS_QUAD)
|
||||||
|
_lift_tween.set_ease(Tween.EASE_OUT)
|
||||||
|
_lift_tween.tween_property(self, "position:y", LIFT_OFFSET if sel else 0.0, LIFT_DURATION)
|
||||||
|
|
||||||
|
func _gui_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseButton:
|
if event is InputEventMouseButton:
|
||||||
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||||
if event.double_click:
|
if event.double_click:
|
||||||
|
|||||||
@@ -4,3 +4,7 @@
|
|||||||
|
|
||||||
[node name="HandArea" type="FlowContainer"]
|
[node name="HandArea" type="FlowContainer"]
|
||||||
script = ExtResource("1_script")
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="TopSpacer" type="Control" parent="."]
|
||||||
|
custom_minimum_size = Vector2(0, 30)
|
||||||
|
mouse_filter = 2
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func _show_table_cards(cards: Array[Card]) -> void:
|
|||||||
sorted_cards.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0)
|
sorted_cards.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0)
|
||||||
|
|
||||||
var total_width := sorted_cards.size() * 60
|
var total_width := sorted_cards.size() * 60
|
||||||
var start_x := (1280 - total_width) / 2
|
var start_x := (1280.0 - total_width) / 2.0
|
||||||
|
|
||||||
for i in range(sorted_cards.size()):
|
for i in range(sorted_cards.size()):
|
||||||
var card: Card = sorted_cards[i]
|
var card: Card = sorted_cards[i]
|
||||||
|
|||||||
Reference in New Issue
Block a user