commit 035b2f7af93705683a1c29297d80c70130f4318f Author: hefanyang Date: Sun May 24 08:10:22 2026 +0800 游戏可以运行 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5d407ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# 战国卡牌 - 水墨风云 + +春秋战国主题卡牌对战游戏,基于 pygame 构建,国风水墨视觉风格。 + +## 运行方式 + +```bash +pip install -r requirements.txt +python -m card_game.main +``` + +## 架构 + +``` +card_game/ + config.py — 常量、水墨色板、卡牌数据库(80+张)、阵营定义、预设卡组 + card.py — Card 类(单位/指令卡) + deck.py — Deck 类(组牌、洗牌、抽牌) + factions.py — 阵营被动技能系统 + utils.py — 工具函数 + player.py — Player 类(手牌、营地、前线、HP、粮草) + ink_style.py — 水墨风格绘制原语(宣纸纹理、毛笔笔触、印章、卷轴等) + effects.py — 视觉特效(浮动文字、攻击线、墨花飞溅) + battlefield.py — 核心游戏逻辑(战斗、指令、回合管理) + ai.py — AI 决策(规则驱动) + ui.py — 所有 UI 渲染(使用 ink_style 水墨风格) + main.py — 主循环、状态机 +``` + +## 游戏规则 + +- 7 个阵营(战国七雄):秦、齐、楚、燕、韩、赵、魏 +- 30 张牌组,起始 4 张手牌,最多 8 张 +- 粮草系统:每回合获得 turn+1 粮草(上限 10) +- 战场:营地(5槽+都城) → 前线(5槽) → 对手 +- 胜利条件:将对方都城 HP 降为 0 diff --git a/card_game/__init__.py b/card_game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/card_game/__pycache__/__init__.cpython-312.pyc b/card_game/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..601a34a Binary files /dev/null and b/card_game/__pycache__/__init__.cpython-312.pyc differ diff --git a/card_game/__pycache__/ai.cpython-312.pyc b/card_game/__pycache__/ai.cpython-312.pyc new file mode 100644 index 0000000..abd2ed9 Binary files /dev/null and b/card_game/__pycache__/ai.cpython-312.pyc differ diff --git a/card_game/__pycache__/battlefield.cpython-312.pyc b/card_game/__pycache__/battlefield.cpython-312.pyc new file mode 100644 index 0000000..7f9e9ab Binary files /dev/null and b/card_game/__pycache__/battlefield.cpython-312.pyc differ diff --git a/card_game/__pycache__/card.cpython-312.pyc b/card_game/__pycache__/card.cpython-312.pyc new file mode 100644 index 0000000..9ab4b0d Binary files /dev/null and b/card_game/__pycache__/card.cpython-312.pyc differ diff --git a/card_game/__pycache__/config.cpython-312.pyc b/card_game/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..401d189 Binary files /dev/null and b/card_game/__pycache__/config.cpython-312.pyc differ diff --git a/card_game/__pycache__/deck.cpython-312.pyc b/card_game/__pycache__/deck.cpython-312.pyc new file mode 100644 index 0000000..ce7bfe2 Binary files /dev/null and b/card_game/__pycache__/deck.cpython-312.pyc differ diff --git a/card_game/__pycache__/effects.cpython-312.pyc b/card_game/__pycache__/effects.cpython-312.pyc new file mode 100644 index 0000000..7820abf Binary files /dev/null and b/card_game/__pycache__/effects.cpython-312.pyc differ diff --git a/card_game/__pycache__/factions.cpython-312.pyc b/card_game/__pycache__/factions.cpython-312.pyc new file mode 100644 index 0000000..469bb8b Binary files /dev/null and b/card_game/__pycache__/factions.cpython-312.pyc differ diff --git a/card_game/__pycache__/ink_style.cpython-312.pyc b/card_game/__pycache__/ink_style.cpython-312.pyc new file mode 100644 index 0000000..7910359 Binary files /dev/null and b/card_game/__pycache__/ink_style.cpython-312.pyc differ diff --git a/card_game/__pycache__/main.cpython-312.pyc b/card_game/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..6c5c71e Binary files /dev/null and b/card_game/__pycache__/main.cpython-312.pyc differ diff --git a/card_game/__pycache__/player.cpython-312.pyc b/card_game/__pycache__/player.cpython-312.pyc new file mode 100644 index 0000000..6106fc6 Binary files /dev/null and b/card_game/__pycache__/player.cpython-312.pyc differ diff --git a/card_game/__pycache__/ui.cpython-312.pyc b/card_game/__pycache__/ui.cpython-312.pyc new file mode 100644 index 0000000..26f88f7 Binary files /dev/null and b/card_game/__pycache__/ui.cpython-312.pyc differ diff --git a/card_game/__pycache__/utils.cpython-312.pyc b/card_game/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..deb7011 Binary files /dev/null and b/card_game/__pycache__/utils.cpython-312.pyc differ diff --git a/card_game/ai.py b/card_game/ai.py new file mode 100644 index 0000000..b278ff4 --- /dev/null +++ b/card_game/ai.py @@ -0,0 +1,148 @@ +"""AI player: simple rule-based decision making.""" + +from card_game.config import CARD_DATABASE + + +class AIPlayer: + def __init__(self, battlefield): + self.battlefield = battlefield + + def execute_turn(self): + actions = [] + ai = self.battlefield.ai + + actions.extend(self._play_untargeted_orders(ai)) + actions.extend(self._deploy_units(ai)) + actions.extend(self._play_targeted_orders(ai)) + actions.extend(self._move_units(ai)) + actions.extend(self._attack(ai)) + + return actions + + def _play_untargeted_orders(self, ai): + actions = [] + for card in ai.hand[:]: + if card.card_type != "order": + continue + if card.cost > ai.provisions: + continue + if not self.battlefield.needs_target(card): + self.battlefield.apply_order_effect(card, ai) + ai.play_order(card) + actions.append(("order", card)) + return actions + + def _deploy_units(self, ai): + actions = [] + units = [c for c in ai.hand if c.card_type == "unit" and ai.can_play_card(c)] + units.sort(key=lambda c: c.cost) + for card in units: + if not ai.can_play_card(card): + continue + from card_game.factions import apply_faction_passive + apply_faction_passive(card, ai.faction_id) + slot = ai.deploy_unit(card) + if slot >= 0: + actions.append(("deploy", card, slot)) + self._handle_deploy(card, ai) + return actions + + def _play_targeted_orders(self, ai): + actions = [] + for card in ai.hand[:]: + if card.card_type != "order": + continue + if card.cost > ai.provisions: + continue + if not self.battlefield.needs_target(card): + continue + targets = self.battlefield.get_valid_targets(card, ai) + if targets: + target = self._pick_best_target(card, targets, ai) + if target: + self.battlefield.apply_order_effect(card, ai, target) + ai.play_order(card) + actions.append(("order_targeted", card, target)) + return actions + + def _move_units(self, ai): + actions = [] + if not self.battlefield.can_move_to_frontline(ai): + return actions + for unit in ai.get_support_units(): + op_cost = ai._get_op_cost(unit) + if ai.provisions >= op_cost: + slot = ai.move_to_frontline(unit) + if slot >= 0: + if self.battlefield.frontline_controller is None: + self.battlefield.claim_frontline(ai) + actions.append(("move", unit, slot)) + return actions + + def _attack(self, ai): + actions = [] + player = self.battlefield.player + for unit in ai.get_frontline_units(): + if not unit.can_attack or unit.has_attacked: + continue + enemy_units = player.get_frontline_units() + if enemy_units: + killable = [u for u in enemy_units if u.current_hp <= unit.get_effective_attack()] + if killable: + target = min(killable, key=lambda u: u.current_hp) + else: + target = max(enemy_units, key=lambda u: u.get_effective_attack()) + dead = self.battlefield.resolve_attack(unit, target) + actions.append(("attack_unit", unit, target)) + else: + self.battlefield.attack_capital(unit) + actions.append(("attack_capital", unit)) + + # Ranged units in support attack + for unit in ai.get_support_units(): + if not unit.can_attack or unit.has_attacked or not unit.is_ranged(): + continue + enemy_units = player.get_frontline_units() + if enemy_units: + killable = [u for u in enemy_units if u.current_hp <= unit.get_effective_attack()] + target = min(killable, key=lambda u: u.current_hp) if killable else min(enemy_units, key=lambda u: u.current_hp) + self.battlefield.resolve_attack(unit, target) + actions.append(("attack_unit", unit, target)) + else: + self.battlefield.attack_capital(unit) + actions.append(("attack_capital", unit)) + + return actions + + def _handle_deploy(self, card, ai): + for ability in card.abilities: + if ability.startswith("draw_on_deploy:"): + count = int(ability.split(":")[1]) + for _ in range(count): + ai.draw_card() + elif ability.startswith("damage_on_deploy:"): + dmg = int(ability.split(":")[1]) + opponent = self.battlefield.get_opponent(ai) + targets = opponent.get_all_units() + if targets: + import random + target = random.choice(targets) + target.take_damage(dmg) + + def _pick_best_target(self, card, targets, ai): + if not targets: + return None + if card.effect_type == "destroy_damaged": + dmg_targets = [t for t in targets if hasattr(t, 'current_hp') and t.current_hp < t.max_hp] + if dmg_targets: + return min(dmg_targets, key=lambda u: u.current_hp) + return None + if card.effect_type == "bounce": + return max(targets, key=lambda u: u.get_effective_attack() if hasattr(u, 'get_effective_attack') else 0) + if card.effect_type in ("buff_single", "move_to_front"): + return max(targets, key=lambda u: u.get_effective_attack() if hasattr(u, 'get_effective_attack') else 0) + if card.effect_type == "damage": + unit_targets = [t for t in targets if hasattr(t, 'get_effective_attack')] + if unit_targets: + return max(unit_targets, key=lambda u: u.get_effective_attack()) + return targets[0] if targets else None diff --git a/card_game/battlefield.py b/card_game/battlefield.py new file mode 100644 index 0000000..2e16a46 --- /dev/null +++ b/card_game/battlefield.py @@ -0,0 +1,337 @@ +"""Battlefield: core game logic — combat, orders, turn management.""" + +from card_game.player import Player +from card_game.factions import apply_faction_passive + + +class Battlefield: + def __init__(self, player_faction, ai_faction): + self.player = Player(player_faction, is_ai=False) + self.ai = Player(ai_faction, is_ai=True) + self.turn_number = 0 + self.current_turn = "player" + self.game_over = False + self.winner = None + self.log = [] + self.pending_order = None + self.effects = [] + self.frontline_controller = None + + def start_game(self): + self.player.start_game() + self.ai.start_game() + self.turn_number = 1 + self.current_turn = "player" + self.frontline_controller = None + self.player.start_turn(self.turn_number) + + def get_active_player(self): + return self.player if self.current_turn == "player" else self.ai + + def get_opponent(self, player): + return self.ai if player == self.player else self.player + + # --- Frontline Control --- + + def can_move_to_frontline(self, player): + if self.frontline_controller is None: + return True + if player == self.player and self.frontline_controller == "player": + return True + if player == self.ai and self.frontline_controller == "ai": + return True + return False + + def claim_frontline(self, player): + if player == self.player: + self.frontline_controller = "player" + else: + self.frontline_controller = "ai" + + def _update_frontline_control(self): + if self.frontline_controller == "player": + if not self.player.get_frontline_units(): + self.frontline_controller = None + elif self.frontline_controller == "ai": + if not self.ai.get_frontline_units(): + self.frontline_controller = None + + # --- Turn Management --- + + def end_player_turn(self): + if self.current_turn != "player" or self.game_over: + return + self.current_turn = "ai" + + def start_ai_turn(self): + self.turn_number += 1 + self.ai.start_turn(self.turn_number) + self.current_turn = "ai" + + def end_ai_turn(self): + if self.game_over: + return + self.current_turn = "player" + self.turn_number += 1 + self.player.start_turn(self.turn_number) + + # --- Combat --- + + def resolve_attack(self, attacker, defender): + dead = [] + atk = attacker.get_effective_attack() + + if "siege" in attacker.abilities and defender == "capital": + atk *= 2 + + owner = self._get_unit_owner(attacker) + if (owner and owner.faction_id == "han" + and attacker.unit_type == "archer" and defender == "capital"): + atk += 1 + + if isinstance(defender, str) and defender == "capital": + target_player = self.get_opponent(owner) + target_player.capital_hp -= atk + self._add_effect("damage", target_player, "capital", atk) + self._check_game_over() + attacker.has_attacked = True + attacker.can_attack = False + return dead + + defender.take_damage(atk) + self._add_effect("damage", self._get_unit_owner(defender), defender, atk) + + if not defender.is_alive(): + dead.append(defender) + if owner and owner.faction_id == "qin": + owner.provisions += 1 + + if "no_retaliation" not in attacker.abilities: + if not (attacker.is_ranged() and attacker.zone == "support"): + retal = defender.get_effective_defense() if defender.is_alive() else 0 + if retal > 0: + attacker.take_damage(retal) + self._add_effect("damage", self._get_unit_owner(attacker), attacker, retal) + if not attacker.is_alive(): + dead.append(attacker) + + attacker.has_attacked = True + attacker.can_attack = False + self._cleanup_dead() + self._check_game_over() + return dead + + def attack_capital(self, attacker): + owner = self._get_unit_owner(attacker) + if not owner: + return + self.resolve_attack(attacker, "capital") + + # --- Order Effects --- + + def apply_order_effect(self, card, caster, target=None): + etype = card.effect_type + params = card.effect_params + opponent = self.get_opponent(caster) + + if etype == "damage": + dmg = params["damage"] + if target and hasattr(target, 'take_damage'): + target.take_damage(dmg) + self._add_effect("damage", self._get_unit_owner(target), target, dmg) + self._cleanup_dead() + return True + + elif etype == "damage_hq": + dmg = params["damage"] + opponent.capital_hp -= dmg + self._add_effect("damage", opponent, "capital", dmg) + self._check_game_over() + return True + + elif etype == "damage_all_front": + dmg = params["damage"] + tgt = params.get("target", "enemy") + if tgt == "enemy": + for u in opponent.get_frontline_units(): + u.take_damage(dmg) + self._add_effect("damage", opponent, u, dmg) + else: + for u in caster.get_frontline_units(): + u.take_damage(dmg) + self._add_effect("damage", caster, u, dmg) + self._cleanup_dead() + return True + + elif etype == "draw": + count = params["count"] + for _ in range(count): + caster.draw_card() + return True + + elif etype == "gain_provisions": + caster.provisions += params["amount"] + return True + + elif etype == "buff_all": + atk_b = params.get("attack_bonus", 0) + def_b = params.get("defense_bonus", 0) + dur = params.get("duration", 1) + for u in caster.get_all_units(): + u.buffs.append((atk_b, def_b, dur)) + return True + + elif etype == "buff_type": + unit_type = params["unit_type"] + atk_b = params.get("attack_bonus", 0) + def_b = params.get("defense_bonus", 0) + dur = params.get("duration", 1) + for u in caster.get_all_units(): + if u.unit_type == unit_type: + u.buffs.append((atk_b, def_b, dur)) + return True + + elif etype == "buff_single": + if target and hasattr(target, 'buffs'): + atk_b = params.get("attack_bonus", 0) + def_b = params.get("defense_bonus", 0) + dur = params.get("duration", 1) + target.buffs.append((atk_b, def_b, dur)) + return True + + elif etype == "heal_hq": + amount = params["amount"] + caster.capital_hp = min(caster.max_capital_hp, caster.capital_hp + amount) + return True + + elif etype == "bounce": + if target and hasattr(target, 'zone'): + owner = self._get_unit_owner(target) + if owner: + owner.remove_unit(target) + target.zone = "hand" + if len(owner.hand) < 8: + owner.hand.append(target) + return True + + elif etype == "destroy_damaged": + if target and hasattr(target, 'is_alive'): + if target.current_hp < target.max_hp: + owner = self._get_unit_owner(target) + if owner: + owner.remove_unit(target) + return True + + elif etype == "move_to_front": + if target and hasattr(target, 'zone') and target.zone == "support": + caster.move_to_frontline_free(target) + return True + + elif etype == "summon": + from card_game.card import Card + count = params.get("count", 1) + unit_id = params["unit_id"] + for _ in range(count): + new_card = Card(unit_id) + new_card.turn_played = caster.turn_number + placed = False + for i, s in enumerate(caster.support_line): + if s is None: + caster.support_line[i] = new_card + new_card.zone = "support" + new_card.slot = i + placed = True + break + if not placed: + break + return True + + elif etype == "heal_all": + amount = params.get("amount", 1) + for u in caster.get_all_units(): + u.current_hp = min(u.max_hp, u.current_hp + amount) + return True + + elif etype == "draw_self_damage": + count = params.get("count", 1) + self_damage = params.get("self_damage", 0) + for _ in range(count): + caster.draw_card() + if self_damage > 0: + caster.capital_hp -= self_damage + return True + + return False + + def needs_target(self, card): + etype = card.effect_type + if etype in ("damage", "bounce", "destroy_damaged", "move_to_front", "buff_single"): + return True + return False + + def get_valid_targets(self, card, caster): + etype = card.effect_type + opponent = self.get_opponent(caster) + params = card.effect_params + + if etype == "damage": + tt = params.get("target_type", "any") + targets = [] + if tt in ("any", "enemy_unit"): + targets.extend(opponent.get_all_units()) + if tt == "any": + targets.append(("capital", opponent)) + return targets + + elif etype == "bounce": + return opponent.get_frontline_units() + + elif etype == "destroy_damaged": + return [u for u in opponent.get_all_units() if u.current_hp < u.max_hp] + + elif etype == "move_to_front": + return [u for u in caster.get_support_units()] + + elif etype == "buff_single": + return caster.get_all_units() + + return [] + + # --- Helpers --- + + def _get_unit_owner(self, unit): + for u in self.player.get_all_units(): + if u is unit: + return self.player + for u in self.ai.get_all_units(): + if u is unit: + return self.ai + return None + + def _cleanup_dead(self): + self.player.cleanup_dead() + self.ai.cleanup_dead() + self._update_frontline_control() + + def _check_game_over(self): + if self.player.capital_hp <= 0: + self.game_over = True + self.winner = "ai" + elif self.ai.capital_hp <= 0: + self.game_over = True + self.winner = "player" + + def _add_effect(self, etype, target_player, target, value): + self.effects.append({ + "type": etype, + "target_player": target_player, + "target": target, + "value": value, + "timer": 60, + }) + + def update_effects(self): + for eff in self.effects[:]: + eff["timer"] -= 1 + if eff["timer"] <= 0: + self.effects.remove(eff) diff --git a/card_game/card.py b/card_game/card.py new file mode 100644 index 0000000..95fc47c --- /dev/null +++ b/card_game/card.py @@ -0,0 +1,87 @@ +"""Card class: represents a single card (unit or order).""" + +from card_game.config import CARD_DATABASE, FACTION_COLORS, KEYWORDS + + +class Card: + def __init__(self, card_id): + data = CARD_DATABASE[card_id] + self.id = data["id"] + self.name = data["name"] + self.faction = data["faction"] + self.card_type = data["type"] # "unit" or "order" + self.cost = data["cost"] + self.op_cost = data.get("op_cost", 0) + self.description = data["description"] + self.rarity = data["rarity"] + + # Unit-specific + self.unit_type = data.get("unit_type", None) + self.attack = data.get("attack", 0) + self.defense = data.get("defense", 0) + self.max_hp = data.get("max_hp", 0) + self.abilities = data.get("abilities", []) + + # Order-specific + self.effect_type = data.get("effect_type", None) + self.effect_params = data.get("effect_params", {}) + + # Runtime state + self.current_hp = self.max_hp + self.zone = "hand" # "hand", "support", "frontline" + self.slot = -1 + self.can_attack = False + self.has_moved = False + self.has_attacked = False + self.turn_played = 0 + + # Buff tracking: list of (attack_bonus, defense_bonus, turns_remaining) + self.buffs = [] + + def take_damage(self, amount): + self.current_hp -= amount + + def is_alive(self): + return self.current_hp > 0 + + def get_effective_attack(self): + bonus = sum(b[0] for b in self.buffs) + return self.attack + bonus + + def get_effective_defense(self): + bonus = sum(b[1] for b in self.buffs) + return self.defense + bonus + + def reset_turn_flags(self): + self.can_attack = False + self.has_moved = False + self.has_attacked = False + # Tick buffs + self.buffs = [(a, d, t - 1) for a, d, t in self.buffs if t > 1] + + def can_move_and_attack(self): + return "charge" in self.abilities + + def is_ranged(self): + return "ranged" in self.abilities + + def get_keywords(self): + result = [] + for ability in self.abilities: + base = ability.split(":")[0] + if base in KEYWORDS: + kw = KEYWORDS[base] + desc = kw["desc"] + if ":" in ability: + param = ability.split(":")[1] + desc = desc.replace("X", param) + result.append((kw["icon"], kw["name"], desc, kw["color"])) + return result + + def get_color(self): + if self.faction == "neutral": + return (110, 105, 95) + return FACTION_COLORS.get(self.faction, (128, 128, 128)) + + def __repr__(self): + return f"Card({self.name})" diff --git a/card_game/config.py b/card_game/config.py new file mode 100644 index 0000000..ec7d9c2 --- /dev/null +++ b/card_game/config.py @@ -0,0 +1,1588 @@ +"""All constants, colors, card data, faction data, and deck presets.""" + +# --- Window --- +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 720 +FPS = 60 + +# --- 国风水墨色板 (Chinese Ink Painting Palette) --- +# 基础色 +INK_BLACK = (20, 15, 10) # 墨黑 +PAPER_WHITE = (245, 235, 220) # 宣纸白 +GRAY = (128, 128, 128) +DARK_GRAY = (64, 60, 55) +LIGHT_GRAY = (192, 185, 175) + +# 传统色 +ZHU_HONG = (190, 50, 40) # 朱红 +SONGHUA_GREEN = (70, 140, 80) # 松花绿 +DIAN_BLUE = (50, 80, 140) # 靛蓝 +TENG_HUANG = (210, 170, 50) # 藤黄 +JIANG_BROWN = (140, 80, 40) # 酱褐 +QING_CYAN = (55, 120, 150) # 青色 +ZI_PURPLE = (100, 55, 140) # 紫色 +ORANGE = (200, 130, 45) # 橙 +GOLD = (210, 175, 55) # 金 +SILVER = (185, 180, 170) # 银 + +# 兼容旧名 +BLACK = INK_BLACK +WHITE = PAPER_WHITE +RED = ZHU_HONG +GREEN = SONGHUA_GREEN +BLUE = DIAN_BLUE +YELLOW = TENG_HUANG +BROWN = JIANG_BROWN +CYAN = QING_CYAN +PURPLE = ZI_PURPLE +DARK_RED = (139, 30, 30) +DARK_GREEN = (30, 90, 30) + +# 水墨渐变色阶 (从淡到浓) +INK_WASH_1 = (210, 200, 185) # 淡墨 +INK_WASH_2 = (175, 165, 150) # 轻墨 +INK_WASH_3 = (130, 120, 108) # 中墨 +INK_WASH_4 = (80, 72, 62) # 浓墨 +INK_WASH_5 = (35, 28, 20) # 焦墨 + +# 背景色 +BG_COLOR = (235, 225, 205) # 宣纸底色 +FIELD_COLOR = (225, 218, 198) # 淡宣纸 +FRONTLINE_COLOR = (215, 205, 185) # 前线区 +HIGHLIGHT_COLOR = (220, 200, 120, 128) +VALID_TARGET = (100, 200, 100, 100) + +# --- 阵营色 (Faction Colors - muted ink-wash tones) --- +FACTION_COLORS = { + "qin": (160, 50, 45), # 秦 - 朱砂 (Vermillion) + "qi": (65, 125, 70), # 齐 - 松绿 (Pine Green) + "chu": (100, 55, 130), # 楚 - 葡紫 (Grape Purple) + "yan": (55, 115, 145), # 燕 - 青瓷 (Celadon) + "han": (170, 135, 50), # 韩 - 古金 (Antique Gold) + "zhao": (165, 85, 40), # 赵 - 赭石 (Sienna) + "wei": (50, 70, 145), # 魏 - 靛蓝 (Indigo) + "neutral": (110, 105, 95), # 中立 - 墨灰 (Ink Gray) + "ally": (160, 140, 75), # 盟国 - 古铜 (Bronze) +} + +# --- Layout --- +ENEMY_INFO_HEIGHT = 40 +ENEMY_HAND_HEIGHT = 50 +ACTION_BAR_HEIGHT = 50 +HAND_HEIGHT = 120 + +BATTLEFIELD_AVAILABLE = WINDOW_HEIGHT - ENEMY_INFO_HEIGHT - ENEMY_HAND_HEIGHT - HAND_HEIGHT - ACTION_BAR_HEIGHT +ZONE_HEIGHT = BATTLEFIELD_AVAILABLE // 3 + +CARD_WIDTH = 80 +CARD_HEIGHT = 110 +FIELD_CARD_WIDTH = 70 +FIELD_CARD_HEIGHT = 95 +CAPITAL_WIDTH = 80 +CAPITAL_HEIGHT = 60 +HAND_CARD_SPACING = 85 +SLOT_SPACING = 85 + +ENEMY_SUPPORT_Y = ENEMY_INFO_HEIGHT + ENEMY_HAND_HEIGHT +FRONTLINE_Y = ENEMY_SUPPORT_Y + ZONE_HEIGHT +PLAYER_SUPPORT_Y = FRONTLINE_Y + ZONE_HEIGHT +PLAYER_HAND_Y = PLAYER_SUPPORT_Y + ZONE_HEIGHT +ACTION_BAR_Y = WINDOW_HEIGHT - ACTION_BAR_HEIGHT + +MAX_SUPPORT_SLOTS = 5 +MAX_FRONTLINE_SLOTS = 5 +MAX_HAND_SIZE = 8 + +# --- Game Rules --- +STARTING_CAPITAL_HP = 20 +MAX_PROVISIONS = 10 +DECK_SIZE = 30 +STARTING_HAND_SIZE = 4 +FATIGUE_START_DAMAGE = 1 + +# --- Rarity --- +RARITY_LIMITS = { + "common": 3, + "rare": 2, + "legendary": 1, +} + +# --- Unit Types --- +UNIT_TYPES = ["infantry", "cavalry", "chariot", "archer", "siege"] + +# ============================================================ +# CARD DATABASE +# ============================================================ +CARD_DATABASE = { + # ==================== 秦 (Qin) ==================== + "qin_tiesying": { + "id": "qin_tiesying", + "name": "铁鹰剑士", + "faction": "qin", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "qin_qiangnu": { + "id": "qin_qiangnu", + "name": "强弩手", + "faction": "qin", + "type": "unit", + "unit_type": "archer", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "qin_bubing": { + "id": "qin_bubing", + "name": "秦锐士", + "faction": "qin", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "qin_qibing": { + "id": "qin_qibing", + "name": "秦骑兵", + "faction": "qin", + "type": "unit", + "unit_type": "cavalry", + "cost": 4, + "op_cost": 1, + "attack": 4, + "defense": 2, + "max_hp": 2, + "description": "骑兵·冲锋", + "abilities": ["charge"], + "rarity": "common", + }, + "qin_gongcheng": { + "id": "qin_gongcheng", + "name": "攻城弩", + "faction": "qin", + "type": "unit", + "unit_type": "siege", + "cost": 5, + "op_cost": 2, + "attack": 3, + "defense": 1, + "max_hp": 3, + "description": "攻城·对都城双倍伤害", + "abilities": ["siege", "ranged"], + "rarity": "rare", + }, + "qin_shangyang": { + "id": "qin_shangyang", + "name": "商鞅变法", + "faction": "qin", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 1, "defense_bonus": 0, "duration": 1}, + "description": "所有友方单位+1攻击", + "rarity": "rare", + }, + "qin_lianheng": { + "id": "qin_lianheng", + "name": "连横", + "faction": "qin", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "damage_all_front", + "effect_params": {"damage": 2, "target": "enemy"}, + "description": "对所有敌方前线单位造成2伤害", + "rarity": "rare", + }, + "qin_jiancu": { + "id": "qin_jiancu", + "name": "剑卒突击", + "faction": "qin", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "damage", + "effect_params": {"damage": 2, "target_type": "any"}, + "description": "对一个单位造成2伤害", + "rarity": "common", + }, + "qin_shihuang": { + "id": "qin_shihuang", + "name": "始皇帝令", + "faction": "qin", + "type": "order", + "cost": 5, + "op_cost": 0, + "effect_type": "damage_hq", + "effect_params": {"damage": 5}, + "description": "对敌方都城造成5伤害", + "rarity": "legendary", + }, + + # ==================== 齐 (Qi) ==================== + "qi_jiji": { + "id": "qi_jiji", + "name": "齐技击", + "faction": "qi", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "qi_shangren": { + "id": "qi_shangren", + "name": "临淄商人", + "faction": "qi", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 1, + "defense": 2, + "max_hp": 3, + "description": "部署时:抽1牌", + "abilities": ["draw_on_deploy:1"], + "rarity": "common", + }, + "qi_gongshou": { + "id": "qi_gongshou", + "name": "齐弓手", + "faction": "qi", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "qi_tianqi": { + "id": "qi_tianqi", + "name": "天齐战车", + "faction": "qi", + "type": "unit", + "unit_type": "chariot", + "cost": 5, + "op_cost": 2, + "attack": 4, + "defense": 4, + "max_hp": 4, + "description": "战车·无视报复", + "abilities": ["no_retaliation"], + "rarity": "rare", + }, + "qi_tongshang": { + "id": "qi_tongshang", + "name": "通商宽农", + "faction": "qi", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "gain_provisions", + "effect_params": {"amount": 3}, + "description": "获得3粮草", + "rarity": "common", + }, + "qi_jixia": { + "id": "qi_jixia", + "name": "稷下学宫", + "faction": "qi", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "draw", + "effect_params": {"count": 2}, + "description": "抽2牌", + "rarity": "rare", + }, + "qi_sunbin": { + "id": "qi_sunbin", + "name": "孙膑兵法", + "faction": "qi", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "damage", + "effect_params": {"damage": 3, "target_type": "enemy_unit"}, + "description": "对一个敌方单位造成3伤害", + "rarity": "rare", + }, + "qi_fuguo": { + "id": "qi_fuguo", + "name": "富国强兵", + "faction": "qi", + "type": "order", + "cost": 4, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 1, "defense_bonus": 1, "duration": 1}, + "description": "所有友方单位+1/+1", + "rarity": "legendary", + }, + + # ==================== 楚 (Chu) ==================== + "chu_manbing": { + "id": "chu_manbing", + "name": "楚蛮兵", + "faction": "chu", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 4, + "max_hp": 4, + "description": "步兵·高防御", + "abilities": [], + "rarity": "common", + }, + "chu_wuyi": { + "id": "chu_wuyi", + "name": "巫医", + "faction": "chu", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 1, + "defense": 3, + "max_hp": 3, + "description": "回合开始时恢复友方单位1HP", + "abilities": ["heal_all:1"], + "rarity": "common", + }, + "chu_gongshou": { + "id": "chu_gongshou", + "name": "楚弓手", + "faction": "chu", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 3, + "max_hp": 3, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "chu_zhancha": { + "id": "chu_zhancha", + "name": "楚战车", + "faction": "chu", + "type": "unit", + "unit_type": "chariot", + "cost": 5, + "op_cost": 2, + "attack": 4, + "defense": 5, + "max_hp": 5, + "description": "战车·无视报复", + "abilities": ["no_retaliation"], + "rarity": "rare", + }, + "chu_gushou": { + "id": "chu_gushou", + "name": "固守", + "faction": "chu", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 0, "defense_bonus": 2, "duration": 1}, + "description": "所有友方单位+2防御", + "rarity": "common", + }, + "chu_cici": { + "id": "chu_cici", + "name": "楚辞鼓舞", + "faction": "chu", + "type": "order", + "cost": 4, + "op_cost": 0, + "effect_type": "heal_hq", + "effect_params": {"amount": 5}, + "description": "恢复都城5HP", + "rarity": "rare", + }, + "chu_dazhao": { + "id": "chu_dazhao", + "name": "楚国之怒", + "faction": "chu", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "damage_all_front", + "effect_params": {"damage": 1, "target": "enemy"}, + "description": "对所有敌方前线单位造成1伤害", + "rarity": "rare", + }, + "chu_xiangyu": { + "id": "chu_xiangyu", + "name": "霸王降世", + "faction": "chu", + "type": "order", + "cost": 6, + "op_cost": 0, + "effect_type": "summon", + "effect_params": {"unit_id": "chu_manbing", "count": 2, "zone": "support"}, + "description": "召唤2个楚蛮兵到营地", + "rarity": "legendary", + }, + + # ==================== 燕 (Yan) ==================== + "yan_qibing": { + "id": "yan_qibing", + "name": "燕骑", + "faction": "yan", + "type": "unit", + "unit_type": "cavalry", + "cost": 2, + "op_cost": 0, + "attack": 3, + "defense": 1, + "max_hp": 2, + "description": "骑兵·行动费用-1", + "abilities": ["charge"], + "rarity": "common", + }, + "yan_cike": { + "id": "yan_cike", + "name": "刺客", + "faction": "yan", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 4, + "defense": 1, + "max_hp": 1, + "description": "可攻击敌方营地单位", + "abilities": ["can_attack_support"], + "rarity": "rare", + }, + "yan_bubing": { + "id": "yan_bubing", + "name": "燕步兵", + "faction": "yan", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "yan_gongshou": { + "id": "yan_gongshou", + "name": "燕弓手", + "faction": "yan", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "yan_jingke": { + "id": "yan_jingke", + "name": "荆轲刺秦", + "faction": "yan", + "type": "order", + "cost": 4, + "op_cost": 0, + "effect_type": "damage_hq", + "effect_params": {"damage": 4}, + "description": "对敌方都城造成4伤害", + "rarity": "rare", + }, + "yan_jixing": { + "id": "yan_jixing", + "name": "急行军", + "faction": "yan", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "move_to_front", + "effect_params": {}, + "description": "将一个友方单位移至前线", + "rarity": "common", + }, + "yan_tuxi": { + "id": "yan_tuxi", + "name": "突袭", + "faction": "yan", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_type", + "effect_params": {"unit_type": "cavalry", "attack_bonus": 2, "defense_bonus": 0, "duration": 1}, + "description": "所有骑兵+2攻击", + "rarity": "common", + }, + "yan_yanzhao": { + "id": "yan_yanzhao", + "name": "燕昭王求贤", + "faction": "yan", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "draw", + "effect_params": {"count": 2}, + "description": "抽2牌", + "rarity": "rare", + }, + + # ==================== 韩 (Han) ==================== + "han_nubing": { + "id": "han_nubing", + "name": "韩弩兵", + "faction": "han", + "type": "unit", + "unit_type": "archer", + "cost": 2, + "op_cost": 1, + "attack": 1, + "defense": 3, + "max_hp": 3, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "han_shushi": { + "id": "han_shushi", + "name": "术士", + "faction": "han", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "部署时:对敌单位造成2伤害", + "abilities": ["damage_on_deploy:2"], + "rarity": "common", + }, + "han_jianbing": { + "id": "han_jianbing", + "name": "韩剑兵", + "faction": "han", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "han_qibing": { + "id": "han_qibing", + "name": "韩骑兵", + "faction": "han", + "type": "unit", + "unit_type": "cavalry", + "cost": 4, + "op_cost": 1, + "attack": 3, + "defense": 2, + "max_hp": 3, + "description": "骑兵·冲锋", + "abilities": ["charge"], + "rarity": "common", + }, + "han_weiwei": { + "id": "han_weiwei", + "name": "围魏救赵", + "faction": "han", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "bounce", + "effect_params": {"target_type": "enemy_front"}, + "description": "将一个敌方前线单位返回手牌", + "rarity": "rare", + }, + "han_fubing": { + "id": "han_fubing", + "name": "伏兵", + "faction": "han", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "destroy_damaged", + "effect_params": {}, + "description": "消灭一个受伤的敌方单位", + "rarity": "rare", + }, + "han_liannu": { + "id": "han_liannu", + "name": "连弩齐射", + "faction": "han", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "damage_all_front", + "effect_params": {"damage": 1, "target": "enemy"}, + "description": "对所有敌方前线单位造成1伤害", + "rarity": "common", + }, + "han_shenjian": { + "id": "han_shenjian", + "name": "神臂弓", + "faction": "han", + "type": "order", + "cost": 4, + "op_cost": 0, + "effect_type": "damage_hq", + "effect_params": {"damage": 4}, + "description": "对敌方都城造成4伤害", + "rarity": "legendary", + }, + + # ==================== 赵 (Zhao) ==================== + "zhao_bianqi": { + "id": "zhao_bianqi", + "name": "赵边骑", + "faction": "zhao", + "type": "unit", + "unit_type": "cavalry", + "cost": 3, + "op_cost": 1, + "attack": 4, + "defense": 2, + "max_hp": 2, + "description": "骑兵·冲锋", + "abilities": ["charge"], + "rarity": "common", + }, + "zhao_tieqi": { + "id": "zhao_tieqi", + "name": "铁骑", + "faction": "zhao", + "type": "unit", + "unit_type": "cavalry", + "cost": 5, + "op_cost": 2, + "attack": 5, + "defense": 3, + "max_hp": 4, + "description": "骑兵·无视报复", + "abilities": ["charge", "no_retaliation"], + "rarity": "rare", + }, + "zhao_bubing": { + "id": "zhao_bubing", + "name": "赵步兵", + "faction": "zhao", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "zhao_gongshou": { + "id": "zhao_gongshou", + "name": "赵弓骑", + "faction": "zhao", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "zhao_qixi": { + "id": "zhao_qixi", + "name": "奇袭", + "faction": "zhao", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_type", + "effect_params": {"unit_type": "cavalry", "attack_bonus": 2, "defense_bonus": 0, "duration": 1}, + "description": "所有骑兵+2攻击", + "rarity": "common", + }, + "zhao_wuling": { + "id": "zhao_wuling", + "name": "武灵王令", + "faction": "zhao", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 2, "defense_bonus": 0, "duration": 1}, + "description": "所有友方骑兵+2攻击", + "rarity": "rare", + }, + "zhao_chongfeng": { + "id": "zhao_chongfeng", + "name": "冲锋号令", + "faction": "zhao", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "move_to_front", + "effect_params": {}, + "description": "将一个友方单位移至前线", + "rarity": "common", + }, + "zhao_lianpo": { + "id": "zhao_lianpo", + "name": "廉颇之勇", + "faction": "zhao", + "type": "order", + "cost": 4, + "op_cost": 0, + "effect_type": "buff_single", + "effect_params": {"attack_bonus": 3, "defense_bonus": 3, "duration": 1}, + "description": "一个友方单位+3/+3", + "rarity": "legendary", + }, + + # ==================== 魏 (Wei) ==================== + "wei_wuzu": { + "id": "wei_wuzu", + "name": "魏武卒", + "faction": "wei", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 4, + "max_hp": 4, + "description": "步兵·高防御", + "abilities": [], + "rarity": "common", + }, + "wei_zhongzhuang": { + "id": "wei_zhongzhuang", + "name": "重装步兵", + "faction": "wei", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "wei_gongshou": { + "id": "wei_gongshou", + "name": "魏弓手", + "faction": "wei", + "type": "unit", + "unit_type": "archer", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "wei_qibing": { + "id": "wei_qibing", + "name": "魏骑兵", + "faction": "wei", + "type": "unit", + "unit_type": "cavalry", + "cost": 4, + "op_cost": 1, + "attack": 4, + "defense": 2, + "max_hp": 3, + "description": "骑兵·冲锋", + "abilities": ["charge"], + "rarity": "common", + }, + "wei_zhengjun": { + "id": "wei_zhengjun", + "name": "整军备战", + "faction": "wei", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_type", + "effect_params": {"unit_type": "infantry", "attack_bonus": 1, "defense_bonus": 1, "duration": 1}, + "description": "所有步兵+1/+1", + "rarity": "common", + }, + "wei_diaodu": { + "id": "wei_diaodu", + "name": "调度", + "faction": "wei", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "move_to_front", + "effect_params": {}, + "description": "将一个友方单位移至前线", + "rarity": "common", + }, + "wei_lianbao": { + "id": "wei_lianbao", + "name": "连环堡", + "faction": "wei", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 0, "defense_bonus": 2, "duration": 2}, + "description": "所有友方单位+2防御持续2回合", + "rarity": "rare", + }, + "wei_weiliaozi": { + "id": "wei_weiliaozi", + "name": "尉缭子兵法", + "faction": "wei", + "type": "order", + "cost": 5, + "op_cost": 0, + "effect_type": "damage_all_front", + "effect_params": {"damage": 3, "target": "enemy"}, + "description": "对所有敌方前线单位造成3伤害", + "rarity": "legendary", + }, + + # ==================== 中立牌 (Neutral) ==================== + "neutral_miliao": { + "id": "neutral_miliao", + "name": "密探", + "faction": "neutral", + "type": "unit", + "unit_type": "infantry", + "cost": 1, + "op_cost": 1, + "attack": 1, + "defense": 1, + "max_hp": 2, + "description": "部署时:抽1牌", + "abilities": ["draw_on_deploy:1"], + "rarity": "common", + }, + "neutral_yimin": { + "id": "neutral_yimin", + "name": "义民", + "faction": "neutral", + "type": "unit", + "unit_type": "infantry", + "cost": 1, + "op_cost": 1, + "attack": 1, + "defense": 2, + "max_hp": 2, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "neutral_liangcao": { + "id": "neutral_liangcao", + "name": "粮草补给", + "faction": "neutral", + "type": "order", + "cost": 0, + "op_cost": 0, + "effect_type": "gain_provisions", + "effect_params": {"amount": 2}, + "description": "获得2粮草", + "rarity": "common", + }, + + # ==================== 盟国 (Ally) ==================== + # --- 鲁 (Lu) --- + "ally_lu_dizi": { + "id": "ally_lu_dizi", + "name": "孔门弟子", + "faction": "ally", + "ally_state": "鲁", + "type": "unit", + "unit_type": "infantry", + "cost": 1, + "op_cost": 1, + "attack": 1, + "defense": 2, + "max_hp": 2, + "description": "部署时:抽1牌", + "abilities": ["draw_on_deploy:1"], + "rarity": "common", + }, + "ally_lu_liyue": { + "id": "ally_lu_liyue", + "name": "礼乐教化", + "faction": "ally", + "ally_state": "鲁", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_all", + "effect_params": {"attack_bonus": 0, "defense_bonus": 1, "duration": 1}, + "description": "所有友方+1防御", + "rarity": "common", + }, + "ally_lu_gongjiang": { + "id": "ally_lu_gongjiang", + "name": "鲁国工匠", + "faction": "ally", + "ally_state": "鲁", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "部署时:获得2粮草", + "abilities": ["gain_on_deploy:2"], + "rarity": "common", + }, + "ally_lu_xingtan": { + "id": "ally_lu_xingtan", + "name": "杏坛讲学", + "faction": "ally", + "ally_state": "鲁", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "draw", + "effect_params": {"count": 2}, + "description": "抽2牌", + "rarity": "rare", + }, + + # --- 宋 (Song) --- + "ally_song_shanggu": { + "id": "ally_song_shanggu", + "name": "商贾", + "faction": "ally", + "ally_state": "宋", + "type": "unit", + "unit_type": "infantry", + "cost": 1, + "op_cost": 1, + "attack": 1, + "defense": 2, + "max_hp": 2, + "description": "部署时:获得2粮草", + "abilities": ["gain_on_deploy:2"], + "rarity": "common", + }, + "ally_song_ren": { + "id": "ally_song_ren", + "name": "宋襄之仁", + "faction": "ally", + "ally_state": "宋", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "heal_hq", + "effect_params": {"amount": 3}, + "description": "恢复都城3HP", + "rarity": "common", + }, + "ally_song_chongji": { + "id": "ally_song_chongji", + "name": "宋国重骑", + "faction": "ally", + "ally_state": "宋", + "type": "unit", + "unit_type": "cavalry", + "cost": 4, + "op_cost": 1, + "attack": 4, + "defense": 2, + "max_hp": 2, + "description": "骑兵·冲锋", + "abilities": ["charge"], + "rarity": "rare", + }, + "ally_song_yishang": { + "id": "ally_song_yishang", + "name": "殷商遗礼", + "faction": "ally", + "ally_state": "宋", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "heal_all", + "effect_params": {"amount": 2}, + "description": "所有友方单位恢复2HP", + "rarity": "rare", + }, + + # --- 吴 (Wu) --- + "ally_wu_wugou": { + "id": "ally_wu_wugou", + "name": "吴钩武士", + "faction": "ally", + "ally_state": "吴", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 3, + "defense": 1, + "max_hp": 2, + "description": "步兵·高攻", + "abilities": [], + "rarity": "common", + }, + "ally_wu_sunwu": { + "id": "ally_wu_sunwu", + "name": "孙武兵法", + "faction": "ally", + "ally_state": "吴", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "damage_all_front", + "effect_params": {"damage": 2, "target": "enemy"}, + "description": "对所有敌方前线单位造成2伤害", + "rarity": "rare", + }, + "ally_wu_shuijun": { + "id": "ally_wu_shuijun", + "name": "吴国水军", + "faction": "ally", + "ally_state": "吴", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 3, + "max_hp": 3, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "ally_wu_wuzixu": { + "id": "ally_wu_wuzixu", + "name": "伍子胥复仇", + "faction": "ally", + "ally_state": "吴", + "type": "order", + "cost": 5, + "op_cost": 0, + "effect_type": "damage_hq", + "effect_params": {"damage": 4}, + "description": "对敌方都城造成4伤害", + "rarity": "legendary", + }, + + # --- 越 (Yue) --- + "ally_yue_nvjian": { + "id": "ally_yue_nvjian", + "name": "越女剑客", + "faction": "ally", + "ally_state": "越", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "步兵·冲锋", + "abilities": ["charge"], + "rarity": "common", + }, + "ally_yue_woxin": { + "id": "ally_yue_woxin", + "name": "卧薪尝胆", + "faction": "ally", + "ally_state": "越", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "draw_self_damage", + "effect_params": {"count": 1, "self_damage": 2}, + "description": "抽1牌,都城-2HP", + "rarity": "common", + }, + "ally_yue_sishi": { + "id": "ally_yue_sishi", + "name": "越国死士", + "faction": "ally", + "ally_state": "越", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 4, + "defense": 1, + "max_hp": 1, + "description": "步兵·无视报复", + "abilities": ["no_retaliation"], + "rarity": "rare", + }, + "ally_yue_jinggang": { + "id": "ally_yue_jinggang", + "name": "精钢剑", + "faction": "ally", + "ally_state": "越", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "buff_single", + "effect_params": {"attack_bonus": 2, "defense_bonus": 0, "duration": 1}, + "description": "一个友方单位+2攻击", + "rarity": "common", + }, + + # --- 郑 (Zheng) --- + "ally_zheng_xiangao": { + "id": "ally_zheng_xiangao", + "name": "弦高犒师", + "faction": "ally", + "ally_state": "郑", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "bounce", + "effect_params": {"target_type": "enemy_front"}, + "description": "将一个敌方前线单位返回手牌", + "rarity": "common", + }, + "ally_zheng_jianshi": { + "id": "ally_zheng_jianshi", + "name": "郑国剑士", + "faction": "ally", + "ally_state": "郑", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 2, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "ally_zheng_zichan": { + "id": "ally_zheng_zichan", + "name": "子产治郑", + "faction": "ally", + "ally_state": "郑", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "destroy_damaged", + "effect_params": {}, + "description": "消灭一个受伤的敌方单位", + "rarity": "rare", + }, + "ally_zheng_shangdui": { + "id": "ally_zheng_shangdui", + "name": "郑国商队", + "faction": "ally", + "ally_state": "郑", + "type": "unit", + "unit_type": "infantry", + "cost": 1, + "op_cost": 1, + "attack": 1, + "defense": 1, + "max_hp": 2, + "description": "部署时:获得3粮草", + "abilities": ["gain_on_deploy:3"], + "rarity": "common", + }, + + # --- 陈 (Chen) --- + "ally_chen_wuzhu": { + "id": "ally_chen_wuzhu", + "name": "宛丘巫祝", + "faction": "ally", + "ally_state": "陈", + "type": "unit", + "unit_type": "infantry", + "cost": 2, + "op_cost": 1, + "attack": 1, + "defense": 3, + "max_hp": 3, + "description": "回合开始时恢复友方1HP", + "abilities": ["heal_all:1"], + "rarity": "common", + }, + "ally_chen_maobing": { + "id": "ally_chen_maobing", + "name": "陈国矛兵", + "faction": "ally", + "ally_state": "陈", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 4, + "max_hp": 4, + "description": "步兵·高防御", + "abilities": [], + "rarity": "common", + }, + "ally_chen_wuyu": { + "id": "ally_chen_wuyu", + "name": "舞雩祭", + "faction": "ally", + "ally_state": "陈", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "heal_hq", + "effect_params": {"amount": 4}, + "description": "恢复都城4HP", + "rarity": "common", + }, + "ally_chen_wugu": { + "id": "ally_chen_wugu", + "name": "巫蛊之术", + "faction": "ally", + "ally_state": "陈", + "type": "order", + "cost": 3, + "op_cost": 0, + "effect_type": "damage", + "effect_params": {"damage": 3, "target_type": "enemy_unit"}, + "description": "对一个敌方单位造成3伤害", + "rarity": "rare", + }, + + # --- 蔡 (Cai) --- + "ally_cai_tongjian": { + "id": "ally_cai_tongjian", + "name": "蔡侯铜剑", + "faction": "ally", + "ally_state": "蔡", + "type": "order", + "cost": 2, + "op_cost": 0, + "effect_type": "damage", + "effect_params": {"damage": 3, "target_type": "enemy_unit"}, + "description": "对一个敌方单位造成3伤害", + "rarity": "common", + }, + "ally_cai_jingbing": { + "id": "ally_cai_jingbing", + "name": "蔡国精兵", + "faction": "ally", + "ally_state": "蔡", + "type": "unit", + "unit_type": "infantry", + "cost": 3, + "op_cost": 1, + "attack": 3, + "defense": 3, + "max_hp": 3, + "description": "步兵", + "abilities": [], + "rarity": "common", + }, + "ally_cai_shoushou": { + "id": "ally_cai_shoushou", + "name": "蔡国射手", + "faction": "ally", + "ally_state": "蔡", + "type": "unit", + "unit_type": "archer", + "cost": 3, + "op_cost": 1, + "attack": 2, + "defense": 2, + "max_hp": 2, + "description": "弓手·可从营地射击", + "abilities": ["ranged"], + "rarity": "common", + }, + "ally_cai_qingtong": { + "id": "ally_cai_qingtong", + "name": "青铜铸造", + "faction": "ally", + "ally_state": "蔡", + "type": "order", + "cost": 1, + "op_cost": 0, + "effect_type": "buff_single", + "effect_params": {"attack_bonus": 1, "defense_bonus": 1, "duration": 1}, + "description": "一个友方单位+1/+1", + "rarity": "common", + }, +} + +# ============================================================ +# KEYWORDS SYSTEM +# ============================================================ +KEYWORDS = { + "charge": {"name": "冲锋", "icon": "冲", "desc": "部署当回合可移动并攻击", "color": (200, 130, 45)}, + "ranged": {"name": "射击", "icon": "射", "desc": "可从营地攻击,不受报复", "color": (55, 115, 140)}, + "no_retaliation": {"name": "强袭", "icon": "强", "desc": "攻击时不受报复反击", "color": (170, 50, 40)}, + "siege": {"name": "攻城", "icon": "城", "desc": "对都城造成双倍伤害", "color": (160, 80, 40)}, + "draw_on_deploy": {"name": "部署抽牌", "icon": "抽", "desc": "部署时抽X张牌", "color": (50, 70, 140)}, + "damage_on_deploy": {"name": "部署伤害", "icon": "伤", "desc": "部署时对随机敌造成X伤害", "color": (180, 60, 50)}, + "gain_on_deploy": {"name": "部署获利", "icon": "获", "desc": "部署时获得X粮草", "color": (190, 160, 45)}, + "heal_all": {"name": "治疗", "icon": "愈", "desc": "回合开始恢复友方X HP", "color": (65, 130, 70)}, + "can_attack_support": {"name": "渗透", "icon": "渗", "desc": "可攻击敌方营地单位", "color": (90, 50, 130)}, +} + +# ============================================================ +# FACTION DEFINITIONS +# ============================================================ +FACTIONS = { + "qin": { + "id": "qin", + "name": "秦国", + "leader": "秦始皇", + "passive_id": "military_reward", + "passive_name": "军功爵制", + "passive_desc": "每消灭一个敌方单位,获得1粮草", + "capital_hp": 20, + }, + "qi": { + "id": "qi", + "name": "齐国", + "leader": "齐桓公", + "passive_id": "extra_provision", + "passive_name": "管仲之策", + "passive_desc": "每回合额外获得1粮草", + "capital_hp": 20, + }, + "chu": { + "id": "chu", + "name": "楚国", + "leader": "楚庄王", + "passive_id": "fortified_capital", + "passive_name": "楚国天险", + "passive_desc": "都城拥有25HP(而非20HP)", + "capital_hp": 25, + }, + "yan": { + "id": "yan", + "name": "燕国", + "leader": "燕昭王", + "passive_id": "cavalry_discount", + "passive_name": "突袭战术", + "passive_desc": "骑兵行动费用-1", + "capital_hp": 20, + }, + "han": { + "id": "han", + "name": "韩国", + "leader": "韩昭侯", + "passive_id": "archer_bonus", + "passive_name": "劲弩之术", + "passive_desc": "弓手对敌方都城额外造成1伤害", + "capital_hp": 20, + }, + "zhao": { + "id": "zhao", + "name": "赵国", + "leader": "赵武灵王", + "passive_id": "cavalry_power", + "passive_name": "胡服骑射", + "passive_desc": "骑兵+1攻击", + "capital_hp": 20, + }, + "wei": { + "id": "wei", + "name": "魏国", + "leader": "魏文侯", + "passive_id": "infantry_armor", + "passive_name": "魏武卒", + "passive_desc": "步兵+1防御", + "capital_hp": 20, + }, +} + +# ============================================================ +# DECK PRESETS (30 cards each) +# ============================================================ +DECK_PRESETS = { + "qin": { + "faction": "qin", + "cards": [ + "qin_tiesying", "qin_tiesying", "qin_tiesying", + "qin_qiangnu", "qin_qiangnu", "qin_qiangnu", + "qin_bubing", "qin_bubing", "qin_bubing", + "qin_qibing", "qin_qibing", + "qin_gongcheng", "qin_gongcheng", + "qin_shangyang", "qin_shangyang", + "qin_lianheng", + "qin_jiancu", "qin_jiancu", "qin_jiancu", + "qin_shihuang", + "neutral_miliao", "neutral_miliao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", + "neutral_liangcao", + ], + }, + "qi": { + "faction": "qi", + "cards": [ + "qi_jiji", "qi_jiji", "qi_jiji", + "qi_shangren", "qi_shangren", "qi_shangren", + "qi_gongshou", "qi_gongshou", "qi_gongshou", + "qi_tianqi", "qi_tianqi", + "qi_tongshang", "qi_tongshang", "qi_tongshang", + "qi_jixia", + "qi_sunbin", "qi_sunbin", + "qi_fuguo", + "neutral_miliao", "neutral_miliao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", "neutral_yimin", + "neutral_miliao", + "neutral_yimin", + ], + }, + "chu": { + "faction": "chu", + "cards": [ + "chu_manbing", "chu_manbing", "chu_manbing", + "chu_wuyi", "chu_wuyi", "chu_wuyi", + "chu_gongshou", "chu_gongshou", "chu_gongshou", + "chu_zhancha", "chu_zhancha", + "chu_gushou", "chu_gushou", "chu_gushou", + "chu_cici", + "chu_dazhao", "chu_dazhao", + "chu_xiangyu", + "neutral_miliao", "neutral_miliao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", "neutral_yimin", + "neutral_miliao", + "neutral_yimin", + ], + }, + "yan": { + "faction": "yan", + "cards": [ + "yan_qibing", "yan_qibing", "yan_qibing", + "yan_cike", "yan_cike", + "yan_bubing", "yan_bubing", "yan_bubing", + "yan_gongshou", "yan_gongshou", "yan_gongshou", + "yan_jingke", + "yan_jixing", "yan_jixing", "yan_jixing", + "yan_tuxi", "yan_tuxi", + "yan_yanzhao", "yan_yanzhao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_miliao", "neutral_miliao", + "neutral_miliao", + "neutral_yimin", "neutral_yimin", + ], + }, + "han": { + "faction": "han", + "cards": [ + "han_nubing", "han_nubing", "han_nubing", + "han_shushi", "han_shushi", "han_shushi", + "han_jianbing", "han_jianbing", "han_jianbing", + "han_qibing", "han_qibing", + "han_weiwei", "han_weiwei", + "han_fubing", + "han_liannu", "han_liannu", "han_liannu", + "han_shenjian", + "neutral_miliao", "neutral_miliao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", + "neutral_miliao", + "neutral_yimin", "neutral_yimin", + ], + }, + "zhao": { + "faction": "zhao", + "cards": [ + "zhao_bianqi", "zhao_bianqi", "zhao_bianqi", + "zhao_tieqi", "zhao_tieqi", + "zhao_bubing", "zhao_bubing", "zhao_bubing", + "zhao_gongshou", "zhao_gongshou", "zhao_gongshou", + "zhao_qixi", "zhao_qixi", "zhao_qixi", + "zhao_wuling", + "zhao_chongfeng", "zhao_chongfeng", + "zhao_lianpo", + "neutral_miliao", "neutral_miliao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", "neutral_yimin", + "neutral_miliao", + "neutral_yimin", + ], + }, + "wei": { + "faction": "wei", + "cards": [ + "wei_wuzu", "wei_wuzu", "wei_wuzu", + "wei_zhongzhuang", "wei_zhongzhuang", "wei_zhongzhuang", + "wei_gongshou", "wei_gongshou", "wei_gongshou", + "wei_qibing", "wei_qibing", + "wei_zhengjun", "wei_zhengjun", "wei_zhengjun", + "wei_diaodu", "wei_diaodu", "wei_diaodu", + "wei_lianbao", + "wei_weiliaozi", + "neutral_miliao", "neutral_miliao", + "neutral_liangcao", "neutral_liangcao", "neutral_liangcao", + "neutral_yimin", "neutral_yimin", "neutral_yimin", + "neutral_yimin", + "neutral_miliao", + "neutral_yimin", + ], + }, +} diff --git a/card_game/deck.py b/card_game/deck.py new file mode 100644 index 0000000..0ec42a3 --- /dev/null +++ b/card_game/deck.py @@ -0,0 +1,26 @@ +"""Deck class: build, shuffle, draw.""" + +import random +from card_game.card import Card + + +class Deck: + def __init__(self): + self.cards = [] + self.draw_pile = [] + + def build(self, card_id_list): + self.cards = [Card(cid) for cid in card_id_list] + self.draw_pile = list(self.cards) + random.shuffle(self.draw_pile) + + def draw(self): + if not self.draw_pile: + return None + return self.draw_pile.pop() + + def is_empty(self): + return len(self.draw_pile) == 0 + + def remaining(self): + return len(self.draw_pile) diff --git a/card_game/effects.py b/card_game/effects.py new file mode 100644 index 0000000..20452b3 --- /dev/null +++ b/card_game/effects.py @@ -0,0 +1,116 @@ +"""Visual effects: floating damage numbers, attack lines, ink splash.""" + +import math +import random + +import pygame + +from card_game.config import INK_BLACK, ZHU_HONG, TENG_HUANG + + +class FloatingText: + def __init__(self, x, y, text, color=ZHU_HONG, duration=60): + self.x = x + self.y = y + self.text = text + self.color = color + self.timer = duration + self.max_timer = duration + + def update(self): + self.y -= 0.5 + self.timer -= 1 + return self.timer > 0 + + def draw(self, surface, font): + alpha = min(255, int(255 * self.timer / self.max_timer)) + text_surf = font.render(self.text, True, self.color) + alpha_surf = pygame.Surface(text_surf.get_size(), pygame.SRCALPHA) + alpha_surf.fill((255, 255, 255, alpha)) + text_surf.blit(alpha_surf, (0, 0), special_flags=pygame.BLEND_RGBA_MULT) + surface.blit(text_surf, (self.x - text_surf.get_width() // 2, int(self.y))) + + +class AttackLine: + def __init__(self, x1, y1, x2, y2, duration=20): + self.x1, self.y1 = x1, y1 + self.x2, self.y2 = x2, y2 + self.timer = duration + self.max_timer = duration + + def update(self): + self.timer -= 1 + return self.timer > 0 + + def draw(self, surface): + progress = 1 - self.timer / self.max_timer + cx = self.x1 + (self.x2 - self.x1) * min(progress * 2, 1) + cy = self.y1 + (self.y2 - self.y1) * min(progress * 2, 1) + from card_game.ink_style import draw_brush_stroke + draw_brush_stroke(surface, (int(self.x1), int(self.y1)), + (int(cx), int(cy)), 3, TENG_HUANG, alpha=200) + + +class InkSplash: + def __init__(self, x, y, duration=30): + self.x = x + self.y = y + self.timer = duration + self.max_timer = duration + rng = random.Random(int(x * 100 + y)) + self.particles = [] + for _ in range(8): + angle = rng.uniform(0, 2 * math.pi) + speed = rng.uniform(1, 4) + size = rng.randint(3, 8) + self.particles.append((angle, speed, size)) + + def update(self): + self.timer -= 1 + return self.timer > 0 + + def draw(self, surface): + progress = 1 - self.timer / self.max_timer + alpha = max(0, int(200 * (1 - progress))) + + for angle, speed, size in self.particles: + dist = speed * progress * 30 + px = int(self.x + dist * math.cos(angle)) + py = int(self.y + dist * math.sin(angle)) + current_size = max(1, int(size * (1 - progress * 0.5))) + s = pygame.Surface((current_size * 2, current_size * 2), pygame.SRCALPHA) + pygame.draw.circle(s, (*INK_BLACK[:3], alpha), + (current_size, current_size), current_size) + surface.blit(s, (px - current_size, py - current_size)) + + +class EffectManager: + def __init__(self): + self.texts = [] + self.lines = [] + self.splashes = [] + + def add_damage(self, x, y, amount): + self.texts.append(FloatingText(x, y, f"-{amount}", ZHU_HONG)) + + def add_heal(self, x, y, amount): + self.texts.append(FloatingText(x, y, f"+{amount}", (70, 140, 80))) + + def add_attack_line(self, x1, y1, x2, y2): + self.lines.append(AttackLine(x1, y1, x2, y2)) + + def add_ink_splash(self, x, y): + self.splashes.append(InkSplash(x, y)) + + def update(self): + self.texts = [t for t in self.texts if t.update()] + self.lines = [l for l in self.lines if l.update()] + self.splashes = [s for s in self.splashes if s.update()] + + def draw(self, surface, font): + for line in self.lines: + line.draw(surface) + for splash in self.splashes: + splash.draw(surface) + for text in self.texts: + text.draw(surface, font) diff --git a/card_game/factions.py b/card_game/factions.py new file mode 100644 index 0000000..7a62bf7 --- /dev/null +++ b/card_game/factions.py @@ -0,0 +1,20 @@ +"""Faction ability system: applies passive bonuses.""" + + +def get_passive_bonus(faction_id, bonus_type): + """Get passive bonus value for a faction.""" + bonuses = { + "zhao": {"cavalry_attack": 1}, + "wei": {"infantry_defense": 1}, + } + return bonuses.get(faction_id, {}).get(bonus_type, 0) + + +def apply_faction_passive(unit, faction_id): + """Apply faction passive to a unit at deploy time.""" + if faction_id == "zhao" and unit.unit_type == "cavalry": + unit.attack += 1 + elif faction_id == "wei" and unit.unit_type == "infantry": + unit.defense += 1 + unit.max_hp += 1 + unit.current_hp += 1 diff --git a/card_game/ink_style.py b/card_game/ink_style.py new file mode 100644 index 0000000..bbc74bf --- /dev/null +++ b/card_game/ink_style.py @@ -0,0 +1,340 @@ +"""Ink painting style rendering primitives for Chinese aesthetic.""" + +import random +import math +import pygame + +from card_game.config import ( + WINDOW_WIDTH, WINDOW_HEIGHT, + INK_BLACK, PAPER_WHITE, ZHU_HONG, SONGHUA_GREEN, TENG_HUANG, GOLD, + BG_COLOR, INK_WASH_1, INK_WASH_2, INK_WASH_3, INK_WASH_4, INK_WASH_5, +) + +# Cached surfaces +_paper_texture = None +_mountain_layers = None + + +def init_cache(): + """Pre-generate cached surfaces. Call once after pygame.init().""" + global _paper_texture, _mountain_layers + _paper_texture = _generate_paper_texture(WINDOW_WIDTH, WINDOW_HEIGHT) + _mountain_layers = _generate_mountain_layers(WINDOW_WIDTH, WINDOW_HEIGHT) + + +def get_paper_texture(): + return _paper_texture + + +def get_mountain_layers(): + return _mountain_layers + + +def _generate_paper_texture(w, h): + """Generate a rice paper (宣纸) texture surface.""" + surf = pygame.Surface((w, h)) + surf.fill(BG_COLOR) + + # Add subtle noise + rng = random.Random(42) + for y in range(0, h, 2): + for x in range(0, w, 2): + noise = rng.gauss(0, 6) + r = min(255, max(0, int(BG_COLOR[0] + noise))) + g = min(255, max(0, int(BG_COLOR[1] + noise))) + b = min(255, max(0, int(BG_COLOR[2] + noise))) + surf.set_at((x, y), (r, g, b)) + if x + 1 < w: + surf.set_at((x + 1, y), (r, g, b)) + if y + 1 < h: + surf.set_at((x, y + 1), (r, g, b)) + if x + 1 < w and y + 1 < h: + surf.set_at((x + 1, y + 1), (r, g, b)) + + # Add fiber lines + for _ in range(40): + x1 = rng.randint(0, w) + y1 = rng.randint(0, h) + length = rng.randint(30, 150) + angle = rng.uniform(0, math.pi) + color = (rng.randint(200, 220), rng.randint(185, 205), rng.randint(160, 180)) + points = [] + for i in range(10): + t = i / 9 + px = int(x1 + length * t * math.cos(angle) + rng.gauss(0, 2)) + py = int(y1 + length * t * math.sin(angle) + rng.gauss(0, 2)) + points.append((px, py)) + if len(points) >= 2: + pygame.draw.lines(surf, color, False, points, 1) + + return surf + + +def _generate_mountain_layers(w, h): + """Generate 3 layers of misty mountain silhouettes.""" + rng = random.Random(123) + layers = [] + layer_colors = [ + (INK_WASH_1[0], INK_WASH_1[1], INK_WASH_1[2], 60), + (INK_WASH_2[0], INK_WASH_2[1], INK_WASH_2[2], 45), + (INK_WASH_3[0], INK_WASH_3[1], INK_WASH_3[2], 30), + ] + base_heights = [h * 0.55, h * 0.62, h * 0.70] + + for i, (color, base_y) in enumerate(zip(layer_colors, base_heights)): + surf = pygame.Surface((w, h), pygame.SRCALPHA) + points = [(0, h)] + x = 0 + while x <= w: + freq1 = rng.uniform(0.003, 0.008) + freq2 = rng.uniform(0.01, 0.02) + amp1 = rng.uniform(30, 60) + amp2 = rng.uniform(10, 25) + y = base_y + amp1 * math.sin(x * freq1 + i) + amp2 * math.sin(x * freq2 + i * 2) + points.append((x, int(y))) + x += rng.randint(8, 20) + points.append((w, h)) + + if len(points) >= 3: + pygame.draw.polygon(surf, color, points) + layers.append(surf) + + return layers + + +def blit_paper_background(surface): + """Blit the cached paper texture onto the surface.""" + if _paper_texture: + surface.blit(_paper_texture, (0, 0)) + else: + surface.fill(BG_COLOR) + + +def blit_mountains(surface): + """Blit mountain layers onto the surface.""" + if _mountain_layers: + for layer in _mountain_layers: + surface.blit(layer, (0, 0)) + + +def draw_ink_rect(surface, rect, color, alpha=255, border_radius=0): + """Draw a rectangle with slightly wobbly brush-stroke edges.""" + x, y, w, h = rect if isinstance(rect, (list, tuple)) else (rect.x, rect.y, rect.w, rect.h) + + s = pygame.Surface((w, h), pygame.SRCALPHA) + base_color = (*color[:3], alpha) + + # Draw base rect with slight gradient + for col in range(w): + gradient = 1.0 - 0.1 * (col / max(w, 1)) + r = min(255, max(0, int(color[0] * gradient))) + g = min(255, max(0, int(color[1] * gradient))) + b = min(255, max(0, int(color[2] * gradient))) + pygame.draw.line(s, (r, g, b, alpha), (col, 0), (col, h - 1)) + + # Feather edges + rng = random.Random(x * 1000 + y) + for edge_x in range(min(3, w)): + for edge_y in range(h): + if rng.random() < 0.3: + s.set_at((edge_x, edge_y), (0, 0, 0, 0)) + if rng.random() < 0.3: + s.set_at((w - 1 - edge_x, edge_y), (0, 0, 0, 0)) + for edge_y in range(min(3, h)): + for edge_x in range(w): + if rng.random() < 0.3: + s.set_at((edge_x, edge_y), (0, 0, 0, 0)) + if rng.random() < 0.3: + s.set_at((edge_x, h - 1 - edge_y), (0, 0, 0, 0)) + + surface.blit(s, (x, y)) + + +def draw_ink_circle(surface, center, radius, color, alpha=200): + """Draw a circle with irregular ink-wash edges.""" + cx, cy = center + size = radius * 2 + 4 + s = pygame.Surface((size, size), pygame.SRCALPHA) + scx, scy = size // 2, size // 2 + + rng = random.Random(cx * 100 + cy) + n = max(12, radius) + for i in range(n): + angle = 2 * math.pi * i / n + r = radius + rng.gauss(0, max(1, radius * 0.08)) + px = int(scx + r * math.cos(angle)) + py = int(scy + r * math.sin(angle)) + pygame.draw.circle(s, (*color[:3], alpha), (px, py), max(2, int(radius * 0.4))) + + # Fill center + pygame.draw.circle(s, (*color[:3], alpha), (scx, scy), max(1, radius - 2)) + + surface.blit(s, (cx - size // 2, cy - size // 2)) + + +def draw_brush_stroke(surface, start, end, width, color, alpha=180): + """Draw a thick-to-thin brush stroke line.""" + x1, y1 = start + x2, y2 = end + dx, dy = x2 - x1, y2 - y1 + length = math.sqrt(dx * dx + dy * dy) + if length < 1: + return + + steps = max(int(length / 2), 4) + rng = random.Random(int(x1 * 100 + y1)) + + # Perpendicular direction for jitter + nx, ny = -dy / length, dx / length + + for i in range(steps): + t = i / (steps - 1) + # Width tapers: starts thin, peaks middle, ends thin + w = width * (1 - abs(2 * t - 1) * 0.6) * (0.8 + 0.2 * rng.random()) + px = x1 + dx * t + nx * rng.gauss(0, 1.5) + py = y1 + dy * t + ny * rng.gauss(0, 1.5) + a = max(50, int(alpha * (0.7 + 0.3 * rng.random()))) + circle_s = pygame.Surface((int(w * 2 + 4), int(w * 2 + 4)), pygame.SRCALPHA) + pygame.draw.circle(circle_s, (*color[:3], a), + (int(w + 2), int(w + 2)), max(1, int(w))) + surface.blit(circle_s, (int(px - w - 2), int(py - w - 2))) + + +def draw_seal_stamp(surface, rect, text, font, color=None): + """Draw a traditional Chinese seal (印章): red square with white text.""" + if color is None: + color = ZHU_HONG + x, y, w, h = rect if isinstance(rect, (list, tuple)) else (rect.x, rect.y, rect.w, rect.h) + + s = pygame.Surface((w, h), pygame.SRCALPHA) + + # Red background with slight irregularity + rng = random.Random(x * 100 + y + w) + pygame.draw.rect(s, color, (0, 0, w, h)) + # Feather edges slightly + for edge in range(2): + for i in range(w): + if rng.random() < 0.25: + s.set_at((i, edge), (0, 0, 0, 0)) + s.set_at((i, h - 1 - edge), (0, 0, 0, 0)) + for i in range(h): + if rng.random() < 0.25: + s.set_at((edge, i), (0, 0, 0, 0)) + s.set_at((w - 1 - edge, i), (0, 0, 0, 0)) + + # White border + pygame.draw.rect(s, (255, 255, 255), (0, 0, w, h), 2) + + # Text in white + text_surf = font.render(text, True, (255, 255, 255)) + tx = (w - text_surf.get_width()) // 2 + ty = (h - text_surf.get_height()) // 2 + s.blit(text_surf, (tx, ty)) + + surface.blit(s, (x, y)) + + +def draw_scroll(surface, rect, scroll_color=None): + """Draw a scroll/卷轴 shape with wooden rollers.""" + if scroll_color is None: + scroll_color = (210, 195, 170) + x, y, w, h = rect if isinstance(rect, (list, tuple)) else (rect.x, rect.y, rect.w, rect.h) + + roller_h = 6 + roller_color = (140, 90, 50) + + # Top roller + pygame.draw.rect(surface, roller_color, (x - 4, y, w + 8, roller_h), border_radius=3) + pygame.draw.rect(surface, (100, 65, 35), (x - 4, y, w + 8, roller_h), 1, border_radius=3) + + # Paper body + body_rect = pygame.Rect(x, y + roller_h, w, h - roller_h * 2) + draw_ink_rect(surface, body_rect, scroll_color, alpha=240) + + # Bottom roller + by = y + h - roller_h + pygame.draw.rect(surface, roller_color, (x - 4, by, w + 8, roller_h), border_radius=3) + pygame.draw.rect(surface, (100, 65, 35), (x - 4, by, w + 8, roller_h), 1, border_radius=3) + + +def draw_ink_text(surface, text, pos, font, color, shadow=True): + """Render text with a subtle ink bleed shadow.""" + if shadow: + shadow_color = (max(0, color[0] - 40), max(0, color[1] - 40), max(0, color[2] - 40)) + shadow_surf = font.render(text, True, shadow_color) + surface.blit(shadow_surf, (pos[0] + 1, pos[1] + 1)) + text_surf = font.render(text, True, color) + surface.blit(text_surf, pos) + return text_surf + + +def draw_cloud_pattern(surface, rect): + """Draw traditional Chinese cloud motifs (祥云) as decoration.""" + x, y, w, h = rect if isinstance(rect, (list, tuple)) else (rect.x, rect.y, rect.w, rect.h) + color = (*INK_WASH_2[:3], 80) + + s = pygame.Surface((w, h), pygame.SRCALPHA) + rng = random.Random(x * 7 + y) + n_clouds = max(1, w // 80) + + for i in range(n_clouds): + cx = rng.randint(10, w - 10) + cy = rng.randint(5, h - 5) + # Simple cloud: overlapping arcs + for j in range(3): + r = rng.randint(6, 12) + ox = j * 8 - 8 + oy = rng.randint(-3, 3) + pygame.draw.circle(s, color, (cx + ox, cy + oy), r) + + surface.blit(s, (x, y)) + + +def draw_zone_bg(surface, rect, base_color, accent_color=None): + """Draw a battlefield zone background with ink wash effect.""" + x, y, w, h = rect if isinstance(rect, (list, tuple)) else (rect.x, rect.y, rect.w, rect.h) + + s = pygame.Surface((w, h), pygame.SRCALPHA) + + # Base fill + s.fill((*base_color[:3], 120)) + + # Subtle horizontal brush strokes + rng = random.Random(y) + for _ in range(5): + by = rng.randint(0, h) + bx = rng.randint(0, w // 4) + bw = rng.randint(w // 3, w) + bh = rng.randint(2, 6) + bc = (*INK_WASH_2[:3], rng.randint(15, 35)) + pygame.draw.rect(s, bc, (bx, by, bw, bh)) + + surface.blit(s, (x, y)) + + # Border line + if accent_color: + pygame.draw.line(surface, accent_color, (x, y + h - 1), (x + w, y + h - 1), 1) + + +def draw_ink_hp_bar(surface, x, y, w, h, ratio, bg_color=None): + """Draw an HP bar with ink brush style.""" + if bg_color is None: + bg_color = INK_WASH_4 + pygame.draw.rect(surface, bg_color, (x, y, w, h)) + + if ratio > 0: + bar_w = max(1, int(w * ratio)) + if ratio > 0.5: + bar_color = SONGHUA_GREEN + elif ratio > 0.25: + bar_color = TENG_HUANG + else: + bar_color = ZHU_HONG + + # Brush-stroke bar + rng = random.Random(x * 100 + y) + for px in range(bar_w): + thickness = h - rng.randint(0, 1) + pygame.draw.line(surface, bar_color, + (x + px, y + (h - thickness) // 2), + (x + px, y + (h + thickness) // 2)) diff --git a/card_game/main.py b/card_game/main.py new file mode 100644 index 0000000..c9a455e --- /dev/null +++ b/card_game/main.py @@ -0,0 +1,549 @@ +"""Game class: main loop, state machine, event handling.""" + +import sys +import os +import json +import random +import pygame +from card_game.config import WINDOW_WIDTH, WINDOW_HEIGHT, FPS, TENG_HUANG, INK_WASH_3 +from card_game.battlefield import Battlefield +from card_game.ui import UI +from card_game.ai import AIPlayer +from card_game.factions import apply_faction_passive +from card_game import ink_style + +SAVED_DECKS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "saved_decks") + + +class Game: + def __init__(self): + pygame.init() + self.screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) + pygame.display.set_caption("战国卡牌 - 水墨风云") + self.clock = pygame.time.Clock() + + # Initialize ink style cache + ink_style.init_cache() + + self.ui = UI(self.screen) + self.ai_player = None + + self.state = "menu" + self.battlefield = None + self.player_faction = None + self.ai_faction = None + self.custom_deck = None + self._load_custom_deck() + self.custom_deck = None + + # AI turn timing + self.ai_timer = 0 + self.ai_step = 0 + + def _load_custom_deck(self): + if not self.player_faction: + return + path = os.path.join(SAVED_DECKS_DIR, f"{self.player_faction}.json") + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if len(data.get("cards", [])) == 30: + self.custom_deck = data["cards"] + except Exception: + self.custom_deck = None + + def _save_custom_deck(self, faction_id, cards): + os.makedirs(SAVED_DECKS_DIR, exist_ok=True) + path = os.path.join(SAVED_DECKS_DIR, f"{faction_id}.json") + try: + with open(path, "w", encoding="utf-8") as f: + json.dump({"faction": faction_id, "cards": cards}, f, ensure_ascii=False, indent=2) + self.custom_deck = list(cards) + except Exception: + pass + + def run(self): + while True: + try: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + sys.exit() + elif event.type == pygame.MOUSEBUTTONDOWN: + if event.button == 1: + self._handle_click(event.pos) + elif event.button == 3: + self._handle_right_click(event.pos) + elif event.type == pygame.MOUSEMOTION: + self._handle_hover(event.pos) + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + self._handle_escape() + + self._update() + self._draw() + pygame.display.flip() + self.clock.tick(FPS) + except Exception as e: + import traceback + traceback.print_exc() + self.screen.fill((0, 0, 0)) + font = pygame.font.SysFont("microsoftyahei", 16) + lines = traceback.format_exc().split('\n') + for i, line in enumerate(lines[:15]): + surf = font.render(line[:80], True, (255, 80, 80)) + self.screen.blit(surf, (10, 10 + i * 20)) + pygame.display.flip() + waiting = True + while waiting: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + sys.exit() + if event.type == pygame.KEYDOWN: + waiting = False + + # --- State: Menu --- + + def _handle_click_menu(self, pos): + fid = self.ui.get_faction_at(pos) + if fid: + self.player_faction = fid + self.custom_deck = None + self._load_custom_deck() + self.state = "deck_select" + + # --- State: Deck Select --- + + def _handle_click_deck_select(self, pos): + if "back" in self.ui.menu_buttons and self.ui.menu_buttons["back"].collidepoint(pos): + self.state = "menu" + return + if "deck_build" in self.ui.menu_buttons and self.ui.menu_buttons["deck_build"].collidepoint(pos): + self.ui.deck_builder_cards = [] + self.ui.deck_builder_faction = self.player_faction + self.state = "deck_build" + return + fid = self.ui.get_faction_at(pos) + if fid and fid != self.player_faction: + self.ai_faction = fid + self._start_game() + + # --- State: Deck Build --- + + def _handle_click_deck_build(self, pos): + from card_game.config import DECK_SIZE, RARITY_LIMITS, CARD_DATABASE + + if "back" in self.ui.menu_buttons and self.ui.menu_buttons["back"].collidepoint(pos): + self.state = "deck_select" + return + if "clear" in self.ui.menu_buttons and self.ui.menu_buttons["clear"].collidepoint(pos): + self.ui.deck_builder_cards = [] + return + if "preset" in self.ui.menu_buttons and self.ui.menu_buttons["preset"].collidepoint(pos): + from card_game.config import DECK_PRESETS + self.ui.deck_builder_cards = list(DECK_PRESETS[self.player_faction]["cards"]) + return + if "confirm" in self.ui.menu_buttons and self.ui.menu_buttons["confirm"].collidepoint(pos): + if len(self.ui.deck_builder_cards) == DECK_SIZE: + self._save_custom_deck(self.player_faction, self.ui.deck_builder_cards) + self.state = "deck_select" + return + cid = self.ui.get_deck_card_at(pos) + if cid: + card_data = CARD_DATABASE[cid] + in_deck = self.ui.deck_builder_cards.count(cid) + max_copies = RARITY_LIMITS.get(card_data["rarity"], 3) + if in_deck < max_copies and len(self.ui.deck_builder_cards) < DECK_SIZE: + self.ui.deck_builder_cards.append(cid) + return + cid = self.ui.get_deck_build_card_at(pos) + if cid: + card_data = CARD_DATABASE[cid] + in_deck = self.ui.deck_builder_cards.count(cid) + max_copies = RARITY_LIMITS.get(card_data["rarity"], 3) + if in_deck < max_copies and len(self.ui.deck_builder_cards) < DECK_SIZE: + self.ui.deck_builder_cards.append(cid) + + def _handle_right_click(self, pos): + if self.state != "deck_build": + return + cid = self.ui.get_deck_card_at(pos) + if cid and cid in self.ui.deck_builder_cards: + self.ui.deck_builder_cards.remove(cid) + return + cid = self.ui.get_deck_build_card_at(pos) + if cid and cid in self.ui.deck_builder_cards: + self.ui.deck_builder_cards.remove(cid) + + def _start_game(self): + self.battlefield = Battlefield(self.player_faction, self.ai_faction) + if self.custom_deck: + self.battlefield.player.deck.build(self.custom_deck) + self.battlefield.player.hand = [] + for _ in range(4): + self.battlefield.player.draw_card() + self.battlefield.start_game() + self.ai_player = AIPlayer(self.battlefield) + self.ui.clear_selection() + self.state = "playing" + + # --- State: Playing --- + + def _handle_click_playing(self, pos): + bf = self.battlefield + player = bf.player + + if self.ui.end_turn_btn.collidepoint(pos): + self.ui.clear_selection() + self._start_ai_turn() + return + + if self.ui.target_mode == "order_target" and self.ui.selected_card: + self._handle_order_target_click(pos) + return + + if self.ui.target_mode == "deploy" and self.ui.selected_card: + if self._handle_deploy_click(pos): + return + self.ui.clear_selection() + return + + if self.ui.target_mode == "move" and self.ui.selected_unit: + self._handle_move_click(pos) + return + + if self.ui.selected_unit and self.ui.target_mode == "attack": + self._handle_attack_click(pos) + return + + card = self.ui.get_hand_card_at(pos, player) + if card: + self._select_hand_card(card) + return + + unit, owner = self.ui.get_field_unit_at(pos, bf) + if unit and owner == player: + self._select_own_unit(unit) + return + + self.ui.clear_selection() + + def _select_hand_card(self, card): + player = self.battlefield.player + self.ui.clear_selection() + + if not player.can_play_card(card): + return + + if card.card_type == "unit": + self.ui.selected_card = card + self.ui.target_mode = "deploy" + self.ui.valid_targets = [] + elif card.card_type == "order": + if self.battlefield.needs_target(card): + self.ui.selected_card = card + self.ui.target_mode = "order_target" + self.ui.valid_targets = self.battlefield.get_valid_targets(card, player) + else: + self.battlefield.apply_order_effect(card, player) + player.play_order(card) + self.ui.clear_selection() + + def _select_own_unit(self, unit): + self.ui.clear_selection() + player = self.battlefield.player + owner = self.battlefield._get_unit_owner(unit) + if owner != player: + return + + opponent = self.battlefield.get_opponent(player) + + if unit.zone == "support": + if unit.can_attack and not unit.has_attacked and unit.is_ranged(): + self.ui.selected_unit = unit + self.ui.target_mode = "attack" + targets = list(opponent.get_frontline_units()) + targets.append(("capital", opponent)) + self.ui.valid_targets = targets + return + if self.battlefield.can_move_to_frontline(player): + op_cost = player._get_op_cost(unit) + if player.provisions >= op_cost: + self.ui.selected_unit = unit + self.ui.target_mode = "move" + elif unit.zone == "frontline": + if unit.can_attack and not unit.has_attacked: + self.ui.selected_unit = unit + self.ui.target_mode = "attack" + targets = list(opponent.get_frontline_units()) + targets.append(("capital", opponent)) + self.ui.valid_targets = targets + + def _handle_deploy_click(self, pos): + from card_game.config import PLAYER_SUPPORT_Y, ZONE_HEIGHT + player = self.battlefield.player + card = self.ui.selected_card + if not card: + return False + + mx, my = pos + in_support_zone = (PLAYER_SUPPORT_Y <= my <= PLAYER_SUPPORT_Y + ZONE_HEIGHT) + + if in_support_zone: + slot = self.ui.get_support_slot_at(pos, player) + if slot < 0: + for i, s in enumerate(player.support_line): + if s is None: + slot = i + break + if slot >= 0: + apply_faction_passive(card, player.faction_id) + player.deploy_unit(card, slot) + self._handle_deploy_abilities(card, player) + self.ui.clear_selection() + return True + return False + + def _handle_order_target_click(self, pos): + card = self.ui.selected_card + player = self.battlefield.player + if not card: + self.ui.clear_selection() + return + + unit, owner = self.ui.get_field_unit_at(pos, self.battlefield) + if unit and unit in self.ui.valid_targets: + self.battlefield.apply_order_effect(card, player, unit) + player.play_order(card) + self.ui.clear_selection() + return + + if self.ui.get_enemy_capital_at(pos, self.battlefield.ai): + self.battlefield.apply_order_effect(card, player, "capital") + player.play_order(card) + self.ui.clear_selection() + + def _handle_move_click(self, pos): + player = self.battlefield.player + unit = self.ui.selected_unit + if not unit: + self.ui.clear_selection() + return + + if not self.battlefield.can_move_to_frontline(player): + self.ui.clear_selection() + return + + slot = self.ui.get_frontline_slot_at(pos, player) + if slot >= 0: + result_slot = player.move_to_frontline(unit) + if result_slot >= 0: + if self.battlefield.frontline_controller is None: + self.battlefield.claim_frontline(player) + self.ui.clear_selection() + else: + self.ui.clear_selection() + + def _handle_attack_click(self, pos): + unit = self.ui.selected_unit + if not unit: + self.ui.clear_selection() + return + + bf = self.battlefield + player = bf.player + opponent = bf.ai + valid = self.ui.valid_targets + + target_unit, target_owner = self.ui.get_field_unit_at(pos, bf) + if target_unit and target_owner == opponent and target_unit in valid: + dead = bf.resolve_attack(unit, target_unit) + self._add_attack_effect(unit, target_unit) + self.ui.clear_selection() + return + + if self.ui.get_enemy_capital_at(pos, opponent): + cap_target = ("capital", opponent) + if cap_target in valid: + bf.attack_capital(unit) + self.ui.clear_selection() + return + + self.ui.clear_selection() + + def _handle_deploy_abilities(self, card, player): + for ability in card.abilities: + if ability.startswith("draw_on_deploy:"): + count = int(ability.split(":")[1]) + for _ in range(count): + player.draw_card() + elif ability.startswith("damage_on_deploy:"): + dmg = int(ability.split(":")[1]) + import random + opponent = self.battlefield.get_opponent(player) + targets = opponent.get_all_units() + if targets: + target = random.choice(targets) + target.take_damage(dmg) + elif ability.startswith("gain_on_deploy:"): + amount = int(ability.split(":")[1]) + player.provisions += amount + + def _add_attack_effect(self, attacker, target): + from card_game.ui import _support_slot_x, _frontline_slot_x + from card_game.config import (FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT, ZONE_HEIGHT, + ENEMY_SUPPORT_Y, FRONTLINE_Y, PLAYER_SUPPORT_Y, WINDOW_WIDTH, + MAX_FRONTLINE_SLOTS, CAPITAL_WIDTH) + half = ZONE_HEIGHT // 2 + n_fl = MAX_FRONTLINE_SLOTS + + def _unit_pos(unit): + owner = self.battlefield._get_unit_owner(unit) + is_ai = (owner == self.battlefield.ai) + if unit.zone == "support": + x = _support_slot_x(unit.slot) + FIELD_CARD_WIDTH // 2 + zone_y = ENEMY_SUPPORT_Y if is_ai else PLAYER_SUPPORT_Y + y = zone_y + ZONE_HEIGHT // 2 + else: + x = _frontline_slot_x(unit.slot, n_fl) + FIELD_CARD_WIDTH // 2 + zone_y = FRONTLINE_Y if is_ai else (FRONTLINE_Y + half) + y = zone_y + half // 2 + return x, y + + try: + if hasattr(attacker, 'zone'): + ax, ay = _unit_pos(attacker) + else: + ax, ay = WINDOW_WIDTH // 2, ENEMY_SUPPORT_Y + ZONE_HEIGHT // 2 + + if hasattr(target, 'zone'): + tx, ty = _unit_pos(target) + else: + tx = WINDOW_WIDTH // 2 + ty = ENEMY_SUPPORT_Y + ZONE_HEIGHT // 2 + + self.ui.effects.add_attack_line(ax, ay, tx, ty) + self.ui.effects.add_damage(tx, ty - 20, attacker.get_effective_attack()) + except Exception: + pass + + # --- State: AI Turn --- + + def _start_ai_turn(self): + self.state = "ai_turn" + try: + self.battlefield.start_ai_turn() + self.ai_actions = self.ai_player.execute_turn() + except Exception as e: + import traceback + traceback.print_exc() + self.ai_actions = [] + self.ai_step = 0 + self.ai_timer = 20 + + def _update_ai_turn(self): + if self.ai_timer > 0: + self.ai_timer -= 1 + return + + if self.ai_step >= len(self.ai_actions): + try: + self.battlefield.end_ai_turn() + except Exception: + pass + if self.battlefield.game_over: + self.state = "game_over" + else: + self.state = "playing" + return + + action = self.ai_actions[self.ai_step] + self.ai_step += 1 + self.ai_timer = 15 + + if action[0] in ("attack_unit", "attack_capital"): + attacker = action[1] + try: + if len(action) > 2: + self._add_attack_effect(attacker, action[2]) + else: + self._add_attack_effect(attacker, "capital") + except Exception: + pass + + # --- Hover --- + + def _handle_hover(self, pos): + if self.state not in ("playing", "ai_turn"): + return + if not self.battlefield: + return + player = self.battlefield.player + card = self.ui.get_hand_card_at(pos, player) + self.ui.hover_card = card + unit, owner = self.ui.get_field_unit_at(pos, self.battlefield) + self.ui.hover_field_unit = unit + self.ui.hover_pos = pos + + # --- Escape --- + + def _handle_escape(self): + self.ui.clear_selection() + if self.state in ("playing", "ai_turn"): + self.state = "menu" + self.battlefield = None + + # --- State: Game Over --- + + def _handle_click_game_over(self, pos): + if "restart" in self.ui.menu_buttons and self.ui.menu_buttons["restart"].collidepoint(pos): + self._start_game() + elif "menu" in self.ui.menu_buttons and self.ui.menu_buttons["menu"].collidepoint(pos): + self.state = "menu" + self.battlefield = None + + # --- Unified handlers --- + + def _handle_click(self, pos): + if self.state == "menu": + self._handle_click_menu(pos) + elif self.state == "deck_select": + self._handle_click_deck_select(pos) + elif self.state == "deck_build": + self._handle_click_deck_build(pos) + elif self.state == "playing": + self._handle_click_playing(pos) + elif self.state == "game_over": + self._handle_click_game_over(pos) + + def _update(self): + if self.state == "ai_turn": + self._update_ai_turn() + if self.battlefield: + self.battlefield.update_effects() + self.ui.effects.update() + + def _draw(self): + if self.state == "menu": + self.ui.draw_menu() + elif self.state == "deck_select": + self.ui.draw_deck_select(self.player_faction) + elif self.state == "deck_build": + self.ui.draw_deck_builder(self.player_faction) + elif self.state in ("playing", "ai_turn"): + self.ui.draw_game(self.battlefield) + if self.state == "ai_turn": + from card_game.config import WINDOW_WIDTH, ENEMY_INFO_HEIGHT + text = self.ui.font_md.render("对手回合...", True, INK_WASH_3) + self.screen.blit(text, (WINDOW_WIDTH // 2 - text.get_width() // 2, + ENEMY_INFO_HEIGHT + 55)) + elif self.state == "game_over": + self.ui.draw_game(self.battlefield) + self.ui.draw_game_over(self.battlefield.winner, self.battlefield) + + +if __name__ == "__main__": + game = Game() + game.run() diff --git a/card_game/player.py b/card_game/player.py new file mode 100644 index 0000000..ada2569 --- /dev/null +++ b/card_game/player.py @@ -0,0 +1,187 @@ +"""Player class: manages hand, support line, frontline, HP, provisions.""" + +from card_game.config import ( + STARTING_CAPITAL_HP, MAX_PROVISIONS, MAX_HAND_SIZE, + MAX_SUPPORT_SLOTS, MAX_FRONTLINE_SLOTS, DECK_SIZE, STARTING_HAND_SIZE, + FATIGUE_START_DAMAGE, FACTIONS, DECK_PRESETS, +) +from card_game.deck import Deck +from card_game.card import Card + + +class Player: + def __init__(self, faction_id, is_ai=False): + faction = FACTIONS[faction_id] + self.faction_id = faction_id + self.faction = faction + self.is_ai = is_ai + + self.capital_hp = faction["capital_hp"] + self.max_capital_hp = faction["capital_hp"] + self.provisions = 0 + self.max_provisions = 0 + + self.hand = [] + self.support_line = [None] * MAX_SUPPORT_SLOTS + self.frontline = [None] * MAX_FRONTLINE_SLOTS + + self.deck = Deck() + self.fatigue_damage = 0 + self.turn_number = 0 + + def build_deck(self): + preset = DECK_PRESETS[self.faction_id] + self.deck.build(preset["cards"]) + + def start_game(self): + self.build_deck() + for _ in range(STARTING_HAND_SIZE): + self.draw_card() + + def start_turn(self, turn_number): + self.turn_number = turn_number + gained = min(turn_number + 1, MAX_PROVISIONS) + self.provisions = gained + self.max_provisions = gained + + # Faction passive: Qi extra provision + if self.faction_id == "qi": + self.provisions += 1 + + self.draw_card() + + # Reset unit flags + for unit in self.get_all_units(): + unit.reset_turn_flags() + if unit.zone == "frontline" and unit.turn_played < turn_number: + unit.can_attack = True + if unit.zone == "support" and unit.is_ranged() and unit.turn_played < turn_number: + unit.can_attack = True + + # Chu healer + if self.faction_id == "chu": + self._apply_heal_ability() + + def draw_card(self): + if len(self.hand) >= MAX_HAND_SIZE: + return + card = self.deck.draw() + if card: + card.zone = "hand" + self.hand.append(card) + else: + self.fatigue_damage += 1 + self.capital_hp -= self.fatigue_damage + + def can_play_card(self, card): + if card.cost > self.provisions: + return False + if card.card_type == "unit": + return any(s is None for s in self.support_line) + return True + + def deploy_unit(self, card, slot=-1): + if slot < 0: + for i, s in enumerate(self.support_line): + if s is None: + slot = i + break + if slot < 0 or slot >= len(self.support_line): + return None + self.support_line[slot] = card + card.zone = "support" + card.slot = slot + card.turn_played = self.turn_number + self.provisions -= card.cost + self.hand.remove(card) + return slot + + def play_order(self, card): + self.provisions -= card.cost + self.hand.remove(card) + + def move_to_frontline(self, unit): + op_cost = self._get_op_cost(unit) + if self.provisions < op_cost: + return -1 + slot = -1 + for i, s in enumerate(self.frontline): + if s is None: + slot = i + break + if slot < 0: + return -1 + self.support_line[unit.slot] = None + self.frontline[slot] = unit + unit.zone = "frontline" + unit.slot = slot + unit.has_moved = True + self.provisions -= op_cost + if unit.turn_played < self.turn_number: + if unit.can_move_and_attack(): + unit.can_attack = True + return slot + + def move_to_frontline_free(self, unit): + slot = -1 + for i, s in enumerate(self.frontline): + if s is None: + slot = i + break + if slot < 0: + return -1 + self.support_line[unit.slot] = None + self.frontline[slot] = unit + unit.zone = "frontline" + unit.slot = slot + unit.has_moved = True + if unit.turn_played < self.turn_number: + unit.can_attack = True + return slot + + def remove_unit(self, unit): + if unit.zone == "support": + if 0 <= unit.slot < len(self.support_line): + self.support_line[unit.slot] = None + elif unit.zone == "frontline": + if 0 <= unit.slot < len(self.frontline): + self.frontline[unit.slot] = None + + def get_all_units(self): + units = [] + for u in self.support_line: + if u is not None: + units.append(u) + for u in self.frontline: + if u is not None: + units.append(u) + return units + + def get_frontline_units(self): + return [u for u in self.frontline if u is not None] + + def get_support_units(self): + return [u for u in self.support_line if u is not None] + + def _get_op_cost(self, unit): + cost = unit.op_cost + if self.faction_id == "yan" and unit.unit_type == "cavalry": + cost = max(0, cost - 1) + return cost + + def _apply_heal_ability(self): + healers = [u for u in self.get_all_units() if "heal_all:1" in u.abilities] + if healers: + for unit in self.get_all_units(): + if unit.current_hp < unit.max_hp: + unit.current_hp = min(unit.max_hp, unit.current_hp + 1) + + def cleanup_dead(self): + for i in range(len(self.support_line)): + u = self.support_line[i] + if u is not None and not u.is_alive(): + self.support_line[i] = None + for i in range(len(self.frontline)): + u = self.frontline[i] + if u is not None and not u.is_alive(): + self.frontline[i] = None diff --git a/card_game/ui.py b/card_game/ui.py new file mode 100644 index 0000000..9f47124 --- /dev/null +++ b/card_game/ui.py @@ -0,0 +1,776 @@ +"""UI: all rendering — battlefield, cards, menus, HUD. Chinese ink painting style.""" + +import pygame +from card_game.config import ( + WINDOW_WIDTH, WINDOW_HEIGHT, + INK_BLACK, PAPER_WHITE, GRAY, DARK_GRAY, LIGHT_GRAY, + ZHU_HONG, SONGHUA_GREEN, DIAN_BLUE, TENG_HUANG, GOLD, SILVER, + ORANGE, JIANG_BROWN, + BG_COLOR, FIELD_COLOR, FRONTLINE_COLOR, + FACTION_COLORS, RARITY_LIMITS, + HAND_HEIGHT, ACTION_BAR_HEIGHT, + ZONE_HEIGHT, CARD_WIDTH, CARD_HEIGHT, + FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT, + CAPITAL_WIDTH, CAPITAL_HEIGHT, + ENEMY_SUPPORT_Y, FRONTLINE_Y, PLAYER_SUPPORT_Y, PLAYER_HAND_Y, ACTION_BAR_Y, + MAX_SUPPORT_SLOTS, MAX_FRONTLINE_SLOTS, + ENEMY_INFO_HEIGHT, ENEMY_HAND_HEIGHT, + FACTIONS, CARD_DATABASE, + SLOT_SPACING, HAND_CARD_SPACING, + INK_WASH_2, INK_WASH_3, INK_WASH_4, INK_WASH_5, +) +from card_game.effects import EffectManager +from card_game import ink_style + +# Derived layout +HAND_Y = PLAYER_HAND_Y + 5 + + +def _centered_x(n_items, item_w, spacing): + total = n_items * item_w + (n_items - 1) * (spacing - item_w) + return (WINDOW_WIDTH - total) // 2 + + +def _support_slot_x(slot_index): + cap_x = WINDOW_WIDTH // 2 - CAPITAL_WIDTH // 2 + gap = 10 + if slot_index < 2: + return cap_x - gap - FIELD_CARD_WIDTH - (1 - slot_index) * SLOT_SPACING + else: + right_start = cap_x + CAPITAL_WIDTH + gap + return right_start + (slot_index - 2) * SLOT_SPACING + + +def _frontline_slot_x(slot_index, n_slots): + start = _centered_x(n_slots, FIELD_CARD_WIDTH, SLOT_SPACING) + return start + slot_index * SLOT_SPACING + + +class UI: + def __init__(self, screen): + self.screen = screen + # Use SimHei (黑体) for Chinese text - clean and always available on Windows + self.font_sm = pygame.font.SysFont("simhei", 14) + self.font_md = pygame.font.SysFont("simhei", 18) + self.font_lg = pygame.font.SysFont("simhei", 24) + self.font_xl = pygame.font.SysFont("simhei", 36) + self.effects = EffectManager() + + # Interaction state + self.selected_card = None + self.selected_unit = None + self.hover_card = None + self.hover_field_unit = None + self.hover_pos = (0, 0) + self.valid_targets = [] + self.target_mode = None + + # Button rects + self.end_turn_btn = pygame.Rect(0, 0, 0, 0) + self.faction_buttons = [] + self.menu_buttons = {} + + # Deck builder state + self.deck_builder_cards = [] + self.deck_builder_faction = None + self.deck_card_rects = [] + self.deck_build_rects = [] + + # --- Main Draw --- + + def draw_menu(self): + ink_style.blit_paper_background(self.screen) + ink_style.blit_mountains(self.screen) + + # Title in seal stamp style + title_text = "战国卡牌" + title_w, title_h = 320, 70 + title_x = WINDOW_WIDTH // 2 - title_w // 2 + title_y = 60 + ink_style.draw_seal_stamp(self.screen, (title_x, title_y, title_w, title_h), + title_text, self.font_xl) + + # Subtitle + sub = self.font_lg.render("七 雄 争 霸", True, INK_WASH_3) + self.screen.blit(sub, (WINDOW_WIDTH // 2 - sub.get_width() // 2, 145)) + + # Faction buttons as scroll shapes + self.faction_buttons = [] + factions = list(FACTIONS.keys()) + cols = 4 + bw, bh = 240, 90 + start_x = (WINDOW_WIDTH - cols * (bw + 20)) // 2 + start_y = 200 + + for i, fid in enumerate(factions): + row, col = divmod(i, cols) + x = start_x + col * (bw + 20) + y = start_y + row * (bh + 20) + rect = pygame.Rect(x, y, bw, bh) + self.faction_buttons.append((rect, fid)) + + color = FACTION_COLORS[fid] + # Draw as a scroll with faction color tint + ink_style.draw_scroll(self.screen, (x, y, bw, bh), + scroll_color=(min(255, color[0] + 80), + min(255, color[1] + 80), + min(255, color[2] + 80))) + + # Faction name + name = self.font_lg.render(FACTIONS[fid]["name"], True, INK_BLACK) + self.screen.blit(name, (x + bw // 2 - name.get_width() // 2, y + 12)) + + # Passive name + passive = self.font_sm.render(FACTIONS[fid]["passive_name"], True, TENG_HUANG) + self.screen.blit(passive, (x + bw // 2 - passive.get_width() // 2, y + 45)) + + # Passive desc (truncated) + desc = self.font_sm.render(FACTIONS[fid]["passive_desc"][:16], True, INK_WASH_3) + self.screen.blit(desc, (x + bw // 2 - desc.get_width() // 2, y + 68)) + + inst = self.font_sm.render("选择你的国家开始游戏", True, INK_WASH_3) + self.screen.blit(inst, (WINDOW_WIDTH // 2 - inst.get_width() // 2, WINDOW_HEIGHT - 60)) + + def draw_deck_select(self, player_faction): + ink_style.blit_paper_background(self.screen) + ink_style.blit_mountains(self.screen) + + title = self.font_xl.render("选择对手", True, INK_BLACK) + self.screen.blit(title, (WINDOW_WIDTH // 2 - title.get_width() // 2, 40)) + + your_faction = self.font_md.render(f"你的国家:{FACTIONS[player_faction]['name']}", True, INK_WASH_4) + self.screen.blit(your_faction, (WINDOW_WIDTH // 2 - your_faction.get_width() // 2, 90)) + + self.faction_buttons = [] + factions = [f for f in FACTIONS.keys() if f != player_faction] + cols = 3 + bw, bh = 300, 100 + start_x = (WINDOW_WIDTH - cols * (bw + 20)) // 2 + start_y = 140 + + for i, fid in enumerate(factions): + row, col = divmod(i, cols) + x = start_x + col * (bw + 20) + y = start_y + row * (bh + 20) + rect = pygame.Rect(x, y, bw, bh) + self.faction_buttons.append((rect, fid)) + + color = FACTION_COLORS[fid] + ink_style.draw_scroll(self.screen, (x, y, bw, bh), + scroll_color=(min(255, color[0] + 80), + min(255, color[1] + 80), + min(255, color[2] + 80))) + + name = self.font_lg.render(FACTIONS[fid]["name"], True, INK_BLACK) + self.screen.blit(name, (x + bw // 2 - name.get_width() // 2, y + 10)) + + leader = self.font_md.render(f"君主:{FACTIONS[fid]['leader']}", True, INK_WASH_3) + self.screen.blit(leader, (x + bw // 2 - leader.get_width() // 2, y + 45)) + + passive = self.font_sm.render(FACTIONS[fid]["passive_desc"], True, TENG_HUANG) + self.screen.blit(passive, (x + bw // 2 - passive.get_width() // 2, y + 75)) + + # Buttons + self.menu_buttons = {} + back_rect = pygame.Rect(20, WINDOW_HEIGHT - 60, 120, 40) + ink_style.draw_ink_rect(self.screen, back_rect, INK_WASH_3, alpha=200) + t = self.font_md.render("返回", True, PAPER_WHITE) + self.screen.blit(t, (back_rect.centerx - t.get_width() // 2, + back_rect.centery - t.get_height() // 2)) + self.menu_buttons["back"] = back_rect + + build_rect = pygame.Rect(WINDOW_WIDTH - 200, WINDOW_HEIGHT - 60, 180, 40) + ink_style.draw_ink_rect(self.screen, build_rect, (60, 100, 60), alpha=200) + t = self.font_md.render("自由组卡", True, PAPER_WHITE) + self.screen.blit(t, (build_rect.centerx - t.get_width() // 2, + build_rect.centery - t.get_height() // 2)) + self.menu_buttons["deck_build"] = build_rect + + def draw_deck_builder(self, faction_id): + from card_game.config import DECK_SIZE + from collections import Counter + + ink_style.blit_paper_background(self.screen) + self.deck_builder_faction = faction_id + faction = FACTIONS[faction_id] + faction_color = FACTION_COLORS[faction_id] + + title = self.font_lg.render(f"组卡 - {faction['name']}", True, faction_color) + self.screen.blit(title, (WINDOW_WIDTH // 2 - title.get_width() // 2, 10)) + + deck_count = len(self.deck_builder_cards) + count_color = SONGHUA_GREEN if deck_count == DECK_SIZE else (TENG_HUANG if deck_count > 0 else INK_WASH_3) + count_text = self.font_md.render(f"已选:{deck_count}/{DECK_SIZE}", True, count_color) + self.screen.blit(count_text, (WINDOW_WIDTH // 2 - count_text.get_width() // 2, 42)) + + # Left: available cards + self.deck_card_rects = [] + left_x = 20 + left_y = 75 + self.screen.blit(self.font_md.render("可用卡牌 (左键添加)", True, INK_WASH_3), (left_x, left_y)) + left_y += 25 + + available = [c for c in CARD_DATABASE.values() + if c["faction"] == faction_id or c["faction"] in ("neutral", "ally")] + available.sort(key=lambda c: (c["cost"], c["name"])) + + small_w, small_h = 160, 32 + cols = 4 + for i, card_data in enumerate(available): + row, col = divmod(i, cols) + x = left_x + col * (small_w + 8) + y = left_y + row * (small_h + 4) + if y + small_h > WINDOW_HEIGHT - 60: + break + cid = card_data["id"] + in_deck = self.deck_builder_cards.count(cid) + max_copies = RARITY_LIMITS.get(card_data["rarity"], 3) + can_add = in_deck < max_copies and deck_count < DECK_SIZE + + rect = pygame.Rect(x, y, small_w, small_h) + if card_data["faction"] not in ("neutral", "ally"): + bg = tuple(max(0, c - 30) for c in faction_color) + elif card_data["faction"] == "ally": + bg = (100, 90, 55) + else: + bg = (80, 75, 65) + ink_style.draw_ink_rect(self.screen, rect, bg, alpha=180) + border_color = PAPER_WHITE if can_add else INK_WASH_3 + pygame.draw.rect(self.screen, border_color, rect, 1, border_radius=3) + + cost_s = self.font_sm.render(str(card_data["cost"]), True, TENG_HUANG) + self.screen.blit(cost_s, (x + 4, y + 8)) + + icon = {"unit": "兵", "order": "谋"}.get(card_data["type"], "?") + ally_tag = card_data.get("ally_state", "") + if card_data["faction"] == "ally" and ally_tag: + icon = ally_tag + self.screen.blit(self.font_sm.render(icon, True, TENG_HUANG), (x + 22, y + 8)) + self.screen.blit(self.font_sm.render(card_data["name"][:4], True, PAPER_WHITE), (x + 40, y + 8)) + + ct = f"×{in_deck}/{max_copies}" if in_deck > 0 else f"0/{max_copies}" + cs = self.font_sm.render(ct, True, count_color if in_deck > 0 else GRAY) + self.screen.blit(cs, (x + small_w - cs.get_width() - 4, y + 8)) + self.deck_card_rects.append((rect, cid)) + + # Right: current deck + self.deck_build_rects = [] + right_x = WINDOW_WIDTH // 2 + 20 + right_y = 75 + self.screen.blit(self.font_md.render("当前牌组 (右键移除)", True, INK_WASH_3), (right_x, right_y)) + right_y += 25 + + deck_counter = Counter(self.deck_builder_cards) + deck_items = sorted(deck_counter.items(), key=lambda x: (CARD_DATABASE[x[0]]["cost"], CARD_DATABASE[x[0]]["name"])) + + for i, (cid, count) in enumerate(deck_items): + card_data = CARD_DATABASE[cid] + row, col = divmod(i, 3) + x = right_x + col * (small_w + 8) + y = right_y + row * (small_h + 4) + if y + small_h > WINDOW_HEIGHT - 60: + break + rect = pygame.Rect(x, y, small_w, small_h) + if card_data["faction"] not in ("neutral", "ally"): + bg = tuple(max(0, c - 30) for c in faction_color) + elif card_data["faction"] == "ally": + bg = (100, 90, 55) + else: + bg = (80, 75, 65) + ink_style.draw_ink_rect(self.screen, rect, bg, alpha=180) + pygame.draw.rect(self.screen, GOLD, rect, 1, border_radius=3) + + self.screen.blit(self.font_sm.render(str(card_data["cost"]), True, TENG_HUANG), (x + 4, y + 8)) + icon = {"unit": "兵", "order": "谋"}.get(card_data["type"], "?") + ally_tag = card_data.get("ally_state", "") + if card_data["faction"] == "ally" and ally_tag: + icon = ally_tag + self.screen.blit(self.font_sm.render(icon, True, TENG_HUANG), (x + 22, y + 8)) + self.screen.blit(self.font_sm.render(card_data["name"][:4], True, PAPER_WHITE), (x + 40, y + 8)) + cs = self.font_sm.render(f"×{count}", True, SONGHUA_GREEN) + self.screen.blit(cs, (x + small_w - cs.get_width() - 4, y + 8)) + self.deck_build_rects.append((rect, cid)) + + # Bottom buttons + self.menu_buttons = {} + for key, lbl, color, rx in [ + ("back", "返回", INK_WASH_3, 20), + ("clear", "清空", (120, 50, 40), 160), + ("preset", "加载预设", (50, 90, 50), 300), + ]: + r = pygame.Rect(rx, WINDOW_HEIGHT - 55, 130 if key == "preset" else 120, 40) + ink_style.draw_ink_rect(self.screen, r, color, alpha=200) + pygame.draw.rect(self.screen, PAPER_WHITE, r, 1, border_radius=5) + t = self.font_md.render(lbl, True, PAPER_WHITE) + self.screen.blit(t, (r.centerx - t.get_width() // 2, r.centery - t.get_height() // 2)) + self.menu_buttons[key] = r + + confirm_color = (50, 110, 50) if deck_count == DECK_SIZE else INK_WASH_3 + confirm_rect = pygame.Rect(WINDOW_WIDTH - 180, WINDOW_HEIGHT - 55, 160, 40) + ink_style.draw_ink_rect(self.screen, confirm_rect, confirm_color, alpha=200) + pygame.draw.rect(self.screen, PAPER_WHITE if deck_count == DECK_SIZE else GRAY, + confirm_rect, 1, border_radius=5) + t = self.font_md.render("确认组卡", True, PAPER_WHITE if deck_count == DECK_SIZE else GRAY) + self.screen.blit(t, (confirm_rect.centerx - t.get_width() // 2, + confirm_rect.centery - t.get_height() // 2)) + self.menu_buttons["confirm"] = confirm_rect + + def get_deck_card_at(self, pos): + for rect, cid in self.deck_card_rects: + if rect.collidepoint(pos): + return cid + return None + + def get_deck_build_card_at(self, pos): + for rect, cid in self.deck_build_rects: + if rect.collidepoint(pos): + return cid + return None + + # --- Game Drawing --- + + def draw_game(self, battlefield): + ink_style.blit_paper_background(self.screen) + ink_style.blit_mountains(self.screen) + + self._draw_enemy_info(battlefield.ai) + self._draw_enemy_hand(battlefield.ai) + + # Zone backgrounds with ink wash + ink_style.draw_zone_bg(self.screen, + (0, ENEMY_SUPPORT_Y, WINDOW_WIDTH, ZONE_HEIGHT), + FIELD_COLOR, INK_WASH_3) + ink_style.draw_zone_bg(self.screen, + (0, FRONTLINE_Y, WINDOW_WIDTH, ZONE_HEIGHT), + FRONTLINE_COLOR, ZHU_HONG) + ink_style.draw_zone_bg(self.screen, + (0, PLAYER_SUPPORT_Y, WINDOW_WIDTH, ZONE_HEIGHT), + FIELD_COLOR, INK_WASH_3) + + self._draw_zone(battlefield.ai.support_line, ENEMY_SUPPORT_Y, "对方营地", battlefield.ai) + self._draw_frontline(battlefield) + self._draw_zone(battlefield.player.support_line, PLAYER_SUPPORT_Y, "我方营地", battlefield.player) + self._draw_player_hand(battlefield.player, battlefield) + self._draw_action_bar(battlefield) + self._draw_highlights(battlefield) + self.effects.draw(self.screen, self.font_lg) + + def draw_game_over(self, winner, battlefield): + overlay = pygame.Surface((WINDOW_WIDTH, WINDOW_HEIGHT), pygame.SRCALPHA) + overlay.fill((20, 15, 10, 150)) + self.screen.blit(overlay, (0, 0)) + + if winner == "player": + text = "胜 利 !" + color = TENG_HUANG + else: + text = "败 北 ..." + color = ZHU_HONG + + # Draw result as seal stamp + seal_w, seal_h = 280, 70 + seal_x = WINDOW_WIDTH // 2 - seal_w // 2 + seal_y = 250 + ink_style.draw_seal_stamp(self.screen, (seal_x, seal_y, seal_w, seal_h), + text, self.font_xl) + + p = battlefield.player + for i, s in enumerate([f"回合数:{battlefield.turn_number}", f"都城剩余HP:{max(0, p.capital_hp)}"]): + surf = self.font_md.render(s, True, PAPER_WHITE) + self.screen.blit(surf, (WINDOW_WIDTH // 2 - surf.get_width() // 2, 340 + i * 30)) + + self.menu_buttons = {} + restart_rect = pygame.Rect(WINDOW_WIDTH // 2 - 150, 430, 130, 45) + menu_rect = pygame.Rect(WINDOW_WIDTH // 2 + 20, 430, 130, 45) + for rect, label in [(restart_rect, "再来一局"), (menu_rect, "返回主菜单")]: + ink_style.draw_ink_rect(self.screen, rect, INK_WASH_3, alpha=200) + pygame.draw.rect(self.screen, PAPER_WHITE, rect, 2, border_radius=5) + t = self.font_md.render(label, True, PAPER_WHITE) + self.screen.blit(t, (rect.centerx - t.get_width() // 2, rect.centery - t.get_height() // 2)) + self.menu_buttons["restart"] = restart_rect + self.menu_buttons["menu"] = menu_rect + + # --- Zone Drawing --- + + def _draw_zone(self, slots, y, label, player): + if label: + lbl = self.font_sm.render(label, True, INK_WASH_3) + self.screen.blit(lbl, (10, y + 5)) + + self._draw_capital(player, y) + + is_player_zone = (not player.is_ai) + for i, unit in enumerate(slots): + if unit is None: + continue + ux = _support_slot_x(i) + uy = y + (ZONE_HEIGHT - FIELD_CARD_HEIGHT) // 2 + self._draw_field_unit(unit, ux, uy, is_player_zone, player) + + def _draw_frontline(self, battlefield): + y = FRONTLINE_Y + half = ZONE_HEIGHT // 2 + + # Center divider line + pygame.draw.line(self.screen, INK_WASH_3, (0, y + half), (WINDOW_WIDTH, y + half), 1) + + lbl = self.font_sm.render("前 线", True, (ZHU_HONG[0], ZHU_HONG[1], ZHU_HONG[2])) + self.screen.blit(lbl, (WINDOW_WIDTH // 2 - lbl.get_width() // 2, y + 3)) + + n_fl = MAX_FRONTLINE_SLOTS + for i, unit in enumerate(battlefield.ai.frontline): + if unit is None: + continue + ux = _frontline_slot_x(i, n_fl) + uy = y + (half - FIELD_CARD_HEIGHT) // 2 + self._draw_field_unit(unit, ux, uy, False, battlefield.ai) + + for i, unit in enumerate(battlefield.player.frontline): + if unit is None: + continue + ux = _frontline_slot_x(i, n_fl) + uy = y + half + (half - FIELD_CARD_HEIGHT) // 2 + self._draw_field_unit(unit, ux, uy, True, battlefield.player) + + def _draw_capital(self, player, zone_y): + cx = WINDOW_WIDTH // 2 - CAPITAL_WIDTH // 2 + cy = zone_y + (ZONE_HEIGHT - CAPITAL_HEIGHT) // 2 + + color = FACTION_COLORS[player.faction_id] + rect = pygame.Rect(cx, cy, CAPITAL_WIDTH, CAPITAL_HEIGHT) + + # Draw as seal stamp + ink_style.draw_seal_stamp(self.screen, (cx, cy, CAPITAL_WIDTH, CAPITAL_HEIGHT), + player.faction["name"], self.font_sm, color=color) + + # HP text + hp_text = self.font_md.render(f"{max(0, player.capital_hp)}/{player.max_capital_hp}", True, PAPER_WHITE) + self.screen.blit(hp_text, (cx + CAPITAL_WIDTH // 2 - hp_text.get_width() // 2, cy + 30)) + + # HP bar + bar_w = CAPITAL_WIDTH - 10 + bar_h = 6 + bar_x = cx + 5 + bar_y = cy + CAPITAL_HEIGHT - 12 + hp_ratio = max(0, player.capital_hp / player.max_capital_hp) + ink_style.draw_ink_hp_bar(self.screen, bar_x, bar_y, bar_w, bar_h, hp_ratio) + + def _draw_field_unit(self, unit, x, y, is_player, player): + w, h = FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT + color = unit.get_color() + dark_color = tuple(max(0, c - 30) for c in color) + rect = pygame.Rect(x, y, w, h) + + # Ink wash card background + ink_style.draw_ink_rect(self.screen, rect, dark_color, alpha=220) + + # Border + if unit is self.selected_unit: + pygame.draw.rect(self.screen, TENG_HUANG, rect, 2, border_radius=4) + elif unit.can_attack and is_player and not unit.has_attacked: + pygame.draw.rect(self.screen, SONGHUA_GREEN, rect, 1, border_radius=4) + else: + pygame.draw.rect(self.screen, self._rarity_border_color(unit.rarity), rect, 1, border_radius=4) + + # Name (3 chars) + name = unit.name[:3] + name_surf = self.font_sm.render(name, True, PAPER_WHITE) + self.screen.blit(name_surf, (x + w // 2 - name_surf.get_width() // 2, y + 3)) + + # Unit type icon + icon_char = {"infantry": "步", "cavalry": "骑", "chariot": "车", + "archer": "弓", "siege": "攻"}.get(unit.unit_type, "?") + self.screen.blit(self.font_sm.render(icon_char, True, TENG_HUANG), (x + 3, y + 3)) + + # Operation cost + op_cost = unit.op_cost + owner = player + if owner.faction_id == "yan" and unit.unit_type == "cavalry": + op_cost = max(0, op_cost - 1) + oc_surf = self.font_sm.render(str(op_cost), True, TENG_HUANG) + pygame.draw.circle(self.screen, (40, 35, 30), (x + w - 12, y + 12), 9) + self.screen.blit(oc_surf, (x + w - 12 - oc_surf.get_width() // 2, y + 12 - oc_surf.get_height() // 2)) + + # Attack / Defense + atk = unit.get_effective_attack() + dfn = unit.get_effective_defense() + self.screen.blit(self.font_sm.render(str(atk), True, (200, 80, 60)), (x + 5, y + h - 22)) + self.screen.blit(self.font_sm.render(str(dfn), True, (60, 80, 160)), (x + w - 20, y + h - 22)) + + # HP bar + if unit.max_hp > 0: + bar_w = w - 8 + bar_x = x + 4 + bar_y = y + h - 6 + hp_ratio = unit.current_hp / unit.max_hp + ink_style.draw_ink_hp_bar(self.screen, bar_x, bar_y, bar_w, 4, hp_ratio) + + def _draw_player_hand(self, player, battlefield): + n = len(player.hand) + if n == 0: + return + start_x = (WINDOW_WIDTH - n * HAND_CARD_SPACING) // 2 + for i, card in enumerate(player.hand): + x = start_x + i * HAND_CARD_SPACING + y = HAND_Y + if card is self.hover_card: + y -= 15 + if card is self.selected_card: + y -= 25 + self._draw_hand_card(card, x, y, player.can_play_card(card)) + + def _draw_hand_card(self, card, x, y, playable): + w, h = CARD_WIDTH, CARD_HEIGHT + color = card.get_color() + dark_color = tuple(max(0, c - 30) for c in color) + rect = pygame.Rect(x, y, w, h) + + # Ink wash background + ink_style.draw_ink_rect(self.screen, rect, dark_color, alpha=220) + + if card is self.selected_card: + pygame.draw.rect(self.screen, TENG_HUANG, rect, 2, border_radius=5) + elif not playable: + pygame.draw.rect(self.screen, INK_WASH_3, rect, 1, border_radius=5) + else: + pygame.draw.rect(self.screen, self._rarity_border_color(card.rarity), rect, 1, border_radius=5) + + # Cost circle top-left + cost_surf = self.font_md.render(str(card.cost), True, TENG_HUANG) + pygame.draw.circle(self.screen, (35, 30, 25), (x + 14, y + 14), 12) + self.screen.blit(cost_surf, (x + 14 - cost_surf.get_width() // 2, y + 14 - cost_surf.get_height() // 2)) + + # Op cost top-right + op_surf = self.font_sm.render(f"行{card.op_cost}", True, TENG_HUANG if playable else INK_WASH_3) + self.screen.blit(op_surf, (x + w - op_surf.get_width() - 3, y + 3)) + + # Name + name = card.name[:4] + name_surf = self.font_sm.render(name, True, PAPER_WHITE if playable else GRAY) + self.screen.blit(name_surf, (x + w // 2 - name_surf.get_width() // 2, y + 28)) + + if card.card_type == "unit": + icon = {"infantry": "步", "cavalry": "骑", "chariot": "车", + "archer": "弓", "siege": "攻"}.get(card.unit_type, "?") + self.screen.blit(self.font_sm.render(icon, True, TENG_HUANG), (x + 3, y + h - 40)) + self.screen.blit(self.font_md.render(str(card.attack), True, (200, 80, 60)), (x + 5, y + h - 22)) + self.screen.blit(self.font_md.render(str(card.defense), True, (60, 80, 160)), (x + w - 15, y + h - 22)) + else: + self.screen.blit(self.font_sm.render("谋略", True, ORANGE), (x + w // 2 - 12, y + h - 40)) + + desc = card.description[:8] + desc_surf = self.font_sm.render(desc, True, LIGHT_GRAY if playable else DARK_GRAY) + self.screen.blit(desc_surf, (x + w // 2 - desc_surf.get_width() // 2, y + h - 8)) + + def _draw_enemy_info(self, ai): + # Semi-transparent bar + s = pygame.Surface((WINDOW_WIDTH, ENEMY_INFO_HEIGHT), pygame.SRCALPHA) + s.fill((*INK_WASH_5[:3], 160)) + self.screen.blit(s, (0, 0)) + + color = FACTION_COLORS[ai.faction_id] + self.screen.blit(self.font_md.render(f"{ai.faction['name']} (AI)", True, color), (10, 10)) + self.screen.blit(self.font_md.render(f"都城:{max(0, ai.capital_hp)}/{ai.max_capital_hp}", True, PAPER_WHITE), (200, 10)) + self.screen.blit(self.font_md.render(f"粮草:{ai.provisions}", True, TENG_HUANG), (400, 10)) + self.screen.blit(self.font_md.render(f"牌库:{ai.deck.remaining()}", True, INK_WASH_2), (550, 10)) + + def _draw_enemy_hand(self, ai): + y = ENEMY_INFO_HEIGHT + n = len(ai.hand) + if n == 0: + return + card_w = 30 + start_x = (WINDOW_WIDTH - n * (card_w + 5)) // 2 + for i in range(n): + x = start_x + i * (card_w + 5) + rect = pygame.Rect(x, y, card_w, 40) + # Card back as ink wash + ink_style.draw_ink_rect(self.screen, rect, JIANG_BROWN, alpha=180) + pygame.draw.rect(self.screen, INK_WASH_3, rect, 1, border_radius=3) + + def _draw_action_bar(self, battlefield): + s = pygame.Surface((WINDOW_WIDTH, ACTION_BAR_HEIGHT), pygame.SRCALPHA) + s.fill((*INK_WASH_5[:3], 180)) + self.screen.blit(s, (0, ACTION_BAR_Y)) + + p = battlefield.player + info_parts = [ + (f"{p.faction['name']}", FACTION_COLORS[p.faction_id]), + (f"粮草:{p.provisions}/{p.max_provisions}", TENG_HUANG), + (f"回合:{battlefield.turn_number}", PAPER_WHITE), + (f"牌库:{p.deck.remaining()}", INK_WASH_2), + ] + x = 10 + for text, color in info_parts: + surf = self.font_md.render(text, True, color) + self.screen.blit(surf, (x, ACTION_BAR_Y + 15)) + x += surf.get_width() + 30 + + passive = self.font_sm.render(p.faction["passive_name"], True, TENG_HUANG) + self.screen.blit(passive, (x + 10, ACTION_BAR_Y + 17)) + + # End turn button as seal stamp + btn_w, btn_h = 120, 36 + btn_x = WINDOW_WIDTH - btn_w - 15 + btn_y = ACTION_BAR_Y + (ACTION_BAR_HEIGHT - btn_h) // 2 + self.end_turn_btn = pygame.Rect(btn_x, btn_y, btn_w, btn_h) + + if battlefield.current_turn == "player": + ink_style.draw_seal_stamp(self.screen, (btn_x, btn_y, btn_w, btn_h), + "结束回合", self.font_md) + else: + ink_style.draw_ink_rect(self.screen, self.end_turn_btn, INK_WASH_3, alpha=180) + pygame.draw.rect(self.screen, GRAY, self.end_turn_btn, 1, border_radius=5) + t = self.font_md.render("结束回合", True, GRAY) + self.screen.blit(t, (self.end_turn_btn.centerx - t.get_width() // 2, + self.end_turn_btn.centery - t.get_height() // 2)) + + def _draw_highlights(self, battlefield): + if not self.valid_targets: + return + for target in self.valid_targets: + if isinstance(target, tuple) and target[0] == "capital": + player = target[1] + cx = WINDOW_WIDTH // 2 - CAPITAL_WIDTH // 2 + cy = ENEMY_SUPPORT_Y + (ZONE_HEIGHT - CAPITAL_HEIGHT) // 2 + s = pygame.Surface((CAPITAL_WIDTH, CAPITAL_HEIGHT), pygame.SRCALPHA) + s.fill((100, 200, 100, 50)) + self.screen.blit(s, (cx, cy)) + elif hasattr(target, 'zone') and hasattr(target, 'slot'): + unit = target + is_ai = battlefield._get_unit_owner(unit) == battlefield.ai + if unit.zone == "support": + zone_y = ENEMY_SUPPORT_Y if is_ai else PLAYER_SUPPORT_Y + zone_h = ZONE_HEIGHT + ux = _support_slot_x(unit.slot) + uy = zone_y + (zone_h - FIELD_CARD_HEIGHT) // 2 + else: + half = ZONE_HEIGHT // 2 + n_fl = MAX_FRONTLINE_SLOTS + zone_y = FRONTLINE_Y if is_ai else (FRONTLINE_Y + half) + zone_h = half + ux = _frontline_slot_x(unit.slot, n_fl) + uy = zone_y + (zone_h - FIELD_CARD_HEIGHT) // 2 + s = pygame.Surface((FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT), pygame.SRCALPHA) + s.fill((100, 200, 100, 50)) + self.screen.blit(s, (ux, uy)) + + # Deploy highlights + if self.target_mode == "deploy": + for i, slot in enumerate(battlefield.player.support_line): + if slot is None: + sx = _support_slot_x(i) + sy = PLAYER_SUPPORT_Y + (ZONE_HEIGHT - FIELD_CARD_HEIGHT) // 2 + s = pygame.Surface((FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT), pygame.SRCALPHA) + s.fill((100, 200, 100, 50)) + self.screen.blit(s, (sx, sy)) + + # Move highlights + if self.target_mode == "move": + half = ZONE_HEIGHT // 2 + n_fl = MAX_FRONTLINE_SLOTS + for i, slot in enumerate(battlefield.player.frontline): + if slot is None: + sx = _frontline_slot_x(i, n_fl) + sy = FRONTLINE_Y + half + (half - FIELD_CARD_HEIGHT) // 2 + s = pygame.Surface((FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT), pygame.SRCALPHA) + s.fill((100, 180, 220, 50)) + self.screen.blit(s, (sx, sy)) + + # --- Hit Testing --- + + def get_hand_card_at(self, pos, player): + n = len(player.hand) + if n == 0: + return None + start_x = (WINDOW_WIDTH - n * HAND_CARD_SPACING) // 2 + mx, my = pos + for i, card in enumerate(player.hand): + x = start_x + i * HAND_CARD_SPACING + y = HAND_Y + if card is self.hover_card: + y -= 15 + if card is self.selected_card: + y -= 25 + rect = pygame.Rect(x, y, CARD_WIDTH, CARD_HEIGHT) + if rect.collidepoint(mx, my): + return card + return None + + def get_field_unit_at(self, pos, battlefield): + mx, my = pos + half = ZONE_HEIGHT // 2 + n_fl = MAX_FRONTLINE_SLOTS + zones = [ + (battlefield.player.support_line, PLAYER_SUPPORT_Y, ZONE_HEIGHT, battlefield.player, "support"), + (battlefield.player.frontline, FRONTLINE_Y + half, half, battlefield.player, "frontline"), + (battlefield.ai.support_line, ENEMY_SUPPORT_Y, ZONE_HEIGHT, battlefield.ai, "support"), + (battlefield.ai.frontline, FRONTLINE_Y, half, battlefield.ai, "frontline"), + ] + for slots, zone_y, zone_h, owner, zone_type in zones: + for i, unit in enumerate(slots): + if unit is None: + continue + if zone_type == "support": + ux = _support_slot_x(i) + else: + ux = _frontline_slot_x(i, n_fl) + uy = zone_y + (zone_h - FIELD_CARD_HEIGHT) // 2 + rect = pygame.Rect(ux, uy, FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT) + if rect.collidepoint(mx, my): + return unit, owner + return None, None + + def get_support_slot_at(self, pos, player): + mx, my = pos + zone_y = PLAYER_SUPPORT_Y + for i, slot in enumerate(player.support_line): + if slot is not None: + continue + sx = _support_slot_x(i) + sy = zone_y + (ZONE_HEIGHT - FIELD_CARD_HEIGHT) // 2 + rect = pygame.Rect(sx, sy, FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT) + if rect.collidepoint(mx, my): + return i + return -1 + + def get_frontline_slot_at(self, pos, player): + mx, my = pos + half = ZONE_HEIGHT // 2 + n_fl = MAX_FRONTLINE_SLOTS + zone_y = FRONTLINE_Y + half + for i, slot in enumerate(player.frontline): + sx = _frontline_slot_x(i, n_fl) + sy = zone_y + (half - FIELD_CARD_HEIGHT) // 2 + rect = pygame.Rect(sx, sy, FIELD_CARD_WIDTH, FIELD_CARD_HEIGHT) + if rect.collidepoint(mx, my): + return i + return -1 + + def get_enemy_capital_at(self, pos, ai): + mx, my = pos + cx = WINDOW_WIDTH // 2 - CAPITAL_WIDTH // 2 + cy = ENEMY_SUPPORT_Y + (ZONE_HEIGHT - CAPITAL_HEIGHT) // 2 + rect = pygame.Rect(cx, cy, CAPITAL_WIDTH, CAPITAL_HEIGHT) + return rect.collidepoint(mx, my) + + def get_faction_at(self, pos): + for rect, fid in self.faction_buttons: + if rect.collidepoint(pos): + return fid + return None + + # --- Helpers --- + + def _rarity_border_color(self, rarity): + if rarity == "legendary": + return ZHU_HONG + elif rarity == "rare": + return TENG_HUANG + return SILVER + + def clear_selection(self): + self.selected_card = None + self.selected_unit = None + self.valid_targets = [] + self.target_mode = None diff --git a/card_game/utils.py b/card_game/utils.py new file mode 100644 index 0000000..ebdefbd --- /dev/null +++ b/card_game/utils.py @@ -0,0 +1,11 @@ +"""Utility functions.""" + +import math + + +def distance(x1, y1, x2, y2): + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + +def lerp(a, b, t): + return a + (b - a) * t diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18caa77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame>=2.5