Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded infinite world: tap/joystick steering, pinch zoom, formation pivoting, planet landings, an ore economy with extractors and haulers, and player built structures (mothership, ship crafter, command centre). All art is hand-drawn with draw_* calls rather than textures, and the backgrounds are camera-relative shaders so they stay precise at large world coordinates. Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot tooling that renders real frames through Xvfb and Mesa softpipe, since --headless has no GPU. Excluded from version control: .godot/ (import cache, and it holds export credentials), builds/, android/build/ and packaged APKs - all regenerable, and ~325 MB combined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
313
GameController.gd
Normal file
313
GameController.gd
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
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")
|
||||
# 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")
|
||||
|
||||
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
|
||||
|
||||
var pivot_hold_amount: 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,
|
||||
}
|
||||
const KEY_PIVOT_CW: int = KEY_E
|
||||
const KEY_PIVOT_CCW: int = KEY_Q
|
||||
const WHEEL_ZOOM_STEP: float = 1.12
|
||||
|
||||
@onready var keyboard_hint: Label = get_node_or_null("UI/KeyboardHintLabel")
|
||||
|
||||
var keys_down: Dictionary = {}
|
||||
var keyboard_seen: bool = false
|
||||
var keyboard_steering: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if joystick != null:
|
||||
joystick.direction_changed.connect(_on_joystick_direction)
|
||||
if keyboard_hint != null:
|
||||
keyboard_hint.visible = false
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
func _note_keyboard() -> void:
|
||||
if keyboard_seen:
|
||||
return
|
||||
keyboard_seen = true
|
||||
if keyboard_hint != null:
|
||||
keyboard_hint.visible = true
|
||||
# The stick is only taken away where there is no touchscreen to work it
|
||||
# with. A touch device that also has a keyboard keeps both, and a device
|
||||
# wrongly reported as non-touch still shows the stick until the player
|
||||
# has demonstrably used a keyboard - so nobody is ever left with no way
|
||||
# to steer.
|
||||
if joystick != null and not DisplayServer.is_touchscreen_available():
|
||||
joystick.visible = false
|
||||
|
||||
|
||||
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.
|
||||
if pivot_hold_amount < PIVOT_MIN_STEP:
|
||||
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
|
||||
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()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_update_pivot(delta)
|
||||
|
||||
# Keep the heading fresh while a control is held. Fleet advances its own
|
||||
# anchor in _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.
|
||||
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)
|
||||
|
||||
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 _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 InputEventMouseButton and event.pressed:
|
||||
# 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:
|
||||
# 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 _handle_pivot_touch(event):
|
||||
return
|
||||
if joystick != null and joystick.visible and joystick.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:
|
||||
if joystick != null and joystick.visible and joystick.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)
|
||||
Loading…
Reference in a new issue