@tool extends ArcadeGame @export_range(0,99) var waves_to_win: int = 1 @export_range(1,9) var starting_lives: int = 3 @export_range(.5,2,.1) var difficulty: float = 1.0 @export_range(0,100000,5000) var extra_life_interval: int = 25000 @export var sound_enabled: bool = true @export var crt_aspect: bool = true @export var reduced_flashes: bool = false const ARENA := Rect2(8,20,276,208) const POINTS := {"grunt":100,"hulk":0,"spheroid":1000,"enforcer":150,"quark":1000,"tank":200,"brain":500,"prog":100,"electrode":0} var phase := "title" 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 rescue_chain := 0 var next_extra := 25000 var player := Vector2(146,124) var facing := Vector2.DOWN var moving := false var fire_delay := 0.0 var invulnerable := 0.0 var grunt_delay := 0.0 var entities: Array[Dictionary] = [] var shots: Array[Dictionary] = [] var enemy_shots: Array[Dictionary] = [] var effects: Array[Dictionary] = [] var rng := RandomNumberGenerator.new() var next_id := 0 @onready var board: Node2D = $PixelScreen/Board func _ready() -> void: lives = starting_lives rng.seed = 2084 setup_wave() if not Engine.is_editor_hint(): $Audio.effect("startup") 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 eight_way(vector: Vector2) -> Vector2: if vector.length()<.25: return Vector2.ZERO return Vector2.from_angle(roundf(vector.angle()/(PI/4))*(PI/4)).normalized() func control_vector(prefix: String) -> Vector2: return eight_way(Input.get_vector("robotron_"+prefix+"left","robotron_"+prefix+"right","robotron_"+prefix+"up","robotron_"+prefix+"down")) func start_game() -> bool: if is_running: return false demo = false score = 0 lives = starting_lives wave = 1 next_extra = extra_life_interval rng.randomize() setup_wave() phase = "ready" phase_time = 2.0 $Audio.stop_all() $Audio.effect("game-start") return super.start_game() func reset_game() -> void: demo = false score = 0 last_score = 0 wave = 1 lives = starting_lives setup_wave() phase = "title" phase_time = 0 $Audio.stop_all() super.reset_game() func spawn(kind: String, position: Vector2) -> Dictionary: next_id += 1 var entity := {"id":next_id,"kind":kind,"position":position,"velocity":Vector2.from_angle(rng.randf()*TAU),"timer":rng.randf_range(.6,2.0),"age":0.0,"frame":rng.randi_range(0,2),"alive":true,"spawns":0,"human_type":rng.randi_range(0,2),"hp":3 if kind=="brain" else 1} entities.append(entity) return entity func spawn_point() -> Vector2: for attempt in range(80): var p := Vector2(rng.randf_range(15,277),rng.randf_range(28,220)) if p.distance_to(player)<=48: continue var occupied := false for e in entities: if e.alive and p.distance_squared_to(e.position)<100: occupied = true break if not occupied: return p return Vector2(20,30) func setup_wave() -> void: entities.clear() shots.clear() enemy_shots.clear() effects.clear() player = Vector2(146,124) facing = Vector2.DOWN rescue_chain = 0 fire_delay = 0 invulnerable = 1.5 var brain_wave := wave%5==0 var tank_wave := wave>=7 and wave%5==2 for i in range(mini(15+wave*3,65)): spawn("grunt",spawn_point()) for i in range(5+mini(wave/3,5)): spawn("hulk",spawn_point()) for i in range(15 if not brain_wave else 5): spawn("electrode",spawn_point()) for i in range(20 if brain_wave else 3+mini(wave,5)): spawn("human",spawn_point()) if brain_wave: for i in range(8+mini(wave,12)): spawn("brain",spawn_point()) elif tank_wave: for i in range(5+mini(wave/5,5)): spawn("quark",spawn_point()) elif wave>=2: for i in range(mini(wave,8)): spawn("spheroid",spawn_point()) func _physics_process(delta: float) -> void: clock += delta if Engine.is_editor_hint(): phase_time = fmod(clock,12) board.queue_redraw() return if phase in ["title","story","roster","scores"]: phase_time += delta if phase_time>12: phase_time = 0 match phase: "title": phase = "story" "story": phase = "roster" "roster": begin_demo() "scores": phase = "title" elif phase in ["ready","dead","transition","lost"]: phase_time -= delta if phase_time<=0: match phase: "ready": phase = "play" "dead": if lives<=0: finish(false) else: respawn() "transition": if not demo and waves_to_win>0 and wave>=waves_to_win: finish(true) else: wave += 1 setup_wave() phase = "ready" phase_time = 1.5 if wave%5==0: $Audio.effect("brain-wave-start") "lost": phase = "scores" phase_time = 0 elif phase == "play": var movement := Vector2.ZERO var firing := Vector2.ZERO if demo: var target := Vector2(146+cos(demo_time*.8)*88,124+sin(demo_time*.8)*70) movement = eight_way(target-player) var enemy := nearest(player,false) if not enemy.is_empty(): firing = eight_way(enemy.position-player) else: movement = control_vector("move_") firing = control_vector("fire_") step_world(delta,movement,firing) if demo: demo_time += delta if demo_time>25: end_demo() board.queue_redraw() func begin_demo() -> void: demo = true demo_time = 0 score = 0 lives = 3 wave = 3 rng.seed = 2084 setup_wave() phase = "play" $Audio.stop_all() func end_demo() -> void: demo = false score = last_score phase = "scores" phase_time = 0 $Audio.stop_all() func clamp_arena(p: Vector2, margin: float = 4) -> Vector2: return p.clamp(ARENA.position+Vector2.ONE*margin,ARENA.end-Vector2.ONE*margin) func nearest(p: Vector2, human: bool) -> Dictionary: var best: Dictionary = {} var distance := INF for e in entities: if not e.alive or (e.kind=="human")!=human or e.kind in ["electrode","hulk"]: continue var d: float = p.distance_squared_to(e.position) if d bool: if phase!="play" or direction==Vector2.ZERO or fire_delay>0 or shots.size()>=4: return false shots.append({"position":player+direction*5,"velocity":direction*260}) fire_delay = .085 $Audio.effect("player-lazer") return true func radius(kind: String) -> float: return 7.0 if kind in ["brain","hulk","tank"] else (5.0 if kind in ["spheroid","quark"] else 3.5) func projectile(kind: String, origin: Vector2, target: Vector2) -> void: if enemy_shots.size()>=70: return var velocity := (target-origin).normalized()*(90 if kind=="shell" else 65) enemy_shots.append({"kind":kind,"position":origin,"velocity":velocity,"life":9.0,"bounces":0}) $Audio.effect("tank-fire" if kind=="shell" else "enforcer-fire") func step_world(delta: float, movement: Vector2, firing: Vector2) -> void: moving = movement!=Vector2.ZERO if moving: facing = movement player = clamp_arena(player+movement*86*delta) invulnerable = maxf(0,invulnerable-delta) fire_delay = maxf(0,fire_delay-delta) grunt_delay -= delta if grunt_delay<=0: grunt_delay = .6 if entities.any(func(e): return e.alive and e.kind=="grunt"): $Audio.effect("grunt-walk") fire(firing) for i in range(effects.size()-1,-1,-1): effects[i].life -= delta if effects[i].life<=0: effects.remove_at(i) # Spawners append enemies; the snapshot prevents updating new enemies twice. for e in entities.duplicate(): if not e.alive: continue e.age += delta e.timer -= delta var direction: Vector2 = (player-e.position).normalized() match e.kind: "human": if e.timer<=0: e.velocity = eight_way(Vector2(rng.randf_range(-1,1),rng.randf_range(-1,1))) e.timer = rng.randf_range(.4,1.4) e.position = clamp_arena(e.position+e.velocity*12*delta) if player.distance_to(e.position)<7: save_human(e) "grunt","prog": e.position += eight_way(direction)*(32 if e.kind=="grunt" else 58)*difficulty*delta if e.kind=="grunt": for obstacle in entities: if obstacle.alive and obstacle.kind=="electrode" and e.position.distance_to(obstacle.position)<6: e.alive = false obstacle.alive = false burst(e.position) break "hulk","brain": var human := nearest(e.position,true) var destination: Vector2 = human.position if not human.is_empty() else player if e.timer<=0: e.velocity = eight_way(destination-e.position) e.timer = .6 if e.kind=="hulk" else .2 e.position = clamp_arena(e.position+e.velocity*(17 if e.kind=="hulk" else 22)*difficulty*delta) if not human.is_empty() and e.position.distance_to(human.position)<8: human.alive = false if e.kind=="brain": var prog := spawn("converting",human.position) prog.timer = .7 $Audio.effect("human-to-prog") else: burst(human.position) $Audio.effect("human-die") if e.kind=="brain" and int(e.age/2.2)!=int((e.age-delta)/2.2): projectile("cruise",e.position,player) "converting": if e.timer<=0: e.kind = "prog" $Audio.effect("prog-transformation") "spheroid","quark": var next: Vector2 = e.position+e.velocity*(45 if e.kind=="spheroid" else 28)*delta if next.x<14 or next.x>278: e.velocity.x *= -1 if next.y<26 or next.y>222: e.velocity.y *= -1 e.position = clamp_arena(e.position+e.velocity*40*delta) if e.timer<=0: spawn("enforcer" if e.kind=="spheroid" else "tank",e.position) e.spawns += 1 e.timer = 1.4 $Audio.effect("spheroid-spawn" if e.kind=="spheroid" else "quark-spawn") if e.spawns>=4: e.alive = false burst(e.position) "enforcer","tank": if e.timer<=0: e.velocity = Vector2.from_angle(rng.randf()*TAU) e.timer = rng.randf_range(.8,1.7)/difficulty projectile("spark" if e.kind=="enforcer" else "shell",e.position,player) e.position = clamp_arena(e.position+e.velocity*(30 if e.kind=="enforcer" else 18)*delta) if e.alive and e.kind!="human" and e.kind!="converting" and player.distance_to(e.position) void: for i in range(shots.size()-1,-1,-1): var shot: Dictionary = shots[i] var distance: float = shot.velocity.length()*delta var direction: Vector2 = shot.velocity.normalized() var hit := false while distance>0 and not hit: var step := minf(2,distance) shot.position += direction*step distance -= step if not ARENA.has_point(shot.position): hit = true for enemy in entities: if hit: break if not enemy.alive or enemy.kind in ["human","converting"]: continue if shot.position.distance_to(enemy.position)0 and shot.position.distance_to(bullet.position)<4: bullet.life = 0 hit = true add_score(50 if bullet.kind=="shell" else (75 if bullet.kind=="cruise" else 25)) if hit: shots.remove_at(i) func update_enemy_shots(delta: float) -> void: for i in range(enemy_shots.size()-1,-1,-1): var b: Dictionary = enemy_shots[i] b.life -= delta if b.kind=="cruise": b.velocity = b.velocity.move_toward((player-b.position).normalized()*78,delta*45) b.position += b.velocity*delta if not ARENA.has_point(b.position): if b.kind=="shell" and b.bounces<4: if b.position.xARENA.end.x: b.velocity.x *= -1 if b.position.yARENA.end.y: b.velocity.y *= -1 b.position = clamp_arena(b.position,1) b.bounces += 1 $Audio.effect("tank-projectile-bounce") else: b.life = 0 if b.life>0 and b.position.distance_to(player)<5 and invulnerable<=0: die() return if b.life<=0: enemy_shots.remove_at(i) func damage(enemy: Dictionary) -> void: enemy.hp -= 1 if enemy.hp>0: return enemy.alive = false add_score(POINTS.get(enemy.kind,0)) burst(enemy.position) var sounds := {"brain":"brain-explode","tank":"tank-explode","electrode":"electrode-explode","spheroid":"spheroid-pop"} $Audio.effect(sounds.get(enemy.kind,"robot-explode")) func save_human(human: Dictionary) -> void: if not human.alive: return human.alive = false rescue_chain = mini(5,rescue_chain+1) add_score(rescue_chain*1000) burst(human.position,str(rescue_chain*1000)) $Audio.effect("human-save") func add_score(value: int) -> void: score += value if demo: return high_score = maxi(high_score,score) while extra_life_interval>0 and score>=next_extra: lives += 1 next_extra += extra_life_interval $Audio.effect("extra-life") func burst(position: Vector2, label: String = "") -> void: effects.append({"position":position,"life":.55 if label!="" else .25,"label":label}) func remaining_enemies() -> int: var count := 0 for e in entities: if e.alive and e.kind not in ["human","hulk","electrode"]: count += 1 return count func die() -> void: if phase!="play": return lives -= 1 rescue_chain = 0 phase = "dead" phase_time = 1.2 $Audio.stop_all() $Audio.effect("player-death") func respawn() -> void: player = Vector2(146,124) shots.clear() enemy_shots.clear() invulnerable = 2 for e in entities: if e.alive and e.kind!="human" and e.position.distance_to(player)<48: e.position = spawn_point() phase = "ready" phase_time = 1 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() if score>0 and score>=high_score: $Audio.effect("high-score-top")