Add real server pool, race roster overhaul, and ship energy system
Server browsing & matchmaking: - HL2-style main menu (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT) replaces the old CASUAL/RANKED tiles; RANKED removed end-to-end (menu, matchmaking-api's Mode.ranked queue path, MMR matching) until ranked is real. - New menu/server_select.gd lists real servers from the matchmaking API's GET /servers and connects directly, retiring the old unreachable menu/server_browser.gd. - Real game-server pool: NetworkManager.host_server() self-registers on boot and heartbeats every 8s with live player counts; API computes available/full status from player_count, and a background sweep marks any server offline once its heartbeat goes stale (catches a crashed server that never deregistered). - Server-select rows get a live UDP ping probe (query port = game port + 10000) instead of trusting stale DB numbers; post-connect HUD shows live RTT off ENet's own peer stats. Race roster overhaul: - Swapped Terran/Mechanos/Vorg for the pivoted roster — Apex Dynamics, Inner Sphere Navy, Outer Rim Collective — each with a 3-ship Fighter/Gunner/Tank lineup, art cropped from concept sheets with background removal + orientation fixes per sheet. - Live headcount + roster + "TEAM FULL" lock on the race-select screen, shared between the initial pre-spawn pick and the pause menu's live SELECT TEAM swap. - Bot personalities (bots/bot_personality.gd): aggression/caution/ accuracy/reaction/awareness traits rolled per bot instead of one fixed AI profile. HUD additions: - Player list (roster, teammates white/enemies yellow, bots flagged), kill feed, minimap, and explosion VFX on death. - Health and energy now render as bars (hud/stat_bar.gd) instead of text in the top-left HUD. Ship energy system: - Per-role max energy (Fighter 100 / Gunner 150 / Tank 250), 75 energy per shot, flat regen (100 per 2.5s), fully server-authoritative and piggybacked on the existing per-tick state broadcast alongside health. - New blue "mirrored" bar top-middle of the screen (hud/energy_bar.gd) whose fill drains from both edges toward the center instead of left-to-right. Ship handling tuning: - Turn rate reduced (4.0 -> 1.0 rad/s) so a quick tap no longer over-rotates; holding past 0.15s ramps to double speed (2.0 rad/s) for fast full turns, gated the same way damage already is so replay during reconciliation can't double-count the hold timer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@@ -20,22 +20,30 @@ README for how to run/extend it.
|
||||
|
||||
## Completed
|
||||
|
||||
1. **Server browser** — player name input, race toggles, server list (Trench Wars 0/32), JOIN button; stores name + race in `GameConfig` autoload
|
||||
2. **Main menu** — CASUAL / RANKED (disabled) buttons, callsign input, rank badge, QUIT; clears player state on load
|
||||
3. **In-game pause menu** — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (stub), QUIT TO MENU, QUIT TO DESKTOP
|
||||
1. ~~Server browser~~ — superseded by item 14's `menu/server_select.gd`
|
||||
2. ~~Main menu CASUAL/RANKED tiles~~ — superseded by item 14's HL2-style nav menu
|
||||
3. **In-game pause menu** — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (live team swap — see item 12), QUIT TO MENU, QUIT TO DESKTOP
|
||||
4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match, decided once by the server so every player sees the same pair; ship grid with sprites; player spawns with chosen ship after selection
|
||||
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`)
|
||||
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).
|
||||
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 at the time (still hardcoded, but unreachable in the live flow) — it has since been deleted, see item 14.
|
||||
11. **Player list HUD** — top-left roster of every connected player, reactive to `PlayerRegistry`'s `loadout_updated`/`player_removed` signals rather than polled (`hud/player_list.gd`, added to `world.tscn`). White entries are the local player's team (same race), yellow are the enemy team; bots (negative peer_id) get a trailing " (b)". Teammates are sorted to the top.
|
||||
12. **Select Team in pause menu (live team swap)** — pause menu's SELECT TEAM reopens `TeamSelect` mid-match instead of only offering it once pre-spawn; re-picking a race/ship reuses the existing `PlayerRegistry.submit_local_loadout` → `loadout_updated` → `Ship._init_player`/`_server_respawn` path, so the swap gets a fresh respawn + invincibility window for free. `GameConfig.team_select_focused` (mirrors `chat_focused`) blocks ship input while the picker is open, and Esc cancels back out without swapping (only once `TeamSelect` has already been used to reopen — the original mandatory pre-spawn pick still can't be cancelled). Fixed a bot-fill gap this surfaced: `PlayerRegistry.race_changed` now fires alongside `loadout_updated` so `BotManager` rebalances the race the player *left*, not just the one they joined.
|
||||
13. **Live headcounts + roster + team-full lock on the race-selection screen** — each race box on `TeamSelect`'s "CHOOSE YOUR FACTION" screen now shows a live human player count and the roster of names below it, reactive to `PlayerRegistry.loadout_updated`/`player_removed` (bots excluded from both — they're padding, not a player's actual choice). If one race has more than 2 more humans than the other, the larger side is disabled and reads "TEAM FULL" (except for a player already on that race, so reopening the picker to pick a different ship never locks you out of your own team). This is one shared code path (`_show_race_selection()`/`_refresh_race_boxes()`), so it applies identically to the initial pre-spawn pick and to item 12's mid-match SELECT TEAM reopen — no separate implementation needed for the two.
|
||||
14. **HL2-style main menu + real server select** — `main_menu.gd` rebuilt as a plain white-on-space vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS stub, PROFILE, QUIT) instead of the old big CASUAL/RANKED tiles; RANKED is gone entirely (not just disabled) until ranked matchmaking is real. Top-right shows callsign/rank/level (rank/level still placeholders, same as before). Callsign entry moved into a PROFILE overlay, auto-opened the first time QUICK PLAY is pressed with no callsign set. New `menu/server_select.gd`/`.tscn` replaces the retired `menu/server_browser.gd` — lists real servers from the matchmaking API's `GET /servers`, lets the player pick one and connect directly via `NetworkManager.join_server` (race/ship selection still happens in-world via TeamSelect, same as the matchmaking-queue path). `MatchmakingClient.list_servers()` added as the public wrapper other screens should use for this — don't call `_request` directly from outside the autoload.
|
||||
15. **Ranked mode removed backend-side + server population/ping** — item 14 already dropped ranked from the menu UI, but the matchmaking API still had a `Mode.ranked` queue path (MMR-sorted matching, `RANKED_TEAM_SIZE`) with no client ever hitting it; that's now deleted (`app/models.py`/`config.py`/`queue_manager.py`/`schemas.py` in `matchmaking-api/`) — `Mode` only has `casual`. Note this is distinct from the CADET/PILOT/.../LEGEND rank-tier *display* system (`Player.mmr`, `/ranks`, `/stats`) backing the main menu's profile badge, which stays (still placeholder-driven, see Current Tasks). `GameServer` gained `player_count`/`max_players` columns, returned by `GET /servers` and settable via `POST /servers/register`; API startup now seeds 3 demo servers (`127.0.0.1:7777/7778/7779`, `player_count` 40/20/10 of 50) instead of one. `menu/server_select.gd` shows these as DB-fallback PLAYERS/PING columns, then actively overrides them per-row with a live probe: `NetworkManager` now runs a `UDPServer` on game-port+10000 (`_start_query_responder`) that answers a raw `"SPACEWAR_PING"` datagram with live `PlayerRegistry.players.size()`/`MAX_PLAYERS`, separate from the ENet game port since ENet won't answer arbitrary UDP itself — same game-port/query-port split classic server browsers use (offset is large, not +1, since local dev runs servers on sequential ports and a +1 query port would alias onto the next server's actual game port). Only `:7777` is realistically reachable without running extra local servers, so the other two demo rows show DB numbers with ping timing out until real servers register there. Post-connect, `hud/ping_display.gd` shows the local client's live RTT top-right using ENet's own `ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME)` on peer 1 (the server) — no custom protocol needed once already connected, unlike the pre-connect server-select probe.
|
||||
16. **Real game-server pool (self-registration + heartbeat + stale sweep)** — `NetworkManager.host_server()` now calls the new `MatchmakingClient.register_server()` once on boot and then every `HEARTBEAT_INTERVAL` (8s) via a `Timer`, reporting live `PlayerRegistry.players.size()`/`MAX_PLAYERS` — item 15's demo-seeded rows are no longer the only thing populating `GET /servers`; a real server registering on the same `(ip, port, mode)` as a demo row just takes it over in place, no special-casing needed. `--server-ip=` is a new cmdline arg for the IP a hosted server advertises (defaults to loopback for local dev — see the reachability note already in `matchmaking-api/README.md` about `DEV_SEED_SERVER_IP`, same constraint applies here). API-side, `POST /servers/register` now computes `status` from `player_count`/`max_players` (`full` vs `available`) instead of always writing `available`, and a new background loop (`sweep_stale_servers` in `app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks any server `offline` once its `last_heartbeat` exceeds `server_stale_seconds` (20s) — catches a crashed/killed server that never got to deregister cleanly, so a dead server doesn't sit in the list looking joinable forever. `menu/server_select.gd`'s connect button now also blocks (with a message) on `status == "full"`, not just `"offline"`.
|
||||
17. **Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective)** — `team_select.gd`'s `RACES` now points at the 3 factions from `overview/racesclasses.md`'s pivot, each with a 3-ship Fighter/Gunner/Tank roster (Lancet/Pulsar/Sovereign, Patriot/Barrage/Behemoth, Rail-Jack/Scrap-Spitter/Iron-Clad) instead of the old 5-ship Interceptor/Gunship/Bomber/Support/Heavy set; `SPEED_BY_ROLE` shrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped at `assets/images/ship/`, now cropped per-ship into `assets/images/ships/apex|isn|orc/` with a `source/` copy of each sheet, same convention as the old race folders) — background removed via flood-fill + largest-connected-component matting (plain background for Apex, starfield for ORC, grid-lined UI panels for ISN, each needing different thresholding). ISN and ORC's source art was drawn nose-*sideways*; both were rotated 90° before saving since this project's ship rotation convention is nose-up at `rotation = 0` (`Vector2.UP.rotated(rotation)` in `ship_movement.gd`) — Apex's source art was already nose-up. `scale` values were computed with the same per-role target-on-screen-height normalization the old roster used (Fighter/Gunner/Tank targets reuse the old Interceptor/Gunship/Heavy heights, ~52.5/58.5/82.5px) so ship sizes read consistently across factions. Verified by hosting a real server and screenshotting bot fill flying with the new sprites — no load errors, transparency and nose-up orientation both correct. The old `terran/mech/vorg` asset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for.
|
||||
|
||||
## Current Tasks
|
||||
|
||||
- [ ] Asteroids and environment hazards
|
||||
- [ ] 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
|
||||
- [ ] Options screen — currently just a disabled stub button on the main menu
|
||||
- [ ] Real rank/level backend — main menu's top-right badge and RANK_DATA are still placeholders (`CURRENT_RANK`/`CURRENT_LEVEL` constants in `main_menu.gd`)
|
||||
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
|
||||
- [ ] Ranked matchmaking refinement — MMR-window widening, real account-linked MMR (currently a naive nearest-neighbor sort on a per-callsign stub); GodotSteam auth + VAC still not started
|
||||
- [ ] Real account-linked identity — GodotSteam auth + VAC still not started; `callsign` is the only player identity today
|
||||
- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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).
|
||||
# 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.
|
||||
# Real-value target per overview/bots.md (casual min 7v7). 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
|
||||
RANKED_TEAM_SIZE=5
|
||||
|
||||
# Registered on API startup so local matchmaking has somewhere to assign
|
||||
# players to before real game servers self-register via POST /servers/register.
|
||||
|
||||
@@ -27,37 +27,59 @@ rebuilding the image. Rebuild (`docker compose up -d --build`) only when
|
||||
|
||||
## How matchmaking works right now
|
||||
|
||||
- `POST /matchmaking/queue/join` `{callsign, mode, mmr?}` — creates the
|
||||
- `POST /matchmaking/queue/join` `{callsign, mode}` — creates the
|
||||
player row if it doesn't exist yet, returns a `ticket_id`.
|
||||
- `GET /matchmaking/queue/status/{ticket_id}` — poll this; once matched it
|
||||
returns the assigned server's `server_ip`/`server_port`/`team`.
|
||||
- `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket.
|
||||
|
||||
A background loop (`app/queue_manager.py`) runs every 2s and forms a match
|
||||
once enough players are queued for a mode, then splits them into two teams
|
||||
and assigns the first available `GameServer` for that mode. Casual and
|
||||
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.
|
||||
once enough players are queued for casual, then splits them into two teams
|
||||
and assigns the first available `GameServer`. 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 match — `CASUAL_TEAM_SIZE` just caps how many real players can group
|
||||
into one match, it no longer gates formation. Ranked mode has been removed
|
||||
entirely (was never implemented past matchmaking-queue scaffolding) — the
|
||||
`Mode` enum only has `casual` now.
|
||||
|
||||
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
|
||||
`127.0.0.1:7777`, i.e. a Godot server run on the host via
|
||||
`godot4 --headless --path spacewar -- --server`). That IP is handed straight
|
||||
Real servers self-register: `spacewar/autoload/network_manager.gd`'s
|
||||
`host_server()` calls `POST /servers/register` once on boot and then every
|
||||
`HEARTBEAT_INTERVAL` (8s) as a heartbeat, reporting live player count. The
|
||||
endpoint upserts on `(ip, port, mode)` — an unrecognized combo creates a
|
||||
row, a recognized one just refreshes it — and now also computes `status`
|
||||
from `player_count` vs `max_players` (`full` once at capacity) instead of
|
||||
always writing `available`. A background loop (`sweep_stale_servers` in
|
||||
`app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks a
|
||||
server `offline` once its `last_heartbeat` is older than
|
||||
`server_stale_seconds` (20s default) — catches a server that crashed or was
|
||||
`kill -9`'d instead of shutting down cleanly, so it doesn't sit in the list
|
||||
looking joinable forever.
|
||||
|
||||
Three demo `GameServer` rows are still auto-seeded on API startup
|
||||
(`_seed_demo_servers` in `main.py`) at `127.0.0.1:7777/7778/7779` with
|
||||
`player_count` 40/20/10 out of `max_players` 50, so `menu/server_select.gd`
|
||||
has something to list even with no real server running — and since
|
||||
registration is a plain upsert, running a real Godot server on one of those
|
||||
same `(ip, port)` pairs (`godot4 --headless --path spacewar -- --server`)
|
||||
just takes that row over with live data, no special-casing needed. A demo
|
||||
row with nothing real backing it goes stale and flips to `offline` within
|
||||
`server_stale_seconds` of API startup, same as any other server. `--server-ip=`
|
||||
(new `network_manager.gd` cmdline arg) controls what IP a hosted server
|
||||
advertises — defaults to loopback for local dev. That IP is handed straight
|
||||
to game clients to connect to, so it must be reachable from wherever the
|
||||
*client* runs, not the API container — `host.docker.internal` would resolve
|
||||
inside the api container but not on the client's machine, which is why this
|
||||
isn't a docker-internal address. Real servers should eventually call
|
||||
`POST /servers/register` on boot and periodically as a heartbeat — that
|
||||
endpoint exists but nothing calls it yet.
|
||||
isn't a docker-internal address (same constraint `DEV_SEED_SERVER_IP` has).
|
||||
`player_count`/`max_players` from `GET /servers` are just this DB-stored
|
||||
value; `menu/server_select.gd` also queries each server directly over UDP
|
||||
(`network_manager.gd`'s ping responder) for a live, pre-connect number and
|
||||
RTT, overriding the DB value in the UI once that probe answers.
|
||||
|
||||
## Client integration
|
||||
|
||||
The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`):
|
||||
CASUAL/RANKED on the main menu calls `POST /matchmaking/queue/join`, polls
|
||||
QUICK PLAY on the main menu calls `POST /matchmaking/queue/join`, polls
|
||||
`GET /matchmaking/queue/status/{ticket_id}` every 1.5s, and once `matched`
|
||||
connects `NetworkManager` directly to the returned `server_ip`/`server_port`.
|
||||
Point it at a non-default API with `godot4 ... -- --matchmaking-api=http://host:port`.
|
||||
@@ -81,6 +103,6 @@ support, in this same service.
|
||||
- **Auth**: `callsign` is the only player identity, matching the game
|
||||
client's current state (no accounts yet). Real identity arrives with the
|
||||
GodotSteam auth checklist item in `overview/tech.md`.
|
||||
- **Ranked MMR-window widening / bot-fill timeouts**: current matching is a
|
||||
simple threshold (enough players queued → form a match). Refine once
|
||||
there's real queue volume to tune against.
|
||||
- **Bot-fill timeouts**: current matching is a simple threshold (enough
|
||||
players queued → form a match). Refine once there's real queue volume to
|
||||
tune against.
|
||||
|
||||
@@ -6,9 +6,14 @@ class Settings(BaseSettings):
|
||||
|
||||
database_url: str
|
||||
casual_team_size: int = 7
|
||||
ranked_team_size: int = 5
|
||||
dev_seed_server_ip: str = "127.0.0.1"
|
||||
dev_seed_server_port: int = 7777
|
||||
# Real/demo servers are considered offline once their last heartbeat is
|
||||
# older than this. Godot's NetworkManager heartbeats every 8s (see
|
||||
# spacewar/autoload/network_manager.gd's HEARTBEAT_INTERVAL), so this
|
||||
# needs enough slack for one missed beat without flapping a live server
|
||||
# to "offline" and back.
|
||||
server_stale_seconds: int = 20
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -9,17 +9,30 @@ from app.database import Base, async_session, engine
|
||||
from app.models import GameServer, Mode, ServerStatus
|
||||
from app.queue_manager import queue_manager
|
||||
from app.routers import matchmaking, ranks, servers, stats
|
||||
from app.routers.servers import sweep_stale_servers
|
||||
|
||||
|
||||
async def _seed_dev_server() -> None:
|
||||
# Three fake rows so server_select.gd has a realistic-looking list before a
|
||||
# real server pool exists (see README's "Not set up yet"). Only the first
|
||||
# port matches DEV_SEED_SERVER_IP/PORT, so it's the only one of the three
|
||||
# that's actually reachable by running a real Godot server locally — the
|
||||
# other two are display-only until real servers self-register on those
|
||||
# ports. player_count here is just the DB fallback; a live server overrides
|
||||
# it via the UDP query responder the client probes directly (see
|
||||
# spacewar/autoload/network_manager.gd).
|
||||
_DEMO_PLAYER_COUNTS = [40, 20, 10]
|
||||
|
||||
|
||||
async def _seed_demo_servers() -> None:
|
||||
async with async_session() as db:
|
||||
for mode in (Mode.casual, Mode.ranked):
|
||||
for i, player_count in enumerate(_DEMO_PLAYER_COUNTS):
|
||||
port = settings.dev_seed_server_port + i
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(GameServer).where(
|
||||
GameServer.ip == settings.dev_seed_server_ip,
|
||||
GameServer.port == settings.dev_seed_server_port,
|
||||
GameServer.mode == mode,
|
||||
GameServer.port == port,
|
||||
GameServer.mode == Mode.casual,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
@@ -27,22 +40,33 @@ async def _seed_dev_server() -> None:
|
||||
db.add(
|
||||
GameServer(
|
||||
ip=settings.dev_seed_server_ip,
|
||||
port=settings.dev_seed_server_port,
|
||||
mode=mode,
|
||||
port=port,
|
||||
mode=Mode.casual,
|
||||
status=ServerStatus.available,
|
||||
player_count=player_count,
|
||||
max_players=50,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _run_stale_sweep_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
async with async_session() as db:
|
||||
await sweep_stale_servers(db)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await _seed_dev_server()
|
||||
await _seed_demo_servers()
|
||||
matching_task = asyncio.create_task(queue_manager.run_matching_loop())
|
||||
sweep_task = asyncio.create_task(_run_stale_sweep_loop())
|
||||
yield
|
||||
matching_task.cancel()
|
||||
sweep_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.database import Base
|
||||
|
||||
class Mode(str, enum.Enum):
|
||||
casual = "casual"
|
||||
ranked = "ranked"
|
||||
|
||||
|
||||
class ServerStatus(str, enum.Enum):
|
||||
@@ -43,6 +42,13 @@ class GameServer(Base):
|
||||
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
|
||||
status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available)
|
||||
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
# DB-stored fallback shown immediately in the server list; a live server
|
||||
# is queried directly for its real-time count (see
|
||||
# spacewar/autoload/network_manager.gd's UDP query responder and
|
||||
# menu/server_select.gd's ping probe), which overrides this in the UI
|
||||
# once that probe answers.
|
||||
player_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
max_players: Mapped[int] = mapped_column(Integer, default=50)
|
||||
|
||||
|
||||
class Match(Base):
|
||||
|
||||
@@ -55,14 +55,7 @@ class QueueManager:
|
||||
|
||||
def _waiting(self, mode: Mode) -> list[Ticket]:
|
||||
tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"]
|
||||
if mode == Mode.ranked:
|
||||
# Simple MMR-sorted grouping. No widening-window-by-wait-time yet
|
||||
# (real ranked matchmaking will want that) — nearest-neighbor by
|
||||
# mmr is a reasonable placeholder until ranked queue volume
|
||||
# exists to tune against.
|
||||
tickets.sort(key=lambda t: t.mmr)
|
||||
else:
|
||||
tickets.sort(key=lambda t: t.queued_at)
|
||||
tickets.sort(key=lambda t: t.queued_at)
|
||||
return tickets
|
||||
|
||||
async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None:
|
||||
@@ -98,20 +91,12 @@ class QueueManager:
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
async with async_session() as db:
|
||||
# Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad
|
||||
# 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.
|
||||
# Bot fill (spacewar/bots/bot_manager.gd) pads 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.
|
||||
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()
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Mode, Player
|
||||
from app.models import Player
|
||||
from app.queue_manager import queue_manager
|
||||
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
|
||||
|
||||
@@ -25,9 +25,8 @@ async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
|
||||
@router.post("/queue/join", response_model=QueueJoinResponse)
|
||||
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
|
||||
player = await _get_or_create_player(db, req.callsign)
|
||||
mmr = req.mmr if req.mode == Mode.ranked and req.mmr is not None else player.mmr
|
||||
|
||||
ticket = await queue_manager.join(player.id, player.callsign, req.mode, mmr)
|
||||
ticket = await queue_manager.join(player.id, player.callsign, req.mode, player.mmr)
|
||||
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import GameServer, ServerStatus
|
||||
from app.schemas import ServerRegisterRequest
|
||||
@@ -11,24 +12,62 @@ from app.schemas import ServerRegisterRequest
|
||||
router = APIRouter(prefix="/servers", tags=["servers"])
|
||||
|
||||
|
||||
# Real game servers should call this on boot and periodically (heartbeat) once
|
||||
# NetworkManager grows that integration. Until then, a dev server is seeded
|
||||
# on API startup (see main.py) from DEV_SEED_SERVER_IP/PORT so matchmaking is
|
||||
# testable end-to-end without it.
|
||||
def _status_for(player_count: int, max_players: int) -> ServerStatus:
|
||||
return ServerStatus.full if player_count >= max_players else ServerStatus.available
|
||||
|
||||
|
||||
# spacewar/autoload/network_manager.gd calls this once on server boot and
|
||||
# then repeatedly as a heartbeat (every HEARTBEAT_INTERVAL, currently 8s),
|
||||
# via MatchmakingClient.register_server(). Same endpoint for both — an
|
||||
# unrecognized (ip, port, mode) creates a new row, a recognized one just
|
||||
# refreshes it. sweep_stale_servers() (below) is what flips a server back to
|
||||
# offline if those heartbeats stop, e.g. the process crashed or was killed
|
||||
# without a clean shutdown.
|
||||
@router.post("/register")
|
||||
async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
existing = (
|
||||
await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode))
|
||||
).scalars().first()
|
||||
status = _status_for(req.player_count, req.max_players)
|
||||
if existing:
|
||||
existing.status = ServerStatus.available
|
||||
existing.status = status
|
||||
existing.player_count = req.player_count
|
||||
existing.max_players = req.max_players
|
||||
existing.last_heartbeat = datetime.utcnow()
|
||||
else:
|
||||
db.add(GameServer(ip=req.ip, port=req.port, mode=req.mode, status=ServerStatus.available))
|
||||
db.add(
|
||||
GameServer(
|
||||
ip=req.ip,
|
||||
port=req.port,
|
||||
mode=req.mode,
|
||||
status=status,
|
||||
player_count=req.player_count,
|
||||
max_players=req.max_players,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "registered"}
|
||||
|
||||
|
||||
# Run periodically from main.py's lifespan (same shape as
|
||||
# queue_manager.run_matching_loop()). Without this, a server that's killed
|
||||
# instead of cleanly shut down (crash, host reboot, `kill -9`) would stay
|
||||
# listed as available/full forever — server_select.gd's own UDP ping probe
|
||||
# catches that live per-row, but this keeps the DB's status column honest
|
||||
# too, e.g. for any future consumer that just reads GET /servers.
|
||||
async def sweep_stale_servers(db: AsyncSession) -> None:
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=settings.server_stale_seconds)
|
||||
stale = (
|
||||
await db.execute(
|
||||
select(GameServer).where(GameServer.last_heartbeat < cutoff, GameServer.status != ServerStatus.offline)
|
||||
)
|
||||
).scalars().all()
|
||||
for server in stale:
|
||||
server.status = ServerStatus.offline
|
||||
if stale:
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
servers = (await db.execute(select(GameServer))).scalars().all()
|
||||
@@ -38,6 +77,8 @@ async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
"port": s.port,
|
||||
"mode": s.mode,
|
||||
"status": s.status,
|
||||
"player_count": s.player_count,
|
||||
"max_players": s.max_players,
|
||||
"last_heartbeat": s.last_heartbeat,
|
||||
}
|
||||
for s in servers
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.models import Mode
|
||||
class QueueJoinRequest(BaseModel):
|
||||
callsign: str
|
||||
mode: Mode
|
||||
mmr: int | None = None # ignored for casual; defaults to the player's stored mmr for ranked
|
||||
|
||||
|
||||
class QueueJoinResponse(BaseModel):
|
||||
@@ -28,6 +27,8 @@ class ServerRegisterRequest(BaseModel):
|
||||
ip: str
|
||||
port: int
|
||||
mode: Mode
|
||||
player_count: int = 0
|
||||
max_players: int = 50
|
||||
|
||||
|
||||
class PlayerStatsResponse(BaseModel):
|
||||
|
||||
@@ -1,84 +1,131 @@
|
||||
# Races & Ship Classes
|
||||
|
||||
## Chosen Races: 1, 4, 3
|
||||
## Setting
|
||||
|
||||
From the 10 concepts below, races **1 (Terran Republic)**, **4 (Mechanos Sovereignty)**, and **3 (Vorg Swarm)** were selected.
|
||||
Three factions fight inside the massive shielding, pipes, and superstructure of
|
||||
refineries, shipyards, and mining stations — not open void. Grounded, blue-collar
|
||||
sci-fi in the vein of *The Expanse* (corporate hegemony vs. disciplined inner-planet
|
||||
military vs. gritty outer-belt miners), but built as a fast, arcade 2D tactical
|
||||
arena shooter rather than a hard-sci-fi sim. Tight factory corridors and choke
|
||||
points are the map language — see `multiplayer.md`.
|
||||
|
||||
> **Current in-game status:** Team selection now uses the real race names and real ship art for all 3 chosen races (`assets/images/ships/<race>/`). Each race has its own 5-ship roster — Interceptor, Gunship, Bomber, Support, Heavy — matching the tables below. See `team_select.gd`.
|
||||
> **Status:** This replaces the previous 10-alien-race concept pool and the
|
||||
> Terran Republic / Mechanos Sovereignty / Vorg Swarm roster. Doc-only for now —
|
||||
> game code (`team_select.gd` RACES data, `GameConfig`, existing
|
||||
> `assets/images/ships/<race>/` art) still reflects the old races until that work
|
||||
> is scheduled.
|
||||
|
||||
---
|
||||
|
||||
## The 3 Chosen Races
|
||||
## The 3 Factions
|
||||
|
||||
### Race 1 — Terran Republic ✅ Chosen
|
||||
**Weapon type:** Projectile / Ballistic
|
||||
**Visual:** Angular gray-blue military ships, recognizable fighter jet silhouettes, clean livery
|
||||
**Feel:** Standard military. Balanced and familiar. Easiest to learn.
|
||||
### Apex Dynamics — The Corporate Syndicate
|
||||
**Weapon type:** High-tech energy (coherent light / plasma)
|
||||
**Visual:** Sleek, pearlescent-white hulls with razor-sharp geometric lines and
|
||||
glowing neon-blue engine trails — looks more like a high-end luxury smartphone
|
||||
than a military weapon.
|
||||
**Feel:** High-tech and precise. Wins by not being seen until it's too late.
|
||||
**Tactical ability:** **Active Camouflage** — see [Faction Abilities](#faction-abilities-universal-system)
|
||||
|
||||
| # | Ship | Role | Description |
|
||||
|---|------|------|-------------|
|
||||
| 1 | **Warframe** | Interceptor | Very fast, rapid weak laser bursts. Built to close distance instantly and chip away at anything in range. |
|
||||
| 2 | **Striker** | Gunship | Super fast fire rate, very low damage lasers. Trades hitting power for volume of fire. |
|
||||
| 3 | **Deployer** | Bomber | Fast lasers plus an EMP mine dropped in your wake. Strong for area denial and covering a retreat. |
|
||||
| 4 | **Link Node** | Support | Lasers plus a small repair pack dropped for allies to pick up. Keeps a team topped off in prolonged fights. |
|
||||
| 5 | **Bastion** | Heavy | Tankier and slower, but its laser hits harder than anything else in the fleet. Anchors a push. |
|
||||
### Inner Sphere Navy (ISN) — The Military
|
||||
**Weapon type:** Standard-issue ballistics / ordnance
|
||||
**Visual:** Gunmetal-gray, wedge-shaped hulls with hard militaristic angles, dense
|
||||
protective plating, and prominent forward-facing gun turrets.
|
||||
**Feel:** Disciplined, heavy, frontal. Wins by tanking a choke point and forcing
|
||||
the fight.
|
||||
**Tactical ability:** **Overcharged Aegis** — see [Faction Abilities](#faction-abilities-universal-system)
|
||||
|
||||
### Outer Rim Collective (ORC) — The Belters
|
||||
**Weapon type:** Kinetic / repurposed mining hardware
|
||||
**Visual:** Blocky, gritty patchwork of mismatched metal plating and exposed
|
||||
hydraulic wiring, dominated by external fuel tanks and heavy industrial hardware
|
||||
bolted onto the hull.
|
||||
**Feel:** Scrappy and improvised. Wins by controlling terrain and denying space.
|
||||
**Tactical ability:** **Proximity Scrap-Mine** — see [Faction Abilities](#faction-abilities-universal-system)
|
||||
|
||||
---
|
||||
|
||||
### Race 4 — Mechanos Sovereignty ✅ Chosen
|
||||
**Weapon type:** Cannon / Missile
|
||||
**Visual:** Heavy industrial war machines, chrome-and-gunmetal plating, red accent lighting. Modular, interchangeable parts. Clearly machine-made.
|
||||
**Feel:** Brute-force industrial muscle. Durable, hard-hitting, built to grind a fight down rather than outmaneuver it.
|
||||
## Ship Classes
|
||||
|
||||
| # | Ship | Role | Description |
|
||||
|---|------|------|-------------|
|
||||
| 1 | **Sentinel** | Interceptor | A light scout mech with twin rapid cannons. Fast and maneuverable for a Mechanos hull. |
|
||||
| 2 | **Vanguard** | Gunship | Dual autocannons built for sustained fire. Durable enough to hold a line, fast enough to reposition. |
|
||||
| 3 | **Ravager** | Bomber | Drops heavy ordnance and mines from its cargo pods. Best used to seal off a chokepoint. |
|
||||
| 4 | **Aegis** | Support | Projects a short-range shield and deploys a repair drone for nearby allies. |
|
||||
| 5 | **Colossus** | Heavy | A walking fortress bristling with cannons. Slow and telegraphed, but absorbs enormous damage. |
|
||||
Every faction fields the same 3-ship structure, re-skinned to its own theme:
|
||||
|
||||
| Class | Role |
|
||||
|-------|------|
|
||||
| **Fighter** | Single, high-precision, high-damage shot |
|
||||
| **Gunner** | Rapid-fire spray/burst, more shots for less damage each |
|
||||
| **Tank** | Bigger and slower; fires both bullets and missiles |
|
||||
|
||||
### Apex Dynamics
|
||||
|
||||
| Ship | Class | Weapon |
|
||||
|------|-------|--------|
|
||||
| **Lancet** | Fighter | A single, high-precision bolt of blue coherent light |
|
||||
| **Pulsar** | Gunner | A wide, rapid-fire fan of low-damage green plasma pulses |
|
||||
| **Sovereign** | Tank | Homing micro-missiles alongside dual heavy plasma batteries |
|
||||
|
||||
### Inner Sphere Navy (ISN)
|
||||
|
||||
| Ship | Class | Weapon |
|
||||
|------|-------|--------|
|
||||
| **Patriot** | Fighter | A single, hard-hitting high-caliber explosive shell |
|
||||
| **Barrage** | Gunner | A relentless quad-barrel spray of fast, low-damage light bullets |
|
||||
| **Behemoth** | Tank | Heavy tracking torpedoes plus massive physical deck-guns |
|
||||
|
||||
### Outer Rim Collective (ORC)
|
||||
|
||||
| Ship | Class | Weapon |
|
||||
|------|-------|--------|
|
||||
| **Rail-Jack** | Fighter | A single, heavy magnetic railgun slug |
|
||||
| **Scrap-Spitter** | Gunner | A rotary scrap-cannon spraying rapid, low-damage metal shrapnel |
|
||||
| **Iron-Clad** | Tank | Unguided explosive rockets plus continuous vulcan machine-gun fire |
|
||||
|
||||
---
|
||||
|
||||
### Race 3 — Vorg Swarm ✅ Chosen
|
||||
**Weapon type:** Bio / Acid (DoT)
|
||||
**Visual:** Organic insectoid creatures rather than built ships — chitin, wings, bioluminescent glow. No two look mechanically alike.
|
||||
**Feel:** Chaotic organic swarm. Corrosive damage-over-time chips away at anyone who lingers nearby.
|
||||
## Faction Abilities (Universal System)
|
||||
|
||||
| # | Ship | Role | Description |
|
||||
|---|------|------|-------------|
|
||||
| 1 | **Stinger** | Interceptor | Fast, acid shots with light damage-over-time. Swarms a target from range. |
|
||||
| 2 | **Spitter** | Gunship | Faster-firing acid with a stronger damage-over-time effect. Sustained corrosive pressure. |
|
||||
| 3 | **Broodling** | Bomber | Normal acid shots that also spawn a small slowing swarm cloud. Locks down a retreat or chokepoint. |
|
||||
| 4 | **Nurse** | Support | Acid shots plus a small health/regeneration pack dropped for allies. |
|
||||
| 5 | **Behemoth** | Heavy | Tankier and slower, with heavy acid shots that carry a large damage-over-time effect. The swarm's apex predator. |
|
||||
Each faction has exactly one tactical ability. Every ship in that faction gets
|
||||
it — what changes per class is scale, not kind, so the ability reads instantly
|
||||
off a ship's faction regardless of which of the 3 hulls it's flying. Same energy
|
||||
cost and cooldown across factions; duration/impact scales with the hull:
|
||||
|
||||
---
|
||||
| Class | Scaling rule |
|
||||
|-------|--------------|
|
||||
| Fighter | Fast cooldown, short/light effect |
|
||||
| Gunner | Baseline cooldown and effect |
|
||||
| Tank | Long cooldown, largest/heaviest effect |
|
||||
|
||||
## All 10 Race Concepts (Reference)
|
||||
### Apex Dynamics — Active Camouflage
|
||||
Ship turns 90% transparent and disappears from radar. Firing or taking damage
|
||||
breaks it instantly. Fighter gets a quick, short slip; Tank's lasts longest but
|
||||
is riskiest to commit a slow hull to.
|
||||
**Use:** Slip past a defender in a narrow pipe, flank, or break a missile lock.
|
||||
|
||||
<details>
|
||||
<summary>Click to expand all 10 races</summary>
|
||||
### Inner Sphere Navy — Overcharged Aegis
|
||||
A frontal hard-light barrier absorbs 100% of incoming damage from the front for
|
||||
a few seconds. Sides and rear stay exposed, turn rate drops, and the ship can't
|
||||
fire while it's up. Tank's barrier is wide enough to shield teammates trailing
|
||||
behind it; Fighter's is a narrow, quick poke.
|
||||
**Use:** Push straight through a defended choke point, soaking fire so teammates
|
||||
can follow.
|
||||
|
||||
### Race 2 — Xel'Nara Collective
|
||||
**Weapon type:** Laser / Crystal Energy | **Feel:** Alien elegance, glass cannon
|
||||
### Outer Rim Collective — Proximity Scrap-Mine
|
||||
Ejects a magnetized mine that arms after ~1 second and goes dark on radar.
|
||||
Detonates on enemy proximity for heavy damage in a small radius plus knockback;
|
||||
alert enemies can shoot it from a distance to trigger it safely. Only one mine
|
||||
per ship at a time — dropping a second detonates the first. Tank's mine has a
|
||||
larger blast radius; Fighter's arms faster but hits smaller.
|
||||
**Use:** Cover a retreat down a corridor, or seed a choke point/objective before
|
||||
a push.
|
||||
|
||||
### Race 5 — Void Wraiths
|
||||
**Weapon type:** Dark Energy / Gravity / Phase | **Feel:** Terrifying and unpredictable, gravity wells and phase attacks, hard to pin down
|
||||
### The Rock-Paper-Scissors Loop
|
||||
|
||||
### Race 6 — Solari Imperium
|
||||
**Weapon type:** Plasma / Solar Fire | **Feel:** Ancient empire, slow and devastating
|
||||
- **ORC mines counter Apex cloak** — a cloaked ship blundering through a mined
|
||||
corridor eats the blast, which also breaks the cloak.
|
||||
- **ISN shield counters ORC mines** — the frontal barrier absorbs a mine
|
||||
detonation, clearing the path for the team behind it.
|
||||
- **Apex cloak counters ISN shield** — a slow, frontal-shielded push leaves the
|
||||
ISN ship's flank and engines open for a cloaked ship to slip around and
|
||||
punish.
|
||||
|
||||
### Race 7 — Kryx Hivemind
|
||||
**Weapon type:** Swarm Missiles / Acid | **Feel:** Insectoid swarm, volume of fire
|
||||
|
||||
### Race 8 — Nebulon Drifters
|
||||
**Weapon type:** Gas Clouds / Toxin | **Feel:** Environmental control, zone denial
|
||||
|
||||
### Race 9 — Iron Covenant
|
||||
**Weapon type:** EMP / Railgun / Hacking | **Feel:** Disruptor faction, breaks enemy systems
|
||||
|
||||
### Race 10 — Eldari Ascendancy
|
||||
**Weapon type:** Psionic / Reality Distortion | **Feel:** Psychic manipulation, bullets that curve
|
||||
|
||||
</details>
|
||||
No ability wins a fight by itself — it depends on the map and the moment it's
|
||||
used, same as a smoke/flash/molotov in CS:GO.
|
||||
|
||||
@@ -9,8 +9,8 @@ spacewar/ ← repo root
|
||||
├── autoload/
|
||||
│ └── game_config.gd ← autoload singleton (tuning values, player state, signals)
|
||||
├── menu/
|
||||
│ ├── main_menu.tscn/gd ← entry point / main scene
|
||||
│ ├── server_browser.tscn/gd ← server browser UI (built, not in active flow yet)
|
||||
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav
|
||||
│ ├── server_select.tscn/gd ← server list UI, backed by matchmaking-api's GET /servers
|
||||
│ ├── pause_menu.tscn/gd ← in-game pause overlay (ESC / Start button)
|
||||
│ └── team_select.tscn/gd ← race + ship selection overlay (shown on world load)
|
||||
├── ships/
|
||||
@@ -19,6 +19,8 @@ spacewar/ ← repo root
|
||||
│ └── bullet.tscn/gd ← projectile
|
||||
├── chat/
|
||||
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
|
||||
├── hud/
|
||||
│ └── player_list.tscn/gd ← top-left player roster (white=team, yellow=enemy, "(b)"=bot), 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
|
||||
@@ -47,6 +49,11 @@ spacewar/ ← repo root
|
||||
MainMenu (Control) ← main_menu.gd builds all UI in _ready()
|
||||
```
|
||||
|
||||
### `menu/server_select.tscn` — server list
|
||||
```
|
||||
ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches GET /servers
|
||||
```
|
||||
|
||||
### `world/world.tscn` — game world
|
||||
```
|
||||
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
|
||||
@@ -56,7 +63,8 @@ World (Node2D) ← world.gd instances MAP_SCENE into MapContainer o
|
||||
│ └── HealthLabel
|
||||
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
|
||||
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
|
||||
└── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
|
||||
├── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
|
||||
└── PlayerList (CanvasLayer, layer=4) ← hud/player_list.tscn
|
||||
```
|
||||
|
||||
### `world/maps/map_01.tscn` — hand-painted map
|
||||
@@ -95,14 +103,17 @@ Player (CharacterBody2D) ← ship_movement.gd
|
||||
|
||||
```
|
||||
main_menu.tscn
|
||||
├── CASUAL → world.tscn
|
||||
├── QUICK PLAY → matchmaking queue (casual) → world.tscn
|
||||
│ ├── TeamSelect overlay: pick race (2 random of 3 offered)
|
||||
│ ├── TeamSelect overlay: pick ship
|
||||
│ └── Player spawns → game live
|
||||
└── RANKED → disabled (coming soon)
|
||||
├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
|
||||
├── OPTIONS → stub, coming soon
|
||||
├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
|
||||
└── QUIT
|
||||
```
|
||||
|
||||
> **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.
|
||||
Ranked matchmaking is removed from the menu entirely (not just disabled) until it's real — see `CLAUDE.md` Current Tasks.
|
||||
|
||||
## Display
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
# Task Log
|
||||
|
||||
Task 1
|
||||
|
||||
I want to build an energy system for each ship. Below the health put an energy of 100/100 for the fighter/ 150/150 for middle ship and big ship 250 energy. Each bullet takes 75 to shoot. Have a blue energy bar that is mirrord top middle appear at the top . Have it take 2.5 seconds to refill 100
|
||||
|
||||
## Completed
|
||||
|
||||
| # | Task | Notes |
|
||||
|---|------|-------|
|
||||
| 0 | Format all design docs as Markdown | Done |
|
||||
| 24 | Energy system (Fighter 100 / Gunner 150 / Tank 250, 75 per shot, blue center-out mirrored bar top-middle, +100 per 2.5s regen) | `GameConfig.ship_max_energy_by_role`/`bullet_energy_cost`/`ship_energy_regen_rate`; server-authoritative in `ships/ship_movement.gd` (broadcast via `_receive_state`, same pattern as health); role threaded through `PlayerRegistry` loadout + bots; text readout `HUD/EnergyLabel` below HP, visual bar `hud/energy_bar.gd`/`.tscn` |
|
||||
| 1 | Design 10 races, pick 3 | Races 1, 4, 5 chosen — see `racesclasses.md` |
|
||||
| 2 | Ranked + casual mode ideas | See `multiplayer.md` |
|
||||
| 3 | Multiplayer engineering doc | See `tech.md` |
|
||||
| 4 | Server browser screen | Player name, race buttons, server list, JOIN — stores in `GameConfig` |
|
||||
| 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button |
|
||||
| 4 | ~~Server browser screen~~ | Superseded by #21's `menu/server_select.gd` |
|
||||
| 5 | ~~Main menu (CASUAL/RANKED tiles)~~ | Superseded by #21's HL2-style nav menu |
|
||||
| 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 |
|
||||
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
|
||||
@@ -17,6 +22,9 @@
|
||||
| 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` |
|
||||
| 17 | Select Team in pause menu (live team swap) | Reopens `TeamSelect` mid-match via `PlayerRegistry.submit_local_loadout`'s existing respawn path; bot fill rebalances the vacated race too (`PlayerRegistry.race_changed`) |
|
||||
| 20 | Live headcount/roster + TEAM FULL lock on race select | Shared by the initial pick and #17's reopen; humans only (bots excluded), blocks joining a race >2 humans ahead of the other, exempts your own current race |
|
||||
| 21 | HL2-style main menu + real server select | `main_menu.gd` rebuilt as plain white vertical nav (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT); RANKED removed entirely; new `menu/server_select.gd` lists real servers via `MatchmakingClient.list_servers()` (`GET /servers`) and connects directly, retiring `menu/server_browser.gd` |
|
||||
|
||||
## Up Next
|
||||
|
||||
@@ -27,4 +35,5 @@
|
||||
| 14 | Galaxy war meta + sector control | Low (post-networking) |
|
||||
| 15 | Ranked matchmaking + MMR | Low (post-networking) |
|
||||
| 16 | Settings screen (audio, controls, display) | Low |
|
||||
| 17 | Select Team in pause menu (live team swap) | Low |
|
||||
| 22 | Options screen (currently a disabled stub on the main menu) | Low |
|
||||
| 23 | Real rank/level backend for the main menu's top-right badge | Low |
|
||||
|
||||
@@ -95,6 +95,6 @@ but nothing calls it yet.
|
||||
- [x] Position sync with interpolation
|
||||
- [x] Ship class registration per peer
|
||||
- [x] Server-authoritative health/death
|
||||
- [x] Matchmaking API (queue + lobby assignment) — client wired end-to-end; real server pool and ranked MMR-window widening still open, see Current Tasks in `CLAUDE.md`
|
||||
- [x] Matchmaking API (queue + lobby assignment) — client wired end-to-end for casual; ranked mode implementation removed until it's real (see `CLAUDE.md`); real server pool still open, see Current Tasks in `CLAUDE.md`
|
||||
- [ ] GodotSteam auth + VAC
|
||||
- [ ] Lag compensation (basic rewind)
|
||||
|
||||
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dguubg5diklxk"
|
||||
path="res://.godot/imported/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg-f0cdd8e07e962d7ee4a3798785dc7c62.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg"
|
||||
dest_files=["res://.godot/imported/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg-f0cdd8e07e962d7ee4a3798785dc7c62.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://qlamialwad8x"
|
||||
path="res://.godot/imported/explosion_01.png-4c53e5953b371103554a8dd07eede195.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_01.png"
|
||||
dest_files=["res://.godot/imported/explosion_01.png-4c53e5953b371103554a8dd07eede195.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dm6ng2eudva3n"
|
||||
path="res://.godot/imported/explosion_02.png-0e50681433ba5e47b7e2eb897c21f876.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_02.png"
|
||||
dest_files=["res://.godot/imported/explosion_02.png-0e50681433ba5e47b7e2eb897c21f876.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bx5fwswqtolwf"
|
||||
path="res://.godot/imported/explosion_03.png-2edccd48705b215af7cd78a8c2fa9c21.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_03.png"
|
||||
dest_files=["res://.godot/imported/explosion_03.png-2edccd48705b215af7cd78a8c2fa9c21.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c0hjd6i5aq8o7"
|
||||
path="res://.godot/imported/explosion_04.png-908ea26c3b0aba8f98e0c73226999853.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_04.png"
|
||||
dest_files=["res://.godot/imported/explosion_04.png-908ea26c3b0aba8f98e0c73226999853.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dnlm5tu7i3oj8"
|
||||
path="res://.godot/imported/explosion_05.png-0d8dbeaabed098318c1db7a1eb18d746.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_05.png"
|
||||
dest_files=["res://.godot/imported/explosion_05.png-0d8dbeaabed098318c1db7a1eb18d746.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cqflkg8vy0ntw"
|
||||
path="res://.godot/imported/explosion_06.png-8f7bb7be1ccca8a8508b2c1a818ff9b1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_06.png"
|
||||
dest_files=["res://.godot/imported/explosion_06.png-8f7bb7be1ccca8a8508b2c1a818ff9b1.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://chkgsyufrx3kk"
|
||||
path="res://.godot/imported/explosion_07.png-c3e0ab76fd3a34d4ebeef527ae6b74a6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_07.png"
|
||||
dest_files=["res://.godot/imported/explosion_07.png-c3e0ab76fd3a34d4ebeef527ae6b74a6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bmlawa6tytlet"
|
||||
path="res://.godot/imported/explosion_08.png-8517f51a72fc7aa47bfbd1d58d86f54b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_08.png"
|
||||
dest_files=["res://.godot/imported/explosion_08.png-8517f51a72fc7aa47bfbd1d58d86f54b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ce3pmj845ihtn"
|
||||
path="res://.godot/imported/explosion_09.png-a26440b2a6f5b50a561e92c564a69fe6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_09.png"
|
||||
dest_files=["res://.godot/imported/explosion_09.png-a26440b2a6f5b50a561e92c564a69fe6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cte31rb8efu72"
|
||||
path="res://.godot/imported/explosion_10.png-1727ee912d3fba21a1357a2b67b4c150.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_10.png"
|
||||
dest_files=["res://.godot/imported/explosion_10.png-1727ee912d3fba21a1357a2b67b4c150.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fc1rqxscnw8f"
|
||||
path="res://.godot/imported/explosion_11.png-81f0f24665c9d3d8d5e58503f8831d24.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_11.png"
|
||||
dest_files=["res://.godot/imported/explosion_11.png-81f0f24665c9d3d8d5e58503f8831d24.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://76gv4wm32p7l"
|
||||
path="res://.godot/imported/explosion_12.png-dfe87e1d89d69bec2c167437163c7444.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/explosion_12.png"
|
||||
dest_files=["res://.godot/imported/explosion_12.png-dfe87e1d89d69bec2c167437163c7444.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cxr88dcqaulit"
|
||||
path="res://.godot/imported/explosion_sheet.jpeg-d3eff2ea5e3c0e5b20184dd6e9e60baf.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion/source/explosion_sheet.jpeg"
|
||||
dest_files=["res://.godot/imported/explosion_sheet.jpeg-d3eff2ea5e3c0e5b20184dd6e9e60baf.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -2,16 +2,16 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ded6ek6l7n8gb"
|
||||
path="res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex"
|
||||
uid="uid://b8tlmag3nkye1"
|
||||
path="res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/effects/explosion1.png"
|
||||
dest_files=["res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex"]
|
||||
source_file="res://assets/images/ships/apex/lancet.png"
|
||||
dest_files=["res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b1d5536o6asen"
|
||||
path="res://.godot/imported/pulsar.png-540998692d6787c6418d7a09a4174132.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/apex/pulsar.png"
|
||||
dest_files=["res://.godot/imported/pulsar.png-540998692d6787c6418d7a09a4174132.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bybcq2rus582u"
|
||||
path="res://.godot/imported/apex_ships.jpeg-0ec570ae56f16d08d6f035d1183b6e44.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/apex/source/apex_ships.jpeg"
|
||||
dest_files=["res://.godot/imported/apex_ships.jpeg-0ec570ae56f16d08d6f035d1183b6e44.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bykx3457gpph0"
|
||||
path="res://.godot/imported/sovereign.png-cabfebc1fb08153369b73ec967b3f2d9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/apex/sovereign.png"
|
||||
dest_files=["res://.godot/imported/sovereign.png-cabfebc1fb08153369b73ec967b3f2d9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://co1mqbhl0uyxn"
|
||||
path="res://.godot/imported/barrage.png-a8fd1168c9dbf90adbf522771b397e6a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/isn/barrage.png"
|
||||
dest_files=["res://.godot/imported/barrage.png-a8fd1168c9dbf90adbf522771b397e6a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cvfko6k4gria8"
|
||||
path="res://.godot/imported/behemoth.png-4d8f9559d83bf436fa79a4f9bc89b85d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/isn/behemoth.png"
|
||||
dest_files=["res://.godot/imported/behemoth.png-4d8f9559d83bf436fa79a4f9bc89b85d.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ny5lxa253x2l"
|
||||
path="res://.godot/imported/patriot.png-e50347577a9cfbf01e487858016c3f0b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/isn/patriot.png"
|
||||
dest_files=["res://.godot/imported/patriot.png-e50347577a9cfbf01e487858016c3f0b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 330 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bu3mx4q42yjt3"
|
||||
path="res://.godot/imported/isn_ships.jpeg-bc1ba3af07741827f797cccb15cc8285.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/isn/source/isn_ships.jpeg"
|
||||
dest_files=["res://.godot/imported/isn_ships.jpeg-bc1ba3af07741827f797cccb15cc8285.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c81ebvx34vj6c"
|
||||
path="res://.godot/imported/iron_clad.png-fae857e77dcbb343670adfb9236cbae9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/orc/iron_clad.png"
|
||||
dest_files=["res://.godot/imported/iron_clad.png-fae857e77dcbb343670adfb9236cbae9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cpe81feymft3l"
|
||||
path="res://.godot/imported/rail_jack.png-5cf2e7c1664f7d1d3c3cbcf08acfc3ce.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/orc/rail_jack.png"
|
||||
dest_files=["res://.godot/imported/rail_jack.png-5cf2e7c1664f7d1d3c3cbcf08acfc3ce.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bkula1evuj8td"
|
||||
path="res://.godot/imported/scrap_spitter.png-36960733faf816166f6cb9624422a331.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/orc/scrap_spitter.png"
|
||||
dest_files=["res://.godot/imported/scrap_spitter.png-36960733faf816166f6cb9624422a331.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 124 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://r812qvf8wvnj"
|
||||
path="res://.godot/imported/orc_ships.jpeg-3c93ef705a05d2faa073a21bbfddf048.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/images/ships/orc/source/orc_ships.jpeg"
|
||||
dest_files=["res://.godot/imported/orc_ships.jpeg-3c93ef705a05d2faa073a21bbfddf048.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -8,6 +8,40 @@ signal message_received(sender_name: String, sender_race: int, team_only: bool,
|
||||
|
||||
const MAX_MESSAGE_LENGTH := 140
|
||||
|
||||
# Virtual system sender for join/leave announcements below. Not a real peer,
|
||||
# so chat_box.gd special-cases this name to render it distinctly from player
|
||||
# chat instead of trying to look up a race color for it.
|
||||
const ADMIN_NAME := "Admin"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# PlayerRegistry's join/removal state is already replicated identically to
|
||||
# every peer (loadout relay + ENet's own disconnect broadcast), so these
|
||||
# fire in sync on every client already — no extra RPC needed to announce
|
||||
# them, same as send_chat's local echo below needing no round trip.
|
||||
PlayerRegistry.player_joined.connect(_on_player_joined)
|
||||
PlayerRegistry.player_removed.connect(_on_player_removed)
|
||||
|
||||
|
||||
# Bots (negative peer_id) are matchmaking padding, not real players — the
|
||||
# race-selection headcount already excludes them for the same reason (see
|
||||
# team_select.gd), so admin chat doesn't announce bot fill churn either.
|
||||
func _on_player_joined(peer_id: int) -> void:
|
||||
if peer_id < 0:
|
||||
return
|
||||
var player_name: String = PlayerRegistry.get_info(peer_id).get("name", "A player")
|
||||
_announce("%s has joined the match." % player_name)
|
||||
|
||||
|
||||
func _on_player_removed(peer_id: int, info: Dictionary) -> void:
|
||||
if peer_id < 0:
|
||||
return
|
||||
_announce("%s has left the match." % info.get("name", "A player"))
|
||||
|
||||
|
||||
func _announce(text: String) -> void:
|
||||
message_received.emit(ADMIN_NAME, 0, false, text)
|
||||
|
||||
|
||||
func send_chat(raw_text: String, team_only: bool) -> void:
|
||||
var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH)
|
||||
|
||||
@@ -3,7 +3,12 @@ extends Node
|
||||
# Ship movement
|
||||
var ship_thrust: float = 250.0
|
||||
var ship_max_speed: float = 300.0
|
||||
var ship_rotation_speed: float = 4.0
|
||||
var ship_rotation_speed: float = 1.0
|
||||
# Continuing to hold left/right past this many seconds ramps to
|
||||
# ship_rotation_speed_held (double the tap rate) — a quick tap stays precise,
|
||||
# a held turn goes faster. See ship_movement.gd's _simulate_step().
|
||||
var ship_rotation_ramp_delay: float = 0.15
|
||||
var ship_rotation_speed_held: float = 2.0
|
||||
|
||||
# Shooting
|
||||
var ship_fire_rate: float = 0.15
|
||||
@@ -15,10 +20,34 @@ var ship_respawn_delay: float = 3.0
|
||||
var ship_invincibility_time: float = 2.0
|
||||
var bullet_damage: int = 40
|
||||
|
||||
# Energy (see ships/ship_movement.gd) — max energy scales by ship role
|
||||
# (Fighter/Gunner/Tank, see menu/team_select.gd's RACES ship "role" field);
|
||||
# regen is a flat rate regardless of role, so bigger ships take longer to
|
||||
# top off from empty than smaller ones.
|
||||
var ship_max_energy_by_role: Dictionary = {"Fighter": 100.0, "Gunner": 150.0, "Tank": 250.0}
|
||||
var bullet_energy_cost: float = 75.0
|
||||
var ship_energy_regen_rate: float = 100.0 / 2.5 # 100 energy per 2.5s
|
||||
|
||||
# Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed
|
||||
var ship_bounce_restitution: float = 0.45
|
||||
var ship_collision_damage_scale: float = 0.064
|
||||
|
||||
# One shared knob to resize every ship's in-world sprite at once, applied on
|
||||
# top of each ship's own per-role "scale" in team_select.gd's RACES (see
|
||||
# ship_movement.gd's _init_player()) — doesn't touch those individually
|
||||
# balanced per-role values, just scales the end result uniformly.
|
||||
var ship_scale_factor: float = 0.75
|
||||
|
||||
# Explosion VFX (effects/explosion.gd) — one shared knob to resize every
|
||||
# explosion in the game at once; explosion_fps controls playback speed of the
|
||||
# 12-frame animation (see assets/images/effects/explosion/). Each frame is
|
||||
# held for 5 ticks (explosion.tscn's SpriteFrames "duration": 5.0 per frame,
|
||||
# so mostly-transparent/dark frames like the opening spark and the frame 6
|
||||
# ember cloud have time to register instead of reading as a flicker-to-black
|
||||
# gap between the brighter frames) — total time is (12 * 5) / explosion_fps.
|
||||
var explosion_scale: float = 0.75
|
||||
var explosion_fps: float = 24.0 # 60 ticks / 24.0 fps = 2.5s total
|
||||
|
||||
# Locally-remembered callsign, pre-fills the name field on the menu.
|
||||
# Race/ship/speed are per-peer now — see PlayerRegistry.
|
||||
var player_name: String = ""
|
||||
@@ -26,12 +55,40 @@ var player_name: String = ""
|
||||
# Current map's play area, in world coordinates. Set by world.gd on load.
|
||||
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
||||
|
||||
# Random offset applied around a chosen team_a_spawn/team_b_spawn marker
|
||||
# (see ship_movement.gd's _get_spawn_position()) so multiple ships spawning
|
||||
# off the same single marker -- e.g. a 7-bot casual-fill team -- don't stack
|
||||
# exactly on top of each other.
|
||||
var spawn_scatter_radius: float = 150.0
|
||||
|
||||
# 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
|
||||
|
||||
# True while TeamSelect is reopened mid-match (pause menu "SELECT TEAM") —
|
||||
# same purpose as chat_focused, so clicking ship rows doesn't also fly the
|
||||
# ship underneath. Not needed for the initial pre-spawn pick since the ship
|
||||
# has no physics processing yet at that point.
|
||||
var team_select_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
|
||||
|
||||
# Personality trait ranges (bots/bot_personality.gd) — each bot rolls all 5
|
||||
# traits 0..1 independently once at spawn; these ranges convert that roll
|
||||
# into the concrete numbers BotAI actually flies with.
|
||||
var bot_stop_distance_min: float = 70.0 # aggression 1: dogfights up close
|
||||
var bot_stop_distance_max: float = 260.0 # aggression 0: hangs back at range
|
||||
var bot_retreat_health_frac_min: float = 0.10 # caution 0: fights to the death
|
||||
var bot_retreat_health_frac_max: float = 0.65 # caution 1: breaks off early
|
||||
var bot_aim_tolerance_best_deg: float = 3.0 # accuracy 1: pinpoint gate
|
||||
var bot_aim_tolerance_worst_deg: float = 14.0 # accuracy 0: sloppy gate
|
||||
var bot_aim_jitter_max_px: float = 220.0 # accuracy 0: aims this far off the real spot
|
||||
var bot_reaction_update_best_sec: float = 0.05 # reaction 1: re-aims almost every tick
|
||||
var bot_reaction_update_worst_sec: float = 0.6 # reaction 0: laggy, stale tracking
|
||||
var bot_awareness_range_min: float = 900.0 # awareness 0: near-sighted
|
||||
var bot_awareness_range_max: float = 16000.0 # awareness 1: sees across the whole map
|
||||
# map_01 spawns the two teams ~10608 units apart (see world/maps/map_01.tscn),
|
||||
# on a ~14016x6000 map (diagonal ~15247) — the max must clear that or every
|
||||
# bot spawns permanently unable to detect the enemy team and never advances.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
extends Node
|
||||
|
||||
# Server-authoritative kill feed. Unlike ChatManager, a death is only ever
|
||||
# decided on the server (see ships/ship_movement.gd's _die()), so there's no
|
||||
# client -> server leg here -- just one broadcast RPC per kill.
|
||||
|
||||
signal kill_reported(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool)
|
||||
|
||||
|
||||
# source_peer_id == 0 means no attacking ship (hazard/wall collision damage,
|
||||
# see ship_movement.gd's take_damage()/_apply_collision_bounce()).
|
||||
func report_kill(victim_peer_id: int, killer_peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var victim_info := PlayerRegistry.get_info(victim_peer_id)
|
||||
var victim_name: String = victim_info.get("name", "Player")
|
||||
var victim_race: int = victim_info.get("race", 0)
|
||||
|
||||
var is_environment_kill := killer_peer_id == 0 or killer_peer_id == victim_peer_id
|
||||
var killer_name := ""
|
||||
var killer_race := 0
|
||||
if not is_environment_kill:
|
||||
var killer_info := PlayerRegistry.get_info(killer_peer_id)
|
||||
killer_name = killer_info.get("name", "Player")
|
||||
killer_race = killer_info.get("race", 0)
|
||||
|
||||
_receive_kill.rpc(victim_name, victim_race, killer_name, killer_race, is_environment_kill)
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _receive_kill(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool) -> void:
|
||||
kill_reported.emit(victim_name, victim_race, killer_name, killer_race, is_environment_kill)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dinxq78rl35lu
|
||||
@@ -52,6 +52,34 @@ func start_matchmaking(callsign: String, mode: String) -> void:
|
||||
_poll_timer.start()
|
||||
|
||||
|
||||
# Returns {"ok": bool, "servers": Array} — one dict per row from GET /servers
|
||||
# (ip, port, mode, status, player_count, max_players, last_heartbeat). "ok"
|
||||
# is false if the API couldn't be reached at all, distinct from a reachable
|
||||
# API returning an empty list.
|
||||
func list_servers() -> Dictionary:
|
||||
var result := await _request("GET", "/servers", null)
|
||||
if result.get("code", 0) != 200:
|
||||
return {"ok": false, "servers": []}
|
||||
return {"ok": true, "servers": result.body}
|
||||
|
||||
|
||||
# Called by NetworkManager once on server boot and then repeatedly as a
|
||||
# heartbeat — same endpoint both times, an unrecognized (ip, port, mode)
|
||||
# creates a row, a recognized one refreshes it. Returns true on success;
|
||||
# NetworkManager doesn't hard-fail on a missed heartbeat (the API's own
|
||||
# stale-server sweep just marks it offline until the next one lands), so
|
||||
# callers can fire-and-forget this.
|
||||
func register_server(ip: String, port: int, mode: String, player_count: int, max_players: int) -> bool:
|
||||
var result := await _request("POST", "/servers/register", {
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"mode": mode,
|
||||
"player_count": player_count,
|
||||
"max_players": max_players,
|
||||
})
|
||||
return result.get("code", 0) == 200
|
||||
|
||||
|
||||
func cancel_matchmaking() -> void:
|
||||
if not _searching:
|
||||
return
|
||||
|
||||
@@ -8,9 +8,39 @@ signal peer_left(peer_id: int)
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_PLAYERS := 50 # covers 25v25 casual
|
||||
|
||||
# Lightweight ping/population query, answered on a dedicated UDP port
|
||||
# (game port + a large offset, same "game port vs. query port" split classic
|
||||
# server browsers use) rather than the ENet game port itself, since ENet's
|
||||
# own protocol won't respond to arbitrary raw datagrams. Lets
|
||||
# menu/server_select.gd measure real RTT and live player count for a server
|
||||
# *before* actually joining it, without touching PlayerRegistry or ENet peer
|
||||
# state at all. The offset must be big enough that it can't land on another
|
||||
# server's game port — local dev runs several servers on sequential ports
|
||||
# (7777, 7778, 7779, ...), so a +1 offset would alias server N's query port
|
||||
# onto server N+1's actual game port.
|
||||
const QUERY_PORT_OFFSET := 10000
|
||||
const PING_REQUEST := "SPACEWAR_PING"
|
||||
|
||||
# How often a hosted server re-registers with the matchmaking API as a
|
||||
# heartbeat. app/config.py's server_stale_seconds (20s) needs enough slack
|
||||
# over this for one missed beat without flapping a live server to "offline".
|
||||
const HEARTBEAT_INTERVAL := 8.0
|
||||
|
||||
var is_server: bool = false
|
||||
var target_ip: String = "127.0.0.1"
|
||||
|
||||
# IP advertised to the matchmaking API for other players to connect to —
|
||||
# not necessarily the same as the interface ENet binds to. Defaults to
|
||||
# loopback for local dev; a real deployment sets --server-ip= to whatever
|
||||
# address is actually reachable from a player's machine (matches the
|
||||
# DEV_SEED_SERVER_IP note in matchmaking-api/README.md about this same
|
||||
# reachability requirement).
|
||||
var _register_ip: String = "127.0.0.1"
|
||||
var _hosted_port: int = DEFAULT_PORT
|
||||
var _heartbeat_timer: Timer
|
||||
|
||||
var _query_udp: UDPServer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
@@ -23,12 +53,16 @@ func _ready() -> void:
|
||||
|
||||
|
||||
# Dev-only bootstrap: `-- --server` runs this instance as a dedicated headless
|
||||
# server; `-- --connect=<ip>` overrides the default join target for clients.
|
||||
# server; `-- --connect=<ip>` overrides the default join target for clients;
|
||||
# `-- --server-ip=<ip>` overrides the IP a hosted server advertises to the
|
||||
# matchmaking API (see _register_ip).
|
||||
func _apply_cmdline_args() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
for arg in args:
|
||||
if arg.begins_with("--connect="):
|
||||
target_ip = arg.substr("--connect=".length())
|
||||
elif arg.begins_with("--server-ip="):
|
||||
_register_ip = arg.substr("--server-ip=".length())
|
||||
if args.has("--server"):
|
||||
host_server(DEFAULT_PORT)
|
||||
await get_tree().process_frame
|
||||
@@ -43,6 +77,9 @@ func host_server(port: int = DEFAULT_PORT) -> void:
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_server = true
|
||||
_hosted_port = port
|
||||
_start_query_responder(port + QUERY_PORT_OFFSET)
|
||||
_start_heartbeat()
|
||||
print("[NetworkManager] Hosting on port %d" % port)
|
||||
|
||||
|
||||
@@ -51,6 +88,60 @@ func disconnect_from_game() -> void:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
multiplayer.multiplayer_peer = null
|
||||
is_server = false
|
||||
_stop_query_responder()
|
||||
_stop_heartbeat()
|
||||
|
||||
|
||||
func _start_heartbeat() -> void:
|
||||
_register_with_matchmaking_api()
|
||||
_heartbeat_timer = Timer.new()
|
||||
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
|
||||
_heartbeat_timer.one_shot = false
|
||||
_heartbeat_timer.timeout.connect(_register_with_matchmaking_api)
|
||||
add_child(_heartbeat_timer)
|
||||
_heartbeat_timer.start()
|
||||
|
||||
|
||||
func _stop_heartbeat() -> void:
|
||||
if _heartbeat_timer != null:
|
||||
_heartbeat_timer.queue_free()
|
||||
_heartbeat_timer = null
|
||||
|
||||
|
||||
# Fire-and-forget: a missed heartbeat just leaves the API's last_heartbeat
|
||||
# stale, which its own sweep turns into "offline" after server_stale_seconds
|
||||
# (see matchmaking-api/app/routers/servers.py) — no retry logic needed here.
|
||||
func _register_with_matchmaking_api() -> void:
|
||||
await MatchmakingClient.register_server(_register_ip, _hosted_port, "casual", PlayerRegistry.players.size(), MAX_PLAYERS)
|
||||
|
||||
|
||||
func _start_query_responder(query_port: int) -> void:
|
||||
_query_udp = UDPServer.new()
|
||||
var err := _query_udp.listen(query_port)
|
||||
if err != OK:
|
||||
push_error("[NetworkManager] Failed to start ping responder on port %d: %s" % [query_port, err])
|
||||
_query_udp = null
|
||||
return
|
||||
print("[NetworkManager] Ping responder listening on port %d" % query_port)
|
||||
|
||||
|
||||
func _stop_query_responder() -> void:
|
||||
if _query_udp != null:
|
||||
_query_udp.stop()
|
||||
_query_udp = null
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _query_udp == null:
|
||||
return
|
||||
_query_udp.poll()
|
||||
while _query_udp.is_connection_available():
|
||||
var udp_peer := _query_udp.take_connection()
|
||||
var packet := udp_peer.get_packet()
|
||||
if packet.get_string_from_utf8() == PING_REQUEST:
|
||||
var player_count: int = PlayerRegistry.players.size()
|
||||
var reply := "SPACEWAR_PONG:%d:%d" % [player_count, MAX_PLAYERS]
|
||||
udp_peer.put_packet(reply.to_utf8_buffer())
|
||||
|
||||
|
||||
func join_server(ip: String, port: int = DEFAULT_PORT) -> void:
|
||||
|
||||
@@ -6,8 +6,18 @@ extends Node
|
||||
|
||||
signal loadout_updated(peer_id: int)
|
||||
signal player_removed(peer_id: int, info: Dictionary)
|
||||
# Fired once, the first time a peer ever registers a loadout (as opposed to
|
||||
# loadout_updated, which also fires on every later ship re-pick) — lets
|
||||
# ChatManager's admin announcer say "X has joined" exactly once per peer.
|
||||
signal player_joined(peer_id: int)
|
||||
# Fired only when an already-registered peer's race actually changes (e.g. a
|
||||
# live team swap via the pause menu's SELECT TEAM), not on a fresh peer's
|
||||
# first-ever loadout — lets BotManager rebalance the race the player left,
|
||||
# which loadout_updated alone can't do since by the time it fires the old
|
||||
# race is already overwritten in `players`.
|
||||
signal race_changed(peer_id: int, old_race: int, new_race: int)
|
||||
|
||||
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, role}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -22,18 +32,18 @@ func get_info(peer_id: int) -> Dictionary:
|
||||
return players.get(peer_id, {})
|
||||
|
||||
|
||||
func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
|
||||
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
|
||||
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
|
||||
|
||||
# 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:
|
||||
func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> 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)
|
||||
_receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
_store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
|
||||
|
||||
func unregister_bot(peer_id: int) -> void:
|
||||
@@ -44,41 +54,47 @@ func unregister_bot(peer_id: int) -> void:
|
||||
# the new loadout to everyone else, and backfills the newcomer with everyone
|
||||
# else's already-known loadout.
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
|
||||
func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
if sender_id == 0:
|
||||
sender_id = multiplayer.get_unique_id()
|
||||
|
||||
if not multiplayer.is_server():
|
||||
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
return
|
||||
|
||||
for peer_id in multiplayer.get_peers():
|
||||
if peer_id != sender_id:
|
||||
_receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
_receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
for peer_id in players:
|
||||
if peer_id != sender_id:
|
||||
var info: Dictionary = players[peer_id]
|
||||
_receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor)
|
||||
_receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor, info.role)
|
||||
|
||||
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
|
||||
_store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
|
||||
_store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
|
||||
|
||||
|
||||
func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
|
||||
func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
|
||||
var old_race: int = players.get(peer_id, {}).get("race", 0)
|
||||
players[peer_id] = {
|
||||
"name": player_name,
|
||||
"race": race,
|
||||
"ship_path": ship_path,
|
||||
"ship_scale": ship_scale,
|
||||
"ship_speed_factor": ship_speed_factor,
|
||||
"role": role,
|
||||
}
|
||||
print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]])
|
||||
loadout_updated.emit(peer_id)
|
||||
if old_race == 0:
|
||||
player_joined.emit(peer_id)
|
||||
elif old_race != race:
|
||||
race_changed.emit(peer_id, old_race, race)
|
||||
|
||||
|
||||
func _on_peer_left(peer_id: int) -> void:
|
||||
|
||||
@@ -1,41 +1,104 @@
|
||||
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.
|
||||
# "Seek and shoot" bot AI (see overview/bots.md), personality-driven by
|
||||
# BotPersonality (bots/bot_personality.gd, rolled once per bot-slot by
|
||||
# BotManager). Five independent traits reshape the same base behavior instead
|
||||
# of branching into separate hand-written behavior trees per archetype:
|
||||
# aggression -> how close it presses in before it's satisfied with range
|
||||
# caution -> health fraction at which it breaks off and flees
|
||||
# accuracy -> how tightly it tracks the target's true position
|
||||
# reaction -> how often it refreshes its aim point (lag on movers)
|
||||
# awareness -> detection radius for picking a target at all
|
||||
# Kept isolated from ship_movement.gd's networking code entirely so future
|
||||
# tuning only ever touches this file and game_config.gd's bot_* ranges.
|
||||
|
||||
var _ship: Ship
|
||||
var _race_id: int
|
||||
var personality: BotPersonality
|
||||
|
||||
var _stop_distance: float
|
||||
var _retreat_health_frac: float
|
||||
var _aim_tolerance_rad: float
|
||||
var _reaction_interval: float
|
||||
var _awareness_range_sq: float
|
||||
|
||||
var _current_target: Ship = null
|
||||
var _tracked_pos: Vector2 = Vector2.ZERO
|
||||
var _reaction_timer: float = 0.0
|
||||
|
||||
|
||||
func _init(ship: Ship, race_id: int) -> void:
|
||||
func _init(ship: Ship, race_id: int, bot_personality: BotPersonality = null) -> void:
|
||||
_ship = ship
|
||||
_race_id = race_id
|
||||
personality = bot_personality if bot_personality != null else BotPersonality.random()
|
||||
|
||||
_stop_distance = lerp(GameConfig.bot_stop_distance_max, GameConfig.bot_stop_distance_min, personality.aggression)
|
||||
_retreat_health_frac = lerp(GameConfig.bot_retreat_health_frac_min, GameConfig.bot_retreat_health_frac_max, personality.caution)
|
||||
_aim_tolerance_rad = deg_to_rad(lerp(GameConfig.bot_aim_tolerance_worst_deg, GameConfig.bot_aim_tolerance_best_deg, personality.accuracy))
|
||||
_reaction_interval = lerp(GameConfig.bot_reaction_update_worst_sec, GameConfig.bot_reaction_update_best_sec, personality.reaction)
|
||||
var awareness_range: float = lerp(GameConfig.bot_awareness_range_min, GameConfig.bot_awareness_range_max, personality.awareness)
|
||||
_awareness_range_sq = awareness_range * awareness_range
|
||||
_reaction_timer = randf() * _reaction_interval # desync bots so they don't all re-aim on the same tick
|
||||
|
||||
|
||||
func compute_input(_delta: float) -> Dictionary:
|
||||
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}
|
||||
_current_target = null
|
||||
return _compute_advance_input()
|
||||
|
||||
if target != _current_target:
|
||||
_current_target = target
|
||||
_reaction_timer = 0.0 # snap to a fresh target immediately, don't lag onto it
|
||||
|
||||
_reaction_timer -= delta
|
||||
if _reaction_timer <= 0.0:
|
||||
_reaction_timer = _reaction_interval
|
||||
var jitter_radius: float = (1.0 - personality.accuracy) * GameConfig.bot_aim_jitter_max_px
|
||||
var jitter := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * jitter_radius
|
||||
_tracked_pos = target.global_position + jitter
|
||||
|
||||
var fleeing: bool = float(_ship.health) / float(GameConfig.ship_max_health) < _retreat_health_frac
|
||||
var true_distance: float = _ship.global_position.distance_to(target.global_position)
|
||||
|
||||
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
|
||||
# _simulate_step), so facing a point 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 aim_at: Vector2 = _ship.global_position - target.global_position if fleeing else _tracked_pos - _ship.global_position
|
||||
var desired_rot: float = aim_at.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,
|
||||
"up": fleeing or true_distance > _stop_distance,
|
||||
"down": false,
|
||||
"left": diff < -aim_tolerance,
|
||||
"right": diff > aim_tolerance,
|
||||
"shoot": absf(diff) <= aim_tolerance and distance <= GameConfig.bot_engage_range,
|
||||
"left": diff < -_aim_tolerance_rad,
|
||||
"right": diff > _aim_tolerance_rad,
|
||||
"shoot": not fleeing and absf(diff) <= _aim_tolerance_rad and true_distance <= GameConfig.bot_engage_range,
|
||||
}
|
||||
|
||||
|
||||
# No enemy within awareness range yet (common right after match start: two
|
||||
# 7-bot teams spawn on opposite sides of the map, farther apart than most
|
||||
# personality rolls' awareness_range — see GameConfig.bot_awareness_range_min
|
||||
# comment). Without this, those bots would just sit still at spawn forever,
|
||||
# waiting for someone to wander into range. Advance toward the map's center
|
||||
# instead, closing the distance until awareness (or GameConfig.bot_engage_range)
|
||||
# picks up an enemy.
|
||||
func _compute_advance_input() -> Dictionary:
|
||||
var center: Vector2 = GameConfig.world_bounds.get_center()
|
||||
var to_center: Vector2 = center - _ship.global_position
|
||||
if to_center.length_squared() < 2500.0: # ~50px: already at the rally point
|
||||
return {"up": false, "down": false, "left": false, "right": false, "shoot": false}
|
||||
|
||||
var desired_rot: float = to_center.angle() + PI / 2.0
|
||||
var diff: float = angle_difference(_ship.rotation, desired_rot)
|
||||
return {
|
||||
"up": true,
|
||||
"down": false,
|
||||
"left": diff < -_aim_tolerance_rad,
|
||||
"right": diff > _aim_tolerance_rad,
|
||||
"shoot": false,
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +113,8 @@ func _find_nearest_enemy() -> Ship:
|
||||
if info.get("race", -1) == _race_id:
|
||||
continue
|
||||
var dist: float = _ship.global_position.distance_squared_to(candidate.global_position)
|
||||
if dist > _awareness_range_sq:
|
||||
continue
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best = candidate
|
||||
|
||||
@@ -19,6 +19,7 @@ var _next_bot_id: int = -1
|
||||
func _ready() -> void:
|
||||
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
|
||||
PlayerRegistry.player_removed.connect(_on_player_removed)
|
||||
PlayerRegistry.race_changed.connect(_on_race_changed)
|
||||
|
||||
|
||||
# Called once by World.decide_offered_races() when the match's 2 races are
|
||||
@@ -45,6 +46,15 @@ func _on_player_removed(peer_id: int, info: Dictionary) -> void:
|
||||
_reconcile_race(info.get("race", 0))
|
||||
|
||||
|
||||
# A live team swap (pause menu SELECT TEAM) fires loadout_updated for the
|
||||
# *new* race, but that alone leaves the race the player just left one human
|
||||
# short — this backfills it. _on_loadout_updated handles the new race.
|
||||
func _on_race_changed(peer_id: int, old_race: int, _new_race: int) -> void:
|
||||
if not multiplayer.is_server() or _world == null or peer_id < 0:
|
||||
return
|
||||
_reconcile_race(old_race)
|
||||
|
||||
|
||||
func _reconcile_race(race_id: int) -> void:
|
||||
if race_id <= 0:
|
||||
return
|
||||
@@ -74,7 +84,7 @@ func _spawn_bot(race_id: int) -> int:
|
||||
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
|
||||
ship_def.path, ship_def.scale, ship_def.speed_factor, ship_def.role
|
||||
)
|
||||
|
||||
var ship_node := _world.spawn_peer(bot_id) as Ship
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class_name BotPersonality
|
||||
extends RefCounted
|
||||
|
||||
# Rolled once per bot-slot when BotManager spawns a bot, then handed to that
|
||||
# bot's BotAI and kept for the rest of the match (even across death/respawn)
|
||||
# so a player can learn "that bot" and plan around it — see overview/bots.md.
|
||||
# Each trait is independent (0.0-1.0), so e.g. a bot can be aggressive AND
|
||||
# accurate, or aggressive and a terrible shot; BotAI converts these into
|
||||
# concrete numbers via the bot_* ranges in GameConfig.
|
||||
|
||||
var aggression: float # higher = presses in closer before it's satisfied with range
|
||||
var caution: float # higher = flees earlier when hurt instead of trading hits
|
||||
var accuracy: float # higher = tracks the target's true position tightly
|
||||
var reaction: float # higher = updates its aim point more often (less lag on movers)
|
||||
var awareness: float # higher = notices enemies farther away
|
||||
|
||||
|
||||
static func random() -> BotPersonality:
|
||||
var p := BotPersonality.new()
|
||||
p.aggression = randf()
|
||||
p.caution = randf()
|
||||
p.accuracy = randf()
|
||||
p.reaction = randf()
|
||||
p.awareness = randf()
|
||||
return p
|
||||
@@ -0,0 +1 @@
|
||||
uid://ba0y2016tyc76
|
||||
@@ -139,14 +139,17 @@ func _on_text_submitted(text: String) -> void:
|
||||
|
||||
|
||||
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]
|
||||
if sender_name == ChatManager.ADMIN_NAME:
|
||||
line = "[color=#e0c341]* %s[/color]" % safe_text
|
||||
else:
|
||||
line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text]
|
||||
var name_color := _race_color(sender_race).to_html(false)
|
||||
var safe_name := _escape_bbcode(sender_name)
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
extends AnimatedSprite2D
|
||||
class_name Explosion
|
||||
|
||||
# Per-instance override; leave at 1.0 to use GameConfig.explosion_scale (the
|
||||
# one shared knob for resizing every explosion in the game at once).
|
||||
@export var scale_override: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var s: float = scale_override if scale_override > 0.0 else GameConfig.explosion_scale
|
||||
scale = Vector2.ONE * s
|
||||
sprite_frames.set_animation_speed("explode", GameConfig.explosion_fps)
|
||||
play("explode")
|
||||
animation_finished.connect(queue_free)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dft6gwjocfwx5
|
||||
@@ -0,0 +1,65 @@
|
||||
[gd_scene load_steps=15 format=3 uid="uid://cexplosion01a"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://dft6gwjocfwx5" path="res://effects/explosion.gd" id="1_expl01"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_01.png" id="2_expl01"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_02.png" id="3_expl02"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_03.png" id="4_expl03"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_04.png" id="5_expl04"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_05.png" id="6_expl05"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_06.png" id="7_expl06"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_07.png" id="8_expl07"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_08.png" id="9_expl08"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_09.png" id="10_expl09"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_10.png" id="11_expl10"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_11.png" id="12_expl11"]
|
||||
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_12.png" id="13_expl12"]
|
||||
|
||||
[sub_resource type="SpriteFrames" id="SpriteFrames_expl01"]
|
||||
animations = [{
|
||||
"frames": [{
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("2_expl01")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("3_expl02")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("4_expl03")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("5_expl04")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("6_expl05")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("7_expl06")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("8_expl07")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("9_expl08")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("10_expl09")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("11_expl10")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("12_expl11")
|
||||
}, {
|
||||
"duration": 5.0,
|
||||
"texture": ExtResource("13_expl12")
|
||||
}],
|
||||
"loop": false,
|
||||
"name": &"explode",
|
||||
"speed": 20.0
|
||||
}]
|
||||
|
||||
[node name="Explosion" type="AnimatedSprite2D"]
|
||||
sprite_frames = SubResource("SpriteFrames_expl01")
|
||||
animation = &"explode"
|
||||
autoplay = ""
|
||||
script = ExtResource("1_expl01")
|
||||
@@ -0,0 +1,60 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Blue "mirrored" energy bar, top-middle of the screen: the filled region is
|
||||
# centered in the bar's full extent, so it shrinks toward the middle from
|
||||
# both edges as energy drains, instead of draining left-to-right like a
|
||||
# normal bar. Driven by Ship._update_hud() calling set_fill() with the local
|
||||
# player's energy/max_energy fraction (see ships/ship_movement.gd).
|
||||
|
||||
const BAR_WIDTH := 300.0
|
||||
const BAR_HEIGHT := 14.0
|
||||
const MARGIN_TOP := 20.0
|
||||
const COLOR_BG := Color(0.15, 0.18, 0.25, 0.6)
|
||||
const COLOR_FILL := Color(0.25, 0.55, 1.0, 0.95)
|
||||
|
||||
var _fill: ColorRect
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 4
|
||||
_build_ui()
|
||||
set_fill(0.0)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var container := Control.new()
|
||||
container.anchor_left = 0.5
|
||||
container.anchor_right = 0.5
|
||||
container.anchor_top = 0.0
|
||||
container.anchor_bottom = 0.0
|
||||
container.offset_left = -BAR_WIDTH / 2.0
|
||||
container.offset_right = BAR_WIDTH / 2.0
|
||||
container.offset_top = MARGIN_TOP
|
||||
container.offset_bottom = MARGIN_TOP + BAR_HEIGHT
|
||||
container.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(container)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = COLOR_BG
|
||||
bg.anchor_left = 0.0
|
||||
bg.anchor_right = 1.0
|
||||
bg.anchor_top = 0.0
|
||||
bg.anchor_bottom = 1.0
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
container.add_child(bg)
|
||||
|
||||
_fill = ColorRect.new()
|
||||
_fill.color = COLOR_FILL
|
||||
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
container.add_child(_fill)
|
||||
|
||||
|
||||
# frac in [0, 1]; the fill rect stays centered in the bar's full extent so it
|
||||
# shrinks toward the middle from both edges as energy drains.
|
||||
func set_fill(frac: float) -> void:
|
||||
if _fill == null:
|
||||
return
|
||||
var f := clampf(frac, 0.0, 1.0)
|
||||
var w := BAR_WIDTH * f
|
||||
_fill.position = Vector2((BAR_WIDTH - w) / 2.0, 0.0)
|
||||
_fill.size = Vector2(w, BAR_HEIGHT)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://hud/energy_bar.gd" id="1_script"]
|
||||
|
||||
[node name="EnergyBar" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
@@ -0,0 +1,86 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Last MAX_LINES deaths, server-relayed via KillFeedManager. Stacked directly
|
||||
# above ChatBox's history panel (same left column, off-center rather than
|
||||
# top-dead-center) so both overlays read as one HUD block.
|
||||
|
||||
const MAX_LINES := 4
|
||||
const WIDTH := 460.0 # matches chat/chat_box.gd's HISTORY_W for column alignment
|
||||
const HEIGHT := 100.0
|
||||
const MARGIN_X := 16.0
|
||||
const GAP := 4.0
|
||||
|
||||
# chat/chat_box.gd's history panel spans y -210 to -50 off the window's
|
||||
# bottom edge (anchor_bottom 1.0) -- see that script's _build_ui() for the
|
||||
# margin/gap math behind those numbers. Stack this panel on top of it using
|
||||
# the same GAP chat uses internally between its own history and input line.
|
||||
const CHAT_HISTORY_TOP := -210.0
|
||||
|
||||
var _list: RichTextLabel
|
||||
var _lines: Array[String] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 5
|
||||
_build_ui()
|
||||
KillFeedManager.kill_reported.connect(_on_kill_reported)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
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
|
||||
# Same anchor pattern as chat_box.gd/player_list.gd: set every
|
||||
# anchor_* as a plain property assignment (not sequential
|
||||
# set_anchor_and_offset() calls) to avoid the push_opposite_anchor
|
||||
# footgun described in team_select.gd's _place().
|
||||
panel.anchor_left = 0.0
|
||||
panel.anchor_right = 0.0
|
||||
panel.anchor_top = 1.0
|
||||
panel.anchor_bottom = 1.0
|
||||
panel.offset_left = MARGIN_X
|
||||
panel.offset_right = MARGIN_X + WIDTH
|
||||
panel.offset_bottom = CHAT_HISTORY_TOP - GAP
|
||||
panel.offset_top = panel.offset_bottom - HEIGHT
|
||||
add_child(panel)
|
||||
|
||||
_list = RichTextLabel.new()
|
||||
_list.bbcode_enabled = true
|
||||
_list.scroll_active = false
|
||||
_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_list.add_theme_font_size_override("normal_font_size", 15)
|
||||
_list.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_list.offset_left = 8
|
||||
_list.offset_top = 4
|
||||
_list.offset_right = -8
|
||||
_list.offset_bottom = -4
|
||||
panel.add_child(_list)
|
||||
|
||||
|
||||
func _on_kill_reported(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool) -> void:
|
||||
var victim_text := "[color=#%s]%s[/color]" % [_race_color(victim_race).to_html(false), _escape_bbcode(victim_name)]
|
||||
var line: String
|
||||
if is_environment_kill:
|
||||
line = "%s killed by the environment" % victim_text
|
||||
else:
|
||||
var killer_text := "[color=#%s]%s[/color]" % [_race_color(killer_race).to_html(false), _escape_bbcode(killer_name)]
|
||||
line = "%s killed by %s" % [victim_text, killer_text]
|
||||
|
||||
_lines.append(line)
|
||||
if _lines.size() > MAX_LINES:
|
||||
_lines.pop_front()
|
||||
_list.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://bp0k0nhyjdvl6
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://ckillfeed001"]
|
||||
|
||||
[ext_resource type="Script" path="res://hud/kill_feed.gd" id="1_script"]
|
||||
|
||||
[node name="KillFeed" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
@@ -0,0 +1,100 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Bottom-right minimap: green translucent background, gray dots for every
|
||||
# wall/asteroid tile, white dots for teammates, yellow dots for enemies.
|
||||
# Drawing itself lives in mini_map_view.gd (a Control, since CanvasLayer
|
||||
# can't _draw()) — this script only polls game state and feeds it in.
|
||||
|
||||
const MARGIN := 16.0
|
||||
const WIDTH := 260.0
|
||||
const HEIGHT := 180.0
|
||||
const REFRESH_INTERVAL := 0.1 # ~10Hz is plenty for a minimap dot
|
||||
|
||||
const TEAM_COLOR := Color(1.0, 1.0, 1.0)
|
||||
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
|
||||
const SELF_COLOR := Color(0.3, 0.55, 1.0)
|
||||
|
||||
var _view: MiniMapView
|
||||
var _refresh_timer: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 4
|
||||
_build_ui()
|
||||
# Deferred: MiniMap is a child of World and so (like every other World
|
||||
# child, see World.decide_offered_races()'s comment) has its own _ready()
|
||||
# fire *before* World._ready() — before the map has even been instanced
|
||||
# into MapContainer. Deferring runs this after the whole _ready cascade
|
||||
# finishes, once the map (and GameConfig.world_bounds) actually exist.
|
||||
call_deferred("_cache_hazard_points")
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_view = MiniMapView.new()
|
||||
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# Anchored fully to the bottom-right corner (all anchors 1.0), so offsets
|
||||
# are negative pixel distances from that corner that stay correct
|
||||
# regardless of window size — see structure.md's Display section on why
|
||||
# every anchor here must be set directly rather than via sequential
|
||||
# set_anchor_and_offset() calls (push_opposite_anchor footgun).
|
||||
_view.anchor_left = 1.0
|
||||
_view.anchor_right = 1.0
|
||||
_view.anchor_top = 1.0
|
||||
_view.anchor_bottom = 1.0
|
||||
_view.offset_left = -MARGIN - WIDTH
|
||||
_view.offset_right = -MARGIN
|
||||
_view.offset_top = -MARGIN - HEIGHT
|
||||
_view.offset_bottom = -MARGIN
|
||||
add_child(_view)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_refresh_timer -= delta
|
||||
if _refresh_timer > 0.0:
|
||||
return
|
||||
_refresh_timer = REFRESH_INTERVAL
|
||||
_update_players()
|
||||
_view.queue_redraw()
|
||||
|
||||
|
||||
func _cache_hazard_points() -> void:
|
||||
var map_container := get_node_or_null("/root/World/MapContainer")
|
||||
if not map_container:
|
||||
return
|
||||
var bounds := GameConfig.world_bounds
|
||||
var points := PackedVector2Array()
|
||||
for tile_layer in map_container.find_children("*", "TileMapLayer", true, false):
|
||||
for cell in tile_layer.get_used_cells():
|
||||
var world_pos: Vector2 = tile_layer.to_global(tile_layer.map_to_local(cell))
|
||||
points.append(_normalize(world_pos, bounds))
|
||||
_view.hazard_points = points
|
||||
_view.queue_redraw()
|
||||
|
||||
|
||||
func _update_players() -> void:
|
||||
var players_node := get_node_or_null("/root/World/Players")
|
||||
if not players_node:
|
||||
return
|
||||
var bounds := GameConfig.world_bounds
|
||||
var local_id := PlayerRegistry.get_local_id()
|
||||
var local_race: int = PlayerRegistry.get_info(local_id).get("race", 0)
|
||||
|
||||
var points: Array = []
|
||||
for peer_id in PlayerRegistry.players.keys():
|
||||
var ship := players_node.get_node_or_null(str(peer_id))
|
||||
if not ship or not ship.visible:
|
||||
continue
|
||||
var color: Color
|
||||
if peer_id == local_id:
|
||||
color = SELF_COLOR
|
||||
else:
|
||||
var info: Dictionary = PlayerRegistry.players[peer_id]
|
||||
var is_teammate: bool = info.get("race", 0) == local_race
|
||||
color = TEAM_COLOR if is_teammate else ENEMY_COLOR
|
||||
points.append({"pos": _normalize(ship.global_position, bounds), "color": color})
|
||||
_view.player_points = points
|
||||
|
||||
|
||||
func _normalize(world_pos: Vector2, bounds: Rect2) -> Vector2:
|
||||
var frac := (world_pos - bounds.position) / bounds.size
|
||||
return frac.clamp(Vector2.ZERO, Vector2.ONE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bw5qhf17nlrg
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://cminimap0001"]
|
||||
|
||||
[ext_resource type="Script" path="res://hud/mini_map.gd" id="1_script"]
|
||||
|
||||
[node name="MiniMap" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
@@ -0,0 +1,34 @@
|
||||
extends Control
|
||||
class_name MiniMapView
|
||||
|
||||
# Pure drawing surface for hud/mini_map.gd — split out because CanvasLayer
|
||||
# (which the rest of the HUD overlays use for their `layer` property, see
|
||||
# player_list.gd/kill_feed.gd) isn't a CanvasItem and can't override _draw().
|
||||
# mini_map.gd owns all game-state polling and just pokes these two arrays
|
||||
# before calling queue_redraw().
|
||||
|
||||
# Normalized (0..1 within world_bounds) positions of every wall/asteroid
|
||||
# tile, cached once by mini_map.gd since the map layout never changes.
|
||||
var hazard_points: PackedVector2Array = PackedVector2Array()
|
||||
|
||||
# [{pos: Vector2 (normalized 0..1), color: Color}], rebuilt every refresh.
|
||||
var player_points: Array = []
|
||||
|
||||
const BG_COLOR := Color(0.04, 0.28, 0.08, 0.6)
|
||||
const BORDER_COLOR := Color(0.5, 0.85, 0.5, 0.8)
|
||||
const HAZARD_COLOR := Color(0.65, 0.65, 0.65, 0.9)
|
||||
const HAZARD_RADIUS := 1.5
|
||||
const PLAYER_RADIUS := 3.0
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
draw_rect(rect, BG_COLOR, true)
|
||||
|
||||
for p in hazard_points:
|
||||
draw_circle(p * size, HAZARD_RADIUS, HAZARD_COLOR)
|
||||
|
||||
for entry in player_points:
|
||||
draw_circle(entry.pos * size, PLAYER_RADIUS, entry.color)
|
||||
|
||||
draw_rect(rect, BORDER_COLOR, false, 1.5)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3shd4b2thlml
|
||||
@@ -0,0 +1,75 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Top-right live latency readout: this client's RTT to the server, read
|
||||
# straight from ENet's own peer statistic (no custom protocol needed, unlike
|
||||
# menu/server_select.gd's pre-connect probe, which has no ENet connection
|
||||
# yet to read a statistic from). Meaningless for the server itself (peer id
|
||||
# 1 is the local server, RTT to itself is always ~0), so it stays hidden
|
||||
# there.
|
||||
|
||||
const MARGIN_X := 16.0
|
||||
const MARGIN_TOP := 20.0
|
||||
const WIDTH := 100.0
|
||||
const HEIGHT := 30.0
|
||||
const UPDATE_INTERVAL := 0.5
|
||||
|
||||
const COLOR_GOOD := Color(0.4, 0.9, 0.5)
|
||||
const COLOR_OK := Color(0.95, 0.75, 0.3)
|
||||
const COLOR_BAD := Color(0.9, 0.4, 0.4)
|
||||
|
||||
var _label: Label
|
||||
var _timer: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 4
|
||||
if NetworkManager.is_server:
|
||||
queue_free()
|
||||
return
|
||||
_build_ui()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_label = Label.new()
|
||||
_label.anchor_left = 1.0
|
||||
_label.anchor_right = 1.0
|
||||
_label.anchor_top = 0.0
|
||||
_label.anchor_bottom = 0.0
|
||||
_label.offset_left = -(MARGIN_X + WIDTH)
|
||||
_label.offset_right = -MARGIN_X
|
||||
_label.offset_top = MARGIN_TOP
|
||||
_label.offset_bottom = MARGIN_TOP + HEIGHT
|
||||
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_label.add_theme_font_size_override("font_size", 15)
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_timer += delta
|
||||
if _timer < UPDATE_INTERVAL:
|
||||
return
|
||||
_timer = 0.0
|
||||
_refresh()
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer == null or not (peer is ENetMultiplayerPeer):
|
||||
_label.text = ""
|
||||
return
|
||||
|
||||
var enet_peer: ENetPacketPeer = (peer as ENetMultiplayerPeer).get_peer(1)
|
||||
if enet_peer == null:
|
||||
_label.text = "PING: --"
|
||||
_label.add_theme_color_override("font_color", Color(0.5, 0.53, 0.6))
|
||||
return
|
||||
|
||||
var rtt := int(enet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
|
||||
_label.text = "PING: %d ms" % rtt
|
||||
if rtt <= 80:
|
||||
_label.add_theme_color_override("font_color", COLOR_GOOD)
|
||||
elif rtt <= 180:
|
||||
_label.add_theme_color_override("font_color", COLOR_OK)
|
||||
else:
|
||||
_label.add_theme_color_override("font_color", COLOR_BAD)
|
||||
@@ -0,0 +1 @@
|
||||
uid://x5kt6q3136cu
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://hud/ping_display.gd" id="1_script"]
|
||||
|
||||
[node name="PingDisplay" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
@@ -0,0 +1,98 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Top-left roster of every connected player (human + bot). White entries are
|
||||
# on the local player's team (same race — races double as teams, see
|
||||
# TeamSelect), yellow entries are the enemy team. Bots (negative peer_id, see
|
||||
# bots/bot_manager.gd) get a trailing " (b)". Rebuilt whenever
|
||||
# PlayerRegistry's roster changes rather than polled every frame.
|
||||
|
||||
const MARGIN_X := 16.0
|
||||
const MARGIN_TOP := 90.0 # below HUD/HealthBar + HUD/EnergyBar, which occupy y 20-74
|
||||
const WIDTH := 220.0
|
||||
const HEIGHT := 260.0
|
||||
|
||||
const TEAM_COLOR := Color(0.92, 0.95, 1.0)
|
||||
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
|
||||
|
||||
var _list: RichTextLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 4
|
||||
_build_ui()
|
||||
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
|
||||
PlayerRegistry.player_removed.connect(_on_player_removed)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
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
|
||||
# Anchored fully to the top-left corner (all anchors 0.0), so offsets are
|
||||
# plain absolute pixel coordinates that stay correct regardless of window
|
||||
# size — no push_opposite_anchor risk since every anchor here agrees.
|
||||
panel.anchor_left = 0.0
|
||||
panel.anchor_right = 0.0
|
||||
panel.anchor_top = 0.0
|
||||
panel.anchor_bottom = 0.0
|
||||
panel.offset_left = MARGIN_X
|
||||
panel.offset_top = MARGIN_TOP
|
||||
panel.offset_right = MARGIN_X + WIDTH
|
||||
panel.offset_bottom = MARGIN_TOP + HEIGHT
|
||||
add_child(panel)
|
||||
|
||||
_list = RichTextLabel.new()
|
||||
_list.bbcode_enabled = true
|
||||
_list.scroll_active = true
|
||||
_list.scroll_following = false
|
||||
_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_list.add_theme_font_size_override("normal_font_size", 15)
|
||||
_list.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_list.offset_left = 8
|
||||
_list.offset_top = 4
|
||||
_list.offset_right = -8
|
||||
_list.offset_bottom = -4
|
||||
panel.add_child(_list)
|
||||
|
||||
|
||||
func _on_loadout_updated(_peer_id: int) -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_player_removed(_peer_id: int, _info: Dictionary) -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
var local_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
|
||||
|
||||
var peer_ids := PlayerRegistry.players.keys()
|
||||
peer_ids.sort_custom(func(a, b):
|
||||
var info_a: Dictionary = PlayerRegistry.players[a]
|
||||
var info_b: Dictionary = PlayerRegistry.players[b]
|
||||
var a_team: bool = info_a.get("race", 0) == local_race
|
||||
var b_team: bool = info_b.get("race", 0) == local_race
|
||||
if a_team != b_team:
|
||||
return a_team # teammates first
|
||||
return String(info_a.get("name", "")).naturalnocasecmp_to(info_b.get("name", "")) < 0
|
||||
)
|
||||
|
||||
var lines: Array[String] = []
|
||||
for peer_id in peer_ids:
|
||||
var info: Dictionary = PlayerRegistry.players[peer_id]
|
||||
var is_teammate: bool = info.get("race", 0) == local_race
|
||||
var color := TEAM_COLOR if is_teammate else ENEMY_COLOR
|
||||
var name_text := _escape_bbcode(String(info.get("name", "")))
|
||||
if peer_id < 0:
|
||||
name_text += " (b)"
|
||||
lines.append("[color=#%s]%s[/color]" % [color.to_html(false), name_text])
|
||||
|
||||
_list.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _escape_bbcode(s: String) -> String:
|
||||
return s.replace("[", "[lb]")
|
||||
@@ -0,0 +1 @@
|
||||
uid://jbjhcpnern4x
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://cplayerlist01a"]
|
||||
|
||||
[ext_resource type="Script" path="res://hud/player_list.gd" id="1_script"]
|
||||
|
||||
[node name="PlayerList" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
@@ -0,0 +1,39 @@
|
||||
extends Control
|
||||
|
||||
# Simple left-to-right stat bar for the top-left HUD (HealthBar/EnergyBar in
|
||||
# world.tscn's HUD node) — fill shrinks from the right as the stat depletes.
|
||||
# Contrast hud/energy_bar.gd's top-middle bar, which drains from both edges
|
||||
# toward the center instead.
|
||||
|
||||
@export var fill_color: Color = Color(0.3, 0.85, 0.35, 0.95)
|
||||
@export var bg_color: Color = Color(0.12, 0.14, 0.18, 0.6)
|
||||
|
||||
var _fill: ColorRect
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = bg_color
|
||||
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(bg)
|
||||
|
||||
_fill = ColorRect.new()
|
||||
_fill.color = fill_color
|
||||
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_fill.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(_fill)
|
||||
set_fill(1.0)
|
||||
|
||||
|
||||
# frac in [0, 1]; anchor_right (not an explicit pixel width) so the fill
|
||||
# tracks this control's actual size regardless of window width.
|
||||
func set_fill(frac: float) -> void:
|
||||
if _fill == null:
|
||||
return
|
||||
_fill.anchor_left = 0.0
|
||||
_fill.anchor_right = clampf(frac, 0.0, 1.0)
|
||||
_fill.offset_left = 0.0
|
||||
_fill.offset_right = 0.0
|
||||
@@ -1,10 +1,19 @@
|
||||
extends Control
|
||||
|
||||
var _name_input: LineEdit
|
||||
var _casual_btn: Button
|
||||
var _ranked_btn: Button
|
||||
# HL2-style menu: no big mode tiles, just a plain vertical text nav over a
|
||||
# space backdrop. Quick Play always queues casual — ranked is gone until the
|
||||
# matchmaking/MMR work in overview/task.md lands.
|
||||
|
||||
var _status_lbl: Label
|
||||
var _name_lbl: Label
|
||||
var _rank_lbl: Label
|
||||
|
||||
var _profile_overlay: Control
|
||||
var _profile_input: LineEdit
|
||||
var _profile_hint_lbl: Label
|
||||
|
||||
var _connecting: bool = false
|
||||
var _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
|
||||
|
||||
const RANK_DATA := [
|
||||
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
|
||||
@@ -14,7 +23,15 @@ const RANK_DATA := [
|
||||
{"label": "ADMIRAL", "color": Color(0.62, 0.28, 1.0)},
|
||||
{"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)},
|
||||
]
|
||||
const CURRENT_RANK := 0 # placeholder until backend exists
|
||||
const CURRENT_RANK := 0 # placeholder until backend exists
|
||||
const CURRENT_LEVEL := 1 # placeholder until backend exists
|
||||
|
||||
const NAV_ITEMS := ["QUICK PLAY", "SERVER SELECT", "OPTIONS", "PROFILE", "QUIT"]
|
||||
const NAV_LEFT := 64.0
|
||||
const NAV_WIDTH := 360.0
|
||||
const NAV_TOP := 250.0
|
||||
const NAV_BTN_H := 52.0
|
||||
const NAV_GAP := 4.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -27,11 +44,13 @@ func _ready() -> void:
|
||||
|
||||
func _build_ui() -> void:
|
||||
_add_bg()
|
||||
_add_title()
|
||||
_add_play_section()
|
||||
_add_rank_badge()
|
||||
_add_quit_btn()
|
||||
_add_logo()
|
||||
_add_nav_menu()
|
||||
_add_profile_badge()
|
||||
_add_status_label()
|
||||
_add_version_label()
|
||||
_add_profile_overlay()
|
||||
_refresh_profile_badge()
|
||||
|
||||
|
||||
func _add_bg() -> void:
|
||||
@@ -43,330 +62,328 @@ func _add_bg() -> void:
|
||||
add_child(bg)
|
||||
|
||||
var ov := ColorRect.new()
|
||||
ov.color = Color(0.01, 0.02, 0.09, 0.80)
|
||||
ov.color = Color(0.0, 0.0, 0.0, 0.55)
|
||||
ov.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(ov)
|
||||
|
||||
|
||||
func _add_title() -> void:
|
||||
func _add_logo() -> void:
|
||||
var title := Label.new()
|
||||
title.text = "S P A C E W A R"
|
||||
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||
title.offset_top = 72.0
|
||||
title.offset_bottom = 148.0
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
title.add_theme_font_size_override("font_size", 56)
|
||||
title.add_theme_color_override("font_color", Color(0.45, 0.88, 1.0))
|
||||
title.anchor_left = 0.0
|
||||
title.anchor_top = 0.0
|
||||
title.anchor_right = 0.0
|
||||
title.anchor_bottom = 0.0
|
||||
title.offset_left = 60.0
|
||||
title.offset_top = 56.0
|
||||
title.offset_right = 700.0
|
||||
title.offset_bottom = 118.0
|
||||
title.add_theme_font_size_override("font_size", 44)
|
||||
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||||
add_child(title)
|
||||
|
||||
var sub := Label.new()
|
||||
sub.text = "FIGHT FOR THE GALAXY"
|
||||
sub.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||
sub.offset_top = 152.0
|
||||
sub.offset_bottom = 178.0
|
||||
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
sub.anchor_left = 0.0
|
||||
sub.anchor_top = 0.0
|
||||
sub.anchor_right = 0.0
|
||||
sub.anchor_bottom = 0.0
|
||||
sub.offset_left = 64.0
|
||||
sub.offset_top = 116.0
|
||||
sub.offset_right = 500.0
|
||||
sub.offset_bottom = 138.0
|
||||
sub.add_theme_font_size_override("font_size", 13)
|
||||
sub.add_theme_color_override("font_color", Color(0.42, 0.54, 0.70))
|
||||
sub.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78, 0.85))
|
||||
add_child(sub)
|
||||
|
||||
|
||||
func _add_play_section() -> void:
|
||||
const BTN_W := 480.0
|
||||
const BTN_H := 230.0
|
||||
const GAP := 40.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
|
||||
func _add_nav_menu() -> void:
|
||||
for i in NAV_ITEMS.size():
|
||||
var label_text: String = NAV_ITEMS[i]
|
||||
var stub := label_text == "OPTIONS"
|
||||
var btn := _make_nav_btn(label_text, stub)
|
||||
btn.anchor_left = 0.0
|
||||
btn.anchor_top = 0.0
|
||||
btn.anchor_right = 0.0
|
||||
btn.anchor_bottom = 0.0
|
||||
btn.offset_left = NAV_LEFT
|
||||
btn.offset_right = NAV_LEFT + NAV_WIDTH
|
||||
btn.offset_top = NAV_TOP + i * (NAV_BTN_H + NAV_GAP)
|
||||
btn.offset_bottom = btn.offset_top + NAV_BTN_H
|
||||
add_child(btn)
|
||||
|
||||
_casual_btn = _make_mode_btn(
|
||||
-ROW_HALF_W, TOP_Y, -HALF_GAP, TOP_Y + BTN_H,
|
||||
"CASUAL",
|
||||
"25 vs 25",
|
||||
"Drop in, drop out anytime.\nNo rank at stake.",
|
||||
Color(0.06, 0.22, 0.08),
|
||||
Color(0.30, 0.90, 0.40),
|
||||
)
|
||||
_casual_btn.pressed.connect(_on_play_casual)
|
||||
add_child(_casual_btn)
|
||||
|
||||
_ranked_btn = _make_mode_btn(
|
||||
HALF_GAP, TOP_Y, ROW_HALF_W, TOP_Y + BTN_H,
|
||||
"RANKED",
|
||||
"5 vs 5",
|
||||
"Competitive ladder.\nYour rank is on the line.",
|
||||
Color(0.06, 0.10, 0.26),
|
||||
Color(0.40, 0.72, 1.00),
|
||||
)
|
||||
_ranked_btn.disabled = true
|
||||
_ranked_btn.pressed.connect(_on_play_ranked)
|
||||
add_child(_ranked_btn)
|
||||
|
||||
var coming_soon := Label.new()
|
||||
coming_soon.text = "— COMING SOON —"
|
||||
_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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
coming_soon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
coming_soon.add_theme_font_size_override("font_size", 11)
|
||||
coming_soon.add_theme_color_override("font_color", Color(0.30, 0.36, 0.48, 0.70))
|
||||
coming_soon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(coming_soon)
|
||||
|
||||
# Callsign label
|
||||
var lbl := Label.new()
|
||||
lbl.text = "CALLSIGN"
|
||||
_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.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
lbl.add_theme_font_size_override("font_size", 12)
|
||||
lbl.add_theme_color_override("font_color", Color(0.45, 0.55, 0.70))
|
||||
add_child(lbl)
|
||||
|
||||
_name_input = LineEdit.new()
|
||||
_name_input.placeholder_text = "Enter your callsign..."
|
||||
_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.add_theme_font_size_override("font_size", 15)
|
||||
if GameConfig.player_name != "":
|
||||
_name_input.text = GameConfig.player_name
|
||||
_name_input.text_changed.connect(_on_name_changed)
|
||||
add_child(_name_input)
|
||||
|
||||
_status_lbl = Label.new()
|
||||
_status_lbl.text = ""
|
||||
_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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_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))
|
||||
add_child(_status_lbl)
|
||||
|
||||
_update_play_buttons()
|
||||
match label_text:
|
||||
"QUICK PLAY":
|
||||
btn.pressed.connect(_on_quick_play)
|
||||
"SERVER SELECT":
|
||||
btn.pressed.connect(_on_server_select)
|
||||
"OPTIONS":
|
||||
btn.pressed.connect(_on_options)
|
||||
"PROFILE":
|
||||
btn.pressed.connect(_on_profile)
|
||||
"QUIT":
|
||||
btn.pressed.connect(_on_quit)
|
||||
|
||||
|
||||
func _make_mode_btn(
|
||||
left: float, top: float, right: float, bottom: float,
|
||||
mode: String, fmt: String, desc: String,
|
||||
bg: Color, accent: Color) -> Button:
|
||||
|
||||
func _make_nav_btn(label_text: String, stub: bool) -> Button:
|
||||
var btn := Button.new()
|
||||
btn.text = ""
|
||||
_place(btn, 0.5, left, 0.5, right, top, bottom)
|
||||
btn.text = label_text
|
||||
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
btn.add_theme_font_size_override("font_size", 22)
|
||||
|
||||
var sn := _flat(bg.lightened(0.05), 12)
|
||||
sn.set_border_width_all(2)
|
||||
sn.border_color = accent.darkened(0.45)
|
||||
var sh := _flat(bg.lightened(0.18), 12)
|
||||
sh.set_border_width_all(2)
|
||||
sh.border_color = accent
|
||||
var sd := _flat(Color(0.04, 0.04, 0.06, 0.5), 12)
|
||||
sd.set_border_width_all(1)
|
||||
sd.border_color = Color(0.2, 0.22, 0.28, 0.5)
|
||||
btn.add_theme_stylebox_override("normal", sn)
|
||||
btn.add_theme_stylebox_override("hover", sh)
|
||||
btn.add_theme_stylebox_override("pressed", sh)
|
||||
btn.add_theme_stylebox_override("disabled", sd)
|
||||
var sn := StyleBoxEmpty.new()
|
||||
sn.content_margin_left = 20.0
|
||||
btn.add_theme_stylebox_override("normal", sn)
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vb.offset_left = 24.0
|
||||
vb.offset_top = 20.0
|
||||
vb.offset_right = -24.0
|
||||
vb.offset_bottom = -20.0
|
||||
vb.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
vb.add_theme_constant_override("separation", 12)
|
||||
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
btn.add_child(vb)
|
||||
if stub:
|
||||
btn.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48, 0.8))
|
||||
btn.add_theme_stylebox_override("hover", sn)
|
||||
btn.add_theme_stylebox_override("pressed", sn)
|
||||
else:
|
||||
btn.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
|
||||
btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 1.0))
|
||||
btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0))
|
||||
|
||||
var mode_lbl := Label.new()
|
||||
mode_lbl.text = mode
|
||||
mode_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
mode_lbl.add_theme_font_size_override("font_size", 40)
|
||||
mode_lbl.add_theme_color_override("font_color", accent)
|
||||
mode_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vb.add_child(mode_lbl)
|
||||
|
||||
var fmt_lbl := Label.new()
|
||||
fmt_lbl.text = fmt
|
||||
fmt_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
fmt_lbl.add_theme_font_size_override("font_size", 22)
|
||||
fmt_lbl.add_theme_color_override("font_color", Color(0.85, 0.90, 0.96))
|
||||
fmt_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vb.add_child(fmt_lbl)
|
||||
|
||||
var sep := HSeparator.new()
|
||||
sep.add_theme_color_override("color", accent.darkened(0.5))
|
||||
sep.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vb.add_child(sep)
|
||||
|
||||
var desc_lbl := Label.new()
|
||||
desc_lbl.text = desc
|
||||
desc_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
desc_lbl.add_theme_font_size_override("font_size", 13)
|
||||
desc_lbl.add_theme_color_override("font_color", Color(0.55, 0.63, 0.76))
|
||||
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
desc_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vb.add_child(desc_lbl)
|
||||
var sh := StyleBoxFlat.new()
|
||||
sh.bg_color = Color(1.0, 1.0, 1.0, 0.06)
|
||||
sh.content_margin_left = 20.0
|
||||
sh.border_width_left = 3
|
||||
sh.border_color = Color(1.0, 1.0, 1.0, 0.9)
|
||||
btn.add_theme_stylebox_override("hover", sh)
|
||||
btn.add_theme_stylebox_override("pressed", sh)
|
||||
|
||||
return btn
|
||||
|
||||
|
||||
func _add_rank_badge() -> void:
|
||||
const W := 210.0
|
||||
const H := 60.0
|
||||
const MARGIN := 16.0
|
||||
func _add_profile_badge() -> void:
|
||||
_name_lbl = Label.new()
|
||||
_name_lbl.anchor_left = 1.0
|
||||
_name_lbl.anchor_top = 0.0
|
||||
_name_lbl.anchor_right = 1.0
|
||||
_name_lbl.anchor_bottom = 0.0
|
||||
_name_lbl.offset_left = -340.0
|
||||
_name_lbl.offset_top = 28.0
|
||||
_name_lbl.offset_right = -24.0
|
||||
_name_lbl.offset_bottom = 50.0
|
||||
_name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_name_lbl.add_theme_font_size_override("font_size", 17)
|
||||
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||||
add_child(_name_lbl)
|
||||
|
||||
var panel := Panel.new()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
panel.offset_left = -(W + MARGIN)
|
||||
panel.offset_top = MARGIN
|
||||
panel.offset_right = -MARGIN
|
||||
panel.offset_bottom = MARGIN + H
|
||||
var ps := _flat(Color(0.04, 0.07, 0.16, 0.92), 8)
|
||||
ps.set_border_width_all(1)
|
||||
ps.border_color = Color(0.18, 0.36, 0.62, 0.85)
|
||||
panel.add_theme_stylebox_override("panel", ps)
|
||||
add_child(panel)
|
||||
|
||||
var rank := RANK_DATA[CURRENT_RANK]
|
||||
|
||||
# Colored badge block on the left
|
||||
var badge := Panel.new()
|
||||
badge.set_anchor_and_offset(SIDE_LEFT, 0.0, 8.0)
|
||||
badge.set_anchor_and_offset(SIDE_TOP, 0.0, 8.0)
|
||||
badge.set_anchor_and_offset(SIDE_RIGHT, 0.0, 50.0)
|
||||
badge.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -8.0)
|
||||
var bs := _flat(rank.color.darkened(0.45), 5)
|
||||
bs.set_border_width_all(2)
|
||||
bs.border_color = rank.color
|
||||
badge.add_theme_stylebox_override("panel", bs)
|
||||
panel.add_child(badge)
|
||||
|
||||
var init_lbl := Label.new()
|
||||
init_lbl.text = rank.label.left(1)
|
||||
init_lbl.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
init_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
init_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
init_lbl.add_theme_font_size_override("font_size", 20)
|
||||
init_lbl.add_theme_color_override("font_color", rank.color)
|
||||
badge.add_child(init_lbl)
|
||||
|
||||
# Rank name
|
||||
var rank_lbl := Label.new()
|
||||
rank_lbl.text = rank.label
|
||||
rank_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 58.0)
|
||||
rank_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, 0.0)
|
||||
rank_lbl.set_anchor_and_offset(SIDE_RIGHT, 1.0, -8.0)
|
||||
rank_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, H / 2.0 + 2.0)
|
||||
rank_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
||||
rank_lbl.add_theme_font_size_override("font_size", 15)
|
||||
rank_lbl.add_theme_color_override("font_color", rank.color)
|
||||
panel.add_child(rank_lbl)
|
||||
|
||||
var rating_lbl := Label.new()
|
||||
rating_lbl.text = "Rating: —"
|
||||
rating_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 58.0)
|
||||
rating_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, H / 2.0)
|
||||
rating_lbl.set_anchor_and_offset(SIDE_RIGHT, 1.0, -8.0)
|
||||
rating_lbl.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -6.0)
|
||||
rating_lbl.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||
rating_lbl.add_theme_font_size_override("font_size", 11)
|
||||
rating_lbl.add_theme_color_override("font_color", Color(0.45, 0.54, 0.68))
|
||||
panel.add_child(rating_lbl)
|
||||
_rank_lbl = Label.new()
|
||||
_rank_lbl.anchor_left = 1.0
|
||||
_rank_lbl.anchor_top = 0.0
|
||||
_rank_lbl.anchor_right = 1.0
|
||||
_rank_lbl.anchor_bottom = 0.0
|
||||
_rank_lbl.offset_left = -340.0
|
||||
_rank_lbl.offset_top = 50.0
|
||||
_rank_lbl.offset_right = -24.0
|
||||
_rank_lbl.offset_bottom = 68.0
|
||||
_rank_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_rank_lbl.add_theme_font_size_override("font_size", 12)
|
||||
add_child(_rank_lbl)
|
||||
|
||||
|
||||
func _add_quit_btn() -> void:
|
||||
const W := 110.0
|
||||
const H := 38.0
|
||||
const MARGIN := 20.0
|
||||
func _refresh_profile_badge() -> void:
|
||||
if GameConfig.player_name.strip_edges().is_empty():
|
||||
_name_lbl.text = "GUEST"
|
||||
_name_lbl.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78))
|
||||
_rank_lbl.text = "SET A CALLSIGN IN PROFILE"
|
||||
_rank_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
|
||||
else:
|
||||
_name_lbl.text = GameConfig.player_name.to_upper()
|
||||
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||||
var rank: Dictionary = RANK_DATA[CURRENT_RANK]
|
||||
_rank_lbl.text = "%s • LEVEL %d" % [rank.label, CURRENT_LEVEL]
|
||||
_rank_lbl.add_theme_color_override("font_color", rank.color)
|
||||
|
||||
var btn := Button.new()
|
||||
btn.text = "QUIT"
|
||||
btn.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
btn.offset_left = -(W + MARGIN)
|
||||
btn.offset_top = -(H + MARGIN)
|
||||
btn.offset_right = -MARGIN
|
||||
btn.offset_bottom = -MARGIN
|
||||
btn.add_theme_font_size_override("font_size", 13)
|
||||
btn.add_theme_color_override("font_color", Color(0.48, 0.54, 0.65))
|
||||
|
||||
var sn := _flat(Color(0.05, 0.06, 0.11, 0.80), 6)
|
||||
sn.set_border_width_all(1)
|
||||
sn.border_color = Color(0.18, 0.22, 0.33, 0.70)
|
||||
var sh := _flat(Color(0.10, 0.12, 0.20, 0.90), 6)
|
||||
sh.set_border_width_all(1)
|
||||
sh.border_color = Color(0.32, 0.38, 0.52)
|
||||
btn.add_theme_stylebox_override("normal", sn)
|
||||
btn.add_theme_stylebox_override("hover", sh)
|
||||
btn.pressed.connect(func(): get_tree().quit())
|
||||
add_child(btn)
|
||||
func _add_status_label() -> void:
|
||||
_status_lbl = Label.new()
|
||||
_status_lbl.text = ""
|
||||
_status_lbl.anchor_left = 0.0
|
||||
_status_lbl.anchor_top = 1.0
|
||||
_status_lbl.anchor_right = 0.0
|
||||
_status_lbl.anchor_bottom = 1.0
|
||||
_status_lbl.offset_left = NAV_LEFT
|
||||
_status_lbl.offset_right = NAV_LEFT + 460.0
|
||||
_status_lbl.offset_top = -64.0
|
||||
_status_lbl.offset_bottom = -36.0
|
||||
_status_lbl.add_theme_font_size_override("font_size", 13)
|
||||
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
|
||||
add_child(_status_lbl)
|
||||
|
||||
|
||||
func _add_version_label() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = "v0.1-dev"
|
||||
lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 14.0)
|
||||
lbl.set_anchor_and_offset(SIDE_TOP, 1.0, -30.0)
|
||||
lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, 100.0)
|
||||
lbl.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -10.0)
|
||||
lbl.anchor_left = 0.0
|
||||
lbl.anchor_top = 1.0
|
||||
lbl.anchor_right = 0.0
|
||||
lbl.anchor_bottom = 1.0
|
||||
lbl.offset_left = NAV_LEFT
|
||||
lbl.offset_top = -30.0
|
||||
lbl.offset_right = NAV_LEFT + 100.0
|
||||
lbl.offset_bottom = -10.0
|
||||
lbl.add_theme_font_size_override("font_size", 11)
|
||||
lbl.add_theme_color_override("font_color", Color(0.3, 0.35, 0.45))
|
||||
lbl.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48))
|
||||
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 _add_profile_overlay() -> void:
|
||||
_profile_overlay = Control.new()
|
||||
_profile_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_profile_overlay.visible = false
|
||||
add_child(_profile_overlay)
|
||||
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0.0, 0.0, 0.0, 0.6)
|
||||
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
dim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_profile_overlay.add_child(dim)
|
||||
|
||||
var panel := Panel.new()
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
panel.offset_left = -200
|
||||
panel.offset_top = -120
|
||||
panel.offset_right = 200
|
||||
panel.offset_bottom = 120
|
||||
var ps := StyleBoxFlat.new()
|
||||
ps.bg_color = Color(0.05, 0.05, 0.07, 0.97)
|
||||
ps.set_corner_radius_all(6)
|
||||
ps.set_border_width_all(1)
|
||||
ps.border_color = Color(1.0, 1.0, 1.0, 0.25)
|
||||
panel.add_theme_stylebox_override("panel", ps)
|
||||
_profile_overlay.add_child(panel)
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vb.offset_left = 26
|
||||
vb.offset_top = 22
|
||||
vb.offset_right = -26
|
||||
vb.offset_bottom = -22
|
||||
vb.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(vb)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "PROFILE"
|
||||
title.add_theme_font_size_override("font_size", 20)
|
||||
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||||
vb.add_child(title)
|
||||
|
||||
vb.add_child(HSeparator.new())
|
||||
|
||||
_profile_hint_lbl = Label.new()
|
||||
_profile_hint_lbl.text = ""
|
||||
_profile_hint_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
_profile_hint_lbl.add_theme_font_size_override("font_size", 12)
|
||||
_profile_hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
|
||||
_profile_hint_lbl.visible = false
|
||||
vb.add_child(_profile_hint_lbl)
|
||||
|
||||
var lbl := Label.new()
|
||||
lbl.text = "CALLSIGN"
|
||||
lbl.add_theme_font_size_override("font_size", 12)
|
||||
lbl.add_theme_color_override("font_color", Color(0.65, 0.68, 0.75))
|
||||
vb.add_child(lbl)
|
||||
|
||||
_profile_input = LineEdit.new()
|
||||
_profile_input.placeholder_text = "Enter your callsign..."
|
||||
_profile_input.custom_minimum_size = Vector2(0, 40)
|
||||
_profile_input.add_theme_font_size_override("font_size", 15)
|
||||
_profile_input.text_submitted.connect(func(_t): _on_profile_save())
|
||||
vb.add_child(_profile_input)
|
||||
|
||||
var gap := Control.new()
|
||||
gap.custom_minimum_size = Vector2(0, 4)
|
||||
gap.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
vb.add_child(gap)
|
||||
|
||||
var btn_row := HBoxContainer.new()
|
||||
btn_row.add_theme_constant_override("separation", 10)
|
||||
vb.add_child(btn_row)
|
||||
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.text = "CANCEL"
|
||||
cancel_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
cancel_btn.custom_minimum_size = Vector2(0, 42)
|
||||
cancel_btn.pressed.connect(_on_profile_cancel)
|
||||
btn_row.add_child(cancel_btn)
|
||||
|
||||
var save_btn := Button.new()
|
||||
save_btn.text = "SAVE"
|
||||
save_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
save_btn.custom_minimum_size = Vector2(0, 42)
|
||||
save_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
|
||||
var sn := StyleBoxFlat.new()
|
||||
sn.bg_color = Color(1.0, 1.0, 1.0, 0.12)
|
||||
sn.set_corner_radius_all(4)
|
||||
var sh := StyleBoxFlat.new()
|
||||
sh.bg_color = Color(1.0, 1.0, 1.0, 0.22)
|
||||
sh.set_corner_radius_all(4)
|
||||
save_btn.add_theme_stylebox_override("normal", sn)
|
||||
save_btn.add_theme_stylebox_override("hover", sh)
|
||||
save_btn.pressed.connect(_on_profile_save)
|
||||
btn_row.add_child(save_btn)
|
||||
|
||||
|
||||
func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = col
|
||||
s.set_corner_radius_all(radius)
|
||||
return s
|
||||
func _open_profile(pending_quick_play: bool) -> void:
|
||||
_pending_quick_play = pending_quick_play
|
||||
_profile_input.text = GameConfig.player_name
|
||||
_profile_hint_lbl.visible = pending_quick_play
|
||||
_profile_hint_lbl.text = "Enter a callsign to play."
|
||||
_profile_overlay.visible = true
|
||||
_profile_input.grab_focus()
|
||||
|
||||
|
||||
func _on_name_changed(_text: String) -> void:
|
||||
_update_play_buttons()
|
||||
func _on_profile_save() -> void:
|
||||
var new_name := _profile_input.text.strip_edges()
|
||||
if new_name.is_empty():
|
||||
return
|
||||
GameConfig.player_name = new_name
|
||||
_refresh_profile_badge()
|
||||
_profile_overlay.visible = false
|
||||
if _pending_quick_play:
|
||||
_pending_quick_play = false
|
||||
_start_quick_play()
|
||||
|
||||
|
||||
func _update_play_buttons() -> void:
|
||||
var ok := not _name_input.text.strip_edges().is_empty() and not _connecting
|
||||
_casual_btn.disabled = not ok
|
||||
_ranked_btn.disabled = true
|
||||
func _on_profile_cancel() -> void:
|
||||
_pending_quick_play = false
|
||||
_profile_overlay.visible = false
|
||||
|
||||
|
||||
func _on_play_casual() -> void:
|
||||
_start_match("casual")
|
||||
|
||||
|
||||
func _on_play_ranked() -> void:
|
||||
_start_match("ranked")
|
||||
|
||||
|
||||
func _start_match(mode: String) -> void:
|
||||
func _on_quick_play() -> void:
|
||||
if _connecting:
|
||||
return
|
||||
GameConfig.player_name = _name_input.text.strip_edges()
|
||||
if GameConfig.player_name.strip_edges().is_empty():
|
||||
_open_profile(true)
|
||||
return
|
||||
_start_quick_play()
|
||||
|
||||
|
||||
func _start_quick_play() -> void:
|
||||
_connecting = true
|
||||
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
|
||||
_status_lbl.text = "Searching for a match..."
|
||||
_update_play_buttons()
|
||||
MatchmakingClient.start_matchmaking(GameConfig.player_name, mode)
|
||||
MatchmakingClient.start_matchmaking(GameConfig.player_name, "casual")
|
||||
|
||||
|
||||
func _on_server_select() -> void:
|
||||
get_tree().change_scene_to_file("res://menu/server_select.tscn")
|
||||
|
||||
|
||||
func _on_options() -> void:
|
||||
_status_lbl.add_theme_color_override("font_color", Color(0.5, 0.54, 0.62))
|
||||
_status_lbl.text = "Options — coming soon."
|
||||
|
||||
|
||||
func _on_profile() -> void:
|
||||
_open_profile(false)
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _on_match_found(server_ip: String, server_port: int) -> void:
|
||||
@@ -382,12 +399,9 @@ func _on_connect_failed() -> void:
|
||||
_connecting = false
|
||||
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
|
||||
_status_lbl.text = "Connection failed — is the game server reachable?"
|
||||
_update_play_buttons()
|
||||
|
||||
|
||||
func _on_search_failed(reason: String) -> void:
|
||||
_connecting = false
|
||||
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
|
||||
_status_lbl.text = "Matchmaking failed: %s" % reason
|
||||
_update_play_buttons()
|
||||
_update_play_buttons()
|
||||
|
||||