]> Untitled Git - axy/ft/pacman.git/commitdiff
Ghosts and pathfinding
author= <=>
Mon, 5 Oct 2026 11:03:01 +0000 (13:03 +0200)
committer= <=>
Mon, 5 Oct 2026 11:03:01 +0000 (13:03 +0200)
src/pacman/game/__init__.py

index de33de8c0a30a01744a6900c6ed83fa0eff5cb95..58c6dbf3333f734715c72662313dc89a35e6f5ca 100644 (file)
@@ -1,9 +1,10 @@
 from collections.abc import Callable
 from dataclasses import dataclass
+from enum import Enum, auto
 from random import random
 
 from mazegenerator import MazeGenerator
-from pygame import K_a, K_d, K_s, K_w, Vector3
+from pygame import K_a, K_d, K_s, K_w, Vector2, Vector3
 from pygame.math import clamp
 
 from pacman.ecs import minimal_plugins
@@ -31,13 +32,16 @@ class Maze(Component):
         self._y = y
 
 
+_DIR_OFFSETS = [(0, -1), (1, 0), (0, 1), (-1, 0)]
+
+
 @dataclass
 class Cell(Component):
     n: int
     grid_pos: tuple[int, int]
 
     def neighbor(self, idx: int) -> tuple[int, int]:
-        dx, dy = [(0, -1), (1, 0), (0, 1), (-1, 0)][idx]
+        dx, dy = _DIR_OFFSETS[idx]
         return (self.grid_pos[0] + dx, self.grid_pos[1] + dy)
 
     def walls(self) -> list[bool]:
@@ -151,7 +155,7 @@ def setup_maze(world: World) -> None:
 
 @dataclass
 class TargetTile(Component):
-    pos: Entity
+    pos: tuple[int, int]
 
 
 @dataclass
@@ -163,6 +167,7 @@ class TilePos(Component):
 class InMotion(Component):
     src: Entity
     pos: Entity
+    direction: int
 
 
 @dataclass
