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!)

View File

@@ -0,0 +1,94 @@
#lang r7rs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ghost ADT ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A ghost has a position, direction, movement mode, and type identity.
;; The targeting AI is handled by the level — this ADT only stores state.
;; Each ghost instance represents one of the four ghosts (Blinky, Inky,
;; Pinky, Clyde). Contains NO graphics code.
(define-library (pacman-project adt ghost)
(import (scheme base)
(pacman-project adt position))
(export make-ghost)
(begin
;; make-ghost :: symbol, number, number, number, number, number -> ghost
;; Creates a ghost of the given type at start-row/col with a scatter
;; corner target and a house exit delay (0 = starts outside).
(define (make-ghost ghost-type start-row start-col
scatter-row scatter-col exit-delay)
(let ((position (make-position start-row start-col))
(direction 'left)
(mode (if (= exit-delay 0) 'scatter 'in-house))
(scatter-target (make-position scatter-row scatter-col))
(house-timer exit-delay)
(movement-timer 0)
(reverse-queued? #f))
;; direction! :: symbol -> /
(define (direction! new-dir)
(set! direction new-dir))
;; mode! :: symbol -> /
;; Sets the ghost mode. When switching between chase/scatter,
;; the ghost must reverse direction (queued for next move).
(define (mode! new-mode)
(when (and (not (eq? mode new-mode))
(not (eq? mode 'in-house))
(not (eq? new-mode 'in-house)))
(set! reverse-queued? #t))
(set! mode new-mode))
;; move! :: number, number -> /
(define (move! delta-row delta-col)
((position 'row!) (+ (position 'row) delta-row))
((position 'col!) (+ (position 'col) delta-col)))
;; consume-reverse! :: -> boolean
;; Returns #t and clears the flag if a reverse was queued.
(define (consume-reverse!)
(if reverse-queued?
(begin (set! reverse-queued? #f) #t)
#f))
;; update-house-timer! :: number -> /
;; Decreases house timer. When it reaches 0, ghost exits.
(define (update-house-timer! delta-time)
(when (eq? mode 'in-house)
(set! house-timer (- house-timer delta-time))
(when (<= house-timer 0)
(set! mode 'scatter))))
;; reset-movement-timer! :: -> /
(define (reset-movement-timer!)
(set! movement-timer 0))
;; advance-movement-timer! :: number -> number
;; Returns updated movement timer value.
(define (advance-movement-timer! delta-time)
(set! movement-timer (+ movement-timer delta-time))
movement-timer)
;; dispatch-ghost :: symbol -> any
(define (dispatch-ghost msg)
(cond ((eq? msg 'position) position)
((eq? msg 'direction) direction)
((eq? msg 'direction!) direction!)
((eq? msg 'type) ghost-type)
((eq? msg 'mode) mode)
((eq? msg 'mode!) mode!)
((eq? msg 'move!) move!)
((eq? msg 'scatter-target) scatter-target)
((eq? msg 'in-house?) (eq? mode 'in-house))
((eq? msg 'consume-reverse!) consume-reverse!)
((eq? msg 'update-house-timer!) update-house-timer!)
((eq? msg 'movement-timer) movement-timer)
((eq? msg 'advance-movement-timer!) advance-movement-timer!)
((eq? msg 'reset-movement-timer!) reset-movement-timer!)
(else (error "Ghost ADT -- Unknown message:" msg))))
dispatch-ghost))))

View File

@@ -4,11 +4,13 @@
;; Level ADT ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Contains all game logic: automatic Pac-Man movement, collision detection,
;; coin/key pickup, door opening, teleportation, pause, and time management.
;; Pac-Man moves automatically in its current direction. Arrow keys queue a
;; desired turn direction, which is applied at the next movement tick if the
;; path is clear. Contains NO graphics code.
;; Contains all game logic: automatic Pac-Man movement, ghost AI, collision
;; detection, coin/key pickup, door opening, teleportation, pause, and time
;; management. Pac-Man moves automatically in its current direction. Arrow
;; keys queue a desired turn direction, which is applied at the next movement
;; tick if the path is clear. Ghost targeting follows the original Pac-Man
;; AI: Blinky chases directly, Pinky targets ahead, Inky uses vector
;; doubling from Blinky, Clyde is shy within 8 tiles. Contains NO graphics.
(define-library (pacman-project adt level)
(import (scheme base)
@@ -16,6 +18,7 @@
(pacman-project adt position)
(pacman-project adt maze)
(pacman-project adt pacman)
(pacman-project adt ghost)
(pacman-project adt key)
(pacman-project adt score)
(pacman-project adt timer))
@@ -24,7 +27,7 @@
(begin
;; make-level :: -> level
;; Creates a new level with all game objects.
;; Creates a new level with all game objects including four ghosts.
(define (make-level)
(let ((maze (make-maze))
(pacman (make-pacman 5 2))
@@ -32,20 +35,68 @@
(score (make-score))
(timer (make-timer))
(paused? #f)
(game-over? #f)
(queued-direction #f)
(movement-timer 0)
(on-coins-changed! (lambda () #f))
(on-maze-changed! (lambda () #f)))
(on-maze-changed! (lambda () #f))
(on-ghosts-changed! (lambda () #f)))
;; Initialize key after maze is created.
(set! key (make-key maze))
;;
;; Create four ghosts
;;
(define blinky
(make-ghost 'blinky
blinky-start-row blinky-start-col
blinky-scatter-row blinky-scatter-col
0))
(define pinky
(make-ghost 'pinky
pinky-start-row pinky-start-col
pinky-scatter-row pinky-scatter-col
pinky-exit-delay))
(define inky
(make-ghost 'inky
inky-start-row inky-start-col
inky-scatter-row inky-scatter-col
inky-exit-delay))
(define clyde
(make-ghost 'clyde
clyde-start-row clyde-start-col
clyde-scatter-row clyde-scatter-col
clyde-exit-delay))
(define ghosts (list blinky pinky inky clyde))
;;
;; Ghost mode cycling
;;
;; Mode schedule: alternating scatter/chase durations.
;; After the last entry, ghosts stay in chase forever.
(define mode-schedule
(list (cons 'scatter scatter-duration-1)
(cons 'chase chase-duration-1)
(cons 'scatter scatter-duration-2)
(cons 'chase chase-duration-2)
(cons 'scatter scatter-duration-3)))
(define current-schedule mode-schedule)
(define mode-timer (if (null? mode-schedule) 0 (cdar mode-schedule)))
(define global-mode 'scatter)
;;
;; Direction helpers
;;
;; direction->delta :: symbol -> (number . number)
;; Converts a direction to a (delta-row . delta-col) pair.
(define (direction->delta direction)
(cond ((eq? direction 'right) (cons 0 1))
((eq? direction 'left) (cons 0 -1))
@@ -53,29 +104,263 @@
((eq? direction 'down) (cons 1 0))
(else (cons 0 0))))
;; reverse-direction :: symbol -> symbol
(define (reverse-direction dir)
(cond ((eq? dir 'right) 'left)
((eq? dir 'left) 'right)
((eq? dir 'up) 'down)
((eq? dir 'down) 'up)
(else dir)))
;; can-move? :: symbol -> boolean
;; Checks if Pac-Man can move in the given direction (no wall or
;; locked door blocking the way).
;; Checks if Pac-Man can move in the given direction.
(define (can-move? direction)
(let* ((delta (direction->delta direction))
(current-pos (pacman 'position))
(next-row (+ (current-pos 'row) (car delta)))
(next-col (+ (current-pos 'col) (cdr delta))))
(cond
;; Teleportation tunnels are always passable.
((or (< next-col 0) (>= next-col (maze 'cols))) #t)
;; Walls block.
(((maze 'wall?) next-row next-col) #f)
;; Doors block unless the key has been taken.
(((maze 'door?) next-row next-col) (key 'taken?))
(else #t))))
;; ghost-can-move? :: ghost, symbol -> boolean
;; Checks if a ghost can move in the given direction.
;; Active ghosts cannot re-enter the ghost house.
(define (ghost-can-move? ghost direction)
(let* ((delta (direction->delta direction))
(pos (ghost 'position))
(next-row (+ (pos 'row) (car delta)))
(next-col (+ (pos 'col) (cdr delta))))
(cond
;; Off-grid horizontally: tunnel
((or (< next-col 0) (>= next-col (maze 'cols))) #t)
;; Walls block
(((maze 'wall?) next-row next-col) #f)
;; All doors block active ghosts (including ghost house door)
(((maze 'door?) next-row next-col) #f)
(else #t))))
;;
;; Distance calculation
;;
;; squared-distance :: number, number, number, number -> number
(define (squared-distance r1 c1 r2 c2)
(+ (* (- r1 r2) (- r1 r2))
(* (- c1 c2) (- c1 c2))))
;;
;; Ghost targeting AI
;;
;; ghost-target :: ghost -> (number . number)
;; Returns the target tile (row . col) for the ghost based on its
;; current mode and type-specific AI.
(define (ghost-target ghost)
(let ((mode (ghost 'mode)))
(cond
;; Scatter: head to assigned corner
((eq? mode 'scatter)
(let ((st (ghost 'scatter-target)))
(cons (st 'row) (st 'col))))
;; Chase: type-specific targeting
((eq? mode 'chase)
(let* ((pac-pos (pacman 'position))
(pac-row (pac-pos 'row))
(pac-col (pac-pos 'col))
(pac-dir (pacman 'direction))
(ghost-type (ghost 'type)))
(cond
;; Blinky: target Pac-Man directly
((eq? ghost-type 'blinky)
(cons pac-row pac-col))
;; Pinky: target 2 tiles ahead of Pac-Man
;; Original bug: when facing up, also offset 2 left
((eq? ghost-type 'pinky)
(let ((ahead (direction->delta pac-dir)))
(if (eq? pac-dir 'up)
(cons (+ pac-row (* (car ahead) pinky-look-ahead))
(+ pac-col (* (cdr ahead) pinky-look-ahead) (- pinky-look-ahead)))
(cons (+ pac-row (* (car ahead) pinky-look-ahead))
(+ pac-col (* (cdr ahead) pinky-look-ahead))))))
;; Inky: double the vector from Blinky to 2-ahead-of-Pac-Man
((eq? ghost-type 'inky)
(let* ((ahead (direction->delta pac-dir))
(pivot-row (+ pac-row (* (car ahead) pinky-look-ahead)))
(pivot-col (+ pac-col (* (cdr ahead) pinky-look-ahead)))
(blinky-pos (blinky 'position)))
(cons (+ pivot-row (- pivot-row (blinky-pos 'row)))
(+ pivot-col (- pivot-col (blinky-pos 'col))))))
;; Clyde: chase Pac-Man, but scatter when within 8 tiles
((eq? ghost-type 'clyde)
(let* ((ghost-pos (ghost 'position))
(dist-sq (squared-distance
(ghost-pos 'row) (ghost-pos 'col)
pac-row pac-col)))
(if (< dist-sq (* clyde-shy-distance clyde-shy-distance))
(let ((st (ghost 'scatter-target)))
(cons (st 'row) (st 'col)))
(cons pac-row pac-col))))
;; Fallback
(else (cons pac-row pac-col)))))
;; Default (in-house etc): stay put
(else
(let ((pos (ghost 'position)))
(cons (pos 'row) (pos 'col)))))))
;; choose-ghost-direction :: ghost -> symbol
;; Picks the best direction for the ghost at its current tile.
;; Cannot reverse; chooses direction minimizing distance to target.
;; Tie-break order: up, left, down, right.
(define (choose-ghost-direction ghost)
(let* ((target (ghost-target ghost))
(target-row (car target))
(target-col (cdr target))
(current-dir (ghost 'direction))
(reverse-dir (reverse-direction current-dir))
(pos (ghost 'position))
(row (pos 'row))
(col (pos 'col))
(candidates '(up left down right)))
(let loop ((dirs candidates)
(best-dir current-dir)
(best-dist 999999999))
(if (null? dirs)
;; If no valid direction found, reverse as last resort
(if (= best-dist 999999999) reverse-dir best-dir)
(let* ((dir (car dirs))
(delta (direction->delta dir))
(nr (+ row (car delta)))
(nc (+ col (cdr delta))))
(if (and (not (eq? dir reverse-dir))
(ghost-can-move? ghost dir))
(let ((dist (squared-distance nr nc target-row target-col)))
(if (< dist best-dist)
(loop (cdr dirs) dir dist)
(loop (cdr dirs) best-dir best-dist)))
(loop (cdr dirs) best-dir best-dist)))))))
;;
;; Ghost movement
;;
;; move-ghost! :: ghost -> /
;; Moves a ghost one tile in its chosen direction.
(define (move-ghost! ghost)
(let* ((dir (ghost 'direction))
(delta (direction->delta dir))
(pos (ghost 'position))
(next-row (+ (pos 'row) (car delta)))
(next-col (+ (pos 'col) (cdr delta))))
;; Handle tunnel teleportation
(cond
((< next-col 0)
((pos 'col!) (- (maze 'cols) 1)))
((>= next-col (maze 'cols))
((pos 'col!) 0))
(else
((ghost 'move!) (car delta) (cdr delta))))))
;; advance-ghost! :: ghost -> /
;; Chooses direction and moves one ghost.
(define (advance-ghost! ghost)
(when (not (ghost 'in-house?))
;; Handle queued reverse from mode change
(when ((ghost 'consume-reverse!))
((ghost 'direction!) (reverse-direction (ghost 'direction))))
;; Choose best direction at current tile
(let ((new-dir (choose-ghost-direction ghost)))
((ghost 'direction!) new-dir)
(move-ghost! ghost))
(on-ghosts-changed!)))
;; exit-ghost-house! :: ghost -> /
;; Moves a ghost from inside the house to the exit position.
(define (exit-ghost-house! ghost)
(let ((pos (ghost 'position)))
((pos 'row!) ghost-house-exit-row)
((pos 'col!) ghost-house-exit-col)
((ghost 'direction!) 'left)
(on-ghosts-changed!)))
;;
;; Ghost-Pacman collision
;;
;; check-ghost-collision! :: -> /
;; Checks if any ghost occupies the same tile as Pac-Man.
(define (check-ghost-collision!)
(let ((pac-pos (pacman 'position)))
(for-each
(lambda (ghost)
(when (and (not (ghost 'in-house?))
(= ((ghost 'position) 'row) (pac-pos 'row))
(= ((ghost 'position) 'col) (pac-pos 'col)))
(set! game-over? #t)))
ghosts)))
;;
;; Mode cycling
;;
;; update-ghost-mode! :: number -> /
;; Advances the global mode timer and switches scatter/chase.
(define (update-ghost-mode! delta-time)
(when (not (null? current-schedule))
(set! mode-timer (- mode-timer delta-time))
(when (<= mode-timer 0)
(set! current-schedule (cdr current-schedule))
(if (null? current-schedule)
;; Permanent chase after schedule ends
(begin
(set! global-mode 'chase)
(for-each (lambda (g) ((g 'mode!) 'chase)) ghosts))
(let ((next-mode (caar current-schedule))
(next-duration (cdar current-schedule)))
(set! global-mode next-mode)
(set! mode-timer next-duration)
(for-each (lambda (g)
(when (not (g 'in-house?))
((g 'mode!) next-mode)))
ghosts))))))
;;
;; Update ghosts
;;
;; update-ghosts! :: number -> /
;; Updates all ghost timers, house exits, and movement.
(define (update-ghosts! delta-time)
(for-each
(lambda (ghost)
;; Update house timer for ghosts still inside
(let ((was-in-house? (ghost 'in-house?)))
((ghost 'update-house-timer!) delta-time)
;; Ghost just exited house
(when (and was-in-house? (not (ghost 'in-house?)))
(exit-ghost-house! ghost)
((ghost 'mode!) global-mode)))
;; Movement tick for active ghosts
(when (not (ghost 'in-house?))
(let ((mt ((ghost 'advance-movement-timer!) delta-time)))
(when (>= mt ghost-speed-ms)
(advance-ghost! ghost)
((ghost 'reset-movement-timer!))))))
ghosts))
;;
;; Coin logic
;;
;; eat-coin! :: number, number -> /
;; Removes the coin at the cell and updates score/time.
(define (eat-coin! row col)
((maze 'cell-set!) row col cell-type-empty)
((score 'increase!))
@@ -87,7 +372,6 @@
;;
;; pick-up-key! :: number, number -> /
;; Picks up the key and clears the cell.
(define (pick-up-key! row col)
((maze 'cell-set!) row col cell-type-empty)
((key 'take!))
@@ -98,7 +382,6 @@
;;
;; teleport-horizontal! :: number, number -> /
;; Teleports Pac-Man to the other side of the maze.
(define (teleport-horizontal! row col)
(let ((pac-pos (pacman 'position)))
(cond ((< col 0)
@@ -113,8 +396,6 @@
;;
;; move-pacman! :: symbol -> /
;; Moves Pac-Man one step in the given direction, handling collisions,
;; teleportation, and item pickup.
(define (move-pacman! direction)
(let* ((delta (direction->delta direction))
(delta-row (car delta))
@@ -123,25 +404,20 @@
(next-row (+ (current-pos 'row) delta-row))
(next-col (+ (current-pos 'col) delta-col)))
;; Update facing direction for the draw layer.
((pacman 'direction!) direction)
(cond
;; Teleportation: outside grid horizontally.
((or (< next-col 0) (>= next-col (maze 'cols)))
(teleport-horizontal! next-row next-col))
;; Door: open it if key has been taken.
(((maze 'door?) next-row next-col)
(when (key 'taken?)
((maze 'remove-door!) next-row next-col)
(on-maze-changed!)))
;; Normal movement: only if not a wall.
(else
(when (not ((maze 'wall?) next-row next-col))
((pacman 'move!) delta-row delta-col)
;; Check what's at the new position.
(cond
(((maze 'key?) next-row next-col)
(pick-up-key! next-row next-col))
@@ -149,17 +425,13 @@
(eat-coin! next-row next-col))))))))
;; advance-pacman! :: -> /
;; Called every movement tick. Tries the queued direction first; if
;; that path is blocked, continues in the current direction.
(define (advance-pacman!)
(when (not ((timer 'time-up?)))
(when (not (or ((timer 'time-up?)) game-over?))
(let ((current-dir (pacman 'direction)))
;; Try the queued direction first.
(cond
((and queued-direction (can-move? queued-direction))
(move-pacman! queued-direction)
(set! queued-direction #f))
;; Otherwise keep moving in the current direction.
((can-move? current-dir)
(move-pacman! current-dir))))))
@@ -167,7 +439,6 @@
;; Pause logic
;;
;; toggle-pause! :: -> /
(define (toggle-pause!)
(set! paused? (not paused?)))
@@ -176,7 +447,6 @@
;;
;; key-press! :: symbol -> /
;; Processes a key press. Arrow keys queue a desired direction.
(define (key-press! pressed-key)
(cond
((eq? pressed-key 'escape) (toggle-pause!))
@@ -188,45 +458,55 @@
((eq? pressed-key 'down) (set! queued-direction 'down))))))
;;
;; Update (game loop function)
;; Update (game loop)
;;
;; update! :: number -> /
;; Called each frame with elapsed milliseconds. Advances the movement
;; timer and moves Pac-Man automatically when the interval elapses.
(define (update! delta-time)
(when (not paused?)
(when (not (or paused? game-over?))
((timer 'decrease!) delta-time)
;; Pac-Man movement
(set! movement-timer (+ movement-timer delta-time))
(when (>= movement-timer pacman-speed-ms)
(advance-pacman!)
(set! movement-timer 0))))
(set! movement-timer 0)
(check-ghost-collision!))
;; Ghost mode cycling and movement
(update-ghost-mode! delta-time)
(update-ghosts! delta-time)
(check-ghost-collision!)))
;;
;; Callbacks
;;
(define (set-on-coins-changed! callback)
(set! on-coins-changed! callback))
(define (set-on-maze-changed! callback)
(set! on-maze-changed! callback))
(define (set-on-ghosts-changed! callback)
(set! on-ghosts-changed! callback))
;;
;; Dispatch
;;
;; set-on-coins-changed! :: (-> /) -> /
;; Registers a callback for when coins change (eaten or key picked up).
(define (set-on-coins-changed! callback)
(set! on-coins-changed! callback))
;; set-on-maze-changed! :: (-> /) -> /
;; Registers a callback for when the maze changes (door removed).
(define (set-on-maze-changed! callback)
(set! on-maze-changed! callback))
(define (dispatch-level msg)
(cond ((eq? msg 'maze) maze)
((eq? msg 'pacman) pacman)
((eq? msg 'ghosts) ghosts)
((eq? msg 'key) key)
((eq? msg 'score) score)
((eq? msg 'timer) timer)
((eq? msg 'paused?) paused?)
((eq? msg 'game-over?) game-over?)
((eq? msg 'key-press!) key-press!)
((eq? msg 'update!) update!)
((eq? msg 'set-on-coins-changed!) set-on-coins-changed!)
((eq? msg 'set-on-maze-changed!) set-on-maze-changed!)
((eq? msg 'set-on-ghosts-changed!) set-on-ghosts-changed!)
(else (error "Level ADT -- Unknown message:" msg))))
dispatch-level))))

View File

@@ -33,9 +33,54 @@
sprite-scale-pacman
sprite-scale-key
sprite-scale-key-ui
sprite-scale-ghost
;; Movement
pacman-speed-ms
ghost-speed-ms
;; Ghost house
ghost-house-door-row
ghost-house-door-col-left
ghost-house-door-col-right
ghost-house-exit-row
ghost-house-exit-col
;; Ghost start positions
blinky-start-row
blinky-start-col
pinky-start-row
pinky-start-col
inky-start-row
inky-start-col
clyde-start-row
clyde-start-col
;; Ghost scatter corners
blinky-scatter-row
blinky-scatter-col
pinky-scatter-row
pinky-scatter-col
inky-scatter-row
inky-scatter-col
clyde-scatter-row
clyde-scatter-col
;; Ghost house exit delays (ms)
pinky-exit-delay
inky-exit-delay
clyde-exit-delay
;; Ghost mode durations (ms)
scatter-duration-1
chase-duration-1
scatter-duration-2
chase-duration-2
scatter-duration-3
;; Ghost AI
clyde-shy-distance
pinky-look-ahead
;; Animation
animation-interval-ms
@@ -123,9 +168,54 @@
(define sprite-scale-pacman 1.5)
(define sprite-scale-key 1.5)
(define sprite-scale-key-ui 3)
(define sprite-scale-ghost 1.5)
;; Movement speed: time between automatic movement ticks
(define pacman-speed-ms 200)
(define ghost-speed-ms 220)
;; Ghost house position (door and exit point above door)
(define ghost-house-door-row 12)
(define ghost-house-door-col-left 13)
(define ghost-house-door-col-right 14)
(define ghost-house-exit-row 11)
(define ghost-house-exit-col 14)
;; Ghost start positions
(define blinky-start-row 11)
(define blinky-start-col 14)
(define pinky-start-row 14)
(define pinky-start-col 13)
(define inky-start-row 14)
(define inky-start-col 11)
(define clyde-start-row 14)
(define clyde-start-col 16)
;; Ghost scatter target corners
(define blinky-scatter-row 0)
(define blinky-scatter-col 27)
(define pinky-scatter-row 0)
(define pinky-scatter-col 0)
(define inky-scatter-row 30)
(define inky-scatter-col 27)
(define clyde-scatter-row 30)
(define clyde-scatter-col 0)
;; Ghost house exit delays (ms) — staggered release
(define pinky-exit-delay 2000)
(define inky-exit-delay 5000)
(define clyde-exit-delay 8000)
;; Ghost mode durations (ms) — scatter/chase alternation
(define scatter-duration-1 7000)
(define chase-duration-1 20000)
(define scatter-duration-2 7000)
(define chase-duration-2 20000)
(define scatter-duration-3 5000)
;; Ghost AI parameters
(define clyde-shy-distance 8)
(define pinky-look-ahead 2)
;; Animation timing
(define animation-interval-ms 100)

View File

@@ -11,6 +11,7 @@
(prefix (pacman-project tests test-position) position:)
(prefix (pacman-project tests test-maze) maze:)
(prefix (pacman-project tests test-pacman) pacman:)
(prefix (pacman-project tests test-ghost) ghost:)
(prefix (pacman-project tests test-score) score:)
(prefix (pacman-project tests test-timer) timer:))
@@ -18,6 +19,7 @@
(position:test)
(maze:test)
(pacman:test)
(ghost:test)
(score:test)
(timer:test))

View File

@@ -0,0 +1,97 @@
#lang r7rs
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Tests: Ghost ADT ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define-library (pacman-project tests test-ghost)
(import (scheme base)
(pp1 tests)
(pacman-project adt ghost))
(export test)
(begin
;; Test creation and initial state
(define (test-creation)
(define g (make-ghost 'blinky 11 14 0 27 0))
(define pos (g 'position))
(check-eq? (pos 'row) 11 "Start row should be 11")
(check-eq? (pos 'col) 14 "Start col should be 14")
(check-eq? (g 'type) 'blinky "Type should be blinky")
(check-eq? (g 'direction) 'left "Default direction should be left"))
;; Test that exit-delay 0 means ghost starts outside (scatter mode)
(define (test-blinky-starts-active)
(define g (make-ghost 'blinky 11 14 0 27 0))
(check-eq? (g 'in-house?) #f "Blinky should start outside")
(check-eq? (g 'mode) 'scatter "Blinky should start in scatter"))
;; Test that non-zero exit-delay means ghost starts in house
(define (test-pinky-starts-in-house)
(define g (make-ghost 'pinky 14 13 0 0 2000))
(check-eq? (g 'in-house?) #t "Pinky should start in house")
(check-eq? (g 'mode) 'in-house "Pinky mode should be in-house"))
;; Test direction change
(define (test-direction)
(define g (make-ghost 'blinky 11 14 0 27 0))
((g 'direction!) 'right)
(check-eq? (g 'direction) 'right "Direction should be right"))
;; Test mode change and reverse queuing
(define (test-mode-change-queues-reverse)
(define g (make-ghost 'blinky 11 14 0 27 0))
((g 'mode!) 'chase)
(check-eq? (g 'mode) 'chase "Mode should be chase")
(check-eq? ((g 'consume-reverse!)) #t "Reverse should be queued after mode change"))
;; Test reverse consumed only once
(define (test-consume-reverse-once)
(define g (make-ghost 'blinky 11 14 0 27 0))
((g 'mode!) 'chase)
((g 'consume-reverse!))
(check-eq? ((g 'consume-reverse!)) #f "Reverse should be consumed after first call"))
;; Test move
(define (test-move)
(define g (make-ghost 'blinky 11 14 0 27 0))
((g 'move!) 0 -1)
(define pos (g 'position))
(check-eq? (pos 'col) 13 "Col should be 13 after move left"))
;; Test scatter target
(define (test-scatter-target)
(define g (make-ghost 'blinky 11 14 0 27 0))
(define st (g 'scatter-target))
(check-eq? (st 'row) 0 "Scatter target row should be 0")
(check-eq? (st 'col) 27 "Scatter target col should be 27"))
;; Test house timer expiry
(define (test-house-exit)
(define g (make-ghost 'pinky 14 13 0 0 2000))
((g 'update-house-timer!) 1000)
(check-eq? (g 'in-house?) #t "Still in house after 1000ms")
((g 'update-house-timer!) 1000)
(check-eq? (g 'in-house?) #f "Should exit house after 2000ms")
(check-eq? (g 'mode) 'scatter "Should be in scatter after exiting"))
;; Test movement timer
(define (test-movement-timer)
(define g (make-ghost 'blinky 11 14 0 27 0))
(check-eq? ((g 'advance-movement-timer!) 100) 100 "Timer should be 100")
(check-eq? ((g 'advance-movement-timer!) 120) 220 "Timer should be 220")
((g 'reset-movement-timer!))
(check-eq? (g 'movement-timer) 0 "Timer should reset to 0"))
(define (test)
(run-test test-creation "Ghost: creation and initial state")
(run-test test-blinky-starts-active "Ghost: Blinky starts active")
(run-test test-pinky-starts-in-house "Ghost: Pinky starts in house")
(run-test test-direction "Ghost: direction change")
(run-test test-mode-change-queues-reverse "Ghost: mode change queues reverse")
(run-test test-consume-reverse-once "Ghost: consume reverse only once")
(run-test test-move "Ghost: move")
(run-test test-scatter-target "Ghost: scatter target")
(run-test test-house-exit "Ghost: house timer exit")
(run-test test-movement-timer "Ghost: movement timer"))))