430 lines
14 KiB
GDScript
430 lines
14 KiB
GDScript
@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<distance:
|
|
distance = d
|
|
best = e
|
|
return best
|
|
|
|
func fire(direction: Vector2) -> 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)<radius(e.kind)+2 and invulnerable<=0:
|
|
die()
|
|
return
|
|
update_shots(delta)
|
|
update_enemy_shots(delta)
|
|
entities = entities.filter(func(e): return e.alive)
|
|
if phase=="play" and remaining_enemies()==0:
|
|
phase = "transition"
|
|
phase_time = 1.8
|
|
enemy_shots.clear()
|
|
shots.clear()
|
|
$Audio.stop_all()
|
|
$Audio.effect("level-transition")
|
|
|
|
func update_shots(delta: float) -> 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)<radius(enemy.kind):
|
|
hit = true
|
|
if enemy.kind=="hulk":
|
|
enemy.position = clamp_arena(enemy.position+direction*4)
|
|
enemy.velocity = direction
|
|
enemy.timer = .3
|
|
else: damage(enemy)
|
|
for bullet in enemy_shots:
|
|
if not hit and bullet.life>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.x<ARENA.position.x or b.position.x>ARENA.end.x: b.velocity.x *= -1
|
|
if b.position.y<ARENA.position.y or b.position.y>ARENA.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")
|