nodes/world.py¶
Part of Tiny Yurts.
1"""World: owns the Grid, dispatches Settlers, and renders everything iso.
2
3Input is polled rather than event-driven: a drag is a continuous read of which
4cell the cursor is over, which is far simpler to express as a per-frame poll
5than as a stream of motion events. Dragging from a cell to an adjacent one adds
6a path edge; right-clicking near an edge removes it. Both use named input
7actions, so the same code serves mouse and touch.
8
9Game flow:
10 - Every farm accrues demand, faster the longer the run has lasted.
11 - Once a farm's demand reaches 1.0, the yurt of the same kind dispatches an
12 idle settler along the shortest path the player has drawn.
13 - The settler walks the path graph, deducts 1.5 from demand at the farm,
14 walks back home, then becomes idle again.
15 - If a farm's demand exceeds its capacity, the player loses.
16 - Reach the score milestone (DELIVERIES_TO_WIN) and the player wins.
17"""
18
19from __future__ import annotations
20
21from simvx.core import Input, Node2D, Signal
22
23from . import iso
24from .grid import Farm, Grid, Yurt
25from .settler import Settler
26
27DELIVERIES_TO_WIN = 12
28PATH_BUDGET = 32 # placeable edges (player resource)
29
30
31# Initial scenario: one farm and one yurt of each kind, plus a starter path.
32INITIAL_FARMS = (
33 ("ox", (2, 2), iso.COLOUR_OX),
34 ("goat", (9, 2), iso.COLOUR_GOAT),
35 ("fish", (2, 6), iso.COLOUR_FISH),
36)
37INITIAL_YURTS = (
38 ("ox", (4, 4)),
39 ("goat", (7, 4)),
40 ("fish", (5, 6)),
41)
42INITIAL_PATHS = (
43 # Starter path between ox farm and ox yurt
44 ((2, 2), (3, 2)),
45 ((3, 2), (4, 3)),
46 ((4, 3), (4, 4)),
47)
48SETTLERS_PER_YURT = 2
49
50
51class World(Node2D):
52 """Authoritative game state and presentation."""
53
54 def __init__(self, **kwargs):
55 super().__init__(**kwargs)
56 self.grid = Grid()
57 self.deliveries = 0
58 self.path_budget = PATH_BUDGET
59 self.elapsed = 0.0
60 self.game_state = "playing" # "playing" | "won" | "lost"
61 self.lost_farm: Farm | None = None
62
63 # Drag state for path drawing
64 self._drag_active = False
65 self._last_cell: tuple[int, int] | None = None
66
67 # Hover preview
68 self._hover_cell: tuple[int, int] | None = None
69
70 # on_draw renders live, per-frame content from plain (non-Property)
71 # state: the mouse-following hover/drag preview (_hover_cell,
72 # _drag_active), animated farm demand bars (farm.demand grows every
73 # tick), and the path network (grid.edges mutates on drag). None of
74 # these auto-dirty the retained 2D layer, so mark the node dynamic to
75 # re-collect its draw ops every frame -- without it the demand bars
76 # and hover preview freeze on the last incidental re-collect.
77 self.dynamic = True
78
79 # Signals
80 self.delivery_made = Signal()
81 self.game_over = Signal()
82 self.victory = Signal()
83
84 self._spawn_initial_state()
85
86 # ---------- Setup ----------
87
88 def _spawn_initial_state(self) -> None:
89 for kind, cell, colour in INITIAL_FARMS:
90 self.grid.add_farm(Farm(kind=kind, cell=cell, colour=colour))
91 for kind, cell in INITIAL_YURTS:
92 self.grid.add_yurt(Yurt(kind=kind, cell=cell))
93 for a, b in INITIAL_PATHS:
94 if self.grid.add_edge(a, b):
95 self.path_budget -= 1
96 # Spawn settlers for each yurt
97 for yurt in self.grid.yurts:
98 colour = self._settler_colour(yurt.kind)
99 for _ in range(SETTLERS_PER_YURT):
100 s = Settler(kind=yurt.kind, home_cell=yurt.cell, colour=colour)
101 yurt.settlers.append(s)
102 self.add_child(s)
103
104 @staticmethod
105 def _settler_colour(kind: str):
106 return {
107 "ox": iso.COLOUR_OX,
108 "goat": iso.COLOUR_GOAT,
109 "fish": iso.COLOUR_FISH,
110 }[kind]
111
112 def reset(self) -> None:
113 """Restart in place."""
114 # Drop all settlers
115 for yurt in self.grid.yurts:
116 for s in yurt.settlers:
117 s.destroy()
118 self.grid = Grid()
119 self.deliveries = 0
120 self.path_budget = PATH_BUDGET
121 self.elapsed = 0.0
122 self.game_state = "playing"
123 self.lost_farm = None
124 self._drag_active = False
125 self._last_cell = None
126 self._spawn_initial_state()
127
128 # ---------- Input (polled) ----------
129
130 def on_update(self, dt: float) -> None:
131 if self.game_state == "playing":
132 self.elapsed += dt
133 self._tick_demand(dt)
134 self._tick_dispatch()
135 self._check_loss()
136 self._check_win()
137
138 self._handle_input()
139
140 def _handle_input(self) -> None:
141 mx, my = Input.mouse_position
142 cell = iso.screen_to_cell(mx, my)
143 self._hover_cell = cell if iso.in_bounds(*cell) else None
144
145 if self.game_state != "playing":
146 return
147
148 # Left-drag: place path edges between adjacent cells
149 if Input.is_action_just_pressed("place_path"):
150 if self._hover_cell is not None:
151 self._drag_active = True
152 self._last_cell = self._hover_cell
153 elif Input.is_action_pressed("place_path") and self._drag_active:
154 if self._hover_cell is not None and self._last_cell is not None:
155 if self._hover_cell != self._last_cell:
156 self._try_place_edge(self._last_cell, self._hover_cell)
157 self._last_cell = self._hover_cell
158 if Input.is_action_just_released("place_path"):
159 self._drag_active = False
160 self._last_cell = None
161
162 # Right-click: remove an edge whose midpoint is closest to cursor
163 if Input.is_action_just_pressed("remove_path"):
164 self._try_remove_nearby_edge(mx, my)
165
166 def _try_place_edge(self, a: tuple[int, int], b: tuple[int, int]) -> None:
167 if self.path_budget <= 0:
168 return
169 di, dj = b[0] - a[0], b[1] - a[1]
170 if abs(di) > 1 or abs(dj) > 1 or (di == 0 and dj == 0):
171 # Not strictly adjacent (drag jumped): skip silently.
172 return
173 if self.grid.add_edge(a, b):
174 self.path_budget -= 1
175
176 def _try_remove_nearby_edge(self, mx: float, my: float) -> None:
177 best_key = None
178 best_d2 = 18.0 * 18.0 # px²
179 for key in self.grid.edges:
180 a, b = tuple(key)
181 ax, ay = iso.world_to_screen(*a)
182 bx, by = iso.world_to_screen(*b)
183 cx, cy = (ax + bx) * 0.5, (ay + by) * 0.5
184 d2 = (cx - mx) ** 2 + (cy - my) ** 2
185 if d2 < best_d2:
186 best_d2 = d2
187 best_key = key
188 if best_key is not None:
189 a, b = tuple(best_key)
190 self.grid.remove_edge(a, b)
191 self.path_budget += 1
192
193 # ---------- Game tick ----------
194
195 def _tick_demand(self, dt: float) -> None:
196 # Each farm grows demand at a rate scaled by elapsed time difficulty.
197 difficulty = 1.0 + self.elapsed / 60.0
198 for farm in self.grid.farms:
199 farm.demand += dt * 0.45 * difficulty
200
201 def _tick_dispatch(self) -> None:
202 # For each farm with non-zero demand, send the closest matching idle
203 # settler from the matching yurt.
204 for farm in self.grid.farms:
205 if farm.demand < 1.0:
206 continue
207 yurt = self._yurt_for_kind(farm.kind)
208 if yurt is None:
209 continue
210 idle = next((s for s in yurt.settlers if s.is_idle), None)
211 if idle is None:
212 continue
213 # Build farm cell target set (1x1 here, but support N×M)
214 route = self.grid.find_route(yurt.cell, {farm.cell})
215 if not route or len(route) < 2:
216 continue
217 idle.dispatch(route, farm, self._on_delivered)
218
219 def _on_delivered(self, settler: Settler) -> None:
220 # Settlers already walking when the run ends still make it home, but a
221 # finished run must not keep scoring.
222 if self.game_state != "playing":
223 return
224 self.deliveries += 1
225 self.delivery_made.emit()
226
227 def _yurt_for_kind(self, kind: str) -> Yurt | None:
228 for y in self.grid.yurts:
229 if y.kind == kind:
230 return y
231 return None
232
233 def _check_loss(self) -> None:
234 for farm in self.grid.farms:
235 if farm.demand >= farm.capacity:
236 self.game_state = "lost"
237 self.lost_farm = farm
238 self.game_over.emit()
239 return
240
241 def _check_win(self) -> None:
242 if self.deliveries >= DELIVERIES_TO_WIN:
243 self.game_state = "won"
244 self.victory.emit()
245
246 # ---------- Render ----------
247
248 def on_draw(self, renderer) -> None:
249 self._draw_grid(renderer)
250 self._draw_paths(renderer)
251 self._draw_hover(renderer)
252 self._draw_farms(renderer)
253 self._draw_yurts(renderer)
254
255 def _draw_grid(self, renderer) -> None:
256 for j in range(iso.GRID_ROWS):
257 for i in range(iso.GRID_COLS):
258 corners = iso.tile_corners(i, j)
259 checker = (i + j) & 1
260 fill = iso.COLOUR_GRASS if checker else iso.COLOUR_GRASS_DARK
261 renderer.draw_polygon(corners, colour=fill)
262 renderer.draw_lines(corners, closed=True, colour=iso.COLOUR_GRID)
263
264 def _draw_paths(self, renderer) -> None:
265 for key in self.grid.edges:
266 a, b = tuple(key)
267 ax, ay = iso.world_to_screen(*a)
268 bx, by = iso.world_to_screen(*b)
269 # Draw thick path strip
270 renderer.draw_thick_line(ax, ay, bx, by, width=8.0, colour=iso.COLOUR_PATH)
271
272 def _draw_hover(self, renderer) -> None:
273 if self._hover_cell is None or not iso.in_bounds(*self._hover_cell):
274 return
275 corners = iso.tile_corners(*self._hover_cell)
276 renderer.draw_lines(corners, closed=True, colour=iso.COLOUR_PATH_PREVIEW)
277 if self._drag_active and self._last_cell is not None and self._last_cell != self._hover_cell:
278 ax, ay = iso.world_to_screen(*self._last_cell)
279 bx, by = iso.world_to_screen(*self._hover_cell)
280 renderer.draw_thick_line(ax, ay, bx, by, width=6.0, colour=iso.COLOUR_PATH_PREVIEW)
281
282 def _draw_farms(self, renderer) -> None:
283 for farm in self.grid.farms:
284 cx, cy = iso.world_to_screen(*farm.cell)
285 # Fence (diamond)
286 corners = iso.tile_corners(*farm.cell)
287 renderer.draw_polygon(corners, colour=(0.95, 0.93, 0.85, 1.0))
288 renderer.draw_lines(corners, closed=True, colour=(0.20, 0.20, 0.18, 1.0))
289 # Animal blob inside
290 renderer.draw_circle((cx, cy - 4), 7.0, colour=farm.colour, filled=True)
291 # Demand bar: bigger means more urgent
292 ratio = min(1.0, farm.demand / farm.capacity)
293 bar_w = 24.0
294 bar_h = 4.0
295 bx = cx - bar_w / 2
296 by = cy - iso.TILE_H_HALF - 12
297 renderer.draw_rect((bx, by), (bar_w, bar_h), colour=(0.0, 0.0, 0.0, 0.4), filled=True)
298 fg = iso.COLOUR_OK if ratio < 0.6 else iso.COLOUR_WARN
299 renderer.draw_rect((bx, by), (bar_w * ratio, bar_h), colour=fg, filled=True)
300
301 def _draw_yurts(self, renderer) -> None:
302 for yurt in self.grid.yurts:
303 cx, cy = iso.world_to_screen(*yurt.cell)
304 # Yurt walls (square block)
305 corners = iso.tile_corners(*yurt.cell)
306 renderer.draw_polygon(corners, colour=iso.COLOUR_YURT)
307 renderer.draw_lines(corners, closed=True, colour=(0.30, 0.20, 0.10, 1.0))
308 # Roof: triangle peak
309 top = (cx, cy - iso.TILE_H_HALF - 8)
310 renderer.fill_triangle(
311 cx - iso.TILE_W_HALF * 0.7,
312 cy - 2,
313 cx + iso.TILE_W_HALF * 0.7,
314 cy - 2,
315 top[0],
316 top[1],
317 colour=iso.COLOUR_YURT_ROOF,
318 )
319 # Coloured dot to indicate kind
320 kind_col = self._settler_colour(yurt.kind)
321 renderer.draw_circle((cx, cy + 4), 3.0, colour=kind_col, filled=True)
322
323
324def world_centre_origin(width: float, height: float) -> None:
325 """Anchor the iso projection so the board fits horizontally on the screen."""
326 # Centre of the diamond board: when i and j range over [0,COLS) x [0,ROWS),
327 # screen-x ranges [-(ROWS-1)*W, (COLS-1)*W], screen-y ranges [0, (COLS+ROWS-2)*H].
328 cols = iso.GRID_COLS
329 rows = iso.GRID_ROWS
330 board_h = (cols + rows) * iso.TILE_H_HALF
331 # Centre horizontally; place vertical midpoint at ~55% of viewport.
332 iso.set_origin(
333 width / 2 - (cols - rows) * iso.TILE_W_HALF / 2,
334 height * 0.55 - board_h / 2 + (rows - 1) * iso.TILE_H_HALF / 2,
335 )