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)