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:
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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`)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c70wvme1k38y3
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b4dxa42o6vq8k
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddrjywcfsrjdq
|
||||||
@@ -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()]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://s42nhrttgq4b
|
||||||
@@ -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]")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c4tvxxo8uvqrt
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user