181 lines
6.6 KiB
GDScript
181 lines
6.6 KiB
GDScript
extends SceneTree
|
|
const Model = preload("res://games/pacman/pacman_model.gd")
|
|
const Maze = preload("res://games/pacman/pacman_maze.gd")
|
|
const Rules = preload("res://games/pacman/pacman_rules.gd")
|
|
var checks := 0
|
|
var failures := 0
|
|
|
|
func check(condition: bool, label: String) -> void:
|
|
checks += 1
|
|
if not condition:
|
|
failures += 1
|
|
push_error("FAIL: " + label)
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run")
|
|
|
|
func run() -> void:
|
|
for row in Maze.ROWS: check(row.length() == 28, "28-column maze")
|
|
check(Maze.pellets().size() == 244, "244 pellets")
|
|
var powers := 0
|
|
for value in Maze.pellets().values():
|
|
if value: powers += 1
|
|
check(powers == 4, "four energizers")
|
|
var seen := {Maze.PLAYER_START: true}
|
|
var queue := [Maze.PLAYER_START]
|
|
while not queue.is_empty():
|
|
var tile: Vector2i = queue.pop_front()
|
|
for direction in Maze.DIRS:
|
|
var next: Vector2i = Maze.neighbor(tile, direction)
|
|
if Maze.walkable(next) and not seen.has(next):
|
|
seen[next] = true
|
|
queue.append(next)
|
|
for tile in Maze.pellets(): check(seen.has(tile), "reachable pellet %s" % tile)
|
|
check(not Maze.walkable(Vector2i(13, 12)), "player cannot enter ghost door")
|
|
check(Maze.neighbor(Vector2i(0,14), Vector2i.LEFT) == Vector2i(27,14), "tunnel wrap")
|
|
check(Rules.target(0, Vector2i(5,5), Vector2i.UP, Vector2i.ZERO, Vector2i.ZERO) == Vector2i(5,5), "Blinky target")
|
|
check(Rules.target(1, Vector2i(5,5), Vector2i.UP, Vector2i.ZERO, Vector2i.ZERO) == Vector2i(1,1), "Pinky upward quirk")
|
|
check(Rules.target(2, Vector2i(5,5), Vector2i.RIGHT, Vector2i(3,5), Vector2i.ZERO) == Vector2i(11,5), "Inky target")
|
|
check(Rules.target(3, Vector2i(5,5), Vector2i.RIGHT, Vector2i.ZERO, Vector2i(6,5)) == Rules.SCATTER[3], "Clyde retreats")
|
|
var m = Model.new()
|
|
m.start()
|
|
m.update(5.0)
|
|
check(m.state == "playing", "ready becomes playing")
|
|
# Movement stops at the corridor wall, despite a held direction.
|
|
for i in range(180):
|
|
m._move_player(0.1)
|
|
m.pellet_pause = 0.0
|
|
check(Maze.walkable(m.player.tile), "movement stays in corridor")
|
|
# Energizer changes active ghosts; capture returns eyes and adds score.
|
|
m.player = Maze.actor(Vector2i(1,3), Vector2i.DOWN)
|
|
m._consume()
|
|
check(m.fright_time > 0.0, "energizer starts frightened timer")
|
|
var before: int = m.score
|
|
m.ghosts[0].tile = m.player.tile
|
|
m.ghosts[0].next = m.player.tile
|
|
m.ghosts[0].progress = 0.0
|
|
m._check_collisions()
|
|
check(m.ghosts[0].status == "eyes" and m.score == before + 200, "ghost capture score and eyes")
|
|
m.reset()
|
|
check(m.state == "idle" and m.score == 0 and m.dots.size() == 244, "reset restores board")
|
|
m.start()
|
|
m.state = "playing"
|
|
m.dots = {m.player.tile: false}
|
|
m._consume()
|
|
check(m.state == "clear", "last pellet clears level")
|
|
m.update(3.0)
|
|
check(m.state == "won", "default room objective wins")
|
|
m.boards_to_win = 0
|
|
m.start()
|
|
m.state = "clear"
|
|
m.phase_time = 0.0
|
|
m.update(0.1)
|
|
check(m.level == 2 and m.state == "ready" and m.dots.size() == 244, "arcade progression")
|
|
m.state = "clear"
|
|
m.phase_time = 0.0
|
|
m.update(0.1)
|
|
check(m.state == "intermission", "second board starts intermission")
|
|
m.update(6.0)
|
|
check(m.state == "ready" and m.level == 3, "intermission continues to next level")
|
|
m.lives = 1
|
|
m.state = "dying"
|
|
m.phase_time = 0.0
|
|
m.update(0.1)
|
|
assert(m.state == "death_pop", "death pop before final loss")
|
|
m.phase_time = 0.0
|
|
m.update(0.1)
|
|
check(m.state == "lost", "final life loses")
|
|
m.start()
|
|
m.state = "playing"
|
|
m.score_to_win = 100
|
|
m._add_score(100)
|
|
check(m.state == "won", "score objective")
|
|
# Traverse the entire board through the actual movement and consumption code.
|
|
m.score_to_win = 0
|
|
m.boards_to_win = 1
|
|
m.start()
|
|
m.state = "playing"
|
|
var moves := 0
|
|
while not m.dots.is_empty() and moves < 3000:
|
|
var frontier: Array = [m.player.tile]
|
|
var parent := {m.player.tile: null}
|
|
var destination: Vector2i = m.player.tile
|
|
while not frontier.is_empty():
|
|
var current: Vector2i = frontier.pop_front()
|
|
if m.dots.has(current):
|
|
destination = current
|
|
break
|
|
for direction in Maze.DIRS:
|
|
var next: Vector2i = Maze.neighbor(current, direction)
|
|
if Maze.walkable(next) and not parent.has(next):
|
|
parent[next] = current
|
|
frontier.append(next)
|
|
var path: Array[Vector2i] = []
|
|
while destination != m.player.tile:
|
|
path.push_front(destination)
|
|
destination = parent[destination]
|
|
if path.is_empty():
|
|
m._consume()
|
|
for tile in path:
|
|
var direction: Vector2i = tile - m.player.tile
|
|
if abs(direction.x) > 1: direction.x = -signi(direction.x)
|
|
m.requested = direction
|
|
for step in range(50):
|
|
m.pellet_pause = 0.0
|
|
m._move_player(0.1)
|
|
if m.player.tile == tile: break
|
|
check(m.player.tile == tile, "buffered turn reaches next corridor tile")
|
|
moves += 1
|
|
check(m.dots.is_empty() and m.state == "clear", "full maze cleared through movement")
|
|
# Every returning ghost must be able to reach the house and emerge again.
|
|
m.start()
|
|
m.state = "playing"
|
|
for tile in seen:
|
|
var g: Dictionary = m.ghosts[0]
|
|
g.tile = tile
|
|
g.next = tile
|
|
g.progress = 0.0
|
|
g.dir = Vector2i.LEFT
|
|
g.status = "eyes"
|
|
g.reverse = false
|
|
for step in range(1600):
|
|
m._move_ghost(0, 0.5)
|
|
if g.status == "active": break
|
|
check(g.status == "active", "eyes return from %s" % tile)
|
|
# Headless random walk checks ghost movement stays in legal corridors.
|
|
m.score_to_win = 0
|
|
m.start()
|
|
m.state = "playing"
|
|
for frame in range(12000):
|
|
m.requested = Maze.DIRS[(frame / 30) % 4]
|
|
m.update(1.0 / 60.0)
|
|
if m.state == "lost": m.start()
|
|
for g in m.ghosts:
|
|
if g.status == "active":
|
|
check(Maze.walkable(g.tile), "ghost stays on map")
|
|
var game = load("res://games/pacman/Pacman.tscn").instantiate()
|
|
root.add_child(game)
|
|
var counts := {"start": 0, "won": 0, "lost": 0, "reset": 0}
|
|
game.game_started.connect(func(): counts.start += 1)
|
|
game.game_won.connect(func(): counts.won += 1)
|
|
game.game_lost.connect(func(): counts.lost += 1)
|
|
game.game_reset.connect(func(): counts.reset += 1)
|
|
check(game.start_game(), "ArcadeGame starts")
|
|
check(not game.start_game(), "duplicate start ignored")
|
|
game.win_game()
|
|
game.win_game()
|
|
check(counts.won == 1 and counts.start == 1, "win event exactly once")
|
|
game.reset_game()
|
|
game.start_game()
|
|
game.lose_game()
|
|
game.lose_game()
|
|
check(counts.lost == 1 and counts.reset == 1, "loss and reset events")
|
|
var loop = game.audio._loop_stream("siren0", game.audio.stream("siren0_firstloop"))
|
|
check(loop.loop_begin > 0 and loop.loop_end > loop.loop_begin, "intro plays once before PCM loop")
|
|
game.audio.set_background("fright")
|
|
game.reset_game()
|
|
check(game.audio.background == "" and not game.audio.ambience.playing, "reset stops ambience")
|
|
game.queue_free()
|
|
print("PACMAN: %d checks, %d failures" % [checks, failures])
|
|
quit(1 if failures else 0)
|