]> Untitled Git - axy/ft/pacman.git/commitdiff
Schedule fixes and window relative layout
author= <=>
Wed, 14 Oct 2026 12:16:14 +0000 (14:16 +0200)
committer= <=>
Wed, 14 Oct 2026 12:16:14 +0000 (14:16 +0200)
src/pacman/ecs/graphics.py
src/pacman/ecs/gui.py
src/pacman/ecs/schedule.py
src/pacman/ecs/world.py
src/pacman/game/__init__.py

index 00f684d833b7bcc5b536a5324b078c17456a22c0..8cfb6e3287992505aa8d3b16ea6cad9464e116a2 100644 (file)
@@ -4,17 +4,20 @@ from time import time
 from typing import cast, override
 
 import pygame
-from pygame import K_RETURN, K_a, K_d, K_s, K_w
+from pygame import K_RETURN, K_a, K_d, K_s, K_w, Vector2
 
 from pacman.ecs.assets import Asset
-from pacman.ecs.coord import CoordAbs2D, PropagateTransform
+from pacman.ecs.coord import Coord2D, CoordAbs2D, PropagateTransform
 from pacman.ecs.schedule import StartupSchedule, SystemSet, UpdateSchedule
 from pacman.ecs.update import PostUpdate, PreUpdate
 from pacman.ecs.world import (
     Component,
     Entity,
+    Maybe,
     Not,
     Query,
+    RawComponentArg,
+    RawComponentHook,
     Resource,
     Systems,
     World,
@@ -32,6 +35,12 @@ class Window(Resource):
     clock: pygame.time.Clock
 
 
+@dataclass
+class WindowPositionned(Component):
+    pos: Vector2
+    offset: Vector2
+
+
 dir_lookup = {K_w: 0, K_d: 1, K_s: 2, K_a: 3}
 
 
@@ -86,10 +95,49 @@ class Sprite2D(Component, Asset):
         return Sprite2D(self.surface)
 
 
+def window_positionned_insert_hook(
+    elems: Query[tuple[Coord2D | None, CoordAbs2D | None]],
+    window: Maybe[Window],
+    world: World,
+) -> RawComponentHook[WindowPositionned]:
+    def inner(added: RawComponentArg[WindowPositionned]) -> None:
+        dims = Vector2(0, 0)
+        if (win := window.v) is not None:
+            dims = Vector2(win.surface.get_size())
+
+        added_coords = []
+        added_abs = []
+        for e, comp in added:
+            coord, coord_abs = elems[e]
+            pos = dims.elementwise() * comp.pos + comp.offset
+            if coord is not None:
+                coord.xy = pos
+            else:
+                added_coords.append((e, Coord2D(pos.x, pos.y, -1)))
+            if coord_abs is None:
+                added_abs.append((e, CoordAbs2D()))
+
+        world.insert_many(Coord2D, added_coords)
+        world.insert_many(CoordAbs2D, added_abs)
+
+    return inner
+
+
+def update_window_positionned(
+    elems: Query[tuple[WindowPositionned, Coord2D]], window: Window
+) -> None:
+    dims = Vector2(window.surface.get_size())
+    for pos, e in elems:
+        e.xy = dims.elementwise() * pos.pos + pos.offset
+
+
 def init_graphics(world: World) -> None:
     pygame.init()
     world.spawn_resource(
-        Window(pygame.display.set_mode((1600, 1200)), pygame.time.Clock()),
+        Window(
+            pygame.display.set_mode((1600, 1200), pygame.RESIZABLE),
+            pygame.time.Clock(),
+        ),
     )
     world.spawn_resource(WindowEvents([]))
     world.spawn_resource(Inputs({}))
@@ -182,7 +230,12 @@ def graphics_plugins(world: World) -> None:
         )
         .chain()
         .in_set(PostUpdate, GraphicsSystems),
+        Systems(update_window_positionned)
+        .before(PropagateTransform)
+        .in_set(PostUpdate),
         Systems(poll_events, close_on_close, update_inputs)
         .chain()
         .in_set(PreUpdate, InputsSystems),
