spaceship/controllers/GameController.gd
Claude 34e252037e #231, #230: a key per ability, and an upkeep sheet that adds up
#231 - the abilities were hard to use on a keyboard.

"The currently selected weapons" names a real mechanism: MouseCombatController
(#80) has number keys that SELECT which weapon a click fires. That is two
acts for one shot, and the selection is invisible unless you look at the
buttons. Q, E and space now fire the gas bomb, the laser and the dash
outright, wherever the mouse already points.

It goes through fire_at(), the same public entry the click path uses, so
every guard comes with it - no hull ready, cannot afford the shot, a panel
over the button. The number keys stay: they are not what this ticket is
about, and removing them would take the click path's abilities with them.

Q and E were already taken - they pivot the formation - so the pivot pair
moved to Z and C, one row under the movement keys and still left-hand. That
is a change to a control the ticket did not mention, and the one thing here
worth arguing with.

test_keyboard_input spelled Q and E out and so reported a broken pivot when
the pivot was fine. It reads KEY_PIVOT_CW/CCW off the controller now, and
additionally asserts the pair is not bound to an ability key - the failure
that would actually matter.

#230 - the upkeep menu never added anything up.

Two faults, and the second is invisible from the code. There was no TOTAL,
so the bill had to be done in the player's head. And the rows were one Label
padded with %-16s, which aligns only in a monospace font - the theme's is
CarroisGothicSC, which is proportional - with autowrap on top, so a long row
folded and the table lost its shape.

A real GridContainer now: header, a row per paying berth, and a TOTAL. The
headline carries both figures, because they answer different questions - the
per-charge bill is what the player watches happen, the per-minute rate is
what they plan against.

Amounts are listed in the game's resource order rather than the dictionary's.
The screenshot showed "2 ore 1 energy" on one row above "2 gas 2 ore" on the
next, which defeats the point of a column.

The sheet is rebuilt only when the numbers change. BuildController refreshes
this menu every frame while it is open, and rebuilding a dozen Labels per
frame for figures that move every 25 seconds is what #6 and #10 are about.
The test refreshes twice and asserts the same cell objects come back.

Every figure still comes from fleet_upkeep_breakdown(), the calculation the
charging loop itself runs - the new total sums those same per-berth costs
rather than recomputing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:17 -04:00

497 lines
20 KiB
GDScript

extends Node2D
@export var min_zoom: float = 0.4
@export var max_zoom: float = 6.0
@export var camera_follow_speed: float = 5.0
# Distance (world units) below which the camera snaps exactly onto the fleet
# instead of continuing to lerp. Without this the exponential lerp only ever
# approaches the target asymptotically and NEVER arrives, so the camera keeps
# drifting by a fraction of a pixel every single frame - forever, even with
# the fleet completely stationary. That kept the background shaders' camera_pos
# uniform changing every frame, which made screen tearing permanently visible
# as horizontal bands of the background shifted sideways against each other.
# Same class of bug (and same fix) as the ship arrival oscillation.
@export var camera_snap_distance: float = 0.5
@export var debug_input_logging: bool = false
@onready var camera: Camera2D = $Camera2D
@onready var fleet: Node2D = $Fleet
# Optional: scenes without a joystick (none currently) still work.
@onready var joystick: TouchJoystick = get_node_or_null("UI/Joystick")
# The right-hand stick: aims and fires. Optional for the same reason as the
# movement stick - a scene without one still runs, with the guns silent.
@onready var fire_stick: TouchJoystick = get_node_or_null("UI/FireStick")
# Navigation section. Optional so a scene without it still works.
@onready var pivot_cw_button: IconButton = get_node_or_null("UI/PivotCWButton")
@onready var pivot_ccw_button: IconButton = get_node_or_null("UI/PivotCCWButton")
@onready var gas_bomb_controller: GasBombController = get_node_or_null("GasBombController")
# Untyped on purpose: LaserController resolves fine as a static type when a
# script IS the -s entry point, but the same class_name reference fails to
# parse ("Could not find type") when the script holding it is only reached
# indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd
# preview - for a class_name this new. Node + duck-typed calls sidesteps it.
@onready var laser_controller: Node = get_node_or_null("LaserController")
@onready var dash_controller: Node = get_node_or_null("DashController")
@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader")
var touches: Dictionary = {}
var pinch_active: bool = false
var pinch_start_distance: float = 0.0
var pinch_start_zoom: float = 1.0
# Held pivot buttons turn the formation continuously at this rate; a quick
# tap that accumulates less than PIVOT_MIN_STEP is topped up to it on
# release, so tapping still produces a clear, visible nudge.
const PIVOT_SPEED: float = 1.6
const PIVOT_MIN_STEP: float = PI / 10.0
# #72: the tap top-up below used to fire in full on EVERY release, with no
# regard for how recently the last one landed - so tapping fast enough
# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what
# holding the button can ever produce. Each of those artificially-frequent
# re-seats (Fleet.apply_formation()) forces every ship into a fresh
# max-speed catch-up dash toward its new slot with no time to settle
# between them, and since outer-wedge ships swing further per pivot than
# inner ones, spamming made them visibly diverge in speed ("desync") while
# each individual ship's own bounded max_speed made the fleet as a whole
# read as constantly at its speed ceiling ("build up speed"). This cooldown
# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its
# own, so tap-spam can't out-pace holding the button down.
const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED
var pivot_hold_amount: float = 0.0
# Tracked PER DIRECTION, not shared - a single quick correction (tap the
# other way right after an overshoot) is normal play, not the spam this
# cooldown targets, and Q/E each independently producing their own minimum
# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd).
# Only repeatedly re-tapping the SAME direction needs to be capped.
var pivot_cw_tap_cooldown_left: float = 0.0
var pivot_ccw_tap_cooldown_left: float = 0.0
# Touch index currently holding each pivot button, or -1. These buttons are
# driven straight from raw touch events rather than through _gui_input,
# because Godot's Control GUI treats touch as a single emulated pointer and
# will not reliably deliver a SECOND simultaneous finger to another
# Control - which meant pivoting while steering with the joystick silently
# did nothing. The joystick already bypasses the GUI for the same reason.
var pivot_cw_touch: int = -1
var pivot_ccw_touch: int = -1
# --- keyboard and mouse ----------------------------------------------------
# The touch UI is the whole story on a phone. On a machine with a keyboard -
# which in practice means the web build - WASD or the arrow keys steer, Q/E
# pivot the formation, and the wheel zooms. Clicking the icon buttons already
# works without any code here, because the project emulates touch from the
# mouse (see project.godot), which is the same path the buttons already read.
#
# Keys are tracked from events rather than polled off the Input singleton so
# the whole thing is drivable from a headless test.
const KEY_DIRECTIONS: Dictionary = {
KEY_W: Vector2.UP, KEY_UP: Vector2.UP,
KEY_S: Vector2.DOWN, KEY_DOWN: Vector2.DOWN,
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
}
# Z and C rather than Q and E: #231 gives Q, E and space to the abilities, so
# that each one is triggered directly instead of being selected first. Pivot
# stays under the same hand, one row down from the movement keys.
const KEY_PIVOT_CW: int = KEY_C
const KEY_PIVOT_CCW: int = KEY_Z
const WHEEL_ZOOM_STEP: float = 1.12
var keys_down: Dictionary = {}
var keyboard_seen: bool = false
var keyboard_steering: bool = false
# Seconds between automatic saves. Long enough that writing the file is
# never felt, short enough that a lost run is an annoyance rather than an
# evening.
const AUTOSAVE_INTERVAL: float = 30.0
var autosave_countdown: float = AUTOSAVE_INTERVAL
func _ready() -> void:
# Most visuals still animate per rendered frame. The main/surface scenes opt
# their roots out of global interpolation, then opt their physics-follow
# camera and Ship.tscn hulls back in.
# Running the camera after ordinary physics nodes lets it follow the ships'
# newest centroid, and interpolation fills the display frames in between.
process_physics_priority = 1
camera.reset_physics_interpolation()
# #148: the main theme, which outlives this scene - see Music.ensure().
# This script is the root of BOTH the space scene and the planet
# surface, so one call here covers the whole game.
Music.ensure(get_tree())
# Same moment, same reason: build the sfx voices before the first
# sound needs them rather than during it.
Sfx.warm(get_tree())
if joystick != null:
joystick.direction_changed.connect(_on_joystick_direction)
func _held_pivot_direction() -> float:
var dir: float = 0.0
if pivot_cw_button != null and pivot_cw_button.pressed_down:
dir += 1.0
if pivot_ccw_button != null and pivot_ccw_button.pressed_down:
dir -= 1.0
if keys_down.has(KEY_PIVOT_CW):
dir += 1.0
if keys_down.has(KEY_PIVOT_CCW):
dir -= 1.0
return clampf(dir, -1.0, 1.0)
# #153: "after a dialog popup the godot input system is lost on keypress. If
# I had a key held it keeps moving in the key direction."
#
# It was not lost - it was never delivered. A dialog stops the tree (#128),
# a paused node receives no input, and this controller is paused along with
# everything else. So a key RELEASED while the line was up never reached
# _handle_key, keys_down kept it, and the fleet flew on in that direction
# once the game restarted.
#
# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which
# makes it the one moment where the question can be asked. The physical
# keyboard is the authority on what is actually held down; keys_down is only
# a record of what we were told, and it is the record that went stale.
func _notification(what: int) -> void:
if what == NOTIFICATION_UNPAUSED:
_forget_released_keys()
# #225: the phone is where this is played, and Android kills an app in
# the background without warning - so the run is written on the way out
# rather than only on a timer.
elif what == NOTIFICATION_WM_CLOSE_REQUEST \
or what == NOTIFICATION_APPLICATION_PAUSED:
autosave()
func _forget_released_keys() -> void:
for key in keys_down.keys():
if not Input.is_key_pressed(key):
keys_down.erase(key)
func keyboard_direction() -> Vector2:
var dir: Vector2 = Vector2.ZERO
for key in KEY_DIRECTIONS:
if keys_down.has(key):
dir += KEY_DIRECTIONS[key]
return Vector2.ZERO if dir == Vector2.ZERO else dir.normalized()
func _handle_key(event: InputEventKey) -> void:
if event.echo:
return
if event.pressed:
keys_down[event.keycode] = true
_note_keyboard()
else:
keys_down.erase(event.keycode)
# A quick tap of a pivot key gets the same minimum step a tapped
# button does, rather than turning by a barely-visible sliver.
if event.keycode == KEY_PIVOT_CW:
_finish_pivot_tap(1.0)
elif event.keycode == KEY_PIVOT_CCW:
_finish_pivot_tap(-1.0)
# #85: on a computer the on-screen sticks and the pivot pair are dead weight -
# WASD steers, the mouse aims, Q/E pivot.
#
# #171: they now come and GO, rather than being taken away once and for all.
#
# The old rule asked DisplayServer.is_touchscreen_available() and, if it said
# yes, kept everything for ever - because the failure to avoid is a phone left
# with no way to steer. In a browser that query says yes on half the laptops
# in the world, so the web build showed thumb sticks to people holding a
# mouse, which is the ticket. It also could not be undone: a wrong answer the
# other way stranded a tablet with no controls until it was restarted.
#
# Following the LAST INPUT ACTUALLY USED fixes both ends. Touch the screen and
# the controls are there; use the mouse or the keyboard and they go. Nobody is
# ever stranded, because the gesture that needs them is the same gesture that
# brings them back, and it takes one tap. GameState owns the reading (see
# note_touch_input) so the mouse-aiming gate cannot disagree with this.
func _note_keyboard() -> void:
keyboard_seen = true
GameState.note_pointer_input()
_sync_touch_controls()
func _note_touch() -> void:
GameState.note_touch_input()
_sync_touch_controls()
func _sync_touch_controls() -> void:
var offered: bool = GameState.pointer_is_touch
for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]:
if control != null and control.visible != offered:
control.visible = offered
func _button_holds(button: IconButton, at_position: Vector2) -> bool:
return button != null and Rect2(button.global_position, button.size).has_point(at_position)
func _set_button_held(button: IconButton, held: bool) -> void:
if button == null or button.pressed_down == held:
return
button.pressed_down = held
button.queue_redraw()
# Returns true when a pivot button consumed the touch.
func _handle_pivot_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if _button_holds(pivot_cw_button, event.position) and pivot_cw_touch == -1:
pivot_cw_touch = event.index
_set_button_held(pivot_cw_button, true)
return true
if _button_holds(pivot_ccw_button, event.position) and pivot_ccw_touch == -1:
pivot_ccw_touch = event.index
_set_button_held(pivot_ccw_button, true)
return true
return false
if event.index == pivot_cw_touch:
pivot_cw_touch = -1
_set_button_held(pivot_cw_button, false)
_finish_pivot_tap(1.0)
return true
if event.index == pivot_ccw_touch:
pivot_ccw_touch = -1
_set_button_held(pivot_ccw_button, false)
_finish_pivot_tap(-1.0)
return true
return false
func _update_pivot(delta: float) -> void:
var dir: float = _held_pivot_direction()
if dir == 0.0:
return
var amount: float = PIVOT_SPEED * delta * dir
pivot_hold_amount += absf(amount)
_pivot(amount)
func _finish_pivot_tap(direction: float) -> void:
# Fired on release. A press held long enough has already turned far
# enough on its own; a brief tap gets topped up to the minimum step -
# but only once THAT DIRECTION's own cooldown has cleared, so spamming
# one side can't grant rotation faster than holding the button would.
var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left
if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0:
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
if direction > 0.0:
pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
else:
pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
pivot_hold_amount = 0.0
func _pivot(radians: float) -> void:
# Turns the formation and re-seats the ships already flying it, so the
# change is visible immediately rather than only on the next rebuild.
# apply_formation() writes the new offsets into Fleet.formation_offsets,
# which move_to()/move_in_direction() also read - so a pivot applied
# while the joystick is steering survives the next steering update
# instead of being overwritten by the old layout.
GameState.rotate_formation(radians)
fleet.apply_formation()
func _on_joystick_direction(direction: Vector2) -> void:
# Steering is re-issued every frame in _process while the stick is held,
# so this only needs to handle the release (stick back to centre).
if direction == Vector2.ZERO:
fleet.stop()
# Written on a timer as well as on the way out: a crash or a battery pull
# gives no notification at all, and losing half an hour is what makes a save
# system feel absent even when it is there.
func autosave() -> void:
if GameState.intro_active:
# Nothing worth keeping yet, and saving mid-prologue would resume
# into a run that has not been set up.
return
SaveGame.save(GameState)
autosave_countdown = AUTOSAVE_INTERVAL
func _process(delta: float) -> void:
autosave_countdown -= delta
if autosave_countdown <= 0.0:
autosave()
pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0)
pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0)
_update_pivot(delta)
# Keep the heading fresh while a control is held. Fleet advances its own
# anchor in _physics_process, so this only needs to report the direction. The
# keyboard takes precedence, and releasing the keys has to stop the fleet
# itself - unlike the stick, there is no centred-again signal to hook.
# #124: the breach drives. One check here covers the stick, the keyboard
# and the released-again path together - three separate gates would be
# three chances for one of them to keep steering.
if GameState.intro_locks_movement or GameState.encounter_locks_movement:
keyboard_steering = false
else:
_apply_steering()
func _physics_process(delta: float) -> void:
_update_camera(delta)
func _update_camera(delta: float) -> void:
var target: Vector2 = fleet.get_centroid()
if camera.global_position.distance_to(target) <= camera_snap_distance:
# Snap and stop dead - see camera_snap_distance. Assigning the exact
# target means a settled fleet produces a bit-identical camera position
# frame after frame, so the background shaders stop re-rendering a
# shifted image and tearing has nothing to reveal.
camera.global_position = target
return
var t: float = clamp(camera_follow_speed * delta, 0.0, 1.0)
camera.global_position = camera.global_position.lerp(target, t)
func _apply_steering() -> void:
var keyed: Vector2 = keyboard_direction()
if keyed != Vector2.ZERO:
keyboard_steering = true
fleet.move_in_direction(keyed)
elif keyboard_steering:
keyboard_steering = false
fleet.stop()
elif joystick != null and joystick.direction != Vector2.ZERO:
fleet.move_in_direction(joystick.direction)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
_handle_touch(event)
elif event is InputEventScreenDrag:
_handle_drag(event)
elif event is InputEventMagnifyGesture:
_handle_magnify(event)
elif event is InputEventKey:
_handle_key(event)
elif event is InputEventMouseMotion:
_note_keyboard()
elif event is InputEventMouseButton:
_note_keyboard()
# The speed fader used to be driven from here, and could not work:
# MouseCombatController is dispatched before this node (it is a
# child, and this is the root) and marks every left press handled to
# fire the guns, so the press never arrived. It listens for its own
# mouse events now - see SpeedFader._unhandled_input - and claims
# them from the UI layer, ahead of the combat controllers.
if not event.pressed:
return
# Wheel zoom, the desktop equivalent of the pinch.
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
_set_zoom(camera.zoom.x * WHEEL_ZOOM_STEP)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
_set_zoom(camera.zoom.x / WHEEL_ZOOM_STEP)
func _handle_touch(event: InputEventScreenTouch) -> void:
# #171: a finger on the glass is the one unambiguous piece of evidence
# that this is a touch device being played by touch, and it is recorded
# before anything gets a chance to claim the event - a touch swallowed by
# the joystick is still a touch.
_note_touch()
# The joystick gets first refusal on every touch, and only claims ones
# that begin inside its own rect - so pinch-to-zoom still works anywhere
# else on screen.
if gas_bomb_controller != null and gas_bomb_controller.handle_touch(event):
return
if laser_controller != null and laser_controller.handle_touch(event):
return
if dash_controller != null and dash_controller.handle_touch(event):
return
if speed_fader != null and speed_fader.handle_touch(event):
return
if _handle_pivot_touch(event):
return
if joystick != null and joystick.visible and joystick.handle_touch(event):
return
# Order between the two sticks does not matter: each only claims touches
# that begin inside its own rect, and their rects do not overlap.
if fire_stick != null and fire_stick.visible and fire_stick.handle_touch(event):
return
if event.pressed:
touches[event.index] = event.position
if touches.size() == 2:
_start_pinch()
if debug_input_logging:
print("DEBUG touch pressed idx=%d pos=%s active_touches=%d" % [event.index, event.position, touches.size()])
else:
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if debug_input_logging:
print("DEBUG touch released idx=%d active_touches=%d" % [event.index, touches.size()])
func _handle_drag(event: InputEventScreenDrag) -> void:
# A finger moving on the glass, same as a finger arriving on it (#171).
_note_touch()
if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event):
return
if laser_controller != null and laser_controller.handle_drag(event):
return
if dash_controller != null and dash_controller.handle_drag(event):
return
if speed_fader != null and speed_fader.handle_drag(event):
return
if joystick != null and joystick.visible and joystick.handle_drag(event):
return
if fire_stick != null and fire_stick.visible and fire_stick.handle_drag(event):
return
if not touches.has(event.index):
return
touches[event.index] = event.position
if pinch_active and touches.size() == 2 and pinch_start_distance > 0.0:
var positions: Array = touches.values()
var current_distance: float = positions[0].distance_to(positions[1])
# Spreading the fingers apart zooms IN. Camera2D.zoom is a
# magnification (larger = closer), so the ratio goes
# current/start, not start/current.
var new_zoom: float = pinch_start_zoom * (current_distance / max(pinch_start_distance, 1.0))
_set_zoom(new_zoom)
if debug_input_logging:
print("DEBUG pinch drag distance=%.1f zoom=%.3f" % [current_distance, camera.zoom.x])
func _handle_magnify(event: InputEventMagnifyGesture) -> void:
# Fallback path: some platforms (and desktop trackpads) report pinch as a
# single semantic gesture instead of two raw touch streams. factor > 1
# means the gesture is spreading outward, which zooms in - and since
# Camera2D.zoom is a magnification, that multiplies.
_set_zoom(camera.zoom.x * event.factor)
if debug_input_logging:
print("DEBUG magnify gesture factor=%.3f zoom=%.3f" % [event.factor, camera.zoom.x])
func _start_pinch() -> void:
pinch_active = true
var positions: Array = touches.values()
pinch_start_distance = positions[0].distance_to(positions[1])
pinch_start_zoom = camera.zoom.x
func _set_zoom(raw_zoom: float) -> void:
var z: float = clamp(raw_zoom, min_zoom, max_zoom)
camera.zoom = Vector2(z, z)