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