mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
Compare commits
14 Commits
aac00f148e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c02aad66a0 | |||
| 076d27a564 | |||
| 00b900d864 | |||
| fe453ab607 | |||
| 52cc478b38 | |||
| 4ea72be581 | |||
| 4560d2de8a | |||
| 4912837dd7 | |||
| 6983ddd7df | |||
| 7d247bc516 | |||
| 4519f2db82 | |||
| 0f1a7403e4 | |||
| 8a55c33666 | |||
| ad6f9cc148 |
@@ -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 1–6). 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 1–6). 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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk7plirjfqvld
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjtdsbem7kwdv
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user