@tool extends ArcadeGame const ACTOR = preload("res://games/galaga/actors/actor.gd") const FIGHTER = preload("res://games/galaga/actors/Fighter.tscn") const ENEMY = preload("res://games/galaga/actors/Enemy.tscn") const PROJECTILE = preload("res://games/galaga/actors/Projectile.tscn") const EXPLOSION = preload("res://games/galaga/actors/Explosion.tscn") @export_group("Room Objective") @export_range(0,100,1) var stages_to_win := 1 @export_range(0,1000000,100) var score_to_win := 0 @export_range(1,9,1) var starting_lives := 3 @export var extra_life_score := 20000 @export var second_extra_life_score := 70000 @export var repeat_extra_life_interval := 70000 var next_extra_life := 20000 var stage := 1 var score := 0 var lives := 3 var shots_fired := 0 var hits := 0 var state := "idle" var timer := 0.0 var clock := 0.0 var cooldown := 0.0 var dive_timer := 2.0 var invulnerable := 0.0 var awarded := false var player: Node2D var enemies: Array[Dictionary] = [] var bullets: Array[Dictionary] = [] var effects: Array[Dictionary] = [] var voices: Array[AudioStreamPlayer] = [] var streams := {} var field: Node2D var hud: Node2D var rng := RandomNumberGenerator.new() @export_group("Opening Timing") @export var intro_seconds := 6.0 @export var stage_card_seconds := 1.0 @export var player_card_seconds := 1.0 @export_group("Cabinet") @export var credits := 1 var wingman: Node2D var captive: Node2D var captor: Dictionary = {} var dual := false var capture_origin := Vector2.ZERO var capture_cooldown := 8.0 var beam: Dictionary = {} func _ready() -> void: pass field = preload("res://games/galaga/galaga_view.gd").new() add_child(field) player = FIGHTER.instantiate() field.add_child(player) player.position = Vector2(112,256) wingman = FIGHTER.instantiate() field.add_child(wingman) wingman.visible = false captive = FIGHTER.instantiate() field.add_child(captive) captive.visible = false captive.spinning = false captive.modulate = Color(1,0.4,0.4) _spawn_wave(true) if not Engine.is_editor_hint(): for i in range(8): var voice := AudioStreamPlayer.new() add_child(voice) voices.append(voice) for name in ["start","fire","dive","explosion","boss_damage","boss_destroy","goei_destroy","zako_destroy","extend","stage_count","tractor_beam","capturing","fighter_captured","fighter_rescued","captured_ship_destroy"]: streams[name] = load("res://games/galaga/audio/%s.wav" % name) func start_game() -> bool: if is_running or Engine.is_editor_hint(): return false _clear_actors() score = 0 stage = 1 lives = clampi(int(run_config.get("starting_lives",starting_lives)),1,9) shots_fired = 0 hits = 0 awarded = false next_extra_life = extra_life_score clock = 0 credits = maxi(0,credits-1) cooldown = 0 invulnerable = 0 player.position = Vector2(112,256) player.visible = false state = "intro" timer = intro_seconds dual = false wingman.visible = false captive.visible = false captive.spinning = false captor = {} beam = {} capture_cooldown = 8.0 _spawn_wave() _sound("start") return super.start_game() func _exit_tree() -> void: for voice in voices: voice.stop() voice.stream = null func reset_game() -> void: _clear_actors() for voice in voices: voice.stop() state = "idle" dual = false beam = {} captor = {} captive.visible = false wingman.visible = false player.visible = true player.position = Vector2(112,256) _spawn_wave(true) super.reset_game() func win_game() -> void: if not is_running or state=="victory": return for voice in voices: voice.stop() state = "victory" timer = 1.5 beam = {} func lose_game() -> void: if not is_running: return for voice in voices: voice.stop() state = "lost" super.lose_game() func _clear_actors() -> void: for collection in [enemies,bullets,effects]: for item in collection: item.node.queue_free() collection.clear() func _spawn_wave(preview := false) -> void: dive_timer = 2.0 for row in range(5): var count := 4 if row == 0 else (8 if row < 3 else 10) for col in range(count): var node = ENEMY.instantiate() node.kind = "boss" if row == 0 else ("butterfly" if row < 3 else "bee") field.add_child(node) var home := Vector2(112 + (col - (count-1)/2.0)*18,48+row*18) node.position = home if preview else Vector2(-24,-24) node.visible = preview var index := enemies.size() enemies.append({"node":node,"home":home,"mode":"formation" if preview else "enter","time":-(index/8)*1.1-(index%8)*0.075,"side":-1.0 if (index/8)%2==0 else 1.0,"hp":2 if row==0 else 1,"origin":Vector2.ZERO,"target":112.0,"fired":false}) func _input(event: InputEvent) -> void: if Engine.is_editor_hint(): return if event.is_action_pressed("arcade_start"): start_game() func _held(action: String) -> bool: return Input.is_action_pressed(action) func _physics_process(dt: float) -> void: if Engine.is_editor_hint(): return if state=="won": clock += dt return if not is_running: return _tick(dt) func _tick(dt: float) -> void: clock += dt for i in range(effects.size()-1,-1,-1): var effect: Dictionary = effects[i] effect.node.animation_time += dt if effect.node.animation_time >= 0.34: effect.node.queue_free() effects.remove_at(i) if state != "playing": timer -= dt if state=="victory": _update_peaceful_formation() for enemy in enemies: if enemy.mode!="formation" and enemy.node.visible: enemy.node.position += enemy.node.heading*70*dt for i in range(bullets.size()-1,-1,-1): var bullet: Dictionary = bullets[i] bullet.node.position += bullet.velocity*dt if not Rect2(-16,-16,256,320).has_point(bullet.node.position): bullet.node.queue_free() bullets.remove_at(i) if timer<=0: _clear_bullets() state="won" super.win_game() return if state == "capturing": var t := clampf(1-timer/1.5,0,1) player.position = capture_origin.lerp(captor.node.position-Vector2(0,16),t) if timer<=0: _finish_capture() return if state in ["captured_return", "capture_ready", "rescue_spin", "rescue_center", "rescue_align", "rescue_descend"]: _update_peaceful_formation() match state: "captured_return": captor.node.position = captor.node.position.move_toward(_formation_home(captor),100*dt) captive.position = captor.node.position-Vector2(0,16) if timer<=0 and captor.node.position.distance_to(_formation_home(captor))<1: captor.mode="formation" if lives<=0: lose_game() else: player.position=Vector2(112,256) player.visible=true state="capture_ready" timer=1.5 "capture_ready": if timer<=0: _resume_action() "rescue_spin": captive.spin_time += dt if timer<=0: captive.spinning=false state="rescue_center" "rescue_center": player.position = player.position.move_toward(Vector2(104,256),100*dt) if player.position.distance_to(Vector2(104,256))<0.1: state="rescue_align" "rescue_align": captive.position.x = move_toward(captive.position.x,120,100*dt) if absf(captive.position.x-120)<0.1: state="rescue_descend" "rescue_descend": captive.position = captive.position.move_toward(Vector2(120,256),110*dt) if captive.position.distance_to(Vector2(120,256))<0.1: dual=true captive.visible=false wingman.position=Vector2(120,256) wingman.visible=true _resume_action() return if timer <= 0: if state == "intro": state = "stage_card" timer = stage_card_seconds _sound("stage_count") elif state == "stage_card": state = "ready" timer = player_card_seconds player.visible = true elif state == "ready": state = "playing" elif state == "death": if lives <= 0: lose_game() else: player.position = Vector2(112,256) player.visible = true invulnerable = 2.5 state = "playing" elif state == "clear": stage += 1 _spawn_wave() state = "stage_card" player.visible = false wingman.visible = false timer = stage_card_seconds _sound("stage_count") return cooldown = maxf(0,cooldown-dt) invulnerable = maxf(0,invulnerable-dt) player.visible = invulnerable == 0 or int(clock*12)%2==0 var movement := float(_held("arcade_right"))-float(_held("arcade_left")) player.position.x = clampf(player.position.x+movement*110*dt,8,216) if dual: player.position.x = minf(player.position.x,200) wingman.position = player.position+Vector2(16,0) wingman.visible = dual and player.visible if _held("arcade_action_1"): _fire() _update_enemies(dt) if state != "playing": return _update_bullets(dt) if state != "playing": return for enemy in enemies: if (enemy.node.position.distance_to(player.position) < 11 or (dual and enemy.node.position.distance_to(wingman.position)<11)) and invulnerable <= 0: _die() return if enemies.is_empty(): var target := int(run_config.get("stages_to_win", stages_to_win)) if target > 0 and stage >= target: win_game() else: state = "clear" timer = 2 _clear_bullets() func _update_enemies(dt: float) -> void: dive_timer -= dt capture_cooldown -= dt var entering := false for e in enemies: if e.mode=="enter": entering=true for enemy in enemies: var previous_position: Vector2 = enemy.node.position enemy.time += dt var expansion := 1.0+0.10*(0.5+0.5*sin(clock*1.7)) var home: Vector2 = _formation_home(enemy) match enemy.mode: "enter": if enemy.time < 0: enemy.node.visible = false continue enemy.node.visible = true var t := clampf(enemy.time/2.0,0,1) var start := Vector2(112+enemy.side*140,160) if t<0.8: # Every member samples one shared route; its slot affects only docking. var u := t/0.8 enemy.node.position = start.lerp(Vector2(112,90),u) + Vector2(-enemy.side*sin(u*TAU)*72,-sin(u*PI)*105) else: enemy.node.position = Vector2(112,90).lerp(home,(t-0.8)/0.2) if t>=1: enemy.mode = "formation" "formation": enemy.node.position = home if not entering and capture_cooldown<=0 and enemy.node.kind=="boss" and captor.is_empty() and beam.is_empty() and not dual: enemy.mode="beam_approach" enemy.origin=home enemy.target=player.position.x enemy.time=0 beam=enemy capture_cooldown=15 elif dive_timer <= 0 and not entering: enemy.mode = "dive" enemy.time = 0 enemy.origin = home enemy.target = player.position.x enemy.fired = false dive_timer = maxf(0.45,2.0-stage*0.12) _sound("dive") "dive": var t: float = enemy.time/(3.0/maxf(1,1+stage*0.04)) enemy.node.position = Vector2(lerpf(enemy.origin.x,enemy.target,minf(t,1))+sin(t*TAU)*25,enemy.origin.y+t*250) if t>0.35 and not enemy.fired: enemy.fired = true _bullet(enemy.node.position,(player.position-enemy.node.position).normalized()*95,false) if enemy.node.position.y>310: enemy.mode = "return" enemy.time = 0 "return": var t := minf(1,enemy.time/1.0) enemy.node.position = Vector2(home.x,lerpf(-20,home.y,t)) if t>=1: enemy.mode = "formation" "beam_approach": var t := minf(enemy.time/1.3,1) enemy.node.position=enemy.origin.lerp(Vector2(enemy.target,172),t) if t>=1: enemy.mode="beam" enemy.time=0 _sound("tractor_beam") "beam": if invulnerable<=0 and absf(player.position.x-enemy.node.position.x)<18: captor=enemy capture_origin=player.position state="capturing" timer=1.5 _clear_bullets() _stop_sounds() _sound("capturing") return if enemy.time>=3: enemy.mode="return" enemy.time=0 beam={} enemy.node.animation_time += dt if enemy.mode in ["dive","enter","return"]: var travel: Vector2 = enemy.node.position-previous_position if travel.length_squared()>0.01: enemy.node.heading = travel.normalized() else: enemy.node.heading = Vector2.DOWN if not captor.is_empty() and captive.visible: captive.position=captor.node.position-Vector2(0,16) func _fire() -> void: if cooldown>0: return var count := 0 for bullet in bullets: if bullet.friendly: count += 1 if count>=(4 if dual else 2): return cooldown = 0.18 shots_fired += 1 _bullet(player.position-Vector2(0,9),Vector2(0,-230),true) if dual: _bullet(wingman.position-Vector2(0,9),Vector2(0,-230),true) _sound("fire") func _bullet(at: Vector2, velocity: Vector2, friendly: bool) -> void: var node = PROJECTILE.instantiate() node.kind = "shot" if friendly else "enemy_shot" node.position = at field.add_child(node) bullets.append({"node":node,"velocity":velocity,"friendly":friendly}) func _update_bullets(dt: float) -> void: for i in range(bullets.size()-1,-1,-1): var bullet: Dictionary = bullets[i] bullet.node.position += bullet.velocity*dt var remove: bool = bullet.node.position.y < -8 or bullet.node.position.y>296 if bullet.friendly: if captive.visible and state=="playing" and bullet.node.position.distance_to(captive.position)<8: captive.visible=false captor={} _sound("captured_ship_destroy") bullet.node.queue_free() bullets.remove_at(i) continue for j in range(enemies.size()-1,-1,-1): var enemy: Dictionary = enemies[j] if bullet.node.position.distance_to(enemy.node.position)>9: continue hits += 1 enemy.hp -= 1 remove = true if enemy.hp <= 0: var kind: String = enemy.node.kind var points: int = {"boss":150,"butterfly":80,"bee":50}[kind] if enemy.mode == "dive": points *= 2 _explode(enemy.node.position,true) if enemy==beam: beam={} if enemy==captor: captor={} state="rescue_spin" timer=1.5 captive.spinning=true captive.spin_time=0 _stop_sounds() _sound("fighter_rescued") enemy.node.queue_free() enemies.remove_at(j) _sound({"boss":"boss_destroy","butterfly":"goei_destroy","bee":"zako_destroy"}[kind]) _add_score(points) else: enemy.node.damaged = true _sound("boss_damage") break elif invulnerable<=0 and (bullet.node.position.distance_to(player.position)<8 or (dual and bullet.node.position.distance_to(wingman.position)<8)): _die() return if remove: bullet.node.queue_free() bullets.remove_at(i) if state != "playing": return func _add_score(points: int) -> void: score += points while next_extra_life>0 and score>=next_extra_life: var first := not awarded awarded = true lives += 1 _sound("extend") if first and second_extra_life_score>next_extra_life: next_extra_life = second_extra_life_score elif repeat_extra_life_interval>0: next_extra_life += repeat_extra_life_interval else: next_extra_life = 0 var target := int(run_config.get("score_to_win",score_to_win)) if target>0 and score>=target: win_game() func _clear_bullets() -> void: for bullet in bullets: bullet.node.queue_free() bullets.clear() func _die() -> void: if dual: dual=false _explode(wingman.position) wingman.visible=false invulnerable=2.5 _sound("explosion") return _explode(player.position) player.visible = false lives -= 1 state = "death" timer = 1.5 _clear_bullets() _sound("explosion") func _explode(at: Vector2, enemy := false) -> void: var node = EXPLOSION.instantiate() if enemy: node.kind="enemy_explosion" node.position = at field.add_child(node) effects.append({"node":node}) func _finish_capture() -> void: beam={} lives-=1 captive.visible=true captive.position=player.position player.visible=false captor.mode="captured_return" captor.time=0 _stop_sounds() state="captured_return" _sound("fighter_captured") timer=1.5 func _sound(name: String) -> void: if state=="capturing" and name!="capturing": return if state.begins_with("rescue_") and name!="fighter_rescued": return if not streams.has(name): return for voice in voices: if not voice.playing: voice.stream = streams[name] voice.play() return func _stop_sounds() -> void: for voice in voices: voice.stop() func _formation_home(enemy: Dictionary) -> Vector2: var expansion := 1.0+0.10*(0.5+0.5*sin(clock*1.7)) return Vector2(112,48)+(enemy.home-Vector2(112,48))*expansion func _update_peaceful_formation() -> void: for enemy in enemies: if enemy.mode=="formation": enemy.node.position=_formation_home(enemy) func _resume_action() -> void: state="playing" invulnerable=2.5 dive_timer=2.0 _clear_bullets()