-    ).with_plugins(gui_plugins)
+    ).with_plugins(gui_plugins).with_hook(
+        "insert", WindowPositionned, window_positionned_insert_hook
+    )
index 6fcac7afec48ff6e601ceb9d8029f8284df7fbe0..b0d2f9e2b01fc97d8bc0607b8f75b100305d40ed 100644 (file)
@@ -10,18 +10,16 @@ from pacman.ecs.assets import Asset
 from pacman.ecs.coord import (
     CoordAbs2D,
     CoordBundle,
-    PropagateTransform,
 )
 from pacman.ecs.graphics import (
     AnimationSet,
-    GraphicsSystems,
     Inputs,
     Sprite2D,
     WindowEvents,
     dir_lookup,
 )
 from pacman.ecs.schedule import StartupSchedule, UpdateSchedule
-from pacman.ecs.update import PostUpdate, PreUpdate
+from pacman.ecs.update import PreUpdate
 from pacman.ecs.world import (
     CommandQueue,
     Component,
@@ -144,7 +142,7 @@ def update_computed_text(
     world: World,
 ) -> None:
     for display, compute in elems:
-        display.text = world.run_raw_system(compute.cb)
+        display.text = world.run_raw_system(compute.cb, lambda: display.text)
 
 
 def update_text_sprites(
@@ -186,5 +184,5 @@ def gui_plugins(world: World) -> None:
             update_text_sprites,
         )
         .chain()
-        .in_set(PreUpdate)
+        .in_set(PreUpdate),
     )
index 58fe9b5c163723d1c032d1d2f624d9ba4910d355..8536ae9ab7f0e6ac387fb4d36e43faa73de92e3c 100644 (file)
@@ -1,6 +1,7 @@
 from collections import deque
 from collections.abc import Callable, Generator, Iterable
 from dataclasses import dataclass
+from sys import stderr
 from typing import override
 
 
@@ -23,9 +24,44 @@ class DAG[T]:
         return self._nodes[node]
 
     def add_edge(self, parent: T, child: T) -> None:
+        parents = self.node_parents(parent)
+        children = self.node_children(child)
+        shared = parents & children
+        if len(shared) != 0:
+            print(
+                f"Warning: adding edge between {parent} and {child} "
+                + f"create cycle at {shared}",
+                file=stderr,
+            )
         self._get_node(parent).children.add(child)
         self._get_node(child).parents.add(parent)
 
+    def node_parents(self, node: T) -> set[T]:
+        if node not in self._nodes:
+            return set()
+        res = set()
+        queue = [node]
+        while len(queue) != 0:
+            curr = queue.pop()
+            if curr in res:
+                continue
+            res.add(curr)
+            queue.extend(self._nodes[curr].parents)
+        return res
+
+    def node_children(self, node: T) -> set[T]:
+        if node not in self._nodes:
+            return set()
+        res = set()
+        queue = [node]
+        while len(queue) != 0:
+            curr = queue.pop()
+            if curr in res:
+                continue
+            res.add(curr)
+            queue.extend(self._nodes[curr].children)
+        return res
+
     def traverse_from(self, root: T) -> Generator[T]:
         visited: set[T] = set()
         queue = deque([root])
index ddf30ebe97e9c3bf0b7471b78bdcaf823c263384..ce88ecdb8d80554627b06daaa919cfd4d8073e54 100644 (file)
@@ -733,7 +733,8 @@ class World(FromWorld):
             tuple[type, ComponentHooks[Component]]
         ] = []
         self._components_lookup: dict[type, ComponentID] = {}
