2026-08-16 17:59:04 +02:00
|
|
|
class_name SpeedFader
|
|
|
|
|
extends Control
|
|
|
|
|
|
|
|
|
|
# Vertical drag control for GameState.fleet_speed_multiplier. Same raw-touch
|
|
|
|
|
# idiom as TouchJoystick (handle_touch/handle_drag offered first by
|
|
|
|
|
# GameController, not _gui_input) and the same NeonDraw look, but a fader
|
|
|
|
|
# rather than a stick: the knob's Y position along a fixed vertical track IS
|
|
|
|
|
# the value, no spring-back to centre.
|
|
|
|
|
#
|
|
|
|
|
# Self-contained: writes straight to GameState.fleet_speed_multiplier rather
|
|
|
|
|
# than reporting a value for something else to apply, since the multiplier
|
|
|
|
|
# already lives in the one place (the autoload) that needs to survive scene
|
|
|
|
|
# swaps.
|
|
|
|
|
|
|
|
|
|
@export var glow_color: Color = Color(0.7, 0.3, 1.0)
|
|
|
|
|
@export var core_color: Color = Color(1.0, 1.0, 1.0)
|
|
|
|
|
@export var track_margin: float = 28.0
|
|
|
|
|
|
|
|
|
|
@onready var fleet: Node2D = get_node("../../Fleet")
|
|
|
|
|
@onready var label: Label = get_node("Label")
|
|
|
|
|
|
|
|
|
|
var active_touch_index: int = -1
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
# 0 = bottom of the track (1.0x), 1 = top. The top is
|
|
|
|
|
# GameState.max_fleet_speed_multiplier, which widens with each distorder
|
|
|
|
|
# vessel past the first - so the track always spans the whole usable range.
|
2026-08-16 17:59:04 +02:00
|
|
|
var knob_t: float = 0.0
|
2026-08-16 19:21:54 +02:00
|
|
|
var status_message_until: int = 0
|
2026-08-16 17:59:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
visible = false
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
|
|
|
var has_distorder: bool = fleet.has_capability("distorder")
|
|
|
|
|
if has_distorder != visible:
|
|
|
|
|
visible = has_distorder
|
|
|
|
|
if not has_distorder:
|
2026-08-16 21:38:07 +02:00
|
|
|
# Fleet owns persistent composition state. This outgoing UI node can
|
|
|
|
|
# briefly observe an empty fleet during a scene swap, so it must only
|
|
|
|
|
# clear its own interaction state here.
|
2026-08-16 17:59:04 +02:00
|
|
|
active_touch_index = -1
|
|
|
|
|
knob_t = 0.0
|
|
|
|
|
return
|
|
|
|
|
knob_t = (GameState.fleet_speed_multiplier - 1.0) \
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
/ maxf(GameState.max_fleet_speed_multiplier - 1.0, 0.001)
|
2026-08-16 19:21:54 +02:00
|
|
|
# #62: GameState._process() (not this node's own _process()) is what
|
|
|
|
|
# drains energy and force-resets the multiplier, since it has to keep
|
|
|
|
|
# running across the space<->surface swap and while this control might
|
|
|
|
|
# not even be in the current scene tree. It leaves a one-shot flag for
|
|
|
|
|
# whichever SpeedFader picks it up next.
|
|
|
|
|
if GameState.boost_depleted_notice:
|
|
|
|
|
GameState.boost_depleted_notice = false
|
|
|
|
|
# Still holding the drag at the moment energy ran out would
|
|
|
|
|
# otherwise re-boost on the very next drag event, from a knob
|
|
|
|
|
# position the finger never actually moved away from.
|
|
|
|
|
active_touch_index = -1
|
|
|
|
|
label.text = "Out of energy!"
|
|
|
|
|
status_message_until = Time.get_ticks_msec() + 3000
|
|
|
|
|
elif Time.get_ticks_msec() >= status_message_until:
|
|
|
|
|
label.text = "x%.1f" % GameState.fleet_speed_multiplier
|
2026-08-16 17:59:04 +02:00
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_active() -> bool:
|
|
|
|
|
return active_touch_index != -1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _blocked_by_modal() -> bool:
|
|
|
|
|
# Same check as IconButton._blocked_by_modal() - panels stay transparent
|
|
|
|
|
# to raw input (the skill tree/formation editor need _unhandled_input),
|
|
|
|
|
# so every raw-touch control checks for itself instead.
|
|
|
|
|
for menu in get_tree().get_nodes_in_group("modal_menus"):
|
|
|
|
|
if menu.is_visible_in_tree() and not menu.is_ancestor_of(self):
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 21:59:24 +02:00
|
|
|
# #118: the same fader, driven by a mouse.
|
|
|
|
|
#
|
|
|
|
|
# The project used to set emulate_touch_from_mouse, which handed every raw
|
|
|
|
|
# touch handler a synthetic touch per click; with that gone this control was
|
|
|
|
|
# inert on a desktop and there was no other way to reach the boost. Written
|
|
|
|
|
# as thin wrappers over the touch path rather than as a second
|
|
|
|
|
# implementation, so the two cannot drift - the same mistake #93 fixed in
|
|
|
|
|
# the research tree.
|
|
|
|
|
const MOUSE_TOUCH_INDEX: int = -2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func handle_mouse_button(event: InputEventMouseButton) -> bool:
|
|
|
|
|
if event.button_index != MOUSE_BUTTON_LEFT:
|
|
|
|
|
return false
|
|
|
|
|
return _press(event.pressed, event.position, MOUSE_TOUCH_INDEX)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func handle_mouse_motion(event: InputEventMouseMotion) -> bool:
|
|
|
|
|
if active_touch_index != MOUSE_TOUCH_INDEX:
|
|
|
|
|
return false
|
|
|
|
|
_update_from_local(event.position - global_position)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
Let the speed fader have the click it is under
Clicking the fader did nothing on a desktop: it fired the guns instead.
Unhandled input reaches the last nodes in the tree first, and the fader's
mouse handling lived in GameController - the script on Main, the ROOT, so
it was served last of all. MouseCombatController is a child of it and marks
every left press handled to fire, so the press never arrived.
The fader asks for its own mouse events now, from the UI layer, which sits
after the combat controllers: the control the pointer is actually on gets
the press ahead of the guns behind it. The handlers themselves are the same
wrappers #118 wrote.
The test for #118 could not see any of this - it handed the event straight
to Main._unhandled_input, which is precisely the delivery the real game
never makes, so it passed against the bug it was written for. It pushes
through the viewport now, with mouse aiming on, and checks the guns stay
quiet. Confirmed it fails without the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
2026-09-12 19:06:14 +02:00
|
|
|
# ...and listening for those events HERE rather than being handed them by
|
|
|
|
|
# GameController, which is what #118 did.
|
|
|
|
|
#
|
|
|
|
|
# Unhandled input reaches the last nodes in the tree first, and Main - where
|
|
|
|
|
# that dispatch lives - is the root, so it is served last of all.
|
|
|
|
|
# MouseCombatController is a child of it and claims every left press to fire
|
|
|
|
|
# the fleet's guns, which meant a click on the fader shot at the fader
|
|
|
|
|
# instead of taking hold of it: the knob never moved on a desktop. The UI
|
|
|
|
|
# layer sits after the combat controllers, so asking for the event here puts
|
|
|
|
|
# the control the player is actually pointing at ahead of the guns behind it.
|
|
|
|
|
#
|
|
|
|
|
# Touch still comes through GameController: nothing competes for a finger,
|
|
|
|
|
# and the joystick/fire stick ordering it arbitrates is real.
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
|
|
|
if event is InputEventMouseButton:
|
|
|
|
|
if handle_mouse_button(event):
|
|
|
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
elif event is InputEventMouseMotion:
|
|
|
|
|
if handle_mouse_motion(event):
|
|
|
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
|
|
|
|
|
2026-08-16 17:59:04 +02:00
|
|
|
func handle_touch(event: InputEventScreenTouch) -> bool:
|
2026-08-23 21:59:24 +02:00
|
|
|
return _press(event.pressed, event.position, event.index)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _press(pressed: bool, at: Vector2, index: int) -> bool:
|
|
|
|
|
var local: Vector2 = at - global_position
|
|
|
|
|
if pressed:
|
2026-08-16 17:59:04 +02:00
|
|
|
if not visible or active_touch_index != -1 or _blocked_by_modal():
|
|
|
|
|
return false
|
|
|
|
|
if not Rect2(Vector2.ZERO, size).has_point(local):
|
|
|
|
|
return false
|
2026-08-23 21:59:24 +02:00
|
|
|
active_touch_index = index
|
2026-08-16 17:59:04 +02:00
|
|
|
_update_from_local(local)
|
|
|
|
|
return true
|
2026-08-23 21:59:24 +02:00
|
|
|
if index != active_touch_index:
|
2026-08-16 17:59:04 +02:00
|
|
|
return false
|
|
|
|
|
active_touch_index = -1
|
|
|
|
|
queue_redraw()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func handle_drag(event: InputEventScreenDrag) -> bool:
|
|
|
|
|
if event.index != active_touch_index:
|
|
|
|
|
return false
|
|
|
|
|
_update_from_local(event.position - global_position)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _update_from_local(local: Vector2) -> void:
|
|
|
|
|
var usable: float = maxf(size.y - track_margin * 2.0, 1.0)
|
|
|
|
|
# Up is 1.0 (top of the track), matching how a real fader/throttle reads.
|
|
|
|
|
var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0)
|
|
|
|
|
knob_t = t
|
Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
DISTORDER STACKING. Each space-time distorder vessel past the first raises
the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of
being dead weight. GameState gains max_fleet_speed_multiplier, pushed by
Fleet whenever composition changes - the same direction as every other
fleet-derived figure, since GameState does not reach into the scene tree to
count ships. The energy drain is normalised against the CURRENT ceiling, so
full boost costs the same per second however wide it is: extra vessels buy
more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost
rather than leaving the fleet travelling faster than its crew can sustain.
TRANSFER MENU. Rows overlapped, and the last one ran into the hint text.
row_height is divided from the available space, but each row's contents used
a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which
needs about 244px. Three resources fit; the fourth cut rows to 180 and every
one spilled 64px into the next. Every offset is now a fraction of the row, so
it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a
word the footer hint already explains) and moved each glyph and name to the
TOP of its row, where they read as its heading rather than the next row's.
HANGAR. Six vessel types in one row gave each about 100px for a 155px label,
so the names ran together. Now a 3x2 grid per section, sized from the space
between the first heading and the status line, using the empty lower half.
LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and
how hard a lab is depends on the layout authored for it - how many capture
points, how many defenders - which the planet TYPE cannot know. PlanetSetup
carries research_point_reward, set in the map editor, overriding the planet
type's flat rate. 0 means "no opinion", so every map authored before this
keeps its old value.
RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a
red tint, which read as "rocky planet, tinted" rather than someone's
facility: plated floor, seams, service conduits, lit junctions. Loop-free by
construction (#68's lesson applied up front) - conduits stay continuous
across cells by keying a horizontal run to cell.y and a vertical to cell.x,
so neighbouring cells agree without any cell scanning its neighbours.
ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery
and the turret spawner as the same octagon at two sizes - the silhouette
carried no information about what the thing did. EnemyDefinition gains
detail_lines and detail_rings as DATA, so adding an enemy still means adding
a definition rather than editing Enemy.gd, and the four non-default hulls are
redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a
capital ship with spine and ribs.
Three things drew an enemy from its definition - the enemy, the map editor's
markers, and the palette buttons - each reimplementing it from hull_points
alone, so the new detail appeared in the game and nowhere else and an editor
marker stopped matching what it placed. They now share
EnemyDefinitions.draw_definition().
ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the
threat was legible and mostly read as the tower being stuck. It fires
immediately now; the warning is the shell you can see in the air plus a small
cross on the ground at the impact point. Deliberately not a blast-radius ring
- a large translucent area was easy to read as the explosion having already
happened, where the cross says "move off this spot".
MAIN MENU. No Quit in any build (a web build has nothing to quit to, and
Android's back gesture already does it), a plain black background, and the
game is "Spaceship" - in the title and in config/name, which is what drives
the browser tab for the web export. change_scene_to_file() returns an error
and otherwise fails silently, leaving the menu up, which is indistinguishable
from an unpressed button; it is now checked and reported on screen.
Full suite green at 99/99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:54:46 +02:00
|
|
|
GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.max_fleet_speed_multiplier - 1.0))
|
2026-08-16 17:59:04 +02:00
|
|
|
label.text = "x%.1f" % GameState.fleet_speed_multiplier
|
|
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _draw() -> void:
|
|
|
|
|
var top: Vector2 = Vector2(size.x * 0.5, track_margin)
|
|
|
|
|
var bottom: Vector2 = Vector2(size.x * 0.5, size.y - track_margin)
|
|
|
|
|
NeonDraw.draw_glowing_line(self, bottom, top, glow_color, 0.55, 0.5, core_color)
|
|
|
|
|
|
|
|
|
|
# Tick marks at the extremes read as "min" and "max" at a glance.
|
|
|
|
|
for t in [0.0, 1.0]:
|
|
|
|
|
var y: float = lerpf(bottom.y, top.y, t)
|
|
|
|
|
NeonDraw.draw_glowing_line(self, Vector2(size.x * 0.5 - 12.0, y),
|
|
|
|
|
Vector2(size.x * 0.5 + 12.0, y), glow_color, 0.4, 0.35, core_color)
|
|
|
|
|
|
|
|
|
|
var knob_pos: Vector2 = bottom.lerp(top, clampf(knob_t, 0.0, 1.0))
|
|
|
|
|
NeonDraw.draw_glowing_circle(self, knob_pos, 16.0, glow_color,
|
|
|
|
|
1.0 if is_active() else 0.8, core_color)
|