nodes/dialogue.pyΒΆ
Part of GDQuest Open RPG.
1"""Dialogue box: bottom panel with typewriter text + advance-on-key.
2
3An immediate-mode Node2D overlay: the panel is painted straight into the 2D
4draw stream each frame from the live viewport size, so it needs no widget tree
5and follows the window (or browser canvas) when it resizes.
6"""
7
8from __future__ import annotations
9
10from simvx.core import Node2D
11from simvx.core.input.state import Input
12from simvx.core.signals import Signal
13
14from .layout import viewport_size
15from .settings import (
16 PANEL,
17 PANEL_BORDER,
18 TEXT,
19 TEXT_DIM,
20)
21
22
23class DialogueBox(Node2D):
24 """Bottom-anchored dialogue panel with typewriter effect."""
25
26 PANEL_HEIGHT = 130
27 SIDE_MARGIN = 32
28 BOTTOM_MARGIN = 36 # clears the controls strip
29 CHARS_PER_SEC = 70.0
30
31 def __init__(self) -> None:
32 super().__init__()
33 self.z_index = 5000
34
35 self.finished = Signal() # fires when dialogue sequence ends
36
37 self._lines: list[tuple[str, str]] = []
38 self._index = 0
39 self._reveal_idx = 0.0
40 self._waiting_for_advance = False
41 self._just_advanced_cooldown = 0.0
42 self._showing = False
43
44 # ------------------------------------------------------------------
45 # Public API
46 # ------------------------------------------------------------------
47 def show_lines(self, lines: list[tuple[str, str]]) -> None:
48 if not lines:
49 self.finished.emit()
50 return
51 self._lines = list(lines)
52 self._index = 0
53 self._reveal_idx = 0.0
54 self._waiting_for_advance = False
55 self._just_advanced_cooldown = 0.20
56 self._showing = True
57
58 @property
59 def is_showing(self) -> bool:
60 return self._showing
61
62 # ------------------------------------------------------------------
63 # Process: typewriter + advance
64 # ------------------------------------------------------------------
65 def on_update(self, dt: float) -> None:
66 if not self._showing or not self._lines:
67 return
68 # Persistent overlay added once: the typewriter reveal (`_reveal_idx`) and the
69 # advance arrow animate every frame from non-Property state, so dirty it each
70 # showing frame (it emits 0 ops while hidden, so `dynamic` could miss the first
71 # 0->N upload without a structure change).
72 self.queue_redraw()
73 if self._just_advanced_cooldown > 0:
74 self._just_advanced_cooldown -= dt
75 speaker, text = self._lines[self._index]
76 full_len = len(text)
77 if not self._waiting_for_advance:
78 self._reveal_idx += dt * self.CHARS_PER_SEC
79 if self._reveal_idx >= full_len:
80 self._reveal_idx = float(full_len)
81 self._waiting_for_advance = True
82
83 # Advance on confirm. While typewriting, fast-forward to full text.
84 if self._just_advanced_cooldown <= 0 and (
85 Input.is_action_just_pressed("confirm") or Input.is_action_just_pressed("primary")
86 ):
87 if not self._waiting_for_advance:
88 self._reveal_idx = float(full_len)
89 self._waiting_for_advance = True
90 else:
91 self._index += 1
92 self._reveal_idx = 0.0
93 self._waiting_for_advance = False
94 self._just_advanced_cooldown = 0.10
95 if self._index >= len(self._lines):
96 self._lines = []
97 self._showing = False
98 self.finished.emit()
99
100 # ------------------------------------------------------------------
101 # Draw the dialogue panel
102 # ------------------------------------------------------------------
103 def on_draw(self, renderer) -> None:
104 if not self._showing or not self._lines:
105 return
106 ww, wh = viewport_size(self)
107 x = self.SIDE_MARGIN
108 y = wh - self.BOTTOM_MARGIN - self.PANEL_HEIGHT
109 w = ww - 2 * self.SIDE_MARGIN
110 h = self.PANEL_HEIGHT
111
112 # Drop shadow
113 renderer.draw_rect((x + 4, y + 4), (w, h), colour=(0, 0, 0, 0.40), filled=True)
114 # Panel body
115 renderer.draw_rect((x, y), (w, h), colour=PANEL, filled=True)
116 # Border (3 line widths)
117 for k in range(3):
118 renderer.draw_rect((x - k, y - k), (w + 2 * k, h + 2 * k), colour=PANEL_BORDER, filled=False)
119
120 speaker, text = self._lines[self._index]
121 # Speaker label tab: top-left
122 if speaker:
123 tab_w = max(80, int(renderer.text_width(speaker, 1.4)) + 24)
124 renderer.draw_rect((x + 12, y - 18), (tab_w, 24), colour=PANEL_BORDER, filled=True)
125 renderer.draw_rect((x + 12, y - 18), (tab_w, 24), colour=(0, 0, 0, 1), filled=False)
126 renderer.draw_text(speaker, (x + 24, y - 14), colour=(0.10, 0.10, 0.16, 1.0), scale=1.4)
127
128 # Body text: typewriter reveal
129 revealed = text[: int(self._reveal_idx)]
130 renderer.draw_text(revealed, (x + 24, y + 24), colour=TEXT, scale=1.4)
131
132 # Advance indicator
133 if self._waiting_for_advance:
134 arrow_x = x + w - 32
135 arrow_y = y + h - 28
136 renderer.draw_text("v", (arrow_x, arrow_y), colour=TEXT_DIM, scale=1.4)