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>
249 lines
8.8 KiB
GDScript
249 lines
8.8 KiB
GDScript
extends RefCounted
|
||
class_name EnemyShip
|
||
|
||
const THRUST := 0.19
|
||
const DRAG := 0.988
|
||
const MAX_SPEED := 4.8
|
||
const TRAIL_LEN := 16
|
||
const ATTACK_RANGE := 600.0
|
||
const FIRE_INTERVAL := 90
|
||
const RESPAWN_MIN := 4.0
|
||
const RESPAWN_MAX := 8.0
|
||
const SEP_RADIUS := 110.0 # anti-clump separation distance
|
||
const ORBIT_RADIUS := 180.0 # circle-strafe orbit distance
|
||
|
||
# Three attack roles that prevent enemies from all rushing from the same angle.
|
||
enum Role { AGGRO, CIRCLE, FLANK }
|
||
|
||
var x: float; var y: float
|
||
var vx: float = 0.0; var vy: float = 0.0
|
||
var heading: float = 0.0
|
||
var trail: Array = []
|
||
var dead: bool = false
|
||
var respawn_timer: float = 0.0
|
||
var fire_timer: int = 0
|
||
var enemy_index: int = 0 # 0=red, 1=cyan (colour variant)
|
||
var role_id: int = 0 # sequential id, persists across respawns
|
||
var role: int = Role.AGGRO
|
||
var orbit_offset: float = 0.0
|
||
|
||
var patrol_timer: int = 0
|
||
var patrol_target: Vector2 = Vector2.ZERO
|
||
|
||
var stats: ShipStats = null
|
||
var current_shields: int = 0
|
||
var invuln_timer: int = 0
|
||
|
||
func apply_stats(s: ShipStats) -> void:
|
||
stats = s
|
||
current_shields = s.shield_charges
|
||
|
||
# Hull offsets ×2, rendered as 3×3 rects
|
||
const HULL_PIXELS := [
|
||
[6, 0, "nose"],
|
||
[4, -2, "mid"], [4, 2, "mid"],
|
||
[2, 0, "dim"],
|
||
[0, -4, "accent"],[0, 4, "accent"],
|
||
[0, 0, "bright"],
|
||
[-2,-2, "dim"], [-2, 2, "dim"],
|
||
[-4,-4, "shadow"],[-4, 4, "shadow"],
|
||
[-4, 0, "edge"],
|
||
]
|
||
|
||
var palette: Dictionary
|
||
|
||
func init(px: float, py: float, idx: int, rid: int = 0) -> void:
|
||
x = px; y = py
|
||
enemy_index = idx
|
||
role_id = rid
|
||
role = rid % 3
|
||
# Each role_id starts at a different point on the orbit so enemies spread out
|
||
orbit_offset = float(rid) * (TAU / 3.0)
|
||
dead = false
|
||
respawn_timer = 0.0
|
||
# Stagger fire timers so enemies don't all shoot at the same frame
|
||
fire_timer = FIRE_INTERVAL + rid * 17
|
||
patrol_target = Vector2(px + randf_range(-250.0, 250.0), py + randf_range(-250.0, 250.0))
|
||
patrol_timer = randi_range(60, 180)
|
||
invuln_timer = 60
|
||
if stats: current_shields = stats.shield_charges
|
||
if idx == 0:
|
||
palette = {
|
||
"nose": Color("#ff8888"), "bright": Color("#ff4444"), "mid": Color("#cc2222"),
|
||
"dim": Color("#882222"), "accent": Color("#ffaaaa"), "edge": Color("#441111"),
|
||
"shadow": Color("#220000"), "trail": Color(1.0, 0.2, 0.2, 0.25),
|
||
"thrustHot": Color("#ffcc44"), "thrustCool": Color(1.0, 0.4, 0.0, 0.5)
|
||
}
|
||
else:
|
||
palette = {
|
||
"nose": Color("#88ffff"), "bright": Color("#44ccff"), "mid": Color("#2288cc"),
|
||
"dim": Color("#224488"), "accent": Color("#aaccff"), "edge": Color("#112244"),
|
||
"shadow": Color("#001122"), "trail": Color(0.2, 0.8, 1.0, 0.25),
|
||
"thrustHot": Color("#aaffcc"), "thrustCool": Color(0.2, 0.8, 1.0, 0.5)
|
||
}
|
||
|
||
# enemies array is passed in so each enemy can compute separation from allies.
|
||
func update(players: Array, black_holes: Array, enemies: Array, world_w: float, world_h: float, delta: float) -> Bullet:
|
||
if dead:
|
||
respawn_timer -= delta
|
||
return null
|
||
if invuln_timer > 0:
|
||
invuln_timer -= 1
|
||
|
||
var eff_thrust: float = THRUST * (stats.speed_mult if stats else 1.0)
|
||
var eff_max: float = MAX_SPEED * (stats.speed_mult if stats else 1.0)
|
||
var eff_turn: float = 0.1 * (stats.turn_mult if stats else 1.0)
|
||
var eff_interval: int = max(10, int(float(FIRE_INTERVAL) / (stats.fire_rate_mult if stats else 1.0)))
|
||
|
||
# Find nearest living player
|
||
var nearest_player = null
|
||
var nearest_dist := INF
|
||
for p in players:
|
||
if p.dead: continue
|
||
var dx: float = float(p.x) - x
|
||
var dy: float = float(p.y) - y
|
||
var d: float = sqrt(dx*dx + dy*dy)
|
||
if d < nearest_dist:
|
||
nearest_dist = d
|
||
nearest_player = p
|
||
|
||
# Black-hole avoidance — steer away when too close to pull radius
|
||
var avoid_vx := 0.0; var avoid_vy := 0.0
|
||
for bh in black_holes:
|
||
var dx: float = x - float(bh.x)
|
||
var dy: float = y - float(bh.y)
|
||
var d: float = sqrt(dx*dx + dy*dy)
|
||
if d < bh.pull_radius * 1.3 and d > 0.01:
|
||
avoid_vx += (dx / d) * 3.0
|
||
avoid_vy += (dy / d) * 3.0
|
||
|
||
# Separation force — push away from nearby allies to prevent clumping
|
||
var sep_vx := 0.0; var sep_vy := 0.0
|
||
for en in enemies:
|
||
var oe: EnemyShip = en
|
||
if oe == self or oe.dead: continue
|
||
var dx: float = x - oe.x
|
||
var dy: float = y - oe.y
|
||
var d: float = sqrt(dx*dx + dy*dy)
|
||
if d < SEP_RADIUS and d > 0.01:
|
||
var strength: float = (SEP_RADIUS - d) / SEP_RADIUS
|
||
sep_vx += (dx / d) * strength * 2.0
|
||
sep_vy += (dy / d) * strength * 2.0
|
||
|
||
# Role-based movement
|
||
if nearest_player != null and nearest_dist < ATTACK_RANGE:
|
||
var pdx: float = float(nearest_player.x) - x
|
||
var pdy: float = float(nearest_player.y) - y
|
||
_move_attack(pdx, pdy, eff_thrust, eff_turn)
|
||
else:
|
||
_move_patrol(eff_thrust, eff_turn, world_w, world_h)
|
||
|
||
vx += (avoid_vx + sep_vx) * 0.05
|
||
vy += (avoid_vy + sep_vy) * 0.05
|
||
|
||
vx *= DRAG; vy *= DRAG
|
||
var spd := sqrt(vx*vx + vy*vy)
|
||
if spd > eff_max:
|
||
vx = vx / spd * eff_max
|
||
vy = vy / spd * eff_max
|
||
|
||
trail.push_front(Vector2(x, y))
|
||
if trail.size() > TRAIL_LEN: trail.pop_back()
|
||
|
||
x += vx; y += vy
|
||
if x < 0: x += world_w
|
||
elif x > world_w: x -= world_w
|
||
if y < 0: y += world_h
|
||
elif y > world_h: y -= world_h
|
||
|
||
fire_timer -= 1
|
||
if fire_timer <= 0 and nearest_player != null and nearest_dist < ATTACK_RANGE:
|
||
fire_timer = eff_interval
|
||
# Aim toward player with distance-based spread:
|
||
# accurate at close range (~0.08 rad), less so at max range (~0.32 rad).
|
||
var fdx: float = float(nearest_player.x) - x
|
||
var fdy: float = float(nearest_player.y) - y
|
||
var spread: float = lerp(0.08, 0.32, clamp(nearest_dist / ATTACK_RANGE, 0.0, 1.0))
|
||
var fire_angle: float = atan2(fdy, fdx) + randf_range(-spread, spread)
|
||
var b := Bullet.new()
|
||
b.init(x + cos(fire_angle)*8.0, y + sin(fire_angle)*8.0, fire_angle, "enemy")
|
||
if stats:
|
||
b.vx *= stats.bullet_speed_mult
|
||
b.vy *= stats.bullet_speed_mult
|
||
b.effective_hit_radius = Bullet.HIT_RADIUS * stats.damage_mult
|
||
return b
|
||
return null
|
||
|
||
# Role.AGGRO: rush straight at player.
|
||
# Role.CIRCLE: maintain orbit around player at ORBIT_RADIUS.
|
||
# Role.FLANK: approach from a perpendicular angle left or right.
|
||
func _move_attack(pdx: float, pdy: float, eff_thrust: float, eff_turn: float) -> void:
|
||
match role:
|
||
Role.AGGRO:
|
||
var ta := atan2(pdy, pdx)
|
||
var ad := fmod(ta - heading + PI * 3.0, TAU) - PI
|
||
heading += clamp(ad, -eff_turn, eff_turn)
|
||
vx += cos(heading) * eff_thrust
|
||
vy += sin(heading) * eff_thrust
|
||
|
||
Role.CIRCLE:
|
||
orbit_offset += 0.012
|
||
# Target = player position + point on orbit circle
|
||
var player_x: float = x + pdx
|
||
var player_y: float = y + pdy
|
||
var orbit_x: float = player_x + cos(orbit_offset) * ORBIT_RADIUS
|
||
var orbit_y: float = player_y + sin(orbit_offset) * ORBIT_RADIUS
|
||
var tdx: float = orbit_x - x
|
||
var tdy: float = orbit_y - y
|
||
var ta: float = atan2(tdy, tdx)
|
||
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
|
||
heading += clamp(ad, -eff_turn * 1.2, eff_turn * 1.2)
|
||
vx += cos(heading) * eff_thrust
|
||
vy += sin(heading) * eff_thrust
|
||
|
||
Role.FLANK:
|
||
# Approach from +90° or −90° relative to the direct line to player
|
||
var direct: float = atan2(pdy, pdx)
|
||
var flank_side: float = PI * 0.5 if (role_id % 2 == 0) else -PI * 0.5
|
||
var player_x: float = x + pdx
|
||
var player_y: float = y + pdy
|
||
var flank_x: float = player_x + cos(direct + flank_side) * 80.0
|
||
var flank_y: float = player_y + sin(direct + flank_side) * 80.0
|
||
var tdx: float = flank_x - x
|
||
var tdy: float = flank_y - y
|
||
var ta: float = atan2(tdy, tdx)
|
||
var ad: float = fmod(ta - heading + PI * 3.0, TAU) - PI
|
||
heading += clamp(ad, -eff_turn, eff_turn)
|
||
vx += cos(heading) * eff_thrust
|
||
vy += sin(heading) * eff_thrust
|
||
|
||
func _move_patrol(eff_thrust: float, eff_turn: float, world_w: float, world_h: float) -> void:
|
||
patrol_timer -= 1
|
||
var to_target := Vector2(patrol_target.x - x, patrol_target.y - y)
|
||
# Pick a new target when close enough or timer expires → spreads enemies across map
|
||
if patrol_timer <= 0 or to_target.length() < 40.0:
|
||
patrol_target = Vector2(
|
||
randf_range(world_w * 0.1, world_w * 0.9),
|
||
randf_range(world_h * 0.1, world_h * 0.9))
|
||
patrol_timer = randi_range(120, 300)
|
||
var angle: float = atan2(to_target.y, to_target.x)
|
||
var ad: float = fmod(angle - heading + PI * 3.0, TAU) - PI
|
||
heading += clamp(ad, -eff_turn * 0.8, eff_turn * 0.8)
|
||
vx += cos(heading) * eff_thrust * 0.6
|
||
vy += sin(heading) * eff_thrust * 0.6
|
||
|
||
func draw(canvas: CanvasItem) -> void:
|
||
if dead: return
|
||
var cos_h := cos(heading); var sin_h := sin(heading)
|
||
for i in trail.size():
|
||
var t_alpha := (1.0 - float(i) / float(TRAIL_LEN)) * 0.5
|
||
var tc := Color(palette["trail"].r, palette["trail"].g, palette["trail"].b, t_alpha)
|
||
canvas.draw_rect(Rect2(trail[i].x - 1, trail[i].y - 1, 2, 2), tc)
|
||
for px_data in HULL_PIXELS:
|
||
var lx: float = px_data[0]
|
||
var ly: float = px_data[1]
|
||
var col: Color = palette.get(px_data[2], Color.WHITE)
|
||
var rx := x + lx * cos_h - ly * sin_h
|
||
var ry := y + lx * sin_h + ly * cos_h
|
||
canvas.draw_rect(Rect2(rx - 1, ry - 1, 3, 3), col)
|