Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 117 additions & 13 deletions engine/dialogue_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Any

try: # pragma: no cover - optional dependency for runtime
import yaml # type: ignore
Expand All @@ -20,6 +20,7 @@

if TYPE_CHECKING: # pragma: no cover - only for type hints
from .ui_overlay import UIOverlay
from .locale_manager import LocaleManager


@dataclass
Expand All @@ -30,6 +31,10 @@ class DialogueOption:
next: Optional[str] = None
condition: Optional[str] = None
set_flag: Optional[str] = None
requires_flag: Optional[str] = None
clear_flag: Optional[str] = None
requires_memory: Optional[str] = None
set_memory: Dict[str, Any] = field(default_factory=dict)


@dataclass
Expand All @@ -43,6 +48,10 @@ class DialogueLine:
next: Optional[str] = None
condition: Optional[str] = None
set_flag: Optional[str] = None
requires_flag: Optional[str] = None
clear_flag: Optional[str] = None
requires_memory: Optional[str] = None
set_memory: Dict[str, Any] = field(default_factory=dict)


@dataclass
Expand All @@ -58,16 +67,23 @@ class Dialogue:
class DialogueEngine:
"""Manage dialogue trees and render them through :class:`UIOverlay`."""

def __init__(self, game_state: GameState, ui_overlay: Optional["UIOverlay"] = None) -> None:
def __init__(
self,
game_state: GameState,
ui_overlay: Optional["UIOverlay"] = None,
locale_manager: Optional["LocaleManager"] = None,
) -> None:
self.game_state = game_state
self.ui_overlay = ui_overlay
self.locale_manager = locale_manager

self.dialogues: Dict[str, Dialogue] = {}
self.active_dialogue_id: Optional[str] = None
self.current_line_index: int = 0
self.awaiting_choice: bool = False
self._option_cache: List[DialogueOption] = []
self._memory: Dict[str, List[str]] = {}
self._memory_history: Dict[str, List[str]] = {}
self._memory_store: Dict[str, Dict[str, Any]] = {}
self._branch_end: bool = False

# ------------------------------------------------------------------
Expand Down Expand Up @@ -123,6 +139,10 @@ def _parse_line(self, item: Dict) -> DialogueLine:
next=opt.get("next"),
condition=opt.get("condition"),
set_flag=opt.get("set_flag"),
requires_flag=opt.get("requires_flag"),
clear_flag=opt.get("clear_flag"),
requires_memory=opt.get("requires_memory"),
set_memory=opt.get("set_memory", {}) or {},
)
)
return DialogueLine(
Expand All @@ -133,6 +153,10 @@ def _parse_line(self, item: Dict) -> DialogueLine:
next=item.get("next"),
condition=item.get("condition"),
set_flag=item.get("set_flag"),
requires_flag=item.get("requires_flag"),
clear_flag=item.get("clear_flag"),
requires_memory=item.get("requires_memory"),
set_memory=item.get("set_memory", {}) or {},
)

# ------------------------------------------------------------------
Expand All @@ -154,7 +178,8 @@ def start(self, dialogue_id: str) -> None:
if dlg.memory_flag:
self.game_state.set_flag(dlg.memory_flag, True)

self._memory.setdefault(dialogue_id, [])
self._memory_history.setdefault(dialogue_id, [])
self._memory_store.setdefault(dialogue_id, {})

def is_active(self) -> bool:
return self.active_dialogue_id is not None
Expand All @@ -171,6 +196,38 @@ def _current_dialogue(self) -> Optional[Dialogue]:
return self.dialogues.get(self.active_dialogue_id)
return None

def _check_memory(self, expression: Optional[str]) -> bool:
"""Evaluate a simple memory expression."""
if not expression:
return True
expr = expression.strip()
op = None
if "==" in expr:
op = "=="
elif "!=" in expr:
op = "!="
if op:
left, right = expr.split(op, 1)
right = right.strip().strip("\"'")
else:
left, right = expr, None
left = left.strip()
if "." in left:
dlg_id, key = left.split(".", 1)
else:
dlg_id, key = self.active_dialogue_id or "", left
value = self._memory_store.get(dlg_id, {}).get(key)
if op == "==":
return str(value) == right
if op == "!=":
return str(value) != right
return value is not None

