Skip to content

Commit 0c388f2

Browse files
authored
Merge pull request #47 from PtiCalin/kw00l8-codex/enhance-dialogueengine-with-branching-and-localization
Enhance dialogue engine
2 parents 989f9a2 + d01a27e commit 0c388f2

2 files changed

Lines changed: 221 additions & 13 deletions

File tree

engine/dialogue_engine.py

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass, field
6-
from typing import Dict, List, Optional
6+
from typing import Dict, List, Optional, Any
77

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

2121
if TYPE_CHECKING: # pragma: no cover - only for type hints
2222
from .ui_overlay import UIOverlay
23+
from .locale_manager import LocaleManager
2324

2425

2526
@dataclass
@@ -30,6 +31,10 @@ class DialogueOption:
3031
next: Optional[str] = None
3132
condition: Optional[str] = None
3233
set_flag: Optional[str] = None
34+
requires_flag: Optional[str] = None
35+
clear_flag: Optional[str] = None
36+
requires_memory: Optional[str] = None
37+
set_memory: Dict[str, Any] = field(default_factory=dict)
3338

3439

3540
@dataclass
@@ -43,6 +48,10 @@ class DialogueLine:
4348
next: Optional[str] = None
4449
condition: Optional[str] = None
4550
set_flag: Optional[str] = None
51+
requires_flag: Optional[str] = None
52+
clear_flag: Optional[str] = None
53+
requires_memory: Optional[str] = None
54+
set_memory: Dict[str, Any] = field(default_factory=dict)
4655

4756

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

61-
def __init__(self, game_state: GameState, ui_overlay: Optional["UIOverlay"] = None) -> None:
70+
def __init__(
71+
self,
72+
game_state: GameState,
73+
ui_overlay: Optional["UIOverlay"] = None,
74+
locale_manager: Optional["LocaleManager"] = None,
75+
) -> None:
6276
self.game_state = game_state
6377
self.ui_overlay = ui_overlay
78+
self.locale_manager = locale_manager
6479

6580
self.dialogues: Dict[str, Dialogue] = {}
6681
self.active_dialogue_id: Optional[str] = None
6782
self.current_line_index: int = 0
6883
self.awaiting_choice: bool = False
6984
self._option_cache: List[DialogueOption] = []
70-
self._memory: Dict[str, List[str]] = {}
85+
self._memory_history: Dict[str, List[str]] = {}
86+
self._memory_store: Dict[str, Dict[str, Any]] = {}
7187
self._branch_end: bool = False
7288

7389
# ------------------------------------------------------------------
@@ -123,6 +139,10 @@ def _parse_line(self, item: Dict) -> DialogueLine:
123139
next=opt.get("next"),
124140
condition=opt.get("condition"),
125141
set_flag=opt.get("set_flag"),
142+
requires_flag=opt.get("requires_flag"),
143+
clear_flag=opt.get("clear_flag"),
144+
requires_memory=opt.get("requires_memory"),
145+
set_memory=opt.get("set_memory", {}) or {},
126146
)
127147
)
128148
return DialogueLine(
@@ -133,6 +153,10 @@ def _parse_line(self, item: Dict) -> DialogueLine:
133153
next=item.get("next"),
134154
condition=item.get("condition"),
135155
set_flag=item.get("set_flag"),
156+
requires_flag=item.get("requires_flag"),
157+
clear_flag=item.get("clear_flag"),
158+
requires_memory=item.get("requires_memory"),
159+
set_memory=item.get("set_memory", {}) or {},
136160
)
137161

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

157-
self._memory.setdefault(dialogue_id, [])
181+
self._memory_history.setdefault(dialogue_id, [])
182+
self._memory_store.setdefault(dialogue_id, {})
158183

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

