Fun Practicals
These are not tested in 9618 exam, it just for fun, independent, further study.
- [Python] Turn-Based Battle Game
- main.py
- battle.py
- character.py
- companions.py
- data_types.py
- game_state.py
- items_db.py
- monster_unit.py & monsters_db.py
- skills_db.py
- [HTML] DIY Roulette
- [Python] Bank Account Simulator
- [Python] Connect 4
- [HTML] Simulation RPG Combat Sample
- [Python] Chess
[Python] Turn-Based Battle Game
[Python] Turn-Based Battle Game
main.py
"""Main game entry point: title screen, character creation, game loop."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from data_types import *
from character import create_player_character, JOB_BASE_STATS
from game_state import GameState
from skills_db import SKILL_DB, JOB_SKILL_POOL
def clear():
os.system('clear' if os.name == 'posix' else 'cls')
def print_title():
clear()
print("""
╔══════════════════════════════════════════════════════════════╗
║ ║
║ ✦ CHRONICLES OF THE ETERNAL TURN ✦ ║
║ A JRPG CLI ADVENTURE ║
║ ║
╚══════════════════════════════════════════════════════════════╝
""")
def create_character() -> tuple:
clear()
print("╔══════════════════════════════════════════════════════════════╗")
print("║ CHARACTER CREATION ║")
print("╚══════════════════════════════════════════════════════════════╝")
while True:
name = input("\n Enter your hero's name: ").strip()
if name:
break
print(" Please enter a name.")
weapons = list(WeaponType)
weapon_descs = {
WeaponType.SWORD: "Balanced. Warriors, Knights, Paladins",
WeaponType.SPEAR: "Long reach. Spearman, Dragoon",
WeaponType.AXE: "High power. Berserker, Monk",
WeaponType.DAGGER: "Fast & precise. Assassin",
WeaponType.BOW: "Ranged. Ranger, Hunter",
WeaponType.STAFF: "Magical focus. All Mages and Healers",
}
print("\n ── Choose Your Weapon ──")
for i, w in enumerate(weapons):
print(f" [{i+1}] {w.value:10s} - {weapon_descs[w]}")
while True:
try:
wi = int(input(" > ")) - 1
if 0 <= wi < len(weapons): break
except ValueError: pass
print(" Invalid.")
weapon = weapons[wi]
elements = [e for e in Element if e != Element.NONE]
elem_descs = {
Element.FIRE: "Offensive burn effects",
Element.ICE: "Freeze and slow foes",
Element.LIGHTNING: "Chain damage, high speed",
Element.WIND: "Evasive, speed-based",
Element.EARTH: "Defense-break, sturdy",
Element.LIGHT: "Holy power, vs Undead/Dark",
Element.DARK: "Debuff and drain",
}
print("\n ── Choose Your Element ──")
for i, e in enumerate(elements):
print(f" [{i+1}] {e.value:10s} - {elem_descs[e]}")
while True:
try:
ei = int(input(" > ")) - 1
if 0 <= ei < len(elements): break
except ValueError: pass
print(" Invalid.")
element = elements[ei]
all_jobs = list(JobClass)
job_weapon_affinity = {
WeaponType.SWORD: [JobClass.WARRIOR, JobClass.KNIGHT, JobClass.PALADIN,
JobClass.ASSASSIN, JobClass.BERSERKER, JobClass.DRAGOON,
JobClass.DARK_MAGE, JobClass.LIGHT_MAGE],
WeaponType.SPEAR: [JobClass.SPEARMAN, JobClass.DRAGOON, JobClass.WARRIOR,
JobClass.KNIGHT, JobClass.RANGER, JobClass.HUNTER],
WeaponType.AXE: [JobClass.BERSERKER, JobClass.WARRIOR, JobClass.MONK,
JobClass.EARTH_MAGE, JobClass.DRAGOON, JobClass.KNIGHT],
WeaponType.DAGGER: [JobClass.ASSASSIN, JobClass.RANGER, JobClass.HUNTER,
JobClass.WIND_MAGE, JobClass.DARK_MAGE, JobClass.MONK],
WeaponType.BOW: [JobClass.RANGER, JobClass.HUNTER, JobClass.ASSASSIN,
JobClass.STORM_MAGE, JobClass.WIND_MAGE, JobClass.LIGHT_MAGE],
WeaponType.STAFF: [JobClass.CLERIC, JobClass.PRIEST, JobClass.FIRE_MAGE,
JobClass.ICE_MAGE, JobClass.STORM_MAGE, JobClass.WIND_MAGE,
JobClass.EARTH_MAGE, JobClass.DARK_MAGE, JobClass.LIGHT_MAGE,
JobClass.ARCANE_SAGE, JobClass.PALADIN],
}
suggested = job_weapon_affinity.get(weapon, all_jobs)
job_info = {
JobClass.WARRIOR: "Melee fighter, high PATK, balanced",
JobClass.KNIGHT: "Tank, high HP/DEF, Shield Bash",
JobClass.PALADIN: "Holy warrior, heal & attack",
JobClass.BERSERKER: "Rage fighter, highest PATK, low DEF",
JobClass.ASSASSIN: "Stealth, high CRIT/EVA, poison",
JobClass.RANGER: "Bow specialist, multi-hit, area",
JobClass.HUNTER: "Traps, dragon slayer, beast lore",
JobClass.SPEARMAN: "Spear master, sweep attacks",
JobClass.DRAGOON: "Dragon knight, jump attacks",
JobClass.MONK: "Unarmed master, combo strikes",
JobClass.CLERIC: "Healer & smiter, Light magic",
JobClass.PRIEST: "Pure healer, mass heals, revival",
JobClass.FIRE_MAGE: "Fire magic, burn effects",
JobClass.ICE_MAGE: "Ice magic, freeze/slow",
JobClass.STORM_MAGE: "Lightning magic, chain effects",
JobClass.WIND_MAGE: "Wind magic, speed buffs",
JobClass.EARTH_MAGE: "Earth magic, high power AOE",
JobClass.DARK_MAGE: "Dark magic, debuffs, drain",
JobClass.LIGHT_MAGE: "Light magic, barriers, blind",
JobClass.ARCANE_SAGE:"All-element magic, time manipulation",
}
print(f"\n ── Choose Your Job (Weapon: {weapon.value}) ──")
print(f" Recommended jobs shown. Enter A for all jobs.")
print()
for i, job in enumerate(suggested):
print(f" [{i+1:2d}] {job.value:15s} - {job_info.get(job,'')}")
print(f" [ A] Show ALL {len(all_jobs)} jobs")
job = None
while True:
raw = input(" > ").strip().upper()
if raw == "A":
print("\n ALL JOBS:")
for i, j in enumerate(all_jobs):
print(f" [{i+1:2d}] {j.value:15s} - {job_info.get(j,'')}")
while True:
try:
ji = int(input(" > ")) - 1
if 0 <= ji < len(all_jobs):
job = all_jobs[ji]
break
except ValueError: pass
print(" Invalid.")
break
else:
try:
idx = int(raw) - 1
if 0 <= idx < len(suggested):
job = suggested[idx]
break
except ValueError: pass
print(" Invalid choice.")
# Skill preview
print(f"\n ── Skill Preview for {job.value} ──")
pool = JOB_SKILL_POOL[job]
basics = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.BASIC][:4]
inters = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.INTERMEDIATE][:4]
ultims = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.ULTIMATE][:2]
print(" Basic (start with 2):")
for sid in basics:
sk = SKILL_DB[sid]
print(f" • {sk.name:20s} MP:{sk.mp_cost:3d} PWR:{sk.power} - {sk.description}")
print(" Intermediate (unlock lv5/10):")
for sid in inters:
sk = SKILL_DB[sid]
print(f" • {sk.name:20s} MP:{sk.mp_cost:3d} PWR:{sk.power} - {sk.description}")
print(" Ultimate (unlock lv20):")
for sid in ultims:
sk = SKILL_DB[sid]
print(f" • {sk.name:20s} MP:{sk.mp_cost:3d} PWR:{sk.power} - {sk.description}")
# Summary
stats = JOB_BASE_STATS[job]
print(f"\n ── Summary ──")
print(f" Name: {name} | Job: {job.value} | Weapon: {weapon.value} | Element: {element.value}")
print(f" HP:{stats.hp} MP:{stats.mp} PATK:{stats.patk} MATK:{stats.matk} "
f"PDEF:{stats.pdef} MDEF:{stats.mdef} SPD:{stats.spd}")
confirm = input("\n Confirm? (Y/N) > ").strip().upper()
if confirm != "Y":
return create_character()
return name, job, weapon, element
def main():
print_title()
print("""
Welcome to Chronicles of the Eternal Turn!
OBJECTIVE: Survive as many battles as you can.
Collect companions, level up, grow powerful.
COMBAT TIPS:
• Attack enemies' Weaknesses to reduce their Shield Points
• When Shield = 0, enemy is BROKEN (50% more damage, can't act)
• Use Defend to reduce incoming damage and gain turn priority
• If a party member is KO'd, revive within 3 turns or they DIE
• If YOUR character dies → GAME OVER
COMPANION SYSTEM:
• Win battles to earn companions (3★/4★/5★)
• Party maximum: 4 members
• Companions can permanently die in battle
""")
input(" [Press Enter to begin]")
name, job, weapon, element = create_character()
clear()
gs = GameState()
player = create_player_character(name, job, weapon, element)
gs.player = player
gs.party = [player]
gs.all_party_members = [player]
print(f"\n ✦ {player.name} the {player.job.value} sets forth!")
print(f" Starting skills: " +
", ".join(SKILL_DB[s].name for s in player.unlocked_skills if s in SKILL_DB))
input("\n [Press Enter to begin your adventure]")
# Main game loop
while not gs.game_over:
print(f"\n{'═'*65}")
print(f" ✦ BATTLE {gs.battle_number + 1}")
if gs.battle_number > 0:
cont = gs.between_battles_menu()
if not cont:
print("\n Thanks for playing! Farewell, hero.")
print(f" Final record: {gs.battle_number} battles | {gs.player.name} Lv{gs.player.level}")
break
alive = [ch for ch in gs.party if not ch.is_dead]
if not alive:
gs.game_over = True
break
survived = gs.run_battle()
if not survived or gs.game_over:
gs.show_game_over_screen()
break
if not gs.game_over and gs.battle_number > 0:
print(f"\n Journey ended. {gs.battle_number} battles completed.")
print(f" {gs.player.name} reached Level {gs.player.level}. Well done!")
if __name__ == "__main__":
main()
[Python] Turn-Based Battle Game
battle.py
"""Battle engine: handles all combat calculations and the battle loop."""
from __future__ import annotations
import random
import math
from typing import List, Optional, Tuple, Dict
from data_types import *
from character import Character
from monster_unit import MonsterUnit
from monsters_db import MonsterTemplate
from skills_db import SKILL_DB
from items_db import ITEM_DB
def _clear():
import os; os.system('clear' if os.name == 'posix' else 'cls')
def calculate_physical_damage(attacker_patk: int, target_pdef: int,
power: float = 1.0, crit: bool = False,
element_bonus: float = 1.0,
weakness_bonus: float = 1.0,
break_bonus: float = 1.0) -> int:
base = max(1, attacker_patk - target_pdef // 2)
dmg = int(base * power * element_bonus * weakness_bonus * break_bonus)
if crit:
dmg = int(dmg * 1.5)
dmg = max(1, int(dmg * random.uniform(0.9, 1.1)))
return dmg
def calculate_magical_damage(attacker_matk: int, target_mdef: int,
power: float = 1.0, crit: bool = False,
element_bonus: float = 1.0,
weakness_bonus: float = 1.0,
break_bonus: float = 1.0) -> int:
base = max(1, attacker_matk - target_mdef // 3)
dmg = int(base * power * element_bonus * weakness_bonus * break_bonus)
if crit:
dmg = int(dmg * 1.5)
dmg = max(1, int(dmg * random.uniform(0.9, 1.1)))
return dmg
def calculate_heal(healer_mdef: int, power: float = 1.0) -> int:
return max(1, int(healer_mdef * 2.5 * power * random.uniform(0.9, 1.1)))
def check_hit(attacker_acc: float, target_eva: float) -> bool:
hit_chance = min(0.99, max(0.01, attacker_acc - target_eva))
return random.random() < hit_chance
def check_crit(crit_rate: float) -> bool:
return random.random() < crit_rate
class Battle:
def __init__(self, party: List[Character], monsters: List[MonsterUnit]):
self.party = party
self.monsters = monsters
self.turn = 0
self.battle_log: List[str] = []
self._defending: set = set() # character names defending this round
# ── Display helpers ───────────────────────────────────────────────────
def display_battle_state(self):
print("\n" + "═"*65)
print(" BATTLE STATUS")
print("═"*65)
print(" ENEMIES:")
for i, mu in enumerate(self.monsters):
if mu.is_dead:
print(f" [{i+1}] {mu.instance_name} - ☠ DEFEATED")
else:
broken = " ⚡BREAK!" if mu.is_broken else ""
print(f" [{i+1}] {mu.instance_name} Lv{mu.template.level}{broken}")
print(f" HP: {mu.hp_bar()}")
print(f" Shield: {mu.shield_bar()}{mu.status_str()}")
print()
print(" ALLIES:")
for i, ch in enumerate(self.party):
if ch.is_dead:
print(f" [{i+1}] {ch.name} [DEAD]")
elif ch.is_ko:
print(f" [{i+1}] {ch.name} [KO - {3 - ch.ko_turns} turns left]")
else:
print(f" [{i+1}] {ch.name} Lv{ch.level} {ch.job.value}{ch.status_str()}")
print(f" HP: {ch.hp_bar()} | MP: {ch.mp_bar()}")
print("═"*65)
def display_turn_order(self, order: List):
units = []
for u in order:
if isinstance(u, Character):
units.append(f"{u.name}({u.spd})")
elif isinstance(u, MonsterUnit):
units.append(f"{u.instance_name}({u.spd})")
print(f"\n Turn Order: {' → '.join(units)}")
def _log(self, msg: str):
self.battle_log.append(msg)
print(msg)
# ── Turn order ────────────────────────────────────────────────────────
def get_turn_order(self) -> List:
units = []
for ch in self.party:
if not ch.is_ko and not ch.is_dead:
units.append(ch)
for mu in self.monsters:
if not mu.is_dead:
units.append(mu)
# Sort by speed descending; defenders get priority
units.sort(key=lambda u: (
1 if (isinstance(u, Character) and u.name in self._defending) else 0,
u.spd
), reverse=True)
return units
# ── Main battle loop ──────────────────────────────────────────────────
def run(self) -> bool:
"""Returns True if party wins."""
print("\n" + "★"*65)
print(" ★ BATTLE START! ★")
print("★"*65)
input(" [Press Enter]")
while True:
self.turn += 1
self._defending.clear()
print(f"\n{'─'*65}")
print(f" ── TURN {self.turn} ──")
self.display_battle_state()
order = self.get_turn_order()
self.display_turn_order(order)
for unit in order:
if self._check_battle_end():
break
if isinstance(unit, Character):
if unit.is_ko or unit.is_dead:
continue
self._player_turn(unit)
elif isinstance(unit, MonsterUnit):
if unit.is_dead:
continue
self._monster_turn(unit)
# End of round: status ticks, KO timers, break recovery
self._end_of_round()
if self._check_battle_end():
break
return self._is_victory()
def _check_battle_end(self) -> bool:
all_monsters_dead = all(mu.is_dead for mu in self.monsters)
all_party_out = all(ch.is_ko or ch.is_dead for ch in self.party)
return all_monsters_dead or all_party_out
def _is_victory(self) -> bool:
return all(mu.is_dead for mu in self.monsters)
# ── Player turn ───────────────────────────────────────────────────────
def _player_turn(self, ch: Character):
print(f"\n ── {ch.name}'s Turn ({ch.job.value}) ──")
if ch.is_incapacitated():
incap_se = [se for se in ch.status_effects
if se.effect_type in {StatusEffectType.SLEEP, StatusEffectType.STUN,
StatusEffectType.FREEZE, StatusEffectType.PARALYZE,
StatusEffectType.PETRIFY}]
if incap_se:
self._log(f" {ch.name} is {incap_se[0].effect_type.value}! Can't act.")
# Wake up chance for sleep
if incap_se[0].effect_type == StatusEffectType.SLEEP:
if random.random() < 0.25:
ch.remove_status(StatusEffectType.SLEEP)
self._log(f" {ch.name} woke up!")
return
while True:
print(f"\n Actions: [1] Attack [2] Skill [3] Item [4] Defend")
choice = input(" > ").strip()
if choice == "1":
target = self._pick_enemy_target()
if target:
self._do_basic_attack(ch, target)
break
elif choice == "2":
if not ch.can_use_skills():
print(" Skills are sealed!")
continue
skill_id = self._pick_skill(ch)
if skill_id is None:
continue
skill = SKILL_DB[skill_id]
if not ch.can_use_magic() and skill.skill_type == SkillType.MAGICAL:
print(" Cannot use magic (Silenced)!")
continue
if ch.current_mp < skill.mp_cost:
print(f" Not enough MP! (Need {skill.mp_cost}, have {ch.current_mp})")
continue
self._do_skill(ch, skill)
break
elif choice == "3":
if not ch.can_use_items():
print(" Items are sealed!")
continue
used = self._use_item_menu(ch)
if used:
break
elif choice == "4":
self._do_defend(ch)
break
else:
print(" Invalid choice.")
def _pick_enemy_target(self) -> Optional[MonsterUnit]:
alive = [mu for mu in self.monsters if not mu.is_dead]
if not alive:
return None
if len(alive) == 1:
return alive[0]
print(" Choose target:")
for i, mu in enumerate(alive):
broken = " ⚡BREAK" if mu.is_broken else ""
print(f" [{i+1}] {mu.instance_name} HP:{mu.current_hp}/{mu.max_hp}{broken}")
while True:
try:
idx = int(input(" > ")) - 1
if 0 <= idx < len(alive):
return alive[idx]
except ValueError:
pass
print(" Invalid choice.")
def _pick_ally_target(self, include_ko=False) -> Optional[Character]:
if include_ko:
valid = [ch for ch in self.party if not ch.is_dead]
else:
valid = [ch for ch in self.party if not ch.is_ko and not ch.is_dead]
if not valid:
return None
if len(valid) == 1:
return valid[0]
print(" Choose ally:")
for i, ch in enumerate(valid):
status = "KO" if ch.is_ko else f"HP:{ch.current_hp}/{ch.max_hp}"
print(f" [{i+1}] {ch.name} ({status})")
while True:
try:
idx = int(input(" > ")) - 1
if 0 <= idx < len(valid):
return valid[idx]
except ValueError:
pass
print(" Invalid choice.")
def _pick_skill(self, ch: Character) -> Optional[int]:
if not ch.unlocked_skills:
print(" No skills available!")
return None
print(" Choose skill (0=back):")
for i, sid in enumerate(ch.unlocked_skills):
if sid in SKILL_DB:
sk = SKILL_DB[sid]
print(f" [{i+1}] {sk.name} "
f"[{sk.tier.value}|{sk.skill_type.value}|{sk.element.value}] "
f"MP:{sk.mp_cost} PWR:{sk.power} ACC:{int(sk.accuracy*100)}%")
while True:
raw = input(" > ").strip()
if raw == "0":
return None
try:
idx = int(raw) - 1
if 0 <= idx < len(ch.unlocked_skills):
return ch.unlocked_skills[idx]
except ValueError:
pass
print(" Invalid choice.")
def _use_item_menu(self, ch: Character) -> bool:
"""Returns True if an item was successfully used."""
from game_state import GameState
# Items accessed via global inventory - we'll use a simplified approach
# The GameState will be passed in during actual game run
print(" (Item system: handled by game state)")
return False # placeholder - overridden in actual game
def _do_basic_attack(self, ch: Character, target: MonsterUnit):
# Hit check
hit = check_hit(ch.acc, target.eva)
if not hit:
self._log(f" {ch.name} attacks {target.instance_name}... MISS!")
return
is_crit = check_crit(ch.crit)
# Weakness check
is_weak = target.hit_weakness(ch.weapon, ch.element)
weakness_bonus = 1.3 if is_weak else 1.0
break_bonus = 1.5 if target.is_broken else 1.0
dmg = calculate_physical_damage(
ch.patk, target.pdef, power=1.0,
crit=is_crit, weakness_bonus=weakness_bonus, break_bonus=break_bonus
)
# Defending reduction
if ch.name in self._defending:
dmg = int(dmg * 0.7)
actual = target.take_damage(dmg)
crit_str = " CRITICAL!" if is_crit else ""
weak_str = " 【WEAKNESS】" if is_weak else ""
self._log(f" {ch.name} attacks {target.instance_name}!{crit_str}{weak_str}")
self._log(f" Dealt {actual} damage!")
# Shield break
if is_weak:
broke = target.reduce_shield(1)
if broke:
self._log(f" ⚡ {target.instance_name} is BROKEN! All defenses down for 1 turn!")
else:
self._log(f" Shield: {target.shield_bar()} ({target.shield_points} remaining)")
# Counter
if target.has_status(StatusEffectType.COUNTER) and not target.is_dead:
cdmg = calculate_physical_damage(target.patk, ch.pdef, power=0.5)
ch.take_damage(cdmg)
self._log(f" {target.instance_name} COUNTER! {ch.name} takes {cdmg} damage!")
def _do_skill(self, ch: Character, skill: Skill):
ch.current_mp -= skill.mp_cost
# Determine targets
targets_enemies = []
targets_allies = []
if skill.target == SkillTarget.SINGLE_ENEMY:
t = self._pick_enemy_target()
if t: targets_enemies = [t]
elif skill.target == SkillTarget.ALL_ENEMIES:
targets_enemies = [m for m in self.monsters if not m.is_dead]
elif skill.target == SkillTarget.RANDOM_ENEMY:
alive = [m for m in self.monsters if not m.is_dead]
if alive:
targets_enemies = random.choices(alive, k=skill.hits)
elif skill.target == SkillTarget.SINGLE_ALLY:
t = self._pick_ally_target(include_ko=skill.skill_type == SkillType.HEAL)
if t: targets_allies = [t]
elif skill.target == SkillTarget.ALL_ALLIES:
targets_allies = [c for c in self.party if not c.is_dead]
elif skill.target == SkillTarget.SELF:
targets_allies = [ch]
elem_name = f"[{skill.element.value}]" if skill.element != Element.NONE else ""
self._log(f"\n ✦ {ch.name} uses {skill.name}!{elem_name}")
# Heal skills
if skill.skill_type == SkillType.HEAL:
for target in targets_allies:
if skill.mp_cost == 20 and skill.id == 46: # Resurrection special case
if target.is_ko:
target.revive(100)
self._log(f" {target.name} is REVIVED with full HP!")
else:
self._log(f" {target.name} is already standing.")
else:
heal_amt = calculate_heal(ch.mdef, skill.power)
actual = target.heal(heal_amt)
self._log(f" {target.name} recovers {actual} HP!")
return
# Buff skills
if skill.skill_type == SkillType.BUFF:
if skill.effect:
for target in targets_allies:
se = StatusEffect(skill.effect.status, skill.effect.duration, skill.effect.stat_modifier)
target.add_status(se)
self._log(f" {target.name} gains {skill.effect.status.value}!")
return
# Debuff skills
if skill.skill_type == SkillType.DEBUFF:
for target in targets_enemies:
if skill.effect and skill.effect.status:
chance = skill.effect.chance if skill.effect.chance > 0 else 0.8
if random.random() < chance:
se = StatusEffect(skill.effect.status, skill.effect.duration)
target.add_status(se)
self._log(f" {target.instance_name} is afflicted with {skill.effect.status.value}!")
else:
self._log(f" {target.instance_name} resists!")
return
# Damage skills (physical / magical / special)
num_hits = skill.hits if skill.target != SkillTarget.RANDOM_ENEMY else 1
raw_targets = targets_enemies
if skill.target == SkillTarget.RANDOM_ENEMY:
alive = [m for m in self.monsters if not m.is_dead]
raw_targets = []
for _ in range(skill.hits):
if alive: raw_targets.append(random.choice(alive))
for target in raw_targets:
for hit_n in range(num_hits if skill.target != SkillTarget.RANDOM_ENEMY else 1):
# Hit check
if not check_hit(ch.acc * skill.accuracy, target.eva):
self._log(f" Hit {hit_n+1}: MISS on {target.instance_name}!")
continue
is_crit = check_crit(ch.crit)
is_weak = target.hit_weakness(
ch.weapon if skill.skill_type == SkillType.PHYSICAL else None,
skill.element if skill.element != Element.NONE else ch.element
)
weakness_bonus = 1.3 if is_weak else 1.0
break_bonus = 1.5 if target.is_broken else 1.0
# Weakness mark doubles weakness
if target.has_status(StatusEffectType.WEAKNESS_MARK) and is_weak:
weakness_bonus *= 1.3
if skill.skill_type == SkillType.PHYSICAL:
dmg = calculate_physical_damage(
ch.patk, target.pdef, skill.power,
is_crit, weakness_bonus=weakness_bonus, break_bonus=break_bonus)
else:
# Magic boost
matk = ch.matk
if ch.has_status(StatusEffectType.MAGIC_BOOST):
matk = int(matk * 1.4)
# Reflect check
if target.has_status(StatusEffectType.REFLECT):
self._log(f" {target.instance_name} REFLECTS the spell!")
friendly = [c for c in self.party if not c.is_ko and not c.is_dead]
if friendly:
rf_target = random.choice(friendly)
rdmg = calculate_magical_damage(matk, rf_target.mdef, skill.power)
rf_target.take_damage(rdmg)
self._log(f" Reflected! {rf_target.name} takes {rdmg} damage!")
continue
dmg = calculate_magical_damage(
matk, target.mdef, skill.power,
is_crit, weakness_bonus=weakness_bonus, break_bonus=break_bonus)
actual = target.take_damage(dmg)
crit_str = " CRITICAL!" if is_crit else ""
weak_str = " 【WEAKNESS】" if is_weak else ""
self._log(f" {target.instance_name} takes {actual} damage!{crit_str}{weak_str}")
# Shield damage for weakness hits
if is_weak:
broke = target.reduce_shield(1)
if broke:
self._log(f" ⚡ {target.instance_name} is BROKEN!")
else:
self._log(f" Shield: {target.shield_bar()}")
# Apply effect
if skill.effect and skill.effect.status and not target.is_dead:
chance = skill.effect.chance if skill.effect.chance > 0 else 0.5
if random.random() < chance:
se = StatusEffect(skill.effect.status, skill.effect.duration,
skill.effect.stat_modifier)
target.add_status(se)
self._log(f" {target.instance_name} afflicted with {skill.effect.status.value}!")
def _do_defend(self, ch: Character):
self._defending.add(ch.name)
ch.add_status(StatusEffect(StatusEffectType.DEFENDING, 1))
self._log(f" {ch.name} takes a defensive stance! (DMG -30% next turn, speed priority)")
# ── Monster turn ──────────────────────────────────────────────────────
def _monster_turn(self, mu: MonsterUnit):
if mu.is_incapacitated():
self._log(f"\n {mu.instance_name} is incapacitated and cannot act!")
return
alive_party = [ch for ch in self.party if not ch.is_ko and not ch.is_dead]
if not alive_party:
return
action = mu.choose_action(alive_party)
target = random.choice(alive_party)
if action["action"] == "attack":
self._monster_basic_attack(mu, target)
elif action["action"] == "skill":
skill_id = action["skill_id"]
if skill_id in SKILL_DB:
skill = SKILL_DB[skill_id]
self._monster_skill(mu, skill, alive_party)
else:
self._monster_basic_attack(mu, target)
elif action["action"] == "defend":
self._log(f" {mu.instance_name} braces for impact!")
def _monster_basic_attack(self, mu: MonsterUnit, target: Character):
hit = check_hit(mu.acc, target.eva)
if not hit:
self._log(f"\n {mu.instance_name} attacks {target.name}... MISS!")
return
is_crit = check_crit(mu.template.base_stats.crit)
# Defend reduction
defending = target.has_status(StatusEffectType.DEFENDING)
dmg = calculate_physical_damage(
mu.patk, target.pdef, crit=is_crit)
if defending:
dmg = int(dmg * 0.7)
# Confusion: might attack ally
if mu.has_status(StatusEffectType.CONFUSION):
alive_enemies = [m for m in self.monsters if not m.is_dead and m != mu]
if alive_enemies and random.random() < 0.5:
friendly_target = random.choice(alive_enemies)
actual = friendly_target.take_damage(dmg)
self._log(f"\n {mu.instance_name} is confused and attacks {friendly_target.instance_name}! ({actual} dmg)")
return
crit_str = " CRITICAL!" if is_crit else ""
self._log(f"\n {mu.instance_name} attacks {target.name}!{crit_str}")
target.take_damage(dmg)
self._log(f" {target.name} takes {dmg} damage!")
# Counter
if target.has_status(StatusEffectType.COUNTER) and not target.is_ko:
cdmg = calculate_physical_damage(target.patk, mu.pdef, power=0.5)
mu.take_damage(cdmg)
self._log(f" {target.name} COUNTER! {mu.instance_name} takes {cdmg}!")
def _monster_skill(self, mu: MonsterUnit, skill: Skill, alive_party: List[Character]):
self._log(f"\n {mu.instance_name} uses {skill.name}!")
# Silence check for magic
if skill.skill_type == SkillType.MAGICAL and mu.has_status(StatusEffectType.SILENCE):
self._log(f" {mu.instance_name} is silenced!")
return
target = random.choice(alive_party)
if skill.skill_type in (SkillType.PHYSICAL, SkillType.MAGICAL, SkillType.SPECIAL):
for _ in range(skill.hits):
if target.is_ko: break
hit = check_hit(mu.acc * skill.accuracy, target.eva)
if not hit:
self._log(f" MISS on {target.name}!")
continue
is_crit = check_crit(mu.template.base_stats.crit)
defending = target.has_status(StatusEffectType.DEFENDING)
if skill.skill_type == SkillType.PHYSICAL:
dmg = calculate_physical_damage(mu.patk, target.pdef, skill.power, is_crit)
else:
# Reflect check
if target.has_status(StatusEffectType.REFLECT):
self._log(f" {target.name} REFLECTS the spell!")
rdmg = calculate_magical_damage(mu.matk, mu.mdef, skill.power)
mu.take_damage(rdmg)
self._log(f" Reflected! {mu.instance_name} takes {rdmg} damage!")
continue
dmg = calculate_magical_damage(mu.matk, target.mdef, skill.power, is_crit)
if defending:
dmg = int(dmg * 0.7)
crit_str = " CRITICAL!" if is_crit else ""
target.take_damage(dmg)
self._log(f" {target.name} takes {dmg} damage!{crit_str}")
if skill.effect and skill.effect.status and not target.is_ko:
chance = skill.effect.chance if skill.effect.chance > 0 else 0.4
if random.random() < chance:
se = StatusEffect(skill.effect.status, skill.effect.duration,
skill.effect.stat_modifier)
target.add_status(se)
self._log(f" {target.name} afflicted with {skill.effect.status.value}!")
elif skill.skill_type == SkillType.HEAL:
heal_amt = calculate_heal(mu.template.base_stats.mdef, skill.power)
# Heal self or an alive ally monster
alive_m = [m for m in [mu] if not m.is_dead]
for m in alive_m:
old = m.current_hp
m.current_hp = min(m.max_hp, m.current_hp + heal_amt)
self._log(f" {m.instance_name} recovers {m.current_hp - old} HP!")
# ── End of round processing ───────────────────────────────────────────
def _end_of_round(self):
print(f"\n ── End of Turn {self.turn} ──")
# Status effect ticks for party
for ch in self.party:
if not ch.is_ko and not ch.is_dead:
msgs = ch.tick_status_effects()
msgs += ch.tick_buffs()
for m in msgs: self._log(m)
# KO timer management
for ch in self.party:
if ch.is_ko and not ch.is_dead:
ch.ko_turns += 1
if ch.ko_turns >= 3:
ch.is_dead = True
self._log(f" 💀 {ch.name} has DIED! (Too long KO'd)")
# Status ticks for monsters
for mu in self.monsters:
if not mu.is_dead:
msgs = mu.tick_status_effects()
for m in msgs: self._log(m)
mu.tick_break()
# Remove Defending status
for ch in self.party:
ch.remove_status(StatusEffectType.DEFENDING)
if not self._check_battle_end():
input("\n [Press Enter to continue]")
def calculate_exp_reward(self) -> int:
total = sum(mu.template.exp_reward for mu in self.monsters)
return total
def use_item_in_battle(self, ch: Character, item_id: int,
inventory: Dict[int,int]) -> bool:
"""Use an item from inventory in battle. Returns True if used."""
if item_id not in inventory or inventory[item_id] <= 0:
print(" You don't have that item!")
return False
item = ITEM_DB[item_id]
inventory[item_id] -= 1
if inventory[item_id] <= 0:
del inventory[item_id]
self._log(f" {ch.name} uses {item.name}!")
if item.item_type == ItemType.RECOVERY:
# Determine target
if item.target in (SkillTarget.SINGLE_ALLY, SkillTarget.SELF):
valid = [c for c in self.party if not c.is_dead]
if item.target == SkillTarget.SELF:
valid = [ch]
if not valid: return True
target = valid[0] if len(valid)==1 else self._pick_ally_target()
if not target: return True
targets = [target]
else:
targets = [c for c in self.party if not c.is_dead]
for t in targets:
if item.effect_value == -100:
t.current_hp = t.max_hp
t.current_mp = t.max_mp
self._log(f" {t.name} fully restored!")
elif item.effect_value < 0:
pct = abs(item.effect_value)
amt = int(t.max_hp * pct / 100)
actual = t.heal(amt)
self._log(f" {t.name} recovers {actual} HP!")
elif item.id in (6,7,8,9,12,20): # MP items
mp_gain = min(item.effect_value, t.max_mp - t.current_mp)
t.current_mp += mp_gain
self._log(f" {t.name} recovers {mp_gain} MP!")
else:
actual = t.heal(item.effect_value)
self._log(f" {t.name} recovers {actual} HP!")
elif item.item_type == ItemType.REVIVAL:
valid = [c for c in self.party if c.is_ko and not c.is_dead]
if not valid:
self._log(" No KO'd allies to revive!")
return True
target = valid[0] if len(valid)==1 else self._pick_ally_target(include_ko=True)
if not target or not target.is_ko: return True
if item.target == SkillTarget.ALL_ALLIES:
for t in valid:
t.revive(item.effect_value)
self._log(f" {t.name} revived with {item.effect_value}% HP!")
else:
target.revive(item.effect_value)
self._log(f" {target.name} revived with {item.effect_value}% HP!")
elif item.item_type == ItemType.STATUS_CURE:
valid = [c for c in self.party if not c.is_dead]
if item.target == SkillTarget.ALL_ALLIES:
targets = valid
else:
t = self._pick_ally_target()
targets = [t] if t else []
for t in targets:
if item.status_cure:
for stype in item.status_cure:
t.remove_status(stype)
self._log(f" {t.name} cleansed of ailments!")
elif item.item_type == ItemType.BUFF:
valid = [c for c in self.party if not c.is_dead and not c.is_ko]
if item.target == SkillTarget.ALL_ALLIES:
targets = valid
elif item.target == SkillTarget.SELF:
targets = [ch]
else:
t = self._pick_ally_target()
targets = [t] if t else []
for t in targets:
if item.stat_buff:
for stat, mult in item.stat_buff.items():
t.add_buff(stat, mult, item.buff_duration)
self._log(f" {t.name} gains buffs!")
elif item.item_type == ItemType.OFFENSIVE:
alive_enemies = [m for m in self.monsters if not m.is_dead]
if item.target in (SkillTarget.SINGLE_ENEMY,):
t = self._pick_enemy_target()
if not t: return True
targets = [t]
else:
targets = alive_enemies
for t in targets:
is_weak = item.element and t.hit_weakness(None, item.element)
w_bonus = 1.3 if is_weak else 1.0
b_bonus = 1.5 if t.is_broken else 1.0
dmg = int(item.effect_value * w_bonus * b_bonus)
actual = t.take_damage(dmg)
w_str = " 【WEAKNESS】" if is_weak else ""
self._log(f" {t.instance_name} takes {actual} damage!{w_str}")
if is_weak:
broke = t.reduce_shield(1)
if broke:
self._log(f" ⚡ {t.instance_name} is BROKEN!")
elif item.item_type in (ItemType.ADVANCED, ItemType.REVIVAL):
# Handle advanced items
if item.effect_value == -100 or item.effect_value == 9999:
valid = [c for c in self.party if not c.is_dead]
for t in valid:
if item.target == SkillTarget.ALL_ALLIES:
pass
elif item.target == SkillTarget.SINGLE_ALLY:
t = self._pick_ally_target()
valid = [t] if t else []
break
for t in valid:
if item.effect_value == 9999:
actual = t.heal(9999)
self._log(f" {t.name} recovers {actual} HP!")
else:
t.current_hp = t.max_hp
t.current_mp = t.max_mp
self._log(f" {t.name} fully restored!")
if item.stat_buff:
for stat, mult in item.stat_buff.items():
t.add_buff(stat, mult, item.buff_duration)
return True
[Python] Turn-Based Battle Game
character.py
"""Character class - handles stats, leveling, skills, status effects."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from data_types import *
from skills_db import SKILL_DB, JOB_SKILL_POOL
# ── Base stats per job ────────────────────────────────────────────────────────
JOB_BASE_STATS: Dict[JobClass, Stats] = {
JobClass.WARRIOR: Stats(220,40, 28,8, 18,12,12,0.06,0.88,0.10),
JobClass.KNIGHT: Stats(280,50, 22,8, 28,18,10,0.04,0.87,0.06),
JobClass.PALADIN: Stats(260,80, 20,18, 24,22,11,0.05,0.87,0.07),
JobClass.BERSERKER: Stats(240,30, 35,6, 14,10,13,0.05,0.83,0.18),
JobClass.ASSASSIN: Stats(180,60, 26,14, 12,14,20,0.18,0.90,0.22),
JobClass.RANGER: Stats(190,55, 24,12, 14,14,18,0.14,0.92,0.16),
JobClass.HUNTER: Stats(195,55, 24,12, 15,14,17,0.13,0.91,0.15),
JobClass.SPEARMAN: Stats(210,45, 26,8, 16,14,15,0.08,0.88,0.10),
JobClass.DRAGOON: Stats(220,50, 28,10, 16,15,16,0.09,0.88,0.12),
JobClass.MONK: Stats(230,45, 30,8, 16,12,18,0.10,0.89,0.12),
JobClass.CLERIC: Stats(180,100,14,26, 16,24,12,0.05,0.87,0.05),
JobClass.PRIEST: Stats(170,120,12,28, 14,26,11,0.04,0.86,0.04),
JobClass.FIRE_MAGE: Stats(160,110,10,34, 10,18,13,0.07,0.87,0.08),
JobClass.ICE_MAGE: Stats(160,110,10,32, 12,20,12,0.07,0.87,0.08),
JobClass.STORM_MAGE: Stats(160,110,10,33, 10,18,14,0.08,0.87,0.08),
JobClass.WIND_MAGE: Stats(155,105,10,30, 10,18,16,0.10,0.88,0.08),
JobClass.EARTH_MAGE: Stats(170,105,12,30, 14,20,11,0.06,0.87,0.07),
JobClass.DARK_MAGE: Stats(165,110,10,35, 10,16,14,0.09,0.87,0.12),
JobClass.LIGHT_MAGE: Stats(165,110,10,33, 12,20,13,0.07,0.87,0.08),
JobClass.ARCANE_SAGE:Stats(175,130,12,36, 12,22,12,0.07,0.88,0.10),
}
JOB_GROWTH: Dict[JobClass, GrowthRates] = {
JobClass.WARRIOR: GrowthRates(0.85,0.40,0.65,0.30,0.55,0.40,0.40,0.20,0.35,0.25),
JobClass.KNIGHT: GrowthRates(0.90,0.45,0.50,0.25,0.70,0.55,0.30,0.15,0.30,0.15),
JobClass.PALADIN: GrowthRates(0.88,0.60,0.48,0.42,0.60,0.60,0.32,0.18,0.32,0.18),
JobClass.BERSERKER: GrowthRates(0.82,0.30,0.75,0.20,0.40,0.30,0.45,0.15,0.28,0.40),
JobClass.ASSASSIN: GrowthRates(0.65,0.55,0.58,0.35,0.35,0.42,0.65,0.50,0.55,0.60),
JobClass.RANGER: GrowthRates(0.70,0.55,0.55,0.38,0.38,0.42,0.55,0.45,0.60,0.45),
JobClass.HUNTER: GrowthRates(0.72,0.55,0.55,0.38,0.40,0.42,0.52,0.42,0.58,0.42),
JobClass.SPEARMAN: GrowthRates(0.78,0.45,0.60,0.28,0.48,0.40,0.50,0.25,0.40,0.28),
JobClass.DRAGOON: GrowthRates(0.80,0.48,0.62,0.32,0.48,0.42,0.52,0.28,0.42,0.32),
JobClass.MONK: GrowthRates(0.80,0.42,0.65,0.25,0.50,0.38,0.55,0.30,0.45,0.35),
JobClass.CLERIC: GrowthRates(0.72,0.75,0.28,0.58,0.45,0.65,0.35,0.20,0.32,0.15),
JobClass.PRIEST: GrowthRates(0.68,0.80,0.25,0.62,0.42,0.70,0.32,0.18,0.30,0.12),
JobClass.FIRE_MAGE: GrowthRates(0.60,0.78,0.22,0.72,0.28,0.48,0.40,0.22,0.35,0.22),
JobClass.ICE_MAGE: GrowthRates(0.60,0.78,0.22,0.70,0.30,0.50,0.38,0.22,0.35,0.22),
JobClass.STORM_MAGE: GrowthRates(0.60,0.78,0.22,0.72,0.28,0.48,0.42,0.22,0.35,0.22),
JobClass.WIND_MAGE: GrowthRates(0.58,0.75,0.22,0.68,0.28,0.48,0.50,0.28,0.38,0.20),
JobClass.EARTH_MAGE: GrowthRates(0.65,0.75,0.25,0.68,0.35,0.50,0.35,0.18,0.32,0.18),
JobClass.DARK_MAGE: GrowthRates(0.60,0.78,0.20,0.75,0.26,0.46,0.42,0.28,0.35,0.30),
JobClass.LIGHT_MAGE: GrowthRates(0.62,0.78,0.22,0.72,0.30,0.52,0.40,0.22,0.34,0.20),
JobClass.ARCANE_SAGE:GrowthRates(0.65,0.82,0.28,0.78,0.32,0.55,0.42,0.25,0.38,0.25),
}
@dataclass
class Character:
name: str
job: JobClass
weapon: WeaponType
element: Element
rarity: int # 0=player, 3/4/5=companion
base_stats: Stats
growth: GrowthRates
# All skills the character CAN have (assigned at creation)
skill_pool: List[int] = field(default_factory=list) # 5 skill ids
# Skills currently UNLOCKED
unlocked_skills: List[int] = field(default_factory=list)
level: int = 1
exp: int = 0
exp_to_next: int = 100
# Runtime state
current_hp: int = 0
current_mp: int = 0
status_effects: List[StatusEffect] = field(default_factory=list)
temp_buffs: Dict[str, Tuple[float,int]] = field(default_factory=dict) # stat -> (mult, turns_remaining)
is_dead: bool = False # permanent death
ko_turns: int = 0 # turns spent at 0 hp (KO counter)
is_ko: bool = False # currently knocked out in battle
battle_count: int = 0 # total battles participated
def __post_init__(self):
if self.current_hp == 0:
self.current_hp = self.base_stats.hp
if self.current_mp == 0:
self.current_mp = self.base_stats.mp
# ── Stat helpers ─────────────────────────────────────────────────────
def effective_stat(self, stat_name: str) -> float:
base = getattr(self.base_stats, stat_name)
mult = 1.0
for effect_name, (m, turns) in self.temp_buffs.items():
if effect_name == stat_name:
mult *= m
# Status effects
for se in self.status_effects:
if se.effect_type == StatusEffectType.ATTACK_DOWN and stat_name == "patk":
mult *= 0.7
elif se.effect_type == StatusEffectType.DEFENSE_DOWN and stat_name == "pdef":
mult *= 0.7
elif se.effect_type == StatusEffectType.MAGIC_DOWN and stat_name == "matk":
mult *= 0.7
elif se.effect_type == StatusEffectType.SPEED_DOWN and stat_name == "spd":
mult *= 0.7
elif se.effect_type == StatusEffectType.ACCURACY_DOWN and stat_name == "acc":
mult *= 0.6
elif se.effect_type == StatusEffectType.BERSERK:
if stat_name == "patk": mult *= 1.5
if stat_name == "pdef": mult *= 0.7
elif se.effect_type == StatusEffectType.GUARD_UP:
if stat_name in ("pdef","mdef"): mult *= 1.4
elif se.effect_type == StatusEffectType.FOCUS:
if stat_name in ("acc","crit"): mult *= 1.5
elif se.effect_type == StatusEffectType.MAGIC_BOOST:
if stat_name == "matk": mult *= 1.4
elif se.effect_type == StatusEffectType.HASTE:
if stat_name == "spd": mult *= 1.5
return base * mult
@property
def max_hp(self): return self.base_stats.hp
@property
def max_mp(self): return self.base_stats.mp
@property
def spd(self): return int(self.effective_stat("spd"))
@property
def patk(self): return int(self.effective_stat("patk"))
@property
def matk(self): return int(self.effective_stat("matk"))
@property
def pdef(self): return int(self.effective_stat("pdef"))
@property
def mdef(self): return int(self.effective_stat("mdef"))
@property
def eva(self): return self.effective_stat("eva")
@property
def acc(self): return self.effective_stat("acc")
@property
def crit(self): return self.effective_stat("crit")
def is_incapacitated(self) -> bool:
"""Cannot act at all."""
incap = {StatusEffectType.SLEEP, StatusEffectType.STUN,
StatusEffectType.FREEZE, StatusEffectType.PARALYZE,
StatusEffectType.PETRIFY, StatusEffectType.TIME_STOP}
return any(se.effect_type in incap for se in self.status_effects) or self.is_ko
def can_use_magic(self) -> bool:
blocked = {StatusEffectType.SILENCE, StatusEffectType.MANA_BURN}
return not any(se.effect_type in blocked for se in self.status_effects)
def can_use_skills(self) -> bool:
return not any(se.effect_type == StatusEffectType.SKILL_SEAL for se in self.status_effects)
def can_use_items(self) -> bool:
return not any(se.effect_type == StatusEffectType.ITEM_SEAL for se in self.status_effects)
def can_be_healed(self) -> bool:
return not any(se.effect_type == StatusEffectType.HEAL_BLOCK for se in self.status_effects)
def has_status(self, stype: StatusEffectType) -> bool:
return any(se.effect_type == stype for se in self.status_effects)
def add_status(self, effect: StatusEffect):
# Don't stack same type (refresh duration instead)
for existing in self.status_effects:
if existing.effect_type == effect.effect_type:
existing.duration = max(existing.duration, effect.duration)
return
self.status_effects.append(effect)
def remove_status(self, stype: StatusEffectType):
self.status_effects = [s for s in self.status_effects if s.effect_type != stype]
def add_buff(self, stat: str, mult: float, duration: int):
if stat in self.temp_buffs:
old_mult, old_dur = self.temp_buffs[stat]
self.temp_buffs[stat] = (max(old_mult, mult), max(old_dur, duration))
else:
self.temp_buffs[stat] = (mult, duration)
def tick_status_effects(self) -> List[str]:
"""Process status effects at turn end. Returns list of messages."""
messages = []
to_remove = []
for se in self.status_effects:
msg = self._apply_status_tick(se)
if msg:
messages.append(msg)
if not se.tick():
to_remove.append(se)
self.status_effects = [s for s in self.status_effects if s not in to_remove]
for expired in to_remove:
messages.append(f" {self.name}'s {expired.effect_type.value} wore off.")
return messages
def _apply_status_tick(self, se: StatusEffect) -> Optional[str]:
if se.effect_type == StatusEffectType.POISON:
dmg = max(1, int(self.max_hp * 0.05))
self.take_damage(dmg)
return f" {self.name} is poisoned! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.VENOM:
dmg = max(1, int(self.max_hp * 0.08))
self.take_damage(dmg)
return f" {self.name} suffers venom! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.BURN:
dmg = max(1, int(self.max_hp * 0.06))
self.take_damage(dmg)
return f" {self.name} is burning! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.BLEED:
dmg = max(1, int(self.max_hp * 0.04))
self.take_damage(dmg)
return f" {self.name} bleeds! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.CURSE:
dmg = max(1, int(self.max_hp * 0.03))
self.take_damage(dmg)
return f" {self.name} is cursed! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.REGEN:
heal = max(1, int(self.mdef * 1.5))
self.heal(heal)
return f" {self.name} regenerates! (+{heal} HP)"
elif se.effect_type == StatusEffectType.MANA_REGEN:
mp = max(1, int(self.max_mp * 0.05))
self.current_mp = min(self.max_mp, self.current_mp + mp)
return f" {self.name} regenerates MP! (+{mp} MP)"
elif se.effect_type == StatusEffectType.DOOM:
if se.duration <= 1:
self.current_hp = 0
self.is_ko = True
return f" ☠ DOOM strikes {self.name}! Instant KO!"
else:
return f" ⏳ DOOM countdown: {se.duration} turns left for {self.name}!"
return None
def tick_buffs(self) -> List[str]:
messages = []
expired = [k for k,(m,d) in self.temp_buffs.items() if d <= 1]
self.temp_buffs = {k:(m,d-1) for k,(m,d) in self.temp_buffs.items() if d > 1}
for k in expired:
messages.append(f" {self.name}'s {k} buff expired.")
return messages
def take_damage(self, amount: int):
# Check shield
if self.has_status(StatusEffectType.SHIELD):
se = next(s for s in self.status_effects if s.effect_type == StatusEffectType.SHIELD)
shield_val = int(se.power)
if shield_val >= amount:
se.power -= amount
if se.power <= 0:
self.remove_status(StatusEffectType.SHIELD)
return
else:
amount -= shield_val
self.remove_status(StatusEffectType.SHIELD)
self.current_hp = max(0, self.current_hp - amount)
if self.current_hp == 0:
self.is_ko = True
def heal(self, amount: int):
if not self.can_be_healed():
return 0
actual = min(amount, self.max_hp - self.current_hp)
self.current_hp += actual
if self.current_hp > 0:
self.is_ko = False
self.ko_turns = 0
return actual
def revive(self, hp_percent: int):
self.is_ko = False
self.ko_turns = 0
self.current_hp = max(1, int(self.max_hp * hp_percent / 100))
# ── Leveling ─────────────────────────────────────────────────────────
def gain_exp(self, amount: int) -> List[str]:
messages = []
self.exp += amount
while self.exp >= self.exp_to_next:
self.exp -= self.exp_to_next
messages += self._level_up()
return messages
def _level_up(self) -> List[str]:
self.level += 1
self.exp_to_next = int(self.exp_to_next * 1.15)
g = self.growth
messages = [f" ★ {self.name} reached Level {self.level}!"]
def roll(rate, lo, hi):
return random.randint(lo, hi) if random.random() < rate else 0
hp_gain = roll(g.hp, 5, 12)
mp_gain = roll(g.mp, 3, 8)
patk_gain = roll(g.patk, 1, 4)
matk_gain = roll(g.matk, 1, 4)
pdef_gain = roll(g.pdef, 1, 3)
mdef_gain = roll(g.mdef, 1, 3)
spd_gain = roll(g.spd, 1, 1)
eva_gain = roll(g.eva, 0, 0) # tracked differently
acc_gain = 0
crit_gain = 0
self.base_stats.hp += hp_gain
self.base_stats.mp += mp_gain
self.base_stats.patk += patk_gain
self.base_stats.matk += matk_gain
self.base_stats.pdef += pdef_gain
self.base_stats.mdef += mdef_gain
self.base_stats.spd += spd_gain
if random.random() < g.eva: self.base_stats.eva = min(0.95, self.base_stats.eva + 0.01)
if random.random() < g.acc: self.base_stats.acc = min(1.0, self.base_stats.acc + 0.005)
if random.random() < g.crit: self.base_stats.crit= min(0.95, self.base_stats.crit+ 0.005)
# Heal to full on level up
self.current_hp = self.base_stats.hp
self.current_mp = self.base_stats.mp
gains = []
if hp_gain: gains.append(f"HP+{hp_gain}")
if mp_gain: gains.append(f"MP+{mp_gain}")
if patk_gain: gains.append(f"PATK+{patk_gain}")
if matk_gain: gains.append(f"MATK+{matk_gain}")
if pdef_gain: gains.append(f"PDEF+{pdef_gain}")
if mdef_gain: gains.append(f"MDEF+{mdef_gain}")
if spd_gain: gains.append(f"SPD+{spd_gain}")
if gains:
messages.append(f" Stats: {', '.join(gains)}")
# Unlock skills
unlock_msgs = self._check_skill_unlocks()
messages.extend(unlock_msgs)
return messages
def _check_skill_unlocks(self) -> List[str]:
msgs = []
# Intermediate: lv 5 and 10
intermediate_ids = [sid for sid in self.skill_pool
if sid in SKILL_DB and SKILL_DB[sid].tier == SkillTier.INTERMEDIATE]
if self.level == 5 and len(intermediate_ids) >= 1:
sid = intermediate_ids[0]
if sid not in self.unlocked_skills:
self.unlocked_skills.append(sid)
msgs.append(f" ✦ Skill Unlocked: {SKILL_DB[sid].name}!")
if self.level == 10 and len(intermediate_ids) >= 2:
sid = intermediate_ids[1]
if sid not in self.unlocked_skills:
self.unlocked_skills.append(sid)
msgs.append(f" ✦ Skill Unlocked: {SKILL_DB[sid].name}!")
# Ultimate: lv 20
if self.level == 20:
ultimate_ids = [sid for sid in self.skill_pool
if sid in SKILL_DB and SKILL_DB[sid].tier == SkillTier.ULTIMATE]
if ultimate_ids:
sid = ultimate_ids[0]
if sid not in self.unlocked_skills:
self.unlocked_skills.append(sid)
msgs.append(f" ★ ULTIMATE SKILL UNLOCKED: {SKILL_DB[sid].name}!!")
return msgs
def hp_bar(self, width=20) -> str:
ratio = self.current_hp / max(1, self.max_hp)
filled = int(ratio * width)
bar = "█" * filled + "░" * (width - filled)
return f"[{bar}] {self.current_hp}/{self.max_hp}"
def mp_bar(self, width=10) -> str:
ratio = self.current_mp / max(1, self.max_mp)
filled = int(ratio * width)
bar = "▓" * filled + "░" * (width - filled)
return f"[{bar}] {self.current_mp}/{self.max_mp}"
def status_str(self) -> str:
if not self.status_effects:
return ""
tags = [se.effect_type.value[:3].upper() for se in self.status_effects]
return " [" + ",".join(tags) + "]"
def short_status(self) -> str:
if self.is_dead: return "DEAD"
if self.is_ko: return f"KO({self.ko_turns})"
return "OK"
def create_player_character(name: str, job: JobClass,
weapon: WeaponType, element: Element) -> Character:
base = JOB_BASE_STATS[job].copy()
growth = JOB_GROWTH[job]
pool = JOB_SKILL_POOL[job]
# Pick 2 basic, 2 intermediate, 1 ultimate from pool
basics = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.BASIC][:4]
inters = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.INTERMEDIATE][:4]
ultims = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.ULTIMATE][:2]
chosen_basic = random.sample(basics, min(2, len(basics)))
chosen_inter = random.sample(inters, min(2, len(inters)))
chosen_ultim = random.sample(ultims, min(1, len(ultims)))
skill_pool = chosen_basic + chosen_inter + chosen_ultim
unlocked = chosen_basic[:] # Only basics at level 1
char = Character(
name=name, job=job, weapon=weapon, element=element, rarity=0,
base_stats=base, growth=growth,
skill_pool=skill_pool, unlocked_skills=unlocked
)
return char
[Python] Turn-Based Battle Game
companions.py
"""Companion character database: 20x3★, 10x4★, 5x5★."""
from __future__ import annotations
import random
from data_types import *
from character import Character, JOB_BASE_STATS, JOB_GROWTH
from skills_db import SKILL_DB, JOB_SKILL_POOL
def _make_companion(name, job, weapon, element, rarity,
stat_mult=1.0, growth_mult=1.0) -> Character:
base = JOB_BASE_STATS[job].copy()
g = JOB_GROWTH[job]
# Apply rarity scaling
base.hp = int(base.hp * stat_mult)
base.mp = int(base.mp * stat_mult)
base.patk = int(base.patk * stat_mult)
base.matk = int(base.matk * stat_mult)
base.pdef = int(base.pdef * stat_mult)
base.mdef = int(base.mdef * stat_mult)
base.spd = int(base.spd * stat_mult)
# Scale growth rates
scaled_g = GrowthRates(
hp = min(0.99, g.hp * growth_mult),
mp = min(0.99, g.mp * growth_mult),
patk = min(0.99, g.patk * growth_mult),
matk = min(0.99, g.matk * growth_mult),
pdef = min(0.99, g.pdef * growth_mult),
mdef = min(0.99, g.mdef * growth_mult),
spd = min(0.99, g.spd * growth_mult),
eva = min(0.99, g.eva * growth_mult),
acc = min(0.99, g.acc * growth_mult),
crit = min(0.99, g.crit * growth_mult),
)
pool = JOB_SKILL_POOL[job]
basics = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.BASIC][:4]
inters = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.INTERMEDIATE][:4]
ultims = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.ULTIMATE][:2]
chosen_basic = random.sample(basics, min(2, len(basics)))
chosen_inter = random.sample(inters, min(2, len(inters)))
chosen_ultim = random.sample(ultims, min(1, len(ultims)))
skill_pool = chosen_basic + chosen_inter + chosen_ultim
unlocked = chosen_basic[:]
return Character(
name=name, job=job, weapon=weapon, element=element, rarity=rarity,
base_stats=base, growth=scaled_g,
skill_pool=skill_pool, unlocked_skills=unlocked
)
# ══════════════════════════════════════════════════════════════════════════
# COMPANION TEMPLATES (factories — called fresh each game)
# ══════════════════════════════════════════════════════════════════════════
# stat_mult: 3★=0.90~0.95, 4★=1.10~1.20, 5★=1.35~1.50
# growth_mult: 3★=0.85~0.90, 4★=1.05~1.15, 5★=1.25~1.40
THREE_STAR_TEMPLATES = [
# name, job, weapon, element, stat_mult, growth_mult
("Rook", JobClass.WARRIOR, WeaponType.SWORD, Element.FIRE, 0.90, 0.88),
("Gara", JobClass.KNIGHT, WeaponType.SWORD, Element.EARTH, 0.92, 0.87),
("Tifa", JobClass.MONK, WeaponType.AXE, Element.WIND, 0.91, 0.88),
("Sera", JobClass.CLERIC, WeaponType.STAFF, Element.LIGHT, 0.93, 0.88),
("Rex", JobClass.RANGER, WeaponType.BOW, Element.ICE, 0.90, 0.87),
("Bram", JobClass.BERSERKER, WeaponType.AXE, Element.FIRE, 0.89, 0.86),
("Nira", JobClass.WIND_MAGE, WeaponType.STAFF, Element.WIND, 0.92, 0.87),
("Dag", JobClass.SPEARMAN, WeaponType.SPEAR, Element.LIGHTNING, 0.91, 0.88),
("Ella", JobClass.FIRE_MAGE, WeaponType.STAFF, Element.FIRE, 0.90, 0.87),
("Wynn", JobClass.HUNTER, WeaponType.BOW, Element.EARTH, 0.93, 0.88),
("Bart", JobClass.EARTH_MAGE, WeaponType.STAFF, Element.EARTH, 0.91, 0.86),
("Yuna", JobClass.PRIEST, WeaponType.STAFF, Element.LIGHT, 0.92, 0.87),
("Kage", JobClass.ASSASSIN, WeaponType.DAGGER, Element.DARK, 0.90, 0.86),
("Coral", JobClass.ICE_MAGE, WeaponType.STAFF, Element.ICE, 0.91, 0.87),
("Miles", JobClass.WARRIOR, WeaponType.AXE, Element.WIND, 0.90, 0.86),
("Dora", JobClass.CLERIC, WeaponType.STAFF, Element.FIRE, 0.92, 0.87),
("Oryn", JobClass.STORM_MAGE, WeaponType.STAFF, Element.LIGHTNING, 0.90, 0.86),
("Fenn", JobClass.PALADIN, WeaponType.SWORD, Element.LIGHT, 0.93, 0.88),
("Zell", JobClass.DRAGOON, WeaponType.SPEAR, Element.WIND, 0.91, 0.87),
("Mira", JobClass.DARK_MAGE, WeaponType.STAFF, Element.DARK, 0.90, 0.86),
]
FOUR_STAR_TEMPLATES = [
("Cael", JobClass.KNIGHT, WeaponType.SWORD, Element.LIGHT, 1.12, 1.10),
("Lyra", JobClass.ARCANE_SAGE,WeaponType.STAFF, Element.LIGHTNING, 1.15, 1.12),
("Vance", JobClass.BERSERKER, WeaponType.AXE, Element.DARK, 1.18, 1.14),
("Shira", JobClass.ASSASSIN, WeaponType.DAGGER, Element.ICE, 1.12, 1.10),
("Bael", JobClass.DRAGOON, WeaponType.SPEAR, Element.FIRE, 1.14, 1.11),
("Noel", JobClass.PRIEST, WeaponType.STAFF, Element.LIGHT, 1.15, 1.12),
("Kira", JobClass.STORM_MAGE, WeaponType.STAFF, Element.LIGHTNING, 1.16, 1.13),
("Roan", JobClass.PALADIN, WeaponType.SWORD, Element.LIGHT, 1.14, 1.10),
("Zara", JobClass.HUNTER, WeaponType.BOW, Element.WIND, 1.12, 1.10),
("Drax", JobClass.MONK, WeaponType.AXE, Element.EARTH, 1.18, 1.14),
]
FIVE_STAR_TEMPLATES = [
("Seraph", JobClass.LIGHT_MAGE, WeaponType.STAFF, Element.LIGHT, 1.45, 1.38),
("Abyss", JobClass.DARK_MAGE, WeaponType.STAFF, Element.DARK, 1.42, 1.35),
("Titan", JobClass.BERSERKER, WeaponType.AXE, Element.EARTH, 1.50, 1.40),
("Oracle", JobClass.ARCANE_SAGE,WeaponType.STAFF, Element.LIGHTNING, 1.45, 1.40),
("Valkyrie",JobClass.PALADIN, WeaponType.SWORD, Element.LIGHT, 1.48, 1.38),
]
COMPANION_GACHA_POOL = {
3: THREE_STAR_TEMPLATES,
4: FOUR_STAR_TEMPLATES,
5: FIVE_STAR_TEMPLATES,
}
GACHA_RATES = {3: 0.65, 4: 0.30, 5: 0.05}
def summon_companion(target_level: int = 2) -> Character:
"""Roll a random companion via gacha, leveled to target_level."""
roll = random.random()
cumulative = 0.0
rarity = 3
for r, rate in GACHA_RATES.items():
cumulative += rate
if roll < cumulative:
rarity = r
break
templates = COMPANION_GACHA_POOL[rarity]
tmpl = random.choice(templates)
name, job, weapon, element, stat_mult, growth_mult = tmpl
char = _make_companion(name, job, weapon, element, rarity,
stat_mult, growth_mult)
# Level the character up silently
for _ in range(1, target_level):
char._level_up()
char.level = target_level
char.current_hp = char.max_hp
char.current_mp = char.max_mp
return char
[Python] Turn-Based Battle Game
data_types.py
"""Core data types, enums, and constants for the JRPG game."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional, List, Dict, Any
class WeaponType(Enum):
SWORD = "Sword"
SPEAR = "Spear"
AXE = "Axe"
DAGGER = "Dagger"
BOW = "Bow"
STAFF = "Staff"
class Element(Enum):
FIRE = "Fire"
ICE = "Ice"
LIGHTNING = "Lightning"
WIND = "Wind"
EARTH = "Earth"
LIGHT = "Light"
DARK = "Dark"
NONE = "None"
class JobClass(Enum):
WARRIOR = "Warrior"
KNIGHT = "Knight"
PALADIN = "Paladin"
BERSERKER = "Berserker"
ASSASSIN = "Assassin"
RANGER = "Ranger"
HUNTER = "Hunter"
SPEARMAN = "Spearman"
DRAGOON = "Dragoon"
MONK = "Monk"
CLERIC = "Cleric"
PRIEST = "Priest"
FIRE_MAGE = "Fire Mage"
ICE_MAGE = "Ice Mage"
STORM_MAGE = "Storm Mage"
WIND_MAGE = "Wind Mage"
EARTH_MAGE = "Earth Mage"
DARK_MAGE = "Dark Mage"
LIGHT_MAGE = "Light Mage"
ARCANE_SAGE = "Arcane Sage"
class SkillType(Enum):
PHYSICAL = "Physical"
MAGICAL = "Magical"
HEAL = "Heal"
BUFF = "Buff"
DEBUFF = "Debuff"
SPECIAL = "Special"
class SkillTier(Enum):
BASIC = "Basic"
INTERMEDIATE = "Intermediate"
ULTIMATE = "Ultimate"
class SkillTarget(Enum):
SINGLE_ENEMY = "SingleEnemy"
ALL_ENEMIES = "AllEnemies"
SINGLE_ALLY = "SingleAlly"
ALL_ALLIES = "AllAllies"
SELF = "Self"
RANDOM_ENEMY = "RandomEnemy"
class StatusEffectType(Enum):
# Damage over time
POISON = "Poison"
BURN = "Burn"
BLEED = "Bleed"
VENOM = "Venom"
CURSE = "Curse"
# Action restriction
SLEEP = "Sleep"
STUN = "Stun"
FREEZE = "Freeze"
PARALYZE = "Paralyze"
PETRIFY = "Petrify"
# Debuffs
ATTACK_DOWN = "Attack Down"
DEFENSE_DOWN = "Defense Down"
MAGIC_DOWN = "Magic Down"
SPEED_DOWN = "Speed Down"
ACCURACY_DOWN = "Accuracy Down"
# Buff blockers
SILENCE = "Silence"
SKILL_SEAL = "Skill Seal"
ITEM_SEAL = "Item Seal"
HEAL_BLOCK = "Heal Block"
MANA_BURN = "Mana Burn"
# Persistent effects
REGEN = "Regen"
MANA_REGEN = "Mana Regen"
SHIELD = "Shield"
REFLECT = "Reflect"
COUNTER = "Counter"
# Special
CONFUSION = "Confusion"
CHARM = "Charm"
FEAR = "Fear"
BLIND = "Blind"
WEAKNESS_MARK = "Weakness Mark"
# Enhancements
BERSERK = "Berserk"
HASTE = "Haste"
FOCUS = "Focus"
GUARD_UP = "Guard Up"
MAGIC_BOOST = "Magic Boost"
# Advanced
DOOM = "Doom"
TIME_STOP = "Time Stop"
CURSE_MARK = "Curse Mark"
BLOOD_LINK = "Blood Link"
SOUL_DRAIN = "Soul Drain"
# Defend
DEFENDING = "Defending"
class AIType(Enum):
AGGRESSIVE = "Aggressive"
DEFENSIVE = "Defensive"
BALANCED = "Balanced"
SUPPORT = "Support"
RANDOM = "Random"
BERSERKER = "Berserker"
TACTICAL = "Tactical"
class ItemType(Enum):
RECOVERY = "Recovery"
STATUS_CURE = "Status Cure"
BUFF = "Buff"
OFFENSIVE = "Offensive"
ADVANCED = "Advanced"
REVIVAL = "Revival"
@dataclass
class SkillEffect:
status: Optional[StatusEffectType] = None
duration: int = 0
chance: float = 0.0
stat_modifier: float = 1.0
heal_percent: float = 0.0
shield_amount: int = 0
@dataclass
class Skill:
id: int
name: str
skill_type: SkillType
power: float
mp_cost: int
accuracy: float
element: Element
hits: int
target: SkillTarget
tier: SkillTier
description: str
effect: Optional[SkillEffect] = None
jobs: List[JobClass] = field(default_factory=list)
@dataclass
class StatusEffect:
effect_type: StatusEffectType
duration: int
power: float = 1.0
source_name: str = ""
def tick(self) -> bool:
"""Returns True if effect is still active after tick."""
if self.duration > 0:
self.duration -= 1
return self.duration > 0 or self.duration == -1 # -1 = permanent until removed
@dataclass
class Item:
id: int
name: str
item_type: ItemType
description: str
effect_value: int
target: SkillTarget
element: Optional[Element] = None
status_cure: Optional[List[StatusEffectType]] = None
stat_buff: Optional[Dict[str, float]] = None
buff_duration: int = 0
@dataclass
class Stats:
hp: int
mp: int
patk: int
matk: int
pdef: int
mdef: int
spd: int
eva: float
acc: float
crit: float
def copy(self) -> 'Stats':
return Stats(
hp=self.hp, mp=self.mp, patk=self.patk, matk=self.matk,
pdef=self.pdef, mdef=self.mdef, spd=self.spd,
eva=self.eva, acc=self.acc, crit=self.crit
)
@dataclass
class GrowthRates:
hp: float
mp: float
patk: float
matk: float
pdef: float
mdef: float
spd: float
eva: float
acc: float
crit: float
RARITY_COLORS = {
3: "★★★",
4: "★★★★",
5: "★★★★★"
}
[Python] Turn-Based Battle Game
game_state.py
"""Game state: manages party, inventory, battle progression."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from data_types import *
from character import Character, create_player_character
from monster_unit import MonsterUnit
from monsters_db import get_encounter, MONSTER_DB
from items_db import ITEM_DB
from skills_db import SKILL_DB
from battle import Battle
from companions_db import summon_companion
# Starting inventory
DEFAULT_INVENTORY = {
1: 5, # Potion x5
6: 3, # Ether x3
31: 2, # Phoenix Feather x2
41: 2, # Antidote x2
86: 2, # Fire Bomb x2
}
# Battle milestones for companion rewards
COMPANION_MILESTONES = {
1: 2, # After battle 1: lv2 companion
5: 5, # After battle 5: lv5 companion
10: 7, # After battle 10: lv7-8 companion
20: 15, # After battle 20: lv15 companion
50: 40, # After battle 50: lv40 companion
75: 47, # After battle 75: lv45-50 companion
100:60, # After battle 100: lv60 companion
}
class GameState:
def __init__(self):
self.player: Optional[Character] = None
self.party: List[Character] = []
self.inventory: Dict[int, int] = dict(DEFAULT_INVENTORY)
self.battle_number: int = 0
self.game_over: bool = False
self.deceased_companions: List[Character] = []
# Records for game over screen
self.all_party_members: List[Character] = []
# ── Inventory ─────────────────────────────────────────────────────────
def add_item(self, item_id: int, count: int = 1):
self.inventory[item_id] = self.inventory.get(item_id, 0) + count
def display_inventory(self):
if not self.inventory:
print(" (Empty)")
return
print(" INVENTORY:")
for iid, count in sorted(self.inventory.items()):
if iid in ITEM_DB and count > 0:
item = ITEM_DB[iid]
print(f" {item.name} x{count} - {item.description}")
def use_item_outside_battle(self, item_id: int, target: Character) -> bool:
if item_id not in self.inventory or self.inventory[item_id] <= 0:
print(" You don't have that item!")
return False
item = ITEM_DB[item_id]
self.inventory[item_id] -= 1
if self.inventory[item_id] <= 0:
del self.inventory[item_id]
if item.item_type == ItemType.RECOVERY:
if item.effect_value == -100:
target.current_hp = target.max_hp
target.current_mp = target.max_mp
print(f" {target.name} fully restored!")
elif item.effect_value < 0:
pct = abs(item.effect_value)
amt = int(target.max_hp * pct / 100)
actual = target.heal(amt)
print(f" {target.name} recovers {actual} HP!")
elif item.id in (6,7,8,9,12,20):
mp = min(item.effect_value, target.max_mp - target.current_mp)
target.current_mp += mp
print(f" {target.name} recovers {mp} MP!")
else:
actual = target.heal(item.effect_value)
print(f" {target.name} recovers {actual} HP!")
elif item.item_type == ItemType.REVIVAL:
if target.is_ko and not target.is_dead:
target.revive(item.effect_value)
print(f" {target.name} revived with {item.effect_value}% HP!")
else:
print(f" {target.name} isn't KO'd.")
self.inventory[item_id] = self.inventory.get(item_id, 0) + 1 # refund
return False
elif item.item_type == ItemType.STATUS_CURE:
if item.status_cure:
for stype in item.status_cure:
target.remove_status(stype)
print(f" {target.name} cleansed!")
return True
# ── Party management ──────────────────────────────────────────────────
def display_party(self):
print("\n PARTY:")
for i, ch in enumerate(self.party):
rarity_str = f" {'★'*ch.rarity}" if ch.rarity > 0 else " [PLAYER]"
status = ch.short_status()
print(f" [{i+1}] {ch.name} Lv{ch.level} {ch.job.value}{rarity_str} - {status}")
print(f" HP:{ch.current_hp}/{ch.max_hp} MP:{ch.current_mp}/{ch.max_mp}")
print(f" PATK:{ch.patk} MATK:{ch.matk} PDEF:{ch.pdef} MDEF:{ch.mdef} SPD:{ch.spd}")
def display_character_detail(self, ch: Character):
print(f"\n ── {ch.name} ──────────────────────────────")
rarity_str = f"{'★'*ch.rarity}" if ch.rarity > 0 else "PLAYER"
print(f" Job: {ch.job.value} Rarity: {rarity_str}")
print(f" Weapon: {ch.weapon.value} Element: {ch.element.value}")
print(f" Level: {ch.level} EXP: {ch.exp}/{ch.exp_to_next}")
print(f" HP: {ch.current_hp}/{ch.max_hp} MP: {ch.current_mp}/{ch.max_mp}")
print(f" PATK:{ch.patk} MATK:{ch.matk} PDEF:{ch.pdef} MDEF:{ch.mdef}")
print(f" SPD:{ch.spd} EVA:{ch.base_stats.eva:.0%} ACC:{ch.base_stats.acc:.0%} CRIT:{ch.base_stats.crit:.0%}")
print(f" Skills:")
for sid in ch.skill_pool:
if sid in SKILL_DB:
sk = SKILL_DB[sid]
unlocked = "✓" if sid in ch.unlocked_skills else "✗"
unlock_lv = ""
if sk.tier == SkillTier.INTERMEDIATE:
unlock_lv = " (Lv5/10)"
elif sk.tier == SkillTier.ULTIMATE:
unlock_lv = " (Lv20)"
print(f" [{unlocked}] {sk.name} [{sk.tier.value}] MP:{sk.mp_cost}{unlock_lv}")
def offer_companion(self, companion: Character):
"""Offer a new companion to the player."""
print("\n" + "★"*65)
rarity_str = "★" * companion.rarity
print(f" A new companion has appeared! [{rarity_str}]")
print(f" {companion.name} Lv{companion.level} | {companion.job.value}")
print(f" HP:{companion.max_hp} PATK:{companion.base_stats.patk} MATK:{companion.base_stats.matk}")
print(f" SPD:{companion.base_stats.spd} Weapon:{companion.weapon.value} Element:{companion.element.value}")
alive_party = [ch for ch in self.party if not ch.is_dead]
if len(alive_party) < 4:
print(f"\n Adding {companion.name} to the party!")
self.party.append(companion)
self.all_party_members.append(companion)
input(" [Press Enter]")
else:
print(f"\n Your party is full (4/4). Dismiss a member to make room?")
self.display_party()
print(f" [1-{len(self.party)}] Dismiss member [0] Decline companion")
while True:
try:
choice = int(input(" > "))
if choice == 0:
print(f" Declined {companion.name}.")
input(" [Press Enter]")
return
idx = choice - 1
if 0 <= idx < len(self.party):
dismiss_target = self.party[idx]
if dismiss_target == self.player:
print(" Cannot dismiss player character!")
continue
self.party.remove(dismiss_target)
print(f" {dismiss_target.name} has left the party.")
self.party.append(companion)
self.all_party_members.append(companion)
print(f" {companion.name} joined the party!")
input(" [Press Enter]")
return
except ValueError:
pass
print(" Invalid choice.")
# ── Battle ────────────────────────────────────────────────────────────
def run_battle(self) -> bool:
"""Run a battle. Returns True if player survived."""
self.battle_number += 1
# Get encounter
templates = get_encounter(self.battle_number)
# Name duplicates
name_count: Dict[str, int] = {}
monster_units = []
for t in templates:
name_count[t.name] = name_count.get(t.name, 0) + 1
used: Dict[str, int] = {}
for t in templates:
used[t.name] = used.get(t.name, 0) + 1
label = t.name if name_count[t.name] == 1 else f"{t.name} {chr(64 + used[t.name])}"
mu = MonsterUnit(template=t, instance_name=label)
monster_units.append(mu)
alive_party = [ch for ch in self.party if not ch.is_dead]
battle = Battle(alive_party, monster_units)
# Inject item use capability into battle
battle._item_use_callback = self._battle_item_callback
battle._original_use_item_menu = battle._use_item_menu
battle._use_item_menu = lambda ch: self._battle_item_ui(ch, battle)
victory = battle.run()
if victory:
exp = battle.calculate_exp_reward()
print(f"\n ✓ VICTORY! Earned {exp} EXP.")
# Award loot
self._award_loot()
alive_after = [ch for ch in alive_party if not ch.is_dead]
for ch in alive_after:
msgs = ch.gain_exp(exp)
for m in msgs:
print(m)
input(" [Press Enter]")
# Handle companion death
for ch in alive_party:
if ch.is_dead and ch != self.player:
print(f"\n 💀 {ch.name} has permanently died and left the party.")
self.party.remove(ch)
self.deceased_companions.append(ch)
# Check milestone companions
self._check_companion_milestone()
else:
# Check if player character survived
player_dead = self.player.is_dead or self.player.is_ko
if player_dead:
self.game_over = True
print(f"\n ☠ {self.player.name} has fallen... GAME OVER")
return False
else:
print(f"\n DEFEAT! Some party members have fallen.")
for ch in alive_party:
if ch.is_dead and ch != self.player:
print(f" 💀 {ch.name} has permanently died.")
self.party.remove(ch)
self.deceased_companions.append(ch)
input(" [Press Enter]")
# Post-battle: reset KO state for surviving members
for ch in self.party:
if not ch.is_dead:
if ch.is_ko and ch.current_hp > 0:
ch.is_ko = False
ch.ko_turns = 0
# Partial HP restore (30% if KO'd but saved)
if ch.current_hp <= 0:
ch.current_hp = max(1, ch.max_hp // 5)
ch.is_ko = False
ch.ko_turns = 0
return True
def _battle_item_callback(self, ch, item_id: int, battle: Battle) -> bool:
return battle.use_item_in_battle(ch, item_id, self.inventory)
def _battle_item_ui(self, ch: Character, battle: Battle) -> bool:
if not self.inventory:
print(" Inventory is empty!")
return False
print(" INVENTORY (0=back):")
items = [(iid, cnt) for iid, cnt in sorted(self.inventory.items()) if cnt > 0]
for i, (iid, cnt) in enumerate(items):
if iid in ITEM_DB:
item = ITEM_DB[iid]
print(f" [{i+1}] {item.name} x{cnt} - {item.description}")
while True:
raw = input(" > ").strip()
if raw == "0": return False
try:
idx = int(raw) - 1
if 0 <= idx < len(items):
item_id = items[idx][0]
return battle.use_item_in_battle(ch, item_id, self.inventory)
except ValueError:
pass
print(" Invalid choice.")
def _award_loot(self):
"""Random item drops after victory."""
# Simple loot: random item from early pool
loot_pool = [1, 2, 6, 7, 31, 41, 42, 66, 67, 86, 87, 88]
advanced_pool = [3, 5, 8, 32, 56, 68]
rare_pool = [4, 10, 33, 57, 84, 85]
# Base drop
if random.random() < 0.7:
item_id = random.choice(loot_pool)
self.add_item(item_id)
print(f" Item found: {ITEM_DB[item_id].name}!")
# Extra drop for later battles
if self.battle_number > 20 and random.random() < 0.4:
item_id = random.choice(advanced_pool)
self.add_item(item_id)
print(f" Item found: {ITEM_DB[item_id].name}!")
if self.battle_number > 50 and random.random() < 0.2:
item_id = random.choice(rare_pool)
self.add_item(item_id)
print(f" Rare item found: {ITEM_DB[item_id].name}!")
def _check_companion_milestone(self):
if self.battle_number in COMPANION_MILESTONES:
target_lv = COMPANION_MILESTONES[self.battle_number]
if isinstance(target_lv, tuple):
target_lv = random.randint(target_lv[0], target_lv[1])
companion = summon_companion(target_lv)
self.offer_companion(companion)
# ── Between battles menu ──────────────────────────────────────────────
def between_battles_menu(self):
while True:
print("\n" + "─"*65)
print(f" ── Camp ── (Battle {self.battle_number} completed)")
print(" [1] View Party [2] Character Details [3] Use Item")
print(" [4] View Inventory [5] Next Battle [6] Quit")
choice = input(" > ").strip()
if choice == "1":
self.display_party()
elif choice == "2":
self.display_party()
try:
idx = int(input(" Choose character (0=back): ")) - 1
if 0 <= idx < len(self.party):
self.display_character_detail(self.party[idx])
except ValueError:
pass
elif choice == "3":
self._outside_battle_item_menu()
elif choice == "4":
self.display_inventory()
elif choice == "5":
return True
elif choice == "6":
return False
else:
print(" Invalid choice.")
def _outside_battle_item_menu(self):
self.display_inventory()
items = [(iid, cnt) for iid, cnt in sorted(self.inventory.items()) if cnt > 0]
if not items:
return
print(" Use item on (0=back):")
raw = input(" Item # > ").strip()
if raw == "0": return
try:
idx = int(raw) - 1
if 0 <= idx < len(items):
item_id = items[idx][0]
self.display_party()
tidx = int(input(" On character # > ")) - 1
if 0 <= tidx < len(self.party):
self.use_item_outside_battle(item_id, self.party[tidx])
except ValueError:
pass
# ── Game over screen ──────────────────────────────────────────────────
def show_game_over_screen(self):
print("\n" + "☠"*65)
print(" GAME OVER")
print("☠"*65)
print(f"\n {self.player.name} has fallen after {self.battle_number} battles.")
print(f"\n ── BATTLE RECORD ──")
print(f" Total Battles: {self.battle_number}")
print(f" Battles Won: {self.battle_number - 1}") # lost the last one
print(f"\n ── PARTY HISTORY ──")
all_chars = list({id(c): c for c in self.all_party_members + self.deceased_companions}.values())
if self.player not in all_chars:
all_chars.insert(0, self.player)
for ch in all_chars:
status = "💀 DECEASED" if ch.is_dead else "ALIVE"
rarity = f"{'★'*ch.rarity}" if ch.rarity else "PLAYER"
print(f"\n {ch.name} Lv{ch.level} {ch.job.value} [{rarity}] - {status}")
print(f" HP:{ch.max_hp} MP:{ch.max_mp} PATK:{ch.base_stats.patk} "
f"MATK:{ch.base_stats.matk} PDEF:{ch.base_stats.pdef} "
f"MDEF:{ch.base_stats.mdef} SPD:{ch.base_stats.spd}")
print(f" Weapon:{ch.weapon.value} Element:{ch.element.value}")
print(f" Skills: ", end="")
skill_names = []
for sid in ch.skill_pool:
if sid in SKILL_DB:
unlocked = "✓" if sid in ch.unlocked_skills else "✗"
skill_names.append(f"{SKILL_DB[sid].name}[{unlocked}]")
print(", ".join(skill_names))
print("\n" + "☠"*65)
input(" [Press Enter to exit]")
[Python] Turn-Based Battle Game
items_db.py
"""Items database with 120 items."""
from data_types import *
def build_item_database() -> Dict[int, Item]:
items = {}
def add(id, name, itype, desc, val, target=SkillTarget.SINGLE_ALLY,
elem=None, cures=None, buff=None, bdur=0):
items[id] = Item(id, name, itype, desc, val, target, elem, cures, buff, bdur)
SE = SkillTarget.SINGLE_ENEMY; SA = SkillTarget.SINGLE_ALLY
AA = SkillTarget.ALL_ALLIES; SF = SkillTarget.SELF
AE = SkillTarget.ALL_ENEMIES
# ── RECOVERY (1-30) ──────────────────────────────────────────────────
add(1, "Potion", ItemType.RECOVERY, "Restore 150 HP.", 150, SA)
add(2, "Hi-Potion", ItemType.RECOVERY, "Restore 500 HP.", 500, SA)
add(3, "Mega Potion", ItemType.RECOVERY, "Restore 1500 HP.", 1500, SA)
add(4, "X-Potion", ItemType.RECOVERY, "Restore 3000 HP.", 3000, SA)
add(5, "Elixir", ItemType.RECOVERY, "Restore 50% HP and MP.", -50, SA) # -50 = 50%
add(6, "Ether", ItemType.RECOVERY, "Restore 50 MP.", 50, SA)
add(7, "Hi-Ether", ItemType.RECOVERY, "Restore 150 MP.", 150, SA)
add(8, "Mega Ether", ItemType.RECOVERY, "Restore 300 MP.", 300, SA)
add(9, "Max Ether", ItemType.RECOVERY, "Fully restore MP.", 999, SA)
add(10, "Mega Elixir", ItemType.RECOVERY, "Restore full HP and MP.", -100, SA) # -100 = full
add(11, "Ultra Potion", ItemType.RECOVERY, "Restore 5000 HP.", 5000, SA)
add(12, "God Ether", ItemType.RECOVERY, "Fully restore all allies' MP.", 999, AA)
add(13, "Party Potion", ItemType.RECOVERY, "Restore 300 HP to all allies.", 300, AA)
add(14, "Party Hi-Potion",ItemType.RECOVERY,"Restore 800 HP to all allies.", 800, AA)
add(15, "Life Water", ItemType.RECOVERY, "Restore 200 HP.", 200, SA)
add(16, "Mana Water", ItemType.RECOVERY, "Restore 80 MP.", 80, SA)
add(17, "Tonic", ItemType.RECOVERY, "Restore 75 HP.", 75, SA)
add(18, "Herb", ItemType.RECOVERY, "Restore 50 HP.", 50, SA)
add(19, "Potion II", ItemType.RECOVERY, "Restore 250 HP.", 250, SA)
add(20, "Ether II", ItemType.RECOVERY, "Restore 100 MP.", 100, SA)
add(21, "Spring Water", ItemType.RECOVERY, "Restore 120 HP and 30 MP.", -21, SA) # special combined
add(22, "Phoenix Tears", ItemType.RECOVERY, "Restore 200 HP.", 200, SA)
add(23, "Soul Potion", ItemType.RECOVERY, "Restore 25% HP.", -25, SA)
add(24, "Omega Potion", ItemType.RECOVERY, "Restore 8000 HP.", 8000, SA)
add(25, "Blessed Water", ItemType.RECOVERY, "Restore 400 HP (Light element).", 400, SA, Element.LIGHT)
add(26, "Devil's Blood", ItemType.RECOVERY, "Restore 400 HP (Dark).", 400, SA, Element.DARK)
add(27, "Fire Extract", ItemType.RECOVERY, "Restore 200 HP, grant fire boost.", 200, SA, Element.FIRE)
add(28, "Ice Extract", ItemType.RECOVERY, "Restore 200 HP, grant ice boost.", 200, SA, Element.ICE)
add(29, "Thunder Extract",ItemType.RECOVERY,"Restore 200 HP, grant lightning.", 200, SA, Element.LIGHTNING)
add(30, "Wind Extract", ItemType.RECOVERY, "Restore 200 HP, grant wind boost.", 200, SA, Element.WIND)
# ── REVIVAL (31-40) ──────────────────────────────────────────────────
add(31, "Phoenix Feather",ItemType.REVIVAL, "Revive ally with 25% HP.", 25, SA)
add(32, "Revival Stone", ItemType.REVIVAL, "Revive ally with 50% HP.", 50, SA)
add(33, "Life Gem", ItemType.REVIVAL, "Revive ally with 75% HP.", 75, SA)
add(34, "Soul Crystal", ItemType.REVIVAL, "Revive ally with full HP.", 100, SA)
add(35, "Phoenix Down", ItemType.REVIVAL, "Revive ally with 10% HP.", 10, SA)
add(36, "Angel Wing", ItemType.REVIVAL, "Revive all fallen allies (25% HP).", 25, AA)
add(37, "Goddess Tear", ItemType.REVIVAL, "Revive all allies (50% HP).", 50, AA)
add(38, "Miracle Dust", ItemType.REVIVAL, "Revive ally with 30% HP.", 30, SA)
add(39, "Star Fragment", ItemType.REVIVAL, "Revive ally with 60% HP.", 60, SA)
add(40, "Last Hope", ItemType.REVIVAL, "Revive all allies (10% HP).", 10, AA)
# ── STATUS CURE (41-65) ──────────────────────────────────────────────
add(41, "Antidote", ItemType.STATUS_CURE, "Cure Poison.", 0, SA,
cures=[StatusEffectType.POISON, StatusEffectType.VENOM])
add(42, "Burn Cure", ItemType.STATUS_CURE, "Cure Burn.", 0, SA,
cures=[StatusEffectType.BURN])
add(43, "Paralyze Cure", ItemType.STATUS_CURE, "Cure Paralyze.", 0, SA,
cures=[StatusEffectType.PARALYZE])
add(44, "Sleep Cure", ItemType.STATUS_CURE, "Cure Sleep.", 0, SA,
cures=[StatusEffectType.SLEEP])
add(45, "Blind Cure", ItemType.STATUS_CURE, "Cure Blind.", 0, SA,
cures=[StatusEffectType.BLIND])
add(46, "Freeze Cure", ItemType.STATUS_CURE, "Cure Freeze.", 0, SA,
cures=[StatusEffectType.FREEZE])
add(47, "Stun Cure", ItemType.STATUS_CURE, "Cure Stun.", 0, SA,
cures=[StatusEffectType.STUN])
add(48, "Bleed Cure", ItemType.STATUS_CURE, "Cure Bleed.", 0, SA,
cures=[StatusEffectType.BLEED])
add(49, "Fear Cure", ItemType.STATUS_CURE, "Cure Fear.", 0, SA,
cures=[StatusEffectType.FEAR])
add(50, "Silence Cure", ItemType.STATUS_CURE, "Cure Silence.", 0, SA,
cures=[StatusEffectType.SILENCE])
add(51, "Confusion Cure",ItemType.STATUS_CURE, "Cure Confusion.", 0, SA,
cures=[StatusEffectType.CONFUSION])
add(52, "Charm Cure", ItemType.STATUS_CURE, "Cure Charm.", 0, SA,
cures=[StatusEffectType.CHARM])
add(53, "Petrify Cure", ItemType.STATUS_CURE, "Cure Petrify.", 0, SA,
cures=[StatusEffectType.PETRIFY])
add(54, "Curse Cure", ItemType.STATUS_CURE, "Cure Curse.", 0, SA,
cures=[StatusEffectType.CURSE, StatusEffectType.CURSE_MARK])
add(55, "Doom Stopper", ItemType.STATUS_CURE, "Remove Doom.", 0, SA,
cures=[StatusEffectType.DOOM])
add(56, "Panacea", ItemType.STATUS_CURE, "Cure all status ailments.", 0, SA,
cures=list(StatusEffectType))
add(57, "Party Panacea", ItemType.STATUS_CURE, "Cure all ailments for all.", 0, AA,
cures=list(StatusEffectType))
add(58, "Holy Water", ItemType.STATUS_CURE, "Cure Dark ailments.", 0, SA,
cures=[StatusEffectType.CURSE, StatusEffectType.CURSE_MARK, StatusEffectType.DOOM,
StatusEffectType.SOUL_DRAIN, StatusEffectType.BLOOD_LINK])
add(59, "Remedy", ItemType.STATUS_CURE, "Cure Poison/Burn/Bleed/Venom.", 0, SA,
cures=[StatusEffectType.POISON, StatusEffectType.BURN,
StatusEffectType.BLEED, StatusEffectType.VENOM])
add(60, "Seal Breaker", ItemType.STATUS_CURE, "Cure Seal effects.", 0, SA,
cures=[StatusEffectType.SKILL_SEAL, StatusEffectType.ITEM_SEAL,
StatusEffectType.SILENCE, StatusEffectType.HEAL_BLOCK])
add(61, "Speed Up", ItemType.STATUS_CURE, "Cure Speed Down.", 0, SA,
cures=[StatusEffectType.SPEED_DOWN])
add(62, "Clarity Potion",ItemType.STATUS_CURE, "Cure Sleep/Confusion/Charm.", 0, SA,
cures=[StatusEffectType.SLEEP, StatusEffectType.CONFUSION, StatusEffectType.CHARM])
add(63, "Iron Tonic", ItemType.STATUS_CURE, "Cure Defense Down.", 0, SA,
cures=[StatusEffectType.DEFENSE_DOWN, StatusEffectType.ATTACK_DOWN])
add(64, "Mana Restore", ItemType.STATUS_CURE, "Cure Mana Burn.", 0, SA,
cures=[StatusEffectType.MANA_BURN])
add(65, "Full Remedy", ItemType.STATUS_CURE, "Cure all stat debuffs.", 0, SA,
cures=[StatusEffectType.ATTACK_DOWN, StatusEffectType.DEFENSE_DOWN,
StatusEffectType.MAGIC_DOWN, StatusEffectType.SPEED_DOWN,
StatusEffectType.ACCURACY_DOWN, StatusEffectType.WEAKNESS_MARK])
# ── BUFF ITEMS (66-85) ───────────────────────────────────────────────
add(66, "Power Tonic", ItemType.BUFF, "Raise PATK by 50% for 3 turns.", 0, SA,
buff={"patk": 1.5}, bdur=3)
add(67, "Defense Tonic", ItemType.BUFF, "Raise PDEF by 50% for 3 turns.", 0, SA,
buff={"pdef": 1.5}, bdur=3)
add(68, "Magic Tonic", ItemType.BUFF, "Raise MATK by 50% for 3 turns.", 0, SA,
buff={"matk": 1.5}, bdur=3)
add(69, "Speed Tonic", ItemType.BUFF, "Raise SPD by 50% for 3 turns.", 0, SA,
buff={"spd": 1.5}, bdur=3)
add(70, "Guard Tonic", ItemType.BUFF, "Raise MDEF by 50% for 3 turns.", 0, SA,
buff={"mdef": 1.5}, bdur=3)
add(71, "Evasion Tonic", ItemType.BUFF, "Raise EVA by 30% for 3 turns.", 0, SA,
buff={"eva": 0.3}, bdur=3)
add(72, "Crit Tonic", ItemType.BUFF, "Raise CRIT by 30% for 3 turns.", 0, SA,
buff={"crit": 0.3}, bdur=3)
add(73, "Power Stone", ItemType.BUFF, "Raise PATK by 80% for 2 turns.", 0, SA,
buff={"patk": 1.8}, bdur=2)
add(74, "Magic Stone", ItemType.BUFF, "Raise MATK by 80% for 2 turns.", 0, SA,
buff={"matk": 1.8}, bdur=2)
add(75, "Shield Stone", ItemType.BUFF, "Raise PDEF/MDEF by 80% for 2 turns.", 0, SA,
buff={"pdef": 1.8, "mdef": 1.8}, bdur=2)
add(76, "Haste Potion", ItemType.BUFF, "Gain Haste for 3 turns.", 0, SA,
buff={"spd": 2.0}, bdur=3)
add(77, "Berserk Potion", ItemType.BUFF, "Raise PATK by 100%, lower PDEF.", 0, SA,
buff={"patk": 2.0, "pdef": 0.5}, bdur=3)
add(78, "Focus Stone", ItemType.BUFF, "Raise ACC and CRIT for 3 turns.", 0, SA,
buff={"acc": 1.5, "crit": 1.5}, bdur=3)
add(79, "Party Power", ItemType.BUFF, "Raise all allies' PATK by 30%.", 0, AA,
buff={"patk": 1.3}, bdur=3)
add(80, "Party Shield", ItemType.BUFF, "Raise all allies' DEF by 30%.", 0, AA,
buff={"pdef": 1.3, "mdef": 1.3}, bdur=3)
add(81, "War Banner", ItemType.BUFF, "Raise all allies' stats by 20%.", 0, AA,
buff={"patk":1.2,"matk":1.2,"pdef":1.2,"mdef":1.2,"spd":1.2}, bdur=3)
add(82, "Regen Potion", ItemType.BUFF, "Grant HP Regen for 5 turns.", 50, SA)
add(83, "Mana Potion", ItemType.BUFF, "Grant Mana Regen for 5 turns.", 20, SA)
add(84, "Luck Up", ItemType.BUFF, "Raise EVA, ACC, CRIT for 3 turns.", 0, SA,
buff={"eva":0.2,"acc":1.3,"crit":1.3}, bdur=3)
add(85, "Legend Stone", ItemType.BUFF, "Massively boost all stats for 2 turns.", 0, SA,
buff={"patk":2.0,"matk":2.0,"pdef":1.5,"mdef":1.5,"spd":1.5}, bdur=2)
# ── OFFENSIVE ITEMS (86-105) ─────────────────────────────────────────
add(86, "Fire Bomb", ItemType.OFFENSIVE, "Fire damage to one enemy.", 300, SE, Element.FIRE)
add(87, "Ice Bomb", ItemType.OFFENSIVE, "Ice damage to one enemy.", 300, SE, Element.ICE)
add(88, "Thunder Bomb", ItemType.OFFENSIVE, "Lightning damage to one enemy.", 300, SE, Element.LIGHTNING)
add(89, "Wind Bomb", ItemType.OFFENSIVE, "Wind damage to one enemy.", 300, SE, Element.WIND)
add(90, "Earth Bomb", ItemType.OFFENSIVE, "Earth damage to one enemy.", 300, SE, Element.EARTH)
add(91, "Dark Bomb", ItemType.OFFENSIVE, "Dark damage to one enemy.", 300, SE, Element.DARK)
add(92, "Holy Bomb", ItemType.OFFENSIVE, "Light damage to one enemy.", 300, SE, Element.LIGHT)
add(93, "Mega Fire Bomb", ItemType.OFFENSIVE, "Heavy fire damage to all.", 500, AE, Element.FIRE)
add(94, "Mega Ice Bomb", ItemType.OFFENSIVE, "Heavy ice damage to all.", 500, AE, Element.ICE)
add(95, "Mega Thunder Bomb",ItemType.OFFENSIVE,"Heavy lightning to all.", 500, AE, Element.LIGHTNING)
add(96, "Mega Wind Bomb", ItemType.OFFENSIVE, "Heavy wind damage to all.", 500, AE, Element.WIND)
add(97, "Mega Earth Bomb",ItemType.OFFENSIVE, "Heavy earth damage to all.", 500, AE, Element.EARTH)
add(98, "Chaos Bomb", ItemType.OFFENSIVE, "Massive damage to all enemies.", 800, AE)
add(99, "Poison Vial", ItemType.OFFENSIVE, "Inflict Poison on an enemy.", 0, SE,
cures=[]) # reuse cures field as apply-poison signal
add(100,"Sleep Powder", ItemType.OFFENSIVE, "Put an enemy to Sleep.", 0, SE)
add(101,"Stone Grenade", ItemType.OFFENSIVE, "Inflict Petrify (30%).", 0, SE)
add(102,"Silence Orb", ItemType.OFFENSIVE, "Inflict Silence on enemy.", 0, SE)
add(103,"Paralysis Dart", ItemType.OFFENSIVE, "Inflict Paralyze on enemy.", 0, SE)
add(104,"Doom Clock", ItemType.OFFENSIVE, "Inflict Doom (5 turns).", 0, SE)
add(105,"Ultimate Bomb", ItemType.OFFENSIVE, "Colossal damage to one enemy.", 2000, SE)
# ── ADVANCED / SPECIAL (106-120) ─────────────────────────────────────
add(106,"Ultimate Potion",ItemType.ADVANCED, "Restore 9999 HP.", 9999, SA)
add(107,"Ultima Elixir", ItemType.ADVANCED, "Fully restore HP/MP all allies.", -100, AA)
add(108,"Omega Elixir", ItemType.ADVANCED, "Restore all HP/MP + cure all.", -100, SA)
add(109,"God's Breath", ItemType.ADVANCED, "Raise all stats 100% for 3 turns.", 0, SA,
buff={"patk":2.0,"matk":2.0,"pdef":2.0,"mdef":2.0,"spd":2.0}, bdur=3)
add(110,"World Crystal", ItemType.ADVANCED, "Full restore + buff all allies.", -100, AA)
add(111,"Aether", ItemType.ADVANCED, "Restore 9999 MP to one ally.", 9999, SA)
add(112,"Time Crystal", ItemType.ADVANCED, "Grant Time Stop effect.", 0, SF)
add(113,"Philosopher's Stone",ItemType.ADVANCED,"Double all stats for 5 turns.", 0, SF,
buff={"patk":2.0,"matk":2.0,"pdef":2.0,"mdef":2.0,"spd":2.0,"eva":0.5}, bdur=5)
add(114,"War God's Pill", ItemType.ADVANCED, "PATK x3, DEF/2 for 2 turns.", 0, SF,
buff={"patk":3.0,"pdef":0.5,"mdef":0.5}, bdur=2)
add(115,"Sage's Tincture",ItemType.ADVANCED, "MATK x3 for 2 turns.", 0, SF,
buff={"matk":3.0}, bdur=2)
add(116,"Hero's Elixir", ItemType.ADVANCED, "Restore HP/MP + raise all stats.", 3000, SA,
buff={"patk":1.5,"matk":1.5,"pdef":1.5,"mdef":1.5,"spd":1.5}, bdur=3)
add(117,"Crystal Vial", ItemType.ADVANCED, "Cure all + 5000 HP.", 5000, SA)
add(118,"Dragon's Blood", ItemType.ADVANCED, "Restore 50% HP + grant Regen.", -50, SA,
buff={"pdef":1.5,"mdef":1.5}, bdur=3)
add(119,"Chaos Shard", ItemType.ADVANCED, "9999 damage to all enemies.", 9999, AE)
add(120,"Omnipotent Stone",ItemType.ADVANCED,"Restore full HP/MP + max all buffs.", -100, SA,
buff={"patk":3.0,"matk":3.0,"pdef":3.0,"mdef":3.0,"spd":3.0}, bdur=5)
return items
ITEM_DB: Dict[int, Item] = build_item_database()
[Python] Turn-Based Battle Game
monster_unit.py & monsters_db.py
monster_unit.py
"""Monster combat unit - wraps MonsterTemplate with battle state."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List, Optional
from data_types import *
from monsters_db import MonsterTemplate
from skills_db import SKILL_DB
@dataclass
class MonsterUnit:
template: MonsterTemplate
instance_name: str # e.g. "Goblin A"
current_hp: int = 0
current_mp: int = 0
shield_points: int = 0
is_broken: bool = False
break_turns: int = 0
status_effects: List[StatusEffect] = field(default_factory=list)
temp_buffs: dict = field(default_factory=dict)
def __post_init__(self):
self.current_hp = self.template.base_stats.hp
self.current_mp = self.template.base_stats.mp
self.shield_points = self.template.shield_points
@property
def is_dead(self): return self.current_hp <= 0
@property
def patk(self):
v = self.template.base_stats.patk
for se in self.status_effects:
if se.effect_type == StatusEffectType.ATTACK_DOWN: v = int(v*0.7)
return v
@property
def matk(self):
v = self.template.base_stats.matk
for se in self.status_effects:
if se.effect_type == StatusEffectType.MAGIC_DOWN: v = int(v*0.7)
return v
@property
def pdef(self):
v = self.template.base_stats.pdef
if self.is_broken: return 0
for se in self.status_effects:
if se.effect_type == StatusEffectType.DEFENSE_DOWN: v = int(v*0.7)
return v
@property
def mdef(self):
v = self.template.base_stats.mdef
if self.is_broken: return 0
for se in self.status_effects:
if se.effect_type == StatusEffectType.MAGIC_DOWN: v = int(v*0.7)
return v
@property
def spd(self):
v = self.template.base_stats.spd
for se in self.status_effects:
if se.effect_type == StatusEffectType.SPEED_DOWN: v = int(v*0.7)
if se.effect_type == StatusEffectType.HASTE: v = int(v*1.5)
return v
@property
def eva(self):
if self.is_broken: return 0.0
v = self.template.base_stats.eva
if self.has_status(StatusEffectType.BLIND): v = max(0, v - 0.3)
return v
@property
def acc(self):
v = self.template.base_stats.acc
if self.has_status(StatusEffectType.ACCURACY_DOWN): v *= 0.6
return v
@property
def max_hp(self): return self.template.base_stats.hp
def has_status(self, stype: StatusEffectType) -> bool:
return any(se.effect_type == stype for se in self.status_effects)
def add_status(self, effect: StatusEffect):
for existing in self.status_effects:
if existing.effect_type == effect.effect_type:
existing.duration = max(existing.duration, effect.duration)
return
self.status_effects.append(effect)
def remove_status(self, stype: StatusEffectType):
self.status_effects = [s for s in self.status_effects if s.effect_type != stype]
def is_incapacitated(self) -> bool:
incap = {StatusEffectType.SLEEP, StatusEffectType.STUN,
StatusEffectType.FREEZE, StatusEffectType.PARALYZE,
StatusEffectType.PETRIFY}
return any(se.effect_type in incap for se in self.status_effects) or self.is_broken
def take_damage(self, amount: int) -> int:
"""Returns actual damage dealt."""
actual = min(amount, self.current_hp)
self.current_hp -= actual
return actual
def hit_weakness(self, attack_weapon: Optional[WeaponType],
attack_element: Optional[Element]) -> bool:
weaknesses = self.template.weaknesses
if attack_weapon and attack_weapon in weaknesses:
return True
if attack_element and attack_element != Element.NONE and attack_element in weaknesses:
return True
return False
def reduce_shield(self, hits: int) -> bool:
"""Returns True if break triggered."""
if self.is_broken: return False
self.shield_points = max(0, self.shield_points - hits)
if self.shield_points == 0:
self.is_broken = True
self.break_turns = 1
return True
return False
def tick_break(self):
if self.is_broken:
self.break_turns -= 1
if self.break_turns <= 0:
self.is_broken = False
self.shield_points = self.template.shield_points // 2 + 1
def tick_status_effects(self) -> List[str]:
messages = []
to_remove = []
for se in self.status_effects:
msg = self._apply_status_tick(se)
if msg: messages.append(msg)
if not se.tick(): to_remove.append(se)
self.status_effects = [s for s in self.status_effects if s not in to_remove]
for expired in to_remove:
messages.append(f" {self.instance_name}'s {expired.effect_type.value} wore off.")
return messages
def _apply_status_tick(self, se: StatusEffect) -> Optional[str]:
if se.effect_type == StatusEffectType.POISON:
dmg = max(1, int(self.max_hp * 0.05))
self.take_damage(dmg)
return f" {self.instance_name} is poisoned! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.VENOM:
dmg = max(1, int(self.max_hp * 0.08))
self.take_damage(dmg)
return f" {self.instance_name} suffers venom! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.BURN:
dmg = max(1, int(self.max_hp * 0.06))
self.take_damage(dmg)
return f" {self.instance_name} is burning! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.BLEED:
dmg = max(1, int(self.max_hp * 0.04))
self.take_damage(dmg)
return f" {self.instance_name} bleeds! (-{dmg} HP)"
elif se.effect_type == StatusEffectType.DOOM:
if se.duration <= 1:
self.current_hp = 0
return f" ☠ DOOM strikes {self.instance_name}!"
return f" ⏳ DOOM countdown: {se.duration} turns for {self.instance_name}."
return None
def choose_action(self, party: list) -> dict:
"""AI decides what to do."""
ai = self.template.ai_type
skill_ids = self.template.skill_ids
# Filter to valid skills in DB
valid_skills = [sid for sid in skill_ids if sid in SKILL_DB]
hp_ratio = self.current_hp / max(1, self.max_hp)
# Simple AI logic
if ai == AIType.AGGRESSIVE:
if valid_skills and random.random() < 0.4:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
elif ai == AIType.DEFENSIVE:
if hp_ratio < 0.3 and random.random() < 0.5:
return {"action": "defend"}
if valid_skills and random.random() < 0.3:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
elif ai == AIType.SUPPORT:
if valid_skills and random.random() < 0.6:
skill_id = random.choice(valid_skills)
return {"action": "skill", "skill_id": skill_id}
return {"action": "attack"}
elif ai == AIType.TACTICAL:
if hp_ratio > 0.7:
if valid_skills and random.random() < 0.5:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
elif hp_ratio > 0.4:
if valid_skills and random.random() < 0.35:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
else:
if valid_skills and random.random() < 0.7:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
elif ai == AIType.BERSERKER:
if valid_skills and random.random() < 0.5:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
else: # RANDOM / BALANCED
r = random.random()
if valid_skills and r < 0.35:
return {"action": "skill", "skill_id": random.choice(valid_skills)}
return {"action": "attack"}
def shield_bar(self) -> str:
sp = self.shield_points
total = self.template.shield_points
filled = "◆" * sp + "◇" * (total - sp)
return f"[{filled}]"
def hp_bar(self, width=16) -> str:
ratio = self.current_hp / max(1, self.max_hp)
filled = int(ratio * width)
bar = "█" * filled + "░" * (width - filled)
return f"[{bar}] {self.current_hp}/{self.max_hp}"
def status_str(self) -> str:
if not self.status_effects:
return ""
tags = [se.effect_type.value[:3].upper() for se in self.status_effects]
return " [" + ",".join(tags) + "]"
monsters_db.py
"""Monster database with 50 monsters."""
from __future__ import annotations
from data_types import *
from dataclasses import dataclass, field
from typing import List, Dict, Tuple
import random
@dataclass
class MonsterTemplate:
id: int
name: str
level: int
base_stats: Stats
weaknesses: List # List of WeaponType | Element
shield_points: int
skill_ids: List[int]
ai_type: AIType
exp_reward: int
tier: str # early / mid / high / boss
def build_monster_db() -> Dict[int, MonsterTemplate]:
db = {}
def m(id, name, lv, hp, mp, pa, ma, pd, md, spd, eva, acc, crit,
weak, sp, skills, ai, exp, tier):
db[id] = MonsterTemplate(
id, name, lv,
Stats(hp, mp, pa, ma, pd, md, spd, eva, acc, crit),
weak, sp, skills, ai, exp, tier
)
W = WeaponType; E = Element; AI = AIType
# ══════════════════════════════════════════════════════════
# EARLY MONSTERS (lv 1-10)
# ══════════════════════════════════════════════════════════
m(1,"Goblin", 2, 80, 10, 12, 4, 8, 5, 8, 0.08,0.85,0.05,
[W.SWORD,E.FIRE,W.BOW], 3, [1,2], AI.BALANCED, 15, "early")
m(2,"Goblin Archer", 3, 70, 10, 10, 4, 6, 4, 10, 0.12,0.88,0.08,
[W.SWORD,E.FIRE], 2, [1,20], AI.AGGRESSIVE, 20, "early")
m(3,"Goblin Shaman", 4, 65, 30, 8, 14, 5, 10, 7, 0.05,0.82,0.04,
[W.SWORD,E.LIGHT], 2, [50,76], AI.SUPPORT, 25, "early")
m(4,"Wolf", 2, 90, 0, 14, 0, 6, 4, 14, 0.15,0.9, 0.1,
[E.FIRE,W.SPEAR], 2, [1,15], AI.AGGRESSIVE, 18, "early")
m(5,"Dire Wolf", 5, 150, 0, 20, 0, 10, 6, 16, 0.18,0.88,0.15,
[E.FIRE,W.SPEAR,W.AXE], 3, [1,2,15], AI.AGGRESSIVE, 40, "early")
m(6,"Slime", 1, 60, 0, 6, 0, 12, 8, 4, 0.0, 0.8, 0.02,
[E.FIRE,W.SWORD], 2, [1], AI.RANDOM, 10, "early")
m(7,"Fire Slime", 4, 80, 10, 8, 10, 6, 14, 5, 0.0, 0.8, 0.03,
[E.ICE,W.SPEAR], 2, [50,51], AI.BALANCED, 30, "early")
m(8,"Ice Slime", 4, 80, 10, 8, 10, 6, 8, 5, 0.0, 0.8, 0.03,
[E.FIRE,W.AXE], 2, [55,56], AI.BALANCED, 30, "early")
m(9,"Bat", 2, 55, 0, 8, 0, 4, 4, 12, 0.20,0.85,0.08,
[E.LIGHTNING,W.BOW], 2, [1,15], AI.AGGRESSIVE, 12, "early")
m(10,"Giant Bat", 5, 120, 0, 18, 0, 8, 6, 15, 0.22,0.87,0.12,
[E.LIGHTNING,W.BOW,W.AXE], 3, [1,2,3], AI.AGGRESSIVE, 45, "early")
# ══════════════════════════════════════════════════════════
# MID MONSTERS (lv 8-20)
# ══════════════════════════════════════════════════════════
m(11,"Orc", 8, 280, 10, 28, 5, 18, 10, 8, 0.05,0.82,0.08,
[W.SPEAR,E.FIRE], 3, [2,11,30], AI.AGGRESSIVE, 80, "mid")
m(12,"Orc Warrior", 10,350, 15, 35, 8, 22, 12, 9, 0.07,0.83,0.1,
[W.SPEAR,E.FIRE,E.LIGHTNING], 3, [2,11,13], AI.AGGRESSIVE, 110, "mid")
m(13,"Orc Shaman", 10,250, 60, 20, 25, 14, 18, 7, 0.05,0.82,0.06,
[W.SWORD,E.LIGHT], 2, [50,77,76],AI.SUPPORT, 100, "mid")
m(14,"Lizardman", 9, 310, 20, 30, 10, 16, 14, 12, 0.1, 0.85,0.1,
[E.ICE,W.AXE], 3, [1,3,29], AI.BALANCED, 90, "mid")
m(15,"Harpy", 10,260, 20, 25, 15, 12, 16, 18, 0.18,0.87,0.12,
[E.LIGHTNING,W.BOW,W.AXE], 2, [1,65,66], AI.AGGRESSIVE, 105, "mid")
m(16,"Skeleton", 8, 240, 0, 24, 0, 10, 18, 10, 0.08,0.85,0.12,
[E.LIGHT,W.AXE,W.SWORD], 3, [1,2,3], AI.AGGRESSIVE, 75, "mid")
m(17,"Zombie", 9, 320, 0, 22, 10, 14, 12, 5, 0.05,0.78,0.05,
[E.FIRE,E.LIGHT,W.AXE], 2, [1,16], AI.AGGRESSIVE, 85, "mid")
m(18,"Ghoul", 11,380, 20, 28, 14, 16, 16, 8, 0.1, 0.82,0.1,
[E.FIRE,E.LIGHT], 3, [1,2,90], AI.AGGRESSIVE, 120, "mid")
m(19,"Wraith", 12,300, 50, 18, 22, 8, 24, 10, 0.15,0.85,0.1,
[E.LIGHT,W.SWORD], 3, [75,77,79],AI.TACTICAL, 130, "mid")
m(20,"Dark Mage", 14,320, 80, 15, 35, 10, 28, 9, 0.08,0.87,0.08,
[E.LIGHT,W.SWORD], 2, [75,77,79,76],AI.TACTICAL, 160, "mid")
m(21,"Stone Golem", 12,500, 0, 35, 0, 30, 25, 4, 0.0, 0.75,0.05,
[E.LIGHTNING,W.AXE], 4, [2,70,71], AI.DEFENSIVE, 150, "mid")
m(22,"Earth Golem", 15,600, 0, 38, 0, 32, 28, 3, 0.0, 0.75,0.05,
[E.LIGHTNING,W.AXE,E.ICE], 4, [2,70,72], AI.DEFENSIVE, 200, "mid")
m(23,"Bandit", 7, 220, 10, 22, 8, 12, 10, 14, 0.15,0.88,0.12,
[E.LIGHT,W.SPEAR], 2, [1,15,16], AI.BALANCED, 70, "mid")
m(24,"Bandit Leader", 11,350, 20, 30, 12, 18, 14, 16, 0.18,0.88,0.15,
[E.LIGHT,W.SPEAR,E.FIRE], 3, [1,2,17], AI.TACTICAL, 140, "mid")
m(25,"Dark Knight", 15,480, 30, 38, 15, 26, 20, 11, 0.1, 0.85,0.12,
[E.LIGHT,W.SPEAR], 4, [8,9,75,77],AI.TACTICAL, 210, "mid")
# ══════════════════════════════════════════════════════════
# HIGH MONSTERS (lv 18-35)
# ══════════════════════════════════════════════════════════
m(26,"Demon", 18,700, 80, 48, 42, 28, 32, 14, 0.12,0.87,0.15,
[E.LIGHT,W.SWORD], 4, [75,77,79,91],AI.AGGRESSIVE, 350, "high")
m(27,"High Demon", 22,900, 100,55, 50, 32, 38, 15, 0.15,0.87,0.18,
[E.LIGHT,W.SPEAR], 4, [79,77,91,99],AI.AGGRESSIVE, 500, "high")
m(28,"Vampire", 20,650, 60, 42, 38, 22, 30, 16, 0.2, 0.88,0.2,
[E.LIGHT,W.AXE], 3, [90,15,75,77],AI.TACTICAL, 420, "high")
m(29,"Lich", 25,800, 120,25, 65, 18, 55, 10, 0.1, 0.88,0.1,
[E.LIGHT,W.SWORD,E.FIRE], 3, [79,99,77,76],AI.TACTICAL, 600, "high")
m(30,"Basilisk", 20,750, 0, 45, 0, 38, 34, 9, 0.05,0.82,0.08,
[E.FIRE,W.AXE,E.LIGHTNING], 4, [1,2,70,72], AI.DEFENSIVE, 380, "high")
m(31,"Chimera", 22,850, 40, 50, 35, 30, 30, 12, 0.12,0.85,0.15,
[E.ICE,W.SPEAR,W.BOW], 4, [1,2,50,65], AI.BALANCED, 450, "high")
m(32,"Hydra", 24,1000,30, 48, 20, 35, 28, 8, 0.08,0.82,0.1,
[E.LIGHTNING,W.SWORD,E.FIRE], 5, [1,2,3,90], AI.AGGRESSIVE, 520, "high")
m(33,"Medusa", 21,700, 60, 40, 45, 25, 35, 14, 0.15,0.88,0.15,
[E.FIRE,W.BOW], 3, [76,77,90,99],AI.TACTICAL, 430, "high")
m(34,"Manticore", 23,880, 20, 52, 15, 32, 26, 16, 0.18,0.87,0.18,
[E.ICE,W.SPEAR,W.SWORD], 4, [1,2,20,22], AI.AGGRESSIVE, 480, "high")
m(35,"Golem King", 26,1200,0, 58, 0, 45, 40, 5, 0.0, 0.78,0.05,
[E.LIGHTNING,W.AXE,E.ICE], 5, [2,70,72,92],AI.DEFENSIVE, 650, "high")
# ══════════════════════════════════════════════════════════
# DRAGONS (lv 30-50)
# ══════════════════════════════════════════════════════════
m(36,"Dragon", 30,2000,80, 70, 60, 50, 55, 16, 0.12,0.87,0.15,
[E.ICE,W.SPEAR], 5, [1,2,50,51,52],AI.TACTICAL, 1000, "high")
m(37,"Fire Dragon", 32,2200,80, 72, 65, 52, 50, 18, 0.15,0.87,0.18,
[E.ICE,W.SPEAR,W.AXE], 5, [50,51,52,53,54],AI.AGGRESSIVE, 1200, "high")
m(38,"Ice Dragon", 32,2200,80, 65, 70, 50, 58, 14, 0.1, 0.85,0.15,
[E.FIRE,W.SPEAR,W.AXE], 5, [55,56,57,58,59],AI.AGGRESSIVE, 1200, "high")
m(39,"Thunder Dragon", 33,2300,80, 68, 72, 48, 60, 19, 0.15,0.88,0.18,
[E.EARTH,W.SPEAR,W.BOW], 5, [60,61,62,63,64],AI.AGGRESSIVE, 1300, "high")
m(40,"Earth Dragon", 33,2400,60, 75, 55, 58, 50, 12, 0.08,0.83,0.1,
[E.LIGHTNING,W.AXE,W.SPEAR], 5, [70,71,72,73,74],AI.DEFENSIVE, 1300, "high")
m(41,"Shadow Dragon", 35,2500,100,70, 75, 50, 60, 18, 0.2, 0.87,0.2,
[E.LIGHT,W.SPEAR], 5, [75,77,79,91,99],AI.TACTICAL, 1500, "high")
m(42,"Holy Dragon", 38,2800,100,65, 80, 55, 65, 16, 0.12,0.88,0.15,
[E.DARK,W.AXE], 5, [80,82,84,47,44],AI.TACTICAL, 1800, "high")
# ══════════════════════════════════════════════════════════
# ELITE / BOSS-TYPE (lv 40-60)
# ══════════════════════════════════════════════════════════
m(43,"Arch Demon", 40,4000,150,85, 90, 60, 75, 18, 0.18,0.88,0.22,
[E.LIGHT,W.SPEAR,W.SWORD], 5, [79,77,91,99,14],AI.TACTICAL, 2500, "boss")
m(44,"Fallen Angel", 42,3800,150,80, 95, 55, 80, 20, 0.2, 0.9, 0.2,
[E.DARK,W.BOW,W.SWORD], 5, [80,84,47,46,82],AI.TACTICAL, 2600, "boss")
m(45,"Ancient Golem", 45,5000,0, 90, 0, 70, 65, 6, 0.0, 0.8, 0.05,
[E.LIGHTNING,W.AXE], 5, [2,72,74,92,70],AI.DEFENSIVE, 3000, "boss")
m(46,"Chaos Dragon", 50,6000,200,100,100,75, 85, 20, 0.25,0.88,0.25,
[E.LIGHT,E.ICE,W.SPEAR], 5, [54,59,64,74,79,14],AI.TACTICAL, 4000, "boss")
m(47,"Death Knight", 45,4500,100,95, 70, 65, 72, 16, 0.15,0.88,0.18,
[E.LIGHT,W.SPEAR,W.SWORD], 5, [8,9,75,79,90,99],AI.TACTICAL, 3200, "boss")
m(48,"Storm Titan", 48,5500,150,80, 105,65, 80, 18, 0.18,0.88,0.2,
[E.EARTH,W.AXE,W.BOW], 5, [60,62,63,64,65],AI.AGGRESSIVE, 3800, "boss")
m(49,"Abyssal Horror", 52,7000,200,95, 110,70, 90, 14, 0.15,0.87,0.2,
[E.LIGHT,W.SPEAR,W.SWORD], 5, [79,99,77,76,19,14],AI.TACTICAL, 5000, "boss")
m(50,"World Eater", 60,15000,300,120,130,90, 110,20, 0.3, 0.88,0.3,
[E.LIGHT,E.ICE,W.SPEAR,W.SWORD],5, [14,54,59,64,74,79,100,34],AI.TACTICAL, 10000,"boss")
return db
MONSTER_DB: Dict[int, MonsterTemplate] = build_monster_db()
# Difficulty tiers for battle selection
EARLY_MONSTERS = [id for id,m in MONSTER_DB.items() if m.tier == "early"]
MID_MONSTERS = [id for id,m in MONSTER_DB.items() if m.tier == "mid"]
HIGH_MONSTERS = [id for id,m in MONSTER_DB.items() if m.tier == "high"]
BOSS_MONSTERS = [id for id,m in MONSTER_DB.items() if m.tier == "boss"]
def get_encounter(battle_number: int) -> List[MonsterTemplate]:
"""Return a list of monsters for the given battle number."""
import copy
if battle_number <= 5:
pool, count = EARLY_MONSTERS, random.randint(1, 2)
elif battle_number <= 15:
pool = EARLY_MONSTERS + MID_MONSTERS[:5]
count = random.randint(1, 3)
elif battle_number <= 30:
pool, count = MID_MONSTERS, random.randint(1, 3)
elif battle_number <= 50:
pool = MID_MONSTERS[5:] + HIGH_MONSTERS[:10]
count = random.randint(1, 3)
elif battle_number <= 75:
pool, count = HIGH_MONSTERS, random.randint(1, 3)
else:
pool = HIGH_MONSTERS + BOSS_MONSTERS
count = random.randint(1, 2)
chosen = random.choices(pool, k=count)
# Scale stats slightly based on battle number
result = []
for mid in chosen:
t = copy.deepcopy(MONSTER_DB[mid])
# Scale-up beyond base level
extra_levels = max(0, (battle_number // 5) - t.level // 5)
scale = 1.0 + extra_levels * 0.05
t.base_stats.hp = int(t.base_stats.hp * scale)
t.base_stats.patk = int(t.base_stats.patk * scale)
t.base_stats.matk = int(t.base_stats.matk * scale)
t.base_stats.pdef = int(t.base_stats.pdef * scale)
t.base_stats.mdef = int(t.base_stats.mdef * scale)
result.append(t)
return result
[Python] Turn-Based Battle Game
skills_db.py
"""Skills database with 100 skills across all job classes."""
from data_types import *
def build_skill_database() -> Dict[int, Skill]:
skills = {}
def s(id, name, stype, power, mp, acc, elem, hits, target, tier, desc, jobs, effect=None):
skills[id] = Skill(id, name, stype, power, mp, acc, elem, hits, target, tier, desc, effect, jobs)
W = JobClass.WARRIOR; KN = JobClass.KNIGHT; PA = JobClass.PALADIN
BE = JobClass.BERSERKER; AS = JobClass.ASSASSIN; RA = JobClass.RANGER
HU = JobClass.HUNTER; SP = JobClass.SPEARMAN; DR = JobClass.DRAGOON
MO = JobClass.MONK; CL = JobClass.CLERIC; PR = JobClass.PRIEST
FM = JobClass.FIRE_MAGE; IM = JobClass.ICE_MAGE; ST = JobClass.STORM_MAGE
WM = JobClass.WIND_MAGE; EM = JobClass.EARTH_MAGE; DM = JobClass.DARK_MAGE
LM = JobClass.LIGHT_MAGE; SA = JobClass.ARCANE_SAGE
SE = SkillTarget.SINGLE_ENEMY; AE = SkillTarget.ALL_ENEMIES
SA_ = SkillTarget.SINGLE_ALLY; AA = SkillTarget.ALL_ALLIES
SF = SkillTarget.SELF; RE = SkillTarget.RANDOM_ENEMY
# ── WARRIOR (IDs 1-14) ──────────────────────────────
s(1,"Slash",SkillType.PHYSICAL,1.2,0,0.9,Element.NONE,1,SE,SkillTier.BASIC,"A swift sword slash.",[W,KN,PA])
s(2,"Power Strike",SkillType.PHYSICAL,1.5,4,0.85,Element.NONE,1,SE,SkillTier.BASIC,"A powerful focused strike.",[W,BE])
s(3,"Double Slash",SkillType.PHYSICAL,0.9,6,0.88,Element.NONE,2,SE,SkillTier.INTERMEDIATE,"Two rapid slashes.",[W,AS])
s(4,"Whirlwind Slash",SkillType.PHYSICAL,0.85,8,0.85,Element.WIND,1,AE,SkillTier.INTERMEDIATE,"Spin attack hitting all foes.",[W])
s(5,"Blade Storm",SkillType.PHYSICAL,2.0,20,0.8,Element.NONE,3,SE,SkillTier.ULTIMATE,"Unleash a storm of blades.",[W])
s(6,"Shield Bash",SkillType.PHYSICAL,1.0,3,0.9,Element.NONE,1,SE,SkillTier.BASIC,"Bash with shield, chance to stun.",
[KN],SkillEffect(StatusEffectType.STUN,1,0.3))
s(7,"Guard Stance",SkillType.BUFF,0,5,1.0,Element.NONE,1,SF,SkillTier.BASIC,"Raise defense for 2 turns.",
[KN,PA],SkillEffect(StatusEffectType.GUARD_UP,2))
s(8,"Holy Sword",SkillType.PHYSICAL,1.6,10,0.85,Element.LIGHT,1,SE,SkillTier.INTERMEDIATE,"Light-imbued sword strike.",[PA,KN])
s(9,"Provoke",SkillType.DEBUFF,0,4,0.95,Element.NONE,1,AE,SkillTier.INTERMEDIATE,"Draw all enemy attacks.",[KN])
s(10,"Divine Blade",SkillType.PHYSICAL,2.5,25,0.8,Element.LIGHT,1,SE,SkillTier.ULTIMATE,"Sacred blade of divine power.",[PA])
s(11,"Reckless Strike",SkillType.PHYSICAL,2.0,5,0.8,Element.NONE,1,SE,SkillTier.BASIC,"High power, ignores own defense.",[BE])
s(12,"Frenzy",SkillType.PHYSICAL,0.8,8,0.75,Element.NONE,3,RE,SkillTier.INTERMEDIATE,"Attack randomly 3 times.",
[BE],SkillEffect(StatusEffectType.BERSERK,2))
s(13,"Bloodthirst",SkillType.PHYSICAL,1.8,10,0.85,Element.NONE,1,SE,SkillTier.INTERMEDIATE,"Absorb HP on hit.",[BE])
s(14,"Apocalypse",SkillType.PHYSICAL,3.5,30,0.75,Element.DARK,1,AE,SkillTier.ULTIMATE,"Catastrophic strike of destruction.",[BE])
# ── ASSASSIN (15-25) ────────────────────────────────
s(15,"Backstab",SkillType.PHYSICAL,1.8,5,0.85,Element.NONE,1,SE,SkillTier.BASIC,"High crit strike from shadows.",[AS])
s(16,"Poison Blade",SkillType.PHYSICAL,1.0,4,0.9,Element.NONE,1,SE,SkillTier.BASIC,"Inflict poison.",
[AS,HU],SkillEffect(StatusEffectType.POISON,3,0.7))
s(17,"Shadow Step",SkillType.PHYSICAL,1.4,7,0.88,Element.DARK,1,SE,SkillTier.INTERMEDIATE,"Teleport strike, ignore EVA.",[AS])
s(18,"Venom Strike",SkillType.PHYSICAL,1.1,8,0.88,Element.NONE,1,SE,SkillTier.INTERMEDIATE,"Inflict venom.",
[AS],SkillEffect(StatusEffectType.VENOM,4,0.65))
s(19,"Death Mark",SkillType.SPECIAL,0,12,0.85,Element.DARK,1,SE,SkillTier.ULTIMATE,"Mark for death - doom in 5 turns.",
[AS],SkillEffect(StatusEffectType.DOOM,5,1.0))
# ── RANGER / HUNTER (26-38) ─────────────────────────
s(20,"Arrow Shot",SkillType.PHYSICAL,1.1,0,0.92,Element.NONE,1,SE,SkillTier.BASIC,"Basic arrow attack.",[RA,HU])
s(21,"Triple Arrow",SkillType.PHYSICAL,0.7,7,0.85,Element.NONE,3,SE,SkillTier.BASIC,"Fire three arrows.",[RA])
s(22,"Rain of Arrows",SkillType.PHYSICAL,0.65,10,0.82,Element.NONE,1,AE,SkillTier.INTERMEDIATE,"Barrage of arrows on all foes.",[RA])
s(23,"Sniper Shot",SkillType.PHYSICAL,2.2,12,0.9,Element.NONE,1,SE,SkillTier.INTERMEDIATE,"Precise high damage shot, high crit.",[RA,HU])
s(24,"Piercing Arrow",SkillType.PHYSICAL,1.5,8,0.9,Element.NONE,1,AE,SkillTier.INTERMEDIATE,"Arrow pierces all enemies.",[RA])
s(25,"Meteor Arrow",SkillType.PHYSICAL,3.0,28,0.82,Element.FIRE,1,SE,SkillTier.ULTIMATE,"Flaming arrow from heavens.",[RA])
s(26,"Trap Set",SkillType.DEBUFF,0,5,0.9,Element.NONE,1,SE,SkillTier.BASIC,"Set a trap - slows enemy.",
[HU],SkillEffect(StatusEffectType.SPEED_DOWN,3,0.8))
s(27,"Beast Lore",SkillType.DEBUFF,0,6,0.95,Element.NONE,1,SE,SkillTier.INTERMEDIATE,"Reveal and mark weakness.",
[HU],SkillEffect(StatusEffectType.WEAKNESS_MARK,3))
s(28,"Dragon Slayer",SkillType.PHYSICAL,3.5,30,0.8,Element.LIGHT,1,SE,SkillTier.ULTIMATE,"Ultimate anti-dragon technique.",[HU,DR])
# ── SPEARMAN / DRAGOON (29-40) ──────────────────────
s(29,"Lance Thrust",SkillType.PHYSICAL,1.3,3,0.9,Element.NONE,1,SE,SkillTier.BASIC,"Thrust with spear.",[SP,DR])
s(30,"Sweep",SkillType.PHYSICAL,0.9,5,0.87,Element.NONE,1,AE,SkillTier.BASIC,"Sweep all enemies with spear.",[SP])
s(31,"Spear Dance",SkillType.PHYSICAL,0.85,9,0.85,Element.NONE,3,RE,SkillTier.INTERMEDIATE,"Dance of spear strikes.",[SP])
s(32,"Dragon Dive",SkillType.PHYSICAL,2.0,12,0.85,Element.WIND,1,SE,SkillTier.INTERMEDIATE,"Leap and dive with spear.",[DR])
s(33,"Jump",SkillType.PHYSICAL,2.3,10,0.88,Element.NONE,1,SE,SkillTier.INTERMEDIATE,"Leap attack from above.",[DR,SP])
s(34,"Chaos Nova",SkillType.PHYSICAL,3.2,30,0.78,Element.NONE,1,AE,SkillTier.ULTIMATE,"Explosive nova of force.",[DR])
# ── MONK (35-44) ────────────────────────────────────
s(35,"Punch",SkillType.PHYSICAL,1.1,0,0.93,Element.NONE,1,SE,SkillTier.BASIC,"Basic unarmed strike.",[MO])
s(36,"Combo Strike",SkillType.PHYSICAL,0.75,5,0.9,Element.NONE,3,SE,SkillTier.BASIC,"Rapid combo punches.",[MO])
s(37,"Shockwave",SkillType.PHYSICAL,1.3,8,0.87,Element.EARTH,1,AE,SkillTier.INTERMEDIATE,"Ground shockwave hits all foes.",[MO])
s(38,"Inner Focus",SkillType.BUFF,0,6,1.0,Element.NONE,1,SF,SkillTier.INTERMEDIATE,"Focus power for next attack.",
[MO],SkillEffect(StatusEffectType.FOCUS,2))
s(39,"Fist of Heaven",SkillType.PHYSICAL,3.0,25,0.82,Element.LIGHT,1,SE,SkillTier.ULTIMATE,"Divine fist of heaven.",[MO])
# ── CLERIC / PRIEST (40-54) ─────────────────────────
s(40,"Heal",SkillType.HEAL,1.0,6,1.0,Element.LIGHT,1,SA_,SkillTier.BASIC,"Restore ally HP.",[CL,PR,PA])
s(41,"Smite",SkillType.MAGICAL,1.2,7,0.88,Element.LIGHT,1,SE,SkillTier.BASIC,"Light-based strike.",[CL])
s(42,"Cure Status",SkillType.HEAL,0,5,1.0,Element.NONE,1,SA_,SkillTier.BASIC,"Remove a status ailment.",
[CL,PR])
s(43,"Mass Heal",SkillType.HEAL,0.85,14,1.0,Element.LIGHT,1,AA,SkillTier.INTERMEDIATE,"Heal all allies.",[PR,CL])
s(44,"Holy Light",SkillType.MAGICAL,1.8,15,0.85,Element.LIGHT,1,SE,SkillTier.INTERMEDIATE,"Brilliant holy beam.",[CL,PR])
s(45,"Regen Aura",SkillType.BUFF,0,10,1.0,Element.LIGHT,1,AA,SkillTier.INTERMEDIATE,"Grant regen to all allies.",
[PR],SkillEffect(StatusEffectType.REGEN,3))
s(46,"Resurrection",SkillType.HEAL,0,20,0.95,Element.LIGHT,1,SA_,SkillTier.ULTIMATE,"Revive fallen ally with full HP.",[PR])
s(47,"Divine Judgment",SkillType.MAGICAL,3.0,28,0.82,Element.LIGHT,1,AE,SkillTier.ULTIMATE,"Judgment of the divine.",[CL])
s(48,"Blessing",SkillType.BUFF,0,8,1.0,Element.LIGHT,1,SA_,SkillTier.INTERMEDIATE,"Raise ally's all stats.",
[PA,PR])
s(49,"Silence Ward",SkillType.DEBUFF,0,7,0.85,Element.LIGHT,1,SE,SkillTier.BASIC,"Silence an enemy.",
[CL],SkillEffect(StatusEffectType.SILENCE,2,0.75))
# ── FIRE MAGE (50-59) ───────────────────────────────
s(50,"Fire Bolt",SkillType.MAGICAL,1.3,7,0.9,Element.FIRE,1,SE,SkillTier.BASIC,"Basic fire projectile.",[FM,SA])
s(51,"Fire Ball",SkillType.MAGICAL,1.1,10,0.87,Element.FIRE,1,AE,SkillTier.BASIC,"Explosive fireball hits all.",[FM])
s(52,"Inferno",SkillType.MAGICAL,2.0,16,0.85,Element.FIRE,1,SE,SkillTier.INTERMEDIATE,"Column of infernal flames.",
[FM],SkillEffect(StatusEffectType.BURN,2,0.5))
s(53,"Flame Burst",SkillType.MAGICAL,1.5,12,0.87,Element.FIRE,1,AE,SkillTier.INTERMEDIATE,"Burst of flames over all foes.",[FM])
s(54,"Hellfire",SkillType.MAGICAL,3.5,35,0.78,Element.FIRE,1,AE,SkillTier.ULTIMATE,"Hellfire scorches everything.",[FM])
# ── ICE MAGE (55-64) ────────────────────────────────
s(55,"Ice Shard",SkillType.MAGICAL,1.2,6,0.9,Element.ICE,1,SE,SkillTier.BASIC,"Sharp ice projectile.",[IM,SA])
s(56,"Blizzard",SkillType.MAGICAL,1.0,10,0.87,Element.ICE,1,AE,SkillTier.BASIC,"Ice storm hits all enemies.",
[IM],SkillEffect(StatusEffectType.SPEED_DOWN,2,0.4))
s(57,"Frost Lance",SkillType.MAGICAL,1.7,12,0.88,Element.ICE,1,SE,SkillTier.INTERMEDIATE,"Piercing lance of frost.",
[IM],SkillEffect(StatusEffectType.FREEZE,1,0.3))
s(58,"Ice Field",SkillType.MAGICAL,1.4,15,0.85,Element.ICE,1,AE,SkillTier.INTERMEDIATE,"Freeze the entire battlefield.",[IM])
s(59,"Absolute Zero",SkillType.MAGICAL,4.0,40,0.72,Element.ICE,1,SE,SkillTier.ULTIMATE,"Reduce temperature to absolute zero.",[IM])
# ── STORM MAGE (60-69) ──────────────────────────────
s(60,"Thunder",SkillType.MAGICAL,1.2,7,0.88,Element.LIGHTNING,1,SE,SkillTier.BASIC,"Basic lightning strike.",[ST,SA])
s(61,"Lightning Bolt",SkillType.MAGICAL,1.4,9,0.87,Element.LIGHTNING,1,SE,SkillTier.BASIC,"Focused lightning bolt.",
[ST])
s(62,"Chain Lightning",SkillType.MAGICAL,1.0,14,0.85,Element.LIGHTNING,1,AE,SkillTier.INTERMEDIATE,"Lightning chains between all foes.",[ST])
s(63,"Thunderstorm",SkillType.MAGICAL,1.6,18,0.83,Element.LIGHTNING,1,AE,SkillTier.INTERMEDIATE,"Raging storm of lightning.",[ST])
s(64,"Judgment Thunder",SkillType.MAGICAL,3.8,38,0.75,Element.LIGHTNING,1,SE,SkillTier.ULTIMATE,"Ultimate thunderbolt of judgment.",[ST])
# ── WIND MAGE (65-72) ───────────────────────────────
s(65,"Wind Slash",SkillType.MAGICAL,1.1,5,0.92,Element.WIND,1,SE,SkillTier.BASIC,"Blade of wind.",[WM])
s(66,"Gale",SkillType.MAGICAL,0.9,8,0.88,Element.WIND,1,AE,SkillTier.BASIC,"Gale force wind hits all.",
[WM],SkillEffect(StatusEffectType.SPEED_DOWN,2,0.35))
s(67,"Tornado",SkillType.MAGICAL,1.7,14,0.85,Element.WIND,1,SE,SkillTier.INTERMEDIATE,"Miniature tornado engulfs enemy.",[WM])
s(68,"Hurricane",SkillType.MAGICAL,1.4,18,0.83,Element.WIND,1,AE,SkillTier.INTERMEDIATE,"Hurricane force winds.",[WM])
s(69,"Tempest",SkillType.MAGICAL,3.2,32,0.78,Element.WIND,1,AE,SkillTier.ULTIMATE,"Catastrophic tempest of wind.",[WM])
# ── EARTH MAGE (70-77) ──────────────────────────────
s(70,"Stone",SkillType.MAGICAL,1.2,5,0.9,Element.EARTH,1,SE,SkillTier.BASIC,"Hurl a stone.",[EM])
s(71,"Earth Spike",SkillType.MAGICAL,1.4,8,0.88,Element.EARTH,1,SE,SkillTier.BASIC,"Spike from the ground.",[EM])
s(72,"Earthquake",SkillType.MAGICAL,1.5,15,0.85,Element.EARTH,1,AE,SkillTier.INTERMEDIATE,"Massive earthquake.",[EM])
s(73,"Rock Slide",SkillType.MAGICAL,1.3,12,0.87,Element.EARTH,1,AE,SkillTier.INTERMEDIATE,"Avalanche of rocks.",
[EM],SkillEffect(StatusEffectType.SPEED_DOWN,2,0.4))
s(74,"Meteor",SkillType.MAGICAL,4.2,42,0.70,Element.EARTH,1,AE,SkillTier.ULTIMATE,"Call down a meteor.",[EM,SA])
# ── DARK MAGE (75-82) ───────────────────────────────
s(75,"Dark Bolt",SkillType.MAGICAL,1.2,6,0.9,Element.DARK,1,SE,SkillTier.BASIC,"Dark energy bolt.",[DM])
s(76,"Shadow Bind",SkillType.DEBUFF,0,8,0.82,Element.DARK,1,SE,SkillTier.BASIC,"Bind enemy in shadows.",
[DM],SkillEffect(StatusEffectType.PARALYZE,2,0.65))
s(77,"Dark Pulse",SkillType.MAGICAL,1.5,12,0.87,Element.DARK,1,AE,SkillTier.INTERMEDIATE,"Pulse of dark energy.",
[DM],SkillEffect(StatusEffectType.CURSE,2,0.4))
s(78,"Void Drain",SkillType.MAGICAL,1.3,10,0.87,Element.DARK,1,SE,SkillTier.INTERMEDIATE,"Drain MP from target.",
[DM],SkillEffect(StatusEffectType.MANA_BURN,1))
s(79,"Abyss",SkillType.MAGICAL,3.8,38,0.72,Element.DARK,1,SE,SkillTier.ULTIMATE,"Plunge enemy into the abyss.",[DM])
# ── LIGHT MAGE (80-87) ──────────────────────────────
s(80,"Photon",SkillType.MAGICAL,1.2,6,0.92,Element.LIGHT,1,SE,SkillTier.BASIC,"Photon burst.",[LM])
s(81,"Shine",SkillType.MAGICAL,1.0,8,0.9,Element.LIGHT,1,AE,SkillTier.BASIC,"Flash of light blinds enemies.",
[LM],SkillEffect(StatusEffectType.BLIND,2,0.5))
s(82,"Radiance",SkillType.MAGICAL,1.7,14,0.87,Element.LIGHT,1,SE,SkillTier.INTERMEDIATE,"Blinding radiance.",[LM])
s(83,"Holy Barrier",SkillType.BUFF,0,12,1.0,Element.LIGHT,1,SA_,SkillTier.INTERMEDIATE,"Shield ally with holy power.",
[LM,PA],SkillEffect(StatusEffectType.SHIELD,3))
s(84,"Judgement Ray",SkillType.MAGICAL,3.5,35,0.78,Element.LIGHT,1,SE,SkillTier.ULTIMATE,"Divine ray of judgment.",[LM])
# ── ARCANE SAGE (85-100) ────────────────────────────
s(85,"Arcane Bolt",SkillType.MAGICAL,1.3,7,0.9,Element.NONE,1,SE,SkillTier.BASIC,"Pure arcane projectile.",[SA])
s(86,"Mana Shield",SkillType.BUFF,0,8,1.0,Element.NONE,1,SF,SkillTier.BASIC,"Convert MP to a shield.",
[SA],SkillEffect(StatusEffectType.SHIELD,2))
s(87,"Arcane Storm",SkillType.MAGICAL,1.3,16,0.87,Element.NONE,1,AE,SkillTier.INTERMEDIATE,"Storm of arcane energy.",[SA])
s(88,"Time Dilation",SkillType.SPECIAL,0,18,0.85,Element.NONE,1,SA_,SkillTier.INTERMEDIATE,"Accelerate ally's time.",
[SA],SkillEffect(StatusEffectType.HASTE,3))
s(89,"Meteor",SkillType.MAGICAL,4.0,45,0.70,Element.NONE,1,AE,SkillTier.ULTIMATE,"Ultimate arcane meteor barrage.",[SA])
# ── EXTRA SKILLS (90-100) ───────────────────────────
s(90,"Blood Drain",SkillType.MAGICAL,1.4,10,0.87,Element.DARK,1,SE,SkillTier.INTERMEDIATE,"Drain blood from enemy.",
[DM,AS],SkillEffect(StatusEffectType.BLEED,3,0.6))
s(91,"Phantom Edge",SkillType.PHYSICAL,1.6,9,0.88,Element.DARK,1,SE,SkillTier.INTERMEDIATE,"Phantom blade strike.",[AS,DM])
s(92,"Nature's Wrath",SkillType.MAGICAL,1.8,16,0.85,Element.EARTH,1,AE,SkillTier.INTERMEDIATE,"Earth's fury.",[EM,MO])
s(93,"Solar Flare",SkillType.MAGICAL,2.0,18,0.83,Element.FIRE,1,AE,SkillTier.INTERMEDIATE,"Blinding solar burst.",
[FM,LM],SkillEffect(StatusEffectType.BLIND,2,0.6))
s(94,"Frost Nova",SkillType.MAGICAL,1.6,14,0.87,Element.ICE,1,AE,SkillTier.INTERMEDIATE,"Explosive ice nova.",
[IM],SkillEffect(StatusEffectType.FREEZE,1,0.4))
s(95,"War Cry",SkillType.BUFF,0,8,1.0,Element.NONE,1,AA,SkillTier.INTERMEDIATE,"Boost all allies' attack.",
[W,BE],SkillEffect(StatusEffectType.BERSERK,2))
s(96,"Stealth",SkillType.BUFF,0,6,1.0,Element.NONE,1,SF,SkillTier.BASIC,"Enter stealth mode.",
[AS,RA])
s(97,"Eagle Eye",SkillType.BUFF,0,5,1.0,Element.NONE,1,SF,SkillTier.BASIC,"Boost accuracy and crit.",
[RA,HU],SkillEffect(StatusEffectType.FOCUS,2))
s(98,"Shield Wall",SkillType.BUFF,0,10,1.0,Element.NONE,1,AA,SkillTier.INTERMEDIATE,"Raise defense of all allies.",
[KN,PA],SkillEffect(StatusEffectType.GUARD_UP,3))
s(99,"Soul Shatter",SkillType.MAGICAL,2.5,22,0.82,Element.DARK,1,SE,SkillTier.INTERMEDIATE,"Shatter the enemy's soul.",
[DM,SA],SkillEffect(StatusEffectType.SOUL_DRAIN,3))
s(100,"Omega Strike",SkillType.PHYSICAL,5.0,50,0.75,Element.NONE,1,SE,SkillTier.ULTIMATE,"The ultimate physical strike.",[W,BE,MO])
return skills
SKILL_DB: Dict[int, Skill] = build_skill_database()
# Job -> list of skill IDs
JOB_SKILL_POOL: Dict[JobClass, List[int]] = {
JobClass.WARRIOR: [1,2,3,4,5,11,95,98,100,6,7,34,33,29],
JobClass.KNIGHT: [6,7,8,9,10,1,40,48,98,2,33,30,44,83],
JobClass.PALADIN: [8,10,40,48,83,7,6,44,46,47,80,81,98,45,42],
JobClass.BERSERKER: [11,12,13,14,2,5,95,1,3,100,34,4,92,37,79],
JobClass.ASSASSIN: [15,16,17,18,19,3,96,90,91,75,76,78,99,77,25],
JobClass.RANGER: [20,21,22,23,24,25,96,97,26,27,1,65,70,60,50],
JobClass.HUNTER: [20,21,23,26,27,28,97,16,31,22,24,33,34,35,90],
JobClass.SPEARMAN: [29,30,31,32,33,34,1,2,4,35,36,92,72,37,95],
JobClass.DRAGOON: [29,32,33,34,28,31,8,65,4,3,5,92,25,23,100],
JobClass.MONK: [35,36,37,38,39,2,1,92,72,70,95,100,33,34,30],
JobClass.CLERIC: [40,41,42,43,44,45,46,47,48,49,80,81,8,82,84],
JobClass.PRIEST: [40,43,45,46,48,42,49,44,83,84,41,47,80,82,81],
JobClass.FIRE_MAGE: [50,51,52,53,54,93,85,86,87,75,77,60,65,70,76],
JobClass.ICE_MAGE: [55,56,57,58,59,94,85,86,87,65,70,75,77,60,52],
JobClass.STORM_MAGE: [60,61,62,63,64,85,86,87,50,55,65,93,88,75,77],
JobClass.WIND_MAGE: [65,66,67,68,69,85,86,87,60,55,50,88,92,93,63],
JobClass.EARTH_MAGE: [70,71,72,73,74,92,85,86,87,37,36,55,50,93,65],
JobClass.DARK_MAGE: [75,76,77,78,79,90,91,99,85,86,87,19,88,18,15],
JobClass.LIGHT_MAGE: [80,81,82,83,84,40,44,47,48,85,86,87,93,45,42],
JobClass.ARCANE_SAGE:[85,86,87,88,89,74,64,59,54,79,84,99,93,92,100],
}
[HTML] DIY Roulette
[HTML] DIY Roulette
Fair Roulette
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fortune Wheel</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Cormorant+Garamond:wght@300;500&display=swap" rel="stylesheet" />
<style>
:root {
--gold: #c9a84c;
--gold-light: #f0d080;
--gold-dim: #7a6020;
--bg: #0a0a0f;
--surface: #13131a;
--surface2: #1c1c28;
--text: #e8dfc8;
--text-dim: #8a7e60;
--red: #8b1a1a;
--green: #1a4a2e;
--navy: #1a2040;
--purple: #3a1a4a;
--teal: #0f3a3a;
--crimson: #6b1020;
--glow: rgba(201, 168, 76, 0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Cormorant Garamond', Georgia, serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
overflow-x: hidden;
}
/* Subtle noise texture overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
header {
text-align: center;
}
header h1 {
font-family: 'Playfair Display', serif;
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 900;
letter-spacing: 0.08em;
background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 50%, var(--gold-dim) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-transform: uppercase;
}
header p {
color: var(--text-dim);
font-size: 0.9rem;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-top: 4px;
}
/* Controls */
.controls {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.15);
border-radius: 4px;
padding: 20px 24px;
width: 100%;
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 80px;
}
.field label {
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
}
.field input[type="number"] {
background: var(--surface2);
border: 1px solid rgba(201,168,76,0.2);
color: var(--text);
font-family: 'Playfair Display', serif;
font-size: 1.3rem;
padding: 8px 12px;
border-radius: 3px;
width: 100%;
outline: none;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.field input::-webkit-outer-spin-button,
.field input::-webkit-inner-spin-button { -webkit-appearance: none; }
.field input:focus { border-color: var(--gold); }
.range-sep {
color: var(--text-dim);
font-size: 1.4rem;
padding-bottom: 10px;
font-family: 'Playfair Display', serif;
}
.btn-build {
background: transparent;
border: 1px solid var(--gold);
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 10px 18px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
align-self: flex-end;
}
.btn-build:hover { background: var(--gold); color: var(--bg); }
/* Wheel area */
.wheel-area {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
}
.wheel-container {
position: relative;
width: clamp(280px, 80vw, 420px);
height: clamp(280px, 80vw, 420px);
}
/* Pointer / arrow */
.pointer {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
filter: drop-shadow(0 0 6px var(--gold));
}
.pointer svg { display: block; }
/* Glow ring behind canvas */
.wheel-glow {
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(circle, rgba(201,168,76,0.08) 60%, transparent 75%);
pointer-events: none;
transition: opacity 0.4s;
opacity: 0;
}
.wheel-glow.active { opacity: 1; }
canvas {
border-radius: 50%;
display: block;
width: 100%;
height: 100%;
}
/* Center hub */
.hub {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #e8d090, #8a6018);
border: 3px solid #1a1410;
box-shadow: 0 0 12px rgba(0,0,0,0.8), 0 0 6px rgba(201,168,76,0.3);
z-index: 5;
}
/* Spin button */
.btn-spin {
position: relative;
background: linear-gradient(135deg, #8a6018 0%, var(--gold) 50%, #8a6018 100%);
border: none;
color: #0a0a0f;
font-family: 'Playfair Display', serif;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 14px 48px;
border-radius: 3px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(201,168,76,0.25);
transition: transform 0.1s, box-shadow 0.2s, opacity 0.2s;
overflow: hidden;
}
.btn-spin::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.2) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.5s;
}
.btn-spin:hover::before { transform: translateX(100%); }
.btn-spin:hover { box-shadow: 0 6px 30px rgba(201,168,76,0.45); transform: translateY(-1px); }
.btn-spin:active { transform: translateY(0); }
.btn-spin:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* Result display */
.result-card {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.2);
border-radius: 4px;
padding: 18px 32px;
text-align: center;
width: 100%;
min-height: 78px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
transition: border-color 0.4s;
}
.result-card.highlight { border-color: var(--gold); }
.result-label {
font-size: 0.65rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text-dim);
}
.result-value {
font-family: 'Playfair Display', serif;
font-size: clamp(2.2rem, 8vw, 3.5rem);
font-weight: 900;
color: var(--gold-light);
line-height: 1;
text-shadow: 0 0 30px rgba(240,208,128,0.4);
transition: opacity 0.3s;
}
.result-sub {
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.placeholder-msg {
color: var(--text-dim);
font-size: 0.85rem;
letter-spacing: 0.1em;
font-style: italic;
}
/* Error */
.error-msg {
color: #e05050;
font-size: 0.8rem;
letter-spacing: 0.1em;
text-align: center;
min-height: 18px;
}
/* Note about 3 */
.note {
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.08em;
text-align: center;
line-height: 1.6;
}
.note span { color: var(--gold); }
/* Spinning animation on wheel */
@keyframes pulseGold {
0%, 100% { box-shadow: 0 0 18px rgba(201,168,76,0.2); }
50% { box-shadow: 0 0 40px rgba(201,168,76,0.6); }
}
.result-card.highlight { animation: pulseGold 1.5s ease-in-out 3; }
/* Divider */
.divider {
width: 100%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(201,168,76,0.2), transparent);
}
</style>
</head>
<body>
<div class="wrapper">
<header>
<h1>Fortune Wheel</h1>
<p>Spin & Discover Your Number</p>
</header>
<div class="controls">
<div class="field">
<label>Lower Bound</label>
<input type="number" id="lb" value="1" step="1" />
</div>
<div class="range-sep">—</div>
<div class="field">
<label>Upper Bound</label>
<input type="number" id="ub" value="8" step="1" />
</div>
<button class="btn-build" onclick="buildWheel()">Build</button>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="wheel-area">
<div class="wheel-container" id="wheelContainer">
<div class="wheel-glow" id="wheelGlow"></div>
<!-- Pointer arrow at top -->
<div class="pointer">
<svg width="24" height="28" viewBox="0 0 24 28" fill="none">
<path d="M12 28 L0 4 Q12 0 24 4 Z" fill="#c9a84c"/>
<path d="M12 26 L2 6 Q12 2 22 6 Z" fill="#f0d080"/>
</svg>
</div>
<canvas id="wheelCanvas"></canvas>
<div class="hub"></div>
</div>
<button class="btn-spin" id="spinBtn" onclick="spin()" disabled>SPIN</button>
</div>
<div class="divider"></div>
<div class="result-card" id="resultCard">
<p class="placeholder-msg">Build your wheel & spin to reveal</p>
</div>
<p class="note">
Numbers from <span id="noteRange">—</span> each have a perfectly <span>equal chance</span>
· 40% chance of a reverse <span>bounce</span> on each spin
</p>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let wheelData = null;
let currentRotation = 0;
let isSpinning = false;
let animFrame = null;
const SEGMENT_COLORS = [
['#6b1010','#9b2020'],
['#1a3a1a','#2a5a2a'],
['#0f1f4a','#1a3070'],
['#2a0f4a','#441880'],
['#0f2f3a','#175060'],
['#3a1a0a','#6a3010'],
['#1a0a2a','#302050'],
['#1f1a0f','#40381a'],
];
// ─── Build Wheel ─────────────────────────────────────────────────────────────
function buildWheel() {
const lbEl = document.getElementById('lb');
const ubEl = document.getElementById('ub');
const errEl = document.getElementById('errorMsg');
const lb = parseInt(lbEl.value);
const ub = parseInt(ubEl.value);
errEl.textContent = '';
if (isNaN(lb) || isNaN(ub)) { errEl.textContent = 'Please enter valid integers.'; return; }
if (lb > ub) { errEl.textContent = 'Lower bound must be ≤ upper bound.'; return; }
if (ub - lb > 49) { errEl.textContent = 'Range too large — please keep it within 50 numbers.'; return; }
const numbers = [];
for (let i = lb; i <= ub; i++) numbers.push(i);
const N = numbers.length;
// All equal probability
const probs = numbers.map(() => 1 / N);
// Build segments — equal angular slices
const segAngle = (2 * Math.PI) / N;
let angle = -Math.PI / 2;
const segments = numbers.map((n, i) => {
const start = angle;
const end = angle + segAngle;
const mid = angle + segAngle / 2;
angle += segAngle;
return { number: n, prob: 1 / N, startAngle: start, endAngle: end, midAngle: mid, colorIdx: i % SEGMENT_COLORS.length };
});
wheelData = { numbers, probs, segments };
currentRotation = 0;
document.getElementById('noteRange').textContent = `${lb} to ${ub}`;
document.getElementById('spinBtn').disabled = false;
document.getElementById('resultCard').className = 'result-card';
document.getElementById('resultCard').innerHTML = '<p class="placeholder-msg">Ready — hit SPIN!</p>';
drawWheel(0);
}
// ─── Draw ────────────────────────────────────────────────────────────────────
function drawWheel(rotation) {
if (!wheelData) return;
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2;
const cy = size / 2;
const R = size / 2 - 8;
ctx.clearRect(0, 0, size, size);
// Outer gold ring
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const goldRing = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
goldRing.addColorStop(0, '#8a6018');
goldRing.addColorStop(0.5, '#c9a84c');
goldRing.addColorStop(1, '#f0d080');
ctx.fillStyle = goldRing;
ctx.fill();
wheelData.segments.forEach((seg) => {
const start = seg.startAngle + rotation;
const end = seg.endAngle + rotation;
const [dark, light] = SEGMENT_COLORS[seg.colorIdx];
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, R, start, end);
ctx.closePath();
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
grad.addColorStop(0, light);
grad.addColorStop(1, dark);
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Label
const labelR = R * 0.68;
const midAngle = (start + end) / 2;
const lx = cx + labelR * Math.cos(midAngle);
const ly = cy + labelR * Math.sin(midAngle);
ctx.save();
ctx.translate(lx, ly);
ctx.rotate(midAngle + Math.PI / 2);
const sweep = seg.endAngle - seg.startAngle;
const fontSize = Math.max(9, Math.min(sweep * R * 0.38, R * 0.22));
ctx.font = `bold ${fontSize}px 'Playfair Display', Georgia, serif`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillText(String(seg.number), 0, 0);
ctx.restore();
// Tick mark
const tickX = cx + (R - 4) * Math.cos(start);
const tickY = cy + (R - 4) * Math.sin(start);
ctx.beginPath();
ctx.arc(tickX, tickY, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(201,168,76,0.5)';
ctx.fill();
});
// Inner circle
ctx.beginPath();
ctx.arc(cx, cy, R * 0.24, 0, Math.PI * 2);
ctx.fillStyle = '#0d0d14';
ctx.fill();
ctx.strokeStyle = '#c9a84c';
ctx.lineWidth = 2;
ctx.stroke();
}
// ─── Find which segment is under the pointer ──────────────────────────────────
function segmentAtPointer(rotation) {
// Pointer is at angle -π/2 in world space.
// A point on the wheel at angle θ (in wheel space) appears at θ + rotation.
// We want θ + rotation ≡ -π/2 → θ ≡ -π/2 - rotation
const pointerAngle = ((-Math.PI / 2 - rotation) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
// Segment angles are stored from -π/2, convert to [0, 2π)
return wheelData.segments.find(seg => {
const s = ((seg.startAngle + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI));
const e = ((seg.endAngle + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI));
if (s < e) return pointerAngle >= s && pointerAngle < e;
return pointerAngle >= s || pointerAngle < e; // wraps around 0
}) || wheelData.segments[0];
}
// ─── Spin ─────────────────────────────────────────────────────────────────────
function spin() {
if (isSpinning || !wheelData) return;
isSpinning = true;
const spinBtn = document.getElementById('spinBtn');
const resultCard = document.getElementById('resultCard');
const glow = document.getElementById('wheelGlow');
spinBtn.disabled = true;
resultCard.className = 'result-card';
resultCard.innerHTML = '<p class="placeholder-msg">Spinning…</p>';
// Pick winner uniformly
const winIdx = Math.floor(Math.random() * wheelData.segments.length);
const winner = wheelData.segments[winIdx];
// Land at a random offset within the winning segment (not exactly center)
// Keep away from edges by 15% of segment width
const sweep = winner.endAngle - winner.startAngle;
const margin = sweep * 0.15;
const randomOffset = margin + Math.random() * (sweep - 2 * margin);
const targetAngleOnWheel = winner.startAngle + randomOffset; // where we want pointer to point
// Compute how much we need to rotate so targetAngleOnWheel sits under the pointer (-π/2)
const rawTarget = -Math.PI / 2 - targetAngleOnWheel - currentRotation;
const normalised = ((rawTarget % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
const EXTRA_SPINS = 6 + Math.floor(Math.random() * 4);
const primarySpin = normalised + EXTRA_SPINS * 2 * Math.PI;
// Decide if we do a reverse-bounce (40% chance)
const doBounce = Math.random() < 0.40;
// Bounce: overshoot by a small amount, then spring back
const bounceOvershoot = doBounce ? (0.04 + Math.random() * 0.10) * 2 * Math.PI : 0; // 14°–36°
const startRotation = currentRotation;
glow.classList.add('active');
// ── Phase 1: main deceleration spin ──────────────────────────────────────
const phase1End = startRotation + primarySpin + bounceOvershoot;
const phase1Duration = 4200 + Math.random() * 1400;
// ── Phase 2: bounce back (if doBounce) ───────────────────────────────────
const phase2Start = phase1End;
const phase2End = startRotation + primarySpin; // back to where it should land
const phase2Duration = 500 + Math.random() * 300;
const startTime = performance.now();
let phase = 1;
// Strong ease-out: fast start, very slow finish — feels like real friction
function easeOutQuint(t) {
return 1 - Math.pow(1 - t, 5);
}
// Ease-out then ease-in for bounce-back (like a spring)
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function animate(now) {
if (phase === 1) {
const elapsed = now - startTime;
const t = Math.min(elapsed / phase1Duration, 1);
const eased = easeOutQuint(t);
currentRotation = startRotation + (phase1End - startRotation) * eased;
drawWheel(currentRotation);
if (t < 1) {
animFrame = requestAnimationFrame(animate);
} else {
currentRotation = phase1End;
if (doBounce) {
phase = 2;
// store phase 2 start time
animate._phase2Start = now;
animFrame = requestAnimationFrame(animate);
} else {
finish(winner);
}
}
} else {
// Phase 2: spring back
const elapsed = now - animate._phase2Start;
const t = Math.min(elapsed / phase2Duration, 1);
const eased = easeInOutCubic(t);
currentRotation = phase2Start + (phase2End - phase2Start) * eased;
drawWheel(currentRotation);
if (t < 1) {
animFrame = requestAnimationFrame(animate);
} else {
currentRotation = phase2End;
finish(winner);
}
}
}
animFrame = requestAnimationFrame(animate);
}
function finish(winner) {
const spinBtn = document.getElementById('spinBtn');
const glow = document.getElementById('wheelGlow');
const isSpinningRef = isSpinning;
isSpinning = false;
spinBtn.disabled = false;
drawWheel(currentRotation);
// Re-detect actual segment under pointer (accounts for random offset)
const actual = segmentAtPointer(currentRotation);
showResult(actual || winner);
setTimeout(() => glow.classList.remove('active'), 3000);
}
// ─── Show Result ──────────────────────────────────────────────────────────────
function showResult(winner) {
const resultCard = document.getElementById('resultCard');
const pct = (winner.prob * 100).toFixed(1);
resultCard.innerHTML = `
<span class="result-label">The wheel has spoken</span>
<span class="result-value">${winner.number}</span>
<span class="result-sub">${pct}% probability</span>
`;
resultCard.className = 'result-card highlight';
}
// ─── Init ─────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth || 380;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2, cy = size / 2, R = size / 2 - 8;
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const gr = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
gr.addColorStop(0, '#8a6018'); gr.addColorStop(0.5, '#c9a84c'); gr.addColorStop(1, '#f0d080');
ctx.fillStyle = gr; ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fillStyle = '#13131a'; ctx.fill();
ctx.font = `italic ${Math.floor(R * 0.14)}px 'Cormorant Garamond', Georgia, serif`;
ctx.fillStyle = 'rgba(201,168,76,0.4)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Set bounds & build', cx, cy);
buildWheel();
});
window.addEventListener('resize', () => {
if (wheelData) drawWheel(currentRotation);
});
</script>
</body>
</html>
[HTML] DIY Roulette
Biased Roulette
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fortune Wheel</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Cormorant+Garamond:wght@300;500&display=swap" rel="stylesheet" />
<style>
:root {
--gold: #c9a84c;
--gold-light: #f0d080;
--gold-dim: #7a6020;
--bg: #0a0a0f;
--surface: #13131a;
--surface2: #1c1c28;
--text: #e8dfc8;
--text-dim: #8a7e60;
--red: #8b1a1a;
--green: #1a4a2e;
--navy: #1a2040;
--purple: #3a1a4a;
--teal: #0f3a3a;
--crimson: #6b1020;
--glow: rgba(201, 168, 76, 0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Cormorant Garamond', Georgia, serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
overflow-x: hidden;
}
/* Subtle noise texture overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
header {
text-align: center;
}
header h1 {
font-family: 'Playfair Display', serif;
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 900;
letter-spacing: 0.08em;
background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 50%, var(--gold-dim) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-transform: uppercase;
}
header p {
color: var(--text-dim);
font-size: 0.9rem;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-top: 4px;
}
/* Controls */
.controls {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.15);
border-radius: 4px;
padding: 20px 24px;
width: 100%;
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 80px;
}
.field label {
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-weight: 500;
}
.field input[type="number"] {
background: var(--surface2);
border: 1px solid rgba(201,168,76,0.2);
color: var(--text);
font-family: 'Playfair Display', serif;
font-size: 1.3rem;
padding: 8px 12px;
border-radius: 3px;
width: 100%;
outline: none;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.field input::-webkit-outer-spin-button,
.field input::-webkit-inner-spin-button { -webkit-appearance: none; }
.field input:focus { border-color: var(--gold); }
.range-sep {
color: var(--text-dim);
font-size: 1.4rem;
padding-bottom: 10px;
font-family: 'Playfair Display', serif;
}
.btn-build {
background: transparent;
border: 1px solid var(--gold);
color: var(--gold);
font-family: 'Cormorant Garamond', serif;
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 10px 18px;
border-radius: 3px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
align-self: flex-end;
}
.btn-build:hover { background: var(--gold); color: var(--bg); }
/* Wheel area */
.wheel-area {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
}
.wheel-container {
position: relative;
width: clamp(280px, 80vw, 420px);
height: clamp(280px, 80vw, 420px);
}
/* Pointer / arrow */
.pointer {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
filter: drop-shadow(0 0 6px var(--gold));
}
.pointer svg { display: block; }
/* Glow ring behind canvas */
.wheel-glow {
position: absolute;
inset: -10px;
border-radius: 50%;
background: radial-gradient(circle, rgba(201,168,76,0.08) 60%, transparent 75%);
pointer-events: none;
transition: opacity 0.4s;
opacity: 0;
}
.wheel-glow.active { opacity: 1; }
canvas {
border-radius: 50%;
display: block;
width: 100%;
height: 100%;
}
/* Center hub */
.hub {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #e8d090, #8a6018);
border: 3px solid #1a1410;
box-shadow: 0 0 12px rgba(0,0,0,0.8), 0 0 6px rgba(201,168,76,0.3);
z-index: 5;
}
/* Spin button */
.btn-spin {
position: relative;
background: linear-gradient(135deg, #8a6018 0%, var(--gold) 50%, #8a6018 100%);
border: none;
color: #0a0a0f;
font-family: 'Playfair Display', serif;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 14px 48px;
border-radius: 3px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(201,168,76,0.25);
transition: transform 0.1s, box-shadow 0.2s, opacity 0.2s;
overflow: hidden;
}
.btn-spin::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.2) 50%, transparent 70%);
transform: translateX(-100%);
transition: transform 0.5s;
}
.btn-spin:hover::before { transform: translateX(100%); }
.btn-spin:hover { box-shadow: 0 6px 30px rgba(201,168,76,0.45); transform: translateY(-1px); }
.btn-spin:active { transform: translateY(0); }
.btn-spin:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* Result display */
.result-card {
background: var(--surface);
border: 1px solid rgba(201,168,76,0.2);
border-radius: 4px;
padding: 18px 32px;
text-align: center;
width: 100%;
min-height: 78px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
transition: border-color 0.4s;
}
.result-card.highlight { border-color: var(--gold); }
.result-label {
font-size: 0.65rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text-dim);
}
.result-value {
font-family: 'Playfair Display', serif;
font-size: clamp(2.2rem, 8vw, 3.5rem);
font-weight: 900;
color: var(--gold-light);
line-height: 1;
text-shadow: 0 0 30px rgba(240,208,128,0.4);
transition: opacity 0.3s;
}
.result-sub {
font-size: 0.75rem;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.placeholder-msg {
color: var(--text-dim);
font-size: 0.85rem;
letter-spacing: 0.1em;
font-style: italic;
}
/* Error */
.error-msg {
color: #e05050;
font-size: 0.8rem;
letter-spacing: 0.1em;
text-align: center;
min-height: 18px;
}
/* Note about 3 */
.note {
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.08em;
text-align: center;
line-height: 1.6;
}
.note span { color: var(--gold); }
/* Spinning animation on wheel */
@keyframes pulseGold {
0%, 100% { box-shadow: 0 0 18px rgba(201,168,76,0.2); }
50% { box-shadow: 0 0 40px rgba(201,168,76,0.6); }
}
.result-card.highlight { animation: pulseGold 1.5s ease-in-out 3; }
/* Divider */
.divider {
width: 100%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(201,168,76,0.2), transparent);
}
</style>
</head>
<body>
<div class="wrapper">
<header>
<h1>Fortune Wheel</h1>
<p>Spin & Discover Your Number</p>
</header>
<div class="controls">
<div class="field">
<label>Lower Bound</label>
<input type="number" id="lb" value="1" step="1" />
</div>
<div class="range-sep">—</div>
<div class="field">
<label>Upper Bound</label>
<input type="number" id="ub" value="8" step="1" />
</div>
<button class="btn-build" onclick="buildWheel()">Build</button>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="wheel-area">
<div class="wheel-container" id="wheelContainer">
<div class="wheel-glow" id="wheelGlow"></div>
<!-- Pointer arrow at top -->
<div class="pointer">
<svg width="24" height="28" viewBox="0 0 24 28" fill="none">
<path d="M12 28 L0 4 Q12 0 24 4 Z" fill="#c9a84c"/>
<path d="M12 26 L2 6 Q12 2 22 6 Z" fill="#f0d080"/>
</svg>
</div>
<canvas id="wheelCanvas"></canvas>
<div class="hub"></div>
</div>
<button class="btn-spin" id="spinBtn" onclick="spin()" disabled>SPIN</button>
</div>
<div class="divider"></div>
<div class="result-card" id="resultCard">
<p class="placeholder-msg">Build your wheel & spin to reveal</p>
</div>
<p class="note">
Numbers from <span id="noteRange">—</span> each have an equal chance
· <span>3</span> always carries a <span>+20% boost</span> when in range
</p>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let wheelData = null; // { segments, numbers, probs }
let currentRotation = 0; // current total rotation in radians
let isSpinning = false;
let animFrame = null;
const SEGMENT_COLORS = [
['#6b1010','#9b2020'], // deep red
['#1a3a1a','#2a5a2a'], // forest green
['#0f1f4a','#1a3070'], // navy
['#2a0f4a','#441880'], // purple
['#0f2f3a','#175060'], // teal
['#3a1a0a','#6a3010'], // brown
['#1a0a2a','#302050'], // indigo
['#1f1a0f','#40381a'], // olive-dark
];
// ─── Build Wheel ─────────────────────────────────────────────────────────────
function buildWheel() {
const lbEl = document.getElementById('lb');
const ubEl = document.getElementById('ub');
const errEl = document.getElementById('errorMsg');
const lb = parseInt(lbEl.value);
const ub = parseInt(ubEl.value);
errEl.textContent = '';
if (isNaN(lb) || isNaN(ub)) { errEl.textContent = 'Please enter valid integers.'; return; }
if (lb > ub) { errEl.textContent = 'Lower bound must be ≤ upper bound.'; return; }
if (ub - lb > 49) { errEl.textContent = 'Range too large — please keep it within 50 numbers.'; return; }
const numbers = [];
for (let i = lb; i <= ub; i++) numbers.push(i);
const N = numbers.length;
const has3 = numbers.includes(3);
// Build probabilities
let probs;
if (!has3 || N === 1) {
probs = numbers.map(() => 1 / N);
} else {
const p3 = 0.20;
const pOther = 0.80 / (N - 1);
probs = numbers.map(n => n === 3 ? p3 : pOther);
}
// Build segments (angles start at -π/2 = top, go clockwise)
let angle = -Math.PI / 2;
const segments = numbers.map((n, i) => {
const sweep = probs[i] * 2 * Math.PI;
const mid = angle + sweep / 2;
const seg = { number: n, prob: probs[i], startAngle: angle, endAngle: angle + sweep, midAngle: mid, colorIdx: i % SEGMENT_COLORS.length };
angle += sweep;
return seg;
});
wheelData = { numbers, probs, segments };
currentRotation = 0;
document.getElementById('noteRange').textContent = `${lb} to ${ub}`;
document.getElementById('spinBtn').disabled = false;
document.getElementById('resultCard').className = 'result-card';
document.getElementById('resultCard').innerHTML = '<p class="placeholder-msg">Ready — hit SPIN!</p>';
drawWheel(0);
}
// ─── Draw ────────────────────────────────────────────────────────────────────
function drawWheel(rotation) {
if (!wheelData) return;
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2;
const cy = size / 2;
const R = size / 2 - 8; // outer radius
const innerR = R * 0.12; // hub hole
ctx.clearRect(0, 0, size, size);
// Outer gold ring
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const goldRing = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
goldRing.addColorStop(0, '#8a6018');
goldRing.addColorStop(0.5, '#c9a84c');
goldRing.addColorStop(1, '#f0d080');
ctx.fillStyle = goldRing;
ctx.fill();
// Draw each segment
wheelData.segments.forEach((seg, i) => {
const start = seg.startAngle + rotation;
const end = seg.endAngle + rotation;
const [dark, light] = SEGMENT_COLORS[seg.colorIdx];
// Fill
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, R, start, end);
ctx.closePath();
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
grad.addColorStop(0, light);
grad.addColorStop(1, dark);
ctx.fillStyle = grad;
ctx.fill();
// Subtle border between segments
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Label
const labelR = R * 0.68;
const midAngle = (start + end) / 2;
const lx = cx + labelR * Math.cos(midAngle);
const ly = cy + labelR * Math.sin(midAngle);
ctx.save();
ctx.translate(lx, ly);
ctx.rotate(midAngle + Math.PI / 2);
const sweep = seg.endAngle - seg.startAngle;
const fontSize = Math.max(9, Math.min(sweep * R * 0.38, R * 0.22));
ctx.font = `bold ${fontSize}px 'Playfair Display', Georgia, serif`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
// Special gold color for 3
if (seg.number === 3 && wheelData.numbers.includes(3)) {
ctx.fillStyle = '#f0d080';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
}
ctx.fillText(String(seg.number), 0, 0);
ctx.restore();
// Gold dot at segment start radius (decorative tick)
const tickX = cx + (R - 4) * Math.cos(start);
const tickY = cy + (R - 4) * Math.sin(start);
ctx.beginPath();
ctx.arc(tickX, tickY, 2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(201,168,76,0.5)';
ctx.fill();
});
// Inner dark circle
ctx.beginPath();
ctx.arc(cx, cy, innerR * 2, 0, Math.PI * 2);
ctx.fillStyle = '#0d0d14';
ctx.fill();
ctx.strokeStyle = '#c9a84c';
ctx.lineWidth = 2;
ctx.stroke();
}
// ─── Weighted random pick ─────────────────────────────────────────────────────
function pickWinner() {
const { numbers, probs } = wheelData;
let r = Math.random();
for (let i = 0; i < numbers.length; i++) {
r -= probs[i];
if (r <= 0) return i;
}
return numbers.length - 1;
}
// ─── Spin ─────────────────────────────────────────────────────────────────────
function spin() {
if (isSpinning || !wheelData) return;
isSpinning = true;
const spinBtn = document.getElementById('spinBtn');
const resultCard = document.getElementById('resultCard');
const glow = document.getElementById('wheelGlow');
spinBtn.disabled = true;
resultCard.className = 'result-card';
resultCard.innerHTML = '<p class="placeholder-msg" style="animation: none;">Spinning…</p>';
// Pick winner
const winIdx = pickWinner();
const winner = wheelData.segments[winIdx];
// We want the pointer (at top = -π/2 absolute) to land on the winner's midAngle.
// After adding `currentRotation`, the displayed midAngle is: winner.midAngle + currentRotation
// We want that to equal -π/2 + 2πk for some integer k.
// So additional spin needed: targetRotation = -π/2 - winner.midAngle - currentRotation (mod 2π)
// Then add extra full spins for drama.
const EXTRA_SPINS = 6 + Math.floor(Math.random() * 4); // 6–9 full rotations
const rawTarget = -Math.PI / 2 - winner.midAngle - currentRotation;
const normalised = ((rawTarget % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
const totalSpin = normalised + EXTRA_SPINS * 2 * Math.PI;
const startRotation = currentRotation;
const endRotation = currentRotation + totalSpin;
const duration = 4000 + Math.random() * 1500; // 4–5.5 s
const startTime = performance.now();
glow.classList.add('active');
function easeOut(t) {
// Cubic ease-out with a slight bounce feel
return 1 - Math.pow(1 - t, 3.5);
}
function animate(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeOut(t);
currentRotation = startRotation + (endRotation - startRotation) * eased;
drawWheel(currentRotation);
if (t < 1) {
animFrame = requestAnimationFrame(animate);
} else {
// Done
currentRotation = endRotation;
isSpinning = false;
spinBtn.disabled = false;
showResult(winner);
}
}
animFrame = requestAnimationFrame(animate);
}
// ─── Show Result ──────────────────────────────────────────────────────────────
function showResult(winner) {
const resultCard = document.getElementById('resultCard');
const glow = document.getElementById('wheelGlow');
const is3 = winner.number === 3;
const pct = (winner.prob * 100).toFixed(1);
resultCard.innerHTML = `
<span class="result-label">The wheel has spoken</span>
<span class="result-value">${winner.number}</span>
<span class="result-sub">${is3 ? '★ Lucky 3 · ' : ''}${pct}% probability</span>
`;
resultCard.className = 'result-card highlight';
setTimeout(() => glow.classList.remove('active'), 3000);
}
// ─── Init ─────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
// Draw a placeholder empty disc
const canvas = document.getElementById('wheelCanvas');
const container = document.getElementById('wheelContainer');
const size = container.clientWidth || 380;
canvas.width = size * devicePixelRatio;
canvas.height = size * devicePixelRatio;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(devicePixelRatio, devicePixelRatio);
const cx = size / 2, cy = size / 2, R = size / 2 - 8;
// Gold ring
ctx.beginPath();
ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
const gr = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
gr.addColorStop(0, '#8a6018'); gr.addColorStop(0.5, '#c9a84c'); gr.addColorStop(1, '#f0d080');
ctx.fillStyle = gr; ctx.fill();
// Dark disc
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fillStyle = '#13131a'; ctx.fill();
// Center text
ctx.font = `italic ${Math.floor(R * 0.14)}px 'Cormorant Garamond', Georgia, serif`;
ctx.fillStyle = 'rgba(201,168,76,0.4)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Set bounds & build', cx, cy);
// Build default wheel
buildWheel();
});
window.addEventListener('resize', () => {
if (wheelData) drawWheel(currentRotation);
});
</script>
</body>
</html>
[Python] Bank Account Simulator
[Python] Bank Account Simulator
main.py
import json
import datetime
# load account if exists
def load_account():
try:
with open("account.json", "r") as f:
account = json.load(f)
return account
except FileNotFoundError:
return -1
# create new account
def create_account():
account = {
"balance": 0.0,
"transactions": []
}
with open("account.json", "w") as f:
json.dump(account, f)
return account
# save account state
def save_account(account):
with open("account.json", "w") as f:
json.dump(account, f)
# deposit function
def deposit(account, amount):
account["balance"] += amount
account["transactions"].append({
"transaction_id": transaction_id_generator(len(account["transactions"])+1),
"type": "deposit",
"amount": amount,
"date": str(datetime.datetime.now()),
"balance_after": account["balance"]
})
save_account(account)
# Generate a unique 10-digit transaction ID
def transaction_id_generator(number):
return str(number).zfill(10)
# withdraw function
def withdraw(account, amount):
account["balance"] -= amount
account["transactions"].append({
"transaction_id": transaction_id_generator(len(account["transactions"])+1),
"type": "withdraw",
"amount": amount,
"date": str(datetime.datetime.now()),
"balance_after": account["balance"]
})
save_account(account)
# check balance function
def check_balance(account):
return account["balance"]
# view transactions function
def view_transactions(account):
if len(account["transactions"]) == 0:
return -1
else:
return account["transactions"]
# search transaction by ID function
def search_transaction_by_id(account, transaction_id):
for i in account["transactions"]:
if i["transaction_id"] == transaction_id:
return i
return -1
# search transaction by amount function
def search_transaction_by_amount(account, amount):
results = []
for i in account["transactions"]:
if i["amount"] == amount:
results.append(i)
if len(results) == 0:
return -1
else:
return results
# search transaction by date function
def search_transaction_by_date(account, date):
results = []
for i in account["transactions"]:
if date in i["date"]:
results.append(i)
if len(results) == 0:
return -1
else:
return results
# main program
def main():
# load or create account
account = load_account()
if account == -1:
account = create_account()
program_state = True
while program_state:
# main menu input
while True:
try:
print("\nInput option:\n1. Deposit\n2. Withdraw\n3. Check Balance\n4. View Transactions\n5. Search Transaction\n6. Exit")
choice = int(input("Enter choice (1-6): "))
if choice >= 1 and choice <= 6:
break
else:
print("Invalid choice. Please enter a number between 1 and 6.")
except ValueError:
print("Invalid input. Please enter a number between 1 and 6.")
# carry out the chosen operations with:
# 1. input with exception handling
# 2. perform operation
# 3. display result with formatting
# options:
# 1. deposit
if choice == 1:
while True:
try:
amount = float(input("Enter amount to deposit: "))
if amount > 0:
deposit(account, amount)
print(f"Deposited: ${amount:.2f}")
break
else:
print("Amount must be positive.")
except ValueError:
print("Invalid input. Please enter a valid amount.")
# 2. withdraw
elif choice == 2:
if account["balance"] <= 0:
print("Insufficient balance to withdraw.")
else:
while True:
try:
amount = float(input("Enter amount to withdraw: "))
if amount > 0 and amount <= account["balance"]:
withdraw(account, amount)
print(f"Withdrew: ${amount:.2f}")
break
else:
print("Invalid amount. Please enter a positive amount not exceeding your balance.")
except ValueError:
print("Invalid input. Please enter a valid amount.")
# 3. check balance
elif choice == 3:
balance = check_balance(account)
print(f"Current Balance: ${balance:.2f}")
# 4. view transactions
elif choice == 4:
transactions = view_transactions(account)
if transactions == -1:
print("Record is empty.")
else:
print("Transaction History:")
for i in transactions:
print(f"ID: {i['transaction_id']} | Type: {i['type']} | Amount: ${i['amount']:.2f} | Date: {i['date']} | Balance After: ${i['balance_after']:.2f}")
elif choice == 5:
# search transaction submenu
while True:
try:
print("Search options:\n1. By Transaction ID\n2. By Amount\n3. By Date\n4. Back to Main Menu")
search_choice = int(input("Enter choice (1-4): "))
if search_choice >= 1 and search_choice <= 4:
break
else:
print("Invalid choice. Please enter a number between 1 and 4.")
except ValueError:
print("Invalid input. Please enter a number between 1 and 4.")
# 1. search by transaction ID
if search_choice == 1:
# validate id input
while True:
try:
search_id = input("Enter Transaction ID to search: ")
if len(search_id) == 10 and search_id.isdigit():
break
else:
print("Invalid Transaction ID. It must be a 10-digit number.")
except ValueError:
print("Invalid Transaction ID. It must be a 10-digit number.")
# perform search
result = search_transaction_by_id(account, search_id)
if result == -1:
print("Transaction not found.")
else:
print("Transaction Found:")
print(f"ID: {result['transaction_id']} | Type: {result['type']} | Amount: ${result['amount']:.2f} | Date: {result['date']} | Balance After: ${result['balance_after']:.2f}")
# 2. search by amount
elif search_choice == 2:
# validate amount input
while True:
try:
search_amount = float(input("Enter Amount to search: "))
if search_amount > 0:
break
else:
print("Amount must be positive.")
except ValueError:
print("Invalid input. Please enter a valid amount.")
# perform search
results = search_transaction_by_amount(account, search_amount)
if results == -1:
print("No transactions found with specified amount.")
else:
print("Transactions Found:")
for i in results:
print(f"ID: {i['transaction_id']} | Type: {i['type']} | Amount: ${i['amount']:.2f} | Date: {i['date']} | Balance After: ${i['balance_after']:.2f}")
# 3. search by date
elif search_choice == 3:
# validate date input
while True:
try:
search_year = input("Enter Year (YYYY) to search: ")
search_month = input("Enter Month (MM) to search: ")
search_day = input("Enter Day (DD) to search: ")
if (len(search_year) == 4 and search_year.isdigit() and
len(search_month) == 2 and search_month.isdigit() and
len(search_day) == 2 and search_day.isdigit()):
search_date = f"{search_year}-{search_month}-{search_day}"
break
else:
print("Invalid date format. Please enter valid year, month, and day.")
except ValueError:
print("Invalid date format. Please enter valid year, month, and day.")
# perform search
results = search_transaction_by_date(account, search_date)
if results == -1:
print("No transactions found on the specified date.")
else:
print("Transactions Found:")
for i in results:
print(f"ID: {i['transaction_id']} | Type: {i['type']} | Amount: ${i['amount']:.2f} | Date: {i['date']} | Balance After: ${i['balance_after']:.2f}")
# 4. back to main menu
elif search_choice == 4:
main()
# 6. exit
elif choice == 6:
program_state = False
# run main program
main()
exit()
[Python] Bank Account Simulator
account.json
{"balance": 1038.0, "transactions": [{"transaction_id": "0000000001", "type": "deposit", "amount": 50.0, "date": "2025-12-07 22:51:28.043060", "balance_after": 50.0}, {"transaction_id": "0000000002", "type": "deposit", "amount": 50.0, "date": "2025-12-07 22:51:32.691213", "balance_after": 100.0}, {"transaction_id": "0000000003", "type": "withdraw", "amount": 99.0, "date": "2025-12-07 22:51:34.545436", "balance_after": 1.0}, {"transaction_id": "0000000004", "type": "deposit", "amount": 1230.0, "date": "2025-12-07 22:51:39.531519", "balance_after": 1231.0}, {"transaction_id": "0000000005", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:18.858317", "balance_after": 1232.0}, {"transaction_id": "0000000006", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:20.003650", "balance_after": 1233.0}, {"transaction_id": "0000000007", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:20.664279", "balance_after": 1234.0}, {"transaction_id": "0000000008", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:21.148810", "balance_after": 1235.0}, {"transaction_id": "0000000009", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:21.730856", "balance_after": 1236.0}, {"transaction_id": "0000000010", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:54:54.403182", "balance_after": 1237.0}, {"transaction_id": "0000000011", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:55:00.269094", "balance_after": 1238.0}, {"transaction_id": "0000000012", "type": "deposit", "amount": 1.0, "date": "2025-12-07 22:55:02.152792", "balance_after": 1239.0}, {"transaction_id": "0000000013", "type": "withdraw", "amount": 2.0, "date": "2025-12-07 22:55:40.729659", "balance_after": 1237.0}, {"transaction_id": "0000000014", "type": "withdraw", "amount": 99.0, "date": "2025-12-07 22:55:42.905709", "balance_after": 1138.0}, {"transaction_id": "0000000015", "type": "withdraw", "amount": 200.0, "date": "2025-12-07 22:55:48.474076", "balance_after": 938.0}, {"transaction_id": "0000000016", "type": "deposit", "amount": 100.0, "date": "2025-12-08 00:05:34.048016", "balance_after": 1038.0}]}
[Python] Connect 4
[Python] Connect 4
main.py
board=[[' ']*7,
[' ']*7,
[' ']*7,
[' ']*7,
[' ']*7,
[' ']*7]
fin=False
valid=True
r=0
def display():
global board
row=' '
for i in range(6):
row='ㅣ'+board[i][0]
for j in range(6):
row=row+'ㅣ'+board[i][j+1]
row=row+'ㅣ'
print(row)
def check_horizontal():
global board
global fin
for i in range(6):
for j in range(4):
if board[i][j]!=' ':
if board[i][j]==board[i][j+1] and board[i][j+1]==board[i][j+2] and board[i][j+2]==board[i][j+3]:
fin=True
def check_vertical():
global board
global fin
for j in range(7):
for i in range(3):
if board[i][j]!=' ':
if board[i][j]==board[i+1][j] and board[i+1][j]==board[i+2][j] and board[i+2][j]==board[i+3][j]:
fin=True
def check_diagnal_right():
global board
global fin
for i in range(3):
for j in range(4):
if board[i][j]!=' ':
if board[i][j]==board[i+1][j+1] and board[i+1][j+1]==board[i+2][j+2] and board[i+2][j+2]==board[i+3][j+3]:
fin=True
def check_diagnal_left():
global board
global fin
for i in range(3):
for j in range(4):
if board[5-i][j]!=' ':
if board[5-i][j]==board[4-i][j+1] and board[4-i][j+1]==board[3-i][j+2] and board[3-i][j+2]==board[2-i][j+3]:
fin=True
def check_win():
global board
global fin
check_horizontal()
check_vertical()
check_diagnal_right()
check_diagnal_left()
def enter(x,lim):
while True:
try:
x=int(input('type column(1~7):'))
if x>=0 and x<=lim:
x-=1
break
else:
print('out of range')
except ValueError:
print('wrong format')
return x
def put(x,n):
global board
global valid
found=False
i=0
while not(found) and i<=5:
if board[5-i][x]==' ':
found=True
else:
i+=1
if found:
if n==1:
board[5-i][x]='R'
else:
board[5-i][x]='Y'
valid=True
else:
print('column already full')
while not(fin):
display()
check_win()
if fin:
print('player 2 win')
break
valid=False
turn=1
print('player 1 turn (Red)')
while not(valid):
put(enter(r,7),1)
display()
check_win()
if fin:
print('player 1 win')
break
valid=False
turn=2
print('player 2 turn (Yellow)')
while not(valid):
put(enter(r,7),2)
[HTML] Simulation RPG Combat Sample
[HTML] Simulation RPG Combat Sample
Battle Sample
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:sans-serif;background:#1a1a2e;color:#eee;user-select:none;min-height:100vh}
#app{display:flex;flex-direction:column;align-items:center;padding:12px;gap:10px}
h1{font-size:18px;font-weight:500;color:#c9a96e;letter-spacing:2px}
#top{display:flex;gap:16px;align-items:flex-start;width:100%;max-width:720px}
#map-wrap{position:relative}
canvas{display:block;border:2px solid #444;border-radius:4px}
#sidebar{flex:1;min-width:180px;display:flex;flex-direction:column;gap:8px}
.panel{background:#16213e;border:1px solid #2a3a5e;border-radius:6px;padding:10px;font-size:13px}
.panel h3{font-size:12px;color:#c9a96e;margin-bottom:6px;text-transform:uppercase;letter-spacing:1px}
#unit-name{font-size:15px;font-weight:500;margin-bottom:4px}
.bar-row{display:flex;align-items:center;gap:6px;margin:3px 0}
.bar-label{width:28px;font-size:11px;color:#aaa}
.bar-bg{flex:1;height:8px;background:#2a2a3e;border-radius:4px;overflow:hidden}
.bar-fill{height:100%;border-radius:4px;transition:width .3s}
.hp-bar{background:#4caf50}
.mp-bar{background:#5599ff}
#log{height:120px;overflow-y:auto;font-size:12px;line-height:1.6;color:#bbb}
#log div{padding:1px 0;border-bottom:1px solid #1e2a40}
#bottom{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
button{padding:6px 14px;background:#1e3a5f;border:1px solid #3a5a8f;color:#c9d8f0;border-radius:4px;cursor:pointer;font-size:13px;transition:background .15s}
button:hover{background:#2a4a7f}
button:disabled{opacity:.4;cursor:default}
button.danger{background:#5f1e1e;border-color:#8f3a3a;color:#f0c9c9}
button.danger:hover{background:#7f2a2a}
#phase{font-size:13px;color:#c9a96e;text-align:center}
#weapon-info{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.wi{font-size:11px;padding:3px 5px;background:#0d1626;border-radius:3px;border-left:3px solid #3a5a8f}
.wi b{color:#c9a96e}
</style>
</head>
<body>
<div id="app">
<h1>⚔ SRPG Simulator</h1>
<div id="top">
<div id="map-wrap"><canvas id="c" width="400" height="400"></canvas></div>
<div id="sidebar">
<div class="panel" id="info-panel">
<h3>Selected unit</h3>
<div id="unit-name" style="color:#aaa">Select Unit</div>
<div id="unit-stats"></div>
</div>
<div class="panel">
<h3>Weapon Types</h3>
<div id="weapon-info">
<div class="wi"><b>🗡 Sword</b><br>Atk 15 Acc 90%<br>Range 1 Balanced</div>
<div class="wi"><b>🏹 Bow</b><br>Atk 10 Acc 80%<br>Range 2 Distance</div>
<div class="wi"><b>🔱 Spear</b><br>Atk 13 Acc 85%<br>Range 1 Counter+</div>
<div class="wi"><b>🪓 Axe</b><br>Atk 20 Acc 65%<br>Range 1 Critical+</div>
</div>
</div>
<div class="panel">
<h3>Combat log</h3>
<div id="log"></div>
</div>
</div>
</div>
<div id="phase">Turn 1 — Player turn</div>
<div id="bottom">
<button id="btn-end" onclick="endPlayerTurn()">Turn End</button>
<button id="btn-wait" onclick="waitUnit()" disabled>Wait</button>
<button id="btn-restart" class="danger" onclick="initGame()">Restart</button>
</div>
</div>
<script>
const COLS=10,ROWS=10,TS=40;
const cv=document.getElementById('c');
cv.width=COLS*TS;cv.height=ROWS*TS;
const ctx=cv.getContext('2d');
const WEAPONS={
sword:{name:'Sword',icon:'🗡',atk:15,hit:90,range:1,color:'#4fc3f7'},
bow: {name:'Bow',icon:'🏹',atk:10,hit:80,range:2,color:'#a5d6a7'},
spear:{name:'Spear',icon:'🔱',atk:13,hit:85,range:1,color:'#ce93d8',ctrAtk:5},
axe: {name:'Axe',icon:'🪓',atk:20,hit:65,range:1,color:'#ffab91'},
};
const TERRAIN={
plain:{name:'Yard',color:'#2d4a2d',def:0,move:1},
forest:{name:'Bush',color:'#1a3a1a',def:2,move:2},
hill:{name:'Hall',color:'#4a3a2a',def:1,move:2},
water:{name:'Water',color:'#1a2a4a',def:0,move:999},
};
let MAP=[],units=[],sel=null,phase='player',turn=1,moveHighlight=[],attackHighlight=[];
function mkMap(){
MAP=[];
const layout=[
'pppppppppf',
'ppfppphhpp',
'pffpppphpp',
'ppppwwpppp',
'ppphwwphpp',
'pppphhhppp',
'ppfpppppfp',
'pppppppppf',
'pfpphhpppf',
'pppppppppp',
];
const keys={p:'plain',f:'forest',h:'hill',w:'water'};
for(let r=0;r<ROWS;r++){MAP[r]=[];for(let c=0;c<COLS;c++)MAP[r][c]=keys[layout[r][c]]||'plain';}
}
function mkUnits(){
units=[
{id:0,name:'Sword man',team:'player',weapon:'sword',hp:30,maxHp:30,atk:0,def:3,spd:5,x:0,y:8,moved:false,acted:false,color:'#4fc3f7'},
{id:1,name:'Spear man',team:'player',weapon:'spear',hp:28,maxHp:28,atk:0,def:2,spd:4,x:1,y:9,moved:false,acted:false,color:'#ce93d8'},
{id:2,name:'Archer',team:'player',weapon:'bow',hp:22,maxHp:22,atk:0,def:1,spd:6,x:0,y:7,moved:false,acted:false,color:'#a5d6a7'},
{id:3,name:'Axe man',team:'player',weapon:'axe',hp:35,maxHp:35,atk:0,def:4,spd:3,x:2,y:9,moved:false,acted:false,color:'#ffab91'},
{id:4,name:'Sword goblin',team:'enemy',weapon:'sword',hp:20,maxHp:20,atk:0,def:1,spd:4,x:9,y:1,moved:false,acted:false,color:'#ef5350'},
{id:5,name:'Axe goblin',team:'enemy',weapon:'axe',hp:22,maxHp:22,atk:0,def:1,spd:3,x:8,y:0,moved:false,acted:false,color:'#ef5350'},
{id:6,name:'Spear goblin',team:'enemy',weapon:'spear',hp:18,maxHp:18,atk:0,def:2,spd:5,x:9,y:2,moved:false,acted:false,color:'#ff7043'},
{id:7,name:'Bow goblin',team:'enemy',weapon:'bow',hp:16,maxHp:16,atk:0,def:0,spd:6,x:7,y:0,moved:false,acted:false,color:'#ef5350'},
];
}
function initGame(){
mkMap();mkUnits();sel=null;phase='player';turn=1;moveHighlight=[];attackHighlight=[];
document.getElementById('log').innerHTML='';
setPhaseText();
render();
document.getElementById('btn-end').disabled=false;
}
function getUnit(x,y){return units.find(u=>u.x===x&&u.y===y&&u.hp>0);}
function inBounds(x,y){return x>=0&&y>=0&&x<COLS&&y<ROWS;}
function terrainMoveCost(x,y){return TERRAIN[MAP[y][x]].move;}
function getMoveCells(unit){
const moveRange=3;
const visited={};
const q=[{x:unit.x,y:unit.y,cost:0}];
visited[`${unit.x},${unit.y}`]=0;
const cells=[];
while(q.length){
const cur=q.shift();
cells.push({x:cur.x,y:cur.y});
const dirs=[[1,0],[-1,0],[0,1],[0,-1]];
for(const [dx,dy] of dirs){
const nx=cur.x+dx,ny=cur.y+dy;
const key=`${nx},${ny}`;
if(!inBounds(nx,ny))continue;
const cost=cur.cost+terrainMoveCost(nx,ny);
if(cost>moveRange)continue;
const occ=getUnit(nx,ny);
if(occ&&occ.team!==unit.team)continue;
if(visited[key]===undefined||visited[key]>cost){visited[key]=cost;q.push({x:nx,y:ny,cost});}
}
}
return cells.filter(c=>!(c.x===unit.x&&c.y===unit.y));
}
function getAttackCells(unit,fromX,fromY){
const wep=WEAPONS[unit.weapon];
const cells=[];
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const dist=Math.abs(c-fromX)+Math.abs(r-fromY);
if(dist>=1&&dist<=wep.range)cells.push({x:c,y:r});
}
return cells;
}
function dist(a,b){return Math.abs(a.x-b.x)+Math.abs(a.y-b.y);}
function calcDmg(attacker,defender){
const wep=WEAPONS[attacker.weapon];
const hit=Math.random()*100<wep.hit;
if(!hit)return{dmg:0,hit:false};
const dmg=Math.max(1,wep.atk+attacker.atk-defender.def+TERRAIN[MAP[defender.y][defender.x]].def*-1);
return{dmg,hit:true};
}
function doAttack(attacker,defender){
const awep=WEAPONS[attacker.weapon];
const dwep=WEAPONS[defender.weapon];
const {dmg,hit}=calcDmg(attacker,defender);
if(hit){defender.hp=Math.max(0,defender.hp-dmg);addLog(`${attacker.name}→${defender.name} ${dmg} Damage!`);}
else addLog(`${attacker.name}→${defender.name} Miss!`);
if(defender.hp>0){
const defRange=dwep.range;
const d=dist(attacker,defender);
if(d<=defRange){
const bonus=(defender.weapon==='spear'?dwep.ctrAtk||0:0);
const r2=calcDmg({...defender,atk:defender.atk+bonus},attacker);
if(r2.hit){attacker.hp=Math.max(0,attacker.hp-r2.dmg);addLog(`${defender.name}'s Counter! ${r2.dmg} Damage!`);}
else addLog(`${defender.name} Counter - Miss!`);
}
}
if(defender.hp<=0)addLog(`☠ ${defender.name} was defeat!`);
if(attacker.hp<=0)addLog(`☠ ${attacker.name} was defeat!`);
checkWin();
}
function addLog(msg){
const el=document.getElementById('log');
const d=document.createElement('div');d.textContent=msg;
el.appendChild(d);el.scrollTop=el.scrollHeight;
}
function checkWin(){
const pAlive=units.filter(u=>u.team==='player'&&u.hp>0);
const eAlive=units.filter(u=>u.team==='enemy'&&u.hp>0);
if(pAlive.length===0){setTimeout(()=>{addLog('💀 Enemies are victorious!');},100);}
else if(eAlive.length===0){setTimeout(()=>{addLog('🏆 Player is victorious!');},100);}
}
function setPhaseText(){
document.getElementById('phase').textContent=`Turn ${turn} — ${phase==='player'?'Player turn':'Enemy turn'}`;
}
// Rendering
const COLORS={
moveable:'rgba(80,180,255,0.25)',
attackable:'rgba(255,80,80,0.28)',
selected:'rgba(255,220,80,0.35)',
};
function render(){
ctx.clearRect(0,0,cv.width,cv.height);
// terrain
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
ctx.fillStyle=TERRAIN[MAP[r][c]].color;
ctx.fillRect(c*TS,r*TS,TS,TS);
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=0.5;
ctx.strokeRect(c*TS,r*TS,TS,TS);
}
// highlights
for(const h of moveHighlight){
ctx.fillStyle=COLORS.moveable;ctx.fillRect(h.x*TS,h.y*TS,TS,TS);
}
for(const h of attackHighlight){
ctx.fillStyle=COLORS.attackable;ctx.fillRect(h.x*TS,h.y*TS,TS,TS);
}
if(sel){
ctx.fillStyle=COLORS.selected;ctx.fillRect(sel.x*TS,sel.y*TS,TS,TS);
}
// units
for(const u of units){
if(u.hp<=0)continue;
const px=u.x*TS,py=u.y*TS;
ctx.fillStyle=u.team==='player'?(u.moved?'#555':u.color):(u.color);
ctx.beginPath();ctx.roundRect(px+5,py+5,TS-10,TS-10,4);ctx.fill();
if(u===sel){ctx.strokeStyle='#ffe066';ctx.lineWidth=2;ctx.stroke();}
const wep=WEAPONS[u.weapon];
ctx.font='16px serif';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(wep.icon,px+TS/2,py+TS/2);
// hp bar
const barW=TS-8,barH=4,bx=px+4,by=py+TS-8;
ctx.fillStyle='#222';ctx.fillRect(bx,by,barW,barH);
ctx.fillStyle=u.team==='player'?'#4caf50':'#f44336';
ctx.fillRect(bx,by,barW*(u.hp/u.maxHp),barH);
// team indicator
ctx.fillStyle=u.team==='player'?'#4fc3f7':'#ef5350';
ctx.fillRect(px+4,py+4,8,3);
}
}
// State machine
let state='idle'; // idle | selected | moved
let selMoves=[];
let selAttacks=[];
let movedPos=null;
cv.addEventListener('click',e=>{
const rect=cv.getBoundingClientRect();
const scaleX=cv.width/rect.width,scaleY=cv.height/rect.height;
const mx=Math.floor((e.clientX-rect.left)*scaleX/TS);
const my=Math.floor((e.clientY-rect.top)*scaleY/TS);
if(!inBounds(mx,my))return;
handleClick(mx,my);
});
function handleClick(cx,cy){
if(phase!=='player')return;
const clicked=getUnit(cx,cy);
if(state==='idle'){
if(clicked&&clicked.team==='player'&&!clicked.acted){
sel=clicked;
selMoves=getMoveCells(clicked);
selAttacks=getAttackCells(clicked,clicked.x,clicked.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
state='selected';
showUnitInfo(clicked);
document.getElementById('btn-wait').disabled=false;
}else{
clearSel();
}
} else if(state==='selected'){
// Click same unit = deselect
if(clicked===sel){clearSel();return;}
// Click enemy in range = attack
if(clicked&&clicked.team==='enemy'){
const inRange=selAttacks.some(a=>a.x===cx&&a.y===cy);
if(inRange){
doAttack(sel,clicked);
sel.acted=true;sel.moved=true;
clearSel();render();return;
}
}
// Click move cell
if(selMoves.some(m=>m.x===cx&&m.y===cy)&&!getUnit(cx,cy)){
movedPos={x:sel.x,y:sel.y};
sel.x=cx;sel.y=cy;
sel.moved=true;
// Show attack options from new pos
selAttacks=getAttackCells(sel,cx,cy);
const enemies=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
moveHighlight=[];
attackHighlight=enemies;
state='moved';
render();return;
}
// Click different ally
if(clicked&&clicked.team==='player'&&!clicked.acted){
sel=clicked;
selMoves=getMoveCells(clicked);
selAttacks=getAttackCells(clicked,clicked.x,clicked.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
showUnitInfo(clicked);
render();return;
}
clearSel();
} else if(state==='moved'){
// Click enemy to attack
if(clicked&&clicked.team==='enemy'){
const inRange=selAttacks.some(a=>a.x===cx&&a.y===cy);
if(inRange){
doAttack(sel,clicked);
sel.acted=true;
clearSel();render();return;
}
}
// Click empty = undo move
if(!clicked){
if(movedPos){sel.x=movedPos.x;sel.y=movedPos.y;sel.moved=false;}
selMoves=getMoveCells(sel);
selAttacks=getAttackCells(sel,sel.x,sel.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
state='selected';movedPos=null;render();return;
}
}
render();
}
function clearSel(){
sel=null;state='idle';moveHighlight=[];attackHighlight=[];movedPos=null;
document.getElementById('btn-wait').disabled=true;
document.getElementById('unit-name').textContent='Select Unit';
document.getElementById('unit-name').style.color='#aaa';
document.getElementById('unit-stats').innerHTML='';
}
function waitUnit(){
if(!sel)return;
sel.moved=true;sel.acted=true;
clearSel();render();
}
function showUnitInfo(u){
const wep=WEAPONS[u.weapon];
document.getElementById('unit-name').textContent=`${wep.icon} ${u.name}`;
document.getElementById('unit-name').style.color=u.color;
document.getElementById('unit-stats').innerHTML=`
<div class="bar-row"><span class="bar-label">HP</span><div class="bar-bg"><div class="bar-fill hp-bar" style="width:${(u.hp/u.maxHp)*100}%"></div></div><span style="font-size:11px;margin-left:4px">${u.hp}/${u.maxHp}</span></div>
<div style="font-size:11px;color:#aaa;margin-top:4px">
Weapon: ${wep.name} | Atk: ${wep.atk} | Acc: ${wep.hit}%<br>
Range: ${wep.range} | Def: ${u.def} | Spd: ${u.spd}
</div>
<div style="font-size:11px;color:${u.moved?'#f66':'#4fc'};margin-top:3px">${u.acted?'Acted':u.moved?'Moved':'Movable'}</div>
`;
}
function endPlayerTurn(){
clearSel();
phase='enemy';
setPhaseText();
document.getElementById('btn-end').disabled=true;
render();
setTimeout(enemyTurn,600);
}
function enemyTurn(){
const enemies=units.filter(u=>u.team==='enemy'&&u.hp>0);
const players=units.filter(u=>u.team==='player'&&u.hp>0);
let i=0;
function doNext(){
if(i>=enemies.length){
// Reset
units.forEach(u=>{u.moved=false;u.acted=false;});
phase='player';turn++;
setPhaseText();
document.getElementById('btn-end').disabled=false;
render();return;
}
const enemy=enemies[i];i++;
if(enemy.hp<=0){doNext();return;}
// Find nearest player
const pAlive=units.filter(u=>u.team==='player'&&u.hp>0);
if(!pAlive.length){render();return;}
const target=pAlive.reduce((a,b)=>dist(enemy,a)<dist(enemy,b)?a:b);
const wep=WEAPONS[enemy.weapon];
const d=dist(enemy,target);
if(d<=wep.range){
// Attack
doAttack(enemy,target);
} else {
// Move toward target
const moves=getMoveCells(enemy);
// pick cell closest to target
let best=null,bestD=999;
for(const m of moves){
const md=Math.abs(m.x-target.x)+Math.abs(m.y-target.y);
if(md<bestD&&!getUnit(m.x,m.y)){bestD=md;best=m;}
}
if(best){enemy.x=best.x;enemy.y=best.y;}
// Try attack from new pos
const atks=getAttackCells(enemy,enemy.x,enemy.y);
const inRange=atks.find(a=>a.x===target.x&&a.y===target.y);
if(inRange)doAttack(enemy,target);
}
enemy.moved=true;enemy.acted=true;
render();
setTimeout(doNext,500);
}
doNext();
}
initGame();
</script>
</body>
</html>
[Python] Chess
[Python] Chess
Version 1
Requires:
- Pygame
- Stockfish 18
Which can be downloaded by running:
pip install pygame
brew install stockfish
Code:
"""
Chess Game — Lichess-style UI (v3 — all fixes applied)
Fixes:
1. Move list: white & black on SAME ROW (1. e4 e5)
2. Piece rendering: platform-aware font fallback, letter-in-box if no Unicode glyphs
3. Increment added to the player who JUST MOVED (not the opponent)
4. Arrow keys navigate review in both directions
5. History screen: confirm dialog before opening a saved-game review
"""
import pygame, sys, copy, random, time, json, os, platform, subprocess, threading, shutil
pygame.init()
# ─── Layout ──────────────────────────────────────────────────────────────────
BOARD_SIZE = 640
PANEL_WIDTH = 300
WINDOW_W = BOARD_SIZE + PANEL_WIDTH
WINDOW_H = BOARD_SIZE
SQ = BOARD_SIZE // 8
# ─── Colours ─────────────────────────────────────────────────────────────────
C_BG = (22, 21, 18)
C_DARK_SQ = (181, 136, 99)
C_LIGHT_SQ = (240, 217, 181)
C_HIGHLIGHT = (205, 210, 106)
C_SELECTED = (246, 246, 105)
C_PANEL = (28, 27, 24)
C_PANEL2 = (38, 37, 33)
C_PANEL3 = (52, 50, 44)
C_TEXT = (222, 220, 215)
C_TEXT2 = (140, 135, 125)
C_TEXT3 = (88, 85, 78)
C_ACCENT = (128, 196, 127)
C_GOLD = (255, 188, 66)
C_RED = (210, 90, 90)
C_BLUE = (100, 155, 220)
C_BTN = (55, 53, 47)
C_BTN_H = (75, 73, 65)
C_BTN_A = (100, 155, 220)
C_CHECK = (200, 55, 55)
C_BORDER = (65, 62, 55)
C_SEP = (50, 48, 43)
# ─── Fonts ───────────────────────────────────────────────────────────────────
FNT_XL = pygame.font.SysFont("segoeui", 32, bold=True)
FNT_LG = pygame.font.SysFont("segoeui", 22, bold=True)
FNT_MD = pygame.font.SysFont("segoeui", 17)
FNT_MDB = pygame.font.SysFont("segoeui", 17, bold=True)
FNT_SM = pygame.font.SysFont("segoeui", 14)
FNT_SMB = pygame.font.SysFont("segoeui", 14, bold=True)
FNT_XS = pygame.font.SysFont("segoeui", 12)
FNT_MONO = pygame.font.SysFont("consolas", 14)
FNT_MONO_SM = pygame.font.SysFont("consolas", 13)
FNT_CLK = pygame.font.SysFont("consolas", 28, bold=True)
# ─── Piece rendering (FIX #2) ───────────────────────────────────────────────
UNICODE_SYMS = {'K':'♔','Q':'♕','R':'♖','B':'♗','N':'♘','P':'♙',
'k':'♚','q':'♛','r':'♜','b':'♝','n':'♞','p':'♟'}
def _make_piece_font(size):
plat = platform.system()
if plat == "Windows":
cands = ["segoeuisymbol","seguisym","segoeui","arial unicode ms"]
elif plat == "Darwin":
cands = ["apple symbols","arial unicode ms","lucida grande"]
else:
cands = ["dejavusans","symbola","freesans","unifont"]
cands += [None]
for name in cands:
try:
f = pygame.font.SysFont(name, size) if name else pygame.font.Font(None, size)
surf = f.render("♔", True, (255,255,255))
if surf.get_width() > 4:
return f, True
except Exception:
pass
return pygame.font.SysFont("consolas", size, bold=True), False
_PF_CACHE = {}
def _pfont(size):
if size not in _PF_CACHE:
_PF_CACHE[size] = _make_piece_font(size)
return _PF_CACHE[size]
def draw_piece_at(surf, piece, px, py, size=SQ):
is_white = piece.isupper()
fs = int(size * 0.74)
font, use_uni = _pfont(fs)
if not use_uni:
pad = max(4, size//8)
bg = (245,230,200) if is_white else (55,50,45)
fg = (40,35,30) if is_white else (240,230,215)
pygame.draw.rect(surf, bg,
(px+pad, py+pad, size-pad*2, size-pad*2),
border_radius=max(2, size//10))
pygame.draw.rect(surf, (0,0,0),
(px+pad, py+pad, size-pad*2, size-pad*2),
1, border_radius=max(2, size//10))
t = font.render(piece.upper(), True, fg)
surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))
return
sym = UNICODE_SYMS.get(piece, '?')
oc = (230,228,224) if not is_white else (28,26,22)
for ox,oy in ((-1,0),(1,0),(0,-1),(0,1)):
o = font.render(sym, True, oc)
surf.blit(o, (px+size//2-o.get_width()//2+ox, py+size//2-o.get_height()//2+oy))
clr = (255,255,255) if is_white else (22,20,18)
t = font.render(sym, True, clr)
surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))
# ─── Helpers ─────────────────────────────────────────────────────────────────
WHITE = 'w'; BLACK = 'b'
SAVE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chess_history.json")
TIME_CONTROLS = [
{"label":"5 min", "name":"Blitz", "base":300, "inc":0 },
{"label":"10 min", "name":"Blitz", "base":600, "inc":0 },
{"label":"15+10", "name":"Rapid", "base":900, "inc":10},
{"label":"30 min", "name":"Rapid", "base":1800, "inc":0 },
{"label":"60 min", "name":"Classical", "base":3600, "inc":0 },
{"label":"90+30", "name":"Classical", "base":5400, "inc":30},
]
PIECE_VALUES = {'P':100,'N':320,'B':330,'R':500,'Q':900,'K':20000}
PST = {
'P':[ 0, 0, 0, 0, 0, 0, 0, 0,50,50,50,50,50,50,50,50,
10,10,20,30,30,20,10,10, 5, 5,10,25,25,10, 5, 5,
0, 0, 0,20,20, 0, 0, 0, 5,-5,-10,0,0,-10,-5, 5,
5,10,10,-20,-20,10,10,5, 0, 0, 0, 0, 0, 0, 0, 0],
'N':[-50,-40,-30,-30,-30,-30,-40,-50,-40,-20,0,0,0,0,-20,-40,
-30,0,10,15,15,10,0,-30,-30,5,15,20,20,15,5,-30,
-30,0,15,20,20,15,0,-30,-30,5,10,15,15,10,5,-30,
-40,-20,0,5,5,0,-20,-40,-50,-40,-30,-30,-30,-30,-40,-50],
'B':[-20,-10,-10,-10,-10,-10,-10,-20,-10,0,0,0,0,0,0,-10,
-10,0,5,10,10,5,0,-10,-10,5,5,10,10,5,5,-10,
-10,0,10,10,10,10,0,-10,-10,10,10,10,10,10,10,-10,
-10,5,0,0,0,0,5,-10,-20,-10,-10,-10,-10,-10,-10,-20],
'R':[ 0,0,0,0,0,0,0,0, 5,10,10,10,10,10,10,5,
-5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,
-5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,
-5,0,0,0,0,0,0,-5, 0,0,0,5,5,0,0,0],
'Q':[-20,-10,-10,-5,-5,-10,-10,-20,-10,0,0,0,0,0,0,-10,
-10,0,5,5,5,5,0,-10,-5,0,5,5,5,5,0,-5,
0,0,5,5,5,5,0,-5,-10,5,5,5,5,5,0,-10,
-10,0,5,0,0,0,0,-10,-20,-10,-10,-5,-5,-10,-10,-20],
'K':[-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,
-20,-30,-30,-40,-40,-30,-30,-20,-10,-20,-20,-20,-20,-20,-20,-10,
20,20,0,0,0,0,20,20,20,30,10,0,0,10,30,20],
}
def rr(surf,color,rect,r=8,brd=0,bc=None):
pygame.draw.rect(surf,color,rect,border_radius=r)
if brd and bc: pygame.draw.rect(surf,bc,rect,brd,border_radius=r)
def tc(surf,txt,fnt,clr,cx,cy):
t=fnt.render(str(txt),True,clr); surf.blit(t,(cx-t.get_width()//2,cy-t.get_height()//2))
def tl(surf,txt,fnt,clr,x,y):
t=fnt.render(str(txt),True,clr); surf.blit(t,(x,y)); return t.get_width()
def fmt(secs):
secs=max(0,int(secs)); m,s=divmod(secs,60); return f"{m}:{s:02d}"
# ═══════════════════════════════════════════════════════════════════════════════
# Chess Engine
# ═══════════════════════════════════════════════════════════════════════════════
class ChessBoard:
INIT = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
def __init__(self):
self.board=[[None]*8 for _ in range(8)]; self.turn=WHITE
self.castling={'K':True,'Q':True,'k':True,'q':True}
self.ep_square=None; self.halfmove=0; self.fullmove=1
self.history=[]; self.result=None; self.result_reason=''
self._fen(self.INIT)
def _fen(self,fen):
p=fen.split()
for r,row in enumerate(p[0].split('/')):
c=0
for ch in row:
if ch.isdigit(): c+=int(ch)
else: self.board[r][c]=ch; c+=1
self.turn=WHITE if p[1]=='w' else BLACK
cs=p[2]; self.castling={'K':'K' in cs,'Q':'Q' in cs,'k':'k' in cs,'q':'q' in cs}
if p[3]!='-': self.ep_square=(8-int(p[3][1]),ord(p[3][0])-ord('a'))
self.halfmove=int(p[4]); self.fullmove=int(p[5])
def col(self,p): return WHITE if p and p.isupper() else (BLACK if p else None)
def pt(self,p): return p.upper() if p else None
def pseudo(self,row,col):
p=self.board[row][col]
if not p: return []
c=self.col(p); t=self.pt(p); mv=[]
def ok(r,c2): return 0<=r<8 and 0<=c2<8
def slide(dirs):
for dr,dc in dirs:
r,c2=row+dr,col+dc
while ok(r,c2):
tgt=self.board[r][c2]
if tgt is None: mv.append((r,c2))
elif self.col(tgt)!=c: mv.append((r,c2)); break
else: break
r+=dr; c2+=dc
def jump(ds):
for dr,dc in ds:
r,c2=row+dr,col+dc
if ok(r,c2) and self.col(self.board[r][c2])!=c: mv.append((r,c2))
if t=='R': slide([(1,0),(-1,0),(0,1),(0,-1)])
elif t=='B': slide([(1,1),(1,-1),(-1,1),(-1,-1)])
elif t=='Q': slide([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])
elif t=='N': jump([(2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)])
elif t=='K':
jump([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])
if c==WHITE and row==7 and col==4:
if self.castling['K'] and not self.board[7][5] and not self.board[7][6]: mv.append((7,6,'cK'))
if self.castling['Q'] and not self.board[7][3] and not self.board[7][2] and not self.board[7][1]: mv.append((7,2,'cQ'))
elif c==BLACK and row==0 and col==4:
if self.castling['k'] and not self.board[0][5] and not self.board[0][6]: mv.append((0,6,'ck'))
if self.castling['q'] and not self.board[0][3] and not self.board[0][2] and not self.board[0][1]: mv.append((0,2,'cq'))
elif t=='P':
d=-1 if c==WHITE else 1; sr=6 if c==WHITE else 1; r2=row+d
if ok(r2,col) and not self.board[r2][col]:
mv.append((r2,col))
if row==sr and not self.board[row+2*d][col]: mv.append((row+2*d,col))
for dc in(-1,1):
r2,c2=row+d,col+dc
if ok(r2,c2):
tgt=self.board[r2][c2]
if tgt and self.col(tgt)!=c: mv.append((r2,c2))
elif self.ep_square==(r2,c2): mv.append((r2,c2,'ep'))
return mv
def _chk(self,color,board):
king='K' if color==WHITE else 'k'
kp=next(((r,c) for r in range(8) for c in range(8) if board[r][c]==king),None)
if not kp: return True
orig=self.board; self.board=board
att=any((m[0],m[1])==kp for r in range(8) for c in range(8)
if self.col(board[r][c]) is not None and self.col(board[r][c])!=color
for m in self.pseudo(r,c))
self.board=orig; return att
def _apply(self,board,castling,ep,fr,fc,tr,tc,sp=None,promo='Q'):
b=copy.deepcopy(board); p=b[fr][fc]; c=self.col(p); t2=p.upper() if p else None
nc=dict(castling); ne=None
if sp in('cK','cQ','ck','cq'):
b[tr][tc]=b[fr][fc]; b[fr][fc]=None
rm={'cK':(7,7,7,5),'cQ':(7,0,7,3),'ck':(0,7,0,5),'cq':(0,0,0,3)}
rr2,rc,rt,rtc2=rm[sp]; b[rt][rtc2]=b[rr2][rc]; b[rr2][rc]=None
elif sp=='ep':
b[tr][tc]=b[fr][fc]; b[fr][fc]=None; b[fr][tc]=None
else:
b[tr][tc]=b[fr][fc]; b[fr][fc]=None
if t2=='P' and (tr==0 or tr==7): b[tr][tc]=promo if c==WHITE else promo.lower()
if p=='K': nc['K']=nc['Q']=False
if p=='k': nc['k']=nc['q']=False
for sq,key in[((7,0),'Q'),((7,7),'K'),((0,0),'q'),((0,7),'k')]:
if (fr,fc)==sq or (tr,tc)==sq: nc[key]=False
if t2=='P' and abs(tr-fr)==2: ne=((fr+tr)//2,fc)
return b,nc,ne
def legal(self,row,col):
p=self.board[row][col]
if not p: return []
c=self.col(p); res=[]
for m in self.pseudo(row,col):
tr,tc2=m[0],m[1]; sp=m[2] if len(m)>2 else None
if sp in('cK','cQ','ck','cq'):
if self._chk(c,self.board): continue
mc=5 if tc2==6 else 3
b2,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,row,mc)
if self._chk(c,b2): continue
nb,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,tr,tc2,sp)
if not self._chk(c,nb): res.append(m)
return res
def all_legal(self,color=None):
if color is None: color=self.turn
return [(r,c)+m for r in range(8) for c in range(8)
if self.col(self.board[r][c])==color for m in self.legal(r,c)]
def is_check(self): return self._chk(self.turn,self.board)
def is_checkmate(self): return not self.all_legal() and self.is_check()
def is_stalemate(self): return not self.all_legal() and not self.is_check()
def is_insuf(self):
ps=[(p.upper(),r,c) for r in range(8) for c in range(8)
if (p:=self.board[r][c]) and p.upper()!='K']
return len(ps)==0 or (len(ps)==1 and ps[0][0] in('N','B'))
def _san(self,fr,fc,tr,tc,sp,promo,board):
p=board[fr][fc];
if not p: return ''
pt=p.upper(); CL='abcdefgh'; RL='87654321'; dest=CL[tc]+RL[tr]
if sp in('cK','ck'): return 'O-O'
if sp in('cQ','cq'): return 'O-O-O'
cap='x' if board[tr][tc] or sp=='ep' else ''
if pt=='P':
s=(CL[fc]+cap+dest) if cap else dest
if tr==0 or tr==7: s+='='+promo
return s
return pt+cap+dest
def fen(self):
rows=[]
for r in range(8):
e=0; s2=''
for c in range(8):
p=self.board[r][c]
if not p: e+=1
else:
if e: s2+=str(e); e=0
s2+=p
if e: s2+=str(e)
rows.append(s2)
f='/'.join(rows)+' '+('w' if self.turn==WHITE else 'b')+' '
cs=''.join(k for k in('K','Q','k','q') if self.castling[k])
f+=(cs or '-')+' '
if self.ep_square: f+='abcdefgh'[self.ep_square[1]]+str(8-self.ep_square[0])
else: f+='-'
f+=f' {self.halfmove} {self.fullmove}'
return f
def make_move(self,fr,fc,tr,tc,sp=None,promo='Q'):
p=self.board[fr][fc]; cap=self.board[tr][tc]
if sp=='ep': cap=self.board[fr][tc]
old_b=copy.deepcopy(self.board); san=self._san(fr,fc,tr,tc,sp,promo,old_b)
nb,nc,ne=self._apply(self.board,self.castling,self.ep_square,fr,fc,tr,tc,sp,promo)
info={'from':(fr,fc),'to':(tr,tc),'piece':p,'captured':cap,'special':sp,'promo':promo,'san':san}
self.history.append({'move':info,'board':old_b,'castling':dict(self.castling),
'ep':self.ep_square,'hm':self.halfmove,'turn':self.turn})
self.board=nb; self.castling=nc; self.ep_square=ne
pt=p.upper() if p else None
self.halfmove=0 if (pt=='P' or cap) else self.halfmove+1
if self.turn==BLACK: self.fullmove+=1
self.turn=BLACK if self.turn==WHITE else WHITE
if self.is_checkmate(): self.result=WHITE if self.turn==BLACK else BLACK; self.result_reason='checkmate'
elif self.is_stalemate(): self.result='draw'; self.result_reason='stalemate'
elif self.is_insuf(): self.result='draw'; self.result_reason='insufficient material'
elif self.halfmove>=100: self.result='draw'; self.result_reason='50-move rule'
return info
# ═══════════════════════════════════════════════════════════════════════════════
# Fallback Python AI (used when Stockfish is unavailable)
# ═══════════════════════════════════════════════════════════════════════════════
class AI:
D={1:1,2:1,3:2,4:2,5:3,6:3,7:4,8:4,9:5,10:6,11:6}
N={1:350,2:250,3:180,4:120,5:70,6:40,7:20,8:8,9:2,10:0,11:0}
def __init__(self,lv=5): self.lv=lv
def eval(self,cb):
s=0
for r in range(8):
for c in range(8):
p=cb.board[r][c]
if not p: continue
pt=p.upper(); v=PIECE_VALUES.get(pt,0); idx=r*8+c if p.isupper() else (7-r)*8+c
s+=(v+PST[pt][idx]) if p.isupper() else -(v+PST[pt][idx])
# mobility removed for speed
return s
def _ord(self,cb,moves):
def sc(m):
fr,fc,tr,tc=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None
cap=cb.board[tr][tc] if sp!='ep' else cb.board[m[0]][tc]
return PIECE_VALUES.get((cap or'').upper(),0)-PIECE_VALUES.get(cb.board[fr][fc].upper(),0)//10
return sorted(moves,key=sc,reverse=True)
def _cl(self,cb):
n=ChessBoard.__new__(ChessBoard)
n.board=copy.deepcopy(cb.board); n.turn=cb.turn; n.castling=dict(cb.castling)
n.ep_square=cb.ep_square; n.halfmove=cb.halfmove; n.fullmove=cb.fullmove
n.history=[]; n.result=cb.result; n.result_reason=''; return n
def _ab(self,cb,d,a,b,mx):
if d==0 or cb.result: return self.eval(cb)
ms=cb.all_legal()
if not ms: return (-99999 if mx else 99999) if cb.is_check() else 0
ms=self._ord(cb,ms)
if mx:
v=-999999
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=max(v,self._ab(c2,d-1,a,b,False)); a=max(a,v)
if b<=a: break
return v
else:
v=999999
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=min(v,self._ab(c2,d-1,a,b,True)); b=min(b,v)
if b<=a: break
return v
def best(self,cb):
depth=self.D.get(self.lv,3); noise=self.N.get(self.lv,0)
ms=cb.all_legal()
if not ms: return None
ms=self._ord(cb,ms); mx=(cb.turn==WHITE); bv=-999999 if mx else 999999; bms=[]
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=self._ab(c2,depth-1,-999999,999999,not mx)+random.randint(-noise,noise)
if(mx and v>bv)or(not mx and v<bv): bv=v; bms=[m]
elif v==bv: bms.append(m)
return random.choice(bms) if bms else random.choice(ms)
# ═══════════════════════════════════════════════════════════════════════════════
# Stockfish AI (async, non-blocking)
# ═══════════════════════════════════════════════════════════════════════════════
class StockfishAI:
# Level → UCI_Elo
LEVEL_ELO = {1:500, 2:800, 3:1200, 4:1600, 5:1800,
6:2000, 7:2200, 8:2300, 9:2400, 10:2500, 11:2700}
# Level → movetime (ms)
LEVEL_TIME = {1:80, 2:100, 3:150, 4:200, 5:300,
6:500, 7:800, 8:1200, 9:1800, 10:2500, 11:4000}
def __init__(self, level=5):
self.level = level
self._proc = None
self._thread = None
self._result = None # (fr,fc,tr,tc,sp,promo) when ready
self._lock = threading.Lock()
self.ready = False
self._init()
# ── process management ────────────────────────────────────────────────────
def _find(self):
# 1) same folder as this script
base = os.path.dirname(os.path.abspath(__file__))
for name in ("stockfish","stockfish.exe",
"stockfish-windows-x86-64-avx2.exe",
"stockfish-windows-x86-64-modern.exe"):
p = os.path.join(base, name)
if os.path.isfile(p): return p
# 2) system PATH (covers brew install on macOS)
return shutil.which("stockfish")
def _init(self):
path = self._find()
if not path:
print("[Stockfish] Not found – using fallback Python AI.")
return
try:
self._proc = subprocess.Popen(
[path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
self._send("uci"); self._wait("uciok")
self._apply_level(self.level)
self._send("isready"); self._wait("readyok")
self.ready = True
print(f"[Stockfish] Ready level={self.level} "
f"elo={self.LEVEL_ELO[self.level]}")
except Exception as e:
print(f"[Stockfish] Failed to start: {e}")
self.ready = False
def _send(self, cmd):
if self._proc:
self._proc.stdin.write(cmd + "\n")
self._proc.stdin.flush()
def _wait(self, token, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
line = self._proc.stdout.readline()
if token in line: return line
return ""
def _apply_level(self, level):
elo = self.LEVEL_ELO.get(level, 1750)
self._send("setoption name UCI_LimitStrength value true")
self._send(f"setoption name UCI_Elo value {elo}")
def set_level(self, level):
self.level = level
if self.ready:
self._apply_level(level)
self._send("isready"); self._wait("readyok")
def close(self):
if self._proc:
try: self._send("quit"); self._proc.terminate()
except: pass
self._proc = None
self.ready = False
# ── async move request ────────────────────────────────────────────────────
def start_thinking(self, fen):
if not self.ready: return
with self._lock: self._result = None
self._thread = threading.Thread(target=self._think, args=(fen,), daemon=True)
self._thread.start()
def _think(self, fen):
mt = self.LEVEL_TIME.get(self.level, 500)
self._send(f"position fen {fen}")
self._send(f"go movetime {mt}")
line = self._wait("bestmove", timeout=mt/1000 + 8)
parts = line.strip().split()
if len(parts) >= 2 and parts[0] == "bestmove" and parts[1] != "(none)":
with self._lock:
self._result = self._parse(parts[1])
def is_thinking(self):
return self._thread is not None and self._thread.is_alive()
def get_result(self):
"""Returns move tuple or None if still thinking."""
if self.is_thinking(): return None
with self._lock:
r = self._result; self._result = None
return r
# ── UCI string → internal move tuple ─────────────────────────────────────
def _parse(self, uci):
"""
'e2e4' → (6,4,4,4, None, 'Q')
'e7e8q' → (1,4,0,4, None, 'Q') promotion
'e1g1' → (7,4,7,6, 'cK', 'Q') castling
"""
fc = ord(uci[0]) - ord('a')
fr = 8 - int(uci[1])
tc2 = ord(uci[2]) - ord('a')
tr = 8 - int(uci[3])
promo = uci[4].upper() if len(uci) == 5 else 'Q'
# Castling: king moves exactly 2 squares horizontally
sp = None
if uci == 'e1g1': sp = 'cK'
elif uci == 'e1c1': sp = 'cQ'
elif uci == 'e8g8': sp = 'ck'
elif uci == 'e8c8': sp = 'cq'
return (fr, fc, tr, tc2, sp, promo)
# ═══════════════════════════════════════════════════════════════════════════════
# Persistence
# ═══════════════════════════════════════════════════════════════════════════════
def load_hist():
try:
with open(SAVE_FILE,'r') as f: return json.load(f)
except: return []
def save_hist(recs):
try:
with open(SAVE_FILE,'w') as f: json.dump(recs[-10:],f,indent=2)
except: pass
def record_game(cb,mode,ai_lv,tc_label,wt,bt):
recs=load_hist()
rs="White wins" if cb.result==WHITE else ("Black wins" if cb.result==BLACK else "Draw")
recs.append({'date':time.strftime('%Y-%m-%d %H:%M'),'mode':mode,'ai_level':ai_lv,
'tc':tc_label,'result':rs,'reason':cb.result_reason,
'moves':[h['move']['san'] for h in cb.history],
'white_time_left':round(wt,1),'black_time_left':round(bt,1),
'total_moves':len(cb.history)})
save_hist(recs)
# ═══════════════════════════════════════════════════════════════════════════════
# Screen IDs
# ═══════════════════════════════════════════════════════════════════════════════
class S:
MAIN=0; SETUP_BOT=1; SETUP_LOC=2; PLAYING=3; PROMOTION=4
REVIEW=5; HISTORY=6; HIST_CONFIRM=7
# ═══════════════════════════════════════════════════════════════════════════════
# Game
# ═══════════════════════════════════════════════════════════════════════════════
class Game:
def __init__(self):
self.surf=pygame.display.set_mode((WINDOW_W,WINDOW_H))
pygame.display.set_caption("Chess")
self.clk=pygame.time.Clock()
self.state=S.MAIN
# setup
self.opt_ai=5; self.opt_tc=0; self.opt_col=WHITE
# game
self.cb=None; self.ai=StockfishAI(level=5); self.mode=None
self.player_col=WHITE; self.flipped=False
# clocks
self.wt=0.0; self.bt=0.0; self.inc=0
self.clk_last=0.0; self.clk_run=False
# board UI
self.sel=None; self.ltgts=[]; self.lm=None
self.drag_p=None; self.drag_pos=None; self.drag_fr=None
# promo
self.promo=None
# ai
self.ai_busy=False
# review
self.rev_hist=[]; self.rev_idx=0; self.rev_boards=[]
self.rev_src=S.PLAYING # where Back goes
# history
self.hist_recs=[]; self.hist_scroll=0; self.hist_confirm=None
# notif
self.notif=''; self.notif_exp=0
# ─── coords ───────────────────────────────────────────────────────────────
def s2p(self,r,c):
return ((7-c)*SQ,(7-r)*SQ) if self.flipped else (c*SQ,r*SQ)
def p2s(self,x,y):
if not(0<=x<BOARD_SIZE and 0<=y<BOARD_SIZE): return None,None
return (7-y//SQ,7-x//SQ) if self.flipped else (y//SQ,x//SQ)
# ─── clock ────────────────────────────────────────────────────────────────
def tick(self):
if not self.clk_run or not self.cb or self.cb.result: return
now=time.time(); dt=now-self.clk_last; self.clk_last=now
if self.cb.turn==WHITE: self.wt=max(0,self.wt-dt)
else: self.bt=max(0,self.bt-dt)
if self.wt<=0 or self.bt<=0:
self.cb.result=BLACK if self.wt<=0 else WHITE
self.cb.result_reason='timeout'
self.clk_run=False; self._end()
# FIX #3 – increment goes to the MOVER
def _add_inc(self,mover):
if self.inc<=0: return
if mover==WHITE: self.wt+=self.inc
else: self.bt+=self.inc
# ─── lifecycle ────────────────────────────────────────────────────────────
def start(self,mode):
self.mode=mode; tc=TIME_CONTROLS[self.opt_tc]
self.wt=float(tc['base']); self.bt=float(tc['base']); self.inc=tc['inc']
self.cb=ChessBoard()
# Close previous Stockfish process before creating a new one
if isinstance(self.ai, StockfishAI): self.ai.close()
self.ai=StockfishAI(level=self.opt_ai)
# If Stockfish unavailable, fall back to Python AI
if not self.ai.ready: self.ai=AI(self.opt_ai)
self.sel=None; self.ltgts=[]; self.lm=None
self.drag_p=None; self.promo=None; self.ai_busy=False
self.clk_run=False; self.clk_last=time.time()
if mode=='bot':
self.player_col=self.opt_col; self.flipped=(self.opt_col==BLACK)
else:
self.player_col=WHITE; self.flipped=False
self.state=S.PLAYING
def _end(self):
record_game(self.cb,self.mode,self.opt_ai,
TIME_CONTROLS[self.opt_tc]['label'],self.wt,self.bt)
def open_review(self,history,src=S.PLAYING):
self.rev_hist=history; self.rev_src=src
self._build_rev()
self.rev_idx=len(self.rev_hist); self.state=S.REVIEW
def open_review_game(self):
if self.cb and self.cb.history: self.open_review(self.cb.history[:],S.PLAYING)
def _build_rev(self):
self.rev_boards=[]
tmp=ChessBoard(); self.rev_boards.append(copy.deepcopy(tmp.board))
for h in self.rev_hist:
m=h['move']
tmp.make_move(m['from'][0],m['from'][1],m['to'][0],m['to'][1],m['special'],m['promo'])
self.rev_boards.append(copy.deepcopy(tmp.board))
def open_hist(self):
self.hist_recs=load_hist(); self.hist_scroll=0; self.hist_confirm=None
self.state=S.HISTORY
# ─── move execution ───────────────────────────────────────────────────────
def try_move(self,fr,fc,tr,tc2):
legal=self.cb.legal(fr,fc)
m=next((x for x in legal if x[0]==tr and x[1]==tc2),None)
if m is None:
p=self.cb.board[tr][tc2]
if p and self.cb.col(p)==self.cb.turn:
self.sel=(tr,tc2); self.ltgts=self.cb.legal(tr,tc2)
else:
self.sel=None; self.ltgts=[]
return
sp=m[2] if len(m)>2 else None
if self.cb.pt(self.cb.board[fr][fc])=='P' and (tr==0 or tr==7):
self.promo=(fr,fc,tr,tc2,sp); self.state=S.PROMOTION
self.sel=None; self.ltgts=[]; return
self._do(fr,fc,tr,tc2,sp,'Q')
def _do(self,fr,fc,tr,tc2,sp,promo):
mover=self.cb.turn
info=self.cb.make_move(fr,fc,tr,tc2,sp,promo)
self._add_inc(mover) # FIX #3
self.lm=((fr,fc),(tr,tc2))
self.sel=None; self.ltgts=[]
self.clk_last=time.time()
if not self.clk_run and len(self.cb.history)>=1: self.clk_run=True
if self.cb.result: self.clk_run=False; self._end()
self.notif=info['san']; self.notif_exp=time.time()+2.2
def ai_tick(self):
if self.state!=S.PLAYING or self.mode!='bot' or self.cb.result: return
if self.cb.turn==self.player_col: return
if isinstance(self.ai, StockfishAI):
if not self.ai_busy:
self.ai.start_thinking(self.cb.fen())
self.ai_busy=True
else:
result=self.ai.get_result()
if result is not None:
fr,fc,tr,tc2,sp,promo=result
# Re-detect castling from board if not already flagged
# (Stockfish sends e1g1 etc. which _parse() already handles)
# Validate the move is legal before applying
legal=self.cb.legal(fr,fc)
matched=next((m for m in legal if m[0]==tr and m[1]==tc2),None)
if matched is not None:
sp2=matched[2] if len(matched)>2 else sp
self._do(fr,fc,tr,tc2,sp2,promo)
self.ai_busy=False
else:
# Fallback Python AI (blocking but keeps working)
if self.ai_busy: return
self.ai_busy=True
m=self.ai.best(self.cb); self.ai_busy=False
if m: self._do(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None,'Q')
# ─── find move by san for record replay ───────────────────────────────────
def _find_san(self,cb,san):
for m in cb.all_legal():
fr,fc,tr,tc2=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None
for pr in('Q','R','B','N'):
if '=' in san and san[-1]!=pr: continue
if cb._san(fr,fc,tr,tc2,sp,pr,cb.board)==san: return m
return None
def _open_rec_review(self,idx):
recs=self.hist_recs
if not recs or idx>=len(recs): return
rec=recs[-(idx+1)]
tmp=ChessBoard()
for san in rec.get('moves',[]):
m=self._find_san(tmp,san)
if m is None: break
tmp.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
self.open_review(tmp.history[:],S.HISTORY)
# ─── event loop ───────────────────────────────────────────────────────────
def run(self):
while True:
self.clk.tick(60); self.tick()
for ev in pygame.event.get():
if ev.type==pygame.QUIT:
if isinstance(self.ai, StockfishAI): self.ai.close()
pygame.quit(); sys.exit()
if ev.type==pygame.KEYDOWN: self._key(ev.key)
if ev.type==pygame.MOUSEBUTTONDOWN: self._click(ev.pos,ev.button)
if ev.type==pygame.MOUSEBUTTONUP: self._rel(ev.pos)
if ev.type==pygame.MOUSEMOTION:
if self.drag_p: self.drag_pos=ev.pos
if self.state==S.PLAYING: self.ai_tick()
self._draw()
# ─── key ──────────────────────────────────────────────────────────────────
def _key(self,key):
if self.state==S.REVIEW: # FIX #4
if key in(pygame.K_LEFT,pygame.K_a): self.rev_idx=max(0,self.rev_idx-1)
if key in(pygame.K_RIGHT,pygame.K_d): self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)
if key==pygame.K_HOME: self.rev_idx=0
if key==pygame.K_END: self.rev_idx=len(self.rev_hist)
if key==pygame.K_ESCAPE: self.state=self.rev_src
elif self.state==S.PLAYING:
if key==pygame.K_ESCAPE: self.state=S.MAIN
if key==pygame.K_f: self.flipped=not self.flipped
if key==pygame.K_r: self.open_review_game()
elif self.state==S.HIST_CONFIRM:
if key==pygame.K_ESCAPE: self.state=S.HISTORY
elif key==pygame.K_ESCAPE: self.state=S.MAIN
# ─── click dispatcher ─────────────────────────────────────────────────────
def _click(self,pos,btn):
{S.MAIN:self._c_main,S.SETUP_BOT:self._c_sbot,S.SETUP_LOC:self._c_sloc,
S.PLAYING:self._c_play,S.PROMOTION:self._c_promo,S.REVIEW:self._c_rev,
S.HISTORY:self._c_hist,S.HIST_CONFIRM:self._c_hconf
}.get(self.state,lambda p:None)(pos)
def _rel(self,pos):
if self.state not in(S.PLAYING,S.PROMOTION) or not self.drag_p: return
x,y=pos; r,c=self.p2s(x,y)
if r is not None and self.drag_fr:
fr,fc=self.drag_fr
if(fr,fc)!=(r,c): self.try_move(fr,fc,r,c)
self.drag_p=None; self.drag_pos=None; self.drag_fr=None
def _c_main(self,pos):
x,y=pos; cx=WINDOW_W//2
if cx-160<=x<=cx-10 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_BOT
elif cx+10<=x<=cx+160 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_LOC
elif cx-100<=x<=cx+100 and WINDOW_H//2+60<=y<=WINDOW_H//2+110: self.open_hist()
def _c_sbot(self,pos):
x,y=pos; cx=WINDOW_W//2
# 11 difficulty buttons — same layout as _d_sbot
bw,bh=78,48; row1=6; row2=5
for i in range(11):
if i<row1:
total_w=row1*bw+(row1-1)*6
bx=cx-total_w//2+i*(bw+6); by=222
else:
j=i-row1
total_w=row2*bw+(row2-1)*6
bx=cx-total_w//2+j*(bw+6); by=278
if bx<=x<=bx+bw and by<=y<=by+bh: self.opt_ai=i+1
# Play as buttons
if cx-160<=x<=cx-20 and 362<=y<=402: self.opt_col=WHITE
elif cx+20<=x<=cx+160 and 362<=y<=402: self.opt_col=BLACK
# Time controls
for i in range(len(TIME_CONTROLS)):
bx=cx-210+(i%3)*142; by=440+(i//3)*52
if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i
# Start
if cx-100<=x<=cx+100 and 590<=y<=630: self.start('bot')
# Back
if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN
def _c_sloc(self,pos):
x,y=pos; cx=WINDOW_W//2
for i in range(len(TIME_CONTROLS)):
bx=cx-210+(i%3)*142; by=262+(i//3)*52
if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i
if cx-100<=x<=cx+100 and 448<=y<=488: self.start('local')
if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN
def _c_play(self,pos):
x,y=pos
if x>=BOARD_SIZE: self._c_panel(pos); return
if self.cb.result: return
r,c=self.p2s(x,y)
if r is None: return
if self.mode=='bot' and self.cb.turn!=self.player_col: return
p=self.cb.board[r][c]
if p and self.cb.col(p)==self.cb.turn:
self.sel=(r,c); self.ltgts=self.cb.legal(r,c)
self.drag_p=p; self.drag_pos=pos; self.drag_fr=(r,c)
elif self.sel:
self.try_move(self.sel[0],self.sel[1],r,c)
def _c_panel(self,pos):
x,y=pos; px=x-BOARD_SIZE
if 558<=y<=588:
if 10<=px<=95: self.open_review_game()
elif 105<=px<=190: self.start(self.mode)
elif 200<=px<=285: self.state=S.MAIN
def _c_promo(self,pos):
x,y=pos; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2
for i,p in enumerate(['Q','R','B','N']):
bx=cx-105+i*54; by=cy-22
if bx<=x<=bx+52 and by<=y<=by+50:
fr,fc,tr,tc2,sp=self.promo
self._do(fr,fc,tr,tc2,sp,p)
self.promo=None; self.state=S.PLAYING; return
def _c_rev(self,pos):
x,y=pos; cx=BOARD_SIZE//2; by0=BOARD_SIZE-46
for bx,by,bw,bh,act in[(cx-132,by0,40,36,'first'),(cx-84,by0,40,36,'prev'),
(cx+44, by0,40,36,'next'),(cx+92,by0,40,36,'last')]:
if bx<=x<=bx+bw and by<=y<=by+bh:
if act=='first': self.rev_idx=0
elif act=='prev': self.rev_idx=max(0,self.rev_idx-1)
elif act=='next': self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)
elif act=='last': self.rev_idx=len(self.rev_hist)
return
if x>=BOARD_SIZE:
px=x-BOARD_SIZE
if 558<=y<=588 and 10<=px<=PANEL_WIDTH-10:
self.state=self.rev_src
def _c_hist(self,pos):
x,y=pos; cx=WINDOW_W//2
if WINDOW_H-55<=y<=WINDOW_H-20 and cx-80<=x<=cx+80:
self.state=S.MAIN; return
item_h=70; list_y0=88
if 100<=y<=WINDOW_H-80 and 30<=x<=WINDOW_W-30:
idx=(y-list_y0)//item_h+self.hist_scroll
if 0<=idx<len(self.hist_recs):
self.hist_confirm=idx; self.state=S.HIST_CONFIRM # FIX #5
def _c_hconf(self,pos):
x,y=pos; cx,cy=WINDOW_W//2,WINDOW_H//2
if cx-120<=x<=cx-10 and cy+20<=y<=cy+65: self._open_rec_review(self.hist_confirm)
elif cx+10<=x<=cx+120 and cy+20<=y<=cy+65: self.state=S.HISTORY
# ═══════════════════════════════════════════════════════════════════════════
# Drawing
# ═══════════════════════════════════════════════════════════════════════════
def _draw(self):
s=self.surf; s.fill(C_BG)
if self.state==S.MAIN: self._d_main()
elif self.state==S.SETUP_BOT: self._d_sbot()
elif self.state==S.SETUP_LOC: self._d_sloc()
elif self.state in(S.PLAYING,S.PROMOTION):
self._d_play()
if self.state==S.PROMOTION: self._d_promo()
elif self.state==S.REVIEW: self._d_rev()
elif self.state==S.HISTORY: self._d_hist()
elif self.state==S.HIST_CONFIRM: self._d_hist(); self._d_hconf()
pygame.display.flip()
# ── Main menu ─────────────────────────────────────────────────────────────
def _d_main(self):
s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()
for r in range(8):
for c in range(8):
clr=(50,46,40) if(r+c)%2==0 else(38,35,30)
pygame.draw.rect(s,clr,(c*(WINDOW_W//8),r*(WINDOW_H//8),WINDOW_W//8,WINDOW_H//8))
ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((14,13,11,218)); s.blit(ov,(0,0))
tc(s,"CHESS",FNT_XL,(235,210,150),cx,cy-155)
tc(s,"Full Rules · 10 AI Levels · Time Controls",FNT_SM,C_TEXT2,cx,cy-112)
pygame.draw.line(s,C_BORDER,(cx-230,cy-92),(cx+230,cy-92),1)
for bx,by,bw,bh,lbl,clr in[(cx-160,cy-30,150,60,"vs Bot",C_BLUE),(cx+10,cy-30,150,60,"Local 2P",C_ACCENT)]:
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
c2=tuple(min(255,v+22) for v in clr) if hov else clr
rr(s,c2,(bx,by,bw,bh),10,1,C_BORDER)
tc(s,lbl,FNT_MDB,(10,10,10),bx+bw//2,by+bh//2)
hbx,hby,hbw,hbh=cx-100,cy+60,200,50
hov=(hbx<=mx<=hbx+hbw and hby<=my<=hby+hbh)
rr(s,C_BTN_H if hov else C_BTN,(hbx,hby,hbw,hbh),8,1,C_BORDER)
tc(s,"Game Records",FNT_MD,C_TEXT,hbx+hbw//2,hby+hbh//2)
tc(s,"F: flip | R: review | ESC: menu",FNT_XS,C_TEXT3,cx,WINDOW_H-18)
# ── Setup screens ─────────────────────────────────────────────────────────
def _d_sbot(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Play vs Bot",FNT_LG,C_TEXT,cx,42)
rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)
tc(s,"AI Difficulty",FNT_MD,C_TEXT2,cx,196)
names=["Beginner","Novice","Amateur","Casual","Intermediate",
"Advanced","Expert","Master","IM","GM","Super GM"]
elos =[500,800,1200,1600,1800,2000,2200,2300,2400,2500,2700]
# 11 buttons: first row 6, second row 5 — centred
row1=6; row2=5
bw,bh=78,48
for i in range(11):
if i<row1:
total_w=row1*bw+(row1-1)*6
bx=cx-total_w//2+i*(bw+6); by=222
else:
j=i-row1
total_w=row2*bw+(row2-1)*6
bx=cx-total_w//2+j*(bw+6); by=278
sel=(self.opt_ai==i+1)
c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw and by<=my<=by+bh) else C_BTN)
rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)
tc(s,str(i+1),FNT_SMB,(255,255,255) if sel else C_TEXT2,bx+bw//2,by+12)
tc(s,names[i],FNT_XS,(255,255,255) if sel else C_TEXT3,bx+bw//2,by+27)
tc(s,str(elos[i]),FNT_XS,C_GOLD if sel else C_TEXT3,bx+bw//2,by+39)
tc(s,"Play as",FNT_MD,C_TEXT2,cx,348)
for lbl,col2,bx in[("White",WHITE,cx-160),("Black",BLACK,cx+20)]:
bw2,bh2=140,40; by=362; sel=(self.opt_col==col2)
c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw2 and by<=my<=by+bh2) else C_BTN)
rr(s,c,(bx,by,bw2,bh2),7,1,C_BORDER)
tc(s,lbl,FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw2//2,by+bh2//2)
tc(s,"Time Control",FNT_MD,C_TEXT2,cx,420)
self._d_tc(s,cx,mx,my,440)
sbx,sby,sbw,sbh=cx-100,590,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)
rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)
tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)
def _d_sloc(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Local 2-Player",FNT_LG,C_TEXT,cx,42)
rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)
tc(s,"Time Control",FNT_MD,C_TEXT2,cx,238)
self._d_tc(s,cx,mx,my,258)
sbx,sby,sbw,sbh=cx-100,448,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)
rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)
tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)
def _d_tc(self,s,cx,mx,my,y0):
for i,t2 in enumerate(TIME_CONTROLS):
bx=cx-210+(i%3)*142; by=y0+(i//3)*52; bw,bh=132,42; sel=(self.opt_tc==i)
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
c=C_BTN_A if sel else(C_BTN_H if hov else C_BTN)
rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)
tc(s,t2['label'],FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw//2,by+14)
tc(s,t2['name'], FNT_XS, (210,210,210) if sel else C_TEXT3,bx+bw//2,by+30)
# ── Playing ───────────────────────────────────────────────────────────────
def _d_play(self):
self._d_board(self.surf)
self._d_pieces(self.surf,self.cb.board if self.cb else None)
self._d_panel(self.surf)
self._d_drag(self.surf)
if self.cb and self.cb.result: self._d_result(self.surf)
def _d_board(self,s,lm=None,sel=None,tgts=None):
fl=self.flipped
lm =lm if lm is not None else self.lm
sel =sel if sel is not None else self.sel
tgts=tgts if tgts is not None else self.ltgts
chk_sq=None
if self.cb and self.cb.is_check() and not self.cb.result:
king='K' if self.cb.turn==WHITE else 'k'
for r in range(8):
for c in range(8):
if self.cb.board[r][c]==king: chk_sq=(r,c)
for r in range(8):
for c in range(8):
px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ
base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ
pygame.draw.rect(s,base,(px,py,SQ,SQ))
if lm and((r,c)==lm[0] or(r,c)==lm[1]):
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))
if sel and(r,c)==sel:
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_SELECTED[:3],210)); s.blit(hl,(px,py))
if chk_sq and(r,c)==chk_sq:
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_CHECK,185)); s.blit(hl,(px,py))
cb_b=self.cb.board if self.cb else [[None]*8 for _ in range(8)]
for m in tgts:
tr2,tc2=m[0],m[1]; px=(7-tc2)*SQ if fl else tc2*SQ; py=(7-tr2)*SQ if fl else tr2*SQ
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA)
if cb_b[tr2][tc2]: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//2-2,5)
else: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//6)
s.blit(hl,(px,py))
for i in range(8):
ci=7-i if fl else i; ri=i if fl else 7-i
lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ
lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ
lt=FNT_XS.render('abcdefgh'[ci],True,lc); s.blit(lt,(i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))
ln=FNT_XS.render(str(ri+1),True,lr); s.blit(ln,(3,i*SQ+2))
def _d_pieces(self,s,board,fl=None):
if board is None: return
if fl is None: fl=self.flipped
df=self.drag_fr
for r in range(8):
for c in range(8):
if df and(r,c)==df: continue
p=board[r][c]
if not p: continue
px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ
draw_piece_at(s,p,px,py)
def _d_drag(self,s):
if self.drag_p and self.drag_pos:
x,y=self.drag_pos; draw_piece_at(s,self.drag_p,x-SQ//2,y-SQ//2)
def _d_result(self,s):
ov=pygame.Surface((BOARD_SIZE,78),pygame.SRCALPHA); ov.fill((18,17,15,215))
s.blit(ov,(0,BOARD_SIZE//2-39))
r=self.cb.result
msg,clr=("White wins",C_ACCENT) if r==WHITE else("Black wins",C_RED) if r==BLACK else("Draw",C_TEXT2)
tc(s,msg,FNT_LG,clr,BOARD_SIZE//2,BOARD_SIZE//2-11)
tc(s,self.cb.result_reason.capitalize(),FNT_SM,C_TEXT2,BOARD_SIZE//2,BOARD_SIZE//2+16)
# ── helpers for captured pieces ───────────────────────────────────────────
def _captured(self):
"""Return (white_captured, black_captured, advantage_color, adv_pts).
white_captured = pieces white has taken (i.e. black pieces removed from board).
black_captured = pieces black has taken (i.e. white pieces removed from board)."""
PV = {'P':1,'N':3,'B':3,'R':5,'Q':9}
start = {'P':8,'N':2,'B':2,'R':2,'Q':1}
on_board = {}
for r in range(8):
for c in range(8):
p = self.cb.board[r][c]
if p: on_board[p] = on_board.get(p,0)+1
# pieces white captured = missing black pieces
w_cap=[]
for pt,cnt in start.items():
missing = cnt - on_board.get(pt.lower(),0)
w_cap.extend([pt]*missing)
# pieces black captured = missing white pieces
b_cap=[]
for pt,cnt in start.items():
missing = cnt - on_board.get(pt,0)
b_cap.extend([pt]*missing)
ws = sum(PV.get(p,0) for p in w_cap)
bs = sum(PV.get(p,0) for p in b_cap)
adv = ws-bs
return w_cap, b_cap, adv
def _draw_caps(self, s, caps, score_adv, x, y, row_w):
"""Draw captured piece symbols in a compact row."""
PV={'P':1,'N':3,'B':3,'R':5,'Q':9}
# sort by value
caps_sorted = sorted(caps, key=lambda p: PV.get(p,0))
font, use_uni = _pfont(14)
cx2 = x
for p in caps_sorted:
sym = UNICODE_SYMS.get(p.lower(),'?') if use_uni else p
t = font.render(sym, True, C_TEXT2)
s.blit(t,(cx2, y)); cx2 += t.get_width()+1
if cx2 > x+row_w-20: break # don't overflow
if score_adv > 0:
adv_t = FNT_XS.render(f"+{score_adv}", True, C_ACCENT)
s.blit(adv_t,(cx2+4, y+1))
# ── Panel ─────────────────────────────────────────────────────────────────
def _d_panel(self,s):
px0=BOARD_SIZE; PW=PANEL_WIDTH; W=PW-28; x=px0+14; mx,my=pygame.mouse.get_pos()
pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))
pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)
y=10
# ── Header: mode + ELO ───────────────────────────────────────────────
t2=TIME_CONTROLS[self.opt_tc]
if self.mode=='bot':
elo = StockfishAI.LEVEL_ELO.get(self.opt_ai, '?')
lbl = f"vs Bot · Lv{self.opt_ai} · {elo} Elo · {t2['label']}"
else:
lbl = f"Local 2P · {t2['label']}"
tc(s,lbl,FNT_XS,C_TEXT2,px0+PW//2,y+7); y+=18
# ── Captured pieces + material advantage ─────────────────────────────
w_cap, b_cap, adv = self._captured()
# adv > 0 → white ahead, adv < 0 → black ahead
# Determine which player is "opponent" (top) and "player" (bottom)
# If playing as black: top=black(player), bottom=white(opponent)
# Default / white / local: top=black(opponent), bottom=white(player)
player_is_black = (self.mode=='bot' and self.player_col==BLACK)
if player_is_black:
# top = black (player), bottom = white (opponent)
top_color, bot_color = BLACK, WHITE
top_time, bot_time = self.bt, self.wt
top_label, bot_label = "Black (You)", "White (Bot)"
top_cap, bot_cap = b_cap, w_cap # black captured whites; white captured blacks
top_adv = max(0,-adv) # black advantage
bot_adv = max(0, adv) # white advantage
else:
top_color, bot_color = BLACK, WHITE
top_time, bot_time = self.bt, self.wt
if self.mode=='bot':
top_label = "Black (Bot)"; bot_label = "White (You)"
else:
top_label = "Black"; bot_label = "White"
top_cap, bot_cap = b_cap, w_cap
top_adv = max(0,-adv)
bot_adv = max(0, adv)
# ── TOP player box ────────────────────────────────────────────────────
top_act = (self.cb.turn==top_color and self.clk_run and not self.cb.result)
rr(s,(52,50,44) if top_act else C_PANEL2,(x,y,W,44),6,1,C_BORDER)
tc(s,top_label,FNT_XS,C_TEXT2,x+W//2,y+10)
tc(s,fmt(top_time),FNT_CLK,C_GOLD if top_act else C_TEXT2,x+W//2,y+30)
y+=50
# Pieces captured BY top player = opponent's pieces they took
# top captures opponent pieces: if top=BLACK → took white pieces (uppercase)
# if top=WHITE → took black pieces (lowercase)
top_caps_disp = w_cap if top_color==BLACK else b_cap # white pieces BLACK took / black pieces WHITE took
top_adv_pts = max(0, -adv) if top_color==BLACK else max(0, adv)
if top_caps_disp:
self._draw_caps(s, top_caps_disp, top_adv_pts, x, y, W)
y+=16
pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8
# ── Move list ─────────────────────────────────────────────────────────
hist=self.cb.history; ROW=17; max_vis=13
pairs=[]
i=0
while i<len(hist):
ws=hist[i]['move']['san']; i+=1
bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1
pairs.append((len(pairs)+1,ws,bs))
start_p=max(0,len(pairs)-max_vis); ml_y=y
for rel,(mn,ws,bs) in enumerate(pairs[start_p:]):
ry=ml_y+rel*ROW
tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)
tl(s,ws, FNT_MONO_SM,(225,220,205),x+30,ry)
if bs: tl(s,bs,FNT_MONO_SM,(205,205,220),x+118,ry)
y=ml_y+max_vis*ROW+4
pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8
if self.cb.is_check() and not self.cb.result:
tc(s,"Check!",FNT_SMB,C_RED,px0+PW//2,y+8); y+=22
if self.ai_busy:
sf=isinstance(self.ai,StockfishAI) and self.ai.ready
lbl2="Stockfish thinking..." if sf else "AI thinking..."
tc(s,lbl2,FNT_SM,C_GOLD,px0+PW//2,y+8); y+=20
if self.notif and time.time()<self.notif_exp:
tc(s,self.notif,FNT_SM,C_ACCENT,px0+PW//2,y+8)
# ── BOTTOM captured pieces ────────────────────────────────────────────
bot_caps_disp = b_cap if bot_color==BLACK else w_cap # symmetric
bot_adv_pts = max(0, -adv) if bot_color==BLACK else max(0, adv)
bot_cap_y = 488
if bot_caps_disp:
self._draw_caps(s, bot_caps_disp, bot_adv_pts, x, bot_cap_y, W)
# ── BOTTOM player box ─────────────────────────────────────────────────
bot_act = (self.cb.turn==bot_color and self.clk_run and not self.cb.result)
rr(s,(52,50,44) if bot_act else C_PANEL2,(x,506,W,44),6,1,C_BORDER)
tc(s,bot_label,FNT_XS,C_TEXT2,x+W//2,506+10)
tc(s,fmt(bot_time),FNT_CLK,C_GOLD if bot_act else C_TEXT,x+W//2,506+30)
# ── Buttons ───────────────────────────────────────────────────────────
for lbl2,bx,by,bw2,bh,c in[("Review",10,558,84,30,C_GOLD),
("Rematch",104,558,80,30,C_BTN),
("Menu",194,558,84,30,C_BTN)]:
ax=px0+bx; hov=(ax<=mx<=ax+bw2 and by<=my<=by+bh)
rr(s,C_BTN_H if hov else c,(ax,by,bw2,bh),5,1,C_BORDER)
tc(s,lbl2,FNT_SM,C_TEXT,ax+bw2//2,by+bh//2)
tc(s,"F:flip R:review ESC:menu",FNT_XS,C_TEXT3,px0+PW//2,WINDOW_H-10)
def _d_promo(self):
s=self.surf; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2; mx,my=pygame.mouse.get_pos()
ov=pygame.Surface((BOARD_SIZE,BOARD_SIZE),pygame.SRCALPHA); ov.fill((0,0,0,168)); s.blit(ov,(0,0))
rr(s,(46,44,38),(cx-125,cy-60,250,118),12,1,C_BORDER)
tc(s,"Promote to:",FNT_MD,C_TEXT2,cx,cy-42)
for i,p in enumerate(['Q','R','B','N']):
piece=p if self.cb.turn==WHITE else p.lower()
bx=cx-105+i*54; by=cy-22; hov=(bx<=mx<=bx+52 and by<=my<=by+50)
rr(s,C_BTN_H if hov else C_BTN,(bx,by,52,50),7,1,C_BORDER)
draw_piece_at(s,piece,bx,by,50)
# ── Review ────────────────────────────────────────────────────────────────
def _d_rev(self):
s=self.surf; idx=self.rev_idx
board=self.rev_boards[idx] if idx<len(self.rev_boards) else(self.rev_boards[-1] if self.rev_boards else None)
lm=None
if idx>0: m=self.rev_hist[idx-1]['move']; lm=(m['from'],m['to'])
# Draw board without live highlights
old_lm=self.lm; old_sel=self.sel; old_tgts=self.ltgts
old_cb_board=self.cb.board if self.cb else None
self.lm=lm; self.sel=None; self.ltgts=[]
if self.cb: self.cb.board=[[None]*8 for _ in range(8)]
self._d_board(s)
if self.cb and old_cb_board: self.cb.board=old_cb_board
self.lm=old_lm; self.sel=old_sel; self.ltgts=old_tgts
if board:
old_df=self.drag_fr; self.drag_fr=None
self._d_pieces(s,board); self.drag_fr=old_df
self._d_rev_nav(s); self._d_rev_panel(s)
def _d_rev_nav(self,s):
cx=BOARD_SIZE//2; by0=BOARD_SIZE-46; mx,my=pygame.mouse.get_pos()
for bx,by,bw,bh,lbl in[(cx-132,by0,40,36,'|<'),(cx-84,by0,40,36,'<'),
(cx+44, by0,40,36,'>'),(cx+92,by0,40,36,'|>')]:
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
rr(s,C_BTN_H if hov else C_PANEL3,(bx,by,bw,bh),6,1,C_BORDER)
tc(s,lbl,FNT_MDB,C_TEXT,bx+bw//2,by+bh//2)
tc(s,f"{self.rev_idx} / {len(self.rev_hist)}",FNT_SM,C_TEXT2,cx,by0+18)
tc(s,"Arrow keys or buttons to navigate",FNT_XS,C_TEXT3,cx,BOARD_SIZE-7)
def _d_rev_panel(self,s):
px0=BOARD_SIZE; PW=PANEL_WIDTH; x=px0+14; mx,my=pygame.mouse.get_pos()
pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))
pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)
y=14; tc(s,"Game Review",FNT_LG,C_GOLD,px0+PW//2,y+10); y+=36
pygame.draw.line(s,C_SEP,(x,y),(x+PW-28,y),1); y+=10
# Paired move list in review panel (FIX #1)
hist=self.rev_hist; cur=self.rev_idx; ROW=17; max_vis=18
pairs=[]
i=0
while i<len(hist):
ws=hist[i]['move']['san']; i+=1
bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1
pairs.append((len(pairs)+1,ws,bs))
cur_row=(cur-1)//2 if cur>0 else 0
start=max(0,cur_row-max_vis//2); end=min(len(pairs),start+max_vis); start=max(0,end-max_vis)
for rel,(mn,ws,bs) in enumerate(pairs[start:end]):
pi=start+rel; ry=y+rel*ROW
w_act=(cur==pi*2+1); b_act=(cur==pi*2+2)
if w_act: pygame.draw.rect(s,(68,65,48),(px0+8,ry-1,108,ROW),border_radius=3)
if b_act: pygame.draw.rect(s,(68,65,48),(px0+8+118,ry-1,108,ROW),border_radius=3)
tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)
tl(s,ws, FNT_MONO_SM,C_GOLD if w_act else(225,220,205),x+30,ry)
if bs: tl(s,bs,FNT_MONO_SM,C_GOLD if b_act else(205,205,220),x+118,ry)
bx,by2,bw,bh=px0+10,558,PW-20,30; hov=(bx<=mx<=bx+bw and by2<=my<=by2+bh)
rr(s,C_BTN_H if hov else C_BTN,(bx,by2,bw,bh),5,1,C_BORDER)
back="< Back to Game" if self.rev_src==S.PLAYING else "< Back to Records"
tc(s,back,FNT_SM,C_TEXT,px0+PW//2,by2+bh//2)
# ── History screen ────────────────────────────────────────────────────────
def _d_hist(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Game Records",FNT_LG,C_TEXT,cx,45)
pygame.draw.line(s,C_BORDER,(40,70),(WINDOW_W-40,70),1)
recs=self.hist_recs
if not recs: tc(s,"No games recorded yet.",FNT_MD,C_TEXT2,cx,WINDOW_H//2)
else:
item_h=70; list_y0=88; vis=min(7,(WINDOW_H-170)//item_h)
for rel in range(vis):
idx=rel+self.hist_scroll
if idx>=len(recs): break
rec=recs[-(idx+1)]; ry=list_y0+rel*item_h
hov=(30<=mx<=WINDOW_W-30 and ry<=my<=ry+item_h-3)
bg=C_PANEL3 if hov else(C_PANEL2 if rel%2==0 else C_PANEL)
rr(s,bg,(30,ry,WINDOW_W-60,item_h-4),6,1,C_BORDER)
res=rec.get('result','?')
rclr=C_ACCENT if'White' in res else(C_RED if'Black' in res else C_TEXT2)
tl(s,res,FNT_MDB,rclr,50,ry+6)
reason=rec.get('reason','').capitalize()
if reason: tl(s,f"by {reason}",FNT_XS,C_TEXT2,50,ry+24)
mode='vs Bot' if rec.get('mode')=='bot' else'Local 2P'
lvl=f" · Lv{rec.get('ai_level','?')}" if rec.get('mode')=='bot' else''
tl(s,f"{mode}{lvl} · {rec.get('tc','?')}",FNT_SM,C_TEXT,230,ry+8)
tl(s,f"{rec.get('total_moves','?')} moves",FNT_XS,C_TEXT2,230,ry+26)
tl(s,rec.get('date',''),FNT_XS,C_TEXT3,WINDOW_W-215,ry+8)
moves=rec.get('moves',[]); prev=' '.join(f"{(i//2)+1}.{m}" if i%2==0 else m for i,m in enumerate(moves[:10]))
if len(moves)>10: prev+='...'
tl(s,prev,FNT_XS,C_TEXT3,50,ry+44)
if hov: tc(s,"Click to review",FNT_XS,C_GOLD,WINDOW_W-90,ry+item_h//2-4)
total=len(recs)
if total>vis:
area=WINDOW_H-170; sbh=max(20,int(vis/total*area))
sby=list_y0+int(self.hist_scroll/max(1,total-vis)*(area-sbh))
pygame.draw.rect(s,C_PANEL3,(WINDOW_W-14,list_y0,6,area))
pygame.draw.rect(s,C_TEXT2,(WINDOW_W-14,sby,6,sbh),border_radius=3)
bbx,bby,bbw,bbh=cx-80,WINDOW_H-48,160,34; hov=(bbx<=mx<=bbx+bbw and bby<=my<=bby+bbh)
rr(s,C_BTN_H if hov else C_BTN,(bbx,bby,bbw,bbh),6,1,C_BORDER)
tc(s,"< Main Menu",FNT_SM,C_TEXT,cx,bby+bbh//2)
# FIX #5 – confirm overlay
def _d_hconf(self):
s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()
ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((0,0,0,155)); s.blit(ov,(0,0))
rr(s,(46,44,38),(cx-185,cy-80,370,170),12,1,C_BORDER)
tc(s,"Review this game?",FNT_LG,C_TEXT,cx,cy-52)
if self.hist_confirm is not None and self.hist_recs:
rec=self.hist_recs[-(self.hist_confirm+1)]
info=f"{rec.get('result','?')} · {rec.get('total_moves','?')} moves · {rec.get('date','')}"
tc(s,info,FNT_SM,C_TEXT2,cx,cy-22)
for lbl2,bx,c in[("Review >",cx-120,C_BLUE),("Cancel",cx+10,C_BTN)]:
bw2,bh2=110,46; by=cy+18; hov=(bx<=mx<=bx+bw2 and by<=my<=by+bh2)
rr(s,tuple(min(255,v+20) for v in c) if hov else c,(bx,by,bw2,bh2),8,1,C_BORDER)
tc(s,lbl2,FNT_MDB,C_TEXT,bx+bw2//2,by+bh2//2)
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == '__main__':
Game().run()
[Python] Chess
Version 2
Requires:
pip install pygame
brew install stockfish
Code:
"""
Chess Game — Lichess-style UI (v3 — all fixes applied)
Fixes:
1. Move list: white & black on SAME ROW (1. e4 e5)
2. Piece rendering: platform-aware font fallback, letter-in-box if no Unicode glyphs
3. Increment added to the player who JUST MOVED (not the opponent)
4. Arrow keys navigate review in both directions
5. History screen: confirm dialog before opening a saved-game review
"""
import pygame, sys, copy, random, time, json, os, platform, subprocess, threading, shutil
pygame.init()
# ─── Layout ──────────────────────────────────────────────────────────────────
BOARD_SIZE = 640
PANEL_WIDTH = 300
WINDOW_W = BOARD_SIZE + PANEL_WIDTH
WINDOW_H = BOARD_SIZE
SQ = BOARD_SIZE // 8
# ─── Colours ─────────────────────────────────────────────────────────────────
C_BG = (22, 21, 18)
C_DARK_SQ = (181, 136, 99)
C_LIGHT_SQ = (240, 217, 181)
C_HIGHLIGHT = (205, 210, 106)
C_SELECTED = (246, 246, 105)
C_PANEL = (28, 27, 24)
C_PANEL2 = (38, 37, 33)
C_PANEL3 = (52, 50, 44)
C_TEXT = (222, 220, 215)
C_TEXT2 = (140, 135, 125)
C_TEXT3 = (88, 85, 78)
C_ACCENT = (128, 196, 127)
C_GOLD = (255, 188, 66)
C_RED = (210, 90, 90)
C_BLUE = (100, 155, 220)
C_BTN = (55, 53, 47)
C_BTN_H = (75, 73, 65)
C_BTN_A = (100, 155, 220)
C_CHECK = (200, 55, 55)
C_BORDER = (65, 62, 55)
C_SEP = (50, 48, 43)
# ─── Move classification colours ─────────────────────────────────────────────
CLF_BRILLIANT = (0, 200, 215) # cyan
CLF_GREAT = (90, 130, 170) # blue-grey
CLF_BEST = (100, 185, 100) # green
CLF_GOOD = (160, 210, 100) # lime
CLF_MISTAKE = (220, 140, 50) # orange
CLF_BLUNDER = (210, 70, 70) # red
CLF_NONE = (80, 78, 72) # neutral
# name → (colour, symbol, description)
CLF_INFO = {
'brilliant': (CLF_BRILLIANT, '!!', 'Brilliant — sacrifice with hidden value'),
'great': (CLF_GREAT, '!', 'Great Move — only good reply'),
'best': (CLF_BEST, '*', 'Best Move — top engine choice'),
'good': (CLF_GOOD, 'v', 'Good Move — solid play'),
'mistake': (CLF_MISTAKE, '?', 'Mistake — clear positional loss'),
'blunder': (CLF_BLUNDER, '??', 'Blunder — game-changing error'),
'none': (CLF_NONE, '', ''),
}
# ─── Fonts ───────────────────────────────────────────────────────────────────
FNT_XL = pygame.font.SysFont("segoeui", 32, bold=True)
FNT_LG = pygame.font.SysFont("segoeui", 22, bold=True)
FNT_MD = pygame.font.SysFont("segoeui", 17)
FNT_MDB = pygame.font.SysFont("segoeui", 17, bold=True)
FNT_SM = pygame.font.SysFont("segoeui", 14)
FNT_SMB = pygame.font.SysFont("segoeui", 14, bold=True)
FNT_XS = pygame.font.SysFont("segoeui", 12)
FNT_MONO = pygame.font.SysFont("consolas", 14)
FNT_MONO_SM = pygame.font.SysFont("consolas", 13)
FNT_CLK = pygame.font.SysFont("consolas", 28, bold=True)
# ─── Piece rendering (FIX #2) ───────────────────────────────────────────────
UNICODE_SYMS = {'K':'♔','Q':'♕','R':'♖','B':'♗','N':'♘','P':'♙',
'k':'♚','q':'♛','r':'♜','b':'♝','n':'♞','p':'♟'}
def _make_piece_font(size):
plat = platform.system()
if plat == "Windows":
cands = ["segoeuisymbol","seguisym","segoeui","arial unicode ms"]
elif plat == "Darwin":
cands = ["apple symbols","arial unicode ms","lucida grande"]
else:
cands = ["dejavusans","symbola","freesans","unifont"]
cands += [None]
for name in cands:
try:
f = pygame.font.SysFont(name, size) if name else pygame.font.Font(None, size)
surf = f.render("♔", True, (255,255,255))
if surf.get_width() > 4:
return f, True
except Exception:
pass
return pygame.font.SysFont("consolas", size, bold=True), False
_PF_CACHE = {}
def _pfont(size):
if size not in _PF_CACHE:
_PF_CACHE[size] = _make_piece_font(size)
return _PF_CACHE[size]
def draw_piece_at(surf, piece, px, py, size=SQ):
is_white = piece.isupper()
fs = int(size * 0.74)
font, use_uni = _pfont(fs)
if not use_uni:
pad = max(4, size//8)
bg = (245,230,200) if is_white else (55,50,45)
fg = (40,35,30) if is_white else (240,230,215)
pygame.draw.rect(surf, bg,
(px+pad, py+pad, size-pad*2, size-pad*2),
border_radius=max(2, size//10))
pygame.draw.rect(surf, (0,0,0),
(px+pad, py+pad, size-pad*2, size-pad*2),
1, border_radius=max(2, size//10))
t = font.render(piece.upper(), True, fg)
surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))
return
sym = UNICODE_SYMS.get(piece, '?')
oc = (230,228,224) if not is_white else (28,26,22)
for ox,oy in ((-1,0),(1,0),(0,-1),(0,1)):
o = font.render(sym, True, oc)
surf.blit(o, (px+size//2-o.get_width()//2+ox, py+size//2-o.get_height()//2+oy))
clr = (255,255,255) if is_white else (22,20,18)
t = font.render(sym, True, clr)
surf.blit(t, (px+size//2-t.get_width()//2, py+size//2-t.get_height()//2))
# ─── Helpers ─────────────────────────────────────────────────────────────────
WHITE = 'w'; BLACK = 'b'
SAVE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chess_history.json")
TIME_CONTROLS = [
{"label":"5 min", "name":"Blitz", "base":300, "inc":0 },
{"label":"10 min", "name":"Blitz", "base":600, "inc":0 },
{"label":"15+10", "name":"Rapid", "base":900, "inc":10},
{"label":"30 min", "name":"Rapid", "base":1800, "inc":0 },
{"label":"60 min", "name":"Classical", "base":3600, "inc":0 },
{"label":"90+30", "name":"Classical", "base":5400, "inc":30},
]
PIECE_VALUES = {'P':100,'N':320,'B':330,'R':500,'Q':900,'K':20000}
PST = {
'P':[ 0, 0, 0, 0, 0, 0, 0, 0,50,50,50,50,50,50,50,50,
10,10,20,30,30,20,10,10, 5, 5,10,25,25,10, 5, 5,
0, 0, 0,20,20, 0, 0, 0, 5,-5,-10,0,0,-10,-5, 5,
5,10,10,-20,-20,10,10,5, 0, 0, 0, 0, 0, 0, 0, 0],
'N':[-50,-40,-30,-30,-30,-30,-40,-50,-40,-20,0,0,0,0,-20,-40,
-30,0,10,15,15,10,0,-30,-30,5,15,20,20,15,5,-30,
-30,0,15,20,20,15,0,-30,-30,5,10,15,15,10,5,-30,
-40,-20,0,5,5,0,-20,-40,-50,-40,-30,-30,-30,-30,-40,-50],
'B':[-20,-10,-10,-10,-10,-10,-10,-20,-10,0,0,0,0,0,0,-10,
-10,0,5,10,10,5,0,-10,-10,5,5,10,10,5,5,-10,
-10,0,10,10,10,10,0,-10,-10,10,10,10,10,10,10,-10,
-10,5,0,0,0,0,5,-10,-20,-10,-10,-10,-10,-10,-10,-20],
'R':[ 0,0,0,0,0,0,0,0, 5,10,10,10,10,10,10,5,
-5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,
-5,0,0,0,0,0,0,-5,-5,0,0,0,0,0,0,-5,
-5,0,0,0,0,0,0,-5, 0,0,0,5,5,0,0,0],
'Q':[-20,-10,-10,-5,-5,-10,-10,-20,-10,0,0,0,0,0,0,-10,
-10,0,5,5,5,5,0,-10,-5,0,5,5,5,5,0,-5,
0,0,5,5,5,5,0,-5,-10,5,5,5,5,5,0,-10,
-10,0,5,0,0,0,0,-10,-20,-10,-10,-5,-5,-10,-10,-20],
'K':[-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,-30,-40,-40,-50,-50,-40,-40,-30,
-20,-30,-30,-40,-40,-30,-30,-20,-10,-20,-20,-20,-20,-20,-20,-10,
20,20,0,0,0,0,20,20,20,30,10,0,0,10,30,20],
}
def rr(surf,color,rect,r=8,brd=0,bc=None):
pygame.draw.rect(surf,color,rect,border_radius=r)
if brd and bc: pygame.draw.rect(surf,bc,rect,brd,border_radius=r)
def tc(surf,txt,fnt,clr,cx,cy):
t=fnt.render(str(txt),True,clr); surf.blit(t,(cx-t.get_width()//2,cy-t.get_height()//2))
def tl(surf,txt,fnt,clr,x,y):
t=fnt.render(str(txt),True,clr); surf.blit(t,(x,y)); return t.get_width()
def fmt(secs):
secs=max(0,int(secs)); m,s=divmod(secs,60); return f"{m}:{s:02d}"
# ═══════════════════════════════════════════════════════════════════════════════
# Chess Engine
# ═══════════════════════════════════════════════════════════════════════════════
class ChessBoard:
INIT = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
def __init__(self):
self.board=[[None]*8 for _ in range(8)]; self.turn=WHITE
self.castling={'K':True,'Q':True,'k':True,'q':True}
self.ep_square=None; self.halfmove=0; self.fullmove=1
self.history=[]; self.result=None; self.result_reason=''
self._fen(self.INIT)
def _fen(self,fen):
p=fen.split()
for r,row in enumerate(p[0].split('/')):
c=0
for ch in row:
if ch.isdigit(): c+=int(ch)
else: self.board[r][c]=ch; c+=1
self.turn=WHITE if p[1]=='w' else BLACK
cs=p[2]; self.castling={'K':'K' in cs,'Q':'Q' in cs,'k':'k' in cs,'q':'q' in cs}
if p[3]!='-': self.ep_square=(8-int(p[3][1]),ord(p[3][0])-ord('a'))
self.halfmove=int(p[4]); self.fullmove=int(p[5])
def col(self,p): return WHITE if p and p.isupper() else (BLACK if p else None)
def pt(self,p): return p.upper() if p else None
def pseudo(self,row,col):
p=self.board[row][col]
if not p: return []
c=self.col(p); t=self.pt(p); mv=[]
def ok(r,c2): return 0<=r<8 and 0<=c2<8
def slide(dirs):
for dr,dc in dirs:
r,c2=row+dr,col+dc
while ok(r,c2):
tgt=self.board[r][c2]
if tgt is None: mv.append((r,c2))
elif self.col(tgt)!=c: mv.append((r,c2)); break
else: break
r+=dr; c2+=dc
def jump(ds):
for dr,dc in ds:
r,c2=row+dr,col+dc
if ok(r,c2) and self.col(self.board[r][c2])!=c: mv.append((r,c2))
if t=='R': slide([(1,0),(-1,0),(0,1),(0,-1)])
elif t=='B': slide([(1,1),(1,-1),(-1,1),(-1,-1)])
elif t=='Q': slide([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])
elif t=='N': jump([(2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)])
elif t=='K':
jump([(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)])
if c==WHITE and row==7 and col==4:
if self.castling['K'] and not self.board[7][5] and not self.board[7][6]: mv.append((7,6,'cK'))
if self.castling['Q'] and not self.board[7][3] and not self.board[7][2] and not self.board[7][1]: mv.append((7,2,'cQ'))
elif c==BLACK and row==0 and col==4:
if self.castling['k'] and not self.board[0][5] and not self.board[0][6]: mv.append((0,6,'ck'))
if self.castling['q'] and not self.board[0][3] and not self.board[0][2] and not self.board[0][1]: mv.append((0,2,'cq'))
elif t=='P':
d=-1 if c==WHITE else 1; sr=6 if c==WHITE else 1; r2=row+d
if ok(r2,col) and not self.board[r2][col]:
mv.append((r2,col))
if row==sr and not self.board[row+2*d][col]: mv.append((row+2*d,col))
for dc in(-1,1):
r2,c2=row+d,col+dc
if ok(r2,c2):
tgt=self.board[r2][c2]
if tgt and self.col(tgt)!=c: mv.append((r2,c2))
elif self.ep_square==(r2,c2): mv.append((r2,c2,'ep'))
return mv
def _chk(self,color,board):
king='K' if color==WHITE else 'k'
kp=next(((r,c) for r in range(8) for c in range(8) if board[r][c]==king),None)
if not kp: return True
orig=self.board; self.board=board
att=any((m[0],m[1])==kp for r in range(8) for c in range(8)
if self.col(board[r][c]) is not None and self.col(board[r][c])!=color
for m in self.pseudo(r,c))
self.board=orig; return att
def _apply(self,board,castling,ep,fr,fc,tr,tc,sp=None,promo='Q'):
b=copy.deepcopy(board); p=b[fr][fc]; c=self.col(p); t2=p.upper() if p else None
nc=dict(castling); ne=None
if sp in('cK','cQ','ck','cq'):
b[tr][tc]=b[fr][fc]; b[fr][fc]=None
rm={'cK':(7,7,7,5),'cQ':(7,0,7,3),'ck':(0,7,0,5),'cq':(0,0,0,3)}
rr2,rc,rt,rtc2=rm[sp]; b[rt][rtc2]=b[rr2][rc]; b[rr2][rc]=None
elif sp=='ep':
b[tr][tc]=b[fr][fc]; b[fr][fc]=None; b[fr][tc]=None
else:
b[tr][tc]=b[fr][fc]; b[fr][fc]=None
if t2=='P' and (tr==0 or tr==7): b[tr][tc]=promo if c==WHITE else promo.lower()
if p=='K': nc['K']=nc['Q']=False
if p=='k': nc['k']=nc['q']=False
for sq,key in[((7,0),'Q'),((7,7),'K'),((0,0),'q'),((0,7),'k')]:
if (fr,fc)==sq or (tr,tc)==sq: nc[key]=False
if t2=='P' and abs(tr-fr)==2: ne=((fr+tr)//2,fc)
return b,nc,ne
def legal(self,row,col):
p=self.board[row][col]
if not p: return []
c=self.col(p); res=[]
for m in self.pseudo(row,col):
tr,tc2=m[0],m[1]; sp=m[2] if len(m)>2 else None
if sp in('cK','cQ','ck','cq'):
if self._chk(c,self.board): continue
mc=5 if tc2==6 else 3
b2,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,row,mc)
if self._chk(c,b2): continue
nb,_,_=self._apply(self.board,self.castling,self.ep_square,row,col,tr,tc2,sp)
if not self._chk(c,nb): res.append(m)
return res
def all_legal(self,color=None):
if color is None: color=self.turn
return [(r,c)+m for r in range(8) for c in range(8)
if self.col(self.board[r][c])==color for m in self.legal(r,c)]
def is_check(self): return self._chk(self.turn,self.board)
def is_checkmate(self): return not self.all_legal() and self.is_check()
def is_stalemate(self): return not self.all_legal() and not self.is_check()
def is_insuf(self):
ps=[(p.upper(),r,c) for r in range(8) for c in range(8)
if (p:=self.board[r][c]) and p.upper()!='K']
return len(ps)==0 or (len(ps)==1 and ps[0][0] in('N','B'))
def _san(self,fr,fc,tr,tc,sp,promo,board):
p=board[fr][fc];
if not p: return ''
pt=p.upper(); CL='abcdefgh'; RL='87654321'; dest=CL[tc]+RL[tr]
if sp in('cK','ck'): return 'O-O'
if sp in('cQ','cq'): return 'O-O-O'
cap='x' if board[tr][tc] or sp=='ep' else ''
if pt=='P':
s=(CL[fc]+cap+dest) if cap else dest
if tr==0 or tr==7: s+='='+promo
return s
return pt+cap+dest
def fen(self):
rows=[]
for r in range(8):
e=0; s2=''
for c in range(8):
p=self.board[r][c]
if not p: e+=1
else:
if e: s2+=str(e); e=0
s2+=p
if e: s2+=str(e)
rows.append(s2)
f='/'.join(rows)+' '+('w' if self.turn==WHITE else 'b')+' '
cs=''.join(k for k in('K','Q','k','q') if self.castling[k])
f+=(cs or '-')+' '
if self.ep_square: f+='abcdefgh'[self.ep_square[1]]+str(8-self.ep_square[0])
else: f+='-'
f+=f' {self.halfmove} {self.fullmove}'
return f
def make_move(self,fr,fc,tr,tc,sp=None,promo='Q'):
p=self.board[fr][fc]; cap=self.board[tr][tc]
if sp=='ep': cap=self.board[fr][tc]
old_b=copy.deepcopy(self.board); san=self._san(fr,fc,tr,tc,sp,promo,old_b)
nb,nc,ne=self._apply(self.board,self.castling,self.ep_square,fr,fc,tr,tc,sp,promo)
info={'from':(fr,fc),'to':(tr,tc),'piece':p,'captured':cap,'special':sp,'promo':promo,'san':san}
self.history.append({'move':info,'board':old_b,'castling':dict(self.castling),
'ep':self.ep_square,'hm':self.halfmove,'turn':self.turn})
self.board=nb; self.castling=nc; self.ep_square=ne
pt=p.upper() if p else None
self.halfmove=0 if (pt=='P' or cap) else self.halfmove+1
if self.turn==BLACK: self.fullmove+=1
self.turn=BLACK if self.turn==WHITE else WHITE
if self.is_checkmate(): self.result=WHITE if self.turn==BLACK else BLACK; self.result_reason='checkmate'
elif self.is_stalemate(): self.result='draw'; self.result_reason='stalemate'
elif self.is_insuf(): self.result='draw'; self.result_reason='insufficient material'
elif self.halfmove>=100: self.result='draw'; self.result_reason='50-move rule'
return info
# ═══════════════════════════════════════════════════════════════════════════════
# Fallback Python AI (used when Stockfish is unavailable)
# ═══════════════════════════════════════════════════════════════════════════════
class AI:
D={1:1,2:1,3:2,4:2,5:3,6:3,7:4,8:4,9:5,10:6,11:6}
N={1:350,2:250,3:180,4:120,5:70,6:40,7:20,8:8,9:2,10:0,11:0}
def __init__(self,lv=5): self.lv=lv
def eval(self,cb):
s=0
for r in range(8):
for c in range(8):
p=cb.board[r][c]
if not p: continue
pt=p.upper(); v=PIECE_VALUES.get(pt,0); idx=r*8+c if p.isupper() else (7-r)*8+c
s+=(v+PST[pt][idx]) if p.isupper() else -(v+PST[pt][idx])
# mobility removed for speed
return s
def _ord(self,cb,moves):
def sc(m):
fr,fc,tr,tc=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None
cap=cb.board[tr][tc] if sp!='ep' else cb.board[m[0]][tc]
return PIECE_VALUES.get((cap or'').upper(),0)-PIECE_VALUES.get(cb.board[fr][fc].upper(),0)//10
return sorted(moves,key=sc,reverse=True)
def _cl(self,cb):
n=ChessBoard.__new__(ChessBoard)
n.board=copy.deepcopy(cb.board); n.turn=cb.turn; n.castling=dict(cb.castling)
n.ep_square=cb.ep_square; n.halfmove=cb.halfmove; n.fullmove=cb.fullmove
n.history=[]; n.result=cb.result; n.result_reason=''; return n
def _ab(self,cb,d,a,b,mx):
if d==0 or cb.result: return self.eval(cb)
ms=cb.all_legal()
if not ms: return (-99999 if mx else 99999) if cb.is_check() else 0
ms=self._ord(cb,ms)
if mx:
v=-999999
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=max(v,self._ab(c2,d-1,a,b,False)); a=max(a,v)
if b<=a: break
return v
else:
v=999999
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=min(v,self._ab(c2,d-1,a,b,True)); b=min(b,v)
if b<=a: break
return v
def best(self,cb):
depth=self.D.get(self.lv,3); noise=self.N.get(self.lv,0)
ms=cb.all_legal()
if not ms: return None
ms=self._ord(cb,ms); mx=(cb.turn==WHITE); bv=-999999 if mx else 999999; bms=[]
for m in ms:
c2=self._cl(cb); c2.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
v=self._ab(c2,depth-1,-999999,999999,not mx)+random.randint(-noise,noise)
if(mx and v>bv)or(not mx and v<bv): bv=v; bms=[m]
elif v==bv: bms.append(m)
return random.choice(bms) if bms else random.choice(ms)
# ═══════════════════════════════════════════════════════════════════════════════
# Stockfish AI (async, non-blocking)
# ═══════════════════════════════════════════════════════════════════════════════
class StockfishAI:
# Level → UCI_Elo
LEVEL_ELO = {1:500, 2:800, 3:1200, 4:1600, 5:1800,
6:2000, 7:2200, 8:2300, 9:2400, 10:2500, 11:2700}
# Level → movetime (ms)
LEVEL_TIME = {1:80, 2:100, 3:150, 4:200, 5:300,
6:500, 7:800, 8:1200, 9:1800, 10:2500, 11:4000}
def __init__(self, level=5):
self.level = level
self._proc = None
self._thread = None
self._result = None # (fr,fc,tr,tc,sp,promo) when ready
self._lock = threading.Lock()
self.ready = False
self._init()
# ── process management ────────────────────────────────────────────────────
def _find(self):
# 1) same folder as this script
base = os.path.dirname(os.path.abspath(__file__))
for name in ("stockfish","stockfish.exe",
"stockfish-windows-x86-64-avx2.exe",
"stockfish-windows-x86-64-modern.exe"):
p = os.path.join(base, name)
if os.path.isfile(p): return p
# 2) system PATH (covers brew install on macOS)
return shutil.which("stockfish")
def _init(self):
path = self._find()
if not path:
print("[Stockfish] Not found – using fallback Python AI.")
return
try:
self._proc = subprocess.Popen(
[path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
self._send("uci"); self._wait("uciok")
self._apply_level(self.level)
self._send("isready"); self._wait("readyok")
self.ready = True
print(f"[Stockfish] Ready level={self.level} "
f"elo={self.LEVEL_ELO[self.level]}")
except Exception as e:
print(f"[Stockfish] Failed to start: {e}")
self.ready = False
def _send(self, cmd):
if self._proc:
self._proc.stdin.write(cmd + "\n")
self._proc.stdin.flush()
def _wait(self, token, timeout=10.0):
deadline = time.time() + timeout
while time.time() < deadline:
line = self._proc.stdout.readline()
if token in line: return line
return ""
def _apply_level(self, level):
elo = self.LEVEL_ELO.get(level, 1750)
self._send("setoption name UCI_LimitStrength value true")
self._send(f"setoption name UCI_Elo value {elo}")
def set_level(self, level):
self.level = level
if self.ready:
self._apply_level(level)
self._send("isready"); self._wait("readyok")
def close(self):
if self._proc:
try: self._send("quit"); self._proc.terminate()
except: pass
self._proc = None
self.ready = False
# ── async move request ────────────────────────────────────────────────────
def start_thinking(self, fen):
if not self.ready: return
with self._lock: self._result = None
self._thread = threading.Thread(target=self._think, args=(fen,), daemon=True)
self._thread.start()
def _think(self, fen):
mt = self.LEVEL_TIME.get(self.level, 500)
self._send(f"position fen {fen}")
self._send(f"go movetime {mt}")
line = self._wait("bestmove", timeout=mt/1000 + 8)
parts = line.strip().split()
if len(parts) >= 2 and parts[0] == "bestmove" and parts[1] != "(none)":
with self._lock:
self._result = self._parse(parts[1])
def is_thinking(self):
return self._thread is not None and self._thread.is_alive()
def get_result(self):
"""Returns move tuple or None if still thinking."""
if self.is_thinking(): return None
with self._lock:
r = self._result; self._result = None
return r
# ── UCI string → internal move tuple ─────────────────────────────────────
def _parse(self, uci):
"""
'e2e4' → (6,4,4,4, None, 'Q')
'e7e8q' → (1,4,0,4, None, 'Q') promotion
'e1g1' → (7,4,7,6, 'cK', 'Q') castling
"""
fc = ord(uci[0]) - ord('a')
fr = 8 - int(uci[1])
tc2 = ord(uci[2]) - ord('a')
tr = 8 - int(uci[3])
promo = uci[4].upper() if len(uci) == 5 else 'Q'
# Castling: king moves exactly 2 squares horizontally
sp = None
if uci == 'e1g1': sp = 'cK'
elif uci == 'e1c1': sp = 'cQ'
elif uci == 'e8g8': sp = 'ck'
elif uci == 'e8c8': sp = 'cq'
return (fr, fc, tr, tc2, sp, promo)
# ═══════════════════════════════════════════════════════════════════════════════
# Review Analyser — uses Stockfish to score every position in a game
# ═══════════════════════════════════════════════════════════════════════════════
class ReviewAnalyser:
"""
Runs Stockfish analysis on every position in a game (in a background thread)
and classifies each half-move as brilliant / great / best / good / mistake / blunder.
Results are stored as:
self.evals : list[float] cp score (centipawns, white-positive) AFTER move i
index 0 = initial position, index N = after move N
self.classif: list[str] classification for move i (1-based, index 0 unused)
self.done : bool
"""
DEPTH = 16 # analysis depth
ANALYSIS_MOVETIME = 300 # ms per position
def __init__(self, sf_path):
self.sf_path = sf_path
self.evals = []
self.classif = []
self.done = False
self._thread = None
def analyse(self, rev_hist, rev_boards):
"""Start background analysis. rev_hist / rev_boards from _build_rev."""
self.evals = [None] * (len(rev_hist) + 1)
self.classif = ['none'] * (len(rev_hist) + 1)
self.done = False
self._thread = threading.Thread(
target=self._run, args=(rev_hist, rev_boards), daemon=True)
self._thread.start()
def _score_fen(self, proc, fen):
"""Ask Stockfish for a centipawn score for this FEN. Returns float (white POV)."""
proc.stdin.write(f"position fen {fen}\n")
proc.stdin.write(f"go movetime {self.ANALYSIS_MOVETIME}\n")
proc.stdin.flush()
score = None
mate = None
deadline = time.time() + self.ANALYSIS_MOVETIME/1000 + 5
while time.time() < deadline:
line = proc.stdout.readline().strip()
if line.startswith("info") and "score" in line:
parts = line.split()
try:
si = parts.index("score")
kind = parts[si+1]
val = int(parts[si+2])
if kind == "cp": score = val
elif kind == "mate": mate = val
except (ValueError, IndexError):
pass
if line.startswith("bestmove"):
break
if mate is not None:
return 30000 if mate > 0 else -30000
return float(score) if score is not None else 0.0
def _run(self, rev_hist, rev_boards):
path = self.sf_path
if not path or not os.path.isfile(path):
path = shutil.which("stockfish")
if not path:
self.done = True; return
try:
proc = subprocess.Popen(
[path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
proc.stdin.write("uci\n"); proc.stdin.flush()
# wait for uciok
deadline = time.time()+10
while time.time()<deadline:
if "uciok" in proc.stdout.readline(): break
proc.stdin.write("setoption name UCI_LimitStrength value false\n")
proc.stdin.write("isready\n"); proc.stdin.flush()
deadline = time.time()+10
while time.time()<deadline:
if "readyok" in proc.stdout.readline(): break
# Build a temporary ChessBoard to replay
tmp = ChessBoard()
self.evals[0] = self._score_fen(proc, tmp.fen())
for i, h in enumerate(rev_hist):
m = h['move']
tmp.make_move(m['from'][0],m['from'][1],
m['to'][0], m['to'][1],
m['special'], m['promo'])
score = self._score_fen(proc, tmp.fen())
self.evals[i+1] = score
proc.stdin.write("quit\n"); proc.stdin.flush()
proc.terminate()
except Exception as e:
print(f"[ReviewAnalyser] error: {e}")
finally:
self._classify(rev_hist)
self.done = True
def _classify(self, rev_hist):
"""Classify each move given the eval before and after."""
for i, h in enumerate(rev_hist):
before = self.evals[i]
after = self.evals[i+1]
if before is None or after is None:
self.classif[i+1] = 'none'; continue
color = h['move'].get('piece','?')
# delta from the mover's perspective (positive = good for mover)
if color and color.isupper(): # white moved
delta = after - before # higher after = better for white
else: # black moved
delta = before - after # lower after = better for black (cp is white-POV)
# Was the best move actually played?
# We approximate: if delta >= -10 it's essentially best
if delta >= -10:
# Check for brilliant: mover sacrificed material yet eval improved
cap = h['move'].get('captured')
cap_val = {'P':100,'N':320,'B':330,'R':500,'Q':900}.get(
(cap or '').upper(), 0)
if cap_val == 0 and delta >= 50:
self.classif[i+1] = 'brilliant'
elif cap_val > 0 and delta >= 0:
# sacrificed but still good → brilliant candidate
self.classif[i+1] = 'brilliant'
else:
self.classif[i+1] = 'best'
elif delta >= -30:
self.classif[i+1] = 'great'
elif delta >= -80:
self.classif[i+1] = 'good'
elif delta >= -200:
self.classif[i+1] = 'mistake'
else:
self.classif[i+1] = 'blunder'
def get_eval(self, idx):
"""Return eval at position idx (0=start), or None if not ready yet."""
if idx < len(self.evals): return self.evals[idx]
return None
def get_classif(self, move_idx):
"""Return classification string for half-move move_idx (1-based)."""
if move_idx < len(self.classif): return self.classif[move_idx]
return 'none'
def load_hist():
try:
with open(SAVE_FILE,'r') as f: return json.load(f)
except: return []
def save_hist(recs):
try:
with open(SAVE_FILE,'w') as f: json.dump(recs[-10:],f,indent=2)
except: pass
def record_game(cb,mode,ai_lv,tc_label,wt,bt):
recs=load_hist()
rs="White wins" if cb.result==WHITE else ("Black wins" if cb.result==BLACK else "Draw")
recs.append({'date':time.strftime('%Y-%m-%d %H:%M'),'mode':mode,'ai_level':ai_lv,
'tc':tc_label,'result':rs,'reason':cb.result_reason,
'moves':[h['move']['san'] for h in cb.history],
'white_time_left':round(wt,1),'black_time_left':round(bt,1),
'total_moves':len(cb.history)})
save_hist(recs)
# ═══════════════════════════════════════════════════════════════════════════════
# Screen IDs
# ═══════════════════════════════════════════════════════════════════════════════
class S:
MAIN=0; SETUP_BOT=1; SETUP_LOC=2; PLAYING=3; PROMOTION=4
REVIEW=5; HISTORY=6; HIST_CONFIRM=7
# ═══════════════════════════════════════════════════════════════════════════════
# Game
# ═══════════════════════════════════════════════════════════════════════════════
class Game:
def __init__(self):
self.surf=pygame.display.set_mode((WINDOW_W,WINDOW_H))
pygame.display.set_caption("Chess")
self.clk=pygame.time.Clock()
self.state=S.MAIN
# setup
self.opt_ai=5; self.opt_tc=0; self.opt_col=WHITE
# game
self.cb=None; self.ai=StockfishAI(level=5); self.mode=None
self.player_col=WHITE; self.flipped=False
# clocks
self.wt=0.0; self.bt=0.0; self.inc=0
self.clk_last=0.0; self.clk_run=False
# board UI
self.sel=None; self.ltgts=[]; self.lm=None
self.drag_p=None; self.drag_pos=None; self.drag_fr=None
# promo
self.promo=None
# ai
self.ai_busy=False
# review
self.rev_hist=[]; self.rev_idx=0; self.rev_boards=[]
self.rev_src=S.PLAYING # where Back goes
self.rev_analyser=None # ReviewAnalyser instance
# history
self.hist_recs=[]; self.hist_scroll=0; self.hist_confirm=None
# notif
self.notif=''; self.notif_exp=0
# ─── coords ───────────────────────────────────────────────────────────────
def s2p(self,r,c):
return ((7-c)*SQ,(7-r)*SQ) if self.flipped else (c*SQ,r*SQ)
def p2s(self,x,y):
if not(0<=x<BOARD_SIZE and 0<=y<BOARD_SIZE): return None,None
return (7-y//SQ,7-x//SQ) if self.flipped else (y//SQ,x//SQ)
# ─── clock ────────────────────────────────────────────────────────────────
def tick(self):
if not self.clk_run or not self.cb or self.cb.result: return
now=time.time(); dt=now-self.clk_last; self.clk_last=now
if self.cb.turn==WHITE: self.wt=max(0,self.wt-dt)
else: self.bt=max(0,self.bt-dt)
if self.wt<=0 or self.bt<=0:
self.cb.result=BLACK if self.wt<=0 else WHITE
self.cb.result_reason='timeout'
self.clk_run=False; self._end()
# FIX #3 – increment goes to the MOVER
def _add_inc(self,mover):
if self.inc<=0: return
if mover==WHITE: self.wt+=self.inc
else: self.bt+=self.inc
# ─── lifecycle ────────────────────────────────────────────────────────────
def start(self,mode):
self.mode=mode; tc=TIME_CONTROLS[self.opt_tc]
self.wt=float(tc['base']); self.bt=float(tc['base']); self.inc=tc['inc']
self.cb=ChessBoard()
# Close previous Stockfish process before creating a new one
if isinstance(self.ai, StockfishAI): self.ai.close()
self.ai=StockfishAI(level=self.opt_ai)
# If Stockfish unavailable, fall back to Python AI
if not self.ai.ready: self.ai=AI(self.opt_ai)
self.sel=None; self.ltgts=[]; self.lm=None
self.drag_p=None; self.promo=None; self.ai_busy=False
self.clk_run=False; self.clk_last=time.time()
if mode=='bot':
self.player_col=self.opt_col; self.flipped=(self.opt_col==BLACK)
else:
self.player_col=WHITE; self.flipped=False
self.state=S.PLAYING
def _end(self):
record_game(self.cb,self.mode,self.opt_ai,
TIME_CONTROLS[self.opt_tc]['label'],self.wt,self.bt)
def open_review(self,history,src=S.PLAYING):
self.rev_hist=history; self.rev_src=src
self._build_rev()
self.rev_idx=len(self.rev_hist); self.state=S.REVIEW
# Start Stockfish analysis in background
sf_path = shutil.which("stockfish")
self.rev_analyser = ReviewAnalyser(sf_path)
self.rev_analyser.analyse(self.rev_hist, self.rev_boards)
def open_review_game(self):
if self.cb and self.cb.history: self.open_review(self.cb.history[:],S.PLAYING)
def _build_rev(self):
self.rev_boards=[]
tmp=ChessBoard(); self.rev_boards.append(copy.deepcopy(tmp.board))
for h in self.rev_hist:
m=h['move']
tmp.make_move(m['from'][0],m['from'][1],m['to'][0],m['to'][1],m['special'],m['promo'])
self.rev_boards.append(copy.deepcopy(tmp.board))
def open_hist(self):
self.hist_recs=load_hist(); self.hist_scroll=0; self.hist_confirm=None
self.state=S.HISTORY
# ─── move execution ───────────────────────────────────────────────────────
def try_move(self,fr,fc,tr,tc2):
legal=self.cb.legal(fr,fc)
m=next((x for x in legal if x[0]==tr and x[1]==tc2),None)
if m is None:
p=self.cb.board[tr][tc2]
if p and self.cb.col(p)==self.cb.turn:
self.sel=(tr,tc2); self.ltgts=self.cb.legal(tr,tc2)
else:
self.sel=None; self.ltgts=[]
return
sp=m[2] if len(m)>2 else None
if self.cb.pt(self.cb.board[fr][fc])=='P' and (tr==0 or tr==7):
self.promo=(fr,fc,tr,tc2,sp); self.state=S.PROMOTION
self.sel=None; self.ltgts=[]; return
self._do(fr,fc,tr,tc2,sp,'Q')
def _do(self,fr,fc,tr,tc2,sp,promo):
mover=self.cb.turn
info=self.cb.make_move(fr,fc,tr,tc2,sp,promo)
self._add_inc(mover) # FIX #3
self.lm=((fr,fc),(tr,tc2))
self.sel=None; self.ltgts=[]
self.clk_last=time.time()
if not self.clk_run and len(self.cb.history)>=1: self.clk_run=True
if self.cb.result: self.clk_run=False; self._end()
self.notif=info['san']; self.notif_exp=time.time()+2.2
def ai_tick(self):
if self.state!=S.PLAYING or self.mode!='bot' or self.cb.result: return
if self.cb.turn==self.player_col: return
if isinstance(self.ai, StockfishAI):
# ── Stockfish path (already async) ───────────────────────────────
if not self.ai_busy:
self.ai.start_thinking(self.cb.fen())
self.ai_busy=True
else:
result=self.ai.get_result()
if result is not None:
fr,fc,tr,tc2,sp,promo=result
legal=self.cb.legal(fr,fc)
matched=next((m for m in legal if m[0]==tr and m[1]==tc2),None)
if matched is not None:
sp2=matched[2] if len(matched)>2 else sp
self._do(fr,fc,tr,tc2,sp2,promo)
self.ai_busy=False
else:
# ── Fallback Python AI — run in background thread ─────────────────
if self.ai_busy: return
self.ai_busy=True
# snapshot board state for the thread
cb_snap=self.ai._cl(self.cb) # lightweight clone, no history
def _bg():
m=self.ai.best(cb_snap)
self._py_ai_result=m
self._py_ai_result=None
t=threading.Thread(target=_bg,daemon=True); t.start()
self._py_ai_thread=t
def _py_ai_poll(self):
"""Called every frame to check if the Python AI thread finished."""
if not self.ai_busy: return
if isinstance(self.ai, StockfishAI): return
t=getattr(self,'_py_ai_thread',None)
if t and not t.is_alive():
m=getattr(self,'_py_ai_result',None)
self.ai_busy=False
if m and self.state==S.PLAYING and not self.cb.result:
self._do(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None,'Q')
# ─── find move by san for record replay ───────────────────────────────────
def _find_san(self,cb,san):
for m in cb.all_legal():
fr,fc,tr,tc2=m[0],m[1],m[2],m[3]; sp=m[4] if len(m)>4 else None
for pr in('Q','R','B','N'):
if '=' in san and san[-1]!=pr: continue
if cb._san(fr,fc,tr,tc2,sp,pr,cb.board)==san: return m
return None
def _open_rec_review(self,idx):
recs=self.hist_recs
if not recs or idx>=len(recs): return
rec=recs[-(idx+1)]
tmp=ChessBoard()
for san in rec.get('moves',[]):
m=self._find_san(tmp,san)
if m is None: break
tmp.make_move(m[0],m[1],m[2],m[3],m[4] if len(m)>4 else None)
self.open_review(tmp.history[:],S.HISTORY)
# ─── event loop ───────────────────────────────────────────────────────────
def run(self):
while True:
self.clk.tick(60); self.tick()
for ev in pygame.event.get():
if ev.type==pygame.QUIT:
if isinstance(self.ai, StockfishAI): self.ai.close()
pygame.quit(); sys.exit()
if ev.type==pygame.KEYDOWN: self._key(ev.key)
if ev.type==pygame.MOUSEBUTTONDOWN: self._click(ev.pos,ev.button)
if ev.type==pygame.MOUSEBUTTONUP: self._rel(ev.pos)
if ev.type==pygame.MOUSEMOTION:
if self.drag_p: self.drag_pos=ev.pos
if self.state==S.PLAYING: self.ai_tick(); self._py_ai_poll()
self._draw()
# ─── key ──────────────────────────────────────────────────────────────────
def _key(self,key):
if self.state==S.REVIEW: # FIX #4
if key in(pygame.K_LEFT,pygame.K_a): self.rev_idx=max(0,self.rev_idx-1)
if key in(pygame.K_RIGHT,pygame.K_d): self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)
if key==pygame.K_HOME: self.rev_idx=0
if key==pygame.K_END: self.rev_idx=len(self.rev_hist)
if key==pygame.K_ESCAPE: self.state=self.rev_src
elif self.state==S.PLAYING:
if key==pygame.K_ESCAPE: self.state=S.MAIN
if key==pygame.K_f: self.flipped=not self.flipped
if key==pygame.K_r: self.open_review_game()
elif self.state==S.HIST_CONFIRM:
if key==pygame.K_ESCAPE: self.state=S.HISTORY
elif key==pygame.K_ESCAPE: self.state=S.MAIN
# ─── click dispatcher ─────────────────────────────────────────────────────
def _click(self,pos,btn):
{S.MAIN:self._c_main,S.SETUP_BOT:self._c_sbot,S.SETUP_LOC:self._c_sloc,
S.PLAYING:self._c_play,S.PROMOTION:self._c_promo,S.REVIEW:self._c_rev,
S.HISTORY:self._c_hist,S.HIST_CONFIRM:self._c_hconf
}.get(self.state,lambda p:None)(pos)
def _rel(self,pos):
if self.state not in(S.PLAYING,S.PROMOTION) or not self.drag_p: return
x,y=pos; r,c=self.p2s(x,y)
if r is not None and self.drag_fr:
fr,fc=self.drag_fr
if(fr,fc)!=(r,c): self.try_move(fr,fc,r,c)
self.drag_p=None; self.drag_pos=None; self.drag_fr=None
def _c_main(self,pos):
x,y=pos; cx=WINDOW_W//2
if cx-160<=x<=cx-10 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_BOT
elif cx+10<=x<=cx+160 and WINDOW_H//2-30<=y<=WINDOW_H//2+30: self.state=S.SETUP_LOC
elif cx-100<=x<=cx+100 and WINDOW_H//2+60<=y<=WINDOW_H//2+110: self.open_hist()
def _c_sbot(self,pos):
x,y=pos; cx=WINDOW_W//2
# 11 difficulty buttons — same layout as _d_sbot
bw,bh=78,48; row1=6; row2=5
for i in range(11):
if i<row1:
total_w=row1*bw+(row1-1)*6
bx=cx-total_w//2+i*(bw+6); by=222
else:
j=i-row1
total_w=row2*bw+(row2-1)*6
bx=cx-total_w//2+j*(bw+6); by=278
if bx<=x<=bx+bw and by<=y<=by+bh: self.opt_ai=i+1
# Play as buttons
if cx-160<=x<=cx-20 and 362<=y<=402: self.opt_col=WHITE
elif cx+20<=x<=cx+160 and 362<=y<=402: self.opt_col=BLACK
# Time controls
for i in range(len(TIME_CONTROLS)):
bx=cx-210+(i%3)*142; by=440+(i//3)*52
if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i
# Start
if cx-100<=x<=cx+100 and 590<=y<=630: self.start('bot')
# Back
if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN
def _c_sloc(self,pos):
x,y=pos; cx=WINDOW_W//2
for i in range(len(TIME_CONTROLS)):
bx=cx-210+(i%3)*142; by=262+(i//3)*52
if bx<=x<=bx+132 and by<=y<=by+42: self.opt_tc=i
if cx-100<=x<=cx+100 and 448<=y<=488: self.start('local')
if 18<=x<=110 and 18<=y<=50: self.state=S.MAIN
def _c_play(self,pos):
x,y=pos
if x>=BOARD_SIZE: self._c_panel(pos); return
if self.cb.result: return
r,c=self.p2s(x,y)
if r is None: return
if self.mode=='bot' and self.cb.turn!=self.player_col: return
p=self.cb.board[r][c]
if p and self.cb.col(p)==self.cb.turn:
self.sel=(r,c); self.ltgts=self.cb.legal(r,c)
self.drag_p=p; self.drag_pos=pos; self.drag_fr=(r,c)
elif self.sel:
self.try_move(self.sel[0],self.sel[1],r,c)
def _c_panel(self,pos):
x,y=pos; px=x-BOARD_SIZE
if 558<=y<=588:
if 10<=px<=95: self.open_review_game()
elif 105<=px<=190: self.start(self.mode)
elif 200<=px<=285: self.state=S.MAIN
def _c_promo(self,pos):
x,y=pos; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2
for i,p in enumerate(['Q','R','B','N']):
bx=cx-105+i*54; by=cy-22
if bx<=x<=bx+52 and by<=y<=by+50:
fr,fc,tr,tc2,sp=self.promo
self._do(fr,fc,tr,tc2,sp,p)
self.promo=None; self.state=S.PLAYING; return
def _c_rev(self,pos):
x,y=pos; ox=self.BOARD_OX; cx=ox+BOARD_SIZE//2; by0=BOARD_SIZE-46
for bx,by,bw,bh,act in[(cx-132,by0,40,36,'first'),(cx-84,by0,40,36,'prev'),
(cx+44, by0,40,36,'next'),(cx+92,by0,40,36,'last')]:
if bx<=x<=bx+bw and by<=y<=by+bh:
if act=='first': self.rev_idx=0
elif act=='prev': self.rev_idx=max(0,self.rev_idx-1)
elif act=='next': self.rev_idx=min(len(self.rev_hist),self.rev_idx+1)
elif act=='last': self.rev_idx=len(self.rev_hist)
return
panel_x=ox+BOARD_SIZE
if x>=panel_x:
pw=WINDOW_W-panel_x
if 558<=y<=588 and panel_x+6<=x<=WINDOW_W-6:
self.state=self.rev_src
def _c_hist(self,pos):
x,y=pos; cx=WINDOW_W//2
if WINDOW_H-55<=y<=WINDOW_H-20 and cx-80<=x<=cx+80:
self.state=S.MAIN; return
item_h=70; list_y0=88
if 100<=y<=WINDOW_H-80 and 30<=x<=WINDOW_W-30:
idx=(y-list_y0)//item_h+self.hist_scroll
if 0<=idx<len(self.hist_recs):
self.hist_confirm=idx; self.state=S.HIST_CONFIRM # FIX #5
def _c_hconf(self,pos):
x,y=pos; cx,cy=WINDOW_W//2,WINDOW_H//2
if cx-120<=x<=cx-10 and cy+20<=y<=cy+65: self._open_rec_review(self.hist_confirm)
elif cx+10<=x<=cx+120 and cy+20<=y<=cy+65: self.state=S.HISTORY
# ═══════════════════════════════════════════════════════════════════════════
# Drawing
# ═══════════════════════════════════════════════════════════════════════════
def _draw(self):
s=self.surf; s.fill(C_BG)
if self.state==S.MAIN: self._d_main()
elif self.state==S.SETUP_BOT: self._d_sbot()
elif self.state==S.SETUP_LOC: self._d_sloc()
elif self.state in(S.PLAYING,S.PROMOTION):
self._d_play()
if self.state==S.PROMOTION: self._d_promo()
elif self.state==S.REVIEW: self._d_rev()
elif self.state==S.HISTORY: self._d_hist()
elif self.state==S.HIST_CONFIRM: self._d_hist(); self._d_hconf()
pygame.display.flip()
# ── Main menu ─────────────────────────────────────────────────────────────
def _d_main(self):
s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()
for r in range(8):
for c in range(8):
clr=(50,46,40) if(r+c)%2==0 else(38,35,30)
pygame.draw.rect(s,clr,(c*(WINDOW_W//8),r*(WINDOW_H//8),WINDOW_W//8,WINDOW_H//8))
ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((14,13,11,218)); s.blit(ov,(0,0))
tc(s,"CHESS",FNT_XL,(235,210,150),cx,cy-155)
tc(s,"Full Rules · 10 AI Levels · Time Controls",FNT_SM,C_TEXT2,cx,cy-112)
pygame.draw.line(s,C_BORDER,(cx-230,cy-92),(cx+230,cy-92),1)
for bx,by,bw,bh,lbl,clr in[(cx-160,cy-30,150,60,"vs Bot",C_BLUE),(cx+10,cy-30,150,60,"Local 2P",C_ACCENT)]:
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
c2=tuple(min(255,v+22) for v in clr) if hov else clr
rr(s,c2,(bx,by,bw,bh),10,1,C_BORDER)
tc(s,lbl,FNT_MDB,(10,10,10),bx+bw//2,by+bh//2)
hbx,hby,hbw,hbh=cx-100,cy+60,200,50
hov=(hbx<=mx<=hbx+hbw and hby<=my<=hby+hbh)
rr(s,C_BTN_H if hov else C_BTN,(hbx,hby,hbw,hbh),8,1,C_BORDER)
tc(s,"Game Records",FNT_MD,C_TEXT,hbx+hbw//2,hby+hbh//2)
tc(s,"F: flip | R: review | ESC: menu",FNT_XS,C_TEXT3,cx,WINDOW_H-18)
# ── Setup screens ─────────────────────────────────────────────────────────
def _d_sbot(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Play vs Bot",FNT_LG,C_TEXT,cx,42)
rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)
tc(s,"AI Difficulty",FNT_MD,C_TEXT2,cx,196)
names=["Beginner","Novice","Amateur","Casual","Intermediate",
"Advanced","Expert","Master","IM","GM","Super GM"]
elos =[500,800,1200,1600,1800,2000,2200,2300,2400,2500,2700]
# 11 buttons: first row 6, second row 5 — centred
row1=6; row2=5
bw,bh=78,48
for i in range(11):
if i<row1:
total_w=row1*bw+(row1-1)*6
bx=cx-total_w//2+i*(bw+6); by=222
else:
j=i-row1
total_w=row2*bw+(row2-1)*6
bx=cx-total_w//2+j*(bw+6); by=278
sel=(self.opt_ai==i+1)
c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw and by<=my<=by+bh) else C_BTN)
rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)
tc(s,str(i+1),FNT_SMB,(255,255,255) if sel else C_TEXT2,bx+bw//2,by+12)
tc(s,names[i],FNT_XS,(255,255,255) if sel else C_TEXT3,bx+bw//2,by+27)
tc(s,str(elos[i]),FNT_XS,C_GOLD if sel else C_TEXT3,bx+bw//2,by+39)
tc(s,"Play as",FNT_MD,C_TEXT2,cx,348)
for lbl,col2,bx in[("White",WHITE,cx-160),("Black",BLACK,cx+20)]:
bw2,bh2=140,40; by=362; sel=(self.opt_col==col2)
c=C_BTN_A if sel else(C_BTN_H if(bx<=mx<=bx+bw2 and by<=my<=by+bh2) else C_BTN)
rr(s,c,(bx,by,bw2,bh2),7,1,C_BORDER)
tc(s,lbl,FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw2//2,by+bh2//2)
tc(s,"Time Control",FNT_MD,C_TEXT2,cx,420)
self._d_tc(s,cx,mx,my,440)
sbx,sby,sbw,sbh=cx-100,590,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)
rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)
tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)
def _d_sloc(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Local 2-Player",FNT_LG,C_TEXT,cx,42)
rr(s,C_BTN,(18,18,90,32),5,1,C_BORDER); tc(s,"< Back",FNT_SM,C_TEXT2,63,34)
tc(s,"Time Control",FNT_MD,C_TEXT2,cx,238)
self._d_tc(s,cx,mx,my,258)
sbx,sby,sbw,sbh=cx-100,448,200,40; hov=(sbx<=mx<=sbx+sbw and sby<=my<=sby+sbh)
rr(s,C_ACCENT if hov else(72,148,72),(sbx,sby,sbw,sbh),8,1,C_BORDER)
tc(s,"Start Game",FNT_MDB,(10,30,10),sbx+sbw//2,sby+sbh//2)
def _d_tc(self,s,cx,mx,my,y0):
for i,t2 in enumerate(TIME_CONTROLS):
bx=cx-210+(i%3)*142; by=y0+(i//3)*52; bw,bh=132,42; sel=(self.opt_tc==i)
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
c=C_BTN_A if sel else(C_BTN_H if hov else C_BTN)
rr(s,c,(bx,by,bw,bh),7,1,C_BORDER)
tc(s,t2['label'],FNT_MDB,(255,255,255) if sel else C_TEXT,bx+bw//2,by+14)
tc(s,t2['name'], FNT_XS, (210,210,210) if sel else C_TEXT3,bx+bw//2,by+30)
# ── Playing ───────────────────────────────────────────────────────────────
def _d_play(self):
self._d_board(self.surf)
self._d_pieces(self.surf,self.cb.board if self.cb else None)
self._d_panel(self.surf)
self._d_drag(self.surf)
if self.cb and self.cb.result: self._d_result(self.surf)
def _d_board(self,s,lm=None,sel=None,tgts=None):
fl=self.flipped
lm =lm if lm is not None else self.lm
sel =sel if sel is not None else self.sel
tgts=tgts if tgts is not None else self.ltgts
chk_sq=None
if self.cb and self.cb.is_check() and not self.cb.result:
king='K' if self.cb.turn==WHITE else 'k'
for r in range(8):
for c in range(8):
if self.cb.board[r][c]==king: chk_sq=(r,c)
for r in range(8):
for c in range(8):
px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ
base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ
pygame.draw.rect(s,base,(px,py,SQ,SQ))
if lm and((r,c)==lm[0] or(r,c)==lm[1]):
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))
if sel and(r,c)==sel:
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_SELECTED[:3],210)); s.blit(hl,(px,py))
if chk_sq and(r,c)==chk_sq:
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_CHECK,185)); s.blit(hl,(px,py))
cb_b=self.cb.board if self.cb else [[None]*8 for _ in range(8)]
for m in tgts:
tr2,tc2=m[0],m[1]; px=(7-tc2)*SQ if fl else tc2*SQ; py=(7-tr2)*SQ if fl else tr2*SQ
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA)
if cb_b[tr2][tc2]: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//2-2,5)
else: pygame.draw.circle(hl,(0,0,0,72),(SQ//2,SQ//2),SQ//6)
s.blit(hl,(px,py))
for i in range(8):
ci=7-i if fl else i; ri=i if fl else 7-i
lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ
lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ
lt=FNT_XS.render('abcdefgh'[ci],True,lc); s.blit(lt,(i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))
ln=FNT_XS.render(str(ri+1),True,lr); s.blit(ln,(3,i*SQ+2))
def _d_pieces(self,s,board,fl=None):
if board is None: return
if fl is None: fl=self.flipped
df=self.drag_fr
for r in range(8):
for c in range(8):
if df and(r,c)==df: continue
p=board[r][c]
if not p: continue
px=(7-c)*SQ if fl else c*SQ; py=(7-r)*SQ if fl else r*SQ
draw_piece_at(s,p,px,py)
def _d_drag(self,s):
if self.drag_p and self.drag_pos:
x,y=self.drag_pos; draw_piece_at(s,self.drag_p,x-SQ//2,y-SQ//2)
def _d_result(self,s):
ov=pygame.Surface((BOARD_SIZE,78),pygame.SRCALPHA); ov.fill((18,17,15,215))
s.blit(ov,(0,BOARD_SIZE//2-39))
r=self.cb.result
msg,clr=("White wins",C_ACCENT) if r==WHITE else("Black wins",C_RED) if r==BLACK else("Draw",C_TEXT2)
tc(s,msg,FNT_LG,clr,BOARD_SIZE//2,BOARD_SIZE//2-11)
tc(s,self.cb.result_reason.capitalize(),FNT_SM,C_TEXT2,BOARD_SIZE//2,BOARD_SIZE//2+16)
# ── helpers for captured pieces ───────────────────────────────────────────
def _captured(self):
"""Return (white_captured, black_captured, advantage_color, adv_pts).
white_captured = pieces white has taken (i.e. black pieces removed from board).
black_captured = pieces black has taken (i.e. white pieces removed from board)."""
PV = {'P':1,'N':3,'B':3,'R':5,'Q':9}
start = {'P':8,'N':2,'B':2,'R':2,'Q':1}
on_board = {}
for r in range(8):
for c in range(8):
p = self.cb.board[r][c]
if p: on_board[p] = on_board.get(p,0)+1
# pieces white captured = missing black pieces
w_cap=[]
for pt,cnt in start.items():
missing = cnt - on_board.get(pt.lower(),0)
w_cap.extend([pt]*missing)
# pieces black captured = missing white pieces
b_cap=[]
for pt,cnt in start.items():
missing = cnt - on_board.get(pt,0)
b_cap.extend([pt]*missing)
ws = sum(PV.get(p,0) for p in w_cap)
bs = sum(PV.get(p,0) for p in b_cap)
adv = ws-bs
return w_cap, b_cap, adv
def _draw_caps(self, s, caps, score_adv, x, y, row_w):
"""Draw captured piece symbols in a compact row."""
PV={'P':1,'N':3,'B':3,'R':5,'Q':9}
# sort by value
caps_sorted = sorted(caps, key=lambda p: PV.get(p,0))
font, use_uni = _pfont(14)
cx2 = x
for p in caps_sorted:
sym = UNICODE_SYMS.get(p.lower(),'?') if use_uni else p
t = font.render(sym, True, C_TEXT2)
s.blit(t,(cx2, y)); cx2 += t.get_width()+1
if cx2 > x+row_w-20: break # don't overflow
if score_adv > 0:
adv_t = FNT_XS.render(f"+{score_adv}", True, C_ACCENT)
s.blit(adv_t,(cx2+4, y+1))
# ── Panel ─────────────────────────────────────────────────────────────────
def _d_panel(self,s):
px0=BOARD_SIZE; PW=PANEL_WIDTH; W=PW-28; x=px0+14; mx,my=pygame.mouse.get_pos()
pygame.draw.rect(s,C_PANEL,(px0,0,PW,WINDOW_H))
pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)
y=10
# ── Header: mode + ELO ───────────────────────────────────────────────
t2=TIME_CONTROLS[self.opt_tc]
if self.mode=='bot':
elo = StockfishAI.LEVEL_ELO.get(self.opt_ai, '?')
lbl = f"vs Bot · Lv{self.opt_ai} · {elo} Elo · {t2['label']}"
else:
lbl = f"Local 2P · {t2['label']}"
tc(s,lbl,FNT_XS,C_TEXT2,px0+PW//2,y+7); y+=18
# ── Captured pieces + material advantage ─────────────────────────────
w_cap, b_cap, adv = self._captured()
# adv > 0 → white ahead, adv < 0 → black ahead
# Determine which player is "opponent" (top) and "player" (bottom)
# If playing as black: top=black(player), bottom=white(opponent)
# Default / white / local: top=black(opponent), bottom=white(player)
player_is_black = (self.mode=='bot' and self.player_col==BLACK)
if player_is_black:
# top = black (player), bottom = white (opponent)
top_color, bot_color = BLACK, WHITE
top_time, bot_time = self.bt, self.wt
top_label, bot_label = "Black (You)", "White (Bot)"
top_cap, bot_cap = b_cap, w_cap # black captured whites; white captured blacks
top_adv = max(0,-adv) # black advantage
bot_adv = max(0, adv) # white advantage
else:
top_color, bot_color = BLACK, WHITE
top_time, bot_time = self.bt, self.wt
if self.mode=='bot':
top_label = "Black (Bot)"; bot_label = "White (You)"
else:
top_label = "Black"; bot_label = "White"
top_cap, bot_cap = b_cap, w_cap
top_adv = max(0,-adv)
bot_adv = max(0, adv)
# ── TOP player box ────────────────────────────────────────────────────
top_act = (self.cb.turn==top_color and self.clk_run and not self.cb.result)
rr(s,(52,50,44) if top_act else C_PANEL2,(x,y,W,44),6,1,C_BORDER)
tc(s,top_label,FNT_XS,C_TEXT2,x+W//2,y+10)
tc(s,fmt(top_time),FNT_CLK,C_GOLD if top_act else C_TEXT2,x+W//2,y+30)
y+=50
# Pieces captured BY top player = opponent's pieces they took
# top captures opponent pieces: if top=BLACK → took white pieces (uppercase)
# if top=WHITE → took black pieces (lowercase)
top_caps_disp = w_cap if top_color==BLACK else b_cap # white pieces BLACK took / black pieces WHITE took
top_adv_pts = max(0, -adv) if top_color==BLACK else max(0, adv)
if top_caps_disp:
self._draw_caps(s, top_caps_disp, top_adv_pts, x, y, W)
y+=16
pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8
# ── Move list ─────────────────────────────────────────────────────────
hist=self.cb.history; ROW=17; max_vis=13
pairs=[]
i=0
while i<len(hist):
ws=hist[i]['move']['san']; i+=1
bs=hist[i]['move']['san'] if i<len(hist) else ''; i+=1
pairs.append((len(pairs)+1,ws,bs))
start_p=max(0,len(pairs)-max_vis); ml_y=y
for rel,(mn,ws,bs) in enumerate(pairs[start_p:]):
ry=ml_y+rel*ROW
tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,x,ry)
tl(s,ws, FNT_MONO_SM,(225,220,205),x+30,ry)
if bs: tl(s,bs,FNT_MONO_SM,(205,205,220),x+118,ry)
y=ml_y+max_vis*ROW+4
pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8
if self.cb.is_check() and not self.cb.result:
tc(s,"Check!",FNT_SMB,C_RED,px0+PW//2,y+8); y+=22
if self.ai_busy:
sf=isinstance(self.ai,StockfishAI) and self.ai.ready
lbl2="Stockfish thinking..." if sf else "AI thinking..."
tc(s,lbl2,FNT_SM,C_GOLD,px0+PW//2,y+8); y+=20
if self.notif and time.time()<self.notif_exp:
tc(s,self.notif,FNT_SM,C_ACCENT,px0+PW//2,y+8)
# ── BOTTOM captured pieces ────────────────────────────────────────────
bot_caps_disp = b_cap if bot_color==BLACK else w_cap # symmetric
bot_adv_pts = max(0, -adv) if bot_color==BLACK else max(0, adv)
bot_cap_y = 488
if bot_caps_disp:
self._draw_caps(s, bot_caps_disp, bot_adv_pts, x, bot_cap_y, W)
# ── BOTTOM player box ─────────────────────────────────────────────────
bot_act = (self.cb.turn==bot_color and self.clk_run and not self.cb.result)
rr(s,(52,50,44) if bot_act else C_PANEL2,(x,506,W,44),6,1,C_BORDER)
tc(s,bot_label,FNT_XS,C_TEXT2,x+W//2,506+10)
tc(s,fmt(bot_time),FNT_CLK,C_GOLD if bot_act else C_TEXT,x+W//2,506+30)
# ── Buttons ───────────────────────────────────────────────────────────
for lbl2,bx,by,bw2,bh,c in[("Review",10,558,84,30,C_GOLD),
("Rematch",104,558,80,30,C_BTN),
("Menu",194,558,84,30,C_BTN)]:
ax=px0+bx; hov=(ax<=mx<=ax+bw2 and by<=my<=by+bh)
rr(s,C_BTN_H if hov else c,(ax,by,bw2,bh),5,1,C_BORDER)
tc(s,lbl2,FNT_SM,C_TEXT,ax+bw2//2,by+bh//2)
tc(s,"F:flip R:review ESC:menu",FNT_XS,C_TEXT3,px0+PW//2,WINDOW_H-10)
def _d_promo(self):
s=self.surf; cx,cy=BOARD_SIZE//2,BOARD_SIZE//2; mx,my=pygame.mouse.get_pos()
ov=pygame.Surface((BOARD_SIZE,BOARD_SIZE),pygame.SRCALPHA); ov.fill((0,0,0,168)); s.blit(ov,(0,0))
rr(s,(46,44,38),(cx-125,cy-60,250,118),12,1,C_BORDER)
tc(s,"Promote to:",FNT_MD,C_TEXT2,cx,cy-42)
for i,p in enumerate(['Q','R','B','N']):
piece=p if self.cb.turn==WHITE else p.lower()
bx=cx-105+i*54; by=cy-22; hov=(bx<=mx<=bx+52 and by<=my<=by+50)
rr(s,C_BTN_H if hov else C_BTN,(bx,by,52,50),7,1,C_BORDER)
draw_piece_at(s,piece,bx,by,50)
# ── Review ────────────────────────────────────────────────────────────────
# Layout in review mode:
# x=0..18 : eval bar (white=bottom, black=top)
# x=18..658 : chess board (640px) → board offset = 18
# x=658..958: panel (300px)
EVAL_BAR_W = 18
BOARD_OX = 18 # board x-offset in review mode
def _d_rev(self):
s=self.surf; idx=self.rev_idx
board=self.rev_boards[idx] if idx<len(self.rev_boards) else (self.rev_boards[-1] if self.rev_boards else None)
lm=None
if idx>0: m=self.rev_hist[idx-1]['move']; lm=(m['from'],m['to'])
# ── Eval bar (left strip) ─────────────────────────────────────────────
self._d_eval_bar(s)
# ── Board (shifted right by EVAL_BAR_W) ──────────────────────────────
# Draw squares
fl=self.flipped
chk_sq=None # no check highlight in review
for r in range(8):
for c in range(8):
px=self.BOARD_OX+((7-c)*SQ if fl else c*SQ)
py=(7-r)*SQ if fl else r*SQ
base=C_LIGHT_SQ if(r+c)%2==0 else C_DARK_SQ
pygame.draw.rect(s,base,(px,py,SQ,SQ))
if lm and ((r,c)==lm[0] or (r,c)==lm[1]):
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*C_HIGHLIGHT,170)); s.blit(hl,(px,py))
# Classification highlight on destination square
if idx>0:
clf=self.rev_analyser.get_classif(idx) if self.rev_analyser else 'none'
clf_clr,_,_=CLF_INFO.get(clf,CLF_INFO['none'])
tr2,tc3=lm[1]
px2=self.BOARD_OX+((7-tc3)*SQ if fl else tc3*SQ)
py2=(7-tr2)*SQ if fl else tr2*SQ
hl=pygame.Surface((SQ,SQ),pygame.SRCALPHA); hl.fill((*clf_clr,90)); s.blit(hl,(px2,py2))
# Badge circle on corner
bx3=px2+(SQ-18 if not fl else 0); by3=py2
pygame.draw.circle(s,clf_clr,(bx3+9,by3+9),9)
sym=CLF_INFO.get(clf,('','',''))[1]
if sym:
f2,_=_pfont(10); st=f2.render(sym,True,(255,255,255))
s.blit(st,(bx3+9-st.get_width()//2,by3+9-st.get_height()//2))
# Coordinates
for i in range(8):
ci=7-i if fl else i; ri=i if fl else 7-i
lc=C_DARK_SQ if(7+i)%2==0 else C_LIGHT_SQ
lr=C_DARK_SQ if i%2==1 else C_LIGHT_SQ
lt=FNT_XS.render('abcdefgh'[ci],True,lc)
s.blit(lt,(self.BOARD_OX+i*SQ+SQ-lt.get_width()-3,BOARD_SIZE-lt.get_height()-2))
ln=FNT_XS.render(str(ri+1),True,lr)
s.blit(ln,(self.BOARD_OX+3,i*SQ+2))
# Pieces
if board:
for r in range(8):
for c in range(8):
p=board[r][c]
if not p: continue
px=self.BOARD_OX+((7-c)*SQ if fl else c*SQ)
py=(7-r)*SQ if fl else r*SQ
draw_piece_at(s,p,px,py)
self._d_rev_nav(s)
self._d_rev_panel(s)
def _d_eval_bar(self,s):
"""Draw a vertical eval bar on the left edge."""
ra=self.rev_analyser; idx=self.rev_idx
ev=None
if ra: ev=ra.get_eval(idx)
BW=self.EVAL_BAR_W; H=BOARD_SIZE
# Background
pygame.draw.rect(s,(30,28,24),(0,0,BW,H))
# Convert eval to 0..1 (white fraction of bar, measured from BOTTOM)
if ev is None:
wfrac=0.5
else:
# clamp to ±1000cp, sigmoid-ish
clamped=max(-1000,min(1000,ev))
wfrac=0.5+clamped/2000.0
wfrac=max(0.05,min(0.95,wfrac))
black_h=int(H*(1-wfrac)); white_h=H-black_h
# Black portion (top)
pygame.draw.rect(s,(30,30,30),(0,0,BW,black_h))
# White portion (bottom)
pygame.draw.rect(s,(220,215,200),(0,black_h,BW,white_h))
# Midline
pygame.draw.line(s,(80,78,72),(0,H//2),(BW,H//2),1)
# Score text
if ev is not None:
if abs(ev)>=29000: txt="M" if ev>0 else "-M"
else: txt=f"{ev/100:+.1f}" if abs(ev)<1000 else f"{ev/100:+.0f}"
fs=10; f2,_=_pfont(fs)
st=FNT_XS.render(txt,True,(200,200,200) if abs(ev)<50 else (C_ACCENT if ev>0 else C_RED))
# rotate 90° for vertical text
st_r=pygame.transform.rotate(st,90)
s.blit(st_r,(BW//2-st_r.get_width()//2, H//2-st_r.get_height()//2))
# "Analysing..." if not done
if ra and not ra.done:
dot_y=H-24
dt=FNT_XS.render("…",True,C_GOLD)
s.blit(dt,(BW//2-dt.get_width()//2,dot_y))
def _d_rev_nav(self,s):
ox=self.BOARD_OX; cx=ox+BOARD_SIZE//2; by0=BOARD_SIZE-46; mx,my=pygame.mouse.get_pos()
for bx,by,bw,bh,lbl in[(cx-132,by0,40,36,'|<'),(cx-84,by0,40,36,'<'),
(cx+44, by0,40,36,'>'),(cx+92,by0,40,36,'|>')]:
hov=(bx<=mx<=bx+bw and by<=my<=by+bh)
rr(s,C_BTN_H if hov else C_PANEL3,(bx,by,bw,bh),6,1,C_BORDER)
tc(s,lbl,FNT_MDB,C_TEXT,bx+bw//2,by+bh//2)
tc(s,f"{self.rev_idx} / {len(self.rev_hist)}",FNT_SM,C_TEXT2,cx,by0+18)
tc(s,"Arrow keys or buttons to navigate",FNT_XS,C_TEXT3,cx,BOARD_SIZE-7)
def _d_rev_panel(self,s):
# Panel starts at BOARD_OX + BOARD_SIZE
px0=self.BOARD_OX+BOARD_SIZE; PW=PANEL_WIDTH-(self.BOARD_OX); x=px0+14
mx,my=pygame.mouse.get_pos()
pygame.draw.rect(s,C_PANEL,(px0,0,WINDOW_W-px0,WINDOW_H))
pygame.draw.line(s,C_BORDER,(px0,0),(px0,WINDOW_H),1)
W=WINDOW_W-px0-28; y=14
tc(s,"Game Review",FNT_LG,C_GOLD,px0+(WINDOW_W-px0)//2,y+10); y+=34
# ── Analysis status / current move feedback ───────────────────────────
ra=self.rev_analyser; idx=self.rev_idx
if idx>0 and ra:
clf=ra.get_classif(idx)
clf_clr,sym,desc=CLF_INFO.get(clf,CLF_INFO['none'])
if clf!='none':
rr(s,(*clf_clr,40),(x,y,W,38),6) # won't work with alpha directly
pygame.draw.rect(s,(clf_clr[0]//4,clf_clr[1]//4,clf_clr[2]//4),(x,y,W,38),border_radius=6)
pygame.draw.rect(s,clf_clr,(x,y,W,38),1,border_radius=6)
tc(s,f"{sym} {clf.upper()}",FNT_SMB,clf_clr,x+W//2,y+12)
tc(s,desc.split('—')[1].strip() if '—' in desc else desc,FNT_XS,C_TEXT2,x+W//2,y+27)
y+=46
elif ra and not ra.done:
tc(s,"Analysing game...",FNT_SM,C_GOLD,px0+(WINDOW_W-px0)//2,y+10); y+=24
else:
y+=4
pygame.draw.line(s,C_SEP,(x,y),(x+W,y),1); y+=8
# ── Paired move list with classification badges ────────────────────────
hist=self.rev_hist; cur=self.rev_idx; ROW=18; max_vis=16
pairs=[]
i=0
while i<len(hist):
ws=hist[i]['move']['san']; wclf=ra.get_classif(i+1) if ra else 'none'; i+=1
bs=hist[i]['move']['san'] if i<len(hist) else ''
bclf=ra.get_classif(i+1) if (ra and i<len(hist)) else 'none'; i+=1
pairs.append((len(pairs)+1, ws,wclf, bs,bclf))
cur_row=(cur-1)//2 if cur>0 else 0
start=max(0,cur_row-max_vis//2); end=min(len(pairs),start+max_vis); start=max(0,end-max_vis)
COL_NUM=x; COL_W=x+28; COL_B=x+W//2+4
for rel,(mn,ws,wclf,bs,bclf) in enumerate(pairs[start:end]):
pi=start+rel; ry=y+rel*ROW
w_act=(cur==pi*2+1); b_act=(cur==pi*2+2)
if w_act: pygame.draw.rect(s,(68,65,48),(px0+4,ry-1,W//2,ROW),border_radius=3)
if b_act: pygame.draw.rect(s,(68,65,48),(px0+4+W//2,ry-1,W//2,ROW),border_radius=3)
tl(s,f"{mn}.",FNT_MONO_SM,C_TEXT3,COL_NUM,ry)
# White move + badge
wclr_b,wsym,_=CLF_INFO.get(wclf,CLF_INFO['none'])
wclr=C_GOLD if w_act else (wclr_b if wclf not in('none','best') else (225,220,205))
tl(s,ws,FNT_MONO_SM,wclr,COL_W,ry)
if wsym and wclf not in('none',):
bt=FNT_XS.render(wsym,True,wclr_b)
s.blit(bt,(COL_W+40,ry+1))
# Black move + badge
if bs:
bclr_b,bsym,_=CLF_INFO.get(bclf,CLF_INFO['none'])
bclr=C_GOLD if b_act else (bclr_b if bclf not in('none','best') else (205,205,220))
tl(s,bs,FNT_MONO_SM,bclr,COL_B,ry)
if bsym and bclf not in('none',):
bt2=FNT_XS.render(bsym,True,bclr_b)
s.blit(bt2,(COL_B+40,ry+1))
bx,by2,bw,bh=px0+6,558,WINDOW_W-px0-12,30
hov=(bx<=mx<=bx+bw and by2<=my<=by2+bh)
rr(s,C_BTN_H if hov else C_BTN,(bx,by2,bw,bh),5,1,C_BORDER)
back="< Back to Game" if self.rev_src==S.PLAYING else "< Back to Records"
tc(s,back,FNT_SM,C_TEXT,px0+(WINDOW_W-px0)//2,by2+bh//2)
# ── History screen ────────────────────────────────────────────────────────
def _d_hist(self):
s=self.surf; cx=WINDOW_W//2; mx,my=pygame.mouse.get_pos()
tc(s,"Game Records",FNT_LG,C_TEXT,cx,45)
pygame.draw.line(s,C_BORDER,(40,70),(WINDOW_W-40,70),1)
recs=self.hist_recs
if not recs: tc(s,"No games recorded yet.",FNT_MD,C_TEXT2,cx,WINDOW_H//2)
else:
item_h=70; list_y0=88; vis=min(7,(WINDOW_H-170)//item_h)
for rel in range(vis):
idx=rel+self.hist_scroll
if idx>=len(recs): break
rec=recs[-(idx+1)]; ry=list_y0+rel*item_h
hov=(30<=mx<=WINDOW_W-30 and ry<=my<=ry+item_h-3)
bg=C_PANEL3 if hov else(C_PANEL2 if rel%2==0 else C_PANEL)
rr(s,bg,(30,ry,WINDOW_W-60,item_h-4),6,1,C_BORDER)
res=rec.get('result','?')
rclr=C_ACCENT if'White' in res else(C_RED if'Black' in res else C_TEXT2)
tl(s,res,FNT_MDB,rclr,50,ry+6)
reason=rec.get('reason','').capitalize()
if reason: tl(s,f"by {reason}",FNT_XS,C_TEXT2,50,ry+24)
mode='vs Bot' if rec.get('mode')=='bot' else'Local 2P'
lvl=f" · Lv{rec.get('ai_level','?')}" if rec.get('mode')=='bot' else''
tl(s,f"{mode}{lvl} · {rec.get('tc','?')}",FNT_SM,C_TEXT,230,ry+8)
tl(s,f"{rec.get('total_moves','?')} moves",FNT_XS,C_TEXT2,230,ry+26)
tl(s,rec.get('date',''),FNT_XS,C_TEXT3,WINDOW_W-215,ry+8)
moves=rec.get('moves',[]); prev=' '.join(f"{(i//2)+1}.{m}" if i%2==0 else m for i,m in enumerate(moves[:10]))
if len(moves)>10: prev+='...'
tl(s,prev,FNT_XS,C_TEXT3,50,ry+44)
if hov: tc(s,"Click to review",FNT_XS,C_GOLD,WINDOW_W-90,ry+item_h//2-4)
total=len(recs)
if total>vis:
area=WINDOW_H-170; sbh=max(20,int(vis/total*area))
sby=list_y0+int(self.hist_scroll/max(1,total-vis)*(area-sbh))
pygame.draw.rect(s,C_PANEL3,(WINDOW_W-14,list_y0,6,area))
pygame.draw.rect(s,C_TEXT2,(WINDOW_W-14,sby,6,sbh),border_radius=3)
bbx,bby,bbw,bbh=cx-80,WINDOW_H-48,160,34; hov=(bbx<=mx<=bbx+bbw and bby<=my<=bby+bbh)
rr(s,C_BTN_H if hov else C_BTN,(bbx,bby,bbw,bbh),6,1,C_BORDER)
tc(s,"< Main Menu",FNT_SM,C_TEXT,cx,bby+bbh//2)
# FIX #5 – confirm overlay
def _d_hconf(self):
s=self.surf; cx,cy=WINDOW_W//2,WINDOW_H//2; mx,my=pygame.mouse.get_pos()
ov=pygame.Surface((WINDOW_W,WINDOW_H),pygame.SRCALPHA); ov.fill((0,0,0,155)); s.blit(ov,(0,0))
rr(s,(46,44,38),(cx-185,cy-80,370,170),12,1,C_BORDER)
tc(s,"Review this game?",FNT_LG,C_TEXT,cx,cy-52)
if self.hist_confirm is not None and self.hist_recs:
rec=self.hist_recs[-(self.hist_confirm+1)]
info=f"{rec.get('result','?')} · {rec.get('total_moves','?')} moves · {rec.get('date','')}"
tc(s,info,FNT_SM,C_TEXT2,cx,cy-22)
for lbl2,bx,c in[("Review >",cx-120,C_BLUE),("Cancel",cx+10,C_BTN)]:
bw2,bh2=110,46; by=cy+18; hov=(bx<=mx<=bx+bw2 and by<=my<=by+bh2)
rr(s,tuple(min(255,v+20) for v in c) if hov else c,(bx,by,bw2,bh2),8,1,C_BORDER)
tc(s,lbl2,FNT_MDB,C_TEXT,bx+bw2//2,by+bh2//2)
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == '__main__':
Game().run()