spaceship/controllers/MouseCombatController.gd
2026-09-02 13:43:56 +02:00

132 lines
5.1 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.
#
# #171 changed what that guard asks. It used to be the presence of a
# touchscreen, which fails safe but fails often: a desktop browser reporting a
# touch-capable panel switched mouse aiming off on exactly the machine it was
# written for. It now asks what the player last actually used
# (GameState.pointer_is_touch), which still fails safe - a tap makes the
# reading "touch" before the emulated click arrives, so a phone can never fire
# through this path - and stops being wrong about desktops.
# 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", "DashController"]
@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 follows the last input the player actually used, which is what every
# scene ships with. The explicit settings exist because that reading is seeded
# from the display in an environment - headless - where the answer varies by
# platform, 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 GameState.pointer_is_touch
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.
#
# #172: the cursor's world position goes across as a PLACE. It always was
# one; it used to be flattened into a heading from the fleet's centre on
# the way out, which threw away the distance and made every hull fire
# parallel past the thing being clicked on.
fire_stick_controller.has_external_target = is_enabled() and firing \
and selected_weapon == 0 and not fleet.ships.is_empty()
fire_stick_controller.external_target = aim_world