199+
def _check_memory(self, expression: Optional[str]) -> bool:
200+
"""Evaluate a simple memory expression."""
201+
if not expression:
202+
return True
203+
expr = expression.strip()
204+
op = None
205+
if "==" in expr:
206+
op = "=="
207+
elif "!=" in expr:
208+
op = "!="
209+
if op:
210+
left, right = expr.split(op, 1)
211+
right = right.strip().strip("\"'")
212+
else:
213+
left, right = expr, None
214+
left = left.strip()
215+
if "." in left:
216+
dlg_id, key = left.split(".", 1)
217+
else:
218+
dlg_id, key = self.active_dialogue_id or "", left
219+
value = self._memory_store.get(dlg_id, {}).get(key)
220+
if op == "==":
221+
return str(value) == right
222+
if op == "!=":
223+
return str(value) != right
224+
return value is not None
225+
226+
def _set_memory(self, data: Dict[str, Any], dialogue_id: Optional[str] = None) -> None:
227+
dlg_id = dialogue_id or (self.active_dialogue_id or "")
228+
store = self._memory_store.setdefault(dlg_id, {})
229+
store.update({k: str(v) for k, v in (data or {}).items()})
230+
174231
def current_node(self) -> Optional[DialogueLine]:
175232
dlg = self._current_dialogue()
176233
if not dlg:
@@ -179,9 +236,17 @@ def current_node(self) -> Optional[DialogueLine]:
179236
lines = dlg.lines
180237
while self.current_line_index < len(lines):
181238
line = lines[self.current_line_index]
182-
if self.game_state.check_condition(line.condition):
183-
return line
184-
self.current_line_index += 1
239+
if not self.game_state.check_condition(line.condition):
240+
self.current_line_index += 1
241+
continue
242+
if line.requires_flag and not self.game_state.get_flag(line.requires_flag):
243+
self.current_line_index += 1
244+
continue
245+
if not self._check_memory(line.requires_memory):
246+
self.current_line_index += 1
247+
continue
248+
return line
249+
185250
return None
186251

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

218283
if node.options:
219284
self._option_cache = [
220-
opt for opt in node.options if self.game_state.check_condition(opt.condition)
285+
opt
286+
for opt in node.options
287+
if self.game_state.check_condition(opt.condition)
288+
and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag))
289+
and self._check_memory(opt.requires_memory)
221290
]
222291
self.awaiting_choice = True
223292
return node
224293

225294
if node.set_flag:
226295
self.game_state.set_flag(node.set_flag, True)
296+
if node.clear_flag:
297+
self.game_state.set_flag(node.clear_flag, False)
298+
if node.set_memory:
299+
self._set_memory(node.set_memory)
227300

228301
if node.next:
229302
return self._goto(node.next)
@@ -240,7 +313,11 @@ def advance(self) -> Optional[DialogueLine]:
240313
node = self.current_node()
241314
if node and node.options:
242315
self._option_cache = [
243-
opt for opt in node.options if self.game_state.check_condition(opt.condition)
316+
opt
317+
for opt in node.options
318+
if self.game_state.check_condition(opt.condition)
319+
and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag))
320+
and self._check_memory(opt.requires_memory)
244321
]
245322
self.awaiting_choice = True
246323
return node
@@ -254,10 +331,14 @@ def choose(self, option_index: int) -> Optional[DialogueLine]:
254331
choice = self._option_cache[option_index]
255332
if choice.set_flag:
256333
self.game_state.set_flag(choice.set_flag, True)
334+
if choice.clear_flag:
335+
self.game_state.set_flag(choice.clear_flag, False)
336+
if choice.set_memory:
337+
self._set_memory(choice.set_memory)
257338

258339
self.awaiting_choice = False
259-
mem = self._memory.setdefault(self.active_dialogue_id or "", [])
260-
mem.append(choice.text)
340+
hist = self._memory_history.setdefault(self.active_dialogue_id or "", [])
341+
hist.append(choice.text)
261342

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

299-
text = node.text or ""
380+
text = self.resolve_localized_text(node.text or "")
300381
speaker = node.speaker
301382
self.ui_overlay.draw_dialogue_box(text, speaker)
302383

303384
if self.awaiting_choice:
304-
options_text = [opt.text for opt in self._option_cache]
385+
options_text = [self.resolve_localized_text(opt.text) for opt in self._option_cache]
305386
self.ui_overlay.draw_options(options_text)
306387

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

401+
# ------------------------------------------------------------------
402+
# Convenience helpers
403+
# ------------------------------------------------------------------
404+
def resolve_localized_text(self, key: str) -> str:
405+
if self.locale_manager and self.locale_manager.has_translation(key):
406+
return self.locale_manager.translate(key)
407+
return key
408+
409+
def get_current_line(self) -> Optional[str]:
410+
node = self.current_node()
411+
if not node:
412+
return None
413+
return self.resolve_localized_text(node.text or "")
414+
415+
def next_line(self, choice_index: Optional[int] = None) -> Optional[str]:
416+
if choice_index is not None:
417+
node = self.choose(choice_index)
418+
else:
419+
node = self.advance()
420+
if not node:
421+
return None
422+
return self.resolve_localized_text(node.text or "")
423+

