Files

379 lines
12 KiB
GDScript

extends RefCounted
const Maze = preload("res://games/pacman/pacman_maze.gd")
const Rules = preload("res://games/pacman/pacman_rules.gd")
signal sound_requested(name: String)
signal finished(won: bool)
var state := "idle"
var phase_time := 0.0
var clock := 0.0
var player_anim_time := 0.0
var score := 0
var high_score := 0
var lives := 3
var level := 1
var boards_cleared := 0
var boards_to_win := 1
var score_to_win := 0
var initial_lives := 3
var extra_life_score := 10000
var extra_awarded := false
var dots := {}
var player := {}
var ghosts: Array[Dictionary] = []
var requested := Vector2i.LEFT
var fright_time := 0.0
var ghost_chain := 0
var mode_index := 0
var mode_time := 0.0
var pellet_pause := 0.0
var release_idle := 0.0
var release_counts := [0, 0, 0, 0]
var after_death := false
var global_dots := 0
var fruit_time := 0.0
var fruit_spawned := 0
var popup_time := 0.0
var popup_text := ""
var popup_position := Vector2.ZERO
var rng := RandomNumberGenerator.new()
const DEATH_PAUSE_SECONDS := 1.5
const DEATH_ANIMATION_SECONDS := 1.55
const DEATH_POP_SECONDS := 0.24
func _init() -> void:
reset()
func reset() -> void:
state = "idle"
player_anim_time = 0.0
score = 0
lives = initial_lives
level = 1
boards_cleared = 0
extra_awarded = false
dots = Maze.pellets()
fruit_spawned = 0
after_death = false
_reset_actors()
func start() -> void:
reset()
state = "ready"
phase_time = 4.25
sound_requested.emit("start")
func _reset_actors() -> void:
player = Maze.actor(Maze.PLAYER_START, Vector2i.LEFT)
requested = Vector2i.LEFT
ghosts.clear()
for i in range(4):
var g := Maze.actor([Maze.DOOR, Vector2i(13, 14), Vector2i(11, 14), Vector2i(15, 14)][i], Vector2i.LEFT)
# Center the opening formation like the arcade cabinet. This is cleared
# on the first gameplay movement so normal lane centers stay unchanged.
g["spawn_offset"] = Vector2(0.5, 0.0)
g["status"] = "active" if i == 0 else "house"
g["frightened"] = false
g["reverse"] = false
ghosts.append(g)
mode_index = 0
mode_time = 0.0
fright_time = 0.0
ghost_chain = 0
pellet_pause = 0.0
release_idle = 0.0
release_counts = [0, 0, 0, 0]
global_dots = 0
fruit_time = 0.0
popup_time = 0.0
rng.seed = 12345
func update(dt: float) -> void:
# Keep every clock-driven visual frozen during the pre-death pause. The
# phase timer still advances below, so the death animation starts on time.
if state != "death_pause":
clock += dt
popup_time = maxf(0.0, popup_time - dt)
if state in ["idle", "won", "lost"]:
return
if state != "playing":
phase_time -= dt
if phase_time <= 0.0:
match state:
"ready", "ghost_pause": state = "playing"
"death_pause":
state = "dying"
phase_time = DEATH_ANIMATION_SECONDS
sound_requested.emit("death")
"intermission": _next_level()
"dying":
state = "death_pop"
phase_time = DEATH_POP_SECONDS
"death_pop":
lives -= 1
if lives <= 0:
_end(false)
else:
after_death = true
_reset_actors()
state = "ready"
phase_time = 2.0
"clear":
boards_cleared += 1
if boards_to_win > 0 and boards_cleared >= boards_to_win:
_end(true)
elif level in [2, 5] or (level >= 9 and (level - 9) % 4 == 0):
state = "intermission"
phase_time = 5.28
sound_requested.emit("intermission")
else:
_next_level()
return
if fright_time > 0:
fright_time = maxf(0.0, fright_time - dt)
if fright_time == 0.0:
for g in ghosts: g.frightened = false
else:
mode_time += dt
var schedule: Array = Rules.schedule(level)
if mode_time >= schedule[mode_index]:
mode_time = 0.0
mode_index = mini(mode_index + 1, 7)
for g in ghosts:
if g.status == "active": g.reverse = true
fruit_time = maxf(0.0, fruit_time - dt)
release_idle += dt
_release_ghosts()
if pellet_pause > 0.0:
pellet_pause -= dt
else:
_move_player(_player_speed() * dt)
if state != "playing": return
_check_collisions()
if state != "playing": return
# Animate Pac-Man only while he is actually travelling. This keeps the
# mouth cadence steady when the global clock is affected by pauses or
# unrelated game state changes.
if pellet_pause <= 0.0 and player.tile != player.next:
player_anim_time += dt
elif player.tile == player.next:
player_anim_time = 0.0
for i in range(4):
_move_ghost(i, _ghost_speed(i) * dt)
_check_collisions()
if state != "playing": return
func _player_speed() -> float:
var ratio := 0.8 if level == 1 else (0.9 if level < 5 or level >= 21 else 1.0)
if fright_time > 0.0: ratio = 0.9 if level == 1 else (0.95 if level < 5 else 1.0)
return 9.46875 * ratio
func _ghost_speed(index: int) -> float:
var g: Dictionary = ghosts[index]
if g.status == "eyes": return 15.0
if g.status in ["house", "leaving", "entering"]: return 4.0
if g.tile.y == 14 and (g.tile.x < 6 or g.tile.x > 21):
return 9.46875 * (0.4 if level == 1 else (0.45 if level < 5 else 0.5))
if g.frightened: return 9.46875 * (0.5 if level == 1 else (0.55 if level < 5 else 0.6))
var ratio := 0.75 if level == 1 else (0.85 if level < 5 else 0.95)
if index == 0 and _elroy_active():
ratio += 0.05 if dots.size() > Rules.elroy_threshold(level) / 2 else 0.1
return 9.46875 * ratio
func _elroy_active() -> bool:
return dots.size() <= Rules.elroy_threshold(level) and (not after_death or ghosts[3].status != "house")
func _move_player(distance: float) -> void:
# Reversing is legal between tile centers. Other turns are buffered.
if player.tile != player.next and requested == -player.dir:
var previous: Vector2i = player.tile
player.tile = player.next
player.next = previous
player.progress = 1.0 - player.progress
player.dir = requested
while distance > 0.000001:
if player.tile == player.next:
_consume()
if state != "playing" or pellet_pause > 0.0: return
if Maze.walkable(Maze.neighbor(player.tile, requested)):
player.dir = requested
var destination: Vector2i = Maze.neighbor(player.tile, player.dir)
if not Maze.walkable(destination): return
player.next = destination
var step := minf(distance, 1.0 - player.progress)
player.progress += step
distance -= step
if player.progress >= 0.999999:
player.tile = player.next
player.progress = 0.0
_consume()
if pellet_pause > 0.0 or state != "playing": return
func _consume() -> void:
if dots.has(player.tile):
var power: bool = dots[player.tile]
dots.erase(player.tile)
_add_score(50 if power else 10)
if state != "playing": return
pellet_pause = (3.0 if power else 1.0) / 60.0
sound_requested.emit("eat_dot_%d" % (dots.size() % 2))
release_idle = 0.0
if after_death:
global_dots += 1
else:
for i in range(1, 4):
if ghosts[i].status == "house":
release_counts[i] += 1
break
if power:
fright_time = Rules.frightened_seconds(level)
ghost_chain = 0
for g in ghosts:
if g.status not in ["eyes", "entering"]:
g.frightened = fright_time > 0.0
if g.status == "active": g.reverse = true
var eaten := 244 - dots.size()
if (eaten >= 70 and fruit_spawned == 0) or (eaten >= 170 and fruit_spawned == 1):
fruit_spawned += 1
fruit_time = rng.randf_range(9.0, 10.0)
if dots.is_empty():
state = "clear"
phase_time = 2.5
if fruit_time > 0 and player.tile in [Vector2i(13, 17), Vector2i(14, 17)]:
fruit_time = 0.0
var points: int = Rules.fruit_score(level)
_add_score(points)
_popup(str(points), Maze.position(player))
sound_requested.emit("eat_fruit")
func _add_score(points: int) -> void:
score += points
high_score = maxi(high_score, score)
if not extra_awarded and extra_life_score > 0 and score >= extra_life_score:
extra_awarded = true
lives += 1
sound_requested.emit("extend")
if score_to_win > 0 and score >= score_to_win: _end(true)
func _release_ghosts() -> void:
# Release one ghost at a time; returning ghosts take the same door route.
for g in ghosts:
if g.status in ["leaving", "entering"]: return
for i in range(1, 4):
if ghosts[i].status != "house": continue
var threshold: int = [0, 0, 30 if level == 1 else 0, 60 if level == 1 else (50 if level == 2 else 0)][i]
var ready: bool = release_counts[i] >= threshold
if after_death: ready = global_dots >= [0, 7, 17, 32][i]
if ready or release_idle >= (4.0 if level < 5 else 3.0):
ghosts[i].status = "leaving"
release_idle = 0.0
return
func _move_ghost(index: int, distance: float) -> void:
var g: Dictionary = ghosts[index]
g["spawn_offset"] = Vector2.ZERO
if g.status == "house": return
while distance > 0.000001:
if g.tile == g.next:
if g.status == "eyes" and g.tile == Maze.DOOR:
g.status = "entering"
if g.status == "entering" and g.tile == Vector2i(13, 14):
g.status = "leaving"
g.frightened = false
if g.status == "leaving" and g.tile == Maze.DOOR:
g.status = "active"
g.dir = Vector2i.LEFT
var direction := Vector2i.ZERO
if g.status == "leaving":
direction = Vector2i(signi(13 - g.tile.x), 0) if g.tile.x != 13 else Vector2i.UP
elif g.status == "entering": direction = Vector2i.DOWN
elif g.reverse:
direction = -g.dir
g.reverse = false
else: direction = _ghost_direction(index)
if direction == Vector2i.ZERO: return
g.dir = direction
g.next = Maze.neighbor(g.tile, direction)
var step := minf(distance, 1.0 - g.progress)
g.progress += step
distance -= step
if g.progress >= 0.999999:
g.tile = g.next
g.progress = 0.0
func _ghost_direction(index: int) -> Vector2i:
var g: Dictionary = ghosts[index]
var target: Vector2i = Rules.SCATTER[index]
if g.status == "eyes": target = Maze.DOOR
elif mode_index % 2 == 1 or (index == 0 and _elroy_active()):
target = Rules.target(index, player.tile, player.dir, ghosts[0].tile, g.tile)
var candidates: Array[Vector2i] = []
for direction in Maze.DIRS:
if direction == -g.dir: continue
if not Maze.walkable(Maze.neighbor(g.tile, direction)): continue
# Original maze disallows upward turns at these junctions in normal modes.
if g.status == "active" and not g.frightened and direction == Vector2i.UP and g.tile in [Vector2i(12, 11), Vector2i(15, 11), Vector2i(12, 23), Vector2i(15, 23)]: continue
candidates.append(direction)
if candidates.is_empty():
return -g.dir if Maze.walkable(Maze.neighbor(g.tile, -g.dir)) else Vector2i.ZERO
if g.frightened and g.status == "active": return candidates[rng.randi_range(0, candidates.size() - 1)]
var chosen: Vector2i = candidates[0]
var best := INF
for direction in candidates:
var test: Vector2i = Maze.neighbor(g.tile, direction)
var distance := Vector2(test).distance_squared_to(Vector2(target))
if distance < best:
best = distance
chosen = direction
return chosen
func _check_collisions() -> void:
for g in ghosts:
if g.status != "active": continue
var difference: Vector2 = Maze.position(g) - Maze.position(player)
if absf(difference.x) > 14.0: difference.x = 28.0 - absf(difference.x)
if difference.length_squared() > 0.45 * 0.45: continue
if g.frightened:
g.status = "eyes"
g.frightened = false
g.reverse = false
var points := 200 * (1 << mini(ghost_chain, 3))
ghost_chain += 1
_add_score(points)
if state != "playing": return
_popup(str(points), Maze.position(g))
state = "ghost_pause"
phase_time = 0.6
sound_requested.emit("eat_ghost")
else:
state = "death_pause"
phase_time = DEATH_PAUSE_SECONDS
fruit_time = 0.0
return
func _next_level() -> void:
level += 1
dots = Maze.pellets()
fruit_spawned = 0
after_death = false
_reset_actors()
state = "ready"
phase_time = 2.0
func _popup(text: String, pos: Vector2) -> void:
popup_text = text
popup_position = pos
popup_time = 1.0
func _end(won: bool) -> void:
if state in ["won", "lost"]: return
state = "won" if won else "lost"
finished.emit(won)
func background_sound() -> String:
if state != "playing": return ""
for g in ghosts:
if g.status in ["eyes", "entering"]: return "eyes"
if fright_time > 0.0: return "fright"
return "siren%d" % mini(4, int((244 - dots.size()) / 50.0))