游戏可以运行

This commit is contained in:
2026-05-24 08:10:22 +08:00
commit 035b2f7af9
28 changed files with 4222 additions and 0 deletions

36
CLAUDE.md Normal file
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

148
card_game/ai.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

26
card_game/deck.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
pygame>=2.5