Commit
Theria
feat: add an in-game connect menu for host/join/practice
modified CHANGELOG.md
@@ -35,6 +35,11 @@ protocol version.
### Added
- An in-game connect screen: a windowed launch now opens a menu to start a
single-machine practice match, host a listen-server, or join one by address,
instead of requiring command-line flags. The flags still work and skip the menu
(`-- --host`, `-- --join <address>`, `-- --local`); a headless launch with no flag
still defaults to a single-machine match, so the automated tooling is unchanged.
- Networked multiplayer over a listen-server: one player hosts the authoritative
match and a second joins over the network, each driving their own hero while the
host simulates and broadcasts the world every tick. Peers exchange a protocol
modified README.md
@@ -68,7 +68,7 @@ delay adapts to the connection's measured jitter rather than being fixed.
| `src/sim` | The authoritative simulation core and its data types. |
| `src/bot` | Bot input derived from the world state. |
| `src/net` | Listen-server transport, the client/server wire protocol, remote-entity interpolation, and the playtest link-condition simulator. |
| `src/client` | Local input sampling and rendering. |
| `src/client` | The connect menu, local input sampling, and rendering. |
| `test/unit` | Headless tests of the simulation and the wire protocol. |
| `scenes` | Godot scenes. |
@@ -80,16 +80,20 @@ Open the project in Godot 4.6 and press Play, or from the command line:
godot --path .
```
Move the hero with **WASD** or the **arrow keys**; the bot walks toward it.
A connect screen opens: choose **Practice** for a single-machine match, **Host** to
start a listen-server, or type an address and **Join** one. Move the hero with
**WASD** or the **arrow keys**; the bot walks toward it.
### Multiplayer
Pass arguments after `--` to choose a role; with neither, the game runs on a
single machine. One peer hosts and a second joins it:
The connect screen's **Host** and **Join** cover multiplayer. The same roles can be
selected on the command line, which skips the menu — this is how a headless run picks
a role, since a menu cannot be driven without a display:
```sh
godot --path . -- --host # host the match (you are team 0)
godot --path . -- --join 127.0.0.1 # join a host at an address (you are team 1)
godot --path . -- --local # a single-machine match, no menu
```
The host is authoritative and fills any empty player slot with a bot. The joining
added src/client/connect_menu.gd
@@ -0,0 +1,83 @@
class_name ConnectMenu
extends Control
## The in-game connect screen, shown on a windowed launch with no mode flag. It
## lets the player start a single-machine practice match, host a listen-server, or
## join one by address — the same three modes the command line selects with
## `--local`, `--host`, and `--join`, surfaced as UI so a player never needs flags.
##
## Pure presentation: it owns no networking and no simulation, only emitting a
## signal for the chosen mode. `main.gd` wires those signals to the existing
## `_start_*` paths, so the menu adds an entry point without touching authority or
## the wire. A headless run skips it — a menu cannot be driven without a display —
## and the command-line flags stay the automation path.
## The player chose to host a listen-server.
signal host_requested
## The player chose to join a server at `address` (already resolved to the default
## when the field was left blank).
signal join_requested(address: String)
## The player chose a single-machine practice match.
signal practice_requested
## The address used when the player leaves the field blank. The driver injects its
## own default so the menu and the `--join` flag resolve to one value.
var default_address := "127.0.0.1"
var _address_field: LineEdit
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 16)
center.add_child(box)
var title := Label.new()
title.text = "Ashmere"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(title)
var practice_button := Button.new()
practice_button.text = "Practice (single machine)"
practice_button.pressed.connect(_on_practice_pressed)
box.add_child(practice_button)
var host_button := Button.new()
host_button.text = "Host a match"
host_button.pressed.connect(_on_host_pressed)
box.add_child(host_button)
var join_row := HBoxContainer.new()
box.add_child(join_row)
_address_field = LineEdit.new()
_address_field.placeholder_text = default_address
_address_field.custom_minimum_size = Vector2(220, 0)
join_row.add_child(_address_field)
var join_button := Button.new()
join_button.text = "Join"
join_button.pressed.connect(_on_join_pressed)
join_row.add_child(join_button)
func _on_practice_pressed() -> void:
practice_requested.emit()
func _on_host_pressed() -> void:
host_requested.emit()
## Resolves the typed address — falling back to `default_address` when blank — and
## emits `join_requested`. Trimmed so stray whitespace is not taken as a host name.
func _on_join_pressed() -> void:
var address := _address_field.text.strip_edges()
if address.is_empty():
address = default_address
join_requested.emit(address)
added src/client/connect_menu.gd.uid
@@ -0,0 +1 @@
uid://boy1rnij1iaxp
modified src/client/main.gd
@@ -1,7 +1,9 @@
extends Node2D
## Presentation + driver for the v0.1 match. It runs in one of three modes,
## selected from the command line (`-- --host`, `-- --join [address]`, or nothing
## for a single-machine game):
## Presentation + driver for the v0.1 match. It runs in one of three modes. A
## windowed launch with no mode flag opens a connect menu to pick one; the command
## line selects one directly (`-- --host`, `-- --join [address]`, `-- --local`); and
## a headless launch with no flag defaults to LOCAL, so the automated smokes need no
## menu and stay flag-driven:
##
## LOCAL — owns the authoritative SimCore and drives both heroes (player + bot),
## exactly the single-machine walking skeleton.
@@ -72,6 +74,16 @@ const HP_BAR_FG := Color(0.4, 0.85, 0.4)
var _mode: int = Mode.LOCAL
var _join_address := DEFAULT_JOIN_ADDRESS
## True once a mode flag (`--host`/`--join`/`--local`) was passed, so a flagged or
## headless launch enters the match directly and a bare windowed launch shows the menu.
var _explicit_mode := false
## The connect-menu overlay while it is up; freed once a mode is chosen. Null on a
## flagged or headless launch (the menu never opens) and after the match begins.
var _menu_layer: CanvasLayer = null
## False until a mode has started; gates the per-tick driver and entity draw so the
## menu can sit over a static backdrop with no simulation running behind it.
var _started := false
## CLIENT: optional simulated link conditions parsed from `--netsim
## <latency>,<jitter>,<loss>`, as `[latency_ms, jitter_ms, loss]`, or empty to take
## snapshots as they arrive. A debug aid for exercising the smoothing under a worse
@@ -108,17 +120,16 @@ var _pending_inputs: Array[Dictionary] = []
func _ready() -> void:
_configure_from_cmdline()
match _mode:
Mode.HOST:
_start_host()
Mode.CLIENT:
_start_client()
_:
_start_local()
if _explicit_mode or _is_headless():
_enter_match()
else:
_open_connect_menu()
queue_redraw()
func _physics_process(_delta: float) -> void:
if not _started:
return
match _mode:
Mode.HOST:
_tick_host()
@@ -139,11 +150,16 @@ func _configure_from_cmdline() -> void:
var arg := args[i]
if arg == "--host":
_mode = Mode.HOST
_explicit_mode = true
elif arg == "--join":
_mode = Mode.CLIENT
_explicit_mode = true
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_join_address = args[i + 1]
i += 1
elif arg == "--local":
_mode = Mode.LOCAL
_explicit_mode = true
elif arg == "--netsim":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_netsim_params = _parse_netsim(args[i + 1])
@@ -170,6 +186,67 @@ func _parse_netsim(value: String) -> Array:
]
## Dispatches to the selected mode and marks the match live, so the per-tick driver
## and entity draw begin. The single entry point for both the command-line path and a
## menu choice.
func _enter_match() -> void:
match _mode:
Mode.HOST:
_start_host()
Mode.CLIENT:
_start_client()
_:
_start_local()
_started = true
queue_redraw()
## A headless run cannot drive a menu (no display, no pointer), so it always takes a
## mode from the command line — defaulting to LOCAL — and never opens the connect
## screen. This keeps the automated smokes flag-driven and non-interactive.
func _is_headless() -> bool:
return DisplayServer.get_name() == "headless"
## Opens the connect menu over a static map backdrop and waits: the match begins only
## once the player picks a mode. Built in code on its own CanvasLayer so it renders in
## screen space, above the world the zoomed game camera draws.
func _open_connect_menu() -> void:
var menu := ConnectMenu.new()
menu.default_address = DEFAULT_JOIN_ADDRESS
menu.practice_requested.connect(_on_practice_requested)
menu.host_requested.connect(_on_host_requested)
menu.join_requested.connect(_on_join_requested)
_menu_layer = CanvasLayer.new()
_menu_layer.add_child(menu)
add_child(_menu_layer)
func _on_practice_requested() -> void:
_mode = Mode.LOCAL
_close_menu_and_enter()
func _on_host_requested() -> void:
_mode = Mode.HOST
_close_menu_and_enter()
func _on_join_requested(address: String) -> void:
_mode = Mode.CLIENT
_join_address = address
_close_menu_and_enter()
## Tears down the connect overlay and enters the chosen match. Shared by every menu
## choice so the menu always leaves the tree exactly once, before the match runs.
func _close_menu_and_enter() -> void:
if _menu_layer != null:
_menu_layer.queue_free()
_menu_layer = null
_enter_match()
func _start_local() -> void:
_sim = SimCore.new()
_sim.spawn_structures()
@@ -362,7 +439,8 @@ func _active_state() -> SimState:
func _draw() -> void:
_draw_map()
_draw_entities()
if _started:
_draw_entities()
func _draw_map() -> void:
added test/unit/test_connect_menu.gd
@@ -0,0 +1,45 @@
extends GutTest
## Behavioural checks on the connect menu — the screen shown on a windowed launch
## with no mode flag. They verify each choice maps to the right signal and that a
## blank address falls back to the injected default. The menu owns no networking, so
## this is the whole of its logic; the driver wiring and the live socket are the host
## smoke's job, not these unit tests.
# Builds the menu in the scene tree so `_ready` lays out its controls (the address
# field, the buttons), and frees it when the test ends.
func _menu() -> ConnectMenu:
var menu := ConnectMenu.new()
add_child_autoqfree(menu)
return menu
func test_practice_choice_requests_a_local_match() -> void:
var menu := _menu()
watch_signals(menu)
menu._on_practice_pressed()
assert_signal_emitted(menu, "practice_requested")
func test_host_choice_requests_a_host() -> void:
var menu := _menu()
watch_signals(menu)
menu._on_host_pressed()
assert_signal_emitted(menu, "host_requested")
func test_join_carries_the_typed_address() -> void:
var menu := _menu()
menu._address_field.text = "10.0.0.7"
watch_signals(menu)
menu._on_join_pressed()
assert_signal_emitted_with_parameters(menu, "join_requested", ["10.0.0.7"])
func test_blank_address_falls_back_to_the_default() -> void:
var menu := _menu()
menu.default_address = "203.0.113.9"
menu._address_field.text = " " # whitespace only is treated as blank
watch_signals(menu)
menu._on_join_pressed()
assert_signal_emitted_with_parameters(menu, "join_requested", ["203.0.113.9"])
added test/unit/test_connect_menu.gd.uid
@@ -0,0 +1 @@
uid://b6fs07gah3ig3