379 lines
12 KiB
GDScript
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))
|