mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
076d27a564
Playing with a gamepad did not work: all six move_* actions had no joypad event at all, so a pad could yaw/pitch/roll/turbo but could not translate. Nothing caught it because every action existed and the game booted fine — no assertion checked that an action is reachable on *both* devices. Controller layout, on the 6DOF convention (left stick aims, right stick translates), using all six of the pad's analog axes for the ship's six degrees of freedom: left stick yaw + pitch right stick strafe + vertical LB / RB roll RT / LT forward / back L3 turbo R3 ball camera Input is now read with Input.get_axis instead of is_action_pressed, so triggers and sticks are proportional. Keyboard values are unchanged. Three rotation bugs found by measuring a real Ship rather than reading the code: - apply_torque() is world-space and the torque was never rotated into the hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became pitch after a 90 degree turn and inverted at 180, so the controls were correct flying up-field and backwards flying back. - ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to every axis, giving yaw 7x the angular acceleration of pitch and roll (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so rotation_acceleration means rad/s^2 and all three axes match. Yaw is unchanged. - pitch_down pitched the nose UP: get_axis's arguments were reversed, so the I/K keys and the stick each did the opposite of their label. Menus were unusable on a pad for a separate reason: Godot 4.7 gives ui_up/down/left/right joypad events by default but leaves ui_accept and ui_cancel with none (verified against a pristine project), so a controller could move the highlight and never press anything. A confirms and B goes back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather than ui_cancel, so carrying B for menus cannot abandon a live match. Bindings for both devices are rebindable in Settings -> Controls, persisted to user://input.cfg — a separate file from settings.cfg because VideoSettings.save() rewrites that file wholesale and would drop any section it does not know about. project.godot stays the source of truth for defaults; overrides are only ever a delta on top of a boot-time snapshot. Verified: 268 unit tests, the ENet integration gate, and a 16-sample before/after comparison of networked prediction residuals showing the physics change does not regress them (median 0.083m -> 0.065m). Note for follow-up: every policy in Game/bots/ was trained against the old sluggish, world-axis rotation and will over-rotate until retrained.
178 lines
6.8 KiB
GDScript
178 lines
6.8 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
# Covers PlayerShipController's translation of input actions into a ShipAction.
|
|
#
|
|
# The point of most of these is the *analog* path. The controller used to read
|
|
# is_action_pressed(), which is a bool, so a half-pulled trigger and a fully
|
|
# pulled one produced identical full thrust. A test that only ever pressed
|
|
# actions at full strength could not tell the two implementations apart — so
|
|
# these press at fractional strength, which only the get_action_strength()
|
|
# version can reproduce.
|
|
#
|
|
# Input.action_press writes to the global input state, so every test must
|
|
# release what it pressed before returning or it leaks into later cases.
|
|
|
|
const ACTIONS_USED := [
|
|
"move_forward", "move_back", "move_left", "move_right", "move_up", "move_down",
|
|
"turn_left", "turn_right", "pitch_up", "pitch_down", "roll_left", "roll_right",
|
|
"turbo",
|
|
]
|
|
|
|
|
|
func _controller() -> PlayerShipController:
|
|
return PlayerShipController.new()
|
|
|
|
|
|
func _release_all() -> void:
|
|
for action in ACTIONS_USED:
|
|
Input.action_release(action)
|
|
|
|
|
|
func test_full_strength_matches_the_historical_digital_values() -> void:
|
|
# The keyboard path must be unchanged by the move to analog: a held key
|
|
# reports strength 1.0, so every axis lands on exactly ±1.
|
|
var controller := _controller()
|
|
|
|
Input.action_press("move_forward", 1.0)
|
|
Input.action_press("move_right", 1.0)
|
|
Input.action_press("move_up", 1.0)
|
|
var action := controller.get_action()
|
|
assert_almost_eq(action.thrust.z, 1.0, 0.001, "forward thrust")
|
|
assert_almost_eq(action.thrust.x, 1.0, 0.001, "right thrust")
|
|
assert_almost_eq(action.thrust.y, 1.0, 0.001, "up thrust")
|
|
_release_all()
|
|
|
|
Input.action_press("move_back", 1.0)
|
|
Input.action_press("move_left", 1.0)
|
|
Input.action_press("move_down", 1.0)
|
|
action = controller.get_action()
|
|
assert_almost_eq(action.thrust.z, -1.0, 0.001, "backward thrust")
|
|
assert_almost_eq(action.thrust.x, -1.0, 0.001, "left thrust")
|
|
assert_almost_eq(action.thrust.y, -1.0, 0.001, "down thrust")
|
|
_release_all()
|
|
|
|
|
|
func test_rotation_sign_conventions_are_unchanged() -> void:
|
|
# Each action must move the ship the way its NAME says. The physics
|
|
# directions were measured by driving a real Ship through ship.tscn rather
|
|
# than reasoned about, because the right-hand rule is exactly the kind of
|
|
# thing that reads as obvious and comes out backwards:
|
|
#
|
|
# rotation.x > 0 -> nose UP (torque about local +X)
|
|
# rotation.y > 0 -> nose LEFT (torque about local +Y)
|
|
# rotation.z > 0 -> banks LEFT (torque about local +Z)
|
|
#
|
|
# pitch was inverted against this for a long time — get_axis's arguments
|
|
# were the wrong way round, so "pitch_down" raised the nose and the I/K keys
|
|
# each did the opposite of their label. Nothing caught it because the sign
|
|
# was self-consistent everywhere it was used; only comparing against the
|
|
# physics reveals it.
|
|
var controller := _controller()
|
|
var restore := InputSettings.invert_pitch
|
|
InputSettings.invert_pitch = false
|
|
|
|
Input.action_press("turn_left", 1.0)
|
|
Input.action_press("pitch_up", 1.0)
|
|
Input.action_press("roll_left", 1.0)
|
|
var action := controller.get_action()
|
|
assert_almost_eq(action.rotation.y, 1.0, 0.001, "yaw left is positive")
|
|
assert_almost_eq(action.rotation.x, 1.0, 0.001, "pitch UP is positive (nose up)")
|
|
assert_almost_eq(action.rotation.z, 1.0, 0.001, "roll left is positive")
|
|
_release_all()
|
|
|
|
Input.action_press("turn_right", 1.0)
|
|
Input.action_press("pitch_down", 1.0)
|
|
Input.action_press("roll_right", 1.0)
|
|
action = controller.get_action()
|
|
assert_almost_eq(action.rotation.y, -1.0, 0.001, "yaw right is negative")
|
|
assert_almost_eq(action.rotation.x, -1.0, 0.001, "pitch DOWN is negative (nose down)")
|
|
assert_almost_eq(action.rotation.z, -1.0, 0.001, "roll right is negative")
|
|
_release_all()
|
|
|
|
InputSettings.invert_pitch = restore
|
|
|
|
|
|
func test_partial_strength_produces_partial_thrust() -> void:
|
|
# The analog assertion. A digital is_action_pressed() implementation would
|
|
# return 1.0 here and fail.
|
|
var controller := _controller()
|
|
|
|
Input.action_press("move_forward", 0.5)
|
|
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "half trigger is half thrust")
|
|
_release_all()
|
|
|
|
Input.action_press("move_up", 0.25)
|
|
assert_almost_eq(controller.get_action().thrust.y, 0.25, 0.001, "quarter deflection is quarter thrust")
|
|
_release_all()
|
|
|
|
Input.action_press("turn_left", 0.3)
|
|
assert_almost_eq(controller.get_action().rotation.y, 0.3, 0.001, "partial stick is partial yaw")
|
|
_release_all()
|
|
|
|
|
|
func test_opposing_inputs_subtract_rather_than_saturate() -> void:
|
|
# Both halves of one stick axis can report a strength at once; the result
|
|
# must be their difference, not whichever was read last.
|
|
var controller := _controller()
|
|
|
|
Input.action_press("move_forward", 0.75)
|
|
Input.action_press("move_back", 0.25)
|
|
assert_almost_eq(controller.get_action().thrust.z, 0.5, 0.001, "opposed thrust subtracts")
|
|
_release_all()
|
|
|
|
Input.action_press("move_forward", 0.4)
|
|
Input.action_press("move_back", 0.4)
|
|
assert_almost_eq(controller.get_action().thrust.z, 0.0, 0.001, "equal opposed thrust cancels")
|
|
_release_all()
|
|
|
|
|
|
func test_no_input_is_a_zero_action() -> void:
|
|
var controller := _controller()
|
|
_release_all()
|
|
var action := controller.get_action()
|
|
assert_eq(action.thrust, Vector3.ZERO, "idle thrust")
|
|
assert_eq(action.rotation, Vector3.ZERO, "idle rotation")
|
|
assert_true(not action.turbo, "idle turbo")
|
|
|
|
|
|
func test_invert_pitch_flips_only_the_pitch_axis() -> void:
|
|
var controller := _controller()
|
|
var restore := InputSettings.invert_pitch
|
|
|
|
Input.action_press("pitch_down", 1.0)
|
|
Input.action_press("turn_left", 1.0)
|
|
|
|
InputSettings.invert_pitch = false
|
|
var normal := controller.get_action().copy()
|
|
InputSettings.invert_pitch = true
|
|
var inverted := controller.get_action().copy()
|
|
|
|
assert_almost_eq(inverted.rotation.x, -normal.rotation.x, 0.001, "invert flips pitch")
|
|
assert_almost_eq(inverted.rotation.y, normal.rotation.y, 0.001, "invert leaves yaw alone")
|
|
|
|
InputSettings.invert_pitch = restore
|
|
_release_all()
|
|
|
|
|
|
func test_turbo_is_a_boolean() -> void:
|
|
var controller := _controller()
|
|
Input.action_press("turbo", 1.0)
|
|
assert_true(controller.get_action().turbo, "turbo held")
|
|
Input.action_release("turbo")
|
|
assert_true(not controller.get_action().turbo, "turbo released")
|
|
_release_all()
|
|
|
|
|
|
func test_the_returned_action_is_reused_between_ticks() -> void:
|
|
# get_action() documents that it returns a reused instance and overwrites
|
|
# every axis. Callers that keep an action past its tick must copy() it —
|
|
# local_input_timeline.gd and the prediction ring rely on that contract, so
|
|
# assert both halves of it.
|
|
var controller := _controller()
|
|
Input.action_press("move_forward", 1.0)
|
|
var first := controller.get_action()
|
|
_release_all()
|
|
var second := controller.get_action()
|
|
assert_true(first == second, "the same ShipAction instance is returned each tick")
|
|
assert_almost_eq(second.thrust.z, 0.0, 0.001, "releasing clears the axis rather than leaving it stale")
|