Compare commits

...

14 Commits

Author SHA1 Message Date
Josh Creek c02aad66a0 fix(ui): keep menu content reachable at any window size
The main menu clipped its own title and bottom button in debug builds. The
project lays out in a hard-fixed 1920x1080 logical viewport
(window/stretch/mode="viewport"), and the only overflow strategy in the
scene was a CenterContainer, which centres its child rather than clipping
and scrolling. With DevSection visible the content measures 1133px against
1080, so roughly 53px spilled off both ends with no way to reach it — and
main_menu.gd grabs focus on a button that may itself be off-screen.

Worth recording because it is counter-intuitive: this is not
resolution-dependent. Because the viewport is fixed, a 4K display magnifies
the same clipped 1080p frame rather than giving the menu more room, so the
fix has to make the layout scroll, not scale.

Each menu is now MarginContainer > ScrollContainer > CenterContainer >
VBoxContainer. ScrollContainer sizes its child to max(own size, child
minimum), so an expanding CenterContainer keeps today's centred look when
the content is short and grows past the viewport when it is tall — which is
exactly when scrolling should start. follow_focus is on so keyboard and
controller navigation cannot strand focus off-screen. Lobby and matchmaking
share the same shape and get the same treatment before they hit the same
wall; settings gained its wrapper alongside the Controls tab.

Also stops the dev bot dropdowns widening the whole menu: they are filled
from res://bots filenames and expand horizontally, so a long checkpoint
name dragged the layout past its 420px minimum.

test_menu_layout asserts each screen's bottom-most control really sits
inside a ScrollContainer. That is a structural guard against the wrapper
being removed or a new section being added outside it — not proof that
nothing visually clips, which was checked by hand at 1000x600, 1280x720 and
1920x1080.
2026-09-06 20:41:41 +01:00
Josh Creek 076d27a564 feat(input): full controller support, rebindable controls, and rotation fixes
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.
2026-09-06 20:41:20 +01:00
Josh Creek 00b900d864 Merge pull request #30 from jcreek/feat/multiplayer
Feat/multiplayer
2026-09-06 10:58:50 +01:00
Josh Creek fe453ab607 docs: add a version inventory to TECH_STACK.md, refresh stale claims
TECH_STACK.md explained why each choice was made but never listed what is
actually pinned, so there was no single place to answer "what version of X do
we use". Adds a Version inventory section covering the shipped game, the Go
control plane's four direct dependencies, the datastore/platform versions and
the exactly-pinned training stack, plus a Verification toolchain subsection for
the Make/Docker/kind/Kustomize/Actions harness.

Also corrects two things the doc had outgrown: the allocation pipeline is now
wired end to end and gated in CI, so only the provider deployment remains; and
the Steam section covered only the GodotSteam client transport, omitting the
server-side Web API ticket verifier in server/steam.
2026-09-05 23:16:10 +01:00
Josh Creek 52cc478b38 docs: record the assertion-first debugging habit, and refresh CLAUDE.md
Adds the lesson this branch paid for repeatedly: the expensive failures
were not broken behaviour but assertions that could not distinguish the
two states they implicitly claimed to, each reporting its own ambiguity
as a confident verdict about the system under test. Waiting on a Fleet
field that does not exist, treating not-yet-started as exited, a p99
that conflated regression with scheduler noise, a validator reading a
response shape Agones never sends, and a build guard that verified stale
code. Five in one session, several costing multiple CI round trips.

Two habits go with it, both of which beat reading code every time they
were tried: make the script report what it saw before theorising about
why, and verify the diagnostics actually fire -- two dumps were added
here and neither ran, one suppressed by a reachability guard and one by
an ERR trap that cannot fire inside functions without errtrace.

Also fixes two stale claims and one gap. Audio is no longer "none at
all"; a procedural AudioManager covers UI, countdown, impact, goal and
engine cues, and only authored assets remain. Five docs/ contracts that
server/security asserts against the manifests were unlisted. And the Go
control plane -- a third of the codebase and the current focus -- had no
structural description at all, so it now gets one: package layout, which
binary is test-only, and the three things easiest to get wrong
(integration tests hidden behind a build tag, start-time config, the
versioned wire contract).

TODO.md's entry now points at its ordered backlog rather than describing
it as deferred non-multiplayer work.

Every factual claim in the new section was checked against the tree.
2026-09-05 23:10:06 +01:00
Josh Creek 4ea72be581 fix(compose): stop calling a still-starting game server dead
Allocated Compose failed on a docs-only commit, so nothing functional
had changed. Its own diagnostics showed why: the game server logged a
clean `server_started` -- the exact string the readiness loop waits for
-- and the script reported "game server exited before becoming ready".

The guard asked whether the service was absent from
`compose ps --status running`, which is also true of a container that
has been created but has not started yet. On a slow runner the first
poll can land in that window, and the script concluded the server was
dead when it was still coming up. Ask whether it actually exited
instead.

Also set errtrace. This failure produced no "failed at line N" report
despite the ERR trap added in 432e5a11, because a bare `trap ... ERR`
does not fire inside functions or subshells without it -- the
instrumentation had a blind spot exactly where a readiness loop lives.

The other --status running check, after an explicit `compose stop`, is
correct and unchanged: stop is synchronous, so absence there really does
mean stopped.

Verified by two consecutive local runs.
2026-09-05 23:00:03 +01:00
Josh Creek 4560d2de8a docs: say what order the outstanding work goes in
The backlog listed what is left but not what to do first, and priority
labels do not answer that: #33 is P2 yet belongs before the P0 cluster,
because standing the cluster up first means migrating a running one
afterwards.

Add an explicit ordering to TODO.md in three parts -- a critical path
where each item unblocks the next, a Steam track that runs in parallel
and should start early because its lead time is Valve's, and the set
that is unblocked today and waiting on nobody. The playtests, training
runs and asset work need no cluster and could start now, which was not
obvious from a flat list sorted by priority.

#31 is called out as the highest-leverage item: two answers unblock the
whole of Phase 8, and the work behind them is an agent's.

Two open issues were in no list at all -- #23's design question and
#32's backfill work -- so TODO.md now covers every open issue.

Also record the dependency direction on GitHub rather than only here:
#17, #32 and #33 carried no blocked-by statement, so the graph was
invisible from the issue tracker. And note in §7 task 8.12 why the
workload namespace enforces privileged and where the split is tracked.
2026-09-05 22:50:26 +01:00
Josh Creek 4912837dd7 docs: narrow what #31 actually needs from a person
The issue read as broadly human-gated. Most of it is not: GHCR accepts
the built-in GITHUB_TOKEN with packages: write for the repository's own
namespace, so publishing needs no account, stored secret or spend
approval, and signing and tagging policy can land as a reviewable
default rather than waiting on a decision.

