edc40f9008
- Touch controls: direct InputEventScreenTouch in shop_ui (bypass relay) - ItemDB: static preload list instead of DirAccess scan (export fix) - All 18 items with EN localisation (name_en, desc_en, category_en) - Ship playstyles: NOVA-1 shield, INFERNO ram, AURORA agile/tank - Quasar: SMBH visual, jet boost, merge, push, BH-eating - Atlas & UI text updated EN+DE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1212 lines
43 KiB
GDScript
1212 lines
43 KiB
GDScript
extends Node2D
|
|
|
|
var W: float = 960.0
|
|
var H: float = 600.0
|
|
const STAR_COUNT := 80
|
|
const STAR_MAX := 160 # hard cap including white hole injections
|
|
const PLANET_COUNT := 4
|
|
const PLANET_MAX := 20 # hard cap including supernova spawns
|
|
const NEBULA_COUNT := 3
|
|
const GALAXY_COUNT := 4
|
|
const ENEMY_COUNT := 2
|
|
const BH_START_COUNT := 1
|
|
const BH_MAX_COUNT := 8
|
|
const WHITE_HOLE_MAX := 2 # cap white holes so they don't pile up
|
|
const COMET_SPAWN_MIN := 3.0
|
|
const COMET_SPAWN_MAX := 10.0
|
|
const ANTIMATTER_SPAWN_MIN := 180.0
|
|
const ANTIMATTER_SPAWN_MAX := 360.0
|
|
const DIFFICULTY_RAMP_SECS := 300.0 # full difficulty at 5 minutes
|
|
const OBJECT_WIPE_THRESHOLD := 500
|
|
const PHYS_DT := 1.0 / 60.0
|
|
const MAX_ENEMEYS := 10000
|
|
|
|
var frame: int = 0
|
|
var is_playing: bool = false
|
|
var is_multiplayer: bool = false
|
|
var palette_p1: Dictionary
|
|
var palette_p2: Dictionary
|
|
|
|
var stars: Array = []
|
|
var planets: Array = []
|
|
var nebulae: Array = []
|
|
var comets: Array = []
|
|
var galaxies: Array = []
|
|
var black_holes: Array = []
|
|
var quasars: Array = []
|
|
var white_holes: Array = []
|
|
var neutron_stars: Array = []
|
|
var antimatter: Array = []
|
|
var bullets: Array = []
|
|
var particles: Array = []
|
|
var antimatter_stars: Array = []
|
|
|
|
var players: Array = []
|
|
var enemies: Array = []
|
|
|
|
var comet_timer: float = 0.0
|
|
var comet_next: float = 5.0
|
|
var antimatter_timer: float = 0.0
|
|
var antimatter_next: float = 50.0
|
|
var credit_tick_timer: float = 0.0
|
|
|
|
var big_wipe: BigWipe = null
|
|
var wipe_count_p1: int = 0
|
|
var wipe_count_p2: int = 0
|
|
var main_node: Node = null
|
|
var menu_mode: bool = false
|
|
|
|
var game_time: float = 0.0
|
|
var difficulty: float = 0.0
|
|
|
|
var wave_duration: float = 60.0 # dynamisch: 20s Welle 1, +8s pro Welle, max 120s
|
|
var wave_timer: float = 0.0
|
|
var wave_complete_flag: bool = false
|
|
var _boss: BossShip = null
|
|
var _wave_number_local: int = 1
|
|
|
|
var _phys_accum: float = 0.0
|
|
var _planet_spawn_t: float = 0.0
|
|
|
|
func _ready() -> void:
|
|
_update_viewport_size()
|
|
get_viewport().size_changed.connect(_update_viewport_size)
|
|
set_process(false)
|
|
|
|
func _update_viewport_size() -> void:
|
|
var vs: Vector2 = get_viewport_rect().size
|
|
W = vs.x
|
|
H = vs.y
|
|
|
|
func init_menu_mode() -> void:
|
|
menu_mode = true
|
|
is_playing = false
|
|
frame = 0
|
|
_phys_accum = 0.0
|
|
_clear_all()
|
|
_spawn_universe()
|
|
# Spawn enemy ships so the simulation looks alive
|
|
for i in ENEMY_COUNT:
|
|
var e := EnemyShip.new()
|
|
var ex := W * 0.85 if i == 0 else W * 0.15
|
|
var ey := H * 0.15 if i == 0 else H * 0.85
|
|
e.init(ex, ey, i % 2, i)
|
|
enemies.append(e)
|
|
big_wipe = BigWipe.new()
|
|
big_wipe.wipe_complete.connect(_on_wipe_complete)
|
|
set_process(true)
|
|
|
|
func init_world(pal1: Dictionary, pal2, multi: bool, stats1: ShipStats = null, stats2: ShipStats = null, wave: int = 1) -> void:
|
|
menu_mode = false
|
|
palette_p1 = pal1
|
|
palette_p2 = pal2 if pal2 != null else {}
|
|
is_multiplayer = multi
|
|
is_playing = false
|
|
frame = 0
|
|
_phys_accum = 0.0
|
|
game_time = 0.0
|
|
difficulty = 0.0
|
|
credit_tick_timer = 0.0
|
|
wave_timer = 0.0
|
|
wave_complete_flag = false
|
|
_boss = null
|
|
_wave_number_local = wave
|
|
wave_duration = clamp(20.0 + float(wave - 1) * 8.0, 20.0, 120.0)
|
|
_clear_all()
|
|
_spawn_universe()
|
|
var p1 := Spaceship.new()
|
|
p1.init(W * 0.3, H * 0.5, pal1, 0)
|
|
p1.invuln_timer = Spaceship.INVULN_FRAMES * 3 # 4.5s grace at game start
|
|
if stats1 != null: p1.apply_stats(stats1)
|
|
players.append(p1)
|
|
if multi and pal2 != null:
|
|
var p2 := Spaceship.new()
|
|
p2.init(W * 0.7, H * 0.5, pal2, 1)
|
|
p2.invuln_timer = Spaceship.INVULN_FRAMES * 3
|
|
if stats2 != null: p2.apply_stats(stats2)
|
|
players.append(p2)
|
|
# 2 enemies on wave 1, +1 per wave (faster scale than before), capped by MAX_ENEMEYS
|
|
var enemy_count: int = min(2 + (wave - 1), MAX_ENEMEYS)
|
|
for i in enemy_count:
|
|
var e := EnemyShip.new()
|
|
var ex: float
|
|
var ey: float
|
|
if i == 0:
|
|
ex = W * 0.85; ey = H * 0.15
|
|
elif i == 1:
|
|
ex = W * 0.15; ey = H * 0.85
|
|
else:
|
|
# Extra enemies from random edges
|
|
match randi() % 4:
|
|
0: ex = randf_range(0.0, W); ey = 0.0
|
|
1: ex = randf_range(0.0, W); ey = H
|
|
2: ex = 0.0; ey = randf_range(0.0, H)
|
|
_: ex = W; ey = randf_range(0.0, H)
|
|
e.init(ex, ey, i % 2, i)
|
|
if wave >= 10:
|
|
var item: Dictionary = ItemDB.roll_shop(1, [])[0]
|
|
var es := ShipStats.new()
|
|
es.apply_item(item["effects"])
|
|
e.apply_stats(es)
|
|
enemies.append(e)
|
|
# Boss-Spawn auf Welle 5 und 8
|
|
if wave == 5:
|
|
_boss = BossShip.new()
|
|
_boss.init_miniboss(W * 0.5, H * 0.5)
|
|
elif wave == 8:
|
|
_boss = BossShip.new()
|
|
_boss.init_boss(W * 0.5, H * 0.5)
|
|
big_wipe = BigWipe.new()
|
|
big_wipe.wipe_complete.connect(_on_wipe_complete)
|
|
set_process(true)
|
|
|
|
func _clear_all() -> void:
|
|
stars.clear(); planets.clear(); nebulae.clear(); comets.clear()
|
|
galaxies.clear(); black_holes.clear(); quasars.clear()
|
|
white_holes.clear(); neutron_stars.clear(); antimatter.clear()
|
|
bullets.clear(); particles.clear(); players.clear(); enemies.clear()
|
|
antimatter_stars.clear()
|
|
|
|
func _spawn_universe() -> void:
|
|
var star_count: int = [40, STAR_COUNT, 160][Settings.star_density]
|
|
for _i in star_count:
|
|
var s := CosmicObjects.Star.new()
|
|
s.init(randf()*W, randf()*H, W, H)
|
|
stars.append(s)
|
|
for _i in PLANET_COUNT:
|
|
var p := CosmicObjects.Planet.new()
|
|
p.init(randf()*W, randf()*H, W, H)
|
|
planets.append(p)
|
|
for _i in NEBULA_COUNT:
|
|
var n := CosmicObjects.Nebula.new()
|
|
n.init(W, H)
|
|
nebulae.append(n)
|
|
for _i in GALAXY_COUNT:
|
|
var g := CosmicObjects.Galaxy.new()
|
|
g.init(randf()*W, randf()*H)
|
|
galaxies.append(g)
|
|
# Spawn mobile black holes — keep them away from player spawn areas
|
|
var bh_attempts := 0
|
|
var spawned_bhs := 0
|
|
while spawned_bhs < BH_START_COUNT and bh_attempts < 30:
|
|
bh_attempts += 1
|
|
var bx := randf_range(W * 0.1, W * 0.9)
|
|
var by := randf_range(H * 0.1, H * 0.9)
|
|
# Reject if too close to either player start
|
|
var d1 := sqrt((bx - W*0.3)*(bx - W*0.3) + (by - H*0.5)*(by - H*0.5))
|
|
var d2 := sqrt((bx - W*0.7)*(bx - W*0.7) + (by - H*0.5)*(by - H*0.5))
|
|
if d1 < 220.0 or d2 < 220.0:
|
|
continue
|
|
var bh := BlackHole.new()
|
|
bh.init(bx, by, true)
|
|
black_holes.append(bh)
|
|
spawned_bhs += 1
|
|
var q := CosmicObjects.Quasar.new()
|
|
q.init(randf()*W, randf()*H)
|
|
quasars.append(q)
|
|
|
|
func start_playing() -> void:
|
|
is_playing = true
|
|
wave_timer = 0.0
|
|
wave_complete_flag = false
|
|
# Boss-Musik starten wenn Welle einen Boss hat
|
|
if _boss != null and main_node and main_node.has_method("start_boss_music"):
|
|
# LEVIATHAN (Welle 8) bekommt eigenen Track; WRAITH (Welle 5) den Standard-Boss-Track
|
|
main_node.start_boss_music(not _boss.is_miniboss)
|
|
|
|
func on_player_died() -> void:
|
|
is_playing = false
|
|
|
|
func get_score_data() -> Dictionary:
|
|
var data: Dictionary = {"p1_time": 0.0, "p1_kills": 0, "p1_wipe": 0,
|
|
"p2_time": 0.0, "p2_kills": 0, "p2_wipe": 0, "multiplayer": is_multiplayer}
|
|
if players.size() > 0:
|
|
var p0: Spaceship = players[0]
|
|
data["p1_time"] = p0.survival_time
|
|
data["p1_kills"] = p0.kills
|
|
data["p1_wipe"] = wipe_count_p1 * BigWipe.WIPE_BONUS
|
|
if players.size() > 1:
|
|
var p1: Spaceship = players[1]
|
|
data["p2_time"] = p1.survival_time
|
|
data["p2_kills"] = p1.kills
|
|
data["p2_wipe"] = wipe_count_p2 * BigWipe.WIPE_BONUS
|
|
return data
|
|
|
|
func _total_object_count() -> int:
|
|
return stars.size() + planets.size() + comets.size() + galaxies.size() \
|
|
+ black_holes.size() + quasars.size() + white_holes.size() \
|
|
+ neutron_stars.size() + antimatter.size() + antimatter_stars.size()
|
|
|
|
func _process(delta: float) -> void:
|
|
if not is_playing and not menu_mode:
|
|
queue_redraw()
|
|
return
|
|
_phys_accum = min(_phys_accum + delta, PHYS_DT * 5.0)
|
|
while _phys_accum >= PHYS_DT:
|
|
_phys_accum -= PHYS_DT
|
|
frame += 1
|
|
_tick(PHYS_DT)
|
|
queue_redraw()
|
|
|
|
func _tick(dt: float) -> void:
|
|
_handle_input(dt)
|
|
_update_objects(dt)
|
|
_update_bullets()
|
|
# Boss-Bewegung und Schüsse (vor Kollisionscheck, damit Kugeln im Array sind)
|
|
if _boss != null and not _boss.dead and is_playing and not menu_mode:
|
|
var boss_bullets: Array = _boss.update(dt, W * 0.5, H * 0.5)
|
|
for bb in boss_bullets:
|
|
bullets.append(bb)
|
|
_update_particles(dt)
|
|
_check_collisions()
|
|
_check_big_wipe()
|
|
if big_wipe and big_wipe.is_active():
|
|
big_wipe.update(dt, is_multiplayer)
|
|
# Advance difficulty only during real gameplay
|
|
if not menu_mode and is_playing:
|
|
game_time += dt
|
|
difficulty = clamp(game_time / DIFFICULTY_RAMP_SECS, 0.0, 1.0)
|
|
# Passive credit trickle: 5 credits per 10 seconds
|
|
credit_tick_timer += dt
|
|
if credit_tick_timer >= 10.0:
|
|
credit_tick_timer = 0.0
|
|
if main_node and players.size() > 0:
|
|
main_node.add_credits(0, 5)
|
|
# Wave timer — skaliert von 20s (Welle 1) auf 120s
|
|
wave_timer += dt
|
|
if wave_timer >= wave_duration and not wave_complete_flag:
|
|
wave_complete_flag = true
|
|
if main_node:
|
|
# Boss überlebt → Musik trotzdem zurückschalten
|
|
if _boss != null and main_node.has_method("stop_boss_music"):
|
|
main_node.stop_boss_music()
|
|
main_node.on_wave_complete()
|
|
# Boss gestorben → Welle sofort abschließen + Bonus-Credits
|
|
if _boss != null and _boss.dead and not wave_complete_flag:
|
|
wave_complete_flag = true
|
|
var boss_bonus: int = 150 if _boss.is_miniboss else 300
|
|
if main_node:
|
|
main_node.add_credits(0, boss_bonus)
|
|
if players.size() > 1:
|
|
main_node.add_credits(1, boss_bonus)
|
|
# Zurück zur normalen Musik
|
|
if main_node.has_method("stop_boss_music"):
|
|
main_node.stop_boss_music()
|
|
main_node.on_wave_complete()
|
|
# Boss Phase-2-Übergang: schwarzes Loch spawnen + Musik verdichtet
|
|
if _boss != null and not _boss.is_miniboss and _boss._just_entered_phase2:
|
|
_boss._just_entered_phase2 = false
|
|
if black_holes.size() < BH_MAX_COUNT:
|
|
var phase_bh := BlackHole.new()
|
|
phase_bh.init(_boss.x, _boss.y, true)
|
|
black_holes.append(phase_bh)
|
|
if main_node and main_node.has_method("boss_phase_changed"):
|
|
main_node.boss_phase_changed(2)
|
|
|
|
comet_timer += dt
|
|
var comet_max_interval: float = lerp(float(COMET_SPAWN_MAX), float(COMET_SPAWN_MIN), difficulty)
|
|
if comet_timer >= comet_next:
|
|
comet_timer = 0.0
|
|
comet_next = randf_range(COMET_SPAWN_MIN, comet_max_interval)
|
|
var c := CosmicObjects.Comet.new()
|
|
c.init(W, H)
|
|
comets.append(c)
|
|
antimatter_timer += dt
|
|
var am_max_interval: float = lerp(float(ANTIMATTER_SPAWN_MAX), float(ANTIMATTER_SPAWN_MIN), difficulty)
|
|
if antimatter_timer >= antimatter_next:
|
|
antimatter_timer = 0.0
|
|
antimatter_next = randf_range(ANTIMATTER_SPAWN_MIN, am_max_interval)
|
|
_spawn_antimatter_swarm(W * 0.5 + randf_range(-100, 100), H * 0.5 + randf_range(-100, 100))
|
|
SoundManager.play_sfx("antimatter_swarm")
|
|
var all_dead: bool = true
|
|
for player in players:
|
|
var sp: Spaceship = player
|
|
if not sp.dead:
|
|
all_dead = false
|
|
if all_dead and is_playing:
|
|
is_playing = false
|
|
if main_node:
|
|
main_node.on_game_over()
|
|
|
|
func _handle_input(dt: float) -> void:
|
|
if players.size() > 0:
|
|
var p0: Spaceship = players[0]
|
|
if not p0.dead:
|
|
var thrust: bool = Input.is_action_pressed("p1_thrust")
|
|
var turn: int = 0
|
|
if Input.is_action_pressed("p1_left"): turn = -1
|
|
elif Input.is_action_pressed("p1_right"): turn = 1
|
|
p0.update(thrust, turn, W, H, dt)
|
|
if p0.has_charge_weapon():
|
|
if Input.is_action_just_pressed("p1_shoot"):
|
|
p0.charge_timer = 0.0
|
|
elif Input.is_action_pressed("p1_shoot"):
|
|
p0.charge_timer = min(p0.charge_timer + dt, p0.CHARGE_MAX)
|
|
elif Input.is_action_just_released("p1_shoot"):
|
|
var b: Bullet = p0.shoot_charged(p0.charge_timer / p0.CHARGE_MAX)
|
|
if b != null:
|
|
bullets.append(b)
|
|
SoundManager.play_sfx("player_shoot")
|
|
elif Input.is_action_pressed("p1_shoot"):
|
|
for b: Bullet in p0.shoot_burst():
|
|
bullets.append(b)
|
|
SoundManager.play_sfx("player_shoot")
|
|
if Input.is_action_pressed("p1_wipe") and big_wipe and big_wipe.is_active():
|
|
big_wipe.player_press_survive(0)
|
|
if Input.is_action_just_pressed("p1_boost"):
|
|
if p0.try_boost():
|
|
SoundManager.play_sfx("player_shoot")
|
|
if players.size() > 1:
|
|
var p1: Spaceship = players[1]
|
|
if not p1.dead:
|
|
var thrust: bool = Input.is_action_pressed("p2_thrust")
|
|
var turn: int = 0
|
|
if Input.is_action_pressed("p2_left"): turn = -1
|
|
elif Input.is_action_pressed("p2_right"): turn = 1
|
|
p1.update(thrust, turn, W, H, dt)
|
|
if p1.has_charge_weapon():
|
|
if Input.is_action_just_pressed("p2_shoot"):
|
|
p1.charge_timer = 0.0
|
|
elif Input.is_action_pressed("p2_shoot"):
|
|
p1.charge_timer = min(p1.charge_timer + dt, p1.CHARGE_MAX)
|
|
elif Input.is_action_just_released("p2_shoot"):
|
|
var b: Bullet = p1.shoot_charged(p1.charge_timer / p1.CHARGE_MAX)
|
|
if b != null:
|
|
bullets.append(b)
|
|
SoundManager.play_sfx("player_shoot")
|
|
elif Input.is_action_pressed("p2_shoot"):
|
|
for b: Bullet in p1.shoot_burst():
|
|
bullets.append(b)
|
|
SoundManager.play_sfx("player_shoot")
|
|
if Input.is_action_pressed("p2_wipe") and big_wipe and big_wipe.is_active():
|
|
big_wipe.player_press_survive(1)
|
|
if Input.is_action_just_pressed("p2_boost"):
|
|
if p1.try_boost():
|
|
SoundManager.play_sfx("player_shoot")
|
|
|
|
func _update_objects(delta: float) -> void:
|
|
for star in stars:
|
|
var s: CosmicObjects.Star = star
|
|
var spawn_swarm: bool = s.update(delta, W, H)
|
|
if spawn_swarm:
|
|
_spawn_antimatter_swarm(s.x, s.y)
|
|
s.dead = true
|
|
stars = stars.filter(func(s): return not s.dead)
|
|
while stars.size() < STAR_COUNT:
|
|
var ns := CosmicObjects.Star.new()
|
|
ns.init(randf()*W, H + 2.0, W, H)
|
|
stars.append(ns)
|
|
for planet in planets:
|
|
var p: CosmicObjects.Planet = planet
|
|
p.update(delta)
|
|
planets = planets.filter(func(p): return not p.dead)
|
|
# Slow refill: one planet every 20 s while below baseline, up to hard cap
|
|
if planets.size() < PLANET_COUNT:
|
|
_planet_spawn_t += delta
|
|
if _planet_spawn_t >= 20.0:
|
|
_planet_spawn_t = 0.0
|
|
var rp := CosmicObjects.Planet.new()
|
|
rp.init(randf_range(W * 0.05, W * 0.95), randf_range(H * 0.05, H * 0.95), W, H)
|
|
planets.append(rp)
|
|
for neb in nebulae:
|
|
var n: CosmicObjects.Nebula = neb
|
|
n.update(W, H)
|
|
for comet in comets:
|
|
var c: CosmicObjects.Comet = comet
|
|
c.update(W, H)
|
|
comets = comets.filter(func(c): return not c.dead)
|
|
for gal in galaxies:
|
|
var g: CosmicObjects.Galaxy = gal
|
|
g.update(delta, W, H)
|
|
# When galaxy finishes being consumed, trigger SMBH
|
|
if g.dead and not g.respawning and g.consuming_bh != null:
|
|
var cbh: BlackHole = g.consuming_bh
|
|
_spawn_spiral_particles(g.x, g.y, cbh.x, cbh.y, 16, Color(1.0, 0.8, 0.3), 3.0)
|
|
cbh.trigger_flash(Color(1.0, 0.8, 0.3), 1.0)
|
|
g.consuming_bh = null
|
|
g.respawning = true
|
|
g.respawn_timer = randf_range(5.0, 10.0)
|
|
if cbh.consumed >= 12 and not cbh.is_smbh:
|
|
cbh.become_smbh()
|
|
SoundManager.play_sfx("smbh_spawn")
|
|
for qsr in quasars:
|
|
var q: CosmicObjects.Quasar = qsr
|
|
q.update(delta)
|
|
# Radiation push — always active, no thrust required
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead: continue
|
|
var nv: Vector2 = q.push_object(p.x, p.y, p.vx, p.vy)
|
|
p.vx = nv.x; p.vy = nv.y
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if not e.dead:
|
|
var nv: Vector2 = q.push_object(e.x, e.y, e.vx, e.vy)
|
|
e.vx = nv.x; e.vy = nv.y
|
|
for comet in comets:
|
|
var c: CosmicObjects.Comet = comet
|
|
if not c.dead:
|
|
var nv: Vector2 = q.push_object(c.x, c.y, c.vx, c.vy)
|
|
c.vx = nv.x; c.vy = nv.y
|
|
# Jet boost — only while thrusting
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead or not p.is_thrusting: continue
|
|
var nv: Vector2 = q.boost_if_in_jet(p.x, p.y, p.vx, p.vy)
|
|
if nv.x != p.vx or nv.y != p.vy:
|
|
p.vx = nv.x; p.vy = nv.y
|
|
p.boost_active_t = max(p.boost_active_t, 0.25)
|
|
if q.boost_sound_cd <= 0.0:
|
|
q.boost_sound_cd = 1.2
|
|
SoundManager.play_sfx("quasar_boost")
|
|
quasars = quasars.filter(func(q): return not q.dead)
|
|
_merge_quasars()
|
|
var new_stars_from_wh: Array = []
|
|
var new_planets_from_wh: Array = []
|
|
for whole in white_holes:
|
|
var wh: CosmicObjects.WhiteHole = whole
|
|
var result: Dictionary = wh.update(delta)
|
|
new_stars_from_wh.append_array(result["stars"])
|
|
new_planets_from_wh.append_array(result["planets"])
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if not p.dead:
|
|
var nv: Vector2 = wh.push_object(p.x, p.y, p.vx, p.vy)
|
|
p.vx = nv.x; p.vy = nv.y
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if not e.dead:
|
|
var nv: Vector2 = wh.push_object(e.x, e.y, e.vx, e.vy)
|
|
e.vx = nv.x; e.vy = nv.y
|
|
for comet in comets:
|
|
var c: CosmicObjects.Comet = comet
|
|
if not c.dead:
|
|
var nv: Vector2 = wh.push_object(c.x, c.y, c.vx, c.vy)
|
|
c.vx = nv.x; c.vy = nv.y
|
|
for star in stars:
|
|
var s: CosmicObjects.Star = star
|
|
if s.dead or s.is_spiraling: continue
|
|
var nv: Vector2 = wh.push_object(s.x, s.y, s.grav_vx, s.grav_vy)
|
|
s.grav_vx = nv.x; s.grav_vy = nv.y
|
|
# Only inject up to the hard caps
|
|
for ws in new_stars_from_wh:
|
|
if stars.size() < STAR_MAX: stars.append(ws)
|
|
for wp in new_planets_from_wh:
|
|
if planets.size() < PLANET_MAX: planets.append(wp)
|
|
white_holes = white_holes.filter(func(wh): return not wh.dead)
|
|
for nstar in neutron_stars:
|
|
var ns: CosmicObjects.NeutronStar = nstar
|
|
ns.update(delta)
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if not p.dead:
|
|
var nv: Vector2 = ns.push_if_in_beam(p.x, p.y, p.vx, p.vy)
|
|
p.vx = nv.x; p.vy = nv.y
|
|
neutron_stars = neutron_stars.filter(func(ns): return not ns.dead)
|
|
for am_obj in antimatter:
|
|
var am: CosmicObjects.Antimatter = am_obj
|
|
am.update(W, H, delta, antimatter)
|
|
antimatter = antimatter.filter(func(am): return not am.dead)
|
|
_check_antimatter_clustering()
|
|
for as_obj in antimatter_stars:
|
|
var astar: CosmicObjects.AntimatterStar = as_obj
|
|
astar.update(delta)
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if not p.dead:
|
|
var nv: Vector2 = astar.apply_repulsion(p.x, p.y, p.vx, p.vy)
|
|
p.vx = nv.x; p.vy = nv.y
|
|
antimatter_stars = antimatter_stars.filter(func(a): return not a.dead)
|
|
_apply_gravity_all(delta)
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if e.dead:
|
|
e.respawn_timer -= delta
|
|
if e.respawn_timer <= 0:
|
|
# Spawn from a random screen edge, away from players
|
|
var edge := randi() % 4
|
|
var ex: float; var ey: float
|
|
match edge:
|
|
0: ex = randf_range(0.0, W); ey = 0.0
|
|
1: ex = randf_range(0.0, W); ey = H
|
|
2: ex = 0.0; ey = randf_range(0.0, H)
|
|
_: ex = W; ey = randf_range(0.0, H)
|
|
e.init(ex, ey, e.enemy_index, e.role_id)
|
|
continue
|
|
var b: Bullet = e.update(players, black_holes, enemies, W, H, delta)
|
|
if b != null:
|
|
bullets.append(b)
|
|
SoundManager.play_sfx("enemy_shoot")
|
|
|
|
func _apply_gravity_all(_delta: float) -> void:
|
|
for bhole in black_holes:
|
|
var bh: BlackHole = bhole
|
|
if bh.dead: continue # skip BHs already killed this tick
|
|
bh.update(_delta, players, W, H)
|
|
|
|
# ── Stars ──────────────────────────────────────────────────────────
|
|
for star in stars:
|
|
if bh.dead: break # BH just supernovaed — stop immediately
|
|
var s: CosmicObjects.Star = star
|
|
var nv: Vector2 = bh.apply_gravity(s.x, s.y, s.grav_vx, s.grav_vy)
|
|
s.grav_vx = nv.x * 0.99
|
|
s.grav_vy = nv.y * 0.99
|
|
var gsp := sqrt(s.grav_vx*s.grav_vx + s.grav_vy*s.grav_vy)
|
|
if gsp > BlackHole.OBJ_MAX_VEL:
|
|
s.grav_vx = s.grav_vx/gsp * BlackHole.OBJ_MAX_VEL
|
|
s.grav_vy = s.grav_vy/gsp * BlackHole.OBJ_MAX_VEL
|
|
if not s.is_spiraling and bh.check_swallow(s.x, s.y):
|
|
s.start_spiral(bh.x, bh.y)
|
|
bh.trigger_flash(Color(0.6, 0.7, 1.0), 0.6)
|
|
var trigger: bool = bh.on_swallow()
|
|
SoundManager.play_sfx("bh_swallow")
|
|
if trigger:
|
|
_trigger_supernova(bh)
|
|
break # BH is dead — do not consume more objects this tick
|
|
|
|
if bh.dead: continue
|
|
|
|
# ── Planets ────────────────────────────────────────────────────────
|
|
for planet in planets:
|
|
if bh.dead: break
|
|
var p: CosmicObjects.Planet = planet
|
|
if not p.captured:
|
|
var dx: float = p.x - bh.x; var dy: float = p.y - bh.y
|
|
if sqrt(dx*dx + dy*dy) < bh.pull_radius * 0.6:
|
|
p.start_capture(bh.x, bh.y)
|
|
if p.captured and bh.check_swallow(p.x, p.y):
|
|
_spawn_spiral_particles(p.x, p.y, bh.x, bh.y, int(p.radius * 3.0), p.color, 5.0)
|
|
bh.trigger_flash(p.color, 0.8)
|
|
p.dead = true
|
|
var trigger: bool = bh.on_swallow()
|
|
if trigger:
|
|
_trigger_supernova(bh)
|
|
break
|
|
|
|
if bh.dead: continue
|
|
|
|
# ── Bullets, Players, Enemies ──────────────────────────────────────
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
if bh.check_swallow(b.x, b.y):
|
|
b.dead = true
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead or p.is_invulnerable(): continue
|
|
var prev_vx := p.vx; var prev_vy := p.vy
|
|
var nv: Vector2 = bh.apply_gravity(p.x, p.y, p.vx, p.vy)
|
|
if p.stats and p.stats.bh_resist > 0.0:
|
|
var resist := 1.0 - p.stats.bh_resist
|
|
p.vx = lerp(prev_vx, nv.x, resist)
|
|
p.vy = lerp(prev_vy, nv.y, resist)
|
|
else:
|
|
p.vx = nv.x; p.vy = nv.y
|
|
if bh.check_swallow(p.x, p.y):
|
|
_kill_player(p)
|
|
SoundManager.play_sfx("player_die")
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if e.dead: continue
|
|
var nv: Vector2 = bh.apply_gravity(e.x, e.y, e.vx, e.vy)
|
|
e.vx = nv.x; e.vy = nv.y
|
|
|
|
# ── Galaxies ───────────────────────────────────────────────────────
|
|
for gal in galaxies:
|
|
var g: CosmicObjects.Galaxy = gal
|
|
if g.dead or g.respawning or g.being_consumed: continue
|
|
var gdx: float = bh.x - g.x; var gdy: float = bh.y - g.y
|
|
if sqrt(gdx*gdx + gdy*gdy) < g.radius * 0.55:
|
|
g.being_consumed = true
|
|
g.consuming_bh = bh
|
|
g.consume_bh_x = bh.x
|
|
g.consume_bh_y = bh.y
|
|
g.consume_initial_radius = g.radius
|
|
g.consume_initial_alpha = g.alpha
|
|
g.original_color = g.color
|
|
for gal in galaxies:
|
|
var g: CosmicObjects.Galaxy = gal
|
|
if g.being_consumed and g.consuming_bh != null and g.consuming_bh == bh:
|
|
g.consume_bh_x = bh.x
|
|
g.consume_bh_y = bh.y
|
|
|
|
# ── Antimatter ─────────────────────────────────────────────────────
|
|
for am_obj in antimatter:
|
|
var am: CosmicObjects.Antimatter = am_obj
|
|
if am.dead: continue
|
|
if bh.check_swallow(am.x, am.y):
|
|
am.dead = true
|
|
_spawn_spiral_particles(am.x, am.y, bh.x, bh.y, 10, Color(1.0, 0.2, 0.9), 4.0)
|
|
bh.trigger_flash(Color(1.0, 0.2, 0.9), 0.5)
|
|
# ── Antimatter Stars ───────────────────────────────────────────────
|
|
for as_obj in antimatter_stars:
|
|
if bh.dead: break
|
|
var astar: CosmicObjects.AntimatterStar = as_obj
|
|
if astar.dead: continue
|
|
if bh.check_swallow(astar.x, astar.y):
|
|
astar.dead = true
|
|
_spawn_spiral_particles(astar.x, astar.y, bh.x, bh.y, 20, Color(0.9, 0.1, 1.0), 5.0)
|
|
bh.trigger_flash(Color(0.9, 0.1, 1.0), 0.8)
|
|
var trigger: bool = bh.on_swallow()
|
|
if trigger:
|
|
_trigger_supernova(bh)
|
|
break
|
|
|
|
# ── White holes (one per tick, break after any consumption) ────────
|
|
for whole in white_holes:
|
|
if bh.dead: break
|
|
var wh: CosmicObjects.WhiteHole = whole
|
|
if wh.dead: continue
|
|
var wdx: float = wh.x - bh.x; var wdy: float = wh.y - bh.y
|
|
if sqrt(wdx*wdx + wdy*wdy) < bh.radius + wh.radius:
|
|
wh.dead = true
|
|
_spawn_spiral_particles(wh.x, wh.y, bh.x, bh.y, 20, Color(0.6, 0.9, 1.0), 5.0)
|
|
bh.trigger_flash(Color(0.6, 0.9, 1.0), 1.0)
|
|
var trigger: bool = bh.on_swallow()
|
|
SoundManager.play_sfx("bh_swallow")
|
|
if trigger:
|
|
_trigger_supernova(bh)
|
|
break # only one white hole consumed per tick
|
|
|
|
# ── Quasars (only eaten if BH is larger) ──────────────────────────
|
|
for qsr in quasars:
|
|
if bh.dead: break
|
|
var q: CosmicObjects.Quasar = qsr
|
|
if q.dead or bh.radius <= q.radius: continue
|
|
var qdx := q.x - bh.x; var qdy := q.y - bh.y
|
|
if sqrt(qdx*qdx + qdy*qdy) < bh.radius + q.radius:
|
|
q.dead = true
|
|
_spawn_spiral_particles(q.x, q.y, bh.x, bh.y, 25, Color(1.0, 0.9, 0.3), 5.0)
|
|
bh.trigger_flash(Color(1.0, 0.9, 0.3), 1.0)
|
|
var trigger: bool = bh.on_swallow()
|
|
SoundManager.play_sfx("bh_swallow")
|
|
if trigger:
|
|
_trigger_supernova(bh)
|
|
break
|
|
|
|
# BH-BH merging — larger eats smaller when they overlap
|
|
_merge_black_holes()
|
|
|
|
func _merge_quasars() -> void:
|
|
var n := quasars.size()
|
|
for i in n:
|
|
var qa: CosmicObjects.Quasar = quasars[i]
|
|
if qa.dead: continue
|
|
for j in range(i + 1, n):
|
|
var qb: CosmicObjects.Quasar = quasars[j]
|
|
if qb.dead: continue
|
|
var dx := qa.x - qb.x; var dy := qa.y - qb.y
|
|
if sqrt(dx*dx + dy*dy) < qa.radius + qb.radius + 60.0:
|
|
var bigger := qa if qa.radius >= qb.radius else qb
|
|
var smaller := qb if qa.radius >= qb.radius else qa
|
|
bigger.radius = minf(55.0, bigger.radius + smaller.radius * 0.6)
|
|
bigger.life = maxf(0.0, bigger.life - 8.0)
|
|
_spawn_spiral_particles(smaller.x, smaller.y, bigger.x, bigger.y,
|
|
18, Color(1.0, 0.9, 0.3), 4.0)
|
|
smaller.dead = true
|
|
|
|
func _merge_black_holes() -> void:
|
|
var pending_supernovas: Array = []
|
|
var n := black_holes.size()
|
|
for i in n:
|
|
var bh1: BlackHole = black_holes[i]
|
|
if bh1.dead: continue
|
|
for j in range(i + 1, n):
|
|
var bh2: BlackHole = black_holes[j]
|
|
if bh2.dead: continue
|
|
var dx: float = bh1.x - bh2.x
|
|
var dy: float = bh1.y - bh2.y
|
|
if sqrt(dx*dx + dy*dy) < bh1.radius + bh2.radius:
|
|
var bigger: BlackHole = bh1 if bh1.radius >= bh2.radius else bh2
|
|
var smaller: BlackHole = bh2 if bh1.radius >= bh2.radius else bh1
|
|
_spawn_spiral_particles(smaller.x, smaller.y, bigger.x, bigger.y, 20, Color(0.5, 0.1, 1.0), 6.0)
|
|
bigger.trigger_flash(Color(0.6, 0.2, 1.0), 1.0)
|
|
smaller.dead = true
|
|
var trigger: bool = bigger.on_swallow()
|
|
if trigger:
|
|
pending_supernovas.append(bigger)
|
|
else:
|
|
SoundManager.play_sfx("bh_swallow")
|
|
# Collect dying SMBHs before filtering
|
|
var pending_smbh_collapses: Array = []
|
|
for bhole in black_holes:
|
|
var bh: BlackHole = bhole
|
|
if bh.dead and bh.is_smbh and bh.smbh_dying:
|
|
pending_smbh_collapses.append(bh)
|
|
# Filter first, then trigger events to avoid mid-loop array modification
|
|
black_holes = black_holes.filter(func(b): return not b.dead)
|
|
for sn in pending_supernovas:
|
|
_trigger_supernova(sn)
|
|
for bh in pending_smbh_collapses:
|
|
_trigger_smbh_collapse(bh)
|
|
|
|
func _trigger_supernova(bh: BlackHole) -> void:
|
|
SoundManager.play_sfx("wipe_flash")
|
|
_spawn_explosion_particles(bh.x, bh.y, 40, Color(1.0, 0.8, 0.3))
|
|
bh.dead = true
|
|
black_holes = black_holes.filter(func(b): return not b.dead)
|
|
# Spawn Quasar from the explosion
|
|
var q := CosmicObjects.Quasar.new()
|
|
q.init(bh.x, bh.y)
|
|
quasars.append(q)
|
|
# Spawn 1-2 new mobile black holes — ejected outward so they don't re-eat fresh objects
|
|
var bh_cap := int(lerp(1.0, float(BH_MAX_COUNT), difficulty))
|
|
var spawn_count := randi_range(1, 2)
|
|
for _si in spawn_count:
|
|
if black_holes.size() < bh_cap:
|
|
var nbh := BlackHole.new()
|
|
var bea := randf() * TAU
|
|
nbh.init(bh.x + cos(bea) * randf_range(120.0, 220.0),
|
|
bh.y + sin(bea) * randf_range(120.0, 220.0), true)
|
|
black_holes.append(nbh)
|
|
# Also spawn a white hole (capped) or neutron star
|
|
if randf() > 0.5 and white_holes.size() < WHITE_HOLE_MAX:
|
|
var wh := CosmicObjects.WhiteHole.new()
|
|
wh.init(bh.x + randf_range(-60,60), bh.y + randf_range(-60,60))
|
|
white_holes.append(wh)
|
|
else:
|
|
var ns := CosmicObjects.NeutronStar.new()
|
|
ns.init(bh.x, bh.y)
|
|
neutron_stars.append(ns)
|
|
# Stars ejected radially — they fly outward from the explosion
|
|
for _si in randi_range(8, 14):
|
|
if stars.size() >= STAR_MAX: break
|
|
var ns2 := CosmicObjects.Star.new()
|
|
var ea := randf() * TAU
|
|
var ed := randf_range(25.0, 150.0)
|
|
ns2.x = bh.x + cos(ea) * ed
|
|
ns2.y = bh.y + sin(ea) * ed
|
|
ns2.speed = randf_range(0.15, 0.7)
|
|
ns2.size = randf_range(1.5, 4.5)
|
|
ns2.alpha = 0.7 + randf() * 0.3
|
|
ns2.glow = ns2.size >= 3.0
|
|
ns2.color = Color(randf_range(0.85, 1.0), randf_range(0.55, 0.95), randf_range(0.2, 0.6))
|
|
ns2.grav_vx = cos(ea) * randf_range(0.5, 2.0)
|
|
ns2.grav_vy = sin(ea) * randf_range(0.5, 2.0)
|
|
ns2.twinkle_phase = randf() * TAU
|
|
ns2.twinkle_speed = randf_range(0.5, 2.0)
|
|
stars.append(ns2)
|
|
for _pi in randi_range(3, 5):
|
|
if planets.size() >= PLANET_MAX: break
|
|
var np := CosmicObjects.Planet.new()
|
|
var ea := randf() * TAU
|
|
np.init(bh.x + cos(ea) * randf_range(60.0, 200.0),
|
|
bh.y + sin(ea) * randf_range(60.0, 200.0), W, H)
|
|
planets.append(np)
|
|
# Antimatter burst only during actual gameplay (not menu background)
|
|
if not menu_mode:
|
|
_spawn_antimatter_swarm(bh.x, bh.y)
|
|
|
|
func _trigger_smbh_collapse(bh: BlackHole) -> void:
|
|
SoundManager.play_sfx("wipe_flash")
|
|
_spawn_explosion_particles(bh.x, bh.y, 60, Color(1.0, 0.65, 0.0))
|
|
var bh_cap := int(lerp(1.0, float(BH_MAX_COUNT), difficulty))
|
|
# Large birth burst — stars ejected radially in all directions
|
|
for _i in randi_range(12, 18):
|
|
if stars.size() >= STAR_MAX: break
|
|
var s := CosmicObjects.Star.new()
|
|
var ea := randf() * TAU
|
|
var ed := randf_range(40.0, 260.0)
|
|
s.x = bh.x + cos(ea) * ed
|
|
s.y = bh.y + sin(ea) * ed
|
|
s.speed = randf_range(0.2, 0.8)
|
|
s.size = randf_range(2.0, 5.5)
|
|
s.alpha = 0.8 + randf() * 0.2
|
|
s.glow = true
|
|
s.color = Color(randf_range(0.9, 1.0), randf_range(0.6, 0.9), randf_range(0.2, 0.5))
|
|
s.twinkle_phase = randf() * TAU
|
|
s.twinkle_speed = randf_range(0.5, 2.0)
|
|
s.grav_vx = cos(ea) * randf_range(1.0, 3.5)
|
|
s.grav_vy = sin(ea) * randf_range(1.0, 3.5)
|
|
stars.append(s)
|
|
# Planets
|
|
for _i in randi_range(5, 8):
|
|
if planets.size() >= PLANET_MAX: break
|
|
var p := CosmicObjects.Planet.new()
|
|
var ea := randf() * TAU
|
|
p.init(bh.x + cos(ea) * randf_range(80.0, 300.0),
|
|
bh.y + sin(ea) * randf_range(80.0, 300.0), W, H)
|
|
planets.append(p)
|
|
# New smaller BHs — scattered wide so they don't cluster on fresh objects
|
|
for _i in 3:
|
|
if black_holes.size() >= bh_cap: break
|
|
var nb := BlackHole.new()
|
|
var ea := randf() * TAU
|
|
nb.init(bh.x + cos(ea) * randf_range(160.0, 300.0),
|
|
bh.y + sin(ea) * randf_range(160.0, 300.0), true)
|
|
black_holes.append(nb)
|
|
# White hole at the collapse site
|
|
if white_holes.size() < WHITE_HOLE_MAX:
|
|
var wh := CosmicObjects.WhiteHole.new()
|
|
wh.init(bh.x, bh.y)
|
|
white_holes.append(wh)
|
|
|
|
func _check_antimatter_clustering() -> void:
|
|
const CLUSTER_RADIUS := 30.0
|
|
const CLUSTER_MIN := 5
|
|
var used := {}
|
|
var to_remove: Array = []
|
|
for i in antimatter.size():
|
|
if i in used: continue
|
|
var a: CosmicObjects.Antimatter = antimatter[i]
|
|
var group: Array = [a]
|
|
for j in range(i + 1, antimatter.size()):
|
|
if j in used: continue
|
|
var b: CosmicObjects.Antimatter = antimatter[j]
|
|
var dx := b.x - a.x; var dy := b.y - a.y
|
|
if sqrt(dx*dx + dy*dy) < CLUSTER_RADIUS:
|
|
group.append(b)
|
|
used[j] = true
|
|
if group.size() >= CLUSTER_MIN:
|
|
used[i] = true
|
|
var cx := 0.0; var cy := 0.0
|
|
for m in group:
|
|
cx += float(m.x); cy += float(m.y)
|
|
cx /= group.size(); cy /= group.size()
|
|
var astar := CosmicObjects.AntimatterStar.new()
|
|
astar.init(cx, cy)
|
|
antimatter_stars.append(astar)
|
|
for m in group:
|
|
to_remove.append(m)
|
|
for m in to_remove:
|
|
antimatter.erase(m)
|
|
|
|
func _update_bullets() -> void:
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
b.update(W, H)
|
|
bullets = bullets.filter(func(b): return not b.dead)
|
|
|
|
func _update_particles(delta: float) -> void:
|
|
for pt in particles:
|
|
pt["prev_x"] = pt["x"]
|
|
pt["prev_y"] = pt["y"]
|
|
if pt.has("target_x"):
|
|
var pdx: float = float(pt["target_x"]) - float(pt["x"])
|
|
var pdy: float = float(pt["target_y"]) - float(pt["y"])
|
|
var pdist: float = sqrt(pdx * pdx + pdy * pdy)
|
|
if pdist > 1.0:
|
|
var grav: float = float(pt.get("gravity", 3.0))
|
|
var force: float = grav / maxf(pdist * 0.3, 1.0)
|
|
pt["vx"] = float(pt["vx"]) + (pdx / pdist) * force * delta * 60.0
|
|
pt["vy"] = float(pt["vy"]) + (pdy / pdist) * force * delta * 60.0
|
|
pt["x"] = float(pt["x"]) + float(pt["vx"])
|
|
pt["y"] = float(pt["y"]) + float(pt["vy"])
|
|
pt["life"] = float(pt["life"]) - delta
|
|
particles = particles.filter(func(p): return float(p["life"]) > 0.0)
|
|
|
|
func _check_collisions() -> void:
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
if b.dead: continue
|
|
if b.owner_type == "p1" or b.owner_type == "p2":
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if e.dead or e.invuln_timer > 0: continue
|
|
var dx: float = b.x - e.x
|
|
var dy: float = b.y - e.y
|
|
var hit_r := b.effective_hit_radius
|
|
if dx*dx + dy*dy < hit_r * hit_r:
|
|
# Pierce: bullet survives if it hasn't hit too many targets
|
|
if b.pierce:
|
|
b.pierce_hits += 1
|
|
if b.pierce_hits >= 2: b.dead = true
|
|
else:
|
|
b.dead = true
|
|
if e.current_shields > 0:
|
|
e.current_shields -= 1
|
|
e.invuln_timer = 60
|
|
_spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0))
|
|
continue
|
|
e.dead = true
|
|
e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX)
|
|
_spawn_explosion_particles(e.x, e.y, 16, Color(1,0.4,0.2))
|
|
SoundManager.play_sfx("enemy_die")
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if b.owner_type == ("p1" if p.player_index == 0 else "p2"):
|
|
p.kills += 1
|
|
# Award kill credits
|
|
if main_node and not menu_mode:
|
|
var bonus: float = p.stats.credit_bonus if p.stats else 1.0
|
|
main_node.add_credits(p.player_index, int(15.0 * bonus))
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
if b.dead: continue
|
|
if b.owner_type == "enemy" or b.owner_type == "boss":
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead or p.is_invulnerable(): continue
|
|
var dx: float = b.x - p.x
|
|
var dy: float = b.y - p.y
|
|
if dx*dx + dy*dy < Bullet.HIT_RADIUS * Bullet.HIT_RADIUS:
|
|
b.dead = true
|
|
_kill_player(p)
|
|
SoundManager.play_sfx("player_die")
|
|
for am_obj in antimatter:
|
|
var am: CosmicObjects.Antimatter = am_obj
|
|
if am.dead: continue
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead or p.is_invulnerable(): continue
|
|
var dx: float = am.x - p.x
|
|
var dy: float = am.y - p.y
|
|
if dx*dx + dy*dy < 100.0:
|
|
am.dead = true
|
|
_kill_player(p)
|
|
SoundManager.play_sfx("antimatter_hit")
|
|
for am_obj in antimatter:
|
|
var am: CosmicObjects.Antimatter = am_obj
|
|
if am.dead: continue
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if e.dead or e.invuln_timer > 0: continue
|
|
var dx: float = am.x - e.x
|
|
var dy: float = am.y - e.y
|
|
if dx*dx + dy*dy < 100.0:
|
|
am.dead = true
|
|
if e.current_shields > 0:
|
|
e.current_shields -= 1
|
|
e.invuln_timer = 60
|
|
_spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0))
|
|
else:
|
|
e.dead = true
|
|
e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX)
|
|
_spawn_explosion_particles(e.x, e.y, 12, Color(1,0.2,0.8))
|
|
SoundManager.play_sfx("antimatter_hit")
|
|
# ── INFERNO ram damage ──────────────────────────────────────────────────────
|
|
const INFERNO_RAM_SPEED := 4.5
|
|
const INFERNO_RAM_RADIUS := 10.0
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
if p.dead or p.is_invulnerable(): continue
|
|
if p.stats == null or p.stats.ship_id != "inferno": continue
|
|
var spd := sqrt(p.vx*p.vx + p.vy*p.vy)
|
|
if spd < INFERNO_RAM_SPEED: continue
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
if e.dead or e.invuln_timer > 0: continue
|
|
var dx := p.x - e.x; var dy := p.y - e.y
|
|
if dx*dx + dy*dy < INFERNO_RAM_RADIUS * INFERNO_RAM_RADIUS:
|
|
p.invuln_timer = Spaceship.INVULN_FRAMES
|
|
_spawn_explosion_particles(e.x, e.y, 16, Color(1.0, 0.35, 0.1))
|
|
SoundManager.play_sfx("enemy_die")
|
|
if e.current_shields > 0:
|
|
e.current_shields -= 1
|
|
e.invuln_timer = 60
|
|
_spawn_explosion_particles(e.x, e.y, 8, Color(0.3, 0.6, 1.0))
|
|
else:
|
|
e.dead = true
|
|
e.respawn_timer = randf_range(EnemyShip.RESPAWN_MIN, EnemyShip.RESPAWN_MAX)
|
|
p.kills += 1
|
|
if main_node and not menu_mode:
|
|
main_node.add_credits(p.player_index, int(15.0 * (p.stats.credit_bonus if p.stats else 1.0)))
|
|
break
|
|
|
|
# Spieler-Bullets treffen Boss
|
|
if _boss != null and not _boss.dead:
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
if b.dead: continue
|
|
if b.owner_type != "p1" and b.owner_type != "p2": continue
|
|
var dx: float = b.x - _boss.x
|
|
var dy: float = b.y - _boss.y
|
|
var hr: float = b.effective_hit_radius * 2.5 # Boss ist ein großes Ziel
|
|
if dx * dx + dy * dy < hr * hr:
|
|
b.dead = true
|
|
_boss.take_hit()
|
|
_spawn_explosion_particles(_boss.x, _boss.y, 6, Color(1.0, 0.45, 0.1))
|
|
|
|
func _kill_player(p: Spaceship) -> void:
|
|
if p.dead: return
|
|
# Shield absorbs the hit
|
|
if p.current_shields > 0:
|
|
p.current_shields -= 1
|
|
var invuln_frames := int(float(Spaceship.INVULN_FRAMES) * (p.stats.invuln_mult if p.stats else 1.0))
|
|
p.invuln_timer = invuln_frames
|
|
_spawn_explosion_particles(p.x, p.y, 12, Color(0.3, 0.6, 1.0))
|
|
return
|
|
p.dead = true
|
|
_spawn_explosion_particles(p.x, p.y, 30, Color(1, 0.6, 0.2))
|
|
|
|
func _check_big_wipe() -> void:
|
|
if not big_wipe: return # no wipe in menu mode
|
|
if big_wipe.is_active(): return
|
|
if _total_object_count() > OBJECT_WIPE_THRESHOLD:
|
|
big_wipe.start()
|
|
SoundManager.play_sfx("wipe_start")
|
|
|
|
func _on_wipe_complete(p1_ok: bool, p2_ok: bool) -> void:
|
|
if p1_ok:
|
|
wipe_count_p1 += 1
|
|
if players.size() > 0:
|
|
var p: Spaceship = players[0]
|
|
p.invuln_timer = Spaceship.INVULN_FRAMES * 2 # 3s post-wipe grace
|
|
if main_node and not menu_mode:
|
|
var bonus: float = p.stats.credit_bonus if p.stats else 1.0
|
|
main_node.add_credits(0, int(25.0 * bonus))
|
|
else:
|
|
if players.size() > 0:
|
|
_kill_player(players[0])
|
|
if is_multiplayer:
|
|
if p2_ok:
|
|
wipe_count_p2 += 1
|
|
if players.size() > 1:
|
|
var p: Spaceship = players[1]
|
|
p.invuln_timer = Spaceship.INVULN_FRAMES * 2
|
|
else:
|
|
if players.size() > 1:
|
|
_kill_player(players[1])
|
|
SoundManager.play_sfx("wipe_survived" if (p1_ok or p2_ok) else "wipe_flash")
|
|
_clear_universe_for_rebirth()
|
|
|
|
func _clear_universe_for_rebirth() -> void:
|
|
stars.clear(); comets.clear(); antimatter.clear()
|
|
quasars.clear(); white_holes.clear(); neutron_stars.clear()
|
|
antimatter_stars.clear()
|
|
black_holes = black_holes.slice(0, 1)
|
|
_spawn_universe_partial()
|
|
|
|
func _spawn_universe_partial() -> void:
|
|
while stars.size() < STAR_COUNT:
|
|
var s := CosmicObjects.Star.new()
|
|
s.init(randf()*W, randf()*H, W, H)
|
|
stars.append(s)
|
|
while planets.size() < PLANET_COUNT:
|
|
var p := CosmicObjects.Planet.new()
|
|
p.init(randf()*W, randf()*H, W, H)
|
|
planets.append(p)
|
|
|
|
func _spawn_antimatter_swarm(cx: float, cy: float) -> void:
|
|
var count: int = randi_range(6, 12)
|
|
for _i in count:
|
|
var a: float = randf() * TAU
|
|
var spd: float = randf_range(1.0, 3.0)
|
|
var am := CosmicObjects.Antimatter.new()
|
|
am.init(cx + randf_range(-10,10), cy + randf_range(-10,10), a, spd)
|
|
antimatter.append(am)
|
|
|
|
|
|
func _spawn_explosion_particles(cx: float, cy: float, count: int, col: Color) -> void:
|
|
for _i in count:
|
|
var a: float = randf() * TAU
|
|
var spd: float = randf_range(1.0, 6.0)
|
|
particles.append({"x": cx, "y": cy,
|
|
"vx": cos(a)*spd, "vy": sin(a)*spd,
|
|
"life": randf_range(0.3, 0.9), "max_life": 0.9, "color": col})
|
|
|
|
func _spawn_spiral_particles(cx: float, cy: float, bh_x: float, bh_y: float,
|
|
count: int, col: Color, grav: float = 4.0) -> void:
|
|
var dx: float = bh_x - cx; var dy: float = bh_y - cy
|
|
var dist: float = sqrt(dx * dx + dy * dy)
|
|
var nx: float = dx / maxf(dist, 1.0)
|
|
var ny: float = dy / maxf(dist, 1.0)
|
|
for _i in count:
|
|
var tangent_dir: float = 1.0 if randf() > 0.5 else -1.0
|
|
var tx: float = -ny * tangent_dir
|
|
var ty: float = nx * tangent_dir
|
|
var spd: float = randf_range(1.5, 4.0)
|
|
var radial_spd: float = randf_range(0.3, 1.5)
|
|
particles.append({
|
|
"x": cx + randf_range(-3.0, 3.0), "y": cy + randf_range(-3.0, 3.0),
|
|
"vx": tx * spd + nx * radial_spd, "vy": ty * spd + ny * radial_spd,
|
|
"life": randf_range(0.4, 1.2), "max_life": 1.2,
|
|
"color": col, "target_x": bh_x, "target_y": bh_y,
|
|
"gravity": grav, "stretch": true, "size": randf_range(1.0, 3.0)})
|
|
|
|
func _draw() -> void:
|
|
draw_rect(get_viewport_rect(), Color("#0a0a14"))
|
|
if Settings.nebula_enabled:
|
|
for neb in nebulae:
|
|
var n: CosmicObjects.Nebula = neb
|
|
n.draw(self)
|
|
for star in stars:
|
|
var s: CosmicObjects.Star = star
|
|
s.draw(self)
|
|
for gal in galaxies:
|
|
var g: CosmicObjects.Galaxy = gal
|
|
g.draw(self)
|
|
for planet in planets:
|
|
var p: CosmicObjects.Planet = planet
|
|
p.draw(self)
|
|
for comet in comets:
|
|
var c: CosmicObjects.Comet = comet
|
|
c.draw(self)
|
|
for qsr in quasars:
|
|
var q: CosmicObjects.Quasar = qsr
|
|
q.draw(self)
|
|
for whole in white_holes:
|
|
var wh: CosmicObjects.WhiteHole = whole
|
|
wh.draw(self)
|
|
for nstar in neutron_stars:
|
|
var ns: CosmicObjects.NeutronStar = nstar
|
|
ns.draw(self)
|
|
for bhole in black_holes:
|
|
var bh: BlackHole = bhole
|
|
bh.draw(self)
|
|
for am_obj in antimatter:
|
|
var am: CosmicObjects.Antimatter = am_obj
|
|
am.draw(self)
|
|
for as_obj in antimatter_stars:
|
|
var astar: CosmicObjects.AntimatterStar = as_obj
|
|
astar.draw(self)
|
|
for bul in bullets:
|
|
var b: Bullet = bul
|
|
b.draw(self)
|
|
for en in enemies:
|
|
var e: EnemyShip = en
|
|
e.draw(self)
|
|
# Boss nach Enemies, vor Spielern zeichnen (nur im aktiven Spiel)
|
|
if _boss != null and not _boss.dead and is_playing:
|
|
_boss.draw(self)
|
|
_draw_boss_hp_bar()
|
|
for player in players:
|
|
var p: Spaceship = player
|
|
p.draw(self, frame)
|
|
for pt in particles:
|
|
var a: float = float(pt["life"]) / float(pt["max_life"])
|
|
var c: Color = pt["color"]
|
|
var sz: float = float(pt.get("size", 4.0))
|
|
if pt.get("stretch", false) and pt.has("prev_x"):
|
|
var col := Color(c.r, c.g, c.b, a)
|
|
draw_line(Vector2(float(pt["prev_x"]), float(pt["prev_y"])),
|
|
Vector2(float(pt["x"]), float(pt["y"])),
|
|
col, maxf(1.0, sz * 0.5 * a))
|
|
else:
|
|
var hs: float = sz * 0.5
|
|
draw_rect(Rect2(float(pt["x"]) - hs, float(pt["y"]) - hs, sz, sz),
|
|
Color(c.r, c.g, c.b, a))
|
|
if big_wipe and big_wipe.is_active():
|
|
var t: float = float(frame) / 60.0
|
|
big_wipe.draw_overlay(self, W, H, t)
|
|
|
|
func _draw_boss_hp_bar() -> void:
|
|
if _boss == null or _boss.dead:
|
|
return
|
|
var bw := 300.0; var bh := 14.0
|
|
var bx := (W - bw) * 0.5; var by := H - 52.0
|
|
var name_str: String = "▸ WRAITH" if _boss.is_miniboss else "▸ LEVIATHAN"
|
|
var phase_str: String = "" if _boss.is_miniboss else (" [ PHASE %d ]" % _boss.phase)
|
|
|
|
# Schatten-Box
|
|
draw_rect(Rect2(bx - 3.0, by - 14.0, bw + 6.0, bh + 20.0), Color(0.0, 0.0, 0.0, 0.72))
|
|
# Leere Bar
|
|
draw_rect(Rect2(bx, by, bw, bh), Color(0.08, 0.0, 0.04, 0.95))
|
|
# HP-Füllung
|
|
var fill: float = (float(_boss.hp) / float(_boss.max_hp)) * bw
|
|
var hp_col: Color
|
|
if _boss.is_miniboss:
|
|
hp_col = Color(0.92, 0.08, 0.88, 1.0) # Magenta
|
|
elif _boss.phase == 2:
|
|
hp_col = Color(1.0, 0.12, 0.0, 1.0) # Feuerrot (Phase 2)
|
|
else:
|
|
hp_col = Color(1.0, 0.52, 0.0, 1.0) # Orange (Phase 1)
|
|
draw_rect(Rect2(bx, by, fill, bh), hp_col)
|
|
# Rahmen
|
|
draw_rect(Rect2(bx, by, bw, bh), Color(hp_col.r, hp_col.g, hp_col.b, 0.55), false, 1.5)
|
|
# Name + HP-Zahl
|
|
var font := ThemeDB.fallback_font
|
|
var label: String = name_str + phase_str + " %d / %d" % [_boss.hp, _boss.max_hp]
|
|
var tw: float = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
|
|
draw_string(font, Vector2((W - tw) * 0.5, by - 2.0), label,
|
|
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color(1.0, 0.92, 0.92, 0.92))
|