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    )