Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
7.8 KiB
GDScript
197 lines
7.8 KiB
GDScript
extends Area2D
|
|
|
|
signal destroyed
|
|
|
|
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
|
|
|
|
@export var max_speed: float = 460.0
|
|
@export var acceleration: float = 450.0
|
|
@export var rotation_speed: float = 8.0
|
|
@export var arrive_radius: float = 6.0
|
|
@export var vessel_type: String = "interceptor"
|
|
|
|
var definition: Resource
|
|
var glow_color: Color = Color(0.25, 0.85, 1.0)
|
|
var core_color: Color = Color.WHITE
|
|
# The ship's TRUE world velocity - what `position` is actually integrated
|
|
# from, and the single source of truth for how fast this vessel is moving.
|
|
# Nothing else may contribute motion; see the note in _physics_process.
|
|
var velocity: Vector2 = Vector2.ZERO
|
|
# The velocity of this ship's formation SLOT (the fleet anchor's own travel).
|
|
# Fed forward into the steering below rather than added on top of it: matching
|
|
# the slot's speed is most of the job of holding formation, so supplying it
|
|
# directly leaves `velocity` free to be a small correction and lets the ship
|
|
# sit in its slot with no steady-state lag and no teleporting.
|
|
var fleet_velocity: Vector2 = Vector2.ZERO
|
|
var move_target: Vector2
|
|
|
|
# Compatibility accessors keep old saves/tests readable while shield state
|
|
# itself belongs exclusively to the optional ShieldComponent.
|
|
var shield_charges: int:
|
|
get:
|
|
var component: Node = get_component("shield")
|
|
return component.charges if component != null else 0
|
|
set(value):
|
|
var component: Node = get_component("shield")
|
|
if component != null:
|
|
component.set_charges(value)
|
|
|
|
var shield_recharge_left: float:
|
|
get:
|
|
var component: Node = get_component("shield")
|
|
return component.recharge_left if component != null else 0.0
|
|
set(value):
|
|
var component: Node = get_component("shield")
|
|
if component != null:
|
|
component.recharge_left = value
|
|
|
|
var projectile_damage: int:
|
|
get:
|
|
var component: Node = get_component("weapon")
|
|
return component.projectile_damage if component != null else 0
|
|
|
|
|
|
func _ready() -> void:
|
|
move_target = position
|
|
add_to_group("player_ships")
|
|
area_entered.connect(_on_area_entered)
|
|
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
|
|
if definition == null:
|
|
return
|
|
glow_color = definition.glow_color
|
|
for component_scene in definition.component_scenes:
|
|
var component: Node = component_scene.instantiate()
|
|
add_child(component)
|
|
if component.has_method("setup"):
|
|
component.setup()
|
|
apply_vessel_stats()
|
|
queue_redraw()
|
|
|
|
|
|
func get_component(capability: String) -> Node:
|
|
for child in get_children():
|
|
if child.get("capability_id") == capability:
|
|
return child
|
|
return null
|
|
|
|
|
|
func has_capability(capability: String) -> bool:
|
|
return get_component(capability) != null
|
|
|
|
|
|
func set_move_target(world_pos: Vector2) -> void:
|
|
move_target = world_pos
|
|
|
|
|
|
func apply_vessel_stats() -> void:
|
|
if definition == null:
|
|
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
|
|
if definition == null:
|
|
return
|
|
max_speed = GameState.vessel_stat(vessel_type, "max_speed")
|
|
acceleration = GameState.vessel_stat(vessel_type, "acceleration")
|
|
for child in get_children():
|
|
if child.has_method("apply_stats"):
|
|
child.apply_stats()
|
|
|
|
|
|
func has_shield() -> bool:
|
|
var component: Node = get_component("shield")
|
|
return component != null and component.is_active()
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
for child in get_children():
|
|
if child.has_method("tick"):
|
|
child.tick(delta)
|
|
|
|
# #59: multiplied here, at the point of use, rather than baked into the
|
|
# cached max_speed/acceleration fields apply_vessel_stats() sets - those
|
|
# stay the true base values, so a stat upgrade researched mid-boost can't
|
|
# corrupt them, and the fader takes effect the instant it moves with no
|
|
# re-apply needed. arrive_radius is scaled too: at N physics ticks/sec a
|
|
# ship's per-tick step is `speed * delta`, and once that step outgrows
|
|
# the (otherwise fixed) arrive_radius it can overshoot the snap-to-target
|
|
# zone every tick while chasing a target that is ALSO now receding N
|
|
# times faster - a small, boost-proportional radius keeps the same
|
|
# "snaps onto its slot" formation tightness at any boost level that
|
|
# 1.0x already has.
|
|
var boost: float = GameState.fleet_speed_multiplier
|
|
var effective_acceleration: float = acceleration * boost
|
|
var to_target: Vector2 = move_target - position
|
|
var distance: float = to_target.length()
|
|
|
|
# Snap only when the slot is STANDING STILL. A stationary target is the
|
|
# one case where converging asymptotically would leave a permanent
|
|
# sub-pixel residue, and settling exactly is what "the fleet has arrived"
|
|
# means everywhere else in the game. While the slot is moving the ship
|
|
# steers continuously instead - a snap there would be a teleport, which
|
|
# is what used to make `velocity` read zero for a ship that was in fact
|
|
# travelling at cruise speed, and that lie is what the whole
|
|
# stop()/fleet_velocity bookkeeping tangle existed to paper over.
|
|
if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost:
|
|
position = move_target
|
|
velocity = Vector2.ZERO
|
|
else:
|
|
# Feed-forward plus correction. The ship aims to travel at its slot's
|
|
# velocity, plus whatever extra closes the gap to the slot - so
|
|
# holding formation costs no error, and `velocity` stays the ship's
|
|
# real motion rather than a residual that has to be added to
|
|
# something else to mean anything.
|
|
var correction: Vector2 = Vector2.ZERO
|
|
if distance > 0.001:
|
|
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
|
|
correction = to_target.normalized() * min(max_speed * boost, braking_speed)
|
|
var desired_velocity: Vector2 = fleet_velocity + correction
|
|
# HARD BOUND on how fast any single vessel may travel, applied to the
|
|
# total and re-applied every tick. Feed-forward plus correction can
|
|
# ask for more than a vessel can actually fly, and without this the
|
|
# excess was free speed: a ship that had been handed a large velocity
|
|
# kept it, decaying only at `acceleration`. Clamping the REQUEST is
|
|
# not enough - the clamp has to sit on the integrated result, so no
|
|
# sequence of commands can leave a vessel travelling faster than its
|
|
# own top speed.
|
|
desired_velocity = desired_velocity.limit_length(max_speed * boost)
|
|
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
|
|
velocity = velocity.limit_length(max_speed * boost)
|
|
position += velocity * delta
|
|
|
|
if velocity.length() > 8.0:
|
|
var target_angle: float = velocity.angle() + PI / 2.0
|
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
|
|
|
|
|
func take_damage(_amount: int) -> bool:
|
|
# Ships have no hit points - any unabsorbed hit is lethal. A shield
|
|
# charge absorbs a hit entirely regardless of its amount, matching
|
|
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
|
|
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
|
|
# have a normal damage parameter to pass even though it isn't graded here.
|
|
for child in get_children():
|
|
if child.has_method("absorb_hit") and child.absorb_hit():
|
|
return true
|
|
ShipExplosionVFX.spawn(get_tree().root, position)
|
|
destroyed.emit()
|
|
queue_free()
|
|
return false
|
|
|
|
|
|
func _on_area_entered(area: Area2D) -> void:
|
|
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
|
return
|
|
# The enemy is told about the impact BEFORE this ship resolves its own
|
|
# damage, because resolving it can free this node - and a ship that dies
|
|
# to a ramming chaser must still take the chaser with it. Whether the
|
|
# impact actually destroys the enemy is the enemy's decision (#82).
|
|
if area.is_in_group("enemies") and area.has_method("collide_with_player_vessel"):
|
|
area.collide_with_player_vessel()
|
|
var absorbed: bool = take_damage(1)
|
|
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
|
|
area.queue_free()
|
|
|
|
|
|
func _draw() -> void:
|
|
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
|
|
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
|
|
])
|
|
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)
|