spaceship/vessels/VesselWeaponComponent.gd
2026-09-02 17:15:12 +02:00

196 lines
8.2 KiB
GDScript

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