Add ENet multiplayer (movement/combat sync) and matchmaking API
Networked play, end to end: connect, get matched, spawn, fight, respawn. Godot side: - Replace the single hardcoded Player with per-peer networked ships (MultiplayerSpawner + custom spawn_function), driven by client-side prediction with server reconciliation for movement. - Server-authoritative combat: bullets, health, death/respawn all decided by the server and broadcast to every peer over RPC. - Fix a Camera2D bug where Godot auto-promotes the first camera to ever enter the scene tree as the active one regardless of its own `current` value — with MultiplayerSpawner catch-up replication that was almost never the local player's own ship. `make_current()` on the owner's camera fixes it; property assignment doesn't reliably override the auto-claim. - Fix two "late joiner never learns already-established state" gaps (health, visibility) by folding both into the existing per-tick position broadcast instead of relying on one-shot RPCs that only reach peers already connected when they fire. - Make the 2 offered factions in team select match for every player in a match: the server decides once and clients either read it directly (server's own instance) or request it over RPC, rather than each client rolling its own random pair. - New MatchmakingClient autoload wires the main menu's CASUAL/RANKED buttons to the matchmaking API instead of connecting directly. New matchmaking-api/ (FastAPI + Postgres, Docker Compose): - Queue/match/stats/ranks endpoints. In-memory matchmaking queue in a single API process — no Redis until there's an actual reason to shard the queue across instances. - Background loop forms a match once enough players are queued for a mode, assigns the first available registered game server. - No real server pool yet: one dev server is auto-seeded from DEV_SEED_SERVER_IP/PORT; POST /servers/register exists for real servers to self-register later but nothing calls it yet. Docs: CLAUDE.md and overview/tech.md updated to match — networking checklist mostly checked off, matchmaking backend section rewritten to describe what was actually built vs. the original Go/Redis plan, and Current Tasks reordered around what's actually left (bot fill, real server pool, ranked/MMR refinement, lag compensation). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,19 +14,26 @@
|
||||
| `money.md` | Monetization strategy |
|
||||
| `structure.md` | Project file tree, scene graph, input map |
|
||||
|
||||
Matchmaking API (`matchmaking-api/`, separate FastAPI + Postgres service, own
|
||||
`README.md`) is a second codebase alongside the Godot project — see its
|
||||
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
|
||||
4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match; ship grid with sprites; player spawns with chosen ship after selection
|
||||
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
|
||||
|
||||
## Current Tasks
|
||||
|
||||
- [ ] Asteroids and environment hazards
|
||||
- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`)
|
||||
- [ ] Bot fill for casual (minimum 7v7, bots fill empty slots — see `bots.md`) — next logical step now that matchmaking works, since `CASUAL_TEAM_SIZE` is set low (1) for solo/duo testing on the assumption bots will pad real matches later
|
||||
- [ ] Sound effects (thrust, shoot, explosion, UI)
|
||||
- [ ] Multiplayer networking — ENet authoritative server (see `tech.md`)
|
||||
- [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry
|
||||
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
|
||||
- [ ] Ranked matchmaking + MMR
|
||||
- [ ] 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
|
||||
- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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=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.
|
||||
# This IP is handed straight to game CLIENTS to connect their ENet peer to —
|
||||
# clients run on the host, not in Docker, so it must be host-reachable
|
||||
# (127.0.0.1 for local dev), not a docker-internal alias like
|
||||
# host.docker.internal (that only resolves from inside the api container).
|
||||
DEV_SEED_SERVER_IP=127.0.0.1
|
||||
DEV_SEED_SERVER_PORT=7777
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,81 @@
|
||||
# Spacewar Matchmaking API
|
||||
|
||||
FastAPI service for matchmaking, stats, and ranks (chat planned — see below).
|
||||
One process, one Postgres database, no Redis: matchmaking tickets live
|
||||
in-memory in the API process, which is enough at this scale — add Redis
|
||||
later only if the API needs to run as more than one instance.
|
||||
|
||||
## Run it
|
||||
|
||||
```
|
||||
cp .env.example .env # already done for local dev; edit if needed
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
API is at `http://localhost:8100`, interactive docs at `/docs`. Postgres is
|
||||
exposed on host port `5433` (mapped from its usual `5432`, to avoid clashing
|
||||
with any other local Postgres) for local inspection.
|
||||
|
||||
```
|
||||
docker compose down # stop
|
||||
docker compose down -v # stop + wipe the postgres volume
|
||||
```
|
||||
|
||||
Code in `app/` is bind-mounted with `--reload`, so edits take effect without
|
||||
rebuilding the image. Rebuild (`docker compose up -d --build`) only when
|
||||
`requirements.txt` changes.
|
||||
|
||||
## How matchmaking works right now
|
||||
|
||||
- `POST /matchmaking/queue/join` `{callsign, mode, mmr?}` — 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 once enough
|
||||
players are queued for a mode (`CASUAL_TEAM_SIZE`/`RANKED_TEAM_SIZE` × 2) it
|
||||
forms a match, splits them into two teams, and assigns the first available
|
||||
`GameServer` for that mode.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## 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
|
||||
`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`.
|
||||
|
||||
## Adding a new domain (stats/ranks did this; chat will too)
|
||||
|
||||
1. Add/extend a model in `app/models.py` if it needs its own table.
|
||||
2. Add request/response shapes to `app/schemas.py`.
|
||||
3. Add a router file under `app/routers/`, `include_router()` it in
|
||||
`app/main.py`.
|
||||
|
||||
Chat isn't scaffolded yet (no data model or requirements decided), but it
|
||||
fits the same shape — likely a WebSocket router using FastAPI's native
|
||||
support, in this same service.
|
||||
|
||||
## Not set up yet, on purpose
|
||||
|
||||
- **Migrations**: tables are created via `Base.metadata.create_all()` on
|
||||
startup. Fine while the schema is still moving; switch to Alembic before
|
||||
this holds real data.
|
||||
- **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.
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
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
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,58 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
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
|
||||
|
||||
|
||||
async def _seed_dev_server() -> None:
|
||||
async with async_session() as db:
|
||||
for mode in (Mode.casual, Mode.ranked):
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(GameServer).where(
|
||||
GameServer.ip == settings.dev_seed_server_ip,
|
||||
GameServer.port == settings.dev_seed_server_port,
|
||||
GameServer.mode == mode,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
db.add(
|
||||
GameServer(
|
||||
ip=settings.dev_seed_server_ip,
|
||||
port=settings.dev_seed_server_port,
|
||||
mode=mode,
|
||||
status=ServerStatus.available,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await _seed_dev_server()
|
||||
matching_task = asyncio.create_task(queue_manager.run_matching_loop())
|
||||
yield
|
||||
matching_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
|
||||
|
||||
app.include_router(matchmaking.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(ranks.router)
|
||||
app.include_router(servers.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,67 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Mode(str, enum.Enum):
|
||||
casual = "casual"
|
||||
ranked = "ranked"
|
||||
|
||||
|
||||
class ServerStatus(str, enum.Enum):
|
||||
available = "available"
|
||||
full = "full"
|
||||
offline = "offline"
|
||||
|
||||
|
||||
# No account/auth system yet (GodotSteam auth is still on the networking
|
||||
# checklist), so callsign is the identity key for now — same as the game
|
||||
# client itself, which only has a free-text name field today.
|
||||
class Player(Base):
|
||||
__tablename__ = "players"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
callsign: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
mmr: Mapped[int] = mapped_column(Integer, default=1000)
|
||||
wins: Mapped[int] = mapped_column(Integer, default=0)
|
||||
losses: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class GameServer(Base):
|
||||
__tablename__ = "game_servers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
ip: Mapped[str] = mapped_column(String(255))
|
||||
port: Mapped[int] = mapped_column(Integer)
|
||||
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)
|
||||
|
||||
|
||||
class Match(Base):
|
||||
__tablename__ = "matches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
|
||||
server_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("game_servers.id"))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
players: Mapped[list["MatchPlayer"]] = relationship(back_populates="match")
|
||||
|
||||
|
||||
class MatchPlayer(Base):
|
||||
__tablename__ = "match_players"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
match_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("matches.id"))
|
||||
player_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("players.id"))
|
||||
team: Mapped[int] = mapped_column(Integer) # 0 or 1
|
||||
|
||||
match: Mapped["Match"] = relationship(back_populates="players")
|
||||
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.models import GameServer, Match, MatchPlayer, Mode, ServerStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ticket:
|
||||
ticket_id: uuid.UUID
|
||||
player_id: uuid.UUID
|
||||
callsign: str
|
||||
mode: Mode
|
||||
mmr: int
|
||||
queued_at: datetime = field(default_factory=datetime.utcnow)
|
||||
status: str = "queued" # queued | matched | cancelled
|
||||
server_ip: str | None = None
|
||||
server_port: int | None = None
|
||||
team: int | None = None
|
||||
|
||||
|
||||
class QueueManager:
|
||||
"""In-memory matchmaking queue. No Redis: a single API process holds all
|
||||
ticket state, which is fine at this scale and simpler to run — see
|
||||
memory/spacewar_networking_plan for why Redis was deferred rather than
|
||||
included from the start.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tickets: dict[uuid.UUID, Ticket] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def join(self, player_id: uuid.UUID, callsign: str, mode: Mode, mmr: int) -> Ticket:
|
||||
ticket = Ticket(ticket_id=uuid.uuid4(), player_id=player_id, callsign=callsign, mode=mode, mmr=mmr)
|
||||
async with self._lock:
|
||||
self._tickets[ticket.ticket_id] = ticket
|
||||
return ticket
|
||||
|
||||
async def leave(self, ticket_id: uuid.UUID) -> bool:
|
||||
async with self._lock:
|
||||
ticket = self._tickets.get(ticket_id)
|
||||
if ticket is None or ticket.status != "queued":
|
||||
return False
|
||||
ticket.status = "cancelled"
|
||||
return True
|
||||
|
||||
def get(self, ticket_id: uuid.UUID) -> Ticket | None:
|
||||
return self._tickets.get(ticket_id)
|
||||
|
||||
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)
|
||||
return tickets
|
||||
|
||||
async def _try_form_match(self, mode: Mode, team_size: int, db: AsyncSession) -> None:
|
||||
required = team_size * 2
|
||||
async with self._lock:
|
||||
waiting = self._waiting(mode)
|
||||
if len(waiting) < required:
|
||||
return
|
||||
group = waiting[:required]
|
||||
|
||||
server = (
|
||||
await db.execute(
|
||||
select(GameServer).where(GameServer.mode == mode, GameServer.status == ServerStatus.available)
|
||||
)
|
||||
).scalars().first()
|
||||
if server is None:
|
||||
return # no server free this cycle; leave everyone queued and retry next tick
|
||||
|
||||
match = Match(mode=mode, server_id=server.id)
|
||||
db.add(match)
|
||||
await db.flush()
|
||||
|
||||
for i, ticket in enumerate(group):
|
||||
team = i % 2
|
||||
db.add(MatchPlayer(match_id=match.id, player_id=ticket.player_id, team=team))
|
||||
ticket.status = "matched"
|
||||
ticket.server_ip = server.ip
|
||||
ticket.server_port = server.port
|
||||
ticket.team = team
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def run_matching_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
async with async_session() as db:
|
||||
await self._try_form_match(Mode.casual, settings.casual_team_size, db)
|
||||
await self._try_form_match(Mode.ranked, settings.ranked_team_size, db)
|
||||
|
||||
|
||||
queue_manager = QueueManager()
|
||||
@@ -0,0 +1,53 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Mode, Player
|
||||
from app.queue_manager import queue_manager
|
||||
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
|
||||
|
||||
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
|
||||
|
||||
|
||||
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
player = Player(callsign=callsign)
|
||||
db.add(player)
|
||||
await db.commit()
|
||||
await db.refresh(player)
|
||||
return 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)
|
||||
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
|
||||
|
||||
|
||||
@router.post("/queue/leave")
|
||||
async def leave_queue(ticket_id: uuid.UUID) -> dict:
|
||||
ok = await queue_manager.leave(ticket_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found or already resolved")
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
@router.get("/queue/status/{ticket_id}", response_model=QueueStatusResponse)
|
||||
async def queue_status(ticket_id: uuid.UUID) -> QueueStatusResponse:
|
||||
ticket = queue_manager.get(ticket_id)
|
||||
if ticket is None:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
return QueueStatusResponse(
|
||||
ticket_id=ticket.ticket_id,
|
||||
status=ticket.status,
|
||||
server_ip=ticket.server_ip,
|
||||
server_port=ticket.server_port,
|
||||
team=ticket.team,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Player
|
||||
|
||||
router = APIRouter(prefix="/ranks", tags=["ranks"])
|
||||
|
||||
# Mirrors RANK_DATA in spacewar/menu/main_menu.gd — keep the tier names (and
|
||||
# ideally thresholds) in sync if either side changes.
|
||||
_TIERS: list[tuple[int, str]] = [
|
||||
(0, "CADET"),
|
||||
(1000, "PILOT"),
|
||||
(1400, "ACE"),
|
||||
(1800, "COMMANDER"),
|
||||
(2200, "ADMIRAL"),
|
||||
(2600, "LEGEND"),
|
||||
]
|
||||
|
||||
|
||||
def _tier_for(mmr: int) -> str:
|
||||
tier = _TIERS[0][1]
|
||||
for threshold, name in _TIERS:
|
||||
if mmr >= threshold:
|
||||
tier = name
|
||||
return tier
|
||||
|
||||
|
||||
@router.get("/{callsign}")
|
||||
async def get_rank(callsign: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return {"callsign": player.callsign, "mmr": player.mmr, "rank": _tier_for(player.mmr)}
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import GameServer, ServerStatus
|
||||
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.
|
||||
@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()
|
||||
if existing:
|
||||
existing.status = ServerStatus.available
|
||||
existing.last_heartbeat = datetime.utcnow()
|
||||
else:
|
||||
db.add(GameServer(ip=req.ip, port=req.port, mode=req.mode, status=ServerStatus.available))
|
||||
await db.commit()
|
||||
return {"status": "registered"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
|
||||
servers = (await db.execute(select(GameServer))).scalars().all()
|
||||
return [
|
||||
{
|
||||
"ip": s.ip,
|
||||
"port": s.port,
|
||||
"mode": s.mode,
|
||||
"status": s.status,
|
||||
"last_heartbeat": s.last_heartbeat,
|
||||
}
|
||||
for s in servers
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Player
|
||||
from app.schemas import PlayerStatsResponse
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||
|
||||
|
||||
@router.get("/{callsign}", response_model=PlayerStatsResponse)
|
||||
async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> PlayerStatsResponse:
|
||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||
if player is None:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
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):
|
||||
ticket_id: uuid.UUID
|
||||
status: str
|
||||
|
||||
|
||||
class QueueStatusResponse(BaseModel):
|
||||
ticket_id: uuid.UUID
|
||||
status: str
|
||||
server_ip: str | None = None
|
||||
server_port: int | None = None
|
||||
team: int | None = None
|
||||
|
||||
|
||||
class ServerRegisterRequest(BaseModel):
|
||||
ip: str
|
||||
port: int
|
||||
mode: Mode
|
||||
|
||||
|
||||
class PlayerStatsResponse(BaseModel):
|
||||
callsign: str
|
||||
mmr: int
|
||||
wins: int
|
||||
losses: int
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: matchmaking
|
||||
POSTGRES_PASSWORD: matchmaking
|
||||
POSTGRES_DB: matchmaking
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U matchmaking"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8100:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
# Lets the container reach a Godot dev server running on the host
|
||||
# (Linux Docker Engine doesn't resolve host.docker.internal by default).
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.6.1
|
||||
+29
-18
@@ -55,19 +55,30 @@ func send_input(dir: Vector2, firing: bool) -> void:
|
||||
|
||||
### Matchmaking Backend
|
||||
|
||||
| Component | Tech |
|
||||
|-----------|------|
|
||||
| API Server | Go or Node.js |
|
||||
| Queue / State | Redis |
|
||||
| Database (accounts, ranks) | PostgreSQL |
|
||||
| Hosting | VPS (Hetzner / DigitalOcean) or self-hosted |
|
||||
Built — see `matchmaking-api/README.md` for how to run it. Actual stack
|
||||
diverged from the original plan below:
|
||||
|
||||
**Flow:**
|
||||
1. Client sends "find match" with MMR + mode
|
||||
2. Backend queues player, finds lobby within MMR range
|
||||
3. Lobby full → backend assigns a game server
|
||||
4. Backend sends client the server IP + session token
|
||||
5. Client connects directly to game server
|
||||
| Component | Planned | Actual |
|
||||
|-----------|---------|--------|
|
||||
| API Server | Go or Node.js | **FastAPI (Python)** — I/O-bound queue/CRUD logic, not a latency-critical hot path, so dev velocity won over raw throughput |
|
||||
| Queue / State | Redis | **In-memory in the API process** — no Redis; revisit only if the API needs to run as more than one instance |
|
||||
| Database (accounts, ranks) | PostgreSQL | PostgreSQL, as planned |
|
||||
| Hosting | VPS (Hetzner / DigitalOcean) or self-hosted | Not deployed — local Docker Compose only so far |
|
||||
|
||||
**Flow (as built):**
|
||||
1. Client sends `POST /matchmaking/queue/join` with callsign + mode
|
||||
2. Backend queues the ticket; a background loop forms a match once enough
|
||||
players are queued for that mode
|
||||
3. Match forms → backend assigns the first available registered `GameServer`
|
||||
for that mode
|
||||
4. Client polls `GET /matchmaking/queue/status/{ticket}` until `matched`,
|
||||
then connects directly to the returned server IP/port
|
||||
|
||||
Not yet built: session tokens (server IP/port are handed back unauthenticated
|
||||
— fine for local dev, not for a real deployment), and a real server pool —
|
||||
only one dev server is registered right now (auto-seeded on API startup);
|
||||
`POST /servers/register` exists for real servers to self-register/heartbeat
|
||||
but nothing calls it yet.
|
||||
|
||||
### Steam Integration
|
||||
|
||||
@@ -79,11 +90,11 @@ func send_input(dir: Vector2, firing: bool) -> void:
|
||||
|
||||
## Networking Checklist
|
||||
|
||||
- [ ] Godot ENet server/client setup
|
||||
- [ ] Player input RPC structure
|
||||
- [ ] Position sync with interpolation
|
||||
- [ ] Ship class registration per peer
|
||||
- [ ] Server-authoritative health/death
|
||||
- [ ] Matchmaking API (queue + lobby assignment)
|
||||
- [x] Godot ENet server/client setup
|
||||
- [x] Player input RPC structure
|
||||
- [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`
|
||||
- [ ] GodotSteam auth + VAC
|
||||
- [ ] Lag compensation (basic rewind)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
extends Node
|
||||
|
||||
signal team_selected
|
||||
|
||||
# Ship movement
|
||||
var ship_thrust: float = 250.0
|
||||
var ship_max_speed: float = 300.0
|
||||
@@ -21,12 +19,9 @@ var bullet_damage: int = 40
|
||||
var ship_bounce_restitution: float = 0.45
|
||||
var ship_collision_damage_scale: float = 0.064
|
||||
|
||||
# Set before entering a game
|
||||
# Locally-remembered callsign, pre-fills the name field on the menu.
|
||||
# Race/ship/speed are per-peer now — see PlayerRegistry.
|
||||
var player_name: String = ""
|
||||
var player_race: int = 0
|
||||
var player_ship_path: String = ""
|
||||
var player_ship_scale: float = 1.0
|
||||
var player_ship_speed_factor: float = 1.0
|
||||
|
||||
# Current map's play area, in world coordinates. Set by world.gd on load.
|
||||
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
extends Node
|
||||
|
||||
# Talks to the FastAPI matchmaking service (see /matchmaking-api). No client
|
||||
# library — plain HTTPRequest per call, since traffic here is low-frequency
|
||||
# (one join, then a poll every ~1.5s) and each request gets its own
|
||||
# short-lived HTTPRequest node so calls never collide with each other.
|
||||
|
||||
signal search_started
|
||||
signal match_found(server_ip: String, server_port: int)
|
||||
signal search_failed(reason: String)
|
||||
|
||||
const POLL_INTERVAL := 1.5
|
||||
|
||||
var base_url: String = "http://127.0.0.1:8100"
|
||||
|
||||
var _ticket_id: String = ""
|
||||
var _poll_timer: Timer
|
||||
var _searching: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--matchmaking-api="):
|
||||
base_url = arg.substr("--matchmaking-api=".length())
|
||||
|
||||
_poll_timer = Timer.new()
|
||||
_poll_timer.wait_time = POLL_INTERVAL
|
||||
_poll_timer.one_shot = false
|
||||
_poll_timer.timeout.connect(_poll_status)
|
||||
add_child(_poll_timer)
|
||||
|
||||
|
||||
func start_matchmaking(callsign: String, mode: String) -> void:
|
||||
if _searching:
|
||||
return
|
||||
_searching = true
|
||||
search_started.emit()
|
||||
|
||||
var result := await _request("POST", "/matchmaking/queue/join", {
|
||||
"callsign": callsign,
|
||||
"mode": mode,
|
||||
})
|
||||
|
||||
if not _searching:
|
||||
return # cancelled while the join request was in flight
|
||||
|
||||
if result.get("code", 0) != 200:
|
||||
_fail("Couldn't reach matchmaking service")
|
||||
return
|
||||
|
||||
_ticket_id = result.body.ticket_id
|
||||
_poll_timer.start()
|
||||
|
||||
|
||||
func cancel_matchmaking() -> void:
|
||||
if not _searching:
|
||||
return
|
||||
_searching = false
|
||||
_poll_timer.stop()
|
||||
if _ticket_id != "":
|
||||
_request("POST", "/matchmaking/queue/leave?ticket_id=" + _ticket_id, null)
|
||||
_ticket_id = ""
|
||||
|
||||
|
||||
func _poll_status() -> void:
|
||||
if _ticket_id == "":
|
||||
return
|
||||
var result := await _request("GET", "/matchmaking/queue/status/" + _ticket_id, null)
|
||||
|
||||
if not _searching:
|
||||
return # cancelled while the poll was in flight
|
||||
|
||||
if result.get("code", 0) != 200:
|
||||
_fail("Lost contact with matchmaking service")
|
||||
return
|
||||
|
||||
var status: String = result.body.status
|
||||
if status == "matched":
|
||||
_poll_timer.stop()
|
||||
_searching = false
|
||||
var ip: String = result.body.server_ip
|
||||
var port: int = result.body.server_port
|
||||
_ticket_id = ""
|
||||
match_found.emit(ip, port)
|
||||
elif status == "cancelled":
|
||||
_poll_timer.stop()
|
||||
_searching = false
|
||||
_ticket_id = ""
|
||||
_fail("Queue ticket was cancelled")
|
||||
# else "queued": keep polling
|
||||
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
_poll_timer.stop()
|
||||
_searching = false
|
||||
_ticket_id = ""
|
||||
search_failed.emit(reason)
|
||||
|
||||
|
||||
# Returns {"code": int, "body": Dictionary} — "code" is 0 if the request
|
||||
# itself couldn't even be sent (e.g. malformed URL), not an HTTP status.
|
||||
func _request(method: String, path: String, body) -> Dictionary:
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
|
||||
var headers := PackedStringArray()
|
||||
var body_str := ""
|
||||
if body != null:
|
||||
headers.append("Content-Type: application/json")
|
||||
body_str = JSON.stringify(body)
|
||||
|
||||
var http_method := HTTPClient.METHOD_GET if method == "GET" else HTTPClient.METHOD_POST
|
||||
var err := http.request(base_url + path, headers, http_method, body_str)
|
||||
if err != OK:
|
||||
http.queue_free()
|
||||
return {"code": 0, "body": null}
|
||||
|
||||
var response = await http.request_completed
|
||||
http.queue_free()
|
||||
|
||||
var response_code: int = response[1]
|
||||
var response_body: PackedByteArray = response[3]
|
||||
var text := response_body.get_string_from_utf8()
|
||||
var parsed = JSON.parse_string(text) if text != "" else null
|
||||
return {"code": response_code, "body": parsed}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d26rs8apbu84y
|
||||
@@ -0,0 +1,92 @@
|
||||
extends Node
|
||||
|
||||
signal connection_succeeded
|
||||
signal connection_failed
|
||||
signal peer_joined(peer_id: int)
|
||||
signal peer_left(peer_id: int)
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_PLAYERS := 50 # covers 25v25 casual
|
||||
|
||||
var is_server: bool = false
|
||||
var target_ip: String = "127.0.0.1"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
|
||||
_apply_cmdline_args()
|
||||
|
||||
|
||||
# Dev-only bootstrap: `-- --server` runs this instance as a dedicated headless
|
||||
# server; `-- --connect=<ip>` overrides the default join target for clients.
|
||||
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())
|
||||
if args.has("--server"):
|
||||
host_server(DEFAULT_PORT)
|
||||
await get_tree().process_frame
|
||||
get_tree().change_scene_to_file("res://world/world.tscn")
|
||||
|
||||
|
||||
func host_server(port: int = DEFAULT_PORT) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, MAX_PLAYERS)
|
||||
if err != OK:
|
||||
push_error("[NetworkManager] Failed to host on port %d: %s" % [port, err])
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_server = true
|
||||
print("[NetworkManager] Hosting on port %d" % port)
|
||||
|
||||
|
||||
func disconnect_from_game() -> void:
|
||||
if multiplayer.multiplayer_peer != null:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
multiplayer.multiplayer_peer = null
|
||||
is_server = false
|
||||
|
||||
|
||||
func join_server(ip: String, port: int = DEFAULT_PORT) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(ip, port)
|
||||
if err != OK:
|
||||
push_error("[NetworkManager] Failed to start client toward %s:%d: %s" % [ip, port, err])
|
||||
connection_failed.emit()
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_server = false
|
||||
print("[NetworkManager] Connecting to %s:%d..." % [ip, port])
|
||||
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
print("[NetworkManager] Peer connected: %d" % peer_id)
|
||||
peer_joined.emit(peer_id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
print("[NetworkManager] Peer disconnected: %d" % peer_id)
|
||||
peer_left.emit(peer_id)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
print("[NetworkManager] Connected, local peer id: %d" % multiplayer.get_unique_id())
|
||||
connection_succeeded.emit()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
print("[NetworkManager] Connection failed")
|
||||
multiplayer.multiplayer_peer = null
|
||||
connection_failed.emit()
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
print("[NetworkManager] Server disconnected")
|
||||
multiplayer.multiplayer_peer = null
|
||||
connection_failed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwfpdpotmiial
|
||||
@@ -0,0 +1,72 @@
|
||||
extends Node
|
||||
|
||||
# Server-authoritative registry of every connected peer's chosen loadout,
|
||||
# replicated to all clients so everyone knows everyone's race/ship, not just
|
||||
# their own.
|
||||
|
||||
signal loadout_updated(peer_id: int)
|
||||
signal player_removed(peer_id: int)
|
||||
|
||||
var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
NetworkManager.peer_left.connect(_on_peer_left)
|
||||
|
||||
|
||||
func get_local_id() -> int:
|
||||
return multiplayer.get_unique_id()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Client -> server (and locally to self via call_local). Server then relays
|
||||
# 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:
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
|
||||
|
||||
|
||||
@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 _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
|
||||
players[peer_id] = {
|
||||
"name": player_name,
|
||||
"race": race,
|
||||
"ship_path": ship_path,
|
||||
"ship_scale": ship_scale,
|
||||
"ship_speed_factor": ship_speed_factor,
|
||||
}
|
||||
print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]])
|
||||
loadout_updated.emit(peer_id)
|
||||
|
||||
|
||||
func _on_peer_left(peer_id: int) -> void:
|
||||
if players.erase(peer_id):
|
||||
player_removed.emit(peer_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ccta6qxoqmoh8
|
||||
@@ -3,6 +3,8 @@ extends Control
|
||||
var _name_input: LineEdit
|
||||
var _casual_btn: Button
|
||||
var _ranked_btn: Button
|
||||
var _status_lbl: Label
|
||||
var _connecting: bool = false
|
||||
|
||||
const RANK_DATA := [
|
||||
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
|
||||
@@ -16,9 +18,11 @@ const CURRENT_RANK := 0 # placeholder until backend exists
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameConfig.player_race = 0
|
||||
GameConfig.player_ship_path = ""
|
||||
_build_ui()
|
||||
NetworkManager.connection_succeeded.connect(_on_connected)
|
||||
NetworkManager.connection_failed.connect(_on_connect_failed)
|
||||
MatchmakingClient.match_found.connect(_on_match_found)
|
||||
MatchmakingClient.search_failed.connect(_on_search_failed)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
@@ -134,6 +138,17 @@ func _add_play_section() -> void:
|
||||
_name_input.text_changed.connect(_on_name_changed)
|
||||
add_child(_name_input)
|
||||
|
||||
_status_lbl = Label.new()
|
||||
_status_lbl.text = ""
|
||||
_status_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, LEFT_X)
|
||||
_status_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, TOP_Y + BTN_H + 68.0)
|
||||
_status_lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, LEFT_X + BTN_W * 2.0 + GAP)
|
||||
_status_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, TOP_Y + BTN_H + 92.0)
|
||||
_status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_status_lbl.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()
|
||||
|
||||
|
||||
@@ -322,17 +337,49 @@ func _on_name_changed(_text: String) -> void:
|
||||
|
||||
|
||||
func _update_play_buttons() -> void:
|
||||
var ok := not _name_input.text.strip_edges().is_empty()
|
||||
var ok := not _name_input.text.strip_edges().is_empty() and not _connecting
|
||||
_casual_btn.disabled = not ok
|
||||
_ranked_btn.disabled = true
|
||||
|
||||
|
||||
func _on_play_casual() -> void:
|
||||
GameConfig.player_name = _name_input.text.strip_edges()
|
||||
get_tree().change_scene_to_file("res://world/world.tscn")
|
||||
_start_match("casual")
|
||||
|
||||
|
||||
func _on_play_ranked() -> void:
|
||||
_start_match("ranked")
|
||||
|
||||
|
||||
func _start_match(mode: String) -> void:
|
||||
if _connecting:
|
||||
return
|
||||
GameConfig.player_name = _name_input.text.strip_edges()
|
||||
GameConfig.player_race = 1 # TODO: race selection screen
|
||||
_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)
|
||||
|
||||
|
||||
func _on_match_found(server_ip: String, server_port: int) -> void:
|
||||
_status_lbl.text = "Match found — connecting..."
|
||||
NetworkManager.join_server(server_ip, server_port)
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
get_tree().change_scene_to_file("res://world/world.tscn")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -117,6 +117,7 @@ func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
NetworkManager.disconnect_from_game()
|
||||
get_tree().change_scene_to_file("res://menu/main_menu.tscn")
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
extends CanvasLayer
|
||||
class_name TeamSelect
|
||||
|
||||
# Movement speed factor by role, shared across all 3 races so e.g. every
|
||||
# race's Interceptor moves at the same speed as every other race's.
|
||||
@@ -116,21 +117,41 @@ const ROW_IMG := 95.0
|
||||
|
||||
var _root: Control
|
||||
var _content: Control
|
||||
var _loading_lbl: Label
|
||||
var _offered: Array = []
|
||||
var _chosen_race: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 20
|
||||
_offered = _pick_two_races()
|
||||
_build_root()
|
||||
|
||||
# The 2 offered races must be the same for every player in the match, not
|
||||
# independently randomized per client — the server decides once (see
|
||||
# World.decide_offered_races()) and every client either asks for it
|
||||
# (real remote peers) or reads it directly (the server's own instance,
|
||||
# no round-trip needed since it's the same process).
|
||||
var world := get_node("/root/World")
|
||||
if multiplayer.is_server():
|
||||
offer_races(world.decide_offered_races())
|
||||
else:
|
||||
world.request_offered_races.rpc_id(1)
|
||||
|
||||
|
||||
# Called once this client knows which 2 races (by id) the match is offering.
|
||||
func offer_races(race_ids: Array) -> void:
|
||||
if _loading_lbl:
|
||||
_loading_lbl.queue_free()
|
||||
_loading_lbl = null
|
||||
_offered = race_ids.map(func(id): return _race_by_id(id))
|
||||
_show_race_selection()
|
||||
|
||||
|
||||
func _pick_two_races() -> Array:
|
||||
var pool := RACES.duplicate()
|
||||
pool.shuffle()
|
||||
return [pool[0], pool[1]]
|
||||
func _race_by_id(id: int) -> Dictionary:
|
||||
for race in RACES:
|
||||
if race.id == id:
|
||||
return race
|
||||
return RACES[0]
|
||||
|
||||
|
||||
func _build_root() -> void:
|
||||
@@ -144,6 +165,9 @@ func _build_root() -> void:
|
||||
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_root.add_child(overlay)
|
||||
|
||||
_loading_lbl = _label(_root, "LOADING MATCH...", 20, Color(0.5, 0.6, 0.75),
|
||||
0, 380, 1280, 420, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
|
||||
func _swap_content() -> Control:
|
||||
if _content:
|
||||
@@ -179,7 +203,6 @@ func _show_race_selection() -> void:
|
||||
|
||||
func _on_race_chosen(race: Dictionary) -> void:
|
||||
_chosen_race = race
|
||||
GameConfig.player_race = race.id
|
||||
_show_ship_selection()
|
||||
|
||||
|
||||
@@ -203,10 +226,9 @@ func _show_ship_selection() -> void:
|
||||
|
||||
|
||||
func _on_ship_chosen(ship: Dictionary) -> void:
|
||||
GameConfig.player_ship_path = ship.path
|
||||
GameConfig.player_ship_scale = ship.scale
|
||||
GameConfig.player_ship_speed_factor = ship.speed_factor
|
||||
GameConfig.team_selected.emit()
|
||||
PlayerRegistry.submit_local_loadout(
|
||||
GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor
|
||||
)
|
||||
queue_free()
|
||||
|
||||
|
||||
@@ -346,7 +368,7 @@ func _make_ship_row(ship: Dictionary, accent: Color) -> Button:
|
||||
|
||||
func _label(parent: Control, text: String, font_size: int, color: Color,
|
||||
x1: float, y1: float, x2: float, y2: float,
|
||||
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> void:
|
||||
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> Label:
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
lbl.horizontal_alignment = align
|
||||
@@ -355,6 +377,7 @@ func _label(parent: Control, text: String, font_size: int, color: Color,
|
||||
lbl.add_theme_color_override("font_color", color)
|
||||
_place(lbl, x1, y1, x2, y2)
|
||||
parent.add_child(lbl)
|
||||
return lbl
|
||||
|
||||
|
||||
func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void:
|
||||
|
||||
@@ -18,12 +18,14 @@ config/icon="res://assets/icon.svg"
|
||||
[autoload]
|
||||
|
||||
GameConfig="*res://autoload/game_config.gd"
|
||||
NetworkManager="*res://autoload/network_manager.gd"
|
||||
PlayerRegistry="*res://autoload/player_registry.gd"
|
||||
MatchmakingClient="*res://autoload/matchmaking_client.gd"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=2560
|
||||
window/size/viewport_height=1440
|
||||
window/size/mode=3
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=800
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
extends Area2D
|
||||
|
||||
# Set by World._spawn_bullet() before this node enters the tree.
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
var source: Node = null
|
||||
var source_peer_id: int = 0
|
||||
|
||||
# Only the server detects hits and despawns bullets — that's replicated to
|
||||
# every client automatically since bullets are spawned via BulletSpawner.
|
||||
# Movement itself stays local on every peer (deterministic constant-velocity
|
||||
# simulation from the replicated spawn state, no further sync needed).
|
||||
func _ready() -> void:
|
||||
if multiplayer.is_server():
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
global_position += velocity * delta
|
||||
if multiplayer.is_server():
|
||||
var bounds = GameConfig.world_bounds.grow(64.0)
|
||||
if not bounds.has_point(global_position):
|
||||
queue_free()
|
||||
|
||||
func _on_body_entered(body: Node) -> void:
|
||||
if body == source:
|
||||
if body.get("peer_id") == source_peer_id:
|
||||
return
|
||||
if body.has_method("take_damage"):
|
||||
body.take_damage(GameConfig.bullet_damage)
|
||||
|
||||
@@ -18,7 +18,7 @@ texture = ExtResource("2_epypp")
|
||||
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491]
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
current = true
|
||||
current = false
|
||||
rotating = false
|
||||
position_smoothing_enabled = true
|
||||
position_smoothing_speed = 8.0
|
||||
|
||||
+247
-40
@@ -1,39 +1,89 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
const BULLET_SCENE = preload("res://ships/bullet.tscn")
|
||||
# Set by World._spawn_ship() before this node enters the tree.
|
||||
var peer_id: int = 0
|
||||
|
||||
var _fire_cooldown: float = 0.0
|
||||
var health: int = 0
|
||||
var _dead: bool = false
|
||||
var _invincible: bool = false
|
||||
var _invincible_timer: float = 0.0
|
||||
var _ship_speed_factor: float = 1.0
|
||||
|
||||
var _is_owner: bool = false
|
||||
var _fixed_delta: float = 1.0 / 60.0
|
||||
|
||||
# Owner-side prediction: inputs sent to the server but not yet acknowledged,
|
||||
# replayed on top of the server's authoritative state on correction.
|
||||
const MAX_PENDING_INPUTS := 180
|
||||
var _input_tick: int = 0
|
||||
var _pending_inputs: Array = []
|
||||
|
||||
# Server-side: queued inputs received from the owning peer, plus the last
|
||||
# processed one (reused when the queue runs dry, e.g. a dropped packet).
|
||||
var _remote_inputs: Array = []
|
||||
var _last_remote_input: Dictionary = {"up": false, "down": false, "left": false, "right": false, "shoot": false}
|
||||
var _last_processed_tick: int = 0
|
||||
|
||||
# Remote (non-owner, non-server) clients only ever interpolate toward the
|
||||
# last couple of authoritative snapshots broadcast by the server.
|
||||
var _interp_from_pos: Vector2
|
||||
var _interp_from_rot: float = 0.0
|
||||
var _interp_to_pos: Vector2
|
||||
var _interp_to_rot: float = 0.0
|
||||
var _interp_elapsed: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if GameConfig.player_ship_path.is_empty():
|
||||
_fixed_delta = 1.0 / Engine.physics_ticks_per_second
|
||||
_is_owner = peer_id == multiplayer.get_unique_id()
|
||||
|
||||
# Only ever claim the camera for the local owner. Whichever Camera2D
|
||||
# enters the SceneTree first auto-claims the viewport's active camera
|
||||
# regardless of its own `current` value (Godot's built-in "first camera
|
||||
# wins" fallback) — with MultiplayerSpawner catch-up replication, that's
|
||||
# often another peer's ship (e.g. the always-present server-ghost ship
|
||||
# for peer 1), not ours. Setting `current = true` via property/deferred
|
||||
# assignment does not reliably dethrone that auto-claimed camera; calling
|
||||
# make_current() does, since it's the actual API that updates the
|
||||
# viewport's tracked active camera rather than just the local property.
|
||||
if _is_owner:
|
||||
var camera := get_node_or_null("Camera2D") as Camera2D
|
||||
if camera:
|
||||
camera.call_deferred("make_current")
|
||||
|
||||
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
|
||||
var info := PlayerRegistry.get_info(peer_id)
|
||||
if info.is_empty():
|
||||
visible = false
|
||||
set_physics_process(false)
|
||||
GameConfig.team_selected.connect(_on_team_selected)
|
||||
else:
|
||||
_init_player()
|
||||
_init_player(info)
|
||||
|
||||
|
||||
func _on_team_selected() -> void:
|
||||
_init_player()
|
||||
func _on_loadout_updated(updated_peer_id: int) -> void:
|
||||
if updated_peer_id == peer_id:
|
||||
_init_player(PlayerRegistry.get_info(peer_id))
|
||||
|
||||
|
||||
func _init_player() -> void:
|
||||
func _init_player(info: Dictionary) -> void:
|
||||
var sprite := get_node_or_null("Sprite2D")
|
||||
if sprite and not GameConfig.player_ship_path.is_empty():
|
||||
sprite.texture = load(GameConfig.player_ship_path)
|
||||
sprite.scale = Vector2.ONE * GameConfig.player_ship_scale
|
||||
var ship_path: String = info.get("ship_path", "")
|
||||
if sprite and not ship_path.is_empty():
|
||||
sprite.texture = load(ship_path)
|
||||
sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0)
|
||||
_ship_speed_factor = info.get("ship_speed_factor", 1.0)
|
||||
set_physics_process(true)
|
||||
_respawn()
|
||||
if multiplayer.is_server():
|
||||
_server_respawn()
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if _dead:
|
||||
return
|
||||
|
||||
# 1. INVINCIBILITY BLINK
|
||||
# INVINCIBILITY BLINK — purely visual, runs once per real tick regardless
|
||||
# of role (never part of prediction replay).
|
||||
if _invincible:
|
||||
_invincible_timer -= delta
|
||||
modulate.a = 0.3 if int(_invincible_timer * 8) % 2 == 0 else 1.0
|
||||
@@ -41,37 +91,142 @@ func _physics_process(delta: float) -> void:
|
||||
_invincible = false
|
||||
modulate.a = 1.0
|
||||
|
||||
# 2. ROTATION
|
||||
if Input.is_action_pressed("move_right"):
|
||||
if multiplayer.is_server():
|
||||
_server_tick(delta)
|
||||
elif _is_owner:
|
||||
_owner_tick(delta)
|
||||
else:
|
||||
_remote_tick(delta)
|
||||
|
||||
|
||||
# ── Owner client: predict locally, send input to server ─────────────────────
|
||||
|
||||
func _owner_tick(delta: float) -> void:
|
||||
var input := _sample_local_input()
|
||||
_input_tick += 1
|
||||
_simulate_step(delta, input, true)
|
||||
|
||||
_pending_inputs.append({"tick": _input_tick, "input": input})
|
||||
if _pending_inputs.size() > MAX_PENDING_INPUTS:
|
||||
_pending_inputs.pop_front()
|
||||
submit_input.rpc_id(1, _input_tick, input)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable")
|
||||
func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, shown: bool) -> void:
|
||||
# Piggyback current health AND visibility on every state broadcast (not
|
||||
# just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) so a
|
||||
# peer that connects after another ship already spawned/respawned still
|
||||
# converges within a tick, instead of being stuck with that ship's
|
||||
# defaults (health=0, invisible) forever — those one-shot RPCs only ever
|
||||
# reach peers that were already connected at the moment they fired.
|
||||
if health != hp:
|
||||
health = hp
|
||||
_update_hud()
|
||||
visible = shown
|
||||
|
||||
if _is_owner:
|
||||
_reconcile(tick, pos, rot, vel)
|
||||
else:
|
||||
_interp_from_pos = global_position
|
||||
_interp_from_rot = rotation
|
||||
_interp_to_pos = pos
|
||||
_interp_to_rot = rot
|
||||
_interp_elapsed = 0.0
|
||||
velocity = vel
|
||||
|
||||
|
||||
func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server_vel: Vector2) -> void:
|
||||
var replay_index := -1
|
||||
for i in _pending_inputs.size():
|
||||
if _pending_inputs[i].tick == server_tick:
|
||||
replay_index = i
|
||||
break
|
||||
|
||||
global_position = server_pos
|
||||
rotation = server_rot
|
||||
velocity = server_vel
|
||||
|
||||
if replay_index == -1:
|
||||
_pending_inputs.clear()
|
||||
return
|
||||
|
||||
_pending_inputs = _pending_inputs.slice(replay_index + 1)
|
||||
for entry in _pending_inputs:
|
||||
_simulate_step(_fixed_delta, entry.input, false)
|
||||
|
||||
|
||||
# ── Server: simulate every ship, broadcast authoritative state ──────────────
|
||||
|
||||
func _server_tick(delta: float) -> void:
|
||||
var input: Dictionary
|
||||
if _is_owner:
|
||||
input = _sample_local_input()
|
||||
else:
|
||||
if not _remote_inputs.is_empty():
|
||||
var entry: Dictionary = _remote_inputs.pop_front()
|
||||
_last_remote_input = entry.input
|
||||
_last_processed_tick = entry.tick
|
||||
input = _last_remote_input
|
||||
|
||||
_simulate_step(delta, input, true)
|
||||
_server_process_shoot(delta, input)
|
||||
|
||||
_receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, visible)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable")
|
||||
func submit_input(tick: int, input: Dictionary) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if multiplayer.get_remote_sender_id() != peer_id:
|
||||
return
|
||||
_remote_inputs.append({"tick": tick, "input": input})
|
||||
if _remote_inputs.size() > MAX_PENDING_INPUTS:
|
||||
_remote_inputs.pop_front()
|
||||
|
||||
|
||||
# ── Remote client: no local simulation, just interpolate toward last snapshot ─
|
||||
|
||||
func _remote_tick(delta: float) -> void:
|
||||
_interp_elapsed += delta
|
||||
var t: float = clamp(_interp_elapsed / _fixed_delta, 0.0, 1.0)
|
||||
global_position = _interp_from_pos.lerp(_interp_to_pos, t)
|
||||
rotation = lerp_angle(_interp_from_rot, _interp_to_rot, t)
|
||||
|
||||
|
||||
# ── Shared movement step (deterministic — replayed during reconciliation) ───
|
||||
|
||||
func _sample_local_input() -> Dictionary:
|
||||
return {
|
||||
"up": Input.is_action_pressed("move_up"),
|
||||
"down": Input.is_action_pressed("move_down"),
|
||||
"left": Input.is_action_pressed("move_left"),
|
||||
"right": Input.is_action_pressed("move_right"),
|
||||
"shoot": Input.is_action_pressed("shoot"),
|
||||
}
|
||||
|
||||
|
||||
# apply_side_effects gates anything non-deterministic-safe (e.g. dealing
|
||||
# damage) so replaying buffered inputs during reconciliation can't double it.
|
||||
func _simulate_step(delta: float, input: Dictionary, apply_side_effects: bool) -> void:
|
||||
if input.get("right", false):
|
||||
rotation += GameConfig.ship_rotation_speed * delta
|
||||
if Input.is_action_pressed("move_left"):
|
||||
if input.get("left", false):
|
||||
rotation -= GameConfig.ship_rotation_speed * delta
|
||||
|
||||
# 3. THRUST
|
||||
var thrust := GameConfig.ship_thrust * GameConfig.player_ship_speed_factor
|
||||
var max_speed := GameConfig.ship_max_speed * GameConfig.player_ship_speed_factor
|
||||
if Input.is_action_pressed("move_up"):
|
||||
var thrust := GameConfig.ship_thrust * _ship_speed_factor
|
||||
var max_speed := GameConfig.ship_max_speed * _ship_speed_factor
|
||||
if input.get("up", false):
|
||||
velocity += Vector2.UP.rotated(rotation) * thrust * delta
|
||||
if Input.is_action_pressed("move_down"):
|
||||
if input.get("down", false):
|
||||
velocity += Vector2.DOWN.rotated(rotation) * thrust * delta
|
||||
|
||||
velocity = velocity.limit_length(max_speed)
|
||||
|
||||
var velocity_before_move := velocity
|
||||
move_and_slide()
|
||||
_handle_environment_collisions(velocity_before_move)
|
||||
_apply_collision_bounce(velocity_before_move, apply_side_effects)
|
||||
|
||||
# 4. SHOOT
|
||||
_fire_cooldown -= delta
|
||||
if Input.is_action_pressed("shoot") and _fire_cooldown <= 0.0:
|
||||
_fire_cooldown = GameConfig.ship_fire_rate
|
||||
var bullet = BULLET_SCENE.instantiate()
|
||||
bullet.global_position = global_position + Vector2.UP.rotated(rotation) * 40.0
|
||||
bullet.velocity = Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity
|
||||
bullet.source = self
|
||||
get_parent().add_child(bullet)
|
||||
|
||||
# 5. WALL COLLISION
|
||||
var bounds = GameConfig.world_bounds
|
||||
if global_position.x < bounds.position.x or global_position.x > bounds.end.x:
|
||||
velocity.x = 0
|
||||
@@ -80,7 +235,8 @@ func _physics_process(delta: float) -> void:
|
||||
velocity.y = 0
|
||||
global_position.y = clamp(global_position.y, bounds.position.y, bounds.end.y)
|
||||
|
||||
func _handle_environment_collisions(previous_velocity: Vector2) -> void:
|
||||
|
||||
func _apply_collision_bounce(previous_velocity: Vector2, apply_side_effects: bool) -> void:
|
||||
for i in get_slide_collision_count():
|
||||
var collision := get_slide_collision(i)
|
||||
var collider := collision.get_collider() as Node
|
||||
@@ -95,29 +251,69 @@ func _handle_environment_collisions(previous_velocity: Vector2) -> void:
|
||||
if impact_speed <= 0.0:
|
||||
continue
|
||||
velocity = previous_velocity.bounce(normal) * GameConfig.ship_bounce_restitution
|
||||
if is_hazard:
|
||||
if is_hazard and apply_side_effects and multiplayer.is_server():
|
||||
var damage := int(impact_speed * GameConfig.ship_collision_damage_scale)
|
||||
if damage > 0:
|
||||
take_damage(damage)
|
||||
|
||||
|
||||
# Server-only: decides whether this tick's queued input fires a shot, using
|
||||
# its own authoritative cooldown (never trust a client's fire rate). Requests
|
||||
# World spawn the bullet, which replicates it to every peer.
|
||||
func _server_process_shoot(delta: float, input: Dictionary) -> void:
|
||||
_fire_cooldown -= delta
|
||||
if input.get("shoot", false) and _fire_cooldown <= 0.0:
|
||||
_fire_cooldown = GameConfig.ship_fire_rate
|
||||
var world := get_node_or_null("/root/World")
|
||||
if world:
|
||||
world.request_bullet_spawn(
|
||||
peer_id,
|
||||
global_position + Vector2.UP.rotated(rotation) * 40.0,
|
||||
Vector2.UP.rotated(rotation) * GameConfig.bullet_speed + velocity
|
||||
)
|
||||
|
||||
|
||||
# ── Health / death — server-authoritative, broadcast to every peer ──────────
|
||||
|
||||
func take_damage(amount: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
if _invincible or _dead:
|
||||
return
|
||||
health = max(0, health - amount)
|
||||
_update_hud()
|
||||
_apply_health.rpc(health)
|
||||
if health == 0:
|
||||
_die()
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _apply_health(new_health: int) -> void:
|
||||
health = new_health
|
||||
_update_hud()
|
||||
|
||||
|
||||
func _die() -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_apply_death.rpc()
|
||||
await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout
|
||||
_server_respawn()
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _apply_death() -> void:
|
||||
_dead = true
|
||||
visible = false
|
||||
velocity = Vector2.ZERO
|
||||
await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout
|
||||
_respawn()
|
||||
|
||||
func _respawn() -> void:
|
||||
global_position = _get_spawn_position()
|
||||
|
||||
func _server_respawn() -> void:
|
||||
_apply_respawn.rpc(_get_spawn_position())
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _apply_respawn(pos: Vector2) -> void:
|
||||
global_position = pos
|
||||
velocity = Vector2.ZERO
|
||||
rotation = 0.0
|
||||
health = GameConfig.ship_max_health
|
||||
@@ -125,6 +321,15 @@ func _respawn() -> void:
|
||||
visible = true
|
||||
_invincible = true
|
||||
_invincible_timer = GameConfig.ship_invincibility_time
|
||||
|
||||
_interp_from_pos = global_position
|
||||
_interp_to_pos = global_position
|
||||
_interp_from_rot = rotation
|
||||
_interp_to_rot = rotation
|
||||
_interp_elapsed = 0.0
|
||||
_pending_inputs.clear()
|
||||
_remote_inputs.clear()
|
||||
|
||||
_update_hud()
|
||||
|
||||
func _get_spawn_position() -> Vector2:
|
||||
@@ -135,6 +340,8 @@ func _get_spawn_position() -> Vector2:
|
||||
|
||||
|
||||
func _update_hud() -> void:
|
||||
if not _is_owner:
|
||||
return
|
||||
var label = get_node_or_null("/root/World/HUD/HealthLabel")
|
||||
if label:
|
||||
label.text = "HP: %d / %d" % [health, GameConfig.ship_max_health]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
extends Node2D
|
||||
|
||||
const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn")
|
||||
const SHIP_SCENE: PackedScene = preload("res://ships/ship.tscn")
|
||||
const BULLET_SCENE: PackedScene = preload("res://ships/bullet.tscn")
|
||||
|
||||
@onready var _map_container: Node2D = $MapContainer
|
||||
@onready var _players: Node2D = $Players
|
||||
@onready var _spawner: MultiplayerSpawner = $MultiplayerSpawner
|
||||
@onready var _bullets: Node2D = $Bullets
|
||||
@onready var _bullet_spawner: MultiplayerSpawner = $BulletSpawner
|
||||
@onready var _team_select: TeamSelect = $TeamSelect
|
||||
|
||||
# Server-authoritative: the 2 races offered for this match, decided once and
|
||||
# shared by every player — see TeamSelect._ready(), which either reads this
|
||||
# directly (server's own instance) or requests it over RPC (remote clients).
|
||||
var _offered_race_ids: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -13,6 +25,82 @@ func _ready() -> void:
|
||||
GameConfig.world_bounds = Rect2(background.position, background.size)
|
||||
_build_tile_collisions(map)
|
||||
|
||||
_spawner.spawn_function = _spawn_ship
|
||||
_bullet_spawner.spawn_function = _spawn_bullet
|
||||
NetworkManager.peer_joined.connect(_on_peer_joined)
|
||||
NetworkManager.peer_left.connect(_on_peer_left)
|
||||
|
||||
if multiplayer.is_server():
|
||||
_spawn_peer(multiplayer.get_unique_id())
|
||||
for peer_id in multiplayer.get_peers():
|
||||
_spawn_peer(peer_id)
|
||||
|
||||
|
||||
# Picks the match's 2 offered races once and caches them, so every caller
|
||||
# (the server's own TeamSelect and every remote client's request) converges
|
||||
# on the same pair regardless of call order.
|
||||
func decide_offered_races() -> Array:
|
||||
if _offered_race_ids.is_empty():
|
||||
var ids := range(1, TeamSelect.RACES.size() + 1)
|
||||
ids.shuffle()
|
||||
_offered_race_ids = [ids[0], ids[1]]
|
||||
return _offered_race_ids
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func request_offered_races() -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
_deliver_offered_races.rpc_id(sender_id, decide_offered_races())
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _deliver_offered_races(race_ids: Array) -> void:
|
||||
_team_select.offer_races(race_ids)
|
||||
|
||||
|
||||
func _spawn_peer(peer_id: int) -> void:
|
||||
if _players.has_node(str(peer_id)):
|
||||
return
|
||||
_spawner.spawn(peer_id)
|
||||
|
||||
|
||||
func _spawn_ship(peer_id: int) -> Node:
|
||||
var ship := SHIP_SCENE.instantiate()
|
||||
ship.name = str(peer_id)
|
||||
ship.peer_id = peer_id
|
||||
return ship
|
||||
|
||||
|
||||
# Called by a Ship node running on the server when its authoritative fire
|
||||
# cooldown allows a shot. Replicated to every peer via the bullet spawner.
|
||||
func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_bullet_spawner.spawn({"peer_id": source_peer_id, "pos": pos, "vel": vel})
|
||||
|
||||
|
||||
func _spawn_bullet(data: Dictionary) -> Node:
|
||||
var bullet := BULLET_SCENE.instantiate()
|
||||
bullet.global_position = data.pos
|
||||
bullet.velocity = data.vel
|
||||
bullet.source_peer_id = data.peer_id
|
||||
return bullet
|
||||
|
||||
|
||||
func _on_peer_joined(peer_id: int) -> void:
|
||||
if multiplayer.is_server():
|
||||
_spawn_peer(peer_id)
|
||||
|
||||
|
||||
func _on_peer_left(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var ship := _players.get_node_or_null(str(peer_id))
|
||||
if ship:
|
||||
ship.queue_free()
|
||||
|
||||
|
||||
# Tiles have no collision shapes of their own, so spawn one StaticBody2D per
|
||||
# occupied cell. Asteroids get round hitboxes to roughly match their sprites.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[gd_scene format=3 uid="uid://c77s15ns13p6y"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://dygtdlkvo6ofl" path="res://ships/ship.tscn" id="1_f3sb7"]
|
||||
[ext_resource type="PackedScene" uid="uid://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"]
|
||||
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
|
||||
[ext_resource type="Script" path="res://world/world.gd" id="4_world"]
|
||||
@@ -10,7 +9,15 @@ script = ExtResource("4_world")
|
||||
|
||||
[node name="MapContainer" type="Node2D" parent="."]
|
||||
|
||||
[node name="Player" parent="." unique_id=2118863138 instance=ExtResource("1_f3sb7")]
|
||||
[node name="Players" type="Node2D" parent="."]
|
||||
|
||||
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."]
|
||||
spawn_path = NodePath("../Players")
|
||||
|
||||
[node name="Bullets" type="Node2D" parent="."]
|
||||
|
||||
[node name="BulletSpawner" type="MultiplayerSpawner" parent="."]
|
||||
spawn_path = NodePath("../Bullets")
|
||||
|
||||
[node name="HUD" type="CanvasLayer" parent="."]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user