class_name LaserController extends "res://controllers/AimedFleetAbilityController.gd" # The player's end of the laser: the button, the aim, the fleet's magazine. # The SHOT itself is a LaserBeamEffect in the world (see that file) - it was # lifted out of here so a garrisoned laser vessel could fire the same beam # from a different trigger without this controller being involved at all. # # What is left here is everything that is genuinely about the player firing: # where they aimed, what it costs the fleet hold, and the guide line drawn # while they aim it. const BEAM_EFFECT = preload("res://vfx/LaserBeamEffect.gd") # #201: the beam borrows the fire stick's aim helper, so it is loaded here # for its static assist rather than for anything a laser hull carries. const WEAPON_COMPONENT = preload("res://vessels/VesselWeaponComponent.gd") const BEAM_COLOR := BEAM_EFFECT.BEAM_COLOR const TARGET_COLOR := Color(0.6, 0.95, 1.0) # #203's range mark and the assist's target ring, in screen pixels. const END_TICK_PIXELS: float = 17.0 const TARGET_RING_PIXELS: float = 15.0 # How far the thumb travels to walk the aim point out to the beam's full # reach. The same idiom the gas bomb uses for its throw distance. const DRAG_RADIUS: float = 260.0 # The aim view frames twice the distance to the chosen target, so there is as # much space beyond it as in front of it. Framing the target itself would put # it on the edge of the screen, which is the half that matters when the point # of dragging further is to reach past what is already close. const FRAMING_FACTOR: float = 2.0 const BEAM_HALF_WIDTH := BEAM_EFFECT.BEAM_HALF_WIDTH const FLASH_DURATION := BEAM_EFFECT.FLASH_DURATION const ENERGY_PER_SHOT: int = VesselLaserComponent.ENERGY_PER_SHOT # The beams this controller has fired and that are still alive. Kept so the # fleet's own shots can be reasoned about - "is anything still on screen" for # the redraw, and the tests' view of what a shot did - without this becoming # the owner of them again. 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: # The shot always reaches full range; the drag chooses a direction AND a # depth along it. Depth is what lets the helper reach a target standing # behind a nearer one: the assist looks for something near the point the # thumb actually named, and only falls back to the nearest thing in the # cone when the player has named no place in particular. var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP var reach: float = _aim_max_range() var depth: float = minf(drag.length() / DRAG_RADIUS, 1.0) * reach return _assisted_target(origin, origin + direction * maxf(depth, 1.0)) # The mouse path (#80) names a world point directly instead of dragging one # into existence. It has a real cursor, so it is assisted from that rather # than from a virtual one at the end of the beam - otherwise the crosshair # would be the only aim in the game the helper does not touch. func fire_at(world_target: Vector2) -> bool: return super(_assisted_target(_aim_origin(), world_target)) # #201: ordinary fire's aim helper, applied to the beam. It answers with a # PLACE, not a heading - the same thing the fire stick resolves and every # hull then converges on - so a drag that ends near an enemy, or merely # inside the assist cone, passes through that enemy instead of a few degrees # past it. The beam still runs on to its full range from there. func _assisted_target(origin: Vector2, point: Vector2) -> Vector2: return WEAPON_COMPONENT.assisted_point(get_tree(), origin, point, _aim_max_range()) func _consume_shot(component: Node) -> bool: return component.consume_shot() func _perform_shot(ship: Node2D, component: Node) -> void: # The cooldown was already spent by _consume_shot() above, so this fires # the effect directly rather than going through fire_ability() and # spending it twice. # Aimed from the HULL, not from the fleet centre. aim_target is a place # now that the assist can move it onto an enemy (#201), and ordinary fire # converges on such a place by letting each hull find its own heading to # it - a shared heading measured at the centroid would walk this beam off # the very target the assist just picked, by the ship's formation offset. var direction: Vector2 = aim_target - ship.global_position direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var effect: Node2D = BEAM_EFFECT.fire(get_tree().root, ship.global_position, direction, component.beam_range, int(component.damage)) beams.append(effect) last_hit_count = int(effect.hit_count) func _next_ready_laser_ship() -> Node2D: return _next_ready_ship() func _advance_effects(_delta: float) -> void: # The effects animate and free themselves; this only forgets the ones that # have gone. beams = beams.filter(func(beam): return is_instance_valid(beam)) func beam_extension(beam: Node2D) -> float: return beam.extension() func beam_tip(beam: Node2D) -> Vector2: return beam.tip() # #203: framed around the chosen target rather than around the fleet. The # inherited floor on the zoom was sized for the gas bomb's 950 units and left # most of a 2900-unit aim off screen; the laser's own floor lets this fit. # Twice the target's distance, so what lies beyond it is on screen too - that # is the half the player is reaching into when they drag further out. func _update_aim_zoom(origin: Vector2, target: Vector2) -> void: super(origin, origin + (target - origin) * FRAMING_FACTOR) func _aim_heading(origin: Vector2, target: Vector2) -> Vector2: var direction: Vector2 = target - origin return direction.normalized() if direction.length() > 0.001 else Vector2.UP func _draw() -> void: # Only the aim guide. Each beam draws itself, in its own node. if not aiming: return var origin: Vector2 = _aim_origin() var reach: float = _aim_max_range() var far_end: Vector2 = origin + _aim_heading(origin, aim_target) * reach # Drawn to the far end rather than to aim_target, for the same reason the # camera frames it: this is the line the beam will occupy (#203). NeonDraw.draw_glowing_line(self, origin, far_end, TARGET_COLOR, 0.7, 0.6) # A cross-tick where it stops, so the reach is a mark on the screen rather # than a line that merely runs out. Sized in SCREEN pixels: framing 2900 # units puts the camera far enough out that a mark measured in world units # would be a few pixels of nothing at the moment it matters most. var across: Vector2 = far_end.direction_to(origin).orthogonal() \ * _screen_units(END_TICK_PIXELS) NeonDraw.draw_glowing_line(self, far_end - across, far_end + across, TARGET_COLOR, 0.7, 0.5) # What the assist picked, when that is nearer than the far end. The line # still runs past it - everything behind it on the same line is hit too. if origin.distance_to(aim_target) < reach - 1.0: NeonDraw.draw_glowing_circle(self, aim_target, maxf(BEAM_HALF_WIDTH, _screen_units(TARGET_RING_PIXELS)), TARGET_COLOR, 0.7) # World units that cover a given number of screen pixels at the camera's # current zoom. func _screen_units(pixels: float) -> float: return pixels / maxf(camera.zoom.x, 0.001)