nodes/dude.pyΒΆ

Part of Casual Crusade.

 1"""Dude: pilgrim avatar that walks the path of placed cards.
 2
 3Drawn procedurally in `on_draw` (no spritesheet). Rendered as a small body +
 4head shape that bobs while walking.
 5"""
 6
 7from __future__ import annotations
 8
 9import math
10
11from simvx.core import Node2D
12from simvx.core.math.types import Vec2
13
14from .constants import TILE_HEIGHT, TILE_WIDTH
15
16
17class Dude(Node2D):
18    """The pilgrim. ``position`` matches the top-left of the tile it stands on."""
19
20    # Continuous animation: the walk-bob phase animates the robe/head every frame.
21    dynamic = True
22
23    def __init__(self, x: float, y: float):
24        super().__init__(name="Dude")
25        self.position = Vec2(x, y)
26        self._phase = 0.0
27        self._hop = 0.0
28        self._time = 0.0
29
30    def on_update(self, dt: float) -> None:
31        self._time += dt
32        self._phase = abs(math.sin(self._time * 4.0))
33
34    def hop_to(self, target: Vec2, duration: float = 0.3):
35        """Coroutine: tween position to target with a vertical hop."""
36        start = Vec2(self.position.x, self.position.y)
37        elapsed = 0.0
38        while elapsed < duration:
39            t = elapsed / duration
40            self.position = Vec2(
41                start.x + (target.x - start.x) * t,
42                start.y + (target.y - start.y) * t,
43            )
44            self._hop = math.sin(t * math.pi) * 18.0
45            dt = yield
46            elapsed += dt or 0.0
47        self.position = target
48        self._hop = 0.0
49
50    def on_draw(self, renderer) -> None:
51        if not self.visible:
52            return
53        cx = self.position.x + TILE_WIDTH / 2
54        cy = self.position.y + TILE_HEIGHT / 2
55        # The pilgrim sits ABOVE the card centre; offset up by half-card-height.
56        feet_y = cy + 8 - self._hop
57        head_y = feet_y - 38 - 4 * self._phase
58        # Shadow ellipse
59        renderer.draw_circle((cx, feet_y + 12), 18, colour=(0, 0, 0, 0.30), filled=True, segments=20)
60        # Robe (filled triangle approximated as horizontal strips)
61        top_y = feet_y - 32 - 4 * self._phase
62        bot_y = feet_y + 8
63        steps = 22
64        for s in range(steps):
65            t = s / max(1, steps - 1)
66            yy = top_y + (bot_y - top_y) * t
67            half_w = 2 + t * 13
68            renderer.draw_rect(
69                (cx - half_w, yy - 1.5),
70                (half_w * 2, 3),
71                colour=(1.0, 1.0, 1.0, 1.0),
72                filled=True,
73            )
74        # Robe outline (left/right slanting line + bottom)
75        renderer.draw_line((cx, top_y), (cx - 15, bot_y), colour=(0, 0, 0, 1))
76        renderer.draw_line((cx, top_y), (cx + 15, bot_y), colour=(0, 0, 0, 1))
77        renderer.draw_line((cx - 15, bot_y), (cx + 15, bot_y), colour=(0, 0, 0, 1))
78        # Head: black ring + white face
79        renderer.draw_circle((cx, head_y), 12, colour=(0, 0, 0, 1), filled=True, segments=20)
80        renderer.draw_circle((cx, head_y), 8, colour=(1, 1, 1, 1), filled=True, segments=20)
81        # Eyes
82        renderer.draw_rect((cx - 4, head_y - 1), (1.6, 3), colour=(0, 0, 0, 1), filled=True)
83        renderer.draw_rect((cx + 2.4, head_y - 1), (1.6, 3), colour=(0, 0, 0, 1), filled=True)