Two things genuinely block. Every manifest references
ghcr.io/cosmic-clash/*, and no such organisation exists -- the API
returns 404 and it is not among this account's orgs -- so nothing can be
pushed there. And this repository is private, so GHCR packages inherit
that, while no manifest declares imagePullSecrets; public packages work
as written, private ones need pull secrets threaded through every
workload.

Same optimistic-to-pessimistic drift the §7 audit found in fifteen other
entries: work described as blocked on a person when the person only owes
a decision.
2026-09-05 22:45:26 +01:00
CosmicClash Training Bot 6983ddd7df chore(training): generation 5 progress after 20260903-1146-gen5-s6-league-retry3 2026-09-05 01:01:25 +01:00
CosmicClash Training Bot 7d247bc516 chore(training): Add 20260903-1146-gen5-s6-league-retry3 checkpoints, logs, and exported policy 2026-09-05 00:55:28 +01:00
CosmicClash Training Bot 4519f2db82 chore(training): generation 5 progress after 20260901-2301-gen5-s6-league-retry2 2026-09-03 11:46:05 +01:00
CosmicClash Training Bot 0f1a7403e4 chore(training): Add 20260901-2301-gen5-s6-league-retry2 checkpoints, logs, and exported policy 2026-09-03 11:40:08 +01:00
CosmicClash Training Bot 8a55c33666 chore(training): generation 5 progress after 20260831-0724-gen5-s6-league-retry1 2026-09-01 23:01:35 +01:00
CosmicClash Training Bot ad6f9cc148 chore(training): Add 20260831-0724-gen5-s6-league-retry1 checkpoints, logs, and exported policy 2026-09-01 22:55:32 +01:00
41 changed files with 2648 additions and 193 deletions
+90 -2
View File
@@ -6,7 +6,7 @@ Important rule: never create co-authored commits. Never mention Claude in commit
## Project overview
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, and a live Agones cluster), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Cosmic Clash is an open-source, physics-based "vehicle soccer" game (a spiritual successor to Rocket League) built in Godot 4.7, using space ships instead of cars. The game and the dedicated server are GDScript/Godot only — the "C# backend" an early README described was never started, and the dedicated server is an export of this same Godot project. There is one component outside the Godot project: a **Go matchmaking control plane** in `server/` for casual/ranked queues, ranked ratings and Agones-based server allocation. It is a 1.0 launch blocker — see `docs/MATCHMAKING.md` for the design, `multiplayer-next.md` §0 and §7 for what remains (the allocation-to-connect pipeline is now wired end to end; what is left is external — a Steamworks App ID, custom GodotSteam builds, a registry to publish images to, and a live Agones cluster; `TODO.md` orders them), and `docs/TECH_STACK.md` for why the control plane is Go rather than C#, Rust or C++. README.md's "MVP is local-only against bots" section is historical: server-authoritative online multiplayer, a headless dedicated server, Docker/CI verification, and an optional Steam transport are all implemented (Phases 16). See `multiplayer-next.md` for what actually remains.
Because the gameplay concept (vehicle soccer) can't be copyrighted but specific expression can, all code/art/assets must be original — this is why the project uses Godot instead of Unreal/Unity and ships instead of cars. Keep this in mind when writing code or pulling in assets: don't port or closely mirror Rocket League's actual implementation.
@@ -22,7 +22,9 @@ The prose docs carry far more design rationale than the code comments, and sever
- `FLIGHT_MANUAL.md` — the player-facing flight model.
- `docs/MATCHMAKING.md` — casual/ranked queue design, and the locked constraints (Go/PostgreSQL/Redis/Agones) the `server/` module implements. Partially implemented; a 1.0 launch blocker, and the reason a backend service outside the Godot project exists at all.
- `docs/TECH_STACK.md` — what the project is built with and why, including the Go-vs-C#/Rust/C++ rationale for the matchmaking control plane.
- `TODO.md` — deferred non-multiplayer work (audio is the big one: there is none at all).
- `TODO.md` — deferred non-multiplayer work, **and** the ordered human-actionable backlog: which GitHub issue to do first, what each one unblocks, and which items are waiting on nobody. Start there when asking "what next". Audio is no longer absent — a procedural `AudioManager` covers UI, countdown, impact, goal and engine/turbo cues; what remains is authored assets.
- `docs/THREAT-MODEL.md`, `docs/SUPPLY-CHAIN.md`, `docs/OBSERVABILITY.md`, `docs/MATCHMAKING-SLOs.md`, `docs/ADR-001-matchmaking-platform.md` — the control plane's security, release, telemetry and SLO contracts. `server/security/*.py` asserts several of them against the checked-in manifests, so changing a manifest often means changing one of these.
- `docs/REVIEW-2026-09-feat-multiplayer.md` — a point-in-time adversarial review of this branch. Every finding in it is fixed; it is kept for the reasoning, not as a status report, and its header says so.
## Godot MCP server
@@ -149,6 +151,46 @@ To run one by hand, and for every config flag, see `SERVER.md`. `--smoke-force-g
Note what is *not* in CI: `make verify-multiplayer-local` (the combined local gate, which also runs the Python manifest/contract suites) and the `integration`-tagged Go tests, which need a real PostgreSQL/Redis and live in `scripts/run_*_integration.sh`. Run those by hand before landing server changes.
### When a gate fails, suspect the assertion first
The most expensive failures in this repo have not been broken behaviour. They
have been **assertions that cannot distinguish the two states they implicitly
claim to**, each reporting its own ambiguity as a confident verdict about the
system under test. Five in one session, several costing multiple CI round trips:
| Assertion | What it actually conflated |
|---|---|
| `kubectl wait --for=jsonpath='{.status.ready}'` on an Agones Fleet | field does not exist vs. condition unmet — it could never pass |
| `compose ps --status running \| grep -qx game-server` | not started *yet* vs. exited |
| `remote_residual_position_p99 < 0.3` | real regression vs. host scheduling noise |
| a validator reading `status.gameServer` | Agones' real response vs. an invented one, with unit tests asserting the invention |
| `docker image inspect` guarding a build | image is current vs. image merely exists, so a rerun verified stale code |
Before theorising about the code, ask: **can this check tell "broken" from
"not ready yet", "absent" from "unset", or "regressed" from "slow"?** If not,
that is the bug, whatever else is also true.
Two habits follow from it, and both repeatedly beat reading code:
- **Make the script say what it saw before diagnosing why.** Most gates here are
`curl -fsS` and bare `[[ ]]` under `set -e`, which abort silently — several CI
runs produced nothing but `make: *** Error 1`. Report the failing line and
command, print the value that failed its comparison, and dump the surrounding
state *before* any cleanup trap destroys it. Every root cause found in that
session came from doing this; essentially every confident guess made without
it was wrong.
- **Verify the diagnostics fire.** Two separate dumps were added and neither ran:
one behind a `kubectl cluster-info` guard that misjudged reachability, one
because a bare `trap ... ERR` does not fire inside functions or subshells
without `set -E`. A diagnostic that has never been seen working is not
evidence.
And when a test and the code agree but reality disagrees, suspect they were
written together. A validator and its fixtures both encoded a response shape
Agones never sends; nothing caught it because the gate had never run far enough
to see a real one.
### Other
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
@@ -199,6 +241,52 @@ Server process: `scenes/server_boot.tscn` (`server_boot.gd`) is the shell — st
Known-insecure, and the reason public hosting is gated: **slot reclaim is keyed by display name**, so anyone who knows a disconnected player's name can take their reserved slot. Verified Steam identity (Phase 7) is the fix. Don't expose a server to strangers before then.
### Matchmaking control plane (`server/`, Go)
The only component outside the Godot project, and roughly a third of the
codebase. Layered so policy is testable without a database and persistence
without a network:
- `domain/` (~3.2k lines) — **pure policy, no I/O**: matcher formation and
rating tolerance, Glicko ratings and tiers, proposal/queue/match state
machines, casual lineup and backfill selection, probe validation, join
authorisations. Most behaviour worth asserting lives here and needs no
fixture. `ranked.go`'s arena list is checked against `arena_registry.gd` (see
Arena registry above).
- `store/` (~5.3k) — PostgreSQL boundaries. Every mutation goes through
`RunSerializable`; contention is expected rather than exceptional, so the
retry budget and jittered backoff there are load-bearing, not decoration.
- `api/` (~2.5k) — HTTP surface and the outbox dispatchers. `Service` is a
struct of optional providers, each nil-guarded into a 503, which is why a
binary can look healthy while a whole feature is unreachable — check what
`cmd/*/main.go` actually assigns before concluding a feature is broken.
- `allocator/`, `supervisor/`, `agones/` — allocation, the Go process that
wraps the exported Godot server in an Agones pod, and the Agones client.
- `matcher/`, `workload/`, `observability/`, `steam/`, `testkit/` — the matcher
worker loop, workload-token signing, metrics, the Steam Web API adapter, and
deterministic offline fakes.
`cmd/` holds seven binaries: `control-plane`, `matcher`, `allocator`,
`maintenance`, `game-server-supervisor`, `migrate`, and `testkit-api`.
**`testkit-api` is test-only** — it injects a fake Steam login that accepts any
ticket, and must never be deployed in place of `control-plane`.
Three things that are easy to get wrong:
- **Integration tests are behind `//go:build integration`** and need a real
PostgreSQL/Redis, so `go test ./...` silently skips them. Run them through
`scripts/run_*_integration.sh`, which start their own disposable containers.
`go vet -tags integration ./...` is worth running too, or those files rot
uncompiled.
- **Config is start-time.** Tier bands, the join-signing key set, Steam
credentials and the probe providers are all read once in `main()`. Changing
them is a rolling restart, not a hot reload — deliberate, and consistent with
how everything else in these binaries is supplied.
- **The wire contract is versioned.** `contracts/v1/openapi.json` and
`state-transitions.json` are asserted by `contracts/v1/test_contracts.py`;
changing a status code or operation ID without updating them breaks generated
clients silently.
### Steam transport
`net_transport.gd` (`NetTransport`) is a deliberately narrow boundary: a transport only *creates a peer*; `NetworkManager` keeps ownership of polling, RPC policy and lifecycle. `enet_transport.gd` and `steam_transport.gd` implement it. `NetworkManager.host()/join()` default to `"enet"`; passing `"steam"` **never falls back** — a missing custom build or failed init returns an error naming the missing prerequisite (`steam_bootstrap.gd` produces those messages). Discovery and server advertisement are intentionally unimplemented until a project-owned App ID exists; the local default is Valve's Spacewar App ID 480, which must never be used to advertise servers or ship.
+52 -12
View File
@@ -27,18 +27,58 @@ Welcome to space, pilot! This guide will teach you everything you need to know a
### Controller Controls
#### Translation
Button names are the Xbox layout; a PlayStation pad maps the same physical
positions (A = ✕, B = ○, X = □, Y = △).
- Left Stick - Strafe (Left/Right) + Thrust (Forward/Back)
- Right Trigger - Forward Thrust
- Left Trigger - Reverse Thrust
- Face Buttons - Up/Down Thrust
**The left stick points the nose, the right stick moves the hull.** Your ship has
six degrees of freedom and a pad has exactly six analog axes, so every one gets a
real axis rather than an on/off button.
#### Rotation
#### Rotation — left stick and shoulders
- Right Stick - Pitch/Yaw
- Shoulder Buttons - Roll
- `A Button` - Turbo Boost
- Left Stick (left/right) - Yaw
- Left Stick (up/down) - Pitch. Flight-sim polarity by default: **push the
stick forward and the nose goes down.** Flip it with "Invert pitch" in
Settings → Controls.
- `LB` - Roll Left (Bank Left)
- `RB` - Roll Right (Bank Right)
#### Translation — right stick and triggers
- `RT` - Forward Thrust (Main Engines)
- `LT` - Reverse Thrust (Retro Engines)
- Right Stick (left/right) - Strafe (Port/Starboard Thrusters)
- Right Stick (up/down) - Thrust Up/Down (Dorsal/Ventral Thrusters)
- `L3` (click the left stick) - Turbo Boost
`X` and `Y` are deliberately unused in flight, and `A`/`B` are menu-only, so a
reflexive face-button press never does anything mid-match.
#### Menus
- D-Pad or Left Stick - move the highlight
- `A` - select
- `B` - back
- `Start` - leave a match in progress (deliberately not `B`, which is too easy
to press by accident mid-game)
#### Other
- `R3` (click the right stick) - Toggle ball camera
- `D-Pad Up` - Reset the ball (Free Play only)
The triggers and sticks are **analog**: a half-pulled trigger gives half thrust,
and a gentle stick lean gives a gentle turn. Keyboard keys are all-or-nothing,
which is the main reason a pad is easier to fly precisely.
### Rebinding
Every control above — keyboard and controller alike — can be remapped in
**Settings → Controls**. Pick the device with the Keyboard/Controller toggle,
click the binding you want to change, and press the key or button to assign.
Binding an input that is already in use unbinds it from the action that had it,
and the screen tells you which. "Reset all bindings to defaults" restores this
table.
## 🛸 Basic Flight Principles
@@ -69,7 +109,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
#### Camera Control
- **Ball Cam**: Press `Enter` to toggle ball tracking camera
- **Ball Cam**: Press `Space` (or `R3` on a controller) to toggle ball tracking camera
- **Ship Cam**: Normal follow camera that looks where your ship points
### Intermediate Maneuvers
@@ -115,7 +155,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
## 🎮 Ball Cam vs Ship Cam
### Ball Cam Mode (`Enter` to toggle)
### Ball Cam Mode (`Space` / `R3` to toggle)
- **Camera**: Always looks toward the ball
- **Ship Control**: Based on ship orientation (NOT camera view)
@@ -133,7 +173,7 @@ Your ship is a **realistic space vehicle** with the following characteristics:
### Turbo System
- **Activation**: Hold `Shift` (keyboard) or `A` (controller) while thrusting forward
- **Activation**: Hold `Shift` (keyboard) or `L3` (controller) while thrusting forward
- **Effect**: 2.5x thrust multiplier on main engines only
- **Strategy**: Use for quick acceleration or emergency maneuvers
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37 -3
View File
@@ -28,6 +28,7 @@ run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
GameSettings="*res://scripts/game_settings.gd"
ControlPlaneClient="*res://scripts/control_plane_client.gd"
VideoSettings="*res://scripts/video_settings.gd"
InputSettings="*res://scripts/input_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
NetSim="*res://scripts/net_sim.gd"
@@ -55,42 +56,49 @@ enabled=PackedStringArray("res://addons/godot_rl_agents/plugin.cfg")
reset_ball={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
]
}
move_forward={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
]
}
move_back={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
]
}
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
]
}
move_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
]
}
move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
]
}
turbo={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":7,"pressure":0.0,"pressed":false,"script":null)
]
}
turn_left={
@@ -108,13 +116,13 @@ turn_right={
pitch_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":73,"key_label":0,"unicode":105,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
]
}
pitch_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
]
}
roll_left={
@@ -129,6 +137,32 @@ roll_right={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
toggle_ball_cam={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":8,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_accept={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194310,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
leave_gameplay={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
toggle_perf_overlay={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+35 -21
View File
@@ -13,26 +13,40 @@ grow_vertical = 2
script = ExtResource("1_lobby")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 40
text = "Lobby"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
@@ -41,74 +55,74 @@ text = "Connecting..."
horizontal_alignment = 1
autowrap_mode = 2
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
[node name="TeamsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="TeamsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
[node name="Team0Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
[node name="Team0Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 1"
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
[node name="Team0List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
[node name="TeamsVSeparator" type="VSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
[node name="Team1Panel" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
[node name="Team1Header" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 2"
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
[node name="Team1List" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
[node name="ControlsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="ControlsRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 10
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
[node name="SwitchTeamButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Switch Team"
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
[node name="ReadyButton" type="CheckButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Ready"
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="LeaveButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Leave"
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
[connection signal="toggled" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
+66 -51
View File
@@ -13,124 +13,139 @@ grow_vertical = 2
script = ExtResource("1_menu")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
custom_minimum_size = Vector2(420, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 48
text = "Cosmic Clash"
horizontal_alignment = 1
[node name="SubtitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="SubtitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "Physics-based soccer in space"
horizontal_alignment = 1
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
[node name="TitleSpacer" type="Control" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
layout_mode = 2
[node name="FreePlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="FreePlayButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Free Play"
[node name="FreePlayHint" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="FreePlayHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "Solo practice — no timer, R resets the ball"
horizontal_alignment = 1
[node name="ArenaRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="ArenaRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ArenaLabel" type="Label" parent="CenterContainer/VBoxContainer/ArenaRow"]
[node name="ArenaLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
layout_mode = 2
text = "Arena"
[node name="ArenaDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/ArenaRow"]
[node name="ArenaDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ArenaRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="MatchSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
[node name="MatchSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="MatchHeader" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="MatchHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Match"
[node name="MatchHint" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="MatchHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "A 2:30 match — you vs a trained bot"
[node name="MatchRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="MatchRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="DifficultyLabel" type="Label" parent="CenterContainer/VBoxContainer/MatchRow"]
[node name="DifficultyLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
layout_mode = 2
text = "Difficulty"
[node name="DifficultyDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/MatchRow"]
[node name="DifficultyDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="MatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="MatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Play Match"
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
[node name="MultiplayerSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="MultiplayerHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Multiplayer"
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="MultiplayerHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "LAN / direct IP — host a match or join one"
[node name="FindMatchButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="FindMatchButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Find Match"
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="HostButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Host"
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="JoinRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
[node name="JoinAddressEdit" type="LineEdit" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
@@ -138,12 +153,12 @@ size_flags_horizontal = 3
text = "127.0.0.1"
placeholder_text = "IP address"
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
[node name="JoinButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow"]
custom_minimum_size = Vector2(96, 40)
layout_mode = 2
text = "Join"
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="MultiplayerErrorLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 0.5, 0.5, 1)
layout_mode = 2
@@ -152,82 +167,82 @@ text = ""
autowrap_mode = 2
visible = false
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
[node name="SettingsSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="SettingsButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="SettingsButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Settings"
[node name="DevSection" type="VBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="DevSection" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 10
[node name="DevSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="DevSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
[node name="DevHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="DevHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Developer"
[node name="DevHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="DevHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "Dev-only — hidden in release builds"
[node name="DevOpponentRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="DevOpponentRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="DevOpponentLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
[node name="DevOpponentLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
layout_mode = 2
text = "Opponent override"
[node name="DevBotDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
[node name="DevBotDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/DevOpponentRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="SpectateSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="SpectateSeparator" type="HSeparator" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
[node name="SpectateHeader" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="SpectateHeader" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Spectate"
[node name="SpectateHint" type="Label" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="SpectateHint" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "Watch two bots play each other"
[node name="SpectateRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="SpectateRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="BotADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
[node name="BotADropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="VsLabel" type="Label" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
[node name="VsLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
layout_mode = 2
text = "vs"
[node name="BotBDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/DevSection/SpectateRow"]
[node name="BotBDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="SpectateButton" type="Button" parent="CenterContainer/VBoxContainer/DevSection"]
[node name="SpectateButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection"]
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Watch Match"
@@ -279,12 +294,12 @@ custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Cancel"
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/FindMatchButton" to="." method="_on_find_match_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
[connection signal="text_submitted" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
+32 -18
View File
@@ -13,45 +13,59 @@ grow_vertical = 2
script = ExtResource("1_matchmaking")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer"]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer"]
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 40
text = "Find a Match"
horizontal_alignment = 1
[node name="PlaylistDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer"]
[node name="PlaylistDropdown" type="OptionButton" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="StatusLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Ready to search"
horizontal_alignment = 1
[node name="DetailLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="DetailLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
autowrap_mode = 2
horizontal_alignment = 1
[node name="RankedProfileLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="RankedProfileLabel" type="Label" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
@@ -59,24 +73,24 @@ text = "Ranked profile unavailable"
horizontal_alignment = 1
visible = false
[node name="QueueButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="QueueButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 52)
layout_mode = 2
text = "Search"
[node name="CancelButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="CancelButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Cancel Search"
visible = false
[node name="ProposalRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="ProposalRow" type="HBoxContainer" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="AcceptButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
[node name="AcceptButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
@@ -84,7 +98,7 @@ size_flags_horizontal = 3
text = "Accept"
visible = false
[node name="DeclineButton" type="Button" parent="CenterContainer/VBoxContainer/ProposalRow"]
[node name="DeclineButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
@@ -92,14 +106,14 @@ size_flags_horizontal = 3
text = "Decline"
visible = false
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="BackButton" type="Button" parent="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Back"
[connection signal="pressed" from="CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/QueueButton" to="." method="_on_queue_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/CancelButton" to="." method="_on_cancel_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/AcceptButton" to="." method="_on_accept_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/ProposalRow/DeclineButton" to="." method="_on_decline_pressed"]
[connection signal="pressed" from="MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
+129 -45
View File
@@ -1,7 +1,8 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://scripts/settings_menu.gd" id="1_settings"]
[ext_resource type="Theme" path="res://themes/cosmic_clash_theme.tres" id="2_theme"]
[ext_resource type="Script" path="res://scripts/controls_settings.gd" id="3_controls"]
[node name="SettingsMenu" type="Control"]
layout_mode = 3
@@ -13,69 +14,88 @@ grow_vertical = 2
script = ExtResource("1_settings")
theme = ExtResource("2_theme")
[node name="CenterContainer" type="CenterContainer" parent="."]
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(420, 0)
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
[node name="TitleLabel" type="Label" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 36
text = "Settings"
horizontal_alignment = 1
[node name="TitleSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
tab_alignment = 1
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="Video" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer"]
custom_minimum_size = Vector2(420, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
[node name="PresetRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="PresetLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Graphics preset"
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
[node name="PresetDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="AARow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="AALabel" type="Label" parent="CenterContainer/VBoxContainer/AARow"]
[node name="AALabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Anti-aliasing"
[node name="AADropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/AARow"]
[node name="AADropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="ResolutionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Resolution scale"
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -86,23 +106,23 @@ max_value = 1.0
step = 0.05
value = 1.0
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
[node name="ResolutionValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="GlowRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="GlowLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Glow intensity"
[node name="GlowSlider" type="HSlider" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -113,23 +133,23 @@ max_value = 1.5
step = 0.05
value = 1.0
[node name="GlowValueLabel" type="Label" parent="CenterContainer/VBoxContainer/GlowRow"]
[node name="GlowValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="BrightnessRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="BrightnessRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="BrightnessLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Brightness"
[node name="BrightnessSlider" type="HSlider" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
@@ -140,72 +160,136 @@ max_value = 1.3
step = 0.02
value = 1.0
[node name="BrightnessValueLabel" type="Label" parent="CenterContainer/VBoxContainer/BrightnessRow"]
[node name="BrightnessValueLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="VsyncRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
[node name="VsyncLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "VSync"
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
[node name="VsyncDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="FpsCapRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
[node name="FpsCapLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "FPS cap"
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
[node name="FpsCapDropdown" type="OptionButton" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
[node name="FpsReadoutRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
[node name="FpsReadoutTitleLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Current"
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
[node name="FpsReadoutLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsReadoutRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "0 fps"
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
[node name="Controls" type="ScrollContainer" parent="MarginContainer/VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
follow_focus = true
horizontal_scroll_mode = 0
script = ExtResource("3_controls")
[node name="BackButton" type="Button" parent="CenterContainer/VBoxContainer"]
[node name="CenterContainer" type="CenterContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer"]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="DeviceRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="DeviceLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
text = "Device"
[node name="KeyboardButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
toggle_mode = true
button_pressed = true
text = "Keyboard"
[node name="ControllerButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer/DeviceRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
toggle_mode = true
text = "Controller"
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 0.85, 0.5, 1)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = ""
autowrap_mode = 2
[node name="BindingList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
[node name="InvertPitchCheck" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
text = "Invert pitch"
[node name="ResetButton" type="Button" parent="MarginContainer/VBoxContainer/TabContainer/Controls/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 44)
layout_mode = 2
text = "Reset all bindings to defaults"
[node name="BackButton" type="Button" parent="MarginContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Back"
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
[connection signal="value_changed" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
[connection signal="item_selected" from="MarginContainer/VBoxContainer/TabContainer/Video/CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
[connection signal="pressed" from="MarginContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
+188
View File
@@ -0,0 +1,188 @@
extends ScrollContainer
# Settings screen's Controls tab: rebinds every action in InputSettings.ACTIONS
# for either device, toggles invert-pitch, and resets to the project.godot
# defaults. InputSettings owns the bindings themselves and their persistence;
# this script is only the editor for them, and deliberately keeps
# settings_menu.gd video-only.
#
# Rows are built in code rather than laid out in settings.tscn so the list stays
# derived from InputSettings.ACTIONS — adding a rebindable action means editing
# that one const, not this scene as well.
# A joypad axis has to travel this far before a capture accepts it. Resting
# stick drift is routinely a few percent off centre and would otherwise bind
# itself the instant the player opened a capture.
const AXIS_CAPTURE_THRESHOLD := 0.5
@onready var keyboard_button: Button = %KeyboardButton
@onready var controller_button: Button = %ControllerButton
@onready var status_label: Label = %StatusLabel
@onready var binding_list: VBoxContainer = %BindingList
@onready var invert_pitch_check: CheckBox = %InvertPitchCheck
@onready var reset_button: Button = %ResetButton
var _device: String = InputSettings.DEVICE_KEYBOARD
# The action currently awaiting an input event, or "" when not capturing.
var _capturing: String = ""
# action -> the row's Button, so a rebuild-free label refresh is possible and
# so capture can restore the right button's text on cancel.
var _row_buttons: Dictionary = {}
func _ready() -> void:
keyboard_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_KEYBOARD))
controller_button.pressed.connect(_on_device_selected.bind(InputSettings.DEVICE_JOYPAD))
invert_pitch_check.toggled.connect(_on_invert_pitch_toggled)
reset_button.pressed.connect(_on_reset_pressed)
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_update_device_buttons()
_rebuild_rows()
func _on_device_selected(device: String) -> void:
_cancel_capture()
_device = device
_update_device_buttons()
_rebuild_rows()
func _update_device_buttons() -> void:
keyboard_button.button_pressed = _device == InputSettings.DEVICE_KEYBOARD
controller_button.button_pressed = _device == InputSettings.DEVICE_JOYPAD
func _rebuild_rows() -> void:
for child in binding_list.get_children():
child.queue_free()
_row_buttons.clear()
var last_group := ""
for entry in InputSettings.ACTIONS:
var group: String = entry["group"]
if group != last_group:
last_group = group
binding_list.add_child(_make_group_header(group))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var label := Label.new()
label.text = entry["label"]
label.custom_minimum_size = Vector2(200, 0)
row.add_child(label)
var button := Button.new()
var action: String = entry["action"]
button.text = InputSettings.binding_text(action, _device)
button.custom_minimum_size = Vector2(0, 36)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.clip_text = true
button.pressed.connect(_begin_capture.bind(action))
row.add_child(button)
_row_buttons[action] = button
binding_list.add_child(row)
# Re-bound after every rebuild: the rows above are new nodes each time, so
# the buttons AudioManager was tracking no longer exist.
AudioManager.bind_tree_buttons(self)
func _make_group_header(group: String) -> Label:
var header := Label.new()
header.text = group
header.add_theme_font_size_override("font_size", 18)
header.modulate = Color(1, 1, 1, 0.7)
return header
func _begin_capture(action: String) -> void:
_cancel_capture()
_capturing = action
var button: Button = _row_buttons[action]
button.text = "Press a key…" if _device == InputSettings.DEVICE_KEYBOARD else "Press a button…"
status_label.text = "Listening — press Escape to cancel."
func _cancel_capture() -> void:
if _capturing == "":
return
var action := _capturing
_capturing = ""
if _row_buttons.has(action) and is_instance_valid(_row_buttons[action]):
_row_buttons[action].text = InputSettings.binding_text(action, _device)
status_label.text = ""
# _input rather than _unhandled_input: the row Button has focus while capturing,
# and an unhandled-input handler would never see the key that Button consumes as
# its own activation. Everything consumed here is marked handled so the pending
# event cannot also re-press that button and re-enter capture.
func _input(event: InputEvent) -> void:
if _capturing == "":
return
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_cancel_capture()
return
var captured := _capturable_event(event)
if captured == null:
return
get_viewport().set_input_as_handled()
var action := _capturing
_capturing = ""
var displaced := InputSettings.set_binding(action, captured)
_rebuild_rows()
if displaced.is_empty():
status_label.text = ""
else:
status_label.text = "Unbound %s — it was using the same input." % ", ".join(_labels_for(displaced))
# Returns the event to bind, or null if this event is not a legal binding for
# the device kind currently being edited. Keeping the check here means a joypad
# press can never land in the keyboard column just because that tab was open.
func _capturable_event(event: InputEvent) -> InputEvent:
if _device == InputSettings.DEVICE_KEYBOARD:
if event is InputEventKey and event.pressed and not event.echo:
var key := InputEventKey.new()
key.physical_keycode = event.physical_keycode
return key
return null
if event is InputEventJoypadButton and event.pressed:
var button := InputEventJoypadButton.new()
button.button_index = event.button_index
return button
if event is InputEventJoypadMotion and absf(event.axis_value) >= AXIS_CAPTURE_THRESHOLD:
var motion := InputEventJoypadMotion.new()
motion.axis = event.axis
motion.axis_value = signf(event.axis_value)
return motion
return null
func _labels_for(actions: PackedStringArray) -> PackedStringArray:
var out := PackedStringArray()
for action in actions:
for entry in InputSettings.ACTIONS:
if entry["action"] == action:
out.append(entry["label"])
break
return out
func _on_invert_pitch_toggled(pressed: bool) -> void:
InputSettings.invert_pitch = pressed
func _on_reset_pressed() -> void:
_cancel_capture()
InputSettings.reset_all()
invert_pitch_check.button_pressed = InputSettings.invert_pitch
_rebuild_rows()
status_label.text = "Bindings reset to defaults."
+1
View File
@@ -0,0 +1 @@
uid://dk7plirjfqvld
+5 -1
View File
@@ -276,7 +276,11 @@ func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
# leave_gameplay (Escape / Start), NOT ui_cancel. ui_cancel carries the B
# button so menus behave the way a controller player expects, and B is far
# too easy to hit by accident for "abandon the match you are playing".
# Menus and the lobby still use ui_cancel; only live gameplay is guarded.
if event.is_action_pressed("leave_gameplay"):
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+329
View File
@@ -0,0 +1,329 @@
extends Node
# Autoload: persisted keyboard/controller bindings on top of project.godot's
# [input] defaults, plus the flight-feel preferences that belong with them
# (invert pitch). The Settings screen's Controls tab (controls_settings.gd) is
# the only writer; PlayerShipController is the only reader of pitch_sign().
#
# project.godot stays the single source of truth for *defaults*: _ready()
# snapshots whatever InputMap holds at boot, before any override is applied, so
# the default table is never duplicated in GDScript and can never drift from the
# file. An override is only ever a delta on top of that snapshot.
#
# Persisted to user://input.cfg rather than user://settings.cfg, deliberately.
# VideoSettings.save() builds a fresh ConfigFile and writes it, which would drop
# every section it does not itself know about — so two autoloads sharing one
# file would silently erase each other. A separate file sidesteps that entirely
# instead of coupling the two save paths.
# Each action is bound at most once per device kind. That is a deliberate
# simplification of Godot's arbitrary-length event list: it makes a rebind row
# a single button rather than an editable list, and makes "what is X bound to?"
# answerable. The consequence is that applying a binding replaces the whole
# event list for that action (see apply()), so anything project.godot binds
# beyond one keyboard + one joypad event per action would be dropped here.
const DEVICE_KEYBOARD := "keyboard"
const DEVICE_JOYPAD := "joypad"
const SETTINGS_PATH := "user://input.cfg"
# The rebindable action list, and the only place the Controls tab and the tests
# read it from. Order is display order. Actions NOT listed here (ui_*, the F3/F4
# debug overlays) are deliberately not rebindable.
const ACTIONS := [
{"action": "move_forward", "label": "Thrust forward", "group": "Flight"},
{"action": "move_back", "label": "Thrust backward", "group": "Flight"},
{"action": "move_left", "label": "Strafe left", "group": "Flight"},
{"action": "move_right", "label": "Strafe right", "group": "Flight"},
{"action": "move_up", "label": "Thrust up", "group": "Flight"},
{"action": "move_down", "label": "Thrust down", "group": "Flight"},
{"action": "turbo", "label": "Turbo", "group": "Flight"},
{"action": "turn_left", "label": "Yaw left", "group": "Attitude"},
{"action": "turn_right", "label": "Yaw right", "group": "Attitude"},
{"action": "pitch_up", "label": "Pitch up", "group": "Attitude"},
{"action": "pitch_down", "label": "Pitch down", "group": "Attitude"},
{"action": "roll_left", "label": "Roll left", "group": "Attitude"},
{"action": "roll_right", "label": "Roll right", "group": "Attitude"},
{"action": "toggle_ball_cam", "label": "Ball camera", "group": "Other"},
{"action": "reset_ball", "label": "Reset ball (Free Play)", "group": "Other"},
]
# button_index -> label, using the Xbox names the default map is expressed in.
# InputEvent.as_text() renders these as "Joypad Button 9 (Left Shoulder)", which
# is both long and wrong-looking in a rebind row.
const JOY_BUTTON_NAMES := {
JOY_BUTTON_A: "A", JOY_BUTTON_B: "B", JOY_BUTTON_X: "X", JOY_BUTTON_Y: "Y",
JOY_BUTTON_BACK: "Back", JOY_BUTTON_GUIDE: "Guide", JOY_BUTTON_START: "Start",
JOY_BUTTON_LEFT_STICK: "L3", JOY_BUTTON_RIGHT_STICK: "R3",
JOY_BUTTON_LEFT_SHOULDER: "LB", JOY_BUTTON_RIGHT_SHOULDER: "RB",
JOY_BUTTON_DPAD_UP: "D-Pad Up", JOY_BUTTON_DPAD_DOWN: "D-Pad Down",
JOY_BUTTON_DPAD_LEFT: "D-Pad Left", JOY_BUTTON_DPAD_RIGHT: "D-Pad Right",
}
# axis -> [label at negative deflection, label at positive deflection]. The
# triggers rest at 0 and only travel positive, so their negative half is never
# a reachable binding and is labelled as such rather than as a direction.
const JOY_AXIS_NAMES := {
JOY_AXIS_LEFT_X: ["Left Stick Left", "Left Stick Right"],
JOY_AXIS_LEFT_Y: ["Left Stick Up", "Left Stick Down"],
JOY_AXIS_RIGHT_X: ["Right Stick Left", "Right Stick Right"],
JOY_AXIS_RIGHT_Y: ["Right Stick Up", "Right Stick Down"],
JOY_AXIS_TRIGGER_LEFT: ["LT", "LT"],
JOY_AXIS_TRIGGER_RIGHT: ["RT", "RT"],
}
signal bindings_changed
# Push the right stick forward and the nose goes down (flight-sim). Ticking this
# flips it. Applied in PlayerShipController rather than by rewriting the
# bindings, so it stays one preference instead of two swapped rows the player
# then has to reason about.
var invert_pitch: bool = false
# action -> {DEVICE_KEYBOARD: InputEvent|null, DEVICE_JOYPAD: InputEvent|null},
# snapshotted from InputMap at boot before any override lands.
var _defaults: Dictionary = {}
# Same shape, but only for actions the player has actually customised. A device
# key that is absent means "still using the default"; a device key present with
# null means "the player deliberately unbound it".
var _overrides: Dictionary = {}
func _ready() -> void:
_capture_defaults()
_load()
apply()
# Reads project.godot's [input] back out of InputMap. Anything that is neither a
# key nor a joypad button/motion event (mouse buttons, say) is ignored rather
# than mis-filed under a device kind it does not belong to.
func _capture_defaults() -> void:
_defaults.clear()
for entry in ACTIONS:
var action: String = entry["action"]
var slots := {DEVICE_KEYBOARD: null, DEVICE_JOYPAD: null}
if InputMap.has_action(action):
for event in InputMap.action_get_events(action):
var kind := device_kind_of(event)
if kind != "" and slots[kind] == null:
slots[kind] = event
_defaults[action] = slots
# "" for an event this system cannot express (mouse, gesture, MIDI), which is
# also the signal to callers that it is not a legal binding.
static func device_kind_of(event: InputEvent) -> String:
if event is InputEventKey:
return DEVICE_KEYBOARD
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
return DEVICE_JOYPAD
return ""
func _load() -> void:
_overrides.clear()
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
return
invert_pitch = cfg.get_value("input", "invert_pitch", invert_pitch)
for entry in ACTIONS:
var action: String = entry["action"]
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var key := "%s.%s" % [action, device]
if not cfg.has_section_key("bindings", key):
continue
var stored = cfg.get_value("bindings", key)
if not (stored is Dictionary):
continue
# An empty dict is the "deliberately unbound" sentinel — see save().
if stored.is_empty():
_set_override(action, device, null)
continue
var event := event_from_dict(stored)
if event != null:
_set_override(action, device, event)
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("input", "invert_pitch", invert_pitch)
for action in _overrides:
var slots: Dictionary = _overrides[action]
for device in slots:
var event: InputEvent = slots[device]
var key := "%s.%s" % [action, device]
# An unbound override is written as an empty dict, NOT as null:
# ConfigFile.set_value() treats a null value as "erase this key", so
# storing null would drop the entry and the next load would fall
# back to the project default — silently rebinding something the
# player had deliberately cleared.
cfg.set_value("bindings", key, {} if event == null else event_to_dict(event))
cfg.save(SETTINGS_PATH)
# Rebuilds InputMap for every rebindable action from defaults + overrides. Runs
# wholesale rather than incrementally so there is exactly one code path that
# decides what an action is bound to, whatever route got us here.
func apply() -> void:
for entry in ACTIONS:
var action: String = entry["action"]
if not InputMap.has_action(action):
continue
InputMap.action_erase_events(action)
for device in [DEVICE_KEYBOARD, DEVICE_JOYPAD]:
var event := get_binding(action, device)
if event != null:
InputMap.action_add_event(action, event)
bindings_changed.emit()
func get_binding(action: String, device: String) -> InputEvent:
if _overrides.has(action) and _overrides[action].has(device):
return _overrides[action][device]
if _defaults.has(action):
return _defaults[action][device]
return null
func get_default_binding(action: String, device: String) -> InputEvent:
if not _defaults.has(action):
return null
return _defaults[action][device]
# Binds `event` to `action`, replacing whatever that action had for the event's
# own device kind. Returns the actions that were unbound to avoid a duplicate,
# so the caller can say so rather than leaving the player to discover it.
func set_binding(action: String, event: InputEvent) -> PackedStringArray:
var device := device_kind_of(event)
if device == "":
return PackedStringArray()
var displaced := find_conflicts(event, action)
for other in displaced:
_set_override(other, device, null)
_set_override(action, device, event)
apply()
return displaced
func clear_binding(action: String, device: String) -> void:
_set_override(action, device, null)
apply()
# Actions already bound to an equivalent event, excluding `except_action`.
# Compared by value rather than by object identity — the event coming out of a
# rebind capture is a different instance from the one in the map.
func find_conflicts(event: InputEvent, except_action: String = "") -> PackedStringArray:
var device := device_kind_of(event)
var out := PackedStringArray()
if device == "":
return out
for entry in ACTIONS:
var action: String = entry["action"]
if action == except_action:
continue
var bound := get_binding(action, device)
if bound != null and events_match(bound, event):
out.append(action)
return out
# Equality by the fields a binding is identified by. Deliberately not
# InputEvent.is_match(): for an axis that ignores axis_value, which would make
# "Right Stick Up" and "Right Stick Down" collide as the same binding.
static func events_match(a: InputEvent, b: InputEvent) -> bool:
if a is InputEventKey and b is InputEventKey:
return a.physical_keycode == b.physical_keycode
if a is InputEventJoypadButton and b is InputEventJoypadButton:
return a.button_index == b.button_index
if a is InputEventJoypadMotion and b is InputEventJoypadMotion:
return a.axis == b.axis and signf(a.axis_value) == signf(b.axis_value)
return false
func reset_action(action: String) -> void:
_overrides.erase(action)
apply()
func reset_all() -> void:
_overrides.clear()
invert_pitch = false
apply()
func has_override(action: String) -> bool:
return _overrides.has(action)
func pitch_sign() -> float:
return -1.0 if invert_pitch else 1.0
func _set_override(action: String, device: String, event: InputEvent) -> void:
if not _overrides.has(action):
_overrides[action] = {}
_overrides[action][device] = event
# ConfigFile stores Dictionary values natively, so bindings persist as plain
# data. Never the Object(...) literal Godot writes into project.godot — that
# form is only parsed by the engine's own project-file loader, and round-tripping
# it through user:// would be storing engine-internal syntax in a save file.
static func event_to_dict(event: InputEvent) -> Dictionary:
if event is InputEventKey:
return {"type": "key", "physical_keycode": int(event.physical_keycode)}
if event is InputEventJoypadButton:
return {"type": "joy_button", "button_index": int(event.button_index)}
if event is InputEventJoypadMotion:
return {"type": "joy_axis", "axis": int(event.axis), "value": float(signf(event.axis_value))}
return {}
# Returns null for anything unrecognised, so a save file from a newer build (or
# a hand-edited one) degrades to "this action is unbound" rather than crashing
# the game before the player can reach the Controls tab to fix it.
static func event_from_dict(data: Dictionary) -> InputEvent:
match data.get("type", ""):
"key":
var key := InputEventKey.new()
key.physical_keycode = int(data.get("physical_keycode", 0))
return key if key.physical_keycode != 0 else null
"joy_button":
var button := InputEventJoypadButton.new()
button.button_index = int(data.get("button_index", -1))
return button if button.button_index >= 0 else null
"joy_axis":
var motion := InputEventJoypadMotion.new()
motion.axis = int(data.get("axis", -1))
motion.axis_value = signf(float(data.get("value", 0.0)))
return motion if motion.axis >= 0 and motion.axis_value != 0.0 else null
return null
func event_to_text(event: InputEvent) -> String:
if event == null:
return "Unbound"
if event is InputEventKey:
# Physical keycodes throughout, so the label matches the key's position
# on a non-QWERTY layout the same way the binding itself does. The
# headless display server has no keyboard layout to consult and pushes
# an ERROR for the attempt — which the ENet smoke gate treats as a
# failure on sight — so fall back to the unmapped keycode there.
var keycode: int = event.physical_keycode
if DisplayServer.get_name() != "headless":
keycode = DisplayServer.keyboard_get_keycode_from_physical(keycode)
return OS.get_keycode_string(keycode)
if event is InputEventJoypadButton:
return JOY_BUTTON_NAMES.get(event.button_index, "Button %d" % event.button_index)
if event is InputEventJoypadMotion:
if JOY_AXIS_NAMES.has(event.axis):
return JOY_AXIS_NAMES[event.axis][0 if event.axis_value < 0.0 else 1]
return "Axis %d%s" % [event.axis, "-" if event.axis_value < 0.0 else "+"]
return event.as_text()
func binding_text(action: String, device: String) -> String:
return event_to_text(get_binding(action, device))
+1
View File
@@ -0,0 +1 @@
uid://bjtdsbem7kwdv
+7 -2
View File
@@ -52,7 +52,7 @@ func _ready() -> void:
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.connection_failed.connect(_on_connection_failed)
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
%FreePlayButton.grab_focus()
# main_menu.gd's first async flow (task 1.7): Host is synchronous
@@ -120,6 +120,11 @@ func _list_bots() -> Array[String]:
# disk, else the newest (last) bot.
func _populate_dropdown(dropdown: OptionButton, bots: Array[String], preferred_path: String, include_none: bool = false) -> void:
dropdown.clear()
# These are filled from whatever checkpoints happen to be in res://bots, so
# a long filename would otherwise widen the OptionButton (size_flags_h =
# EXPAND_FILL) and drag the whole menu past its 420px minimum width.
dropdown.clip_text = true
dropdown.fit_to_longest_item = false
if include_none:
dropdown.add_item("(Use difficulty)")
dropdown.set_item_metadata(0, "")
@@ -173,7 +178,7 @@ func _on_match_pressed() -> void:
func _on_settings_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/settings.tscn")
get_tree().change_scene_to_file(ScenePaths.SETTINGS)
func _on_spectate_pressed() -> void:
+22 -13
View File
@@ -10,30 +10,39 @@ var _action := ShipAction.new()
func get_action() -> ShipAction:
# Full overwrite per axis (not +=/-=): _action is reused across ticks, so
# fields must not depend on starting from a fresh Vector3.ZERO each call.
#
# Input.get_axis(negative, positive) is strength(positive) -
# strength(negative), so these keep the exact sign conventions the digital
# version had while becoming proportional on a controller:
# get_action_strength() returns a flat 1.0 for a held key but the
# normalised past-deadzone deflection for an InputEventJoypadMotion. A
# half-pulled trigger is therefore half thrust, and keyboard flight is
# unchanged down to the value.
# Forward/Backward thrust (main engines)
_action.thrust.z = (1.0 if Input.is_action_pressed("move_forward") else 0.0) \
- (1.0 if Input.is_action_pressed("move_back") else 0.0)
_action.thrust.z = Input.get_axis("move_back", "move_forward")
# Strafe thrusters (left/right)
_action.thrust.x = (1.0 if Input.is_action_pressed("move_right") else 0.0) \
- (1.0 if Input.is_action_pressed("move_left") else 0.0)
_action.thrust.x = Input.get_axis("move_left", "move_right")
# Vertical thrusters (up/down)
_action.thrust.y = (1.0 if Input.is_action_pressed("move_up") else 0.0) \
- (1.0 if Input.is_action_pressed("move_down") else 0.0)
_action.thrust.y = Input.get_axis("move_down", "move_up")
# Yaw (turn left/right around Y axis)
_action.rotation.y = (1.0 if Input.is_action_pressed("turn_left") else 0.0) \
- (1.0 if Input.is_action_pressed("turn_right") else 0.0)
_action.rotation.y = Input.get_axis("turn_right", "turn_left")
# Pitch (nose up/down around X axis)
_action.rotation.x = (1.0 if Input.is_action_pressed("pitch_down") else 0.0) \
- (1.0 if Input.is_action_pressed("pitch_up") else 0.0)
# Pitch (nose up/down around X axis). Positive rotation.x is nose-UP:
# torque about local +X rotates the ship's up vector toward its tail by the
# right-hand rule, which lifts the nose (measured, not assumed). The
# argument order here used to be reversed, so "pitch_down" pitched up and
# the I/K keys were each labelled as the opposite of what they did.
# The default binding then gives flight-sim polarity — right stick forward
# is pitch_down is nose down — and InputSettings holds the player's
# preference for flipping that.
_action.rotation.x = Input.get_axis("pitch_down", "pitch_up") * InputSettings.pitch_sign()
# Roll (bank left/right around Z axis)
_action.rotation.z = (1.0 if Input.is_action_pressed("roll_left") else 0.0) \
- (1.0 if Input.is_action_pressed("roll_right") else 0.0)
_action.rotation.z = Input.get_axis("roll_right", "roll_left")
_action.turbo = Input.is_action_pressed("turbo")
+1
View File
@@ -1,6 +1,7 @@
class_name ScenePaths
const MAIN_MENU := "res://scenes/main_menu.tscn"
const SETTINGS := "res://scenes/settings.tscn"
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
# a community server whose players are all dumped back to their own menus
# every 2.5 minutes has no way to keep a lobby together.
+9 -1
View File
@@ -1,6 +1,10 @@
extends Control
# Settings screen: player-facing video knobs on top of VideoSettings (the
# Settings screen root, owning the Video tab and the shared Back button. The
# Controls tab has its own script (controls_settings.gd) so this file stays
# video-only; both tabs' state is committed in _on_back_pressed below.
#
# Video tab: player-facing video knobs on top of VideoSettings (the
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
# scale apply immediately since they're Viewport- or DisplayServer-wide;
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
@@ -205,6 +209,10 @@ func _mark_custom_if_user_driven() -> void:
func _on_back_pressed() -> void:
VideoSettings.save()
# Bindings are applied live as the player rebinds them (InputSettings.apply
# runs on every change) but are only committed to disk here, matching how
# the video knobs behave.
InputSettings.save()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+18 -9
View File
@@ -15,7 +15,7 @@ const SimConstants = preload("res://scripts/sim_constants.gd")
@export var vertical_thrust = 120.0 # Up/down thruster power
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
@export var max_speed = 35.0 # Maximum velocity
@export var rotation_power = 20.0 # Angular thrust power
@export var rotation_acceleration = 20.0 # Angular acceleration, rad/s^2, equal on all three axes (see apply_rotation_forces)
@export var max_angular_speed = 3.0 # Maximum rotation speed
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag
@@ -557,18 +557,27 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
if rotation_input.length() < 0.01:
return
# Apply torque for rotation - simple and effective
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
# Lower inertia = higher angular acceleration for same torque
# Scaling each axis by its own inertia makes rotation_acceleration mean
# exactly that — α, in rad/s² — so all three axes respond identically.
# ship.tscn's inertia is Vector3(7, 1, 7): a flat torque across all three
# axes therefore used to give yaw 7x the angular acceleration of pitch and
# roll (172 deg/s vs 52 deg/s at steady state). That was an accident of the
# inertia tensor rather than a design decision, and it read as "rotation is
# sluggish except when turning".
var torque = Vector3(
rotation_input.x * rotation_power, # Pitch (rotation around X-axis)
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
rotation_input.x * rotation_acceleration * inertia.x, # Pitch (local X)
rotation_input.y * rotation_acceleration * inertia.y, # Yaw (local Y)
rotation_input.z * rotation_acceleration * inertia.z # Roll (local Z)
)
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
state.apply_torque(torque)
# apply_torque() is world-space, and the vector above is in the ship's own
# frame, so it MUST be rotated by the hull's basis — exactly as thrust is
# (see the -ship_basis.z term in apply_thrust_forces). Without this the
# ship rotated about the world axes: roll input became pitch once the ship
# had yawed 90 degrees, and both roll and pitch inverted at 180 degrees, so
# the controls were correct flying up-field and backwards flying back.
state.apply_torque(state.transform.basis * torque)
# Scales a per-tick decay multiplier `k` (defined at a 60 Hz reference rate)
+3 -1
View File
@@ -110,7 +110,9 @@ func _exit_tree() -> void:
func _input(event):
if event.is_action_pressed("ui_accept"): # Enter key
# A dedicated action rather than ui_accept, so the camera toggle is
# rebindable and A stays purely a menu-confirm button. Space / R3.
if event.is_action_pressed("toggle_ball_cam"):
ball_cam_enabled = !ball_cam_enabled
camera_mode_changed.emit(ball_cam_enabled)
+450
View File
@@ -0,0 +1,450 @@
extends "res://tests/test_case.gd"
# Guards the input map and the InputSettings remap layer.
#
# The defect this file exists for: project.godot bound joypad events to only 5
# of the 13 flight actions, so a controller could yaw/pitch/roll/turbo but could
# not translate at all. Nothing failed, because nothing asserted that a *pair*
# of bindings exists — the actions were all present and the game booted fine.
# test_every_action_has_both_a_keyboard_and_a_joypad_binding is that assertion,
# and it can tell "bound on both devices" from "bound on one", which is the
# distinction that was actually missing.
#
# Several of these tests write to the global InputMap through InputSettings, so
# each one must restore it before returning or it corrupts every later case in
# the run (the runner shares one process). reset_all() is the restore.
const CONTROLLER_ONLY_ACTIONS := ["toggle_ball_cam", "reset_ball"]
func _joypad_events(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_JOYPAD:
out.append(event)
return out
func _keyboard_events(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if InputSettings.device_kind_of(event) == InputSettings.DEVICE_KEYBOARD:
out.append(event)
return out
func test_every_rebindable_action_exists() -> void:
for entry in InputSettings.ACTIONS:
assert_true(InputMap.has_action(entry["action"]), "InputMap has action %s" % entry["action"])
func test_every_action_has_both_a_keyboard_and_a_joypad_binding() -> void:
# The regression itself: a controller player must be able to reach every
# action without touching the keyboard, and vice versa.
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
assert_true(not _keyboard_events(action).is_empty(), "%s has a keyboard binding" % action)
assert_true(not _joypad_events(action).is_empty(), "%s has a joypad binding" % action)
func test_apply_is_lossless_against_the_project_defaults() -> void:
# InputSettings stores one binding per device per action, so apply()
# rewrites each action's event list to exactly [keyboard, joypad]. If
# project.godot ever gains a second keyboard event for a rebindable action,
# booting the game would silently drop it — the action would still work, on
# fewer keys than the file says. Asserting apply() is a no-op over the
# defaults is what distinguishes "bindings intact" from "bindings quietly
# trimmed", which counting events cannot do.
InputSettings.reset_all()
var before := {}
for entry in InputSettings.ACTIONS:
before[entry["action"]] = InputMap.action_get_events(entry["action"]).size()
InputSettings.apply()
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
assert_eq(
InputMap.action_get_events(action).size(),
before[action],
"%s keeps every event across apply()" % action
)
assert_eq(before[action], 2, "%s has exactly one keyboard and one joypad event" % action)
func test_no_two_actions_share_a_joypad_binding() -> void:
# Two flight actions sharing one input is silently unplayable rather than an
# error, and it is easy to reintroduce: an early draft of this layout had A
# as both turbo and thrust-up, and B as both thrust-down and ui_cancel.
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_JOYPAD)
assert_true(bound != null, "%s resolves a joypad binding" % action)
if bound == null:
continue
var conflicts := InputSettings.find_conflicts(bound, action)
assert_true(
conflicts.is_empty(),
"%s's joypad binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
)
func test_no_two_actions_share_a_keyboard_binding() -> void:
for entry in InputSettings.ACTIONS:
var action: String = entry["action"]
var bound := InputSettings.get_binding(action, InputSettings.DEVICE_KEYBOARD)
assert_true(bound != null, "%s resolves a keyboard binding" % action)
if bound == null:
continue
var conflicts := InputSettings.find_conflicts(bound, action)
assert_true(
conflicts.is_empty(),
"%s's keyboard binding is unique (also on: %s)" % [action, ", ".join(conflicts)]
)
func _joypad_buttons(action: String) -> Array:
var out := []
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton:
out.append(event.button_index)
return out
func test_menus_are_usable_with_a_controller() -> void:
# Godot 4.7 ships ui_up/down/left/right with D-pad and stick events but
# gives ui_accept and ui_cancel NO joypad binding at all (verified against a
# pristine project). A controller could therefore move the highlight around
# the main menu and never press anything — the menu looked responsive, which
# is exactly why it went unnoticed. project.godot binds them explicitly.
assert_true(JOY_BUTTON_A in _joypad_buttons("ui_accept"), "A confirms in menus")
assert_true(JOY_BUTTON_B in _joypad_buttons("ui_cancel"), "B goes back in menus")
# Navigation is the engine default, but assert it so a future override of
# these actions cannot silently strand a controller player again.
for action in ["ui_up", "ui_down", "ui_left", "ui_right"]:
var pad := 0
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
pad += 1
assert_true(pad > 0, "%s is reachable on a controller" % action)
func test_leaving_gameplay_is_not_on_a_face_button() -> void:
# game_mode.gd exits to the main menu on leave_gameplay, deliberately NOT on
# ui_cancel: ui_cancel carries B so menus behave conventionally, and B is far
# too easy to hit by accident to also mean "abandon this match". The two
# actions being distinct is the whole point, so assert they really differ.
assert_true(InputMap.has_action("leave_gameplay"), "leave_gameplay exists")
var buttons := _joypad_buttons("leave_gameplay")
assert_true(JOY_BUTTON_START in buttons, "Start leaves gameplay")
for face in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]:
assert_true(face not in buttons, "leave_gameplay must not use a face button")
func test_ball_cam_has_its_own_action_off_ui_accept() -> void:
# Ball-cam used to ride ui_accept, which project.godot now binds to A for
# menu confirmation. A dedicated action keeps A purely a menu button and
# lets the camera toggle be rebound like anything else.
assert_true(InputMap.has_action("toggle_ball_cam"), "toggle_ball_cam exists")
var joypad := _joypad_events("toggle_ball_cam")
assert_true(joypad.size() == 1, "toggle_ball_cam has one joypad binding")
if joypad.size() == 1:
assert_true(
joypad[0] is InputEventJoypadButton and joypad[0].button_index != JOY_BUTTON_A,
"toggle_ball_cam is not on A (menu confirm)"
)
func test_thrust_is_on_the_triggers() -> void:
# The requested layout, asserted where it is load-bearing: triggers are the
# only analog inputs on the thrust axis, so binding them to buttons instead
# would silently cost proportional throttle without failing anything.
var forward := _joypad_events("move_forward")
var back := _joypad_events("move_back")
assert_true(forward.size() == 1 and forward[0] is InputEventJoypadMotion, "move_forward is an axis")
assert_true(back.size() == 1 and back[0] is InputEventJoypadMotion, "move_back is an axis")
if forward.size() == 1 and forward[0] is InputEventJoypadMotion:
assert_eq(forward[0].axis, JOY_AXIS_TRIGGER_RIGHT, "move_forward axis")
if back.size() == 1 and back[0] is InputEventJoypadMotion:
assert_eq(back[0].axis, JOY_AXIS_TRIGGER_LEFT, "move_back axis")
func test_pitch_is_on_the_left_stick_nose_down_when_pushed_forward() -> void:
var down := _joypad_events("pitch_down")
var up := _joypad_events("pitch_up")
assert_true(down.size() == 1 and down[0] is InputEventJoypadMotion, "pitch_down is an axis")
assert_true(up.size() == 1 and up[0] is InputEventJoypadMotion, "pitch_up is an axis")
if down.size() == 1 and down[0] is InputEventJoypadMotion:
assert_eq(down[0].axis, JOY_AXIS_LEFT_Y, "pitch_down axis")
# Godot reports a stick pushed away from the player as negative Y.
assert_true(down[0].axis_value < 0.0, "stick forward pitches the nose down")
if up.size() == 1 and up[0] is InputEventJoypadMotion:
assert_eq(up[0].axis, JOY_AXIS_LEFT_Y, "pitch_up axis")
assert_true(up[0].axis_value > 0.0, "stick back pitches the nose up")
func test_all_rotation_lives_on_the_left_stick() -> void:
# Yaw and pitch belong on the same stick. Splitting them across two sticks
# (yaw left, pitch right) is playable in the sense that every input works,
# so nothing here failed when it was wrong — it just felt broken, because
# each stick had a dead axis. Asserting both are on the right stick is what
# pins the 6DOF convention down.
for action in ["turn_left", "turn_right"]:
var events := _joypad_events(action)
assert_true(events.size() == 1 and events[0] is InputEventJoypadMotion, "%s is an axis" % action)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, JOY_AXIS_LEFT_X, "%s axis" % action)
for action in ["pitch_up", "pitch_down"]:
var events := _joypad_events(action)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, JOY_AXIS_LEFT_Y, "%s axis" % action)
func test_translation_is_analog_on_the_right_stick_and_triggers() -> void:
# Six degrees of freedom onto the pad's six analog axes. Strafe and
# vertical were digital buttons at first, which cost proportional control
# without failing anything — a button binding here still "works", it just
# gives full power or nothing, so only checking the event type catches it.
var expected := {
"move_left": JOY_AXIS_RIGHT_X, "move_right": JOY_AXIS_RIGHT_X,
"move_up": JOY_AXIS_RIGHT_Y, "move_down": JOY_AXIS_RIGHT_Y,
"move_forward": JOY_AXIS_TRIGGER_RIGHT, "move_back": JOY_AXIS_TRIGGER_LEFT,
}
for action in expected:
var events := _joypad_events(action)
assert_true(
events.size() == 1 and events[0] is InputEventJoypadMotion,
"%s is analog, not a button" % action
)
if events.size() == 1 and events[0] is InputEventJoypadMotion:
assert_eq(events[0].axis, expected[action], "%s axis" % action)
func test_pushing_the_right_stick_up_thrusts_up() -> void:
# Godot reports a stick pushed away from the player as negative Y, so the
# intuitive direction needs the negative half — easy to get backwards, and
# inverted vertical thrust is not something any other assertion notices.
var up := _joypad_events("move_up")
var down := _joypad_events("move_down")
if up.size() == 1 and up[0] is InputEventJoypadMotion:
assert_true(up[0].axis_value < 0.0, "stick up thrusts up")
if down.size() == 1 and down[0] is InputEventJoypadMotion:
assert_true(down[0].axis_value > 0.0, "stick down thrusts down")
func test_roll_is_on_the_shoulder_buttons() -> void:
var expected := {"roll_left": JOY_BUTTON_LEFT_SHOULDER, "roll_right": JOY_BUTTON_RIGHT_SHOULDER}
for action in expected:
var events := _joypad_events(action)
assert_true(events.size() == 1 and events[0] is InputEventJoypadButton, "%s is a button" % action)
if events.size() == 1 and events[0] is InputEventJoypadButton:
assert_eq(events[0].button_index, expected[action], "%s button" % action)
func test_the_face_buttons_are_free_for_menus() -> void:
# A/B/X/Y carry no flight action, which is what lets ui_accept keep A and
# keeps a stray face-button press from doing something during a match.
for entry in InputSettings.ACTIONS:
var bound := InputSettings.get_binding(entry["action"], InputSettings.DEVICE_JOYPAD)
if bound is InputEventJoypadButton:
assert_true(
bound.button_index not in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y],
"%s must not use a face button" % entry["action"]
)
func test_event_dict_round_trip_preserves_every_default() -> void:
for entry in InputSettings.ACTIONS:
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
var original := InputSettings.get_default_binding(entry["action"], device)
assert_true(original != null, "%s/%s has a default" % [entry["action"], device])
if original == null:
continue
var restored := InputSettings.event_from_dict(InputSettings.event_to_dict(original))
assert_true(restored != null, "%s/%s round-trips to an event" % [entry["action"], device])
if restored != null:
assert_true(
InputSettings.events_match(original, restored),
"%s/%s round-trips to an equal event" % [entry["action"], device]
)
func test_event_from_dict_rejects_junk() -> void:
# A save file from a newer build, or a hand-edited one, must degrade to
# "unbound" rather than taking the game down before the player can reach
# the Controls tab to fix it.
assert_true(InputSettings.event_from_dict({}) == null, "empty dict is not an event")
assert_true(InputSettings.event_from_dict({"type": "mouse"}) == null, "unknown type is not an event")
assert_true(InputSettings.event_from_dict({"type": "key"}) == null, "keycode-less key is not an event")
assert_true(
InputSettings.event_from_dict({"type": "joy_axis", "axis": 3, "value": 0.0}) == null,
"a centred axis is not an event"
)
func test_axis_bindings_are_distinguished_by_direction() -> void:
# events_match must NOT collapse the two halves of one axis, or binding
# pitch-up would silently unbind pitch-down as a "conflict".
var up := InputEventJoypadMotion.new()
up.axis = JOY_AXIS_RIGHT_Y
up.axis_value = 1.0
var down := InputEventJoypadMotion.new()
down.axis = JOY_AXIS_RIGHT_Y
down.axis_value = -1.0
assert_true(not InputSettings.events_match(up, down), "opposite axis halves are different bindings")
assert_true(InputSettings.events_match(up, up), "an axis binding matches itself")
func test_set_binding_changes_the_live_input_map() -> void:
var rebound := InputEventKey.new()
rebound.physical_keycode = KEY_F # not used by any default binding
InputSettings.set_binding("move_forward", rebound)
var found := false
for event in InputMap.action_get_events("move_forward"):
if event is InputEventKey and event.physical_keycode == KEY_F:
found = true
assert_true(found, "the rebound key reaches InputMap")
assert_true(InputSettings.has_override("move_forward"), "the rebind is recorded as an override")
# The joypad half must survive a keyboard-only rebind.
assert_true(not _joypad_events("move_forward").is_empty(), "rebinding the key keeps the trigger")
InputSettings.reset_all()
func test_set_binding_displaces_the_conflicting_action() -> void:
# Binding X to an input already in use must report and clear the previous
# owner, not leave both bound and let the player wonder why two things fire.
var shared := InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD)
assert_true(shared != null, "move_left has a keyboard binding to steal")
if shared == null:
return
var displaced := InputSettings.set_binding("move_right", shared)
assert_true(displaced.has("move_left"), "the displaced action is reported")
assert_true(
InputSettings.get_binding("move_left", InputSettings.DEVICE_KEYBOARD) == null,
"the displaced action is actually unbound"
)
InputSettings.reset_all()
func test_reset_all_restores_the_project_defaults() -> void:
var before := InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD)
var rebound := InputEventJoypadButton.new()
rebound.button_index = JOY_BUTTON_BACK
InputSettings.set_binding("move_up", rebound)
assert_true(
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD) != before,
"the rebind took effect"
)
InputSettings.reset_all()
assert_eq(
InputSettings.binding_text("move_up", InputSettings.DEVICE_JOYPAD),
before,
"reset_all restores the default binding"
)
assert_true(not InputSettings.has_override("move_up"), "reset_all clears the override")
func test_bindings_survive_a_save_and_reload() -> void:
# The end-to-end persistence path, which nothing else covers: a rebind that
# does not survive a restart is the single most visible way this feature can
# fail, and it fails silently — the game runs fine, just on the defaults.
#
# This writes the real user://input.cfg, so the player's own file is saved
# and put back. Restoring it is not optional: the test suite shares a
# user:// directory with the game.
var had_file := FileAccess.file_exists(InputSettings.SETTINGS_PATH)
var original := ""
if had_file:
original = FileAccess.get_file_as_string(InputSettings.SETTINGS_PATH)
var rebound := InputEventJoypadButton.new()
rebound.button_index = JOY_BUTTON_BACK
InputSettings.set_binding("turbo", rebound)
InputSettings.invert_pitch = true
InputSettings.save()
# Drop the in-memory state the way a fresh launch would, then reload.
InputSettings.reset_all()
assert_true(not InputSettings.has_override("turbo"), "state cleared before reload")
InputSettings._load()
InputSettings.apply()
assert_true(InputSettings.has_override("turbo"), "the override came back from disk")
var loaded := InputSettings.get_binding("turbo", InputSettings.DEVICE_JOYPAD)
assert_true(loaded != null, "the reloaded binding is an event")
if loaded != null:
assert_true(InputSettings.events_match(loaded, rebound), "the reloaded binding matches what was saved")
assert_true(InputSettings.invert_pitch, "invert_pitch survives a reload")
# A deliberately-cleared binding must stay cleared across a restart. This is
# the case that distinguishes a real "unbound" record from an absent one:
# ConfigFile.set_value() erases a key whose value is null, so a naive
# implementation silently restores the default here instead.
InputSettings.clear_binding("roll_left", InputSettings.DEVICE_JOYPAD)
InputSettings.save()
InputSettings.reset_all()
InputSettings._load()
InputSettings.apply()
assert_true(
InputSettings.get_binding("roll_left", InputSettings.DEVICE_JOYPAD) == null,
"an unbound action stays unbound across a reload"
)
assert_true(
_joypad_events("roll_left").is_empty(),
"the unbound action has no joypad event in InputMap after a reload"
)
# And it must actually be live in InputMap, not merely remembered.
var live := false
for event in InputMap.action_get_events("turbo"):
if event is InputEventJoypadButton and event.button_index == JOY_BUTTON_BACK:
live = true
assert_true(live, "the reloaded binding is applied to InputMap")
InputSettings.reset_all()
if had_file:
var restore := FileAccess.open(InputSettings.SETTINGS_PATH, FileAccess.WRITE)
if restore != null:
restore.store_string(original)
restore.close()
InputSettings._load()
InputSettings.apply()
else:
DirAccess.remove_absolute(ProjectSettings.globalize_path(InputSettings.SETTINGS_PATH))
func test_invert_pitch_drives_pitch_sign() -> void:
var restore := InputSettings.invert_pitch
InputSettings.invert_pitch = false
assert_eq(InputSettings.pitch_sign(), 1.0, "default pitch sign")
InputSettings.invert_pitch = true
assert_eq(InputSettings.pitch_sign(), -1.0, "inverted pitch sign")
InputSettings.invert_pitch = restore
func test_every_default_binding_has_readable_text() -> void:
# A rebind row showing "" or "Joypad Button 9 (Left Shoulder)" is a UI bug
# that no other assertion here would catch.
for entry in InputSettings.ACTIONS:
for device in [InputSettings.DEVICE_KEYBOARD, InputSettings.DEVICE_JOYPAD]:
var text := InputSettings.binding_text(entry["action"], device)
assert_true(
text != "" and text != "Unbound",
"%s/%s has a readable label (got %s)" % [entry["action"], device, text]
)
func test_device_kind_of_rejects_events_it_cannot_bind() -> void:
assert_eq(InputSettings.device_kind_of(InputEventMouseButton.new()), "", "mouse is not a bindable device")
assert_eq(InputSettings.device_kind_of(InputEventKey.new()), InputSettings.DEVICE_KEYBOARD, "key device")
assert_eq(
InputSettings.device_kind_of(InputEventJoypadMotion.new()),
InputSettings.DEVICE_JOYPAD,
"joypad motion device"
)
@@ -0,0 +1 @@
uid://bwbkb6wa46yh1
+136
View File
@@ -0,0 +1,136 @@
extends "res://tests/test_case.gd"
# Guards the menu screens against the overflow bug that made the main menu
# unusable: the layout runs in a hard-fixed 1920x1080 logical viewport
# (window/stretch/mode="viewport"), and a CenterContainer centres its child
# rather than clipping it, so once the content's minimum height passed 1080 the
# top and bottom spilled off-screen with no way to reach them. In a debug build
# the main menu's DevSection pushed it to roughly 1120px, cutting off both the
# title and the last button.
#
# What this file can and cannot do, stated plainly: it asserts the *structure*
# that makes overflow reachable — the bottom-most control of each screen sits
# inside a ScrollContainer, and that container follows focus so keyboard and
# controller navigation cannot strand the player on an off-screen row. It does
# not measure anything, so it cannot prove nothing visually clips; that check is
# manual, at several window sizes. It exists to stop the wrapper being removed
# or a new section being added outside it.
#
# Scenes are inspected through PackedScene.get_state() rather than instantiated.
# main_menu.gd and lobby.gd connect NetworkManager signals and scan res://bots
# in _ready(), so instantiating them in a unit test would be doing real work to
# answer a question about the scene file.
# scene path -> the control furthest down that screen, i.e. the first thing to
# be lost to overflow. Naming a specific leaf rather than "some ScrollContainer
# exists" is what makes this assertion say something: a wrapper that does not
# actually contain the content would still pass the weaker version.
const DEEPEST_CONTROLS := {
"res://scenes/main_menu.tscn": "SpectateButton",
"res://scenes/settings.tscn": "ResetButton",
"res://scenes/lobby.tscn": "LeaveButton",
"res://scenes/matchmaking.tscn": "BackButton",
}
# Returns node index -> "Parent/Path/Name" for every node in the scene state,
# reconstructing full paths from SceneState's parent-relative storage.
func _node_paths(state: SceneState) -> Dictionary:
var paths := {}
for i in state.get_node_count():
var parent := state.get_node_path(i, true)
var name := String(state.get_node_name(i))
var parent_str := String(parent)
if parent_str == "." or parent_str == "":
paths[i] = name
else:
paths[i] = "%s/%s" % [parent_str, name]
return paths
func _property(state: SceneState, index: int, wanted: String, fallback):
for p in state.get_node_property_count(index):
if String(state.get_node_property_name(index, p)) == wanted:
return state.get_node_property_value(index, p)
return fallback
func test_every_menu_scene_loads() -> void:
for scene_path in DEEPEST_CONTROLS:
var scene := load(scene_path)
assert_true(scene is PackedScene, "%s loads as a PackedScene" % scene_path)
func test_the_bottom_of_each_menu_sits_inside_a_scroll_container() -> void:
for scene_path in DEEPEST_CONTROLS:
var scene: PackedScene = load(scene_path)
if scene == null:
assert_true(false, "%s failed to load" % scene_path)
continue
var state := scene.get_state()
var paths := _node_paths(state)
# Collect the paths of every ScrollContainer in the scene...
var scroll_paths := []
for i in state.get_node_count():
if String(state.get_node_type(i)) == "ScrollContainer":
scroll_paths.append(paths[i])
assert_true(not scroll_paths.is_empty(), "%s has a ScrollContainer" % scene_path)
# ...then require the deepest control to live under one of them.
var wanted: String = DEEPEST_CONTROLS[scene_path]
var found_path := ""
for i in state.get_node_count():
if String(state.get_node_name(i)) == wanted:
found_path = paths[i]
break
assert_true(found_path != "", "%s contains %s" % [scene_path, wanted])
if found_path == "":
continue
var scrolled := false
for scroll_path in scroll_paths:
if found_path.begins_with(String(scroll_path) + "/"):
scrolled = true
break
assert_true(scrolled, "%s's %s is inside a ScrollContainer (at %s)" % [scene_path, wanted, found_path])
func test_menu_scroll_containers_follow_focus() -> void:
# Without follow_focus, grab_focus() on a control below the fold (main_menu
# focuses FreePlayButton on ready) leaves the view showing something else,
# and controller navigation walks focus off-screen silently.
for scene_path in DEEPEST_CONTROLS:
var scene: PackedScene = load(scene_path)
if scene == null:
continue
var state := scene.get_state()
var checked := 0
for i in state.get_node_count():
if String(state.get_node_type(i)) != "ScrollContainer":
continue
checked += 1
assert_true(
_property(state, i, "follow_focus", false) == true,
"%s/%s has follow_focus" % [scene_path, state.get_node_name(i)]
)
assert_true(checked > 0, "%s has at least one ScrollContainer to check" % scene_path)
func test_menu_scroll_containers_do_not_scroll_horizontally() -> void:
# Horizontal scrolling is disabled so content is clamped to the window
# width instead of growing a second scrollbar — the dev bot dropdowns are
# filled from filenames and would otherwise widen the whole menu.
for scene_path in DEEPEST_CONTROLS:
var scene: PackedScene = load(scene_path)
if scene == null:
continue
var state := scene.get_state()
for i in state.get_node_count():
if String(state.get_node_type(i)) != "ScrollContainer":
continue
assert_eq(
_property(state, i, "horizontal_scroll_mode", ScrollContainer.SCROLL_MODE_AUTO),
ScrollContainer.SCROLL_MODE_DISABLED,
"%s/%s disables horizontal scrolling" % [scene_path, state.get_node_name(i)]
)
+1
View File
@@ -0,0 +1 @@
uid://bs8c31fs0tfhe
@@ -0,0 +1,177 @@
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")
@@ -0,0 +1 @@
uid://xpr311fjhw2m
+7 -2
View File
@@ -71,7 +71,7 @@ func test_physics_engine_is_jolt() -> void:
func test_required_autoloads_are_registered() -> void:
# NetworkManager in particular is reached by name from many scripts; losing
# it from [autoload] fails only at the point of use, deep in a smoke test.
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "NetworkManager", "MatchNet", "MatchSim"]:
for autoload_name in ["GameSettings", "ControlPlaneClient", "VideoSettings", "InputSettings", "NetworkManager", "MatchNet", "MatchSim"]:
assert_true(
ProjectSettings.has_setting("autoload/" + autoload_name),
"autoload/%s registered" % autoload_name
@@ -89,7 +89,12 @@ func test_test_hook_autoloads_are_not_shipped() -> void:
# when running those scene-level smoke tests, and must be removed again —
# see CLAUDE.md. Shipping one registered would run test code in the real
# game, so fail here rather than discovering it in a build.
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks"]:
# McpInteractionServer is registered automatically by the vendored godot-mcp
# tooling whenever it launches the project, and is left behind in
# project.godot afterwards. It is a debug channel into a running game, so
# shipping it registered is worse than a stray test hook, and it arrives
# without anyone having typed it.
for hook_name in ["MainMenuTestHooks", "LobbyTestHooks", "NetworkedMatchTestHooks", "McpInteractionServer"]:
assert_true(
not ProjectSettings.has_setting("autoload/" + hook_name),
"test hook autoload/%s must not be registered" % hook_name
+4 -4
View File
@@ -35,7 +35,7 @@ func _ready() -> void:
func _run_host() -> void:
var menu := get_tree().current_scene
var host_btn: Button = menu.get_node("CenterContainer/VBoxContainer/HostButton")
var host_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton")
host_btn.emit_signal("pressed")
await get_tree().create_timer(1.0).timeout
var scene := get_tree().current_scene
@@ -58,7 +58,7 @@ func _run_join_ok() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
@@ -72,7 +72,7 @@ func _run_join_refused() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
@@ -92,7 +92,7 @@ func _run_join_cancel() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
var join_btn: Button = menu.get_node("CenterContainer/VBoxContainer/JoinRow/JoinButton")
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
await get_tree().create_timer(0.5).timeout
+58 -1
View File
@@ -43,6 +43,61 @@ Everything below needs a person — hardware, a design decision, an external acc
Each item is also a GitHub issue (linked inline), labelled `needs:human` plus a
`P0-blocker``P3-low` priority. Close the issue and tick the box together.
**Priority labels say how much something matters; this says what to do first.**
They differ: #33 is P2 but belongs before the P0 cluster, because standing the
cluster up first means migrating a running one afterwards.
#### Do these in order — each unblocks the next
1. **[#31](https://github.com/jcreek/CosmicClash/issues/31) — answer two
questions.** Which registry namespace (`ghcr.io/cosmic-clash/*` is in every
manifest and no such org exists), and whether packages are public (this repo
is private and no manifest declares `imagePullSecrets`). Publishing needs no
new credential. **This is the highest-leverage thing on the list**: two
answers unblock the whole of Phase 8, and the work behind them is an agent's.
2. **[#33](https://github.com/jcreek/CosmicClash/issues/33) — split the
game-server namespace.** Agent work, no decision owed. Before #17 rather than
after, so the cluster is stood up on the final topology instead of being
migrated later.
3. **[#17](https://github.com/jcreek/CosmicClash/issues/17) — stand up the
cluster.** Needs #31's images to exist. Unblocks the production halves of
most of Phase 8.
4. **[#32](https://github.com/jcreek/CosmicClash/issues/32) — casual backfill.**
Mostly agent work; the design decision is already made. Needs #17 to verify
a late roster reaching a running server.
5. **[#22](https://github.com/jcreek/CosmicClash/issues/22) — release gates.**
Last: needs the cluster and the App ID.
#### Steam, in parallel — long external lead time, start early
6. **[#15](https://github.com/jcreek/CosmicClash/issues/15) — App ID and
publisher key.** Valve coordination, so the calendar time is theirs, not
yours. The adapter is written and config-gated: sign-in returns 503 until
both values are set.
7. **[#16](https://github.com/jcreek/CosmicClash/issues/16) — GodotSteam build
templates.** The client-side ticket code is written and needs the custom
build to run.
#### Unblocked today — nothing is stopping these
- **[#19](https://github.com/jcreek/CosmicClash/issues/19)** then
**[#18](https://github.com/jcreek/CosmicClash/issues/18)**: the 3v3 gate is
the cheaper session to arrange and exercises #18's latency conditions
incidentally, so doing it first can settle both.
**[#20](https://github.com/jcreek/CosmicClash/issues/20)** needs two machines
and the internet, not a cluster.
- **[#24](https://github.com/jcreek/CosmicClash/issues/24)** then
**[#25](https://github.com/jcreek/CosmicClash/issues/25)**: training runs,
independent of everything above.
- **[#21](https://github.com/jcreek/CosmicClash/issues/21)**,
**[#26](https://github.com/jcreek/CosmicClash/issues/26)**,
**[#27](https://github.com/jcreek/CosmicClash/issues/27)**,
**[#28](https://github.com/jcreek/CosmicClash/issues/28)**: hardware, audio,
font, graphics QA. No dependencies, no ordering between them.
- **[#23](https://github.com/jcreek/CosmicClash/issues/23)**,
**[#29](https://github.com/jcreek/CosmicClash/issues/29)**: open design
questions with no deadline. Neither blocks anything.
- [x] ([#14](https://github.com/jcreek/CosmicClash/issues/14)) **Join-signing design decided and implemented.** Resolved as HMAC-SHA256 over the canonical claim bytes with a **key ID inside those bytes**: the allocator signs with one named key while allocated servers hold the set of currently-valid keys, so rotation does not invalidate authorisations already issued for in-flight matches. `allocator.Worker` now publishes the signed roster after binding, and `cmd/allocator` refuses to start without key material. Rotation procedure is in `docs/MATCHMAKING.md` §2; see `multiplayer-next.md` §8.31. Nothing human-only remains here — live verification is covered by [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#18](https://github.com/jcreek/CosmicClash/issues/18)) **Phase 4 playtest at ~100 ms RTT** — does the ship/ball feel local, do contact corrections read as bumps or glitches? Every numeric gate is green; this is a feel judgment no metric can answer. `multiplayer-next.md` §0, gate A.
- [ ] ([#19](https://github.com/jcreek/CosmicClash/issues/19)) **Phase 5 3v3 gate** — a full 6-player match start to finish, with a mid-match disconnect and a late joiner. Only verified so far at 1v1 plus a two-bot CI match. `multiplayer-next.md` §0, gate B.
@@ -51,9 +106,11 @@ Each item is also a GitHub issue (linked inline), labelled `needs:human` plus a
- [ ] ([#16](https://github.com/jcreek/CosmicClash/issues/16)) **Supply custom GodotSteam client/server build templates** and pin them in `steam-dependencies.lock.json` (`COSMIC_CLASH_STEAM_CLIENT_GODOT` / `COSMIC_CLASH_STEAM_SERVER_GODOT`) — `make verify-steam-templates` refuses a stock Godot binary until these exist. `STEAM.md`.
- [ ] ([#21](https://github.com/jcreek/CosmicClash/issues/21)) **Reference-hardware profiling (task 0.15b)** in the live editor on real low/mid-tier hardware — blocks 0.16, 0.17/0.17b/0.17c/0.17d, 0.26 (arena GI bake), and 0.28 (physics separate-thread prototype). Covered above; listed again here because it also gates Phase 5.5's graphics QA gate for multiplayer sign-off.
- [ ] ([#17](https://github.com/jcreek/CosmicClash/issues/17)) **Stand up the live Kubernetes cluster and Agones deployment** for Phase 8 — provider-portable manifests exist, but nothing has run against a real cluster; needs the provider-specific deployment overlay (network, DNS, secrets) per `docs/MATCHMAKING.md`.
- [ ] ([#31](https://github.com/jcreek/CosmicClash/issues/31)) **Build, push and pin the container images the Kubernetes manifests reference.** Every image target builds, but no workflow publishes any of them and all manifest digests are still all-zero placeholders, so `deploy/k8s/base` cannot pull running images. Needs a registry namespace, publish credentials, and a signing/provenance decision; once digests are real, turn on `--require-concrete` in `make verify-supply-chain` so a placeholder can no longer pass. Blocks [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#31](https://github.com/jcreek/CosmicClash/issues/31)) **Build, push and pin the container images the Kubernetes manifests reference.** Every image target builds, but no workflow publishes any of them and all manifest digests are still all-zero placeholders, so `deploy/k8s/base` cannot pull running images. **Only two things need a person**: the `ghcr.io/cosmic-clash/*` namespace in the manifests does not exist (no such org), and this repo is private while no manifest declares `imagePullSecrets`, so package visibility must be chosen. Publishing itself needs no new credential — GHCR accepts the built-in `GITHUB_TOKEN` with `packages: write` — so the workflow, digest pinning and enabling `--require-concrete` are agent work once those two are answered. Blocks [#17](https://github.com/jcreek/CosmicClash/issues/17).
- [ ] ([#33](https://github.com/jcreek/CosmicClash/issues/33)) **Move game servers to their own namespace** so `cosmic-clash` can enforce `restricted` again. Agones' Dynamic port policy needs a `hostPort`, which `baseline`/`restricted` forbid, so the whole namespace dropped to `privileged` — including the control plane, which mounts the database DSN, workload secret and Steam publisher key. Deferred until the Agones gate was green so a new failure could not be ambiguous.
- [x] (no issue — agent-actionable) **Phase 8.48 has its own Compose smoke fixture.** `compose.allocated-smoke.yml` and `scripts/verify_allocated_compose.sh` are independent of `compose.phase6-smoke.yml` — the script states so explicitly and reuses none of its ports — so the allocated-mode flow no longer inherits that fixture's hardcoded port, first-come slots or `--max-matches=2`. Exercised by `make verify-allocated-compose`.
- [ ] ([#23](https://github.com/jcreek/CosmicClash/issues/23)) **Decide the contact-cohort-only client-side shadow world** (open question F in `multiplayer-next.md` §0). A design call about whether contact pairs get a client-side shadow simulation; nothing is blocked on it, and it can stay open indefinitely without holding anything up.
- [ ] ([#32](https://github.com/jcreek/CosmicClash/issues/32)) **Implement casual backfill** — proposal, matcher pass, client offer UI and late roster delivery. Listed here because it has an issue, not because it needs you: the roster-delivery design is decided (`docs/MATCHMAKING.md` § Casual) and candidate selection has landed, so the rest is agent work. End-to-end verification needs the cluster ([#17](https://github.com/jcreek/CosmicClash/issues/17)).
- [ ] ([#22](https://github.com/jcreek/CosmicClash/issues/22)) **Release-evidence and human sign-off gates for Phase 8 production launch** — once the above are done, someone needs to actually run and sign off the production-shaped checks `multiplayer-next.md` §7 lists as infrastructure/production-dependent.
Defect **C** (slot reservation keyed on display name alone — real, demonstrated, exploitable during the 30 s disconnect window) is not its own action item: it is fixed for free by the Steam auth tickets in task 7.4 above, so nothing to do until Steam identity lands.
+113 -3
View File
@@ -105,6 +105,15 @@ port-forwarding requirement and to supply verified player identity —
direct-IP ENet's slot-reclaim logic is keyed by display name today, which is
insecure against a public server (`multiplayer-next.md` §0, known defect C).
There is a **second, independent** use of Steam that does not involve
GodotSteam at all: `server/steam/` verifies session tickets server-side against
Valve's `ISteamUserAuth/AuthenticateUserTicket` Web API over plain HTTP, which
is what turns a claimed identity into a trusted one for matchmaking and for
slot reclaim. It distinguishes "Valve rejected this ticket" (401) from "Valve
is unreachable" (503) so an outage cannot be mistaken for an authentication
failure, and refuses family-shared and banned accounts. It needs a **publisher
Web API key**, which is a server-side secret and must never reach a client.
## Dedicated server hosting: Docker (primary) or native systemd
The dedicated server is not a separately-written service — it's the same
@@ -206,6 +215,81 @@ file and runs **inside the game** in pure GDScript — shipped bots need no
Python, no .NET, no network." Keeping the shipped game GDScript-only (no
.NET Godot build) is consistent with the rest of the stack.
## Version inventory
Everything the project actually pins, in one place. The rest of this document
explains *why* these were chosen; this is *what* is in use. Versions here are
the source of truth's values at the time of writing — when they disagree with
the files named, the files win.
### Shipped game and dedicated server
| Thing | Version | Pinned in |
|---|---|---|
| Godot | 4.7.1 | `Dockerfile` (digest-pinned `barichello/godot-ci`) |
| Physics | Jolt | `Game/project.godot``3d/physics_engine="Jolt Physics"` |
| Runtime dependencies | none | pure GDScript; no .NET, no ONNX, no native extensions in the default build |
| GodotSteam | custom build, opt-in | `steam-dependencies.lock.json` |
The shipped client and server carry **no third-party runtime dependency at
all** in the default ENet build. That is a deliberate constraint, not an
accident of scope — see "What's deliberately absent".
### Matchmaking control plane (Go)
| Thing | Version | Notes |
|---|---|---|
| Go | 1.23 | `server/go.mod` |
| `jackc/pgx/v5` | 5.7.4 | PostgreSQL driver; used through `database/sql` for pooling, and directly for `LISTEN`/`NOTIFY`, which needs a dedicated session |
| `redis/go-redis/v9` | 9.7.0 | transient candidate index only; the durable queue is PostgreSQL |
| `alicebob/miniredis/v2` | 2.38.0 | test-only in-process Redis |
Four direct dependencies, three of them drivers. There is no web framework, no
ORM, no DI container and no code generation: HTTP is `net/http` with a hand-
written mux (`server/api/service.go`), SQL is hand-written, and migrations are
numbered `.sql` files under `server/migrations/` — each with a `down/`
counterpart — applied by the `cmd/migrate` binary. That is a deliberate choice
about a service whose whole job is a small number of carefully-fenced
transactions.
Rating maths is Glicko-2, implemented in `server/domain/rating.go` rather than
taken from a library.
### Datastores and platform
| Thing | Version | Pinned in |
|---|---|---|
| PostgreSQL | 17 (alpine) | `compose.*.yml`, `scripts/run_*_integration.sh` |
| Redis | 7 (alpine) | `compose.*.yml`, `scripts/run_redis_integration.sh` |
| Agones | 1.49.0 | `scripts/verify_kind_agones.sh` (`AGONES_VERSION`) |
| Kubernetes | 1.33 in CI | `kindest/node:v1.33.1` |
| Manifests | Kustomize | `deploy/k8s/base` + `overlays/{eu,na}` |
| Metrics | Prometheus | `deploy/observability/` — ServiceMonitors and PrometheusRules |
Container images are referenced by digest, never by tag; `scripts/verify_supply_chain.py`
fails the build on any mutable reference. All six digests under `deploy/` are
currently all-zero placeholders, and the `ghcr.io/cosmic-clash/*` registry
namespace does not exist yet — publishing the images is the open work tracked
in issue #31, and is the last thing standing between the manifests and a real
deployment.
### Training (out-of-process, not shipped)
| Thing | Version |
|---|---|
| Python | 3.12 |
| `godot-rl` | 0.8.2 |
| `stable-baselines3` | 2.4.0 |
| `torch` | 2.13.0 |
| `gymnasium` | 1.0.0 |
| `tensorboard` | 2.21.0 |
Pinned exactly, and `training/requirements.txt` explains why in unusual detail:
the curriculum depends on specific library *internals* rather than documented
public APIs, so an unpinned reinstall could silently change behaviour partway
through a 12-hour training stage. None of this ships — the game runs exported
policies through a pure-GDScript MLP.
## Tooling (not shipped with the game)
- **`mcp/godot-mcp`** (git submodule, Node/TypeScript) — drives a live
@@ -218,6 +302,29 @@ Python, no .NET, no network." Keeping the shipped game GDScript-only (no
(Blender's embedded Python, plus texture generators) used to produce the
project's original meshes and textures.
### Verification toolchain
Everything is driven from `Makefile` targets so that CI and a local run are the
same command:
- **GNU Make** — the single entry point (`verify-phase6`,
`verify-enet-integration`, `verify-kind-agones`, `verify-supply-chain`, …).
- **Docker and Docker Compose** — the multi-process gates. The game gates use
a staged `Dockerfile`; the control-plane gates use `compose.*.yml` fixtures.
- **kind** (`kindest/node:v1.33.1`) **and Helm** — a throwaway Kubernetes
cluster with Agones installed, for the allocation gate.
- **Kustomize** — `deploy/k8s/base` plus `overlays/{eu,na}`, validated by
`kubectl kustomize` in CI rather than only at deploy time.
- **GitHub Actions** — eight workflows under `.github/workflows/`, each one a
thin wrapper around a Make target, with path filters so a docs-only change
doesn't spin up a Kubernetes cluster.
- **`scripts/verify_supply_chain.py`** — fails the build on any mutable image
reference, which is why every manifest pins by digest.
Godot itself has **no build step and no linter** — the project runs from
source, so "the tests pass" is the only mechanical check that exists on the
GDScript side.
## What's deliberately absent
- **No C# or .NET runtime anywhere in the shipped game or server.** The
@@ -238,8 +345,11 @@ Python, no .NET, no network." Keeping the shipped game GDScript-only (no
- **The remaining Go matchmaking control-plane deployment** — independently
runnable matcher, allocator and maintenance roles backed by PostgreSQL and
Redis, deployed on provider-portable Kubernetes with Agones-managed game
fleets. The authenticated API boundary exists; durable production wiring and
provider deployment remain. The cloud provider remains deliberately
replaceable; the application stack is locked.
fleets. The durable wiring now exists end to end — queue, latency probes,
proposal, allocation, signed assignment rosters and result submission — and
is exercised by Compose and kind/Agones gates in CI. What remains is the
provider deployment itself: a registry to publish the images to, and a live
cluster. The cloud provider remains deliberately replaceable; the application
stack is locked.
This is a 1.0 launch blocker and the single largest departure from "one
Godot project, no backend". See [`MATCHMAKING.md`](MATCHMAKING.md).
+1 -1
View File
@@ -237,7 +237,7 @@ are done; everything below is what's left on the tasks still open.
| 8.8 `[D:8.7]` | Session policy (opaque tokens, digests, revocation) | Distributed revocation coordination is done by construction: sessions are durable and `PostgresSessions.Authenticate` reads the row on every authenticated request, so a revocation takes effect immediately on every replica without any cross-replica protocol, and `ApplyIdentityBan` revokes an identity's sessions in the same transaction as the ban. Live Steam/session integration remains ([#15](https://github.com/jcreek/CosmicClash/issues/15)) |
| 8.9 `[D:8.4,8.7]` | Join policy, durable reconnect leases | Live PostgreSQL/Godot process-restart and outage recovery verification remains |
| 8.10 `[D:8.5,8.31]` | Workload credential policy (signed tokens, not Kubernetes JWTs), delivery channel, conflict alerting | Never run against a real Agones cluster; alert validated only statically, not against live Prometheus/Alertmanager traffic |
| 8.12 `[D:8.11]` | Kubernetes hardening baseline, rate/quota limiting, degraded-mode gate | Private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, encrypted backups, live policy/load tests remain |
| 8.12 `[D:8.11]` | Kubernetes hardening baseline, rate/quota limiting, degraded-mode gate | Private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, encrypted backups, live policy/load tests remain. The workload namespace currently enforces `privileged` because Agones' Dynamic port policy injects a `hostPort` that `baseline`/`restricted` forbid; splitting game servers into their own namespace so `cosmic-clash` can enforce `restricted` again is tracked by [#33](https://github.com/jcreek/CosmicClash/issues/33) |
| 8.13 `[D:8.12]` | Digest-pinned images, supply-chain policy checker | Registry SBOM/scan/sign/admission execution and a concrete production overlay remain — the build-and-pin half is tracked by [#31](https://github.com/jcreek/CosmicClash/issues/31) |
#### 8C — Queueing, matchmaking, playlists and rating
+7 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
set -Eeuo pipefail
# Independent allocated-flow fixture for multiplayer-next.md §8.48. This
# intentionally does not call compose.phase6-smoke.yml or reuse its ports.
@@ -88,7 +88,12 @@ for attempt in $(seq 1 180); do
if "${compose[@]}" logs game-server 2>/dev/null | grep -q ' server_started '; then
break
fi
if ! "${compose[@]}" ps --status running --services | grep -qx game-server; then
# Ask whether it EXITED, not whether it is absent from the running list.
# Those differ: a container that has been created but has not started yet is
# missing from --status running too, so the previous check called a
# still-starting server dead on the first poll. It failed intermittently
# against a game server whose own logs showed a clean `server_started`.
if "${compose[@]}" ps -a --status exited --services 2>/dev/null | grep -qx game-server; then
"${compose[@]}" logs game-server >&2
echo "allocated Compose game server exited before becoming ready" >&2
exit 1
+243
View File
@@ -1305,5 +1305,248 @@
"team_1": 46
},
"win_rate_a": 0.6
},
{
"timestamp": "2026-09-01T21:58:06+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 32,
"wins_b": 50,
"draws": 18,
"side_results": {
"a_team_0": {
"wins_a": 14,
"wins_b": 29,
"draws": 7
},
"a_team_1": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
}
},
"physical_team_wins": {
"team_0": 35,
"team_1": 47
},
"win_rate_a": 0.32
},
{
"timestamp": "2026-09-01T21:59:34+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 79,
"wins_b": 15,
"draws": 6,
"side_results": {
"a_team_0": {
"wins_a": 40,
"wins_b": 9,
"draws": 1
},
"a_team_1": {
"wins_a": 39,
"wins_b": 6,
"draws": 5
}
},
"physical_team_wins": {
"team_0": 46,
"team_1": 48
},
"win_rate_a": 0.79
},
{
"timestamp": "2026-09-01T22:01:35+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 54,
"wins_b": 29,
"draws": 17,
"side_results": {
"a_team_0": {
"wins_a": 26,
"wins_b": 18,
"draws": 6
},
"a_team_1": {
"wins_a": 28,
"wins_b": 11,
"draws": 11
}
},
"physical_team_wins": {
"team_0": 37,
"team_1": 46
},
"win_rate_a": 0.54
},
{
"timestamp": "2026-09-03T10:42:38+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 31,
"wins_b": 50,
"draws": 19,
"side_results": {
"a_team_0": {
"wins_a": 16,
"wins_b": 24,
"draws": 10
},
"a_team_1": {
"wins_a": 15,
"wins_b": 26,
"draws": 9
}
},
"physical_team_wins": {
"team_0": 42,
"team_1": 39
},
"win_rate_a": 0.31
},
{
"timestamp": "2026-09-03T10:44:05+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 80,
"wins_b": 14,
"draws": 6,
"side_results": {
"a_team_0": {
"wins_a": 38,
"wins_b": 9,
"draws": 3
},
"a_team_1": {
"wins_a": 42,
"wins_b": 5,
"draws": 3
}
},
"physical_team_wins": {
"team_0": 43,
"team_1": 51
},
"win_rate_a": 0.8
},
{
"timestamp": "2026-09-03T10:46:05+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 56,
"wins_b": 30,
"draws": 14,
"side_results": {
"a_team_0": {
"wins_a": 30,
"wins_b": 14,
"draws": 6
},
"a_team_1": {
"wins_a": 26,
"wins_b": 16,
"draws": 8
}
},
"physical_team_wins": {
"team_0": 46,
"team_1": 40
},
"win_rate_a": 0.56
},
{
"timestamp": "2026-09-04T23:58:01+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 37,
"wins_b": 46,
"draws": 17,
"side_results": {
"a_team_0": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
},
"a_team_1": {
"wins_a": 19,
"wins_b": 25,
"draws": 6
}
},
"physical_team_wins": {
"team_0": 43,
"team_1": 40
},
"win_rate_a": 0.37
},
{
"timestamp": "2026-09-04T23:59:37+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 83,
"wins_b": 12,
"draws": 5,
"side_results": {
"a_team_0": {
"wins_a": 39,
"wins_b": 7,
"draws": 4
},
"a_team_1": {
"wins_a": 44,
"wins_b": 5,
"draws": 1
}
},
"physical_team_wins": {
"team_0": 44,
"team_1": 51
},
"win_rate_a": 0.83
},
{
"timestamp": "2026-09-05T00:01:25+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 61,
"wins_b": 26,
"draws": 13,
"side_results": {
"a_team_0": {
"wins_a": 29,
"wins_b": 14,
"draws": 7
},
"a_team_1": {
"wins_a": 32,
"wins_b": 12,
"draws": 6
}
},
"physical_team_wins": {
"team_0": 41,
"team_1": 46
},
"win_rate_a": 0.61
}
]
+421 -1
View File
@@ -1,6 +1,6 @@
{
"stage_index": 2,
"attempt": 1,
"attempt": 4,
"status": "in_progress",
"log": [
{
@@ -1334,6 +1334,426 @@
}
],
"decision": "fail"
},
{
"stage_index": 2,
"stage_number": 6,
"stage_name": "league",
"experiment": "20260831-0724-gen5-s6-league-retry1",
"attempt": 1,
"telemetry_tail": {
"rollout/air_touch_fraction": 0.00025999999418854715,
"rollout/airborne_fraction": 0.37644733387231827,
"rollout/ball_above_air_touch_fraction": 0.3837828570008278,
"rollout/ball_mean_altitude": 3.9463526849746704,
"rollout/ball_peak_altitude": 5.105142880439758,
"rollout/ep_len_mean": 84.73158009338378,
"rollout/ep_rew_mean": 29.349980991363527,
"rollout/forward_motion_fraction": 0.4234337618947029,
"rollout/goal_rate": 0.8689000006914139,
"rollout/grounded_upright_fraction": 0.1273200002387166,
"rollout/mean_altitude": 3.745820795059204,
"rollout/productive_air_touch_episode_fraction": 0.00011999999731779099,
"rollout/productive_air_touch_fraction": 0.00011999999731779099,
"rollout/upright_fraction": 0.7569749516248703,
"rollout/vertical_thrust_mean": 0.16791499748826028
},
"telemetry_failures": [
"rollout/productive_air_touch_episode_fraction=0.0001 < 0.0150"
],
"evaluation_goal_failures": [],
"side_balance_failures": [],
"eval": {
"timestamp": "2026-09-01T21:58:06+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 32,
"wins_b": 50,
"draws": 18,
"side_results": {
"a_team_0": {
"wins_a": 14,
"wins_b": 29,
"draws": 7
},
"a_team_1": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
}
},
"physical_team_wins": {
"team_0": 35,
"team_1": 47
},
"win_rate_a": 0.32
},
"evals": [
{
"timestamp": "2026-09-01T21:58:06+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 32,
"wins_b": 50,
"draws": 18,
"side_results": {
"a_team_0": {
"wins_a": 14,
"wins_b": 29,
"draws": 7
},
"a_team_1": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
}
},
"physical_team_wins": {
"team_0": 35,
"team_1": 47
},
"win_rate_a": 0.32
},
{
"timestamp": "2026-09-01T21:59:34+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 79,
"wins_b": 15,
"draws": 6,
"side_results": {
"a_team_0": {
"wins_a": 40,
"wins_b": 9,
"draws": 1
},
"a_team_1": {
"wins_a": 39,
"wins_b": 6,
"draws": 5
}
},
"physical_team_wins": {
"team_0": 46,
"team_1": 48
},
"win_rate_a": 0.79
},
{
"timestamp": "2026-09-01T22:01:35+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260831-0724-gen5-s6-league-retry1.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 54,
"wins_b": 29,
"draws": 17,
"side_results": {
"a_team_0": {
"wins_a": 26,
"wins_b": 18,
"draws": 6
},
"a_team_1": {
"wins_a": 28,
"wins_b": 11,
"draws": 11
}
},
"physical_team_wins": {
"team_0": 37,
"team_1": 46
},
"win_rate_a": 0.54
}
],
"decision": "fail"
},
{
"stage_index": 2,
"stage_number": 6,
"stage_name": "league",
"experiment": "20260901-2301-gen5-s6-league-retry2",
"attempt": 2,
"telemetry_tail": {
"rollout/air_touch_fraction": 0.00041999999061226843,
"rollout/airborne_fraction": 0.37447328644990924,
"rollout/ball_above_air_touch_fraction": 0.38510095232725144,
"rollout/ball_mean_altitude": 3.9452713265419006,
"rollout/ball_peak_altitude": 5.121470984458924,
"rollout/ep_len_mean": 88.07664016723633,
"rollout/ep_rew_mean": 29.80411368083954,
"rollout/forward_motion_fraction": 0.43738709545135496,
"rollout/goal_rate": 0.8521000001430511,
"rollout/grounded_upright_fraction": 0.15363000041246413,
"rollout/mean_altitude": 3.7138527517318725,
"rollout/productive_air_touch_episode_fraction": 0.0001599999964237213,
"rollout/productive_air_touch_fraction": 0.0001599999964237213,
"rollout/upright_fraction": 0.7552459052801133,
"rollout/vertical_thrust_mean": 0.15257299809571356
},
"telemetry_failures": [
"rollout/productive_air_touch_episode_fraction=0.0002 < 0.0150"
],
"evaluation_goal_failures": [],
"side_balance_failures": [],
"eval": {
"timestamp": "2026-09-03T10:42:38+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 31,
"wins_b": 50,
"draws": 19,
"side_results": {
"a_team_0": {
"wins_a": 16,
"wins_b": 24,
"draws": 10
},
"a_team_1": {
"wins_a": 15,
"wins_b": 26,
"draws": 9
}
},
"physical_team_wins": {
"team_0": 42,
"team_1": 39
},
"win_rate_a": 0.31
},
"evals": [
{
"timestamp": "2026-09-03T10:42:38+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 31,
"wins_b": 50,
"draws": 19,
"side_results": {
"a_team_0": {
"wins_a": 16,
"wins_b": 24,
"draws": 10
},
"a_team_1": {
"wins_a": 15,
"wins_b": 26,
"draws": 9
}
},
"physical_team_wins": {
"team_0": 42,
"team_1": 39
},
"win_rate_a": 0.31
},
{
"timestamp": "2026-09-03T10:44:05+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 80,
"wins_b": 14,
"draws": 6,
"side_results": {
"a_team_0": {
"wins_a": 38,
"wins_b": 9,
"draws": 3
},
"a_team_1": {
"wins_a": 42,
"wins_b": 5,
"draws": 3
}
},
"physical_team_wins": {
"team_0": 43,
"team_1": 51
},
"win_rate_a": 0.8
},
{
"timestamp": "2026-09-03T10:46:05+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260901-2301-gen5-s6-league-retry2.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 56,
"wins_b": 30,
"draws": 14,
"side_results": {
"a_team_0": {
"wins_a": 30,
"wins_b": 14,
"draws": 6
},
"a_team_1": {
"wins_a": 26,
"wins_b": 16,
"draws": 8
}
},
"physical_team_wins": {
"team_0": 46,
"team_1": 40
},
"win_rate_a": 0.56
}
],
"decision": "fail"
},
{
"stage_index": 2,
"stage_number": 6,
"stage_name": "league",
"experiment": "20260903-1146-gen5-s6-league-retry3",
"attempt": 3,
"telemetry_tail": {
"rollout/air_touch_fraction": 0.0003199999928474426,
"rollout/airborne_fraction": 0.38117876160144804,
"rollout/ball_above_air_touch_fraction": 0.38150414246320724,
"rollout/ball_mean_altitude": 3.9262821292877197,
"rollout/ball_peak_altitude": 5.08552738904953,
"rollout/ep_len_mean": 81.23168013000489,
"rollout/ep_rew_mean": 32.9141486377716,
"rollout/forward_motion_fraction": 0.44576871436834337,
"rollout/goal_rate": 0.8812400003671647,
"rollout/grounded_upright_fraction": 0.14325000028312207,
"rollout/mean_altitude": 3.7847617926597596,
"rollout/productive_air_touch_episode_fraction": 9.999999776482581e-05,
"rollout/productive_air_touch_fraction": 9.999999776482581e-05,
"rollout/upright_fraction": 0.756700856924057,
"rollout/vertical_thrust_mean": 0.15271599750965834
},
"telemetry_failures": [
"rollout/productive_air_touch_episode_fraction=0.0001 < 0.0150"
],
"evaluation_goal_failures": [],
"side_balance_failures": [],
"eval": {
"timestamp": "2026-09-04T23:58:01+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 37,
"wins_b": 46,
"draws": 17,
"side_results": {
"a_team_0": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
},
"a_team_1": {
"wins_a": 19,
"wins_b": 25,
"draws": 6
}
},
"physical_team_wins": {
"team_0": 43,
"team_1": 40
},
"win_rate_a": 0.37
},
"evals": [
{
"timestamp": "2026-09-04T23:58:01+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260828-0214-gen5-s5-intercepts-retry4.json",
"seed": 1,
"episodes": 100,
"wins_a": 37,
"wins_b": 46,
"draws": 17,
"side_results": {
"a_team_0": {
"wins_a": 18,
"wins_b": 21,
"draws": 11
},
"a_team_1": {
"wins_a": 19,
"wins_b": 25,
"draws": 6
}
},
"physical_team_wins": {
"team_0": 43,
"team_1": 40
},
"win_rate_a": 0.37
},
{
"timestamp": "2026-09-04T23:59:37+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260806-1939-curric-s3-gauntlet.json",
"seed": 1,
"episodes": 100,
"wins_a": 83,
"wins_b": 12,
"draws": 5,
"side_results": {
"a_team_0": {
"wins_a": 39,
"wins_b": 7,
"draws": 4
},
"a_team_1": {
"wins_a": 44,
"wins_b": 5,
"draws": 1
}
},
"physical_team_wins": {
"team_0": 44,
"team_1": 51
},
"win_rate_a": 0.83
},
{
"timestamp": "2026-09-05T00:01:25+00:00",
"model_a": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260903-1146-gen5-s6-league-retry3.json",
"model_b": "/home/jcreek/ai-training/CosmicClash/Game/bots/20260816-2126-gen5-s4-handling-retry2.json",
"seed": 1,
"episodes": 100,
"wins_a": 61,
"wins_b": 26,
"draws": 13,
"side_results": {
"a_team_0": {
"wins_a": 29,
"wins_b": 14,
"draws": 7
},
"a_team_1": {
"wins_a": 32,
"wins_b": 12,
"draws": 6
}
},
"physical_team_wins": {
"team_0": 41,
"team_1": 46
},
"win_rate_a": 0.61
}
],
"decision": "fail"
}
]
}