@tool extends ArcadeGame @export_range(0, 99) var waves_to_win: int = 1 @export_range(1, 6) var starting_lives: int = 3 @export_range(0, 10000, 100) var bonus_life_score: int = 1500 @export var color_overlay: bool = true @export var sound_enabled: bool = true const SAUCER_SCORES := [100,50,50,100,150,100,100,50,300,100,100,100,50,150,100] const WAVE_HEIGHTS := [96,104,112,112,112,120,120,120] var phase := "attract" var phase_time := 0.0 var clock := 0.0 var demo := false var demo_time := 0.0 var score := 0 var high_score := 0 var last_score := 0 var lives := 3 var wave := 1 var extra_awarded := false var player_x := 24.0 var player_shot := Vector2(-100,-100) var shot_active := false var shot_count := 0 var shot_delay := 0.0 var aliens: Array[Dictionary] = [] var bombs: Array[Dictionary] = [] var effects: Array[Dictionary] = [] var shields: Array[Dictionary] = [] var fleet_direction := 1 var fleet_cursor := 0 var fleet_delta := Vector2(2,0) var fleet_ticks := 0.0 var march_note := 0 var march_sound_delay := 0.0 var hit_pause := 0.0 var bomb_timer := 1.0 var bomb_kind := 0 var bomb_column := 0 var saucer_x := -32.0 var saucer_direction := 1 var saucer_active := false var saucer_timer := 20.0 var demo_target := 112.0 @onready var board: Node2D = $PixelScreen/Board func _ready() -> void: new_wave() lives = starting_lives func new_wave() -> void: aliens.clear() var top: int = 72 if wave == 1 else WAVE_HEIGHTS[(wave-2)%WAVE_HEIGHTS.size()] # Bottom-to-top scan order gives the original rippling fleet movement. for row in range(4,-1,-1): for column in range(11): aliens.append({"position":Vector2(24+column*16,top+row*16),"kind":0 if row==0 else (1 if row<3 else 2),"frame":0,"alive":true,"column":column}) fleet_direction = 1 fleet_cursor = 0 fleet_ticks = 0 march_sound_delay = 0 march_note = 0 fleet_delta = Vector2(2,0) bombs.clear() effects.clear() shot_active = false shot_delay = 0 bomb_timer = 1 saucer_active = false saucer_timer = 20 hit_pause = 0 player_x = 24 build_shields() func build_shields() -> void: shields.clear() var pattern: Image = board.masks["shield"] for x in [32,77,122,167]: var pixels := pattern.duplicate() as Image shields.append({"position":Vector2i(x,192),"image":pixels,"texture":ImageTexture.create_from_image(pixels),"dirty":false}) func start_game() -> bool: if is_running: return false demo = false $Audio.stop_all() score = 0 lives = starting_lives wave = 1 shot_count = 0 extra_awarded = false clock = 0 new_wave() phase = "ready" phase_time = 2 return super.start_game() func reset_game() -> void: demo = false $Audio.stop_all() score = 0 last_score = 0 wave = 1 lives = starting_lives new_wave() phase = "attract" phase_time = 0 clock = 0 super.reset_game() func begin_demo() -> void: demo = true demo_time = 0 score = 0 lives = starting_lives wave = 1 shot_count = 0 new_wave() phase = "play" func end_demo() -> void: demo = false score = last_score phase = "attract" phase_time = 0 $Audio.stop_all() func _unhandled_input(event: InputEvent) -> void: if Engine.is_editor_hint() or event.is_echo(): return if event.is_action_pressed("arcade_start"): start_game() func held(action: StringName) -> bool: return Input.is_action_pressed(action) func _physics_process(delta: float) -> void: clock += delta if Engine.is_editor_hint(): phase_time = fmod(clock,22) board.queue_redraw() return if phase == "attract": phase_time += delta if phase_time >= 28: begin_demo() elif phase == "ready": phase_time -= delta if phase_time <= 0: phase = "play" if saucer_active: $Audio.ufo(true) elif phase == "lost": phase_time -= delta if phase_time <= 0: phase = "attract" phase_time = 0 elif phase == "dead": phase_time -= delta if phase_time <= 0: if lives <= 0: finish(false) else: player_x = 24 bombs.clear() shot_active = false phase = "ready" phase_time = 1.0 elif phase == "clear": phase_time -= delta if phase_time <= 0: if demo: end_demo() elif waves_to_win > 0 and wave >= waves_to_win: finish(true) else: wave += 1 new_wave() phase = "ready" phase_time = 1.5 elif phase == "play": var move := 0.0 if demo: demo_target = 112+sin(demo_time*.7)*88 move = signf(demo_target-player_x) if absf(demo_target-player_x)>1 else 0.0 else: move = float(held(&"arcade_right"))-float(held(&"arcade_left")) player_x = clampf(player_x+move*60*delta,16,208) shot_delay = maxf(0,shot_delay-delta) if demo or held(&"arcade_action_1"): fire() step_world(delta) if demo: demo_time += delta if demo_time >= 25: end_demo() for shield in shields: if shield.dirty: shield.texture.update(shield.image) shield.dirty = false board.queue_redraw() func fire() -> bool: if phase != "play" or shot_active or shot_delay > 0: return false shot_active = true player_shot = Vector2(roundf(player_x),216) $Audio.effect("shot") return true func remove_shot() -> void: if not shot_active: return shot_active = false shot_count += 1 shot_delay = .08 func alive_count() -> int: var count := 0 for alien in aliens: if alien.alive: count += 1 return count func march() -> void: var count := alive_count() if count == 0: return if fleet_cursor == 0: var edge := false for alien in aliens: if alien.alive and (alien.position.x+fleet_direction*2 > 208 or alien.position.x+fleet_direction*2 < 16): edge = true if edge: fleet_direction *= -1 fleet_delta = Vector2(0,8) else: fleet_delta = Vector2(fleet_direction*(3 if count==1 else 2),0) if march_sound_delay <= 0: $Audio.effect("march%d"%march_note) march_note = (march_note+1)%4 march_sound_delay = .08 while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1 if fleet_cursor < aliens.size(): var alien: Dictionary = aliens[fleet_cursor] alien.position += fleet_delta alien.frame = 1-alien.frame var bounds := Rect2(alien.position-Vector2(8,4),Vector2(16,8)) for shield in shields: if bounds.intersects(Rect2(Vector2(shield.position),Vector2(shield.image.get_size()))): for y in range(shield.image.get_height()): for x in range(shield.image.get_width()): if bounds.has_point(Vector2(shield.position+Vector2i(x,y))): shield.image.set_pixel(x,y,Color.TRANSPARENT) shield.dirty = true if alien.position.y >= 216: finish(false) fleet_cursor += 1 while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1 if fleet_cursor >= aliens.size(): fleet_cursor = 0 func launch_bomb() -> void: var kind := bomb_kind bomb_kind = (bomb_kind+1)%3 for bomb in bombs: if bomb.kind == kind: return if kind == 1 and alive_count() == 1: return var candidates: Array[Dictionary] = [] var columns: Dictionary = {} for alien in aliens: if alien.alive and not columns.has(alien.column): columns[alien.column] = true candidates.append(alien) if candidates.is_empty(): return var source: Dictionary = candidates[bomb_column%candidates.size()] bomb_column += 3 if kind == 0: for alien in candidates: if absf(alien.position.x-player_x) < absf(source.position.x-player_x): source = alien bombs.append({"position":source.position+Vector2(0,8),"kind":kind}) func pixel_hit(name: String, origin: Vector2, point: Vector2) -> bool: var image: Image = board.masks[name] var p := Vector2i(point.round()-origin.round()) return p.x>=0 and p.y>=0 and p.x.5 func shield_hit(point: Vector2, upward: bool) -> bool: for shield in shields: var p := Vector2i(point.round())-Vector2i(shield.position) var image: Image = shield.image if p.x<0 or p.y<0 or p.x>=image.get_width() or p.y>=image.get_height(): continue if image.get_pixelv(p).a < .5: continue for dy in range(-4,5): for dx in range(-3,4): var q := p+Vector2i(dx,dy) if q.x<0 or q.y<0 or q.x>=image.get_width() or q.y>=image.get_height(): continue var depth := -dy if upward else dy if dx*dx+dy*dy<=12 and depth>=-1 and (absi(dx)<2 or (q.x+q.y)%3!=0): image.set_pixelv(q,Color.TRANSPARENT) shield.dirty = true return true return false func blast(position: Vector2, name: String = "burst", duration: float = .16, text: String = "") -> void: effects.append({"position":position,"name":name,"time":duration,"text":text}) func step_world(delta: float) -> void: march_sound_delay = maxf(0,march_sound_delay-delta) for i in range(effects.size()-1,-1,-1): effects[i].time -= delta if effects[i].time <= 0: effects.remove_at(i) hit_pause = maxf(0,hit_pause-delta) if hit_pause == 0: fleet_ticks += delta*60 while fleet_ticks >= 1 and phase == "play": fleet_ticks -= 1 march() if phase != "play": return bomb_timer -= delta if bomb_timer <= 0: launch_bomb() bomb_timer = maxf(.25,.75-score/20000.0) saucer_timer -= delta if saucer_timer <= 0 and not saucer_active: saucer_active = true saucer_direction = 1 if shot_count%2==0 else -1 saucer_x = -12 if saucer_direction==1 else 236 $Audio.ufo(true) if saucer_active: saucer_x += saucer_direction*30*delta if saucer_x < -16 or saucer_x > 240: saucer_active = false saucer_timer = 23 $Audio.ufo(false) # One-pixel substeps prevent fast projectiles tunneling through shields/sprites. if shot_active: var travel := delta*240 while travel > 0 and shot_active: var amount := minf(1,travel) player_shot.y -= amount travel -= amount if shield_hit(player_shot,true): remove_shot() elif player_shot.y <= 32: blast(player_shot,"shot_burst") remove_shot() elif saucer_active and pixel_hit("ufo",Vector2(saucer_x-8,40),player_shot): var value: int = SAUCER_SCORES[shot_count%15] add_score(value) blast(Vector2(saucer_x,44),"ufo_burst",.65,str(value)) saucer_active = false saucer_timer = 23 $Audio.ufo(false) $Audio.effect("ufo_hit") remove_shot() else: for alien in aliens: if alien.alive and pixel_hit("alien%d_%d"%[alien.kind,alien.frame],alien.position-Vector2(8,4),player_shot): alien.alive = false add_score([30,20,10][alien.kind]) blast(alien.position) hit_pause = .10 $Audio.effect("invader") remove_shot() break for i in range(bombs.size()-1,-1,-1): var bomb: Dictionary = bombs[i] var destroyed := false var travel := delta*(90 if alive_count()<9 else 60) while travel>0 and not destroyed: var amount := minf(1,travel) bomb.position.y += amount travel -= amount if shield_hit(bomb.position+Vector2(0,3),false): destroyed = true elif shot_active and absf(bomb.position.x-player_shot.x)<3 and absf(bomb.position.y-player_shot.y)<7: destroyed = true remove_shot() blast(bomb.position,"shot_burst") elif pixel_hit("player",Vector2(roundf(player_x)-8,216),bomb.position+Vector2(0,3)): die() destroyed = true elif bomb.position.y>=238: destroyed = true blast(Vector2(bomb.position.x,237),"shot_burst") if destroyed: bombs.remove_at(i) if phase != "play": return if alive_count()==0: phase = "clear" phase_time = 1.5 $Audio.stop_all() func add_score(value: int) -> void: score += value if demo: return high_score = maxi(high_score,score) if bonus_life_score>0 and score>=bonus_life_score and not extra_awarded: extra_awarded = true lives += 1 $Audio.effect("bonus") func die() -> void: if phase != "play": return lives -= 1 phase = "dead" phase_time = 1.5 $Audio.stop_all() $Audio.effect("death") func finish(won: bool) -> void: if demo: end_demo() return if not is_running: return last_score = score phase = "won" if won else "lost" phase_time = 5 $Audio.stop_all() if won: win_game() else: lose_game()