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)