@@ -214,19 +219,20 @@ def move_to_target(
                 continue
             dst_next = cell.neighbor(direction)
             dst_ent = maze._cells[dst_next]
-            candidates.append(dst_ent)
+            candidates.append((dst_ent, direction))
         candidates.sort(
             key=lambda e: (
                 2 << 32
-                if e is motion.src
-                else cells[e][1].xy.distance_squared_to(
-                    cells[target.pos][1].xy
+                if e[0] is motion.src
+                else Vector2(cells[e[0]][0].grid_pos).distance_squared_to(
+                    target.pos
                 )
             ),
         )
-        nxt = candidates[0]
+        nxt, nxt_dir = candidates[0]
         motion.src = motion.pos
         motion.pos = nxt
+        motion.direction = nxt_dir
 
 
 @dataclass
@@ -246,7 +252,7 @@ class Inputs(Resource):
 
 @dataclass
 class Pacman(Component):
-    current_direction: int
+    pass
 
 
 def spawn_pacman(
@@ -255,7 +261,6 @@ def spawn_pacman(
     maze = maze_q.single()
     if maze is None:
         return
-
     world.spawn(
         tuple[
             Coord2D,
@@ -282,17 +287,106 @@ def spawn_pacman(
                 ],
                 6,
             ),
-            Pacman(1),
+            Pacman(),
             ShouldRender(),
-            InMotion(maze._cells[(0, 0)], maze._cells[(0, 0)]),
+            InMotion(maze._cells[(0, 0)], maze._cells[(0, 0)], 1),
             MoveSpeed(3.0),
             TilePos(maze._cells[(0, 0)]),
         ),
     )
 
 
+class GhostVariant(Enum):
+    A = auto()
+    B = auto()
+    C = auto()
+    D = auto()
+
+    def compute_target_tile(
+        self,
+        player: tuple[int, int],
+        player_dir: int,
+        pos: tuple[int, int],
+        slf: "Ghost",
+        blinky_pos: tuple[int, int],
+    ) -> tuple[int, int]:
+        match self:
+            case GhostVariant.A:
+                return player
+            case GhostVariant.B:
+                if Vector2(player).distance_squared_to(pos) >= 64:
+                    return player
+                return slf.home
+            case GhostVariant.C:
+                dx, dy = _DIR_OFFSETS[player_dir]
+                x, y = player
+                return (x + 2 * dx, y + 2 * dy)
+            case GhostVariant.D:
+                dx, dy = _DIR_OFFSETS[player_dir]
+                x, y = player
+                x_target, y_target = (x + 2 * dx, y + 2 * dy)
+                x_blinky, y_blinky = blinky_pos
+                return ((x_target - x_blinky) * 2, (y_target - y_blinky) * 2)
+
+
+@dataclass
+class Ghost(Component):
+    variant: GhostVariant
+    home: tuple[int, int]
+
+
+def spawn_ghosts(
+    world: World, assets: Assets[Sprite2D], maze_q: Query[Maze]
+) -> None:
+    maze = maze_q.single()
+    if maze is None:
+        return
+    world.spawn_many(
+        tuple[
+            Coord2D,
+            CoordAbs2D,
+            AnimationSet,
+            ShouldRender,
+            InMotion,
+            MoveSpeed,
+            TilePos,
+            Ghost,
+            TargetTile,
+        ],
+        [
+            (
+                Coord2D(pos[0] * 64, pos[1] * 64, -10),
+                CoordAbs2D(),
+                AnimationSet(
+                    [
+                        [
+                            assets.load(
+                                f"./asset/enemy/ghost{variant.name}_{direction}.png"
+                            )
+                        ]
+                        for direction in "URDL"
+                    ],
+                    6,
+                ),
+                ShouldRender(),
+                InMotion(maze._cells[pos], maze._cells[pos], 1),
+                MoveSpeed(2.5),
+                TilePos(maze._cells[pos]),
+                Ghost(variant, pos),
+                TargetTile(pos),
+            )
+            for variant, pos in [
+                (GhostVariant.A, (0, 0)),
+                (GhostVariant.B, (maze._x - 1, 0)),
+                (GhostVariant.C, (0, maze._y - 1)),
+                (GhostVariant.D, (maze._x - 1, maze._y - 1)),
+            ]
+        ],
+    )
+
+
 def move_pacman(
-    pacman_q: Query[tuple[Pacman, Coord2D, InMotion, TilePos]],
+    pacman_q: Query[tuple[Coord2D, InMotion, TilePos], Pacman],
     inputs: Inputs,
     cells: Query[tuple[Cell, Coord2D]],
     maze_q: Query[Maze],
@@ -300,32 +394,88 @@ def move_pacman(
     maze = maze_q.single()
     if maze is None:
         return
-    for pacman, coord, motion, pos in pacman_q:
+    for coord, motion, pos in pacman_q:
         direction = inputs.get_dir()
         cell, cell_pos = cells[pos.pos]
         if coord.xy.distance_to(cell_pos.xy) < 12.0:
             if direction is None:
-                direction = pacman.current_direction
+                direction = motion.direction
             if cell.walls()[direction]:
-                direction = pacman.current_direction
+                direction = motion.direction
             if not cell.walls()[direction]:
                 motion.src = pos.pos
                 motion.pos = maze._cells[cell.neighbor(direction)]
-                pacman.current_direction = direction
-        elif ((pacman.current_direction + 2) % 4) == direction:
+                motion.direction = direction
+        elif ((motion.direction + 2) % 4) == direction:
             tmp = motion.pos
             motion.pos = motion.src
             motion.src = tmp
-            pacman.current_direction = direction
+            motion.direction = direction
 
 
-def update_pacman_anim(pacman_q: Query[tuple[Pacman, AnimationSet]]) -> None:
-    for pacman, anim in pacman_q:
-        anim.idx = pacman.current_direction
+@dataclass
+class ChaseStatus(Resource):
+    tick: int = 0
+    cycle_chase: int = 60 * 10
+    cycle_wander: int = 60 * 10
+    chase: bool = False
+
+
+def update_ghost_targets(
+    ghosts: Query[tuple[Ghost, TilePos, TargetTile]],
+    pacman_q: Query[tuple[InMotion, TilePos], Pacman],
+    cells: Query[Cell],
+    chase: ChaseStatus,
+) -> None:
+    player = pacman_q.single()
+    if player is None:
+        return
+    player_motion, player_pos = player
+    blinky_pos = next(
+        pos.pos for ghost, pos, _ in ghosts if ghost.variant == GhostVariant.A
+    )
+    for ghost, pos, target in ghosts:
+        if not chase.chase:
+            target.pos = ghost.home
+            continue
+        target.pos = ghost.variant.compute_target_tile(
+            cells[player_pos.pos].grid_pos,
+            player_motion.direction,
+            cells[pos.pos].grid_pos,
+            ghost,
+            cells[blinky_pos].grid_pos,
+        )
+
+
+def update_chase_status(status: ChaseStatus) -> None:
+    status.tick += 1
+    status.chase = status.tick > status.cycle_wander
+    if status.tick < (status.cycle_wander + status.cycle_chase):
+        return
+    status.tick = 0
+    if status.cycle_wander > 60 * 4:
+        status.cycle_wander -= 60
+    if status.cycle_chase > 60 * 7:
+        status.cycle_chase -= 60
+
+
+def update_pacman_anim(
+    pacman_q: Query[tuple[InMotion, AnimationSet], Pacman],
+) -> None:
+    for motion, anim in pacman_q:
+        anim.idx = motion.direction
+
+
+def update_ghost_anim(
+    pacman_q: Query[tuple[InMotion, AnimationSet], Ghost],
+) -> None:
+    for motion, anim in pacman_q:
+        anim.idx = motion.direction
 
 
 def setup_resources(world: World) -> None:
     world.spawn(Inputs, Inputs(set()))
+    world.spawn(ChaseStatus, ChaseStatus())
 
 
 def update_inputs(inputs: Inputs, events: WindowEvents) -> None:
@@ -345,12 +495,22 @@ def run_game() -> None:
             spawn_superpacgums,
             spawn_pacgums,
             spawn_pacman,
+            spawn_ghosts,
         ).chain(),
     ).with_systems(
         UpdateSchedule,
-        Systems(move_pacman, move_in_motion, update_tile_pos, move_to_target)
+        Systems(
+            move_pacman,
+            update_tile_pos,
+            update_ghost_targets,
+            move_to_target,
+            move_in_motion,
+            update_chase_status,
+        )
         .chain()
         .in_set(Update),
         Systems(update_inputs).in_set(PreUpdate).after(InputsSystems),
-        Systems(update_pacman_anim).before(GraphicsSystems).in_set(PostUpdate),
+        Systems(update_pacman_anim, update_ghost_anim)
+        .before(GraphicsSystems)
+        .in_set(PostUpdate),
     ).run_main()