Twin-stick combat: aim and fire with a second stick, MOBA-style ability ring
The fleet's guns picked their own targets and fired on their own, so combat
happened whether or not the player did anything - there was nothing to aim and
nothing to decide, and the right half of the screen held nothing at all.
Firing is now an action. VesselWeaponComponent no longer seeks a target; it
shoots along a heading, and the only thing that supplies one is the new
right-hand stick via FireStickController. A fleet arriving from another scene
starts with its guns silent, which is the correct resting state.
A subtle dashed guide runs from the fleet along the aimed heading while the
stick is held, out to the guns' OWN range - read from a weapon that is
actually aboard, so the line can never promise reach the fleet does not have.
Layout: the two sticks take the bottom corners, the pivot pair is centred
between them at the bottom edge, and the laser and gas bomb sit on an arc up
and left of the fire stick where a thumb already is. Those four are now round
- IconButton grew a round_frame mode, and the in-world action controls use it
while panel and menu buttons stay square, so the two kinds of button read as
different kinds of thing.
Worth recording, because it cost a screenshot to find: a script-declared
property set BEFORE the `script = ExtResource(...)` line in a .tscn is
silently dropped. Godot's parser has no idea the property exists yet. The
round frames were all correctly set in both scenes and none of them applied.
Also fixed researtch_lab_garrison.tres, which was authored under the void
profile - that is the black hole surface, so a lab garrison was spawning on
black holes.
Suite 110/110. test_enemy_combat's whole premise was auto-fire; it now holds
the real stick toward the enemy rather than poking weapon components, so it
exercises the path the player uses. test_fire_stick is new and covers the
change itself: an idle stick leaves a target parked well inside range
untouched, which is precisely what the old gun would never have done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:21 +02:00
|
|
|
extends SceneTree
|
|
|
|
|
|
|
|
|
|
# Headless gameplay test: run with
|
|
|
|
|
# godot --headless -s res://tests/test_fire_stick.gd
|
|
|
|
|
# The fleet's guns used to pick their own targets and fire on their own, so
|
|
|
|
|
# combat happened whether or not the player did anything. Firing is now an
|
|
|
|
|
# action: the right-hand stick aims it, and nothing else fires it.
|
|
|
|
|
#
|
|
|
|
|
# Covers:
|
|
|
|
|
# A. an idle stick means an idle gun - an enemy parked well inside fire
|
|
|
|
|
# range takes no shots at all. This is the behaviour that CHANGED, so it
|
|
|
|
|
# is checked against a target the old auto-aim would certainly have hit
|
|
|
|
|
# B. pushing the stick fires, and the shots travel along the stick's
|
|
|
|
|
# heading rather than at whatever happens to be nearest
|
|
|
|
|
# C. releasing the stick stops the gun
|
|
|
|
|
# D. the stick claims only touches inside its own rect, so the right half
|
|
|
|
|
# of the screen does not swallow pinches and taps meant for the world
|
|
|
|
|
# E. the dotted aim guide is drawn only while aiming, and reaches exactly as
|
|
|
|
|
# far as the guns actually shoot - a guide that outruns the gun would be
|
|
|
|
|
# worse than none
|
|
|
|
|
|
|
|
|
|
const FIRE_DIRECTION := Vector2(1.0, 0.0)
|
|
|
|
|
|
|
|
|
|
var game_state: Node
|
|
|
|
|
var main: Node
|
|
|
|
|
var fleet: Node
|
|
|
|
|
var fire_stick: Control
|
|
|
|
|
var controller: Node
|
|
|
|
|
var decoy: Node
|
|
|
|
|
var stage: int = 0
|
|
|
|
|
var elapsed: float = 0.0
|
|
|
|
|
var shots_while_idle: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _initialize() -> void:
|
|
|
|
|
game_state = root.get_node("GameState")
|
|
|
|
|
game_state.buildings.clear()
|
|
|
|
|
game_state.extractors.clear()
|
|
|
|
|
game_state.reset_progression()
|
#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
|
|
|
main = load("res://world/Main.tscn").instantiate()
|
Twin-stick combat: aim and fire with a second stick, MOBA-style ability ring
The fleet's guns picked their own targets and fired on their own, so combat
happened whether or not the player did anything - there was nothing to aim and
nothing to decide, and the right half of the screen held nothing at all.
Firing is now an action. VesselWeaponComponent no longer seeks a target; it
shoots along a heading, and the only thing that supplies one is the new
right-hand stick via FireStickController. A fleet arriving from another scene
starts with its guns silent, which is the correct resting state.
A subtle dashed guide runs from the fleet along the aimed heading while the
stick is held, out to the guns' OWN range - read from a weapon that is
actually aboard, so the line can never promise reach the fleet does not have.
Layout: the two sticks take the bottom corners, the pivot pair is centred
between them at the bottom edge, and the laser and gas bomb sit on an arc up
and left of the fire stick where a thumb already is. Those four are now round
- IconButton grew a round_frame mode, and the in-world action controls use it
while panel and menu buttons stay square, so the two kinds of button read as
different kinds of thing.
Worth recording, because it cost a screenshot to find: a script-declared
property set BEFORE the `script = ExtResource(...)` line in a .tscn is
silently dropped. Godot's parser has no idea the property exists yet. The
round frames were all correctly set in both scenes and none of them applied.
Also fixed researtch_lab_garrison.tres, which was authored under the void
profile - that is the black hole surface, so a lab garrison was spawning on
black holes.
Suite 110/110. test_enemy_combat's whole premise was auto-fire; it now holds
the real stick toward the enemy rather than poking weapon components, so it
exercises the path the player uses. test_fire_stick is new and covers the
change itself: an idle stick leaves a target parked well inside range
untouched, which is precisely what the old gun would never have done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:21 +02:00
|
|
|
root.add_child(main)
|
|
|
|
|
current_scene = main
|
|
|
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
fire_stick = main.get_node("UI/FireStick")
|
|
|
|
|
controller = main.get_node("FireStickController")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _projectile_count() -> int:
|
|
|
|
|
return get_nodes_in_group("player_projectiles").size()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _push_stick(direction: Vector2) -> void:
|
|
|
|
|
var centre: Vector2 = fire_stick.global_position + fire_stick.size * 0.5
|
|
|
|
|
var press := InputEventScreenTouch.new()
|
|
|
|
|
press.index = 3
|
|
|
|
|
press.pressed = true
|
|
|
|
|
press.position = centre
|
|
|
|
|
if not fire_stick.handle_touch(press):
|
|
|
|
|
push_error("the fire stick refused a touch at its own centre")
|
|
|
|
|
var drag := InputEventScreenDrag.new()
|
|
|
|
|
drag.index = 3
|
|
|
|
|
drag.position = centre + direction.normalized() * fire_stick.max_radius
|
|
|
|
|
fire_stick.handle_drag(drag)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _release_stick() -> void:
|
|
|
|
|
var release := InputEventScreenTouch.new()
|
|
|
|
|
release.index = 3
|
|
|
|
|
release.pressed = false
|
|
|
|
|
release.position = fire_stick.global_position + fire_stick.size * 0.5
|
|
|
|
|
fire_stick.handle_touch(release)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
|
|
|
elapsed += delta
|
|
|
|
|
match stage:
|
|
|
|
|
0:
|
|
|
|
|
if elapsed < 0.3:
|
|
|
|
|
return false
|
|
|
|
|
main.get_node("EnemySpawner").set_process(false)
|
|
|
|
|
main.get_node("AsteroidField").set_process(false)
|
|
|
|
|
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
|
|
|
|
|
if is_instance_valid(hazard):
|
|
|
|
|
hazard.queue_free()
|
|
|
|
|
for projectile in get_nodes_in_group("player_projectiles"):
|
|
|
|
|
projectile.free()
|
|
|
|
|
|
|
|
|
|
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
|
|
|
|
var ship: Node2D = fleet.ships[0]
|
|
|
|
|
var weapon: Node = ship.get_component("weapon")
|
|
|
|
|
if weapon == null:
|
|
|
|
|
return _fail("the default vessel has no weapon component to test")
|
|
|
|
|
# Parked at a third of fire range, dead ahead: the old auto-aiming
|
|
|
|
|
# gun would have opened up on this immediately.
|
#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
|
|
|
decoy = load("res://enemies/Enemy.tscn").instantiate()
|
Twin-stick combat: aim and fire with a second stick, MOBA-style ability ring
The fleet's guns picked their own targets and fired on their own, so combat
happened whether or not the player did anything - there was nothing to aim and
nothing to decide, and the right half of the screen held nothing at all.
Firing is now an action. VesselWeaponComponent no longer seeks a target; it
shoots along a heading, and the only thing that supplies one is the new
right-hand stick via FireStickController. A fleet arriving from another scene
starts with its guns silent, which is the correct resting state.
A subtle dashed guide runs from the fleet along the aimed heading while the
stick is held, out to the guns' OWN range - read from a weapon that is
actually aboard, so the line can never promise reach the fleet does not have.
Layout: the two sticks take the bottom corners, the pivot pair is centred
between them at the bottom edge, and the laser and gas bomb sit on an arc up
and left of the fire stick where a thumb already is. Those four are now round
- IconButton grew a round_frame mode, and the in-world action controls use it
while panel and menu buttons stay square, so the two kinds of button read as
different kinds of thing.
Worth recording, because it cost a screenshot to find: a script-declared
property set BEFORE the `script = ExtResource(...)` line in a .tscn is
silently dropped. Godot's parser has no idea the property exists yet. The
round frames were all correctly set in both scenes and none of them applied.
Also fixed researtch_lab_garrison.tres, which was authored under the void
profile - that is the black hole surface, so a lab garrison was spawning on
black holes.
Suite 110/110. test_enemy_combat's whole premise was auto-fire; it now holds
the real stick toward the enemy rather than poking weapon components, so it
exercises the path the player uses. test_fire_stick is new and covers the
change itself: an idle stick leaves a target parked well inside range
untouched, which is precisely what the old gun would never have done.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:21 +02:00
|
|
|
decoy.position = ship.global_position + Vector2(weapon.fire_range * 0.33, 0.0)
|
|
|
|
|
main.add_child(decoy)
|
|
|
|
|
decoy.speed = 0.0
|
|
|
|
|
print("a target sits %.0f away, well inside the gun's %.0f range" \
|
|
|
|
|
% [ship.global_position.distance_to(decoy.position), weapon.fire_range])
|
|
|
|
|
stage = 1
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
1:
|
|
|
|
|
# --- A. idle stick, idle gun -----------------------------------
|
|
|
|
|
shots_while_idle = maxi(shots_while_idle, _projectile_count())
|
|
|
|
|
if elapsed < 1.5:
|
|
|
|
|
return false
|
|
|
|
|
if shots_while_idle != 0:
|
|
|
|
|
return _fail("the fleet fired %d shot(s) with the stick untouched - the gun is still aiming itself" \
|
|
|
|
|
% shots_while_idle)
|
|
|
|
|
print("1.5s with the stick untouched: no shots fired")
|
|
|
|
|
|
|
|
|
|
_push_stick(FIRE_DIRECTION)
|
|
|
|
|
if fire_stick.direction.normalized().distance_to(FIRE_DIRECTION) > 0.01:
|
|
|
|
|
return _fail("pushing the stick reported direction %s, expected %s" \
|
|
|
|
|
% [fire_stick.direction, FIRE_DIRECTION])
|
|
|
|
|
stage = 2
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
2:
|
|
|
|
|
# --- B. it fires, along the stick ------------------------------
|
|
|
|
|
if elapsed < 0.6:
|
|
|
|
|
return false
|
|
|
|
|
var projectiles: Array = get_nodes_in_group("player_projectiles")
|
|
|
|
|
if projectiles.is_empty():
|
|
|
|
|
return _fail("pushing the fire stick did not fire anything")
|
|
|
|
|
for projectile in projectiles:
|
|
|
|
|
var heading: Vector2 = projectile.velocity.normalized()
|
|
|
|
|
if heading.distance_to(FIRE_DIRECTION) > 0.05:
|
|
|
|
|
return _fail("a shot travelled along %s, but the stick was pushed %s" \
|
|
|
|
|
% [heading, FIRE_DIRECTION])
|
|
|
|
|
print("%d shot(s) fired, every one along the stick's heading" % projectiles.size())
|
|
|
|
|
|
|
|
|
|
# --- E. the aim guide ------------------------------------------
|
|
|
|
|
if controller.aim_direction == Vector2.ZERO:
|
|
|
|
|
return _fail("the aim guide has no heading while the stick is held")
|
|
|
|
|
if controller.aim_direction.normalized().distance_to(FIRE_DIRECTION) > 0.05:
|
|
|
|
|
return _fail("the guide points along %s but the stick was pushed %s" \
|
|
|
|
|
% [controller.aim_direction.normalized(), FIRE_DIRECTION])
|
|
|
|
|
var weapon: Node = fleet.ships[0].get_component("weapon")
|
|
|
|
|
if not is_equal_approx(controller.guide_range(), weapon.fire_range):
|
|
|
|
|
return _fail("the guide reaches %.0f but the gun shoots %.0f" \
|
|
|
|
|
% [controller.guide_range(), weapon.fire_range])
|
|
|
|
|
print("aim guide drawn along the stick, reaching the gun's own %.0f range" % weapon.fire_range)
|
|
|
|
|
|
|
|
|
|
_release_stick()
|
|
|
|
|
if fire_stick.direction != Vector2.ZERO:
|
|
|
|
|
return _fail("releasing the stick left it reporting %s" % fire_stick.direction)
|
|
|
|
|
# One frame for the controller to push the release through to the
|
|
|
|
|
# weapons, then clear the sky so what follows is unambiguous.
|
|
|
|
|
controller._process(0.0)
|
|
|
|
|
for projectile in get_nodes_in_group("player_projectiles"):
|
|
|
|
|
projectile.free()
|
|
|
|
|
stage = 3
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
return false
|
|
|
|
|
3:
|
|
|
|
|
# --- C. released means stopped ---------------------------------
|
|
|
|
|
if _projectile_count() != 0:
|
|
|
|
|
return _fail("the gun kept firing after the stick was released")
|
|
|
|
|
if elapsed < 1.2:
|
|
|
|
|
return false
|
|
|
|
|
if controller.is_firing():
|
|
|
|
|
return _fail("a weapon still reports itself firing after release")
|
|
|
|
|
if controller.aim_direction != Vector2.ZERO:
|
|
|
|
|
return _fail("the aim guide is still drawn after the stick was released")
|
|
|
|
|
print("1.2s after release: nothing fired")
|
|
|
|
|
|
|
|
|
|
# --- D. the stick claims only its own rect ---------------------
|
|
|
|
|
var outside := InputEventScreenTouch.new()
|
|
|
|
|
outside.index = 5
|
|
|
|
|
outside.pressed = true
|
|
|
|
|
outside.position = fire_stick.global_position - Vector2(40.0, 40.0)
|
|
|
|
|
if fire_stick.handle_touch(outside):
|
|
|
|
|
return _fail("the fire stick claimed a touch outside its own rect, which would swallow world gestures")
|
|
|
|
|
print("a touch outside the stick's rect falls through")
|
|
|
|
|
|
|
|
|
|
print("TEST_FIRE_STICK: PASS")
|
|
|
|
|
quit(0)
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
|
|
|
print("TEST_FIRE_STICK: FAIL (%s)" % message)
|
|
|
|
|
quit(1)
|
|
|
|
|
return true
|