Add ghost CPUs with original Pac-Man AI targeting

Implement four ghosts (Blinky, Pinky, Inky, Clyde) with authentic
Pac-Man AI: Blinky chases directly, Pinky targets 2 tiles ahead
(with original up-direction bug), Inky uses vector doubling from
Blinky, Clyde switches to scatter within 8-tile radius.

Includes chase/scatter mode cycling, ghost house exit with staggered
delays, directional sprite rendering with animation, and ghost-pacman
collision detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-23 11:42:34 +01:00
parent 91b548e0bf
commit 9028dd031c
6 changed files with 714 additions and 83 deletions

View File

@@ -35,6 +35,7 @@
(define maze-layer ((window 'new-layer!)))
(define coins-layer ((window 'new-layer!)))
(define key-layer ((window 'new-layer!)))
(define ghost-layer ((window 'new-layer!)))
(define pacman-layer ((window 'new-layer!)))
(define ui-layer ((window 'new-layer!)))
(define overlay-layer ((window 'new-layer!)))
@@ -47,7 +48,6 @@
((header-layer 'add-drawable!) header-tile)
;; draw-header! :: -> /
;; Draws the static header with the PAC-MAN title.
(define (draw-header!)
((header-tile 'draw-rectangle!) 0 0 width header-height color-header-bg)
((header-tile 'draw-text!)
@@ -81,6 +81,87 @@
((key-ui-sprite 'set-x!) key-ui-x)
((key-ui-sprite 'set-y!) key-ui-y)
;;
;; Coordinate conversion
;;
;; grid->pixel-x :: number -> number
(define (grid->pixel-x col)
(* cell-size-px col))
;; grid->pixel-y :: number -> number
(define (grid->pixel-y row)
(+ (* row cell-size-px) maze-offset-y))
;;
;; Ghost sprites
;;
;; load-direction-seq :: string, string -> tile-sequence
;; Loads a 2-frame animation sequence for one direction.
(define (load-direction-seq prefix dir-name)
(let ((seq (make-tile-sequence
(list (make-bitmap-tile
(string-append prefix dir-name "-1.png"))
(make-bitmap-tile
(string-append prefix dir-name "-2.png"))))))
((seq 'set-scale!) sprite-scale-ghost)
seq))
;; make-ghost-draw-state :: string -> ghost-draw-state
;; Creates sprite management state for one ghost. Returns a
;; dispatch closure for updating position, direction, animation.
(define (make-ghost-draw-state name)
(let* ((prefix (string-append "pacman-sprites/" name "-"))
(up-seq (load-direction-seq prefix "up"))
(down-seq (load-direction-seq prefix "down"))
(left-seq (load-direction-seq prefix "left"))
(right-seq (load-direction-seq prefix "right"))
(active-seq left-seq)
(cached-dir 'left))
;; Add initial sprite to ghost layer
((ghost-layer 'add-drawable!) active-seq)
;; dir->seq :: symbol -> tile-sequence
(define (dir->seq dir)
(cond ((eq? dir 'up) up-seq)
((eq? dir 'down) down-seq)
((eq? dir 'left) left-seq)
((eq? dir 'right) right-seq)
(else left-seq)))
(define (dispatch msg)
(cond
;; update! :: number, number, symbol -> /
;; Updates position and direction of this ghost's sprite.
((eq? msg 'update!)
(lambda (row col direction)
;; Swap sprite sequence if direction changed
(when (not (eq? direction cached-dir))
(let ((old-x ((active-seq 'get-x)))
(old-y ((active-seq 'get-y))))
((ghost-layer 'remove-drawable!) active-seq)
(set! active-seq (dir->seq direction))
((active-seq 'set-x!) old-x)
((active-seq 'set-y!) old-y)
((ghost-layer 'add-drawable!) active-seq)
(set! cached-dir direction)))
;; Update position
((active-seq 'set-x!) (grid->pixel-x col))
((active-seq 'set-y!) (grid->pixel-y row))))
;; animate! :: -> /
((eq? msg 'animate!)
(lambda () ((active-seq 'set-next!))))
(else (error "Ghost draw state -- Unknown message:" msg))))
dispatch))
;; Create draw state for each ghost (in fixed order matching level)
(define blinky-draw (make-ghost-draw-state "blinky"))
(define pinky-draw (make-ghost-draw-state "pinky"))
(define inky-draw (make-ghost-draw-state "inky"))
(define clyde-draw (make-ghost-draw-state "clyde"))
(define ghost-draw-states (list blinky-draw pinky-draw inky-draw clyde-draw))
;;
;; Pac-Man sprite
;;
@@ -112,27 +193,14 @@
(define cached-time "")
(define key-sprite-swapped? #f)
(define cached-paused? #f)
(define cached-time-up? #f)
(define cached-game-over? #f)
(define coins-dirty? #t)
;;
;; Coordinate conversion
;;
;; grid->pixel-x :: number -> number
(define (grid->pixel-x col)
(* cell-size-px col))
;; grid->pixel-y :: number -> number
(define (grid->pixel-y row)
(+ (* row cell-size-px) maze-offset-y))
;;
;; Draw functions
;;
;; draw-maze! :: maze -> /
;; Draws all walls and doors.
(define (draw-maze! maze)
((maze-tile 'clear!))
((maze 'for-each-cell)
@@ -154,7 +222,6 @@
color-door))))))
;; draw-coins! :: maze -> /
;; Redraws all coins as small round dots.
(define (draw-coins! maze)
((coins-tile 'clear!))
((maze 'for-each-cell)
@@ -168,14 +235,12 @@
color-coin)))))
;; init-key-position! :: key -> /
;; Sets the key sprite to its grid position. Called once at startup.
(define (init-key-position! key-obj)
(let ((pos (key-obj 'position)))
((key-sprite 'set-x!) (grid->pixel-x (pos 'col)))
((key-sprite 'set-y!) (grid->pixel-y (pos 'row)))))
;; draw-key! :: key -> /
;; Swaps the key sprite once when taken. No-op on subsequent frames.
(define (draw-key! key-obj)
(when (and (key-obj 'taken?) (not key-sprite-swapped?))
((key-layer 'remove-drawable!) key-sprite)
@@ -183,15 +248,15 @@
(set! key-sprite-swapped? #t)))
;; animate-pacman! :: number -> /
;; Advances the Pac-Man sprite animation based on elapsed time.
(define (animate-pacman! delta-time)
(set! time-since-last-animation (+ time-since-last-animation delta-time))
(when (>= time-since-last-animation animation-interval-ms)
((pacman-sprite 'set-next!))
;; Also animate ghost sprites
(for-each (lambda (gds) ((gds 'animate!))) ghost-draw-states)
(set! time-since-last-animation 0)))
;; draw-pacman! :: pacman -> /
;; Updates Pac-Man position and rotation.
(define (draw-pacman! pacman)
(let* ((pos (pacman 'position))
(direction (pacman 'direction)))
@@ -202,39 +267,44 @@
((eq? direction 'up) ((pacman-sprite 'rotate!) rotation-up))
((eq? direction 'down) ((pacman-sprite 'rotate!) rotation-down)))))
;; draw-ghosts! :: list -> /
;; Updates all ghost sprite positions and directions.
(define (draw-ghosts! ghosts)
(for-each
(lambda (ghost ghost-draw)
(let* ((pos (ghost 'position))
(row (pos 'row))
(col (pos 'col))
(dir (ghost 'direction)))
((ghost-draw 'update!) row col dir)))
ghosts ghost-draw-states))
;; draw-ui! :: score, timer -> /
;; Redraws score and time only when their values have changed.
(define (draw-ui! score timer)
(let ((current-score (score 'points))
(current-time ((timer 'format-time))))
(when (or (not (= current-score cached-score))
(not (string=? current-time cached-time)))
((ui-tile 'clear!))
;; Score label
((ui-tile 'draw-text!)
"SCORE" score-label-size score-label-x score-label-y color-text)
;; Score value
((ui-tile 'draw-text!)
(number->string current-score)
score-value-size score-value-x score-value-y color-text)
;; Sidebar separator
((ui-tile 'draw-rectangle!)
sidebar-x 0 sidebar-width height color-wall)
;; Time label
((ui-tile 'draw-text!)
"TIME" time-label-size time-label-x time-label-y color-text)
;; Time value
((ui-tile 'draw-text!)
current-time
time-value-size time-value-x time-value-y color-text)
;; Update cache
(set! cached-score current-score)
(set! cached-time current-time))))
;; draw-game-over! :: boolean -> /
;; Shows GAME OVER when time is up.
(define (draw-game-over! time-up?)
(when (and time-up? (not cached-time-up?))
;; Shows GAME OVER when time is up or ghost catches Pac-Man.
(define (draw-game-over! is-over?)
(when (and is-over? (not cached-game-over?))
(let ((overlay-tile (make-tile width height)))
((overlay-layer 'add-drawable!) overlay-tile)
((overlay-tile 'draw-rectangle!)
@@ -242,10 +312,9 @@
((overlay-tile 'draw-text!)
"GAME OVER" game-over-text-size
game-over-text-x game-over-text-y color-game-over))
(set! cached-time-up? #t)))
(set! cached-game-over? #t)))
;; draw-pause! :: boolean -> /
;; Only redraws when pause state actually changes.
(define (draw-pause! paused?)
(when (not (eq? paused? cached-paused?))
((overlay-layer 'empty!))
@@ -272,12 +341,12 @@
;;
;; draw-game! :: game -> /
;; Draws the full game. Only redraws changed elements.
(define (draw-game! game)
(let* ((level (game 'level))
(timer (level 'timer)))
;; Always update (lightweight sprite property sets)
(draw-pacman! (level 'pacman))
(draw-ghosts! (level 'ghosts))
;; Only redraw when dirty / changed
(draw-key! (level 'key))
(when coins-dirty?
@@ -286,22 +355,19 @@
(set! coins-dirty? #f))
(draw-ui! (level 'score) timer)
(draw-pause! (level 'paused?))
(draw-game-over! ((timer 'time-up?)))))
(draw-game-over! (or ((timer 'time-up?)) (level 'game-over?)))))
;;
;; Callback registration
;;
;; set-game-loop! :: (number -> /) -> /
(define (set-game-loop! fun)
((window 'set-update-callback!) fun))
;; set-key-callback! :: (symbol, any -> /) -> /
(define (set-key-callback! fun)
((window 'set-key-callback!) fun))
;; start-drawing! :: game -> /
;; Starts drawing by setting the draw callback.
(define (start-drawing! game)
(let ((level (game 'level)))
;; Static elements (drawn once)
@@ -309,6 +375,8 @@
(draw-maze! (level 'maze))
(draw-coins! (level 'maze))
(init-key-position! (level 'key))
;; Initialize ghost positions
(draw-ghosts! (level 'ghosts))
(set! coins-dirty? #f)
;; Register draw callback
((window 'set-draw-callback!)