Files
spacel/scripts/enemy_ship.gd
T
alpacaman edc40f9008 Initial commit — Godot space roguelite source
- 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>
2026-04-21 14:38:09 +02:00

249 lines
8.8 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)