nodes/village.py¶

Part of Dungeon Explorer.

  1"""Tile-based village: replaces the flat brown town."""
  2
  3from simvx.core import Input, Node2D, Vec2
  4
  5from .town_npc import TownNPC
  6
  7# Village dimensions
  8VILLAGE_W = 960
  9VILLAGE_H = 640
 10TILE = 32
 11COLS = VILLAGE_W // TILE  # 30
 12ROWS = VILLAGE_H // TILE  # 20
 13
 14# Dungeon entrance: bottom-centre, just inside the hedge border
 15ENTRANCE_POS = Vec2(VILLAGE_W // 2, (ROWS - 2) * TILE + TILE // 2)
 16ENTRANCE_RANGE = 36.0
 17
 18# Tile types
 19GRASS = 0
 20DIRT = 1
 21STONE = 2
 22HEDGE = 3
 23
 24# Tile colours
 25_TILE_COLOURS = {
 26    GRASS: (0.22, 0.38, 0.18, 1.0),
 27    DIRT: (0.35, 0.28, 0.18, 1.0),
 28    STONE: (0.40, 0.38, 0.36, 1.0),
 29    HEDGE: (0.12, 0.30, 0.10, 1.0),
 30}
 31
 32
 33def _build_village_grid() -> list[list[int]]:
 34    """Build the hard-coded village tile grid."""
 35    grid = [[GRASS] * COLS for _ in range(ROWS)]
 36
 37    # Hedge border
 38    for x in range(COLS):
 39        grid[0][x] = HEDGE
 40        grid[ROWS - 1][x] = HEDGE
 41    for y in range(ROWS):
 42        grid[y][0] = HEDGE
 43        grid[y][COLS - 1] = HEDGE
 44
 45    # Dirt paths (cross pattern connecting buildings)
 46    mid_x = COLS // 2
 47    mid_y = ROWS // 2
 48    for x in range(1, COLS - 1):
 49        grid[mid_y][x] = DIRT
 50        grid[mid_y - 1][x] = DIRT
 51    for y in range(1, ROWS - 1):
 52        grid[y][mid_x] = DIRT
 53        grid[y][mid_x - 1] = DIRT
 54
 55    # Stone buildings in each quadrant
 56    buildings = [
 57        (3, 3, 6, 4),  # Top-left: Shop
 58        (21, 3, 6, 4),  # Top-right: Inn
 59        (3, 13, 6, 4),  # Bottom-left: Blacksmith
 60        (21, 13, 6, 4),  # Bottom-right: Well Keeper
 61    ]
 62    for bx, by, bw, bh in buildings:
 63        for dy in range(bh):
 64            for dx in range(bw):
 65                grid[by + dy][bx + dx] = STONE
 66
 67    # Well in centre
 68    grid[mid_y + 1][mid_x + 1] = STONE
 69    grid[mid_y + 1][mid_x] = STONE
 70
 71    return grid
 72
 73
 74class VillageScene(Node2D):
 75    """Tile-based village hub with NPCs. Same API as TownScene."""
 76
 77    # Transient hub scene: rebuilt on every town visit. Excluded from saves so
 78    # its subtree paths are never required when a save is re-applied.
 79    __save_persist__ = False
 80
 81    def __init__(self, player, on_interact=None, **kwargs):
 82        super().__init__(name="TownScene", **kwargs)
 83        self._player = player
 84        self._on_interact = on_interact
 85        self._npcs: list[TownNPC] = []
 86        self._grid = _build_village_grid()
 87        self._prev_pos: Vec2 | None = None
 88        # on_draw shows an "[E] Enter" prompt only when the player is near the
 89        # dungeon entrance. The static tilemap stays retained; we re-dirty just
 90        # on the discrete near/far flip (clamp_player runs every town frame).
 91        self._was_near_entrance = False
 92
 93    def on_ready(self):
 94        # Position NPCs just outside their building doorways (south side)
 95        npc_defs = [
 96            ("shopkeeper", "Shopkeeper", Vec2(6 * TILE, 7 * TILE + 12), (0.2, 0.7, 0.3, 1.0)),
 97            ("innkeeper", "Innkeeper", Vec2(24 * TILE, 7 * TILE + 12), (0.7, 0.4, 0.2, 1.0)),
 98            ("blacksmith", "Blacksmith", Vec2(6 * TILE, 17 * TILE + 12), (0.5, 0.5, 0.6, 1.0)),
 99            ("well_keeper", "Well Keeper", Vec2(24 * TILE, 17 * TILE + 12), (0.3, 0.5, 0.8, 1.0)),
100            # Quest Board NPC
101            ("quest_board", "Quest Board", Vec2(15 * TILE, 5 * TILE), (0.8, 0.7, 0.3, 1.0)),
102            # Enchanter NPC
103            ("enchanter", "Enchanter", Vec2(15 * TILE, 15 * TILE), (0.6, 0.3, 0.8, 1.0)),
104        ]
105        for npc_id, name, pos, colour in npc_defs:
106            npc = TownNPC(npc_id=npc_id, name=name)
107            npc.display_name = name
108            npc.position = pos
109            npc._colour = colour
110            npc.interact_requested.connect(self._npc_interact)
111            self.add_child(npc)
112            self._npcs.append(npc)
113
114        # Training dummy
115        self._training_dummy = TrainingDummy(Vec2(15 * TILE, 12 * TILE))
116        self.add_child(self._training_dummy)
117
118    def _npc_interact(self, npc_id: str):
119        if self._on_interact:
120            self._on_interact(npc_id)
121
122    def check_interactions(self):
123        """Check if player is near an NPC and E is pressed."""
124        if not Input.just_pressed("interact"):
125            return
126        for npc in self._npcs:
127            if npc.can_interact(self._player.position):
128                npc.interact()
129                return
130
131    def is_near_dungeon_entrance(self) -> bool:
132        """Return True if the player is close enough to the dungeon entrance."""
133        dx = float(self._player.position.x) - ENTRANCE_POS.x
134        dy = float(self._player.position.y) - ENTRANCE_POS.y
135        return dx * dx + dy * dy < ENTRANCE_RANGE * ENTRANCE_RANGE
136
137    def is_blocked(self, gx: int, gy: int) -> bool:
138        """Check if a grid cell blocks movement."""
139        if 0 <= gx < COLS and 0 <= gy < ROWS:
140            return self._grid[gy][gx] in (STONE, HEDGE)
141        return True
142
143    def clamp_player(self):
144        """Per-axis tile collision: resolve X and Y independently."""
145        px, py = float(self._player.position.x), float(self._player.position.y)
146
147        # Clamp to world bounds first
148        px = max(TILE, min(VILLAGE_W - TILE, px))
149        py = max(TILE, min(VILLAGE_H - TILE, py))
150
151        if self._prev_pos is not None:
152            ox, oy = float(self._prev_pos.x), float(self._prev_pos.y)
153            # Per-axis collision against blocked tiles
154            gx_new = int(px / TILE)
155            gy_old = int(oy / TILE)
156            if self.is_blocked(gx_new, gy_old):
157                px = ox  # Revert X movement
158
159            gx_cur = int(px / TILE)
160            gy_new = int(py / TILE)
161            if self.is_blocked(gx_cur, gy_new):
162                py = oy  # Revert Y movement
163
164        self._player.position = Vec2(px, py)
165        self._prev_pos = Vec2(px, py)
166
167        # Re-dirty only when the entrance prompt's visibility actually toggles.
168        near = self.is_near_dungeon_entrance()
169        if near != self._was_near_entrance:
170            self._was_near_entrance = near
171            self.queue_redraw()
172
173    def on_draw(self, renderer):
174        for y in range(ROWS):
175            for x in range(COLS):
176                tile = self._grid[y][x]
177                base = _TILE_COLOURS[tile]
178                # Seeded variation +/-5%
179                v = ((x * 7 + y * 13) % 100) / 100.0 * 0.1 - 0.05
180                colour = (
181                    max(0.0, min(1.0, base[0] + v)),
182                    max(0.0, min(1.0, base[1] + v)),
183                    max(0.0, min(1.0, base[2] + v)),
184                    1.0,
185                )
186                renderer.rect((x * TILE, y * TILE), (TILE, TILE), fill=colour)
187
188                # Tile detail patterns
189                _draw_tile_detail(renderer, x, y, tile)
190
191        # Village building visuals
192        _draw_buildings(renderer)
193
194        # Dungeon entrance marker
195        ex, ey = float(ENTRANCE_POS.x), float(ENTRANCE_POS.y)
196        renderer.rect((ex - 16, ey - 16), (32, 32), fill=(0.35, 0.15, 0.1, 1.0))
197        renderer.rect((ex - 12, ey - 8), (24, 20), fill=(0.15, 0.05, 0.0, 1.0))
198        renderer.text("DUNGEON", (ex - 26, ey - 30), fill=(0.9, 0.4, 0.3), scale=0.9)
199        if self.is_near_dungeon_entrance():
200            renderer.text("[E] Enter", (ex - 26, ey + 20), fill=(1.0, 0.9, 0.3), scale=0.8)
201
202        renderer.text("VILLAGE", (VILLAGE_W // 2 - 40, 40), fill=(0.9, 0.8, 0.5, 1.0), scale=2.0)
203
204
205# ============================================================================
206# Village building visuals
207# ============================================================================
208
209# Building definitions: (grid_x, grid_y, grid_w, grid_h, label)
210_BUILDINGS = [
211    (3, 3, 6, 4, "Shop"),
212    (21, 3, 6, 4, "Inn"),
213    (3, 13, 6, 4, "Blacksmith"),
214    (21, 13, 6, 4, "Well Keeper"),
215]
216
217
218def _draw_buildings(renderer) -> None:
219    """Draw peaked roofs, doors, chimneys, and building-specific features."""
220    for bx, by, bw, bh, label in _BUILDINGS:
221        px, py = bx * TILE, by * TILE
222        pw, ph = bw * TILE, bh * TILE
223
224        # Peaked roof: triangle above the building
225        roof_y = py - 16
226        mid_x = px + pw // 2
227        renderer.polygon([(mid_x, roof_y), (px - 4, py + 2), (px + pw + 4, py + 2)], fill=(0.45, 0.2, 0.12, 0.9))
228        # Roof outline
229        renderer.line((mid_x, roof_y), (px - 4, py + 2), stroke=(0.3, 0.12, 0.06, 0.8))
230        renderer.line((mid_x, roof_y), (px + pw + 4, py + 2), stroke=(0.3, 0.12, 0.06, 0.8))
231
232        # Door: centred on south side
233        door_x = px + pw // 2 - 6
234        door_y = py + ph - 16
235        renderer.rect((door_x, door_y), (12, 16), fill=(0.25, 0.12, 0.06, 0.9))
236        # Door handle
237        renderer.circle((door_x + 9, door_y + 8), 1, fill=(0.7, 0.6, 0.3, 0.8))
238
239        # Chimney: top-right
240        chim_x = px + pw - 14
241        chim_y = roof_y - 4
242        renderer.rect((chim_x, chim_y), (8, 20), fill=(0.35, 0.28, 0.22, 0.9))
243
244        # Label
245        renderer.text(label, (px + 4, py - 28), fill=(0.9, 0.85, 0.7), scale=0.8)
246
247    # Blacksmith: anvil (near bottom-left building)
248    anvil_x, anvil_y = 5 * TILE + 8, 17 * TILE + 8
249    renderer.rect((anvil_x, anvil_y), (14, 6), fill=(0.25, 0.25, 0.28, 0.9))
250    renderer.rect((anvil_x + 2, anvil_y - 4), (10, 4), fill=(0.3, 0.3, 0.35, 0.8))
251    renderer.rect((anvil_x + 5, anvil_y + 6), (4, 4), fill=(0.2, 0.2, 0.22, 0.9))
252
253    # Well rim: centre of village
254    mid_x = COLS // 2
255    mid_y = ROWS // 2
256    well_cx = (mid_x + 1) * TILE
257    well_cy = (mid_y + 1) * TILE + TILE // 2
258    renderer.circle((well_cx, well_cy), 12, fill=(0.35, 0.32, 0.3, 0.9))
259    renderer.circle((well_cx, well_cy), 8, fill=(0.1, 0.12, 0.2, 0.8))
260    renderer.text("Well", (well_cx - 12, well_cy - 20), fill=(0.7, 0.7, 0.8), scale=0.7)
261
262
263# ============================================================================
264# Village tile detail patterns
265# ============================================================================
266
267
268def _draw_tile_detail(renderer, gx: int, gy: int, tile: int) -> None:
269    """Draw small decorative details on village tiles."""
270    h = (gx * 17 + gy * 31) % 100
271    px, py = gx * TILE, gy * TILE
272
273    if tile == GRASS:
274        # Flower dots on ~12% of grass tiles
275        if h < 12:
276            colours = [(0.8, 0.3, 0.3, 0.6), (0.9, 0.8, 0.2, 0.6), (0.3, 0.3, 0.9, 0.6)]
277            c = colours[h % 3]
278            renderer.circle((px + 8 + h % 16, py + 6 + (h * 3) % 18), 2, fill=c)
279    elif tile == DIRT:
280        # Footprints on ~15% of dirt tiles
281        if h < 15:
282            renderer.rect((px + 10, py + 8), (3, 5), fill=(0.28, 0.22, 0.14, 0.3))
283            renderer.rect((px + 18, py + 16), (3, 5), fill=(0.28, 0.22, 0.14, 0.25))
284    elif tile == HEDGE:
285        # Leaf pattern on ~20% of hedge tiles
286        if h < 20:
287            renderer.circle((px + 6 + h % 12, py + 6 + (h * 2) % 14), 3, fill=(0.08, 0.25, 0.06, 0.5))
288
289
290# ============================================================================
291# Training Dummy
292# ============================================================================
293
294
295class TrainingDummy(Node2D):
296    """Attackable training dummy in the village: shows damage and DPS counter."""
297
298    # on_draw fades the white hit-flash (self._hit_flash) and shows a live DPS
299    # readout that decays each frame -> retained 2D must re-run it each frame.
300    dynamic = True
301
302    INTERACTION_RANGE = 30.0
303
304    def __init__(self, pos: Vec2, **kwargs):
305        super().__init__(name="TrainingDummy", **kwargs)
306        self.position = pos
307        self._total_damage = 0
308        self._hit_count = 0
309        self._dps_timer = 0.0
310        self._dps_window: list[tuple[float, int]] = []  # (timestamp, damage)
311        self._last_hit_text = ""
312        self._hit_flash = 0.0
313
314    def take_damage(self, amount: int) -> None:
315        """Record a hit on the dummy."""
316        self._total_damage += amount
317        self._hit_count += 1
318        self._dps_window.append((self._dps_timer, amount))
319        self._last_hit_text = str(amount)
320        self._hit_flash = 0.3
321
322    def can_interact(self, player_pos: Vec2) -> bool:
323        dx = float(player_pos.x) - self.position.x
324        dy = float(player_pos.y) - self.position.y
325        return dx * dx + dy * dy < self.INTERACTION_RANGE * self.INTERACTION_RANGE
326
327    def reset(self) -> None:
328        self._total_damage = 0
329        self._hit_count = 0
330        self._dps_window.clear()
331        self._last_hit_text = ""
332
333    @property
334    def dps(self) -> float:
335        """Compute DPS over the last 5 seconds."""
336        cutoff = self._dps_timer - 5.0
337        self._dps_window = [(t, d) for t, d in self._dps_window if t > cutoff]
338        if not self._dps_window:
339            return 0.0
340        window = self._dps_timer - self._dps_window[0][0]
341        if window < 0.01:
342            return 0.0
343        return sum(d for _, d in self._dps_window) / window
344
345    def on_update(self, dt: float):
346        self._dps_timer += dt
347        if self._hit_flash > 0:
348            self._hit_flash -= dt
349
350    def on_draw(self, renderer):
351        # Wooden post
352        renderer.rect((-3, -5), (6, 20), fill=(0.45, 0.3, 0.15, 1.0))
353        # Cross-beam
354        renderer.rect((-12, -2), (24, 4), fill=(0.5, 0.35, 0.2, 1.0))
355        # Head (straw circle)
356        colour = (1.0, 1.0, 1.0, 1.0) if self._hit_flash > 0 else (0.7, 0.6, 0.3, 1.0)
357        renderer.circle((0, -10), 6, fill=colour)
358        # Label
359        renderer.text("Dummy", (-16, -24), fill=(0.8, 0.8, 0.7), scale=0.7)
360        # DPS counter
361        if self._total_damage > 0:
362            renderer.text(f"Total: {self._total_damage}", (-24, 18), fill=(0.9, 0.9, 0.9), scale=0.7)
363            renderer.text(f"DPS: {self.dps:.0f}", (-16, 30), fill=(1.0, 0.7, 0.2), scale=0.7)
364        # Last hit damage
365        if self._hit_flash > 0 and self._last_hit_text:
366            renderer.text(self._last_hit_text, (8, -18), fill=(1.0, 0.3, 0.3), scale=1.0)