class_name VesselWeaponComponent extends Node # The fleet's gun. It does NOT pick its own targets: the player aims it with # the fire stick, and it shoots along whatever heading that stick reports for # as long as it is held. # # It used to seek the nearest enemy in range and fire on its own, which meant # combat happened whether or not the player did anything - there was nothing # to aim, and nothing to decide. Firing is now an action like any other, and # the right-hand stick is what performs it. # # #172: it is aimed at a POINT, not along a heading. # # Every gun in the fleet used to be handed the SAME direction, so a fleet # firing at something threw a sheet of parallel bolts past either side of it # and only the middle hull could ever hit. Handed a point instead, each hull # works out its own heading to that point, and the fleet's fire converges # where the player is actually pointing - which is what having several ships # should be worth. # # The point is set every frame by FireStickController, from the mouse's own # world position or from where the fire stick is pushed, and cleared the # moment the trigger is let go. No point means not firing, which is the # resting state. var capability_id: String = "weapon" var fire_range: float var fire_cooldown: float var projectile_damage: int var fire_timer: float = 0.0 # Where this gun is pointed, in WORLD space, and whether it is pointed at all. # A point rather than a Vector2.ZERO sentinel: the origin is a perfectly # ordinary place to aim at, and a fleet that happened to be near it would # stop firing. var fire_target: Vector2 = Vector2.ZERO var has_fire_target: bool = false var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn") # By path, not by class name - see the note at the top of MuzzleSparkVFX. const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") func apply_stats() -> void: var ship: Node = get_parent() var state: Node = ship.get_tree().root.get_node("GameState") fire_range = state.vessel_stat(ship.vessel_type, "fire_range") fire_cooldown = state.vessel_stat(ship.vessel_type, "fire_cooldown") projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage")) # Forgejo #89: how far off the aimed heading a target may sit and still be # picked up. 28 degrees is wide enough that pointing roughly at something hits # it, and narrow enough that the shot still goes where the player pointed - # past about 45 the gun starts choosing targets on its own, which is the # behaviour #80 deliberately took away. const ASSIST_CONE_DEGREES: float = 28.0 # #181: a nearby cursor is a second way to express intent. This is deliberately # smaller than the directional cone at ordinary weapon range: it catches a # pointer placed beside something without making the whole screen magnetic. const ASSIST_CURSOR_RADIUS: float = 180.0 const ASSIST_TARGET_GROUP: StringName = &"player_aim_targets" # The concrete point an aim should stick to. A target can qualify in two ways: # it is close to the cursor, or it lies inside the established directional # cone. Cursor proximity wins, then distance from the shooter breaks the old # directional case. This preserves approximate stick aiming while letting a # deliberately placed cursor choose a farther target in the same direction. # # Targets opt in through one group rather than this utility branching on # enemies versus asteroids. That keeps future aimable compositions out of the # weapon code: joining the contract is all they need to do. static func assisted_point(tree: SceneTree, origin: Vector2, point: Vector2, reach: float) -> Vector2: var to_point: Vector2 = point - origin var heading: Vector2 = to_point.normalized() if heading == Vector2.ZERO or tree == null or reach <= 0.0: return point var cone: float = cos(deg_to_rad(ASSIST_CONE_DEGREES)) var cursor_pick: Node2D = null var cursor_distance: float = INF var direction_pick: Node2D = null var direction_distance: float = INF for target_node in tree.get_nodes_in_group(ASSIST_TARGET_GROUP): if not is_instance_valid(target_node) or not (target_node is Node2D) \ or target_node.is_queued_for_deletion(): continue var target: Node2D = target_node as Node2D var to_target: Vector2 = target.global_position - origin var distance: float = to_target.length() if distance > reach or distance < 0.001: continue var from_cursor: float = target.global_position.distance_to(point) if from_cursor <= ASSIST_CURSOR_RADIUS and from_cursor < cursor_distance: cursor_distance = from_cursor cursor_pick = target if to_target.normalized().dot(heading) >= cone and distance < direction_distance: direction_distance = distance direction_pick = target if cursor_pick != null: return cursor_pick.global_position if direction_pick != null: return direction_pick.global_position return point # Compatibility entry point for systems that genuinely have only a heading. # Their virtual cursor sits at maximum range; the returned vector still points # at the same target selected by assisted_point(). static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector2, reach: float) -> Vector2: var heading: Vector2 = direction.normalized() if heading == Vector2.ZERO: return heading var point: Vector2 = assisted_point(tree, origin, origin + heading * reach, reach) return (point - origin).normalized() # Where this gun is firing FROM. The hull it is bolted to. func muzzle() -> Vector2: var ship: Node2D = get_parent() as Node2D return ship.global_position if ship != null else Vector2.ZERO # Called by FireStickController every frame the trigger is held. func aim_at(point: Vector2) -> void: fire_target = point has_fire_target = true func hold_fire() -> void: has_fire_target = false # For anything that knows a DISPLACEMENT rather than a place: an outpost # garrison has already measured the vector to the enemy it picked (see # VesselGarrisonComponent._shoot), and a lone stationed vessel has no fleet # to converge with anyway. Zero still means stop. func set_fire_direction(direction: Vector2) -> void: if direction.length() <= 0.001: hold_fire() return aim_at(muzzle() + direction) func is_firing() -> bool: return has_fire_target # The heading a shot from THIS hull will actually take: from the muzzle to the # aimed point, bent onto an enemy sitting inside the assist cone. Zero when the # gun is not firing, or when the point is on top of the ship - there is no # heading to a place you are already at. func firing_heading() -> Vector2: if not has_fire_target: return Vector2.ZERO var origin: Vector2 = muzzle() var to_point: Vector2 = fire_target - origin if to_point.length() <= 0.001: return Vector2.ZERO var point: Vector2 = assisted_point(get_tree(), origin, fire_target, fire_range) return (point - origin).normalized() func tick(delta: float) -> void: # The cooldown runs down whether or not the trigger is held, so tapping # the stick in a new direction is not punished by a timer that only # started when the stick moved. fire_timer -= delta if not has_fire_target or fire_timer > 0.0: return # #172: worked out per HULL, from this ship to the aimed point, which is # what makes the fleet's fire converge rather than run parallel. var heading: Vector2 = firing_heading() if heading == Vector2.ZERO: return var ship: Node2D = get_parent() var projectile: Node2D = projectile_scene.instantiate() projectile.position = ship.position projectile.damage = projectile_damage # The bolt stops where the gun's range ends, and the aim guide draws that # same range - one number, one meaning. projectile.set_range(fire_range) ship.get_tree().root.add_child(projectile) # The bolt is sent the gun's own range along that heading, whether the # aimed point is nearer or further: range is a property of the gun, not of # where the player pointed. A point beyond reach is fired toward and falls # short; a point inside it is flown through. projectile.launch(heading * fire_range) # #87: sparks off the hull that fired, so a shot is visibly attached to # the vessel it came from. MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading) # #146: and heard. Beside the spark rather than inside it, because the # spark is about this hull and the sound is about the shot. Sfx.play(ship.get_tree(), Sfx.SHOT) fire_timer = fire_cooldown