spaceship/controllers/LaserController.gd
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
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>
2026-08-21 19:44:14 +02:00

101 lines
4 KiB
GDScript

class_name LaserController
extends "res://controllers/AimedFleetAbilityController.gd"
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
const BEAM_HALF_WIDTH := 38.0
# The DRAWN beam is scaled from the hit width rather than tuned separately.
# The two were independent numbers and had drifted apart: the beam struck
# anything within 22 units of the line while its outer glow spanned about 11,
# so it killed things it visibly missed. NeonDraw's widest layer is 18px at
# scale 1, so this makes the glow span exactly what the beam hits.
const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0
const FLASH_DURATION := 0.22
const ENERGY_PER_SHOT: int = 8
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:
# A beam always reaches full range; dragging chooses its direction only.
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
return origin + direction * _aim_max_range()
func _consume_shot(component: Node) -> bool:
return component.consume_shot()
func _perform_shot(ship: Node2D, component: Node) -> void:
var direction: Vector2 = aim_target - _aim_origin()
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = ship.global_position + direction * component.beam_range
_strike(ship.global_position, beam_to, direction, int(component.damage))
func _next_ready_laser_ship() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
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)
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)
# Asteroids share the enemies' take_damage() contract, so the beam sweeps
# both groups in one pass. Shooting a path clear through a field is a
# perfectly good use of a laser, and the ore it leaves behind is the point.
for enemy in get_tree().get_nodes_in_group("enemies") \
+ get_tree().get_nodes_in_group("asteroids"):
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 _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, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45))