def _set_memory(self, data: Dict[str, Any], dialogue_id: Optional[str] = None) -> None:
dlg_id = dialogue_id or (self.active_dialogue_id or "")
store = self._memory_store.setdefault(dlg_id, {})
store.update({k: str(v) for k, v in (data or {}).items()})

def current_node(self) -> Optional[DialogueLine]:
dlg = self._current_dialogue()
if not dlg:
Expand All @@ -179,9 +236,17 @@ def current_node(self) -> Optional[DialogueLine]:
lines = dlg.lines
while self.current_line_index < len(lines):
line = lines[self.current_line_index]
if self.game_state.check_condition(line.condition):
return line
self.current_line_index += 1
if not self.game_state.check_condition(line.condition):
self.current_line_index += 1
continue
if line.requires_flag and not self.game_state.get_flag(line.requires_flag):
self.current_line_index += 1
continue
if not self._check_memory(line.requires_memory):
self.current_line_index += 1
continue
return line

return None

# ------------------------------------------------------------------
Expand Down Expand Up @@ -217,13 +282,21 @@ def advance(self) -> Optional[DialogueLine]:

if node.options:
self._option_cache = [
opt for opt in node.options if self.game_state.check_condition(opt.condition)
opt
for opt in node.options
if self.game_state.check_condition(opt.condition)
and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag))
and self._check_memory(opt.requires_memory)
]
self.awaiting_choice = True
return node

if node.set_flag:
self.game_state.set_flag(node.set_flag, True)
if node.clear_flag:
self.game_state.set_flag(node.clear_flag, False)
if node.set_memory:
self._set_memory(node.set_memory)

if node.next:
return self._goto(node.next)
Expand All @@ -240,7 +313,11 @@ def advance(self) -> Optional[DialogueLine]:
node = self.current_node()
if node and node.options:
self._option_cache = [
opt for opt in node.options if self.game_state.check_condition(opt.condition)
opt
for opt in node.options
if self.game_state.check_condition(opt.condition)
and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag))
and self._check_memory(opt.requires_memory)
]
self.awaiting_choice = True
return node
Expand All @@ -254,10 +331,14 @@ def choose(self, option_index: int) -> Optional[DialogueLine]:
choice = self._option_cache[option_index]
if choice.set_flag:
self.game_state.set_flag(choice.set_flag, True)
if choice.clear_flag:
self.game_state.set_flag(choice.clear_flag, False)
if choice.set_memory:
self._set_memory(choice.set_memory)

self.awaiting_choice = False
mem = self._memory.setdefault(self.active_dialogue_id or "", [])
mem.append(choice.text)
hist = self._memory_history.setdefault(self.active_dialogue_id or "", [])
hist.append(choice.text)

if choice.next:
return self._goto(choice.next)
Expand Down Expand Up @@ -296,12 +377,12 @@ def render(self, surface: "pygame.Surface") -> None: # pragma: no cover - UI on
if not node:
return

text = node.text or ""
text = self.resolve_localized_text(node.text or "")
speaker = node.speaker
self.ui_overlay.draw_dialogue_box(text, speaker)

if self.awaiting_choice:
options_text = [opt.text for opt in self._option_cache]
options_text = [self.resolve_localized_text(opt.text) for opt in self._option_cache]
self.ui_overlay.draw_options(options_text)

# Backwards compatibility -------------------------------------------------
Expand All @@ -317,3 +398,26 @@ def handle_input(self, event: "pygame.event.Event") -> None: # pragma: no cover
elif event.key == pygame.K_SPACE:
self.advance()

# ------------------------------------------------------------------
# Convenience helpers
# ------------------------------------------------------------------
def resolve_localized_text(self, key: str) -> str:
if self.locale_manager and self.locale_manager.has_translation(key):
return self.locale_manager.translate(key)
return key

def get_current_line(self) -> Optional[str]:
node = self.current_node()
if not node:
return None
return self.resolve_localized_text(node.text or "")

