class_name LaserController extends Node2D # Same press-drag-release aiming plumbing as GasBombController, adapted for # an instant beam rather than a lobbed, timed projectile: there is no travel # time or explosion delay - the beam strikes the instant the shot fires, and # only a brief flash lingers for feedback. Damage/range/cooldown are read # straight off the firing ship's own VesselLaserComponent (already resolved # from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's # AOE_RADIUS/DAMAGE/COOLDOWN. # # Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative # ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so # it works identically in space and on a landed surface with no branching. const BEAM_COLOR := Color(0.3, 0.9, 1.0) const TARGET_COLOR := Color(0.6, 0.95, 1.0) const BEAM_HALF_WIDTH := 22.0 const DRAG_DEADZONE := 22.0 const FLASH_DURATION := 0.22 # #61: ammunition from the fleet hold, mirroring #60's gas bomber exactly. const ENERGY_PER_SHOT: int = 8 @export var minimum_zoom: float = 0.4 @onready var fleet: Node2D = get_node("../Fleet") @onready var camera: Camera2D = get_node("../Camera2D") @onready var button: IconButton = get_node("../UI/LaserButton") @onready var status: Label = get_node("../UI/LaserStatus") var status_message_until: int = 0 var aiming := false var aim_touch := -1 var aim_start_screen := Vector2.ZERO var aim_screen := Vector2.ZERO var aim_target := Vector2.ZERO var saved_zoom := 1.0 var restoring_zoom := false var beams: Array = [] var last_hit_count := 0 func _ready() -> void: button.visible = false status.text = "" queue_redraw() func _can_afford_shot() -> bool: return GameState.fleet_resource(GameState.RESOURCE_ENERGY) >= ENERGY_PER_SHOT func _say(message: String) -> void: status.text = message status_message_until = Time.get_ticks_msec() + 3000 func _process(delta: float) -> void: var had_visuals := aiming or not beams.is_empty() button.visible = fleet.has_capability("laser") button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() if not button.visible and aiming: _cancel_aim() if status.text != "" and Time.get_ticks_msec() >= status_message_until: status.text = "" if restoring_zoom and not aiming: var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0) camera.zoom = Vector2(z, z) if is_equal_approx(z, saved_zoom): restoring_zoom = false 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) if aiming or not beams.is_empty() or had_visuals: queue_redraw() func can_begin_aim() -> bool: # Deliberately NOT button.enabled: an unaffordable shot still needs the # press to reach here so it can explain itself (#60's identical # reasoning on the gas bomber). A wholly unready fleet (no laser ship at # all) has nothing more useful to say than "greyed out". return button.visible and not button._blocked_by_modal() \ and not aiming and _next_ready_laser_ship() != null func handle_touch(event: InputEventScreenTouch) -> bool: if event.pressed: if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position): return false if not _can_afford_shot(): _say("Need %d energy in the fleet hold to fire (have %d)" \ % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) return true aiming = true aim_touch = event.index aim_start_screen = event.position aim_screen = event.position saved_zoom = camera.zoom.x restoring_zoom = false button.pressed_down = true button.queue_redraw() _update_aim() return true if event.index != aim_touch: return false var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE if should_fire: _fire() else: _cancel_aim() return true func handle_drag(event: InputEventScreenDrag) -> bool: if not aiming or event.index != aim_touch: return false aim_screen = event.position _update_aim() return true func _aim_origin() -> Vector2: return fleet.get_centroid() func _aim_max_range() -> float: var ship: Node2D = _next_ready_laser_ship() if ship == null: return 950.0 var component: Node = ship.get_component("laser") return component.beam_range if component != null else 950.0 func _update_aim() -> void: # A beam always reaches its full range - only the DIRECTION is aimed, # unlike the bomb's drag-controlled distance. var drag := aim_screen - aim_start_screen var origin: Vector2 = _aim_origin() var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP aim_target = origin + direction * _aim_max_range() _update_aim_zoom(origin, aim_target) queue_redraw() func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: var displacement := target - origin var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0) var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH) var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH) var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom) camera.zoom = Vector2(desired, desired) func _finish_aim() -> void: aiming = false aim_touch = -1 button.pressed_down = false button.queue_redraw() restoring_zoom = true func _cancel_aim() -> void: _finish_aim() queue_redraw() func _fire() -> void: var shooter: Node2D = _next_ready_laser_ship() if shooter == null: _cancel_aim() return if not _can_afford_shot(): _say("Need %d energy in the fleet hold to fire (have %d)" \ % [ENERGY_PER_SHOT, GameState.fleet_resource(GameState.RESOURCE_ENERGY)]) _cancel_aim() return var component: Node = shooter.get_component("laser") if not component.consume_shot(): _cancel_aim() return GameState.withdraw_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT) var direction: Vector2 = (aim_target - _aim_origin()) direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var beam_to: Vector2 = shooter.global_position + direction * component.beam_range _strike(shooter.global_position, beam_to, direction, int(component.damage)) _finish_aim() button.enabled = _next_ready_laser_ship() != null and _can_afford_shot() 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 _next_ready_laser_ship() -> Node2D: for ship in fleet.ships: if not is_instance_valid(ship): continue var component: Node = ship.get_component("laser") if component != null and component.is_ready(): return ship return null 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, 1.2 - progress * 0.6)