spaceship/controllers/MouseCombatController.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

123 lines
4.6 KiB
GDScript

extends Node2D
# Forgejo #80: on a computer the mouse aims and the left button fires, and the
# number keys choose which weapon the click uses - 1 the fleet's own guns, 2
# and up the special abilities.
#
# The twin sticks are built for thumbs. On a desktop they mean dragging a
# virtual stick with a pointer that could simply BE the crosshair, so this
# offers the shape a mouse actually wants: point, click, hold to keep firing.
#
# Touch is unaffected, and deliberately so: Godot emulates a mouse button from
# every touch, so without a guard a tap anywhere on a phone would fire the
# guns. The guard is the presence of a touchscreen rather than a "has used a
# mouse" flag, because the wrong answer has to fail safe - a desktop wrongly
# reported as touch-capable loses a convenience, while a phone wrongly treated
# as a desktop fires its weapons at every tap.
# A literal, not PackedStringArray(...): a constructor call is not a constant
# expression, and the script fails to PARSE rather than to run - which shows up
# as "nonexistent function" on a node that looks fine in the scene.
const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController"]
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController")
# 0 is the fleet's own guns; 1.. index into `abilities` below.
var selected_weapon: int = 0
var firing: bool = false
var aim_world: Vector2 = Vector2.ZERO
var abilities: Array = []
func _ready() -> void:
# Resolved by NAME in a fixed order, so "2" is the gas bomb on every
# scene and in every session. Discovering them by scanning children would
# make the number keys depend on scene node order, which is not something
# a player can see or rely on.
for node_name in ABILITY_ORDER:
var controller: Node = get_node_or_null("../%s" % node_name)
if controller != null:
abilities.append(controller)
# AUTO decides from the display, which is what every scene ships with. The
# explicit settings exist because "is there a touchscreen" is not a question
# that can be answered honestly in every environment - headless reports one,
# so with AUTO alone the aiming below could never be exercised by a test at
# all, and the only covered behaviour would be the guard itself.
enum PointerMode { AUTO, ON, OFF }
@export var pointer_mode: PointerMode = PointerMode.AUTO
func is_enabled() -> bool:
match pointer_mode:
PointerMode.ON:
return true
PointerMode.OFF:
return false
return not DisplayServer.is_touchscreen_available()
func weapon_count() -> int:
return abilities.size() + 1
# Which weapon a given number key selects. Public so a test can ask rather
# than hardcode the mapping a second time.
func select_weapon(index: int) -> bool:
if index < 0 or index >= weapon_count():
return false
selected_weapon = index
_refresh_highlight()
return true
func _refresh_highlight() -> void:
# The chosen ability's own button carries the selection, so the answer to
# "what will a click do" is on screen rather than remembered.
for i in range(abilities.size()):
var button: Node = abilities[i].get("button")
if button != null:
button.highlighted = selected_weapon == i + 1
button.queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not is_enabled():
return
if event is InputEventMouseMotion:
aim_world = get_global_mouse_position()
return
if event is InputEventKey and event.pressed and not event.echo:
var index: int = event.keycode - KEY_1
if index >= 0 and index < weapon_count() and select_weapon(index):
get_viewport().set_input_as_handled()
return
if not (event is InputEventMouseButton) or event.button_index != MOUSE_BUTTON_LEFT:
return
aim_world = get_global_mouse_position()
firing = event.pressed
if not event.pressed:
return
get_viewport().set_input_as_handled()
if selected_weapon > 0:
# One shot per click. Abilities have their own cooldowns and costs;
# holding the button down must not queue them up.
abilities[selected_weapon - 1].fire_at(aim_world)
func _process(_delta: float) -> void:
if fire_stick_controller == null:
return
# Handed to the stick controller rather than driving the weapons here, so
# there is still exactly one thing in the scene that aims a gun. The stick
# wins when both are live - a finger on it is a deliberate act, while a
# mouse merely resting somewhere is not.
var heading: Vector2 = Vector2.ZERO
if is_enabled() and firing and selected_weapon == 0 and not fleet.ships.is_empty():
heading = aim_world - fleet.get_centroid()
fire_stick_controller.external_direction = heading