class_name LaserController extends "res://AimedFleetAbilityController.gd" const BEAM_COLOR := Color(0.3, 0.9, 1.0) const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 38.0 # The DRAWN beam is scaled from the hit width rather than tuned separately. # The two were independent numbers and had drifted apart: the beam struck # anything within 22 units of the line while its outer glow spanned about 11, # so it killed things it visibly missed. NeonDraw's widest layer is 18px at # scale 1, so this makes the glow span exactly what the beam hits. const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0 const FLASH_DURATION := 0.22 const ENERGY_PER_SHOT: int = 8 var beams: Array = [] var last_hit_count: int = 0 func _button_path() -> NodePath: return "../UI/LaserButton" func _status_path() -> NodePath: return "../UI/LaserStatus" func _component_id() -> String: return "laser" func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY func _ammo_cost() -> int: return ENERGY_PER_SHOT func _aim_padding() -> float: return BEAM_HALF_WIDTH func _has_effect_visuals() -> bool: return not beams.is_empty() func _aim_max_range() -> float: # Falls back to the VESSEL DEFINITION's own beam_range, not to a copy of # the number. This used to hardcode 950.0 in both branches, which silently # became a lie the moment the definition's range changed - the aim line # would have been drawn to a different distance than the beam actually # reaches, and only in the cases where the fallback fires. var ship: Node2D = _next_ready_ship() if ship != null: var component: Node = ship.get_component(_component_id()) if component != null: return component.beam_range return GameState.vessel_stat(GameState.VESSEL_LASER, "beam_range") func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2: # A beam always reaches full range; dragging chooses its direction only. var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP return origin + direction * _aim_max_range() func _consume_shot(component: Node) -> bool: return component.consume_shot() func _perform_shot(ship: Node2D, component: Node) -> void: var direction: Vector2 = aim_target - _aim_origin() direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var beam_to: Vector2 = ship.global_position + direction * component.beam_range _strike(ship.global_position, beam_to, direction, int(component.damage)) func _next_ready_laser_ship() -> Node2D: return _next_ready_ship() func _advance_effects(delta: float) -> void: for i in range(beams.size() - 1, -1, -1): var beam: Dictionary = beams[i] beam.time = maxf(float(beam.time) - delta, 0.0) if float(beam.time) <= 0.0: beams.remove_at(i) func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: beams.append({"from": from, "to": to, "time": FLASH_DURATION}) last_hit_count = 0 var range_along: float = from.distance_to(to) for enemy in get_tree().get_nodes_in_group("enemies"): if not is_instance_valid(enemy) or not (enemy is Node2D): continue var to_enemy: Vector2 = enemy.global_position - from var along: float = to_enemy.dot(direction) if along < 0.0 or along > range_along: continue var perpendicular: float = (to_enemy - direction * along).length() if perpendicular <= BEAM_HALF_WIDTH: enemy.take_damage(damage) last_hit_count += 1 func _draw() -> void: if aiming: NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) for beam in beams: var progress: float = 1.0 - float(beam.time) / FLASH_DURATION var alpha: float = 1.0 - progress NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45))