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