tests/test_dialogue_engine.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import sys
33
import json
4+
import pytest
45

56
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
67

78
from engine.dialogue_engine import DialogueEngine
89
from engine.game_state import GameState
10+
from engine.locale_manager import LocaleManager
911

1012

1113
def _write_dialogue(path):
@@ -72,3 +74,105 @@ def test_option_condition(tmp_path):
7274
assert engine.awaiting_choice is True
7375
assert len(engine._option_cache) == 1
7476
assert engine._option_cache[0].text == "I'm just passing through."
77+
78+
79+
def test_flag_injection_and_clearing(tmp_path):
80+
path = tmp_path / "dialogue.json"
81+
data = {
82+
"dialogues": [
83+
{
84+
"id": "flag_test",
85+
"lines": [
86+
{"text": "Hello", "set_flag": "met"},
87+
{"text": "Secret", "requires_flag": "met", "clear_flag": "met"},
88+
],
89+
}
90+
]
91+
}
92+
with open(path, "w") as fh:
93+
json.dump(data, fh)
94+
95+
state = GameState()
96+
engine = DialogueEngine(state)
97+
engine.load_file(str(path))
98+
engine.start("flag_test")
99+
100+
node = engine.current_node()
101+
assert node.text == "Hello"
102+
engine.advance()
103+
node = engine.current_node()
104+
assert node.text == "Secret"
105+
engine.advance()
106+
assert state.get_flag("met") is False
107+
108+
109+
def test_memory_branching(tmp_path):
110+
path = tmp_path / "dialogue.json"
111+
data = {
112+
"dialogues": [
113+
{
114+
"id": "memory_test",
115+
"lines": [
116+
{
117+
"id": "ask",
118+
"text": "Ask",
119+
"options": [
120+
{
121+
"text": "Yes",
122+
"next": "yes",
123+
"set_memory": {"last_choice": "yes"},
124+
},
125+
{
126+
"text": "No",
127+
"next": "no",
128+
"set_memory": {"last_choice": "no"},
129+
},
130+
],
131+
},
132+
{"id": "yes", "text": "Great", "next": "follow"},
133+
{"id": "no", "text": "Too bad", "next": "follow"},
134+
{"id": "follow", "requires_memory": "last_choice == no", "text": "You said no"},
135+
],
136+
}
137+
]
138+
}
139+
with open(path, "w") as fh:
140+
json.dump(data, fh)
141+
142+
state = GameState()
143+
engine = DialogueEngine(state)
144+
engine.load_file(str(path))
145+
engine.start("memory_test")
146+
147+
engine.advance()
148+
engine.choose(1) # choose "No"
149+
node = engine.current_node()
150+
assert node.text == "Too bad"
151+
engine.advance()
152+
node = engine.current_node()
153+
assert node.text == "You said no"
154+
155+
156+
def test_localized_dialogue(tmp_path):
157+
yaml = pytest.importorskip("yaml")
158+
loc_dir = tmp_path / "loc"
159+
loc_dir.mkdir()
160+
with open(loc_dir / "en.yaml", "w") as fh:
161+
fh.write("hello: 'Hello'")
162+
with open(loc_dir / "fr.yaml", "w") as fh:
163+
fh.write("hello: 'Bonjour'")
164+
165+
path = tmp_path / "dialogue.json"
166+
data = {"dialogues": [{"id": "loc", "lines": [{"text": "hello"}]}]}
167+
with open(path, "w") as fh:
168+
json.dump(data, fh)
169+
170+
lm = LocaleManager()
171+
lm.load_locales(str(loc_dir))
172+
lm.set_locale("fr")
173+
state = GameState()
174+
engine = DialogueEngine(state, locale_manager=lm)
175+
engine.load_file(str(path))
176+
engine.start("loc")
177+
node = engine.current_node()
178+
assert engine.resolve_localized_text(node.text) == "Bonjour"

0 commit comments

Comments
 (0)