Add in-game chat, bot fill for casual, and borderless fullscreen

- In-game chat: T=all, Y=team (race doubles as team), server-relayed and
  team-filtered via new ChatManager autoload; last 10 messages shown
  bottom-left (chat/chat_box.gd).
- Bot fill for casual (bots/): keeps each of the match's 2 offered races
  at a minimum of 7 total humans+bots, spawning/despawning reactively as
  players join/leave. Bots always fly their race's fighter and use
  negative peer_ids so they ride the existing networked-ship stack
  (spawning, loadout sync, health/position sync, bullet attribution) with
  no special-casing. Casual matchmaking now forms with just 1 real player
  queued instead of waiting for a second (matchmaking-api/).
- Borderless fullscreen with stretch scaling disabled instead of scaling
  the Steam-Deck-matched 1280x800 canvas up to fill PC monitors (which
  read as zoomed in) — PC monitors now reveal more world/HUD at native
  size instead. Reworked main_menu/team_select/chat_box to position via
  anchors relative to the real window instead of hardcoded coordinates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:02:12 -04:00
parent edcda92812
commit c0b5f421c5
28 changed files with 705 additions and 79 deletions
+3 -1
View File
@@ -27,11 +27,13 @@ README for how to run/extend it.
5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships/<race>/`); placeholder `example_ships` removed 5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships/<race>/`); placeholder `example_ships` removed
6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`) 6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`)
7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks 7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks
8. **In-game chat** — T focuses "all" chat (whole match, both teams), Y focuses "team" chat (peers sharing the sender's race, since race doubles as team); Enter sends, Esc cancels; last 10 messages shown bottom-left, WoW-style translucent log + input line (`chat/chat_box.gd`); server-relayed and team-filtered via `ChatManager` autoload (`autoload/chat_manager.gd`)
9. **Bot fill for casual** — each of the match's 2 offered races is kept at a minimum of `GameConfig.bot_min_team_size` (7) total humans+bots; bots spawn/despawn reactively as players join/leave (`bots/bot_manager.gd`, server-authoritative autoload). Bots always fly their race's fighter/Interceptor (`race.ships[0]`) and use negative peer_ids, which lets them ride every existing networked-ship system (spawning, loadout replication, position/health sync, bullet attribution) with zero special-casing. AI (`bots/bot_ai.gd`, `class_name BotAI`) is a simple seek-nearest-enemy-and-shoot brain, deliberately isolated from ship networking code so future tuning/behavior changes stay contained to that one file. See `bots.md`. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — see `matchmaking-api/README.md`.
10. **Borderless fullscreen, no UI/world scaling** — launches at the real screen resolution with stretch mode disabled (1 game pixel = 1 screen pixel), instead of scaling the Steam-Deck-matched 1280×800 design canvas up to fill PC monitors (which looked zoomed in). PC monitors now just reveal more world/HUD instead. Every menu screen (`main_menu.gd`, `team_select.gd`, `chat/chat_box.gd`) was reworked to position itself via anchors relative to the real window instead of hardcoded 1280×800 pixel coordinates — see `structure.md`'s Display section, including a real `push_opposite_anchor` footgun hit along the way. `menu/server_browser.gd` was NOT updated (still hardcoded, but unreachable in the live flow).
## Current Tasks ## Current Tasks
- [ ] Asteroids and environment hazards - [ ] Asteroids and environment hazards
- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`) — next logical step now that matchmaking works, since `CASUAL_TEAM_SIZE` is set low (1) for solo/duo testing on the assumption bots will pad real matches later
- [ ] Sound effects (thrust, shoot, explosion, UI) - [ ] Sound effects (thrust, shoot, explosion, UI)
- [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry - [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) - [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
+3
View File
@@ -1,6 +1,9 @@
DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking
# Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5). # Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5).
# CASUAL_TEAM_SIZE no longer gates when a casual match forms (bot fill means
# 1 real player is enough — see queue_manager.py); it only caps how many real
# players group into one casual match.
CASUAL_TEAM_SIZE=7 CASUAL_TEAM_SIZE=7
RANKED_TEAM_SIZE=5 RANKED_TEAM_SIZE=5
+9 -4
View File
@@ -33,10 +33,15 @@ rebuilding the image. Rebuild (`docker compose up -d --build`) only when
returns the assigned server's `server_ip`/`server_port`/`team`. returns the assigned server's `server_ip`/`server_port`/`team`.
- `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket. - `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket.
A background loop (`app/queue_manager.py`) runs every 2s, and once enough A background loop (`app/queue_manager.py`) runs every 2s and forms a match
players are queued for a mode (`CASUAL_TEAM_SIZE`/`RANKED_TEAM_SIZE` × 2) it once enough players are queued for a mode, then splits them into two teams
forms a match, splits them into two teams, and assigns the first available and assigns the first available `GameServer` for that mode. Casual and
`GameServer` for that mode. ranked differ here: casual has bot fill (`spacewar/bots/bot_manager.gd`
pads both teams up to `GameConfig.bot_min_team_size` in-game), so 1 queued
player is enough to form a casual match — `CASUAL_TEAM_SIZE` just caps how
many real players can group into one match, it no longer gates formation.
Ranked has no bots, so it still waits for the full `RANKED_TEAM_SIZE × 2`
real players before forming.
There's no real game-server pool yet — one dev server is auto-seeded on API There's no real game-server pool yet — one dev server is auto-seeded on API
startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to
+16 -5
View File
@@ -65,13 +65,12 @@ class QueueManager:
tickets.sort(key=lambda t: t.queued_at) tickets.sort(key=lambda t: t.queued_at)
return tickets return tickets
async def _try_form_match(self, mode: Mode, team_size: int, db: AsyncSession) -> None: async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None:
required = team_size * 2
async with self._lock: async with self._lock:
waiting = self._waiting(mode) waiting = self._waiting(mode)
if len(waiting) < required: if len(waiting) < required:
return return
group = waiting[:required] group = waiting[:cap]
server = ( server = (
await db.execute( await db.execute(
@@ -99,8 +98,20 @@ class QueueManager:
while True: while True:
await asyncio.sleep(2) await asyncio.sleep(2)
async with async_session() as db: async with async_session() as db:
await self._try_form_match(Mode.casual, settings.casual_team_size, db) # Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad
await self._try_form_match(Mode.ranked, settings.ranked_team_size, db) # both teams up to GameConfig.bot_min_team_size, so a single
# queued player is enough to form a match. Still group in
# anyone else who queues in the same tick, up to the real
# casual_team_size*2 target, rather than capping at 1v1.
# Ranked has no bots, so it still needs the full
# ranked_team_size*2 real players queued before forming.
await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db)
await self._try_form_match(
Mode.ranked,
required=settings.ranked_team_size * 2,
cap=settings.ranked_team_size * 2,
db=db,
)
queue_manager = QueueManager() queue_manager = QueueManager()
+22 -5
View File
@@ -13,9 +13,26 @@
- Avoids the ghost-town feeling of a 25v25 map with 3 players - Avoids the ghost-town feeling of a 25v25 map with 3 players
- Bots are not added beyond 7v7 so human players always dominate strategy - Bots are not added beyond 7v7 so human players always dominate strategy
## Implementation Notes (not yet built) ## Implementation Notes
- Bots use simplified AI: thrust toward nearest enemy, shoot when in range - **`bots/bot_manager.gd`** (server-authoritative autoload) owns bot lifecycle. All
- Bot difficulty: intentionally easy (this is casual — bots are filler, not challenge) spawn/despawn decisions funnel through one function, `_reconcile_race(race_id)`,
- Bot names: procedurally generated callsigns so they're not obviously bots in the HUD triggered by `PlayerRegistry`'s existing `loadout_updated`/`player_removed`
- Server tracks human vs bot count per team and manages swap-in on player join signals (join/leave) and `World.decide_offered_races()` (match start) — no
polling. This is the one place to change for future tweaks (fill curve, per-map
team size, etc.)
- Bots get **negative peer_ids** (`-1, -2, ...`), assigned by `BotManager`. This
lets them reuse every existing networked-ship system for free — spawning
(`MultiplayerSpawner`), loadout replication/backfill (`PlayerRegistry`),
position/health/visibility sync, bullet damage attribution — none of which
cares whether a `peer_id` came from ENet or from `BotManager`
- Bots always fly **the race's fighter/Interceptor** (`race.ships[0]` in
`team_select.gd`'s `RACES` data)
- AI (`bots/bot_ai.gd`, `class_name BotAI`): thrust toward nearest living enemy,
turn to face it, shoot when roughly aimed and in range — simplified
seek-and-shoot, intentionally easy (casual bots are filler, not a challenge).
Fully isolated from `ship_movement.gd`'s networking code so future
difficulty/behavior tuning only ever touches this one file
- Bot names: `bots/bot_names.gd`, a flat pool of procedural callsigns
- Tunables live in `GameConfig` (`bot_min_team_size`, `bot_engage_range`,
`bot_stop_distance`, `bot_aim_tolerance_deg`)
+5
View File
@@ -0,0 +1,5 @@
The chat system will be in the game. In order to focus the chat window, the user presses t. He then types a message and enter sends it.
The message will only be in the game that the user is in. The opposing team can see the chat message as well.
If he wants it just for the team, he presses y. It will say [team] infront of it . The chat will show the last 10 messages. It will be positioned on bottom left of screen. The chat input is at the bottom. I want it to look similiar to world of warcrafts chat system.
+55 -2
View File
@@ -17,6 +17,12 @@ spacewar/ ← repo root
│ ├── ship.tscn ← player ship scene (formerly node_2d.tscn) │ ├── ship.tscn ← player ship scene (formerly node_2d.tscn)
│ ├── ship_movement.gd ← player ship logic │ ├── ship_movement.gd ← player ship logic
│ └── bullet.tscn/gd ← projectile │ └── bullet.tscn/gd ← projectile
├── chat/
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
├── bots/
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
│ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain
│ └── bot_names.gd ← class_name BotNames; procedural callsign pool
├── world/ ├── world/
│ ├── world.tscn ← game world; world.gd loads the active map into MapContainer │ ├── world.tscn ← game world; world.gd loads the active map into MapContainer
│ ├── world.gd ← picks/instances a map scene from world/maps/ │ ├── world.gd ← picks/instances a map scene from world/maps/
@@ -49,7 +55,8 @@ World (Node2D) ← world.gd instances MAP_SCENE into MapContainer o
├── HUD (CanvasLayer) ├── HUD (CanvasLayer)
│ └── HealthLabel │ └── HealthLabel
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn ├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection ── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
└── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
``` ```
### `world/maps/map_01.tscn` — hand-painted map ### `world/maps/map_01.tscn` — hand-painted map
@@ -81,6 +88,8 @@ Player (CharacterBody2D) ← ship_movement.gd
| `move_down` | S | — | | `move_down` | S | — |
| `shoot` | Space | — | | `shoot` | Space | — |
| `toggle_pause` | Esc | Start / Options button (JoyButton 6) | | `toggle_pause` | Esc | Start / Options button (JoyButton 6) |
| `chat_all` | T | — |
| `chat_team` | Y | — |
## Game Flow ## Game Flow
@@ -93,7 +102,32 @@ main_menu.tscn
└── RANKED → disabled (coming soon) └── RANKED → disabled (coming soon)
``` ```
> **Note:** `server_browser.tscn` was built (Task 1) and is functional, but the current flow bypasses it — CASUAL goes directly to the world and TeamSelect handles name/race/ship. The server browser will be re-integrated when real multiplayer server listing is built. > **Note:** `server_browser.tscn` was built (Task 1) and is functional, but the current flow bypasses it — CASUAL goes directly to the world and TeamSelect handles name/race/ship. The server browser will be re-integrated when real multiplayer server listing is built. It still uses the old hardcoded-1280px layout style (see Display below) — not yet updated since it isn't reachable in the live flow.
## Display
Launches borderless fullscreen at the real screen resolution (`window/size/mode=3`
in `project.godot`) with stretch mode **disabled** — 1 game pixel = 1 screen
pixel everywhere, no UI/world scaling. Chosen over Godot's default
`canvas_items`+`expand` stretch (which scales the whole 2D canvas to fill the
window) because the game's design canvas is deliberately sized to the Steam
Deck's native 1280×800; scaling that up to fill a PC monitor made everything
look zoomed in. With stretch disabled, PC monitors just reveal more of the
world/HUD at native size instead — the actual map (`world/maps/map_01.tscn`)
is already a fixed-size 14016×6000 arena, not viewport-sized, so there's more
world to reveal.
This means every menu Control has to position itself relative to the *real*
window size, not a fixed 1280×800 design canvas — anchors (0.0=edge,
0.5=center, 1.0=opposite edge) plus fixed pixel offsets from that anchor,
not raw absolute pixel coordinates. `main_menu.gd` and `team_select.gd` both
have a `_place()`/direct-property-assignment helper for this — **always set
anchor properties directly (`node.anchor_left = ...`) rather than through
sequential `set_anchor_and_offset()` calls**: that method's default
`push_opposite_anchor=true` drags the opposite side's anchor/offset along
whenever two sequential calls momentarily disagree (e.g. left set to a 0.5
anchor while right is still its 0.0 default), corrupting layout — hit this
exact bug converting the play-mode buttons to be centered.
## Key Autoload — `GameConfig` ## Key Autoload — `GameConfig`
@@ -113,3 +147,22 @@ main_menu.tscn
| `ship_invincibility_time` | float | Tuning constant | | `ship_invincibility_time` | float | Tuning constant |
| `bullet_damage` | int | Tuning constant | | `bullet_damage` | int | Tuning constant |
| `ship_wall_damage` | int | Tuning constant | | `ship_wall_damage` | int | Tuning constant |
| `chat_focused` | bool | Set by ChatBox while its input line has keyboard focus; gates ship movement/fire input |
| `bot_min_team_size` | int | Tuning constant — min humans+bots per race, see `bots/bot_manager.gd` |
| `bot_engage_range` | float | Tuning constant — bot max shoot distance |
| `bot_stop_distance` | float | Tuning constant — bot stops closing distance below this |
| `bot_aim_tolerance_deg` | float | Tuning constant — how precisely a bot must face a target to fire |
## Key Autoload — `BotManager`
Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the
match's 2 offered races at `GameConfig.bot_min_team_size` total humans+bots,
reacting to `PlayerRegistry.loadout_updated`/`player_removed` and
`World.decide_offered_races()`. See `overview/bots.md`.
## Key Autoload — `ChatManager`
Server-relayed chat (`autoload/chat_manager.gd`). `send_chat(text, team_only)` sends;
`message_received(sender_name, sender_race, team_only, text)` signal delivers
incoming messages to `ChatBox`. "Team" messages are filtered server-side to
peers sharing the sender's race (race doubles as team — see `PlayerRegistry`).
+5 -4
View File
@@ -12,17 +12,18 @@
| 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button | | 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button |
| 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP | | 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP |
| 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after | | 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after |
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
| 9 | Real ship sprites for all 3 races (5 ships each) | Done |
| 13 | Multiplayer networking — ENet authoritative server | Done |
| 18 | In-game chat (T=all, Y=team, last 10 messages, bottom-left) | `chat/chat_box.gd` + `autoload/chat_manager.gd` — see `chat.md` |
| 11 | Bot fill for casual (min 7v7) | `bots/bot_manager.gd` + `bots/bot_ai.gd` — see `bots.md` |
## Up Next ## Up Next
| # | Task | Priority | | # | Task | Priority |
|---|------|----------| |---|------|----------|
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Void Wraiths) | High |
| 9 | Real ship sprites for all 3 races (5 ships each) | High |
| 10 | Asteroids + environment hazards | Medium | | 10 | Asteroids + environment hazards | Medium |
| 11 | Bot fill for casual (min 7v7) | Medium — see `bots.md` |
| 12 | Sound effects (thrust, shoot, explosion, UI clicks) | Medium | | 12 | Sound effects (thrust, shoot, explosion, UI clicks) | Medium |
| 13 | Multiplayer networking — ENet authoritative server | High (big) |
| 14 | Galaxy war meta + sector control | Low (post-networking) | | 14 | Galaxy war meta + sector control | Low (post-networking) |
| 15 | Ranked matchmaking + MMR | Low (post-networking) | | 15 | Ranked matchmaking + MMR | Low (post-networking) |
| 16 | Settings screen (audio, controls, display) | Low | | 16 | Settings screen (audio, controls, display) | Low |
+68
View File
@@ -0,0 +1,68 @@
extends Node
# Server-relayed chat. "All" messages reach every peer in the match; "team"
# messages only reach peers who share the sender's chosen race (races double
# as the match's two teams — see TeamSelect/PlayerRegistry).
signal message_received(sender_name: String, sender_race: int, team_only: bool, text: String)
const MAX_MESSAGE_LENGTH := 140
func send_chat(raw_text: String, team_only: bool) -> void:
var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH)
if text.is_empty():
return
# Echo to ourselves immediately — no need to wait on a round trip to see
# our own message, and this sidesteps relying on "call_local" local
# execution semantics for a targeted rpc_id() call (unlike a broadcast
# .rpc(), those do NOT also run locally on the caller in Godot).
var local_id := multiplayer.get_unique_id()
var local_info := PlayerRegistry.get_info(local_id)
message_received.emit(local_info.get("name", "Player"), local_info.get("race", 0), team_only, text)
if multiplayer.is_server():
_relay(local_id, text, team_only)
else:
# Must target the server explicitly (id 1), not a bare broadcast
# .rpc(): ENetMultiplayerPeer's default server_relay transparently
# forwards a client's broadcast RPC to every other client too, which
# would let every peer run this unfiltered — silently leaking "team"
# chat to the other team before the server ever gets a chance to
# filter it.
submit_chat.rpc_id(1, text, team_only)
# Client -> server only. The server is the sole authority on who a "team"
# message is allowed to reach.
@rpc("any_peer", "call_remote", "reliable")
func submit_chat(text: String, team_only: bool) -> void:
if not multiplayer.is_server():
return
_relay(multiplayer.get_remote_sender_id(), text, team_only)
func _relay(sender_id: int, raw_text: String, team_only: bool) -> void:
var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH)
if text.is_empty():
return
var sender_info := PlayerRegistry.get_info(sender_id)
var sender_name: String = sender_info.get("name", "Player")
var sender_race: int = sender_info.get("race", 0)
for peer_id in multiplayer.get_peers():
if peer_id != sender_id and _should_receive(peer_id, sender_race, team_only):
_receive_chat.rpc_id(peer_id, sender_name, sender_race, team_only, text)
@rpc("authority", "call_remote", "reliable")
func _receive_chat(sender_name: String, sender_race: int, team_only: bool, text: String) -> void:
message_received.emit(sender_name, sender_race, team_only, text)
func _should_receive(receiver_id: int, sender_race: int, team_only: bool) -> bool:
if not team_only:
return true
return PlayerRegistry.get_info(receiver_id).get("race", -1) == sender_race
+1
View File
@@ -0,0 +1 @@
uid://c70wvme1k38y3
+10
View File
@@ -25,3 +25,13 @@ var player_name: String = ""
# Current map's play area, in world coordinates. Set by world.gd on load. # Current map's play area, in world coordinates. Set by world.gd on load.
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648) var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
# True while the chat input box has keyboard focus — gates ship movement/fire
# input so typing (e.g. the letter "w") doesn't also move the ship.
var chat_focused: bool = false
# Bots (casual fill) — see bots/bot_manager.gd and overview/bots.md
var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default
var bot_engage_range: float = 500.0
var bot_stop_distance: float = 160.0
var bot_aim_tolerance_deg: float = 6.0
+21 -2
View File
@@ -5,7 +5,7 @@ extends Node
# their own. # their own.
signal loadout_updated(peer_id: int) signal loadout_updated(peer_id: int)
signal player_removed(peer_id: int) signal player_removed(peer_id: int, info: Dictionary)
var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor} var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor}
@@ -26,6 +26,20 @@ func submit_local_loadout(player_name: String, race: int, ship_path: String, shi
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor) submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor)
# Server-only: registers a bot's loadout the same way a real player's
# submit_loadout would (minus the backfill, since a fresh bot has nothing to
# learn), broadcasting to every connected peer so bots render identically to
# human ships on every client. See bots/bot_manager.gd.
func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
for pid in multiplayer.get_peers():
_receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor)
_store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor)
func unregister_bot(peer_id: int) -> void:
_remove(peer_id)
# Client -> server (and locally to self via call_local). Server then relays # Client -> server (and locally to self via call_local). Server then relays
# the new loadout to everyone else, and backfills the newcomer with everyone # the new loadout to everyone else, and backfills the newcomer with everyone
# else's already-known loadout. # else's already-known loadout.
@@ -68,5 +82,10 @@ func _store(peer_id: int, player_name: String, race: int, ship_path: String, shi
func _on_peer_left(peer_id: int) -> void: func _on_peer_left(peer_id: int) -> void:
_remove(peer_id)
func _remove(peer_id: int) -> void:
var info: Dictionary = players.get(peer_id, {})
if players.erase(peer_id): if players.erase(peer_id):
player_removed.emit(peer_id) player_removed.emit(peer_id, info)
+56
View File
@@ -0,0 +1,56 @@
class_name BotAI
extends RefCounted
# Simplified "seek and shoot" bot AI (see overview/bots.md): thrust toward
# the nearest living enemy, turn to face it, fire once roughly aimed and in
# range. Kept intentionally simple — casual bots are filler, not a challenge
# — and isolated from ship_movement.gd's networking code entirely so future
# difficulty/behavior tweaks only ever touch this file.
var _ship: Ship
var _race_id: int
func _init(ship: Ship, race_id: int) -> void:
_ship = ship
_race_id = race_id
func compute_input(_delta: float) -> Dictionary:
var target := _find_nearest_enemy()
if target == null:
return {"up": false, "down": false, "left": false, "right": false, "shoot": false}
var to_target: Vector2 = target.global_position - _ship.global_position
var distance: float = to_target.length()
# Ship forward is Vector2.UP.rotated(rotation) (see ship_movement.gd
# _simulate_step), so facing `to_target` requires offsetting by the angle
# between Vector2.UP and the zero-rotation forward vector (PI/2).
var desired_rot: float = to_target.angle() + PI / 2.0
var diff: float = angle_difference(_ship.rotation, desired_rot)
var aim_tolerance: float = deg_to_rad(GameConfig.bot_aim_tolerance_deg)
return {
"up": distance > GameConfig.bot_stop_distance,
"down": false,
"left": diff < -aim_tolerance,
"right": diff > aim_tolerance,
"shoot": absf(diff) <= aim_tolerance and distance <= GameConfig.bot_engage_range,
}
func _find_nearest_enemy() -> Ship:
var best: Ship = null
var best_dist := INF
for node in _ship.get_tree().get_nodes_in_group("ships"):
var candidate := node as Ship
if candidate == null or candidate == _ship or not candidate.visible:
continue
var info: Dictionary = PlayerRegistry.get_info(candidate.peer_id)
if info.get("race", -1) == _race_id:
continue
var dist: float = _ship.global_position.distance_squared_to(candidate.global_position)
if dist < best_dist:
best_dist = dist
best = candidate
return best
+1
View File
@@ -0,0 +1 @@
uid://b4dxa42o6vq8k
+95
View File
@@ -0,0 +1,95 @@
extends Node
# Server-authoritative bot fill for casual matches (see overview/bots.md):
# keeps each of the match's 2 offered races at a minimum of
# GameConfig.bot_min_team_size total (humans + bots), spawning/despawning
# bots as players join and leave.
#
# All spawn/despawn logic funnels through _reconcile_race() so every future
# tweak (different fill curve, per-map team size, bot difficulty swaps,
# etc.) has one place to change instead of being scattered across
# join/leave handlers.
var _world = null # World instance; untyped like ship_movement.gd's own
# world ref, since World has no class_name of its own
var _bots_by_race: Dictionary = {} # race_id -> Array[int] (bot peer_ids)
var _next_bot_id: int = -1
func _ready() -> void:
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
PlayerRegistry.player_removed.connect(_on_player_removed)
# Called once by World.decide_offered_races() when the match's 2 races are
# decided, to fill both teams with bots before any human has picked a race.
func on_match_start(world, offered_race_ids: Array) -> void:
if not multiplayer.is_server():
return
_world = world
_bots_by_race.clear()
_next_bot_id = -1
for race_id in offered_race_ids:
_reconcile_race(race_id)
func _on_loadout_updated(peer_id: int) -> void:
if not multiplayer.is_server() or _world == null or peer_id < 0:
return
_reconcile_race(PlayerRegistry.get_info(peer_id).get("race", 0))
func _on_player_removed(peer_id: int, info: Dictionary) -> void:
if not multiplayer.is_server() or _world == null or peer_id < 0:
return
_reconcile_race(info.get("race", 0))
func _reconcile_race(race_id: int) -> void:
if race_id <= 0:
return
var humans := _count_humans(race_id)
var desired: int = max(0, GameConfig.bot_min_team_size - humans)
var current: Array = _bots_by_race.get(race_id, [])
while current.size() < desired:
current.append(_spawn_bot(race_id))
while current.size() > desired:
_despawn_bot(current.pop_back())
_bots_by_race[race_id] = current
func _count_humans(race_id: int) -> int:
var count := 0
for peer_id in PlayerRegistry.players:
if peer_id > 0 and PlayerRegistry.players[peer_id].race == race_id:
count += 1
return count
func _spawn_bot(race_id: int) -> int:
var bot_id := _next_bot_id
_next_bot_id -= 1
var race := _find_race(race_id)
var ship_def: Dictionary = race.ships[0] # bots always fly the race's fighter (Interceptor)
PlayerRegistry.register_bot(
bot_id, BotNames.random_name(), race_id,
ship_def.path, ship_def.scale, ship_def.speed_factor
)
var ship_node := _world.spawn_peer(bot_id) as Ship
if ship_node:
ship_node.bot_ai = BotAI.new(ship_node, race_id)
return bot_id
func _despawn_bot(bot_id: int) -> void:
_world.despawn_peer(bot_id)
PlayerRegistry.unregister_bot(bot_id)
func _find_race(race_id: int) -> Dictionary:
for race in TeamSelect.RACES:
if race.id == race_id:
return race
return TeamSelect.RACES[0]
+1
View File
@@ -0,0 +1 @@
uid://ddrjywcfsrjdq
+18
View File
@@ -0,0 +1,18 @@
class_name BotNames
extends RefCounted
# Procedurally-flavored callsigns so bots aren't obviously bots in the HUD
# or chat (see overview/bots.md). Flat pool, picked randomly — a name can
# repeat across a long casual match, which is fine for filler.
const NAMES := [
"Raptor", "Vagrant", "Nomad", "Cutlass", "Wraith", "Ghostfire", "Talon",
"Drifter", "Ironclad", "Nightshade", "Rustwing", "Voidrunner", "Ashback",
"Comet", "Hollow", "Fangtooth", "Static", "Sable", "Ember", "Backdraft",
"Grim", "Hexbolt", "Ironsight", "Junker", "Lowbeam", "Mirage", "Nullbyte",
"Outlaw", "Payload", "Quickfuse", "Redline", "Scrapheap", "Torque",
"Undertow", "Vector", "Whiplash", "Yardbird", "Zerofall",
]
static func random_name() -> String:
return NAMES[randi() % NAMES.size()]
+1
View File
@@ -0,0 +1 @@
uid://s42nhrttgq4b
+165
View File
@@ -0,0 +1,165 @@
extends CanvasLayer
# WoW-style chat overlay. T focuses "all" chat (visible to both teams), Y
# focuses "team" chat (visible only to players who share the sender's race —
# races double as teams, see TeamSelect). Enter sends, Escape cancels. Last
# MAX_LINES messages are kept, positioned bottom-left per overview/chat.md.
const MAX_LINES := 10
const HISTORY_W := 460.0
const HISTORY_H := 160.0
const INPUT_H := 30.0
const MARGIN_X := 16.0
const MARGIN_BOTTOM := 16.0
const GAP := 4.0
var _history: RichTextLabel
var _input_line: LineEdit
var _team_mode: bool = false
var _lines: Array[String] = []
@onready var _pause_menu = get_node_or_null("/root/World/PauseMenu")
func _ready() -> void:
layer = 5
_build_ui()
ChatManager.message_received.connect(_on_message_received)
func _build_ui() -> void:
# Positioned bottom-left via anchor 1.0 (actual window bottom edge), not
# a hardcoded 800px design height — stretch mode is disabled (see
# project.godot), so nothing scales/repositions automatically.
var input_y2 := -MARGIN_BOTTOM
var input_y1 := input_y2 - INPUT_H
var history_y2 := input_y1 - GAP
var history_y1 := history_y2 - HISTORY_H
var x1 := MARGIN_X
var x2 := MARGIN_X + HISTORY_W
var panel := PanelContainer.new()
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.02, 0.03, 0.06, 0.55)
ps.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", ps)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Anchors/offsets set as plain property assignments, not sequential
# set_anchor_and_offset() calls — that method's default
# push_opposite_anchor=true drags the opposite side along when the two
# calls momentarily disagree (e.g. top set to anchor 1.0 while bottom is
# still its 0.0 default), corrupting layout. See team_select.gd's
# _place() for the fuller writeup of this bug.
panel.anchor_left = 0.0
panel.anchor_right = 0.0
panel.anchor_top = 1.0
panel.anchor_bottom = 1.0
panel.offset_left = x1
panel.offset_right = x2
panel.offset_top = history_y1
panel.offset_bottom = history_y2
add_child(panel)
_history = RichTextLabel.new()
_history.bbcode_enabled = true
_history.scroll_active = true
_history.scroll_following = true
_history.mouse_filter = Control.MOUSE_FILTER_IGNORE
_history.add_theme_font_size_override("normal_font_size", 15)
_history.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_history.offset_left = 8
_history.offset_top = 4
_history.offset_right = -8
_history.offset_bottom = -4
panel.add_child(_history)
_input_line = LineEdit.new()
_input_line.anchor_left = 0.0
_input_line.anchor_right = 0.0
_input_line.anchor_top = 1.0
_input_line.anchor_bottom = 1.0
_input_line.offset_left = x1
_input_line.offset_right = x2
_input_line.offset_top = input_y1
_input_line.offset_bottom = input_y2
_input_line.max_length = ChatManager.MAX_MESSAGE_LENGTH
var ib := StyleBoxFlat.new()
ib.bg_color = Color(0.02, 0.03, 0.06, 0.85)
ib.set_corner_radius_all(4)
ib.set_border_width_all(1)
ib.border_color = Color(0.3, 0.5, 0.75, 0.8)
_input_line.add_theme_stylebox_override("normal", ib)
_input_line.add_theme_stylebox_override("focus", ib)
_input_line.add_theme_font_size_override("font_size", 15)
_input_line.caret_blink = true
_input_line.caret_blink_interval = 0.5
_input_line.caret_force_displayed = true
_input_line.add_theme_color_override("caret_color", Color(0.7, 0.9, 1.0))
_input_line.add_theme_constant_override("caret_width", 9)
_input_line.visible = false
_input_line.text_submitted.connect(_on_text_submitted)
_input_line.focus_exited.connect(_close_chat)
add_child(_input_line)
func _input(event: InputEvent) -> void:
if not _input_line.visible:
if _pause_menu and _pause_menu.is_paused():
return
if event.is_action_pressed("chat_all"):
_open_chat(false)
get_viewport().set_input_as_handled()
elif event.is_action_pressed("chat_team"):
_open_chat(true)
get_viewport().set_input_as_handled()
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_close_chat()
get_viewport().set_input_as_handled()
func _open_chat(team_only: bool) -> void:
_team_mode = team_only
_input_line.placeholder_text = "[TEAM] Message..." if team_only else "Message..."
_input_line.text = ""
_input_line.visible = true
_input_line.grab_focus()
GameConfig.chat_focused = true
func _close_chat() -> void:
_input_line.visible = false
if _input_line.has_focus():
_input_line.release_focus()
GameConfig.chat_focused = false
func _on_text_submitted(text: String) -> void:
ChatManager.send_chat(text, _team_mode)
_close_chat()
func _on_message_received(sender_name: String, sender_race: int, team_only: bool, text: String) -> void:
var name_color := _race_color(sender_race).to_html(false)
var safe_name := _escape_bbcode(sender_name)
var safe_text := _escape_bbcode(text)
var line: String
if team_only:
line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text]
else:
line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text]
_lines.append(line)
if _lines.size() > MAX_LINES:
_lines.pop_front()
_history.text = "\n".join(_lines)
func _race_color(race_id: int) -> Color:
for race in TeamSelect.RACES:
if race.id == race_id:
return race.color
return Color(0.85, 0.87, 0.92)
func _escape_bbcode(s: String) -> String:
return s.replace("[", "[lb]")
+1
View File
@@ -0,0 +1 @@
uid://c4tvxxo8uvqrt
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://cchatbox01a"]
[ext_resource type="Script" path="res://chat/chat_box.gd" id="1_script"]
[node name="ChatBox" type="CanvasLayer"]
script = ExtResource("1_script")
+32 -24
View File
@@ -75,11 +75,15 @@ func _add_play_section() -> void:
const BTN_W := 480.0 const BTN_W := 480.0
const BTN_H := 230.0 const BTN_H := 230.0
const GAP := 40.0 const GAP := 40.0
const LEFT_X := (1280.0 - (BTN_W * 2.0 + GAP)) / 2.0 # 140
const TOP_Y := 220.0 const TOP_Y := 220.0
# The whole row (both buttons + gap) is centered on the actual screen
# width via anchor 0.5, not a hardcoded 1280px design width — offsets
# below are all relative to that center point, not the left edge.
const HALF_GAP := GAP / 2.0
const ROW_HALF_W := (BTN_W * 2.0 + GAP) / 2.0
_casual_btn = _make_mode_btn( _casual_btn = _make_mode_btn(
LEFT_X, TOP_Y, BTN_W, BTN_H, -ROW_HALF_W, TOP_Y, -HALF_GAP, TOP_Y + BTN_H,
"CASUAL", "CASUAL",
"25 vs 25", "25 vs 25",
"Drop in, drop out anytime.\nNo rank at stake.", "Drop in, drop out anytime.\nNo rank at stake.",
@@ -90,7 +94,7 @@ func _add_play_section() -> void:
add_child(_casual_btn) add_child(_casual_btn)
_ranked_btn = _make_mode_btn( _ranked_btn = _make_mode_btn(
LEFT_X + BTN_W + GAP, TOP_Y, BTN_W, BTN_H, HALF_GAP, TOP_Y, ROW_HALF_W, TOP_Y + BTN_H,
"RANKED", "RANKED",
"5 vs 5", "5 vs 5",
"Competitive ladder.\nYour rank is on the line.", "Competitive ladder.\nYour rank is on the line.",
@@ -103,10 +107,7 @@ func _add_play_section() -> void:
var coming_soon := Label.new() var coming_soon := Label.new()
coming_soon.text = "— COMING SOON —" coming_soon.text = "— COMING SOON —"
coming_soon.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X + BTN_W + GAP) _place(coming_soon, 0.5, HALF_GAP, 0.5, ROW_HALF_W, TOP_Y + BTN_H - 36.0, TOP_Y + BTN_H - 8.0)
coming_soon.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H - 36.0)
coming_soon.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP)
coming_soon.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H - 8.0)
coming_soon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER coming_soon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
coming_soon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER coming_soon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
coming_soon.add_theme_font_size_override("font_size", 11) coming_soon.add_theme_font_size_override("font_size", 11)
@@ -117,10 +118,7 @@ func _add_play_section() -> void:
# Callsign label # Callsign label
var lbl := Label.new() var lbl := Label.new()
lbl.text = "CALLSIGN" lbl.text = "CALLSIGN"
lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X) _place(lbl, 0.5, -ROW_HALF_W, 0.5, -ROW_HALF_W + 95.0, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0)
lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 26.0)
lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + 95.0)
lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 62.0)
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", 12) lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", Color(0.45, 0.55, 0.70)) lbl.add_theme_color_override("font_color", Color(0.45, 0.55, 0.70))
@@ -128,10 +126,7 @@ func _add_play_section() -> void:
_name_input = LineEdit.new() _name_input = LineEdit.new()
_name_input.placeholder_text = "Enter your callsign..." _name_input.placeholder_text = "Enter your callsign..."
_name_input.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X + 100.0) _place(_name_input, 0.5, -ROW_HALF_W + 100.0, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0)
_name_input.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 26.0)
_name_input.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP)
_name_input.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 62.0)
_name_input.add_theme_font_size_override("font_size", 15) _name_input.add_theme_font_size_override("font_size", 15)
if GameConfig.player_name != "": if GameConfig.player_name != "":
_name_input.text = GameConfig.player_name _name_input.text = GameConfig.player_name
@@ -140,10 +135,7 @@ func _add_play_section() -> void:
_status_lbl = Label.new() _status_lbl = Label.new()
_status_lbl.text = "" _status_lbl.text = ""
_status_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X) _place(_status_lbl, 0.5, -ROW_HALF_W, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 68.0, TOP_Y + BTN_H + 92.0)
_status_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 68.0)
_status_lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP)
_status_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 92.0)
_status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_lbl.add_theme_font_size_override("font_size", 13) _status_lbl.add_theme_font_size_override("font_size", 13)
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
@@ -153,16 +145,13 @@ func _add_play_section() -> void:
func _make_mode_btn( func _make_mode_btn(
x: float, y: float, w: float, h: float, left: float, top: float, right: float, bottom: float,
mode: String, fmt: String, desc: String, mode: String, fmt: String, desc: String,
bg: Color, accent: Color) -> Button: bg: Color, accent: Color) -> Button:
var btn := Button.new() var btn := Button.new()
btn.text = "" btn.text = ""
btn.set_anchor_and_offset(SIDE_LEFT, 0.0, x) _place(btn, 0.5, left, 0.5, right, top, bottom)
btn.set_anchor_and_offset(SIDE_TOP, 0.0, y)
btn.set_anchor_and_offset(SIDE_RIGHT, 0.0, x + w)
btn.set_anchor_and_offset(SIDE_BOTTOM, 0.0, y + h)
var sn := _flat(bg.lightened(0.05), 12) var sn := _flat(bg.lightened(0.05), 12)
sn.set_border_width_all(2) sn.set_border_width_all(2)
@@ -325,6 +314,25 @@ func _add_version_label() -> void:
add_child(lbl) add_child(lbl)
# Sets all 4 anchors then all 4 offsets as plain property assignments —
# NOT via sequential set_anchor_and_offset() calls, whose default
# push_opposite_anchor=true drags the opposite side's anchor/offset along
# whenever the two calls momentarily disagree (e.g. left set to 0.5 while
# right is still its 0.0 default), corrupting layout for any non-0.0 anchor
# (0.5-centered elements hit this hardest). See _make_mode_btn's git history
# for how this was found — buttons rendered flush against the left edge
# instead of centered until this fix.
func _place(node: Control, anchor_left: float, left: float, anchor_right: float, right: float, top: float, bottom: float) -> void:
node.anchor_left = anchor_left
node.anchor_right = anchor_right
node.anchor_top = 0.0
node.anchor_bottom = 0.0
node.offset_left = left
node.offset_right = right
node.offset_top = top
node.offset_bottom = bottom
func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
var s := StyleBoxFlat.new() var s := StyleBoxFlat.new()
s.bg_color = col s.bg_color = col
+5 -1
View File
@@ -10,11 +10,15 @@ func _ready() -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_pause"): if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused:
_toggle() _toggle()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
func is_paused() -> bool:
return _root.visible
func _toggle() -> void: func _toggle() -> void:
_root.visible = not _root.visible _root.visible = not _root.visible
+51 -18
View File
@@ -107,9 +107,11 @@ const RACES := [
}, },
] ]
# Row layout constants (screen is 1280×800) # Row layout constants — ROW_X1/ROW_X2_MARGIN are left/right margins (not
const ROW_X1 := 120.0 # absolute x-coordinates), so rows stretch to fill the actual window width;
const ROW_X2 := 1160.0 # stretch mode is disabled (project.godot), so nothing scales automatically.
const ROW_X1 := 120.0
const ROW_X2_MARGIN := 120.0
const ROW_H := 115.0 const ROW_H := 115.0
const ROW_GAP := 6.0 const ROW_GAP := 6.0
const ROW_START := 175.0 const ROW_START := 175.0
@@ -166,7 +168,7 @@ func _build_root() -> void:
_root.add_child(overlay) _root.add_child(overlay)
_loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75), _loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75),
0, 380, 1280, 420, HORIZONTAL_ALIGNMENT_CENTER) 0, 380, 0, 420, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
func _swap_content() -> Control: func _swap_content() -> Control:
@@ -185,18 +187,24 @@ func _show_race_selection() -> void:
var c := _swap_content() var c := _swap_content()
_label(c, "CHOOSE YOUR FACTION", 32, Color(0.42, 0.87, 1.0), _label(c, "CHOOSE YOUR FACTION", 32, Color(0.42, 0.87, 1.0),
0, 105, 1280, 160, HORIZONTAL_ALIGNMENT_CENTER) 0, 105, 0, 160, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
_label(c, "This match: %s vs %s" % [_offered[0].name, _offered[1].name], _label(c, "This match: %s vs %s" % [_offered[0].name, _offered[1].name],
13, Color(0.45, 0.58, 0.72), 13, Color(0.45, 0.58, 0.72),
0, 170, 1280, 200, HORIZONTAL_ALIGNMENT_CENTER) 0, 170, 0, 200, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
# Both race buttons are a fixed 440px wide with a 100px gap, centered on
# the actual screen width via anchor 0.5 — not a hardcoded 1280px design
# width, since stretch mode is disabled (see project.godot).
const BTN_W := 440.0
const HALF_GAP := 50.0
var left_btn := _make_race_btn(_offered[0]) var left_btn := _make_race_btn(_offered[0])
_place(left_btn, 150, 225, 590, 540) _place(left_btn, -(BTN_W + HALF_GAP), 225, -HALF_GAP, 540, 0.5, 0.5)
left_btn.pressed.connect(_on_race_chosen.bind(_offered[0])) left_btn.pressed.connect(_on_race_chosen.bind(_offered[0]))
c.add_child(left_btn) c.add_child(left_btn)
var right_btn := _make_race_btn(_offered[1]) var right_btn := _make_race_btn(_offered[1])
_place(right_btn, 690, 225, 1130, 540) _place(right_btn, HALF_GAP, 225, BTN_W + HALF_GAP, 540, 0.5, 0.5)
right_btn.pressed.connect(_on_race_chosen.bind(_offered[1])) right_btn.pressed.connect(_on_race_chosen.bind(_offered[1]))
c.add_child(right_btn) c.add_child(right_btn)
@@ -212,15 +220,19 @@ func _show_ship_selection() -> void:
var c := _swap_content() var c := _swap_content()
_label(c, "SELECT YOUR SHIP", 30, Color(0.42, 0.87, 1.0), _label(c, "SELECT YOUR SHIP", 30, Color(0.42, 0.87, 1.0),
0, 85, 1280, 130, HORIZONTAL_ALIGNMENT_CENTER) 0, 85, 0, 130, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
_label(c, "Playing as — " + _chosen_race.name, 14, _chosen_race.color, _label(c, "Playing as — " + _chosen_race.name, 14, _chosen_race.color,
0, 135, 1280, 165, HORIZONTAL_ALIGNMENT_CENTER) 0, 135, 0, 165, HORIZONTAL_ALIGNMENT_CENTER, 0.0, 1.0)
var ships: Array = _chosen_race.ships var ships: Array = _chosen_race.ships
for i in ships.size(): for i in ships.size():
var y := ROW_START + i * (ROW_H + ROW_GAP) var y := ROW_START + i * (ROW_H + ROW_GAP)
var btn := _make_ship_row(ships[i], _chosen_race.color) var btn := _make_ship_row(ships[i], _chosen_race.color)
_place(btn, ROW_X1, y, ROW_X2, y + ROW_H) # ROW_X1 margin from the left edge, ROW_X2_MARGIN margin from the
# actual right edge (anchor_right=1.0) — the row stretches to fill
# whatever width is available instead of stopping at a hardcoded
# 1280px design width.
_place(btn, ROW_X1, y, -ROW_X2_MARGIN, y + ROW_H, 0.0, 1.0)
btn.pressed.connect(_on_ship_chosen.bind(ships[i])) btn.pressed.connect(_on_ship_chosen.bind(ships[i]))
c.add_child(btn) c.add_child(btn)
@@ -368,23 +380,44 @@ func _make_ship_row(ship: Dictionary, accent: Color) -> Button:
func _label(parent: Control, text: String, font_size: int, color: Color, func _label(parent: Control, text: String, font_size: int, color: Color,
x1: float, y1: float, x2: float, y2: float, x1: float, y1: float, x2: float, y2: float,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> Label: align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT,
anchor_left: float = 0.0, anchor_right: float = 0.0) -> Label:
var lbl := Label.new() var lbl := Label.new()
lbl.text = text lbl.text = text
lbl.horizontal_alignment = align lbl.horizontal_alignment = align
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", font_size) lbl.add_theme_font_size_override("font_size", font_size)
lbl.add_theme_color_override("font_color", color) lbl.add_theme_color_override("font_color", color)
_place(lbl, x1, y1, x2, y2) _place(lbl, x1, y1, x2, y2, anchor_left, anchor_right)
parent.add_child(lbl) parent.add_child(lbl)
return lbl return lbl
func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void: # anchor_left/anchor_right default to 0.0 (pixels measured from the left
node.set_anchor_and_offset(SIDE_LEFT, 0.0, x1) # edge), matching the design-resolution layout this screen was originally
node.set_anchor_and_offset(SIDE_TOP, 0.0, y1) # built at. Pass 1.0 for anchor_right to make x2 a margin from the actual
node.set_anchor_and_offset(SIDE_RIGHT, 0.0, x2) # right edge instead (stretches with window width), or 0.5 for both to
node.set_anchor_and_offset(SIDE_BOTTOM, 0.0, y2) # center a fixed-size element regardless of window width — see call sites
# below for which each element needs (stretch mode is disabled, so nothing
# scales automatically the way it used to under canvas_items stretch).
#
# Anchors and offsets are set as plain property assignments, NOT via
# sequential set_anchor_and_offset() calls — that method's default
# push_opposite_anchor=true drags the opposite side's anchor/offset along
# whenever the two calls momentarily disagree (e.g. left set to 0.5 while
# right is still its 0.0 default), corrupting layout for any non-0.0 anchor.
# Found via main_menu.gd's play buttons rendering flush against the left
# edge instead of centered.
func _place(node: Control, x1: float, y1: float, x2: float, y2: float,
anchor_left: float = 0.0, anchor_right: float = 0.0) -> void:
node.anchor_left = anchor_left
node.anchor_right = anchor_right
node.anchor_top = 0.0
node.anchor_bottom = 0.0
node.offset_left = x1
node.offset_top = y1
node.offset_right = x2
node.offset_bottom = y2
func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
+14 -2
View File
@@ -21,13 +21,15 @@ GameConfig="*res://autoload/game_config.gd"
NetworkManager="*res://autoload/network_manager.gd" NetworkManager="*res://autoload/network_manager.gd"
PlayerRegistry="*res://autoload/player_registry.gd" PlayerRegistry="*res://autoload/player_registry.gd"
MatchmakingClient="*res://autoload/matchmaking_client.gd" MatchmakingClient="*res://autoload/matchmaking_client.gd"
ChatManager="*res://autoload/chat_manager.gd"
BotManager="*res://bots/bot_manager.gd"
[display] [display]
window/size/viewport_width=1280 window/size/viewport_width=1280
window/size/viewport_height=800 window/size/viewport_height=800
window/stretch/mode="canvas_items" window/size/mode=3
window/stretch/aspect="expand" window/stretch/mode="disabled"
[input] [input]
@@ -62,6 +64,16 @@ toggle_pause={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"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)
] ]
} }
chat_all={
"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":84,"key_label":0,"unicode":116,"location":0,"echo":false,"script":null)
]
}
chat_team={
"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":89,"key_label":0,"unicode":121,"location":0,"echo":false,"script":null)
]
}
[physics] [physics]
+15 -1
View File
@@ -1,8 +1,17 @@
extends CharacterBody2D extends CharacterBody2D
class_name Ship
# Set by World._spawn_ship() before this node enters the tree. # Set by World._spawn_ship() before this node enters the tree.
var peer_id: int = 0 var peer_id: int = 0
# Bots use a negative peer_id (see bots/bot_manager.gd) so they ride every
# piece of existing networked-ship infrastructure — spawning, loadout,
# position/health/visibility sync, bullet attribution — for free, all of
# which is already keyed off peer_id and none of which cares whether it came
# from ENet or from BotManager. Set by BotManager right after spawning, only
# ever non-null on the server (bots have no owner/remote client).
var bot_ai: BotAI = null
var _fire_cooldown: float = 0.0 var _fire_cooldown: float = 0.0
var health: int = 0 var health: int = 0
var _dead: bool = false var _dead: bool = false
@@ -37,6 +46,7 @@ var _interp_elapsed: float = 0.0
func _ready() -> void: func _ready() -> void:
_fixed_delta = 1.0 / Engine.physics_ticks_per_second _fixed_delta = 1.0 / Engine.physics_ticks_per_second
_is_owner = peer_id == multiplayer.get_unique_id() _is_owner = peer_id == multiplayer.get_unique_id()
add_to_group("ships") # lets BotAI find nearby targets without a World reference
# Only ever claim the camera for the local owner. Whichever Camera2D # Only ever claim the camera for the local owner. Whichever Camera2D
# enters the SceneTree first auto-claims the viewport's active camera # enters the SceneTree first auto-claims the viewport's active camera
@@ -160,7 +170,9 @@ func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server
func _server_tick(delta: float) -> void: func _server_tick(delta: float) -> void:
var input: Dictionary var input: Dictionary
if _is_owner: if peer_id < 0:
input = bot_ai.compute_input(delta) if bot_ai else {}
elif _is_owner:
input = _sample_local_input() input = _sample_local_input()
else: else:
if not _remote_inputs.is_empty(): if not _remote_inputs.is_empty():
@@ -198,6 +210,8 @@ func _remote_tick(delta: float) -> void:
# ── Shared movement step (deterministic — replayed during reconciliation) ─── # ── Shared movement step (deterministic — replayed during reconciliation) ───
func _sample_local_input() -> Dictionary: func _sample_local_input() -> Dictionary:
if GameConfig.chat_focused:
return {"up": false, "down": false, "left": false, "right": false, "shoot": false}
return { return {
"up": Input.is_action_pressed("move_up"), "up": Input.is_action_pressed("move_up"),
"down": Input.is_action_pressed("move_down"), "down": Input.is_action_pressed("move_down"),
+23 -10
View File
@@ -31,19 +31,25 @@ func _ready() -> void:
NetworkManager.peer_left.connect(_on_peer_left) NetworkManager.peer_left.connect(_on_peer_left)
if multiplayer.is_server(): if multiplayer.is_server():
_spawn_peer(multiplayer.get_unique_id()) spawn_peer(multiplayer.get_unique_id())
for peer_id in multiplayer.get_peers(): for peer_id in multiplayer.get_peers():
_spawn_peer(peer_id) spawn_peer(peer_id)
# Picks the match's 2 offered races once and caches them, so every caller # Picks the match's 2 offered races once and caches them, so every caller
# (the server's own TeamSelect and every remote client's request) converges # (the server's own TeamSelect and every remote client's request) converges
# on the same pair regardless of call order. # on the same pair regardless of call order. Also kicks off bot fill for
# those 2 races — see bots/bot_manager.gd.
func decide_offered_races() -> Array: func decide_offered_races() -> Array:
if _offered_race_ids.is_empty(): if _offered_race_ids.is_empty():
var ids := range(1, TeamSelect.RACES.size() + 1) var ids := range(1, TeamSelect.RACES.size() + 1)
ids.shuffle() ids.shuffle()
_offered_race_ids = [ids[0], ids[1]] _offered_race_ids = [ids[0], ids[1]]
# Deferred: this can run from TeamSelect._ready(), which (as World's
# child) fires before World's own _ready() — before _players/_spawner
# are assigned. Deferring runs it after the whole scene's _ready
# cascade finishes, once World is actually spawn-ready.
BotManager.call_deferred("on_match_start", self, _offered_race_ids)
return _offered_race_ids return _offered_race_ids
@@ -60,10 +66,19 @@ func _deliver_offered_races(race_ids: Array) -> void:
_team_select.offer_races(race_ids) _team_select.offer_races(race_ids)
func _spawn_peer(peer_id: int) -> void: # Spawns (or returns the already-spawned) ship for a given peer_id — real
# (positive) or bot (negative, see bots/bot_manager.gd). Replicates to every
# client automatically via MultiplayerSpawner.
func spawn_peer(peer_id: int) -> Node:
if _players.has_node(str(peer_id)): if _players.has_node(str(peer_id)):
return return _players.get_node(str(peer_id))
_spawner.spawn(peer_id) return _spawner.spawn(peer_id)
func despawn_peer(peer_id: int) -> void:
var ship := _players.get_node_or_null(str(peer_id))
if ship:
ship.queue_free()
func _spawn_ship(peer_id: int) -> Node: func _spawn_ship(peer_id: int) -> Node:
@@ -91,15 +106,13 @@ func _spawn_bullet(data: Dictionary) -> Node:
func _on_peer_joined(peer_id: int) -> void: func _on_peer_joined(peer_id: int) -> void:
if multiplayer.is_server(): if multiplayer.is_server():
_spawn_peer(peer_id) spawn_peer(peer_id)
func _on_peer_left(peer_id: int) -> void: func _on_peer_left(peer_id: int) -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
var ship := _players.get_node_or_null(str(peer_id)) despawn_peer(peer_id)
if ship:
ship.queue_free()
# Tiles have no collision shapes of their own, so spawn one StaticBody2D per # Tiles have no collision shapes of their own, so spawn one StaticBody2D per
+3
View File
@@ -3,6 +3,7 @@
[ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"] [ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"]
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"] [ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
[ext_resource type="Script" path="res://world/world.gd" id="4_world"] [ext_resource type="Script" path="res://world/world.gd" id="4_world"]
[ext_resource type="PackedScene" uid="uid://cchatbox01a" path="res://chat/chat_box.tscn" id="5_chat"]
[node name="World" type="Node2D" unique_id=1962020789] [node name="World" type="Node2D" unique_id=1962020789]
script = ExtResource("4_world") script = ExtResource("4_world")
@@ -32,3 +33,5 @@ theme_override_font_sizes/font_size = 24
[node name="PauseMenu" parent="." instance=ExtResource("2_pm")] [node name="PauseMenu" parent="." instance=ExtResource("2_pm")]
[node name="TeamSelect" parent="." instance=ExtResource("3_ts")] [node name="TeamSelect" parent="." instance=ExtResource("3_ts")]
[node name="ChatBox" parent="." instance=ExtResource("5_chat")]