def next_line(self, choice_index: Optional[int] = None) -> Optional[str]:
if choice_index is not None:
node = self.choose(choice_index)
else:
node = self.advance()
if not node:
return None
return self.resolve_localized_text(node.text or "")

40 changes: 40 additions & 0 deletions engine/rpg/campaign_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
Campaign Manager for DnD-style RPG
"""
class Campaign:
def __init__(self, title, description, scenes=None, npcs=None, quests=None):
self.title = title
self.description = description
self.scenes = scenes or []
self.npcs = npcs or []
self.quests = quests or []

def add_scene(self, scene):
self.scenes.append(scene)

def add_npc(self, npc):
self.npcs.append(npc)

def add_quest(self, quest):
self.quests.append(quest)

def __repr__(self):
return f"<Campaign {self.title} ({len(self.scenes)} scenes)>"

class CampaignManager:
def __init__(self):
self.campaigns = []

def create_campaign(self, title, description, **kwargs):
campaign = Campaign(title, description, **kwargs)
self.campaigns.append(campaign)
return campaign

def get_campaign(self, title):
for campaign in self.campaigns:
if campaign.title == title:
return campaign
return None

def list_campaigns(self):
return self.campaigns
51 changes: 51 additions & 0 deletions engine/rpg/character_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
DnD-style Character Manager for PtiCalin Game Engine
"""

class Character:
def __init__(self, name, race, char_class, level=1, stats=None, inventory=None, spells=None, quests=None):
self.name = name
self.race = race
self.char_class = char_class
self.level = level
self.stats = stats or {
'STR': 10, 'DEX': 10, 'CON': 10, 'INT': 10, 'WIS': 10, 'CHA': 10
}
self.inventory = inventory or []
self.spells = spells or []
self.quests = quests or []
self.hp = self.max_hp()

def max_hp(self):
# Example: 10 + CON modifier per level
return 10 + (self.stats['CON'] - 10) // 2 * self.level

def add_item(self, item):
self.inventory.append(item)

def add_spell(self, spell):
self.spells.append(spell)

def add_quest(self, quest):
self.quests.append(quest)

def __repr__(self):
return f"<Character {self.name} ({self.char_class} Lv{self.level})>"

class CharacterManager:
def __init__(self):
self.characters = []

def create_character(self, name, race, char_class, **kwargs):
char = Character(name, race, char_class, **kwargs)
self.characters.append(char)
return char

def get_character(self, name):
for char in self.characters:
if char.name == name:
return char
return None

def list_characters(self):
return self.characters
28 changes: 28 additions & 0 deletions engine/rpg/combat_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Simple DnD-style Combat Engine
"""
from .character_manager import Character
from .dice_roller import roll_dice

class CombatEngine:
def __init__(self, party, enemies):
self.party = party # list of Character
self.enemies = enemies # list of Character
self.turn_order = self.determine_initiative()

def determine_initiative(self):
# Sort by DEX stat, descending
all_combatants = self.party + self.enemies
return sorted(all_combatants, key=lambda c: c.stats['DEX'], reverse=True)

def attack(self, attacker, defender):
# Roll d20 + STR for melee, DEX for ranged
attack_roll, _, _ = roll_dice('1d20')
attack_mod = attacker.stats['STR'] if attacker.char_class in ['Fighter', 'Barbarian'] else attacker.stats['DEX']
total_attack = attack_roll + (attack_mod - 10) // 2
# Simple AC check
if total_attack >= defender.stats.get('AC', 10):
damage, _, _ = roll_dice('1d8')
defender.hp -= damage
return True, damage
return False, 0
20 changes: 20 additions & 0 deletions engine/rpg/dice_roller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Dice Roller for DnD-style RPG
"""
import random

def roll_dice(dice_str):
"""
Roll dice in NdM format, e.g. '2d6+1'
Returns total and breakdown.
"""
import re
match = re.match(r"(\d+)d(\d+)([+-]\d+)?", dice_str)
if not match:
raise ValueError("Invalid dice format")
n, m, mod = match.groups()
n, m = int(n), int(m)
mod = int(mod) if mod else 0
rolls = [random.randint(1, m) for _ in range(n)]
total = sum(rolls) + mod
return total, rolls, mod
Loading
Loading