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)