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>
This commit is contained in:
2026-07-15 17:56:33 -04:00
parent c0b5f421c5
commit 7b44b2f2dc
112 changed files with 3349 additions and 872 deletions
+48 -7
View File
@@ -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