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))