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.is_action_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.draw_rect((x * TILE, y * TILE), (TILE, TILE), colour=colour, filled=True)
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.draw_rect((ex - 16, ey - 16), (32, 32), colour=(0.35, 0.15, 0.1, 1.0), filled=True)
197        renderer.draw_rect((ex - 12, ey - 8), (24, 20), colour=(0.15, 0.05, 0.0, 1.0), filled=True)
198        renderer.draw_text("DUNGEON", (ex - 26, ey - 30), scale=0.9, colour=(0.9, 0.4, 0.3))
199        if self.is_near_dungeon_entrance():
200            renderer.draw_text("[E] Enter", (ex - 26, ey + 20), scale=0.8, colour=(1.0, 0.9, 0.3))
201
202        renderer.draw_text("VILLAGE", (VILLAGE_W // 2 - 40, 40), scale=2.0, colour=(0.9, 0.8, 0.5, 1.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.fill_triangle(
228            mid_x,
229            roof_y,
230            px - 4,
231            py + 2,
232            px + pw + 4,
233            py + 2,
234            colour=(0.45, 0.2, 0.12, 0.9),
235        )
236        # Roof outline
237        renderer.draw_line((mid_x, roof_y), (px - 4, py + 2), colour=(0.3, 0.12, 0.06, 0.8))
238        renderer.draw_line((mid_x, roof_y), (px + pw + 4, py + 2), colour=(0.3, 0.12, 0.06, 0.8))
239
240        # Door: centred on south side
241        door_x = px + pw // 2 - 6
242        door_y = py + ph - 16
243        renderer.draw_rect((door_x, door_y), (12, 16), colour=(0.25, 0.12, 0.06, 0.9), filled=True)
244        # Door handle
245        renderer.draw_circle((door_x + 9, door_y + 8), 1, colour=(0.7, 0.6, 0.3, 0.8), filled=True)
246
247        # Chimney: top-right
248        chim_x = px + pw - 14
249        chim_y = roof_y - 4
250        renderer.draw_rect((chim_x, chim_y), (8, 20), colour=(0.35, 0.28, 0.22, 0.9), filled=True)
251
252        # Label
253        renderer.draw_text(label, (px + 4, py - 28), scale=0.8, colour=(0.9, 0.85, 0.7))
254
255    # Blacksmith: anvil (near bottom-left building)
256    anvil_x, anvil_y = 5 * TILE + 8, 17 * TILE + 8
257    renderer.draw_rect((anvil_x, anvil_y), (14, 6), colour=(0.25, 0.25, 0.28, 0.9), filled=True)
258    renderer.draw_rect((anvil_x + 2, anvil_y - 4), (10, 4), colour=(0.3, 0.3, 0.35, 0.8), filled=True)
259    renderer.draw_rect((anvil_x + 5, anvil_y + 6), (4, 4), colour=(0.2, 0.2, 0.22, 0.9), filled=True)
260
261    # Well rim: centre of village
262    mid_x = COLS // 2
263    mid_y = ROWS // 2
264    well_cx = (mid_x + 1) * TILE
265    well_cy = (mid_y + 1) * TILE + TILE // 2
266    renderer.draw_circle((well_cx, well_cy), 12, colour=(0.35, 0.32, 0.3, 0.9), filled=True)
267    renderer.draw_circle((well_cx, well_cy), 8, colour=(0.1, 0.12, 0.2, 0.8), filled=True)
268    renderer.draw_text("Well", (well_cx - 12, well_cy - 20), scale=0.7, colour=(0.7, 0.7, 0.8))
269
270
271# ============================================================================
272# Village tile detail patterns
273# ============================================================================
274
275
276def _draw_tile_detail(renderer, gx: int, gy: int, tile: int) -> None:
277    """Draw small decorative details on village tiles."""
278    h = (gx * 17 + gy * 31) % 100
279    px, py = gx * TILE, gy * TILE
280
281    if tile == GRASS:
282        # Flower dots on ~12% of grass tiles
283        if h < 12:
284            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)]
285            c = colours[h % 3]
286            renderer.draw_circle((px + 8 + h % 16, py + 6 + (h * 3) % 18), 2, colour=c, filled=True)
287    elif tile == DIRT:
288        # Footprints on ~15% of dirt tiles
289        if h < 15:
290            renderer.draw_rect((px + 10, py + 8), (3, 5), colour=(0.28, 0.22, 0.14, 0.3), filled=True)
291            renderer.draw_rect((px + 18, py + 16), (3, 5), colour=(0.28, 0.22, 0.14, 0.25), filled=True)
292    elif tile == HEDGE:
293        # Leaf pattern on ~20% of hedge tiles
294        if h < 20:
295            renderer.draw_circle(
296                (px + 6 + h % 12, py + 6 + (h * 2) % 14), 3, colour=(0.08, 0.25, 0.06, 0.5), filled=True
297            )
298
299
300# ============================================================================
301# Training Dummy
302# ============================================================================
303
304
305class TrainingDummy(Node2D):
306    """Attackable training dummy in the village: shows damage and DPS counter."""
307
308    # on_draw fades the white hit-flash (self._hit_flash) and shows a live DPS
309    # readout that decays each frame -> retained 2D must re-run it each frame.
310    dynamic = True
311
312    INTERACTION_RANGE = 30.0
313
314    def __init__(self, pos: Vec2, **kwargs):
315        super().__init__(name="TrainingDummy", **kwargs)
316        self.position = pos
317        self._total_damage = 0
318        self._hit_count = 0
319        self._dps_timer = 0.0
320        self._dps_window: list[tuple[float, int]] = []  # (timestamp, damage)
321        self._last_hit_text = ""
322        self._hit_flash = 0.0
323
324    def take_damage(self, amount: int) -> None:
325        """Record a hit on the dummy."""
326        self._total_damage += amount
327        self._hit_count += 1
328        self._dps_window.append((self._dps_timer, amount))
329        self._last_hit_text = str(amount)
330        self._hit_flash = 0.3
331
332    def can_interact(self, player_pos: Vec2) -> bool:
333        dx = float(player_pos.x) - self.position.x
334        dy = float(player_pos.y) - self.position.y
335        return dx * dx + dy * dy < self.INTERACTION_RANGE * self.INTERACTION_RANGE
336
337    def reset(self) -> None:
338        self._total_damage = 0
339        self._hit_count = 0
340        self._dps_window.clear()
341        self._last_hit_text = ""
342
343    @property
344    def dps(self) -> float:
345        """Compute DPS over the last 5 seconds."""
346        cutoff = self._dps_timer - 5.0
347        self._dps_window = [(t, d) for t, d in self._dps_window if t > cutoff]
348        if not self._dps_window:
349            return 0.0
350        window = self._dps_timer - self._dps_window[0][0]
351        if window < 0.01:
352            return 0.0
353        return sum(d for _, d in self._dps_window) / window
354
355    def on_update(self, dt: float):
356        self._dps_timer += dt
357        if self._hit_flash > 0:
358            self._hit_flash -= dt
359
360    def on_draw(self, renderer):
361        px, py = self.position.x, self.position.y
362        # Wooden post
363        renderer.draw_rect((px - 3, py - 5), (6, 20), colour=(0.45, 0.3, 0.15, 1.0), filled=True)
364        # Cross-beam
365        renderer.draw_rect((px - 12, py - 2), (24, 4), colour=(0.5, 0.35, 0.2, 1.0), filled=True)
366        # Head (straw circle)
367        colour = (1.0, 1.0, 1.0, 1.0) if self._hit_flash > 0 else (0.7, 0.6, 0.3, 1.0)
368        renderer.draw_circle((px, py - 10), 6, colour=colour, filled=True)
369        # Label
370        renderer.draw_text("Dummy", (px - 16, py - 24), scale=0.7, colour=(0.8, 0.8, 0.7))
371        # DPS counter
372        if self._total_damage > 0:
373            renderer.draw_text(f"Total: {self._total_damage}", (px - 24, py + 18), scale=0.7, colour=(0.9, 0.9, 0.9))
374            renderer.draw_text(f"DPS: {self.dps:.0f}", (px - 16, py + 30), scale=0.7, colour=(1.0, 0.7, 0.2))
375        # Last hit damage
376        if self._hit_flash > 0 and self._last_hit_text:
377            renderer.draw_text(self._last_hit_text, (px + 8, py - 18), scale=1.0, colour=(1.0, 0.3, 0.3))