f0e30f56ed
Text.append() behandelte [red]..[/] als Literaltext. Text.from_markup() parst Rich-Markup-Tags korrekt.
245 lines
9.0 KiB
Python
245 lines
9.0 KiB
Python
"""Fortschritts-Quest — Automatisches RPG mit lokalem LLM."""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import random
|
|
import signal
|
|
|
|
# Add src to path
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from rich.console import Console
|
|
from rich.live import Live
|
|
from rich.layout import Layout
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
from rich.progress import Progress, BarColumn, TextColumn
|
|
from rich.align import Align
|
|
from rich import box
|
|
|
|
from character import Character, RACES, CLASSES
|
|
from engine import GameEngine
|
|
from llm import NameCache
|
|
|
|
console = Console()
|
|
SAVE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "saves")
|
|
os.makedirs(SAVE_DIR, exist_ok=True)
|
|
|
|
BANNER = """
|
|
[bold magenta]╔═══════════════════════════════════════════════════════════╗
|
|
║ FORTSCHRITTS-QUEST — Das automatische RPG ║
|
|
║ "Wo du nichts tust und alles passiert" ║
|
|
╚═══════════════════════════════════════════════════════════╝[/]
|
|
"""
|
|
|
|
def create_character():
|
|
"""Character-Erstellung mit Zufallsoption."""
|
|
console.clear()
|
|
console.print(BANNER)
|
|
console.print("[bold]Character-Erstellung[/]\n")
|
|
|
|
# Name
|
|
name = console.input("[cyan]Name deines Helden (oder Enter für Zufall): [/]").strip()
|
|
if not name:
|
|
name = random.choice(["Günther", "Brigitte", "Klaus-Dieter", "Waltraud", "Herbert", "Sieglinde", "Wolfgang", "Margot"])
|
|
console.print(f"[dim]Zufälliger Name: {name}[/]")
|
|
|
|
# Race
|
|
console.print("\n[bold]Verfügbare Rassen:[/]")
|
|
for i, r in enumerate(RACES):
|
|
console.print(f" [cyan]{i+1}[/] — {r}")
|
|
race_choice = console.input(f"\n[cyan]Rasse wählen (1-{len(RACES)}, Enter=Zufall): [/]").strip()
|
|
if race_choice.isdigit() and 1 <= int(race_choice) <= len(RACES):
|
|
race = RACES[int(race_choice) - 1]
|
|
else:
|
|
race = random.choice(RACES)
|
|
console.print(f"[dim]Zufällige Rasse: {race}[/]")
|
|
|
|
# Class
|
|
console.print("\n[bold]Verfügbare Klassen:[/]")
|
|
for i, c in enumerate(CLASSES):
|
|
console.print(f" [cyan]{i+1}[/] — {c}")
|
|
class_choice = console.input(f"\n[cyan]Klasse wählen (1-{len(CLASSES)}, Enter=Zufall): [/]").strip()
|
|
if class_choice.isdigit() and 1 <= int(class_choice) <= len(CLASSES):
|
|
char_class = CLASSES[int(class_choice) - 1]
|
|
else:
|
|
char_class = random.choice(CLASSES)
|
|
console.print(f"[dim]Zufällige Klasse: {char_class}[/]")
|
|
|
|
char = Character(name, race, char_class)
|
|
|
|
# Show summary
|
|
console.print(f"\n[bold green]✅ Character erstellt![/]")
|
|
console.print(f" Name: [bold]{char.name}[/]")
|
|
console.print(f" Rasse: {char.race}")
|
|
console.print(f" Klasse: {char.char_class}")
|
|
console.print(f" Stufe: {char.level}")
|
|
console.print(f" HP: {char.hp}/{char.max_hp} | MP: {char.mp}/{char.max_mp}")
|
|
console.print(f" STR: {char.str} | DEX: {char.dex} | INT: {char.int} | VIT: {char.vit} | LCK: {char.lck}")
|
|
console.print(f" Gold: {char.gold}")
|
|
console.input("\n[dim]Enter drücken um zu starten...[/]")
|
|
|
|
return char
|
|
|
|
|
|
def render_game(engine: GameEngine) -> Panel:
|
|
"""Rendert das komplette Spiel-UI."""
|
|
c = engine.character
|
|
|
|
# === Character Panel ===
|
|
char_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1))
|
|
char_table.add_column("Stat", style="cyan", width=8)
|
|
char_table.add_column("Value", style="bold")
|
|
|
|
char_table.add_row("Name", f"{c.name}")
|
|
char_table.add_row("Rasse", c.race)
|
|
char_table.add_row("Klasse", c.char_class)
|
|
char_table.add_row("Stufe", str(c.level))
|
|
char_table.add_row("XP", f"{c.xp}/{c.xp_to_next}")
|
|
char_table.add_row("HP", f"{c.hp}/{c.max_hp}")
|
|
char_table.add_row("MP", f"{c.mp}/{c.max_mp}")
|
|
char_table.add_row("Angriff", str(c.attack))
|
|
char_table.add_row("Vert.", str(c.defense))
|
|
char_table.add_row("Gold", f"{c.gold} 💰")
|
|
char_table.add_row("Kills", str(c.kills))
|
|
char_table.add_row("Tode", str(c.deaths))
|
|
char_table.add_row("Quests", str(c.quests_done))
|
|
|
|
# === Equipment Panel ===
|
|
equip_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 1), title="[bold]Ausrüstung[/]")
|
|
equip_table.add_column("Slot", style="cyan", width=10)
|
|
equip_table.add_column("Item")
|
|
|
|
slot_names = {"weapon": "Waffe", "armor": "Rüstung", "helmet": "Helm", "boots": "Stiefel", "accessory": "Accessoire"}
|
|
for slot, name in slot_names.items():
|
|
item = c.equipped[slot]
|
|
equip_table.add_row(name, str(item) if item else "[dim]leer[/]")
|
|
|
|
# === Quest Panel ===
|
|
quest_text = Text()
|
|
quest_text.append(f"📜 {engine.current_quest}\n", style="cyan")
|
|
quest_text.append(f"Fortschritt: {engine.quest_progress}/{engine.quest_target}", style="yellow")
|
|
|
|
# === XP Bar ===
|
|
xp_pct = c.xp / c.xp_to_next
|
|
xp_bar_len = 20
|
|
xp_filled = int(xp_pct * xp_bar_len)
|
|
xp_bar = "█" * xp_filled + "░" * (xp_bar_len - xp_filled)
|
|
|
|
# HP Bar
|
|
hp_pct = c.hp / c.max_hp
|
|
hp_filled = int(hp_pct * xp_bar_len)
|
|
hp_bar = "█" * hp_filled + "░" * (xp_bar_len - hp_filled)
|
|
|
|
# === Log Panel ===
|
|
log_text = Text()
|
|
for entry in engine.log[-12:]:
|
|
if isinstance(entry, str):
|
|
t = Text.from_markup(entry)
|
|
log_text.append(t)
|
|
log_text.append("\n")
|
|
|
|
# === Location ===
|
|
loc_text = f"🌍 {engine.current_location}"
|
|
|
|
# === Layout ===
|
|
layout = Layout()
|
|
|
|
# Top: Title bar
|
|
layout.split_column(
|
|
Layout(Panel(f"[bold magenta]FORTSCHRITTS-QUEST[/] — {c.name} der {c.race} {c.char_class}",
|
|
style="magenta"), size=3),
|
|
Layout(name="main"),
|
|
Layout(name="bottom", size=8),
|
|
)
|
|
|
|
# Main: left=char/equip, center=log, right=quest/stats
|
|
layout["main"].split_row(
|
|
Layout(Panel(char_table, title="[bold]Character[/]"), name="left", size=30),
|
|
Layout(Panel(log_text, title="[bold]Ereignis-Log[/]"), name="center"),
|
|
Layout(name="right", size=30),
|
|
)
|
|
|
|
layout["right"].split_column(
|
|
Layout(Panel(equip_table), name="equip"),
|
|
Layout(Panel(quest_text, title="[bold]Aktuelle Quest[/]"), name="quest"),
|
|
)
|
|
|
|
# Bottom: bars + location
|
|
bars = Text()
|
|
bars.append(f"HP [{hp_bar}] {c.hp}/{c.max_hp}\n", style="red")
|
|
bars.append(f"XP [{xp_bar}] {c.xp}/{c.xp_to_next}\n", style="green")
|
|
bars.append(f"{loc_text} | Schritte: {c.steps} | STR {c.str} DEX {c.dex} INT {c.int} VIT {c.vit} LCK {c.lck}")
|
|
|
|
layout["bottom"].update(Panel(bars, style="dim"))
|
|
|
|
return layout
|
|
|
|
|
|
def run_game(engine: GameEngine, save_path: str):
|
|
"""Haupt-Game-Loop mit Live-Rendering."""
|
|
auto_save_counter = 0
|
|
|
|
with Live(render_game(engine), console=console, refresh_per_second=2, screen=True) as live:
|
|
try:
|
|
while True:
|
|
# Game step
|
|
result = engine.step()
|
|
|
|
# Refill cache occasionally
|
|
if engine.character.steps % 5 == 0:
|
|
engine.cache.refill_async()
|
|
|
|
# Sell junk periodically
|
|
if engine.character.steps % 10 == 0:
|
|
engine._sell_junk()
|
|
|
|
# Auto-save every 50 steps
|
|
auto_save_counter += 1
|
|
if auto_save_counter >= 50:
|
|
engine.save(save_path)
|
|
auto_save_counter = 0
|
|
|
|
# Render
|
|
live.update(render_game(engine))
|
|
|
|
# Speed — slower for readability, faster at higher levels
|
|
speed = max(0.3, 1.5 - engine.character.level * 0.02)
|
|
time.sleep(speed)
|
|
|
|
except KeyboardInterrupt:
|
|
live.update(render_game(engine))
|
|
console.print("\n[bold yellow]Spiel pausiert.[/]")
|
|
engine.save(save_path)
|
|
console.print(f"[green]Spielstand gespeichert: {save_path}[/]")
|
|
console.print("[dim]Drücke Enter zum Beenden...[/]")
|
|
input()
|
|
|
|
|
|
def main():
|
|
console.clear()
|
|
console.print(BANNER)
|
|
|
|
save_path = os.path.join(SAVE_DIR, "savegame.json")
|
|
|
|
# Check for existing save
|
|
if os.path.exists(save_path):
|
|
choice = console.input("[cyan]Spielstand gefunden. Fortsetzen? (j/n): [/]").strip().lower()
|
|
if choice in ("j", "ja", "y", "yes", ""):
|
|
console.print("[green]Lade Spielstand...[/]")
|
|
engine = GameEngine.load(save_path)
|
|
console.print(f"[dim]Willkommen zurück, {engine.character.name} (Stufe {engine.character.level})![/]")
|
|
time.sleep(1)
|
|
run_game(engine, save_path)
|
|
return
|
|
|
|
# New game
|
|
char = create_character()
|
|
engine = GameEngine(char)
|
|
run_game(engine, save_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |