From f30178f9aa4ec6e3c25d54cfe5a4c4d055f10acf Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 5 Oct 2026 13:03:01 +0200 Subject: [PATCH] Ghosts and pathfinding --- src/pacman/game/__init__.py | 208 +++++++++++++++++++++++++++++++----- 1 file changed, 184 insertions(+), 24 deletions(-) diff --git a/src/pacman/game/__init__.py b/src/pacman/game/__init__.py index de33de8..58c6dbf 100644 --- a/src/pacman/game/__init__.py +++ b/src/pacman/game/__init__.py @@ -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() -- 2.53.0