-        self._schedules: dict[ScheduleLabel, Schedule[RawSystem[Any]]] = {}
+        self._schedules: dict[ScheduleLabel, Schedule[System[Any]]] = {}
+        self._systems: dict[System[Any], RawSystem[Any]] = {}
         self._base_archetype: Archetype = Archetype(self, ())
         self._extractors_cache: dict[
             System[Any], tuple[Callable[[], FromWorld | None], ...]
@@ -772,15 +773,13 @@ class World(FromWorld):
         self.despawn_resource(ty)
         return self.spawn(ty, comp)
 
-    def despawn_resource[T: Resource](
-            self, ty: type[T]
-        ) -> None:
-            type Ty = ty  # type: ignore
-    
-            def remove(query: Query[Entity, Ty], world: World) -> None:
-                world.remove_many(ty, list(query))
-    
-            self.run_raw_system(remove)
+    def despawn_resource[T: Resource](self, ty: type[T]) -> None:
+        type Ty = ty  # type: ignore
+
+        def remove(query: Query[Entity, Ty], world: World) -> None:
+            world.remove_many(ty, list(query))
+
+        self.run_raw_system(remove)
 
     def spawn_many[T: Components](
         self, ty: type[T], comps: list[T]
@@ -936,7 +935,7 @@ class World(FromWorld):
         if schedule not in self._schedules:
             return
         for system in self._schedules[schedule].traverse():
-            system()
+            self._systems[system]()
 
     def run(
         self,
@@ -946,6 +945,11 @@ class World(FromWorld):
         while not stop_cond():
             self.tick(schedule)
 
+    def _register_schedule_system[T](self, system: System[T]) -> System[T]:
+        if system not in self._systems:
+            self._systems[system] = self.add_raw_system(system, lambda: None)
+        return system
+
     def with_systems(
         self,
         schedule: ScheduleLabel,
@@ -1016,7 +1020,7 @@ class Systems:
         self._conds.extend(conds)
         return self
 
-    def _apply(self, world: World, sched: Schedule[RawSystem]) -> SystemSet:
+    def _apply(self, world: World, sched: Schedule[System[Any]]) -> SystemSet:
         global _order_groups
         _order_groups += 1
         label = SystemSet(f"order-group-{_order_groups}")
@@ -1026,7 +1030,7 @@ class Systems:
             else (
                 e
                 if isinstance(e, SystemSet)
-                else world.add_raw_system(e, default=lambda: None)
+                else world._register_schedule_system(e)
             )
             for e in self._systems
         )
@@ -1036,7 +1040,7 @@ class Systems:
             )
         sched.add_ordering((label,), self._pre, self._post, self._in_set, None)
         if self._chain:
-            prev: tuple[SystemSet | RawSystem] | tuple[()] = ()
+            prev: tuple[SystemSet | System[Any]] | tuple[()] = ()
             for system in systems:
                 sched.add_ordering((system,), prev, (), (label,), None)
                 prev = (system,)
index bc4e0bd0da1b73e03981445049f820ea5d65411a..7a604f7cc6c44f37d03d56611a99b47cd834738d 100644 (file)
@@ -23,6 +23,7 @@ from pacman.ecs.graphics import (
     Inputs,
     ShouldRender,
     Sprite2D,
+    WindowPositionned,
     graphics_plugins,
 )
 from pacman.ecs.gui import (
@@ -31,6 +32,7 @@ from pacman.ecs.gui import (
     Font,
     SelectedButton,
     TextDisplay,
+    setup_gui_resources,
 )
 from pacman.ecs.hierarchy import ChildOf, Children, Hierarchy
 from pacman.ecs.schedule import StartupSchedule, UpdateSchedule
@@ -93,6 +95,7 @@ class InitialPauseTimer(Resource):
     def extract_default(
         cls, ty: type, world: "World"
     ) -> Callable[[], "InitialPauseTimer | None"]:
+        del ty, world
         return InitialPauseTimer
 
 
@@ -216,11 +219,21 @@ def create_maze(
 ) -> Callable[[MazeGenerator], Entity]:
     def inner(maze: MazeGenerator) -> Entity:
         root_maze = Maze(len(maze.maze[0]), len(maze.maze))
+        cell_dims = Vector2(64, 64)
         root = world.spawn(
-            tuple[Maze, CoordBundle],
-            (root_maze, CoordBundle(100, 100)),
+            tuple[Maze, WindowPositionned],
+            (
+                root_maze,
+                WindowPositionned(
+                    Vector2(0.5),
+                    -(
+                        Vector2(root_maze._x, root_maze._y).elementwise()
+                        * cell_dims
+                        / 2
+                    ),
+                ),
+            ),
         )
-        cell_dims = Vector2(64, 64)
         cells = {
             (x, y): (
                 ChildOf(root),
@@ -783,7 +796,6 @@ def initial_pause_done(timer: InitialPauseTimer, config: Config) -> bool:
 
 
 def setup_resources(world: World) -> None:
-    world.spawn_resource(GameState())
     world.spawn_resource(Config())
 
 
@@ -797,7 +809,40 @@ def print_lives(state: GameState) -> str:
 
 def print_timer(state: GameState, config: Config) -> str:
     time = config.level_timer - state.timer
-    return f"{time // 60}"
+    return f"Time left: {time // 60}"
+
+
+def spawn_hud(world: World, fonts: Assets[Font]) -> None:
+    world.spawn_many(
+        tuple[
+            WindowPositionned,
+            CoordBundle,
+            ShouldRender,
+            ComputedText,
+            TextDisplay,
+            GameGui,
+        ],
+        [
+            (
+                WindowPositionned(Vector2(), Vector2(20, y * 100 + 20)),
+                CoordBundle(depth=-20),
+                ShouldRender(),
+                ComputedText(fn),
+                TextDisplay(
+                    "",
+                    fonts.load("./asset/fonts/JetBrainsMonoNL-Regular.ttf:30"),
+                ),
+                GameGui(),
+            )
+            for y, fn in enumerate(
+                [
+                    print_score,
+                    print_lives,
+                    print_timer,
+                ],
+            )
+        ],
+    )
 
 
 def start_game(
@@ -805,11 +850,14 @@ def start_game(
     hierarchy: Hierarchy[tuple[()]],
     menus: Query[Entity, MainMenu],
 ) -> None:
-    world.run_raw_system(setup_new_maze)
     for menu in menus:
         hierarchy.despawn_with_children(menu)(world)
     world.despawn_resource(SelectedButton)
-    
+
+    world.spawn_resource(GameState())
+    world.run_raw_system(setup_new_maze)
+    world.run_raw_system(spawn_hud)
+
 
 def quit_game(world: World) -> None:
     world.spawn_resource(WorldShouldExit())
@@ -817,12 +865,19 @@ def quit_game(world: World) -> None:
 
 def spawn_button(
     world: World, fonts: Assets[Font], sprites: Assets[Sprite2D]
-) -> Callable[[Vector2, str, System], Entity]:
-    def inner(pos: Vector2, s: str, cb: System) -> Entity:
+) -> Callable[[WindowPositionned, str, System], Entity]:
+    def inner(pos: WindowPositionned, s: str, cb: System) -> Entity:
         parent = world.spawn(
-            tuple[CoordBundle, ShouldRender, AnimationSet, Button],
+            tuple[
+                WindowPositionned,
+                CoordBundle,
+                ShouldRender,
+                AnimationSet,
+                Button,
+            ],
             (
-                CoordBundle(*pos.xy, depth=-100),
+                pos,
+                CoordBundle(depth=-100),
                 ShouldRender(),
                 AnimationSet(
                     [
@@ -850,50 +905,17 @@ def spawn_button(
     return inner
 
 
-def setup_text(world: World, fonts: Assets[Font]) -> None:
-    world.spawn_many(
-        tuple[CoordBundle, ShouldRender, ComputedText, TextDisplay],
-        [
-            (
-                CoordBundle(depth=-20),
-                ShouldRender(),
-                ComputedText(print_score),
-                TextDisplay(
-                    "",
-                    fonts.load("./asset/fonts/JetBrainsMonoNL-Regular.ttf:30"),
-                ),
-            ),
-            (
-                CoordBundle(y=100, depth=-20),
-                ShouldRender(),
-                ComputedText(print_lives),
-                TextDisplay(
-                    "",
-                    fonts.load("./asset/fonts/JetBrainsMonoNL-Regular.ttf:30"),
-                ),
-            ),
-            (
-                CoordBundle(y=200, depth=-20),
-                ShouldRender(),
-                ComputedText(print_timer),
-                TextDisplay(
-                    "",
-                    fonts.load("./asset/fonts/JetBrainsMonoNL-Regular.ttf:30"),
-                ),
-            ),
-        ],
-    )
-
+def setup_main_menu(world: World) -> None:
     selected = world.run_system(
         spawn_button,
-        Vector2((1600 - 300) / 2, (1200 - 200) / 2),
+        WindowPositionned(Vector2(0.5), Vector2(-150, -100)),
         "Start game",
         start_game,
     )
 
     quit_button = world.run_system(
         spawn_button,
-        Vector2((1600 - 300) / 2, (1200 - 200) / 2 + 120),
+        WindowPositionned(Vector2(0.5), Vector2(-150, 20)),
         "Quit",
         quit_game,
     )
@@ -909,7 +931,9 @@ def setup_text(world: World, fonts: Assets[Font]) -> None:
 def run_game() -> None:
     World().with_plugins(graphics_plugins, minimal_plugins).with_systems(
         StartupSchedule,
-        Systems(setup_resources, setup_text).chain(),
+        Systems(setup_resources, setup_main_menu)
+        .chain()
+        .after(setup_gui_resources),
     ).with_systems(
         UpdateSchedule,
         Systems(