spaceship/MouseCombatController.gd
Loïc Wisniewski baac11ae8b Implement #80, #81, #82 and #83
#80 - desktop combat. The mouse aims, the left button fires, and the number
keys pick the weapon: 1 the fleet's guns, 2 and up the 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.

The pointer path hands its heading to FireStickController rather than driving
weapons itself, so exactly one thing in the scene aims a gun, and a held stick
wins - a finger on it is a deliberate act, a mouse resting somewhere is not.
Abilities gained fire_at(), which aims and fires in one call instead of
pretending to be a press-drag-release touch.

It is gated on the absence of a touchscreen, because Godot emulates a mouse
button from every touch and without that a tap on a phone would fire the guns.
The gate has an explicit override, and not for tidiness: headless reports a
touchscreen, so with the automatic answer alone the aiming could never be
exercised by a test and the only covered behaviour would be the guard.

#81 - the bottom-left control hint is gone, from both scenes and from
GameController.

#82 - a chaser is destroyed by the impact when it reaches a vessel. It used to
pass through the wreck untouched and line up another pass, which made a
ramming enemy strictly better than any other. Data-driven per enemy
(dies_on_contact), because a sniper that happens to drift into a ship has not
spent itself doing it - and routed through take_damage, so it dies exactly as
it dies to gunfire. The enemy is told about the impact BEFORE the ship
resolves its own damage, since resolving it can free the ship.

#83 - the research tree's buy button is gone; tapping a node twice buys it.
Deliberately "the same node twice" rather than a timed double-tap: no window
to miss on a slow phone, no accidental purchase while browsing, and the first
tap always does something visible, so the second is never a blind commitment.
The status line now says "Tap again to research" - with the button gone it is
the only thing distinguishing an affordable node from an unaffordable one.

test_enemy_collision asserted the exact behaviour #82 reversed, and now checks
the ram costs both. Suite 117/117.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:49:47 +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