From 1e3b25324e83800db184a18f64cb8e714e322968 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 15 Sep 2026 14:48:09 +0200 Subject: [PATCH] Hierarchical transform propagation --- src/pacman/ecs/__init__.py | 100 ++++++++++++++++++++++++++++++++---- src/pacman/ecs/hierarchy.py | 4 +- src/pacman/ecs/world.py | 29 +++++++++-- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/pacman/ecs/__init__.py b/src/pacman/ecs/__init__.py index ebe82f3..5c2b3b5 100644 --- a/src/pacman/ecs/__init__.py +++ b/src/pacman/ecs/__init__.py @@ -6,7 +6,7 @@ from typing import Any, Literal, cast, get_args, overload import pygame.sprite from pygame import Vector2 -from pacman.ecs.hierarchy import hierachy_plugins +from pacman.ecs.hierarchy import Children, Parent, hierachy_plugins from pacman.ecs.schedule import ( MainSchedule, Schedule, @@ -27,20 +27,73 @@ def run_minimal_subschedules(world: World) -> None: world.tick(UpdateSchedule) +PreUpdate = SystemSet("preupdate") +Update = SystemSet("update") +PostUpdate = SystemSet("postupdate") + + def schedule_plugins(world: World) -> None: - world.with_systems(MainSchedule, run_minimal_subschedules) + world.with_systems(MainSchedule, run_minimal_subschedules).with_systems( + UpdateSchedule, Systems(PreUpdate, Update, PostUpdate).chain() + ) -def minimal_plugins(world: World) -> None: - world.with_plugins(schedule_plugins, hierachy_plugins) +class CoordAbs2D: + vec: Vector2 + depth: float + + def __init__(self) -> None: + self.vec = Vector2(0.0) + self.depth = 0.0 @dataclass -class ScreenCoord2D: +class Coord2D: vec: Vector2 depth: float +def update_coord(world: World) -> None: + for abs_coord, coord in world.query(tuple[CoordAbs2D, Coord2D]): + abs_coord.vec = coord.vec.copy() + abs_coord.depth = coord.depth + + +def update_coord_hierarchy(world: World) -> None: + work = [] + for (children,) in world.query(tuple[Children], without=tuple[Parent]): + work.extend(children.children) + while work: + curr = work.pop() + child = world[curr] + parent = world[world[curr][Parent].parent] + if CoordAbs2D in parent and CoordAbs2D in child: + parent_coord = parent[CoordAbs2D] + child_coord = child[CoordAbs2D] + child_coord.vec += parent_coord.vec + child_coord.depth += parent_coord.depth + if Children in child: + work.extend(child[Children].children) + + +PropagateTransform = SystemSet("propagate-transform") + + +def coord_update_plugins(world: World) -> None: + world.with_systems( + UpdateSchedule, + Systems(update_coord, update_coord_hierarchy) + .chain() + .in_set(PostUpdate, PropagateTransform), + ) + + +def minimal_plugins(world: World) -> None: + world.with_plugins( + schedule_plugins, hierachy_plugins, coord_update_plugins + ) + + class ShouldRender: pass @@ -83,7 +136,7 @@ def close_on_close(world: World) -> None: def render_sprite2d(world: World) -> None: window = world.res_s(Window) sprites_raw = sorted( - world[tuple[ScreenCoord2D, Sprite2D, ShouldRender]], + world[tuple[CoordAbs2D, Sprite2D, ShouldRender]], key=lambda e: e[0].depth, reverse=True, ) @@ -97,30 +150,57 @@ def render_sprite2d(world: World) -> None: def graphics_plugins(world: World) -> None: world.with_systems(StartupSchedule, init_window).with_systems( UpdateSchedule, - Systems(poll_events, close_on_close, render_sprite2d).chain(), + Systems(poll_events, close_on_close, render_sprite2d) + .chain() + .after(PropagateTransform), ) +class WiggleSprite: + pass + + def move_sprites(world: World) -> None: import math time = pygame.time.get_ticks() / 100 - for (coord,) in world[tuple[ScreenCoord2D]]: + for coord, _ in world[tuple[Coord2D, WiggleSprite]]: pos = max(time - coord.depth * 0.3, 0.0) coord.vec.y = 500.0 + math.sin(pos) * 30.0 +class WiggleRoot: + pass + + +def move_root(world: World) -> None: + import math + + time = pygame.time.get_ticks() / 100 + for coord, _ in world[tuple[Coord2D, WiggleRoot]]: + coord.vec.x = 30.0 + math.cos(time) * 30.0 + + if __name__ == "__main__": world = ( World() .with_plugins(minimal_plugins, graphics_plugins) - .with_systems(UpdateSchedule, move_sprites) + .with_systems(UpdateSchedule, move_sprites, move_root) ) assets = list(Path("./asset").rglob("*.png")) + root = Entity() + world[root] = ( + Coord2D(Vector2(100.0, 0.0), 0.0), + CoordAbs2D(), + WiggleRoot(), + ) for i, asset in enumerate(assets): world[Entity()] = ( - ScreenCoord2D(Vector2(30.0 * i, 500.0), 1.0 * i), + Coord2D(Vector2(30.0 * i, 500.0), 1.0 * i), + CoordAbs2D(), Sprite2D(pygame.image.load(asset)), ShouldRender(), + WiggleSprite(), + Parent(root), ) world.run_main() diff --git a/src/pacman/ecs/hierarchy.py b/src/pacman/ecs/hierarchy.py index af8ca09..9106a35 100644 --- a/src/pacman/ecs/hierarchy.py +++ b/src/pacman/ecs/hierarchy.py @@ -40,8 +40,6 @@ def children_insert_hook( child[Parent] = Parent(entity) __no_hook = False - pass - def children_remove_hook( world: World, _entity: Entity, component: Children @@ -71,7 +69,7 @@ def parent_insert_hook( if __no_hook: return parent = world[component.parent] - if Parent not in parent: + if Children not in parent: __no_hook = True parent[Children] = Children((entity,)) __no_hook = False diff --git a/src/pacman/ecs/world.py b/src/pacman/ecs/world.py index 6ea3d43..510715e 100644 --- a/src/pacman/ecs/world.py +++ b/src/pacman/ecs/world.py @@ -46,11 +46,21 @@ class EntityThunk: self.world._entities[self.entity].add(ty) if ty not in self.world._components: self.world._components[ty] = {} + if self.entity in self.world._components[ty]: + del self[ty] self.world._components[ty][self.entity] = val + if ty in self.world._insert_hooks: + self.world._insert_hooks[ty](self.world, self.entity, val) def __delitem__[T](self, ty: type[T]) -> None: if ty not in self.world._entities[self.entity]: return + if ty in self.world._remove_hooks: + self.world._remove_hooks[ty]( + self.world, + self.entity, + self.world._components[ty][self.entity], + ) self.world._entities[self.entity].remove(ty) del self.world._components[ty][self.entity] @@ -79,7 +89,9 @@ class World: return sets: list[dict[Entity, Any]] = sorted( ( - self._entities if arg is Entity else self._components[arg] + self._entities + if arg is Entity + else self._components.get(arg, {}) for arg in args if arg is Entity or arg in self._components ), @@ -88,7 +100,9 @@ class World: ) neg_sets: list[dict[Entity, Any]] = sorted( ( - self._entities if arg is Entity else self._components[arg] + self._entities + if arg is Entity + else self._components.get(arg, {}) for arg in neg_args if arg is Entity or arg in self._components ), @@ -193,11 +207,16 @@ class World: self.tick(schedule) def with_systems( - self, schedule: ScheduleLabel, *systems: "System | Systems" + self, + schedule: ScheduleLabel, + *systems: "System | Systems", + sets: set[SystemSet] | None = None, ) -> "World": + if sets is None: + sets = set() if schedule not in self._schedules: self._schedules[schedule] = Schedule() - Systems(*systems)._apply(self._schedules[schedule]) + Systems(*systems).in_set(*sets)._apply(self._schedules[schedule]) return self def with_hook[T]( @@ -210,7 +229,7 @@ class World: case "insert": d = self._insert_hooks case "remove": - d = self._insert_hooks + d = self._remove_hooks if ty not in d: d[ty] = hook else: -- 2.53.0