游戏可以运行
This commit is contained in:
36
CLAUDE.md
Normal file
36
CLAUDE.md
Normal file
@@ -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
|
||||||
0
card_game/__init__.py
Normal file
0
card_game/__init__.py
Normal file
BIN
card_game/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/ai.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/ai.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/battlefield.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/battlefield.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/card.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/card.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/config.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/deck.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/deck.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/effects.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/effects.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/factions.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/factions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/ink_style.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/ink_style.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/main.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/player.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/player.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/ui.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
card_game/__pycache__/utils.cpython-312.pyc
Normal file
BIN
card_game/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
148
card_game/ai.py
Normal file
148
card_game/ai.py
Normal file
@@ -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
|
||||||
337
card_game/battlefield.py
Normal file
337
card_game/battlefield.py
Normal file
@@ -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)
|
||||||
87
card_game/card.py
Normal file
87
card_game/card.py
Normal file
@@ -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})"
|
||||||
1588
card_game/config.py
Normal file
1588
card_game/config.py
Normal file
File diff suppressed because it is too large
Load Diff
26
card_game/deck.py
Normal file
26
card_game/deck.py
Normal file
@@ -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)
|
||||||
116
card_game/effects.py
Normal file
116
card_game/effects.py
Normal file
@@ -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)
|
||||||
20
card_game/factions.py
Normal file
20
card_game/factions.py
Normal file
@@ -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
|
||||||
340
card_game/ink_style.py
Normal file
340
card_game/ink_style.py
Normal file
@@ -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))
|
||||||
549
card_game/main.py
Normal file
549
card_game/main.py
Normal file
@@ -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()
|
||||||
187
card_game/player.py
Normal file
187
card_game/player.py
Normal file
@@ -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
|
||||||
776
card_game/ui.py
Normal file
776
card_game/ui.py
Normal file
@@ -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
|
||||||
11
card_game/utils.py
Normal file
11
card_game/utils.py
Normal file
@@ -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
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pygame>=2.5
|
||||||
Reference in New Issue
Block a user