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 |
|
| `money.md` | Monetization strategy |
|
||||||
| `structure.md` | Project file tree, scene graph, input map |
|
| `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
|
## Completed
|
||||||
|
|
||||||
1. **Server browser** — player name input, race toggles, server list (Trench Wars 0/32), JOIN button; stores name + race in `GameConfig` autoload
|
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
|
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
|
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
|
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
|
## Current Tasks
|
||||||
|
|
||||||
- [ ] Asteroids and environment hazards
|
- [ ] 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)
|
- [ ] 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`)
|
- [ ] 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
|
### Matchmaking Backend
|
||||||
|
|
||||||
| Component | Tech |
|
Built — see `matchmaking-api/README.md` for how to run it. Actual stack
|
||||||
|-----------|------|
|
diverged from the original plan below:
|
||||||
| API Server | Go or Node.js |
|
|
||||||
| Queue / State | Redis |
|
|
||||||
| Database (accounts, ranks) | PostgreSQL |
|
|
||||||
| Hosting | VPS (Hetzner / DigitalOcean) or self-hosted |
|
|
||||||
|
|
||||||
**Flow:**
|
| Component | Planned | Actual |
|
||||||
1. Client sends "find match" with MMR + mode
|
|-----------|---------|--------|
|
||||||
2. Backend queues player, finds lobby within MMR range
|
| 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 |
|
||||||
3. Lobby full → backend assigns a game server
|
| Queue / State | Redis | **In-memory in the API process** — no Redis; revisit only if the API needs to run as more than one instance |
|
||||||
4. Backend sends client the server IP + session token
|
| Database (accounts, ranks) | PostgreSQL | PostgreSQL, as planned |
|
||||||
5. Client connects directly to game server
|
| 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
|
### Steam Integration
|
||||||
|
|
||||||
@@ -79,11 +90,11 @@ func send_input(dir: Vector2, firing: bool) -> void:
|
|||||||
|
|
||||||
## Networking Checklist
|
## Networking Checklist
|
||||||
|
|
||||||
- [ ] Godot ENet server/client setup
|
- [x] Godot ENet server/client setup
|
||||||
- [ ] Player input RPC structure
|
- [x] Player input RPC structure
|
||||||
- [ ] Position sync with interpolation
|
- [x] Position sync with interpolation
|
||||||
- [ ] Ship class registration per peer
|
- [x] Ship class registration per peer
|
||||||
- [ ] Server-authoritative health/death
|
- [x] Server-authoritative health/death
|
||||||
- [ ] Matchmaking API (queue + lobby assignment)
|
- [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
|
- [ ] GodotSteam auth + VAC
|
||||||
- [ ] Lag compensation (basic rewind)
|
- [ ] Lag compensation (basic rewind)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
signal team_selected
|
|
||||||
|
|
||||||
# Ship movement
|
# Ship movement
|
||||||
var ship_thrust: float = 250.0
|
var ship_thrust: float = 250.0
|
||||||
var ship_max_speed: float = 300.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_bounce_restitution: float = 0.45
|
||||||
var ship_collision_damage_scale: float = 0.064
|
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_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.
|
# Current map's play area, in world coordinates. Set by world.gd on load.
|
||||||
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
||||||
|
|||||||
@@ -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 _name_input: LineEdit
|
||||||
var _casual_btn: Button
|
var _casual_btn: Button
|
||||||
var _ranked_btn: Button
|
var _ranked_btn: Button
|
||||||
|
var _status_lbl: Label
|
||||||
|
var _connecting: bool = false
|
||||||
|
|
||||||
const RANK_DATA := [
|
const RANK_DATA := [
|
||||||
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
|
{"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:
|
func _ready() -> void:
|
||||||
GameConfig.player_race = 0
|
|
||||||
GameConfig.player_ship_path = ""
|
|
||||||
_build_ui()
|
_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:
|
func _build_ui() -> void:
|
||||||
@@ -134,6 +138,17 @@ func _add_play_section() -> void:
|
|||||||
_name_input.text_changed.connect(_on_name_changed)
|
_name_input.text_changed.connect(_on_name_changed)
|
||||||
add_child(_name_input)
|
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()
|
_update_play_buttons()
|
||||||
|
|
||||||
|
|
||||||
@@ -322,17 +337,49 @@ func _on_name_changed(_text: String) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _update_play_buttons() -> 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
|
_casual_btn.disabled = not ok
|
||||||
_ranked_btn.disabled = true
|
_ranked_btn.disabled = true
|
||||||
|
|
||||||
|
|
||||||
func _on_play_casual() -> void:
|
func _on_play_casual() -> void:
|
||||||
GameConfig.player_name = _name_input.text.strip_edges()
|
_start_match("casual")
|
||||||
get_tree().change_scene_to_file("res://world/world.tscn")
|
|
||||||
|
|
||||||
|
|
||||||
func _on_play_ranked() -> void:
|
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_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")
|
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:
|
func _on_quit_to_menu() -> void:
|
||||||
|
NetworkManager.disconnect_from_game()
|
||||||
get_tree().change_scene_to_file("res://menu/main_menu.tscn")
|
get_tree().change_scene_to_file("res://menu/main_menu.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
|
class_name TeamSelect
|
||||||
|
|
||||||
# Movement speed factor by role, shared across all 3 races so e.g. every
|
# 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.
|
# 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 _root: Control
|
||||||
var _content: Control
|
var _content: Control
|
||||||
|
var _loading_lbl: Label
|
||||||
var _offered: Array = []
|
var _offered: Array = []
|
||||||
var _chosen_race: Dictionary = {}
|
var _chosen_race: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
layer = 20
|
layer = 20
|
||||||
_offered = _pick_two_races()
|
|
||||||
_build_root()
|
_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()
|
_show_race_selection()
|
||||||
|
|
||||||
|
|
||||||
func _pick_two_races() -> Array:
|
func _race_by_id(id: int) -> Dictionary:
|
||||||
var pool := RACES.duplicate()
|
for race in RACES:
|
||||||
pool.shuffle()
|
if race.id == id:
|
||||||
return [pool[0], pool[1]]
|
return race
|
||||||
|
return RACES[0]
|
||||||
|
|
||||||
|
|
||||||
func _build_root() -> void:
|
func _build_root() -> void:
|
||||||
@@ -144,6 +165,9 @@ func _build_root() -> void:
|
|||||||
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
_root.add_child(overlay)
|
_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:
|
func _swap_content() -> Control:
|
||||||
if _content:
|
if _content:
|
||||||
@@ -179,7 +203,6 @@ func _show_race_selection() -> void:
|
|||||||
|
|
||||||
func _on_race_chosen(race: Dictionary) -> void:
|
func _on_race_chosen(race: Dictionary) -> void:
|
||||||
_chosen_race = race
|
_chosen_race = race
|
||||||
GameConfig.player_race = race.id
|
|
||||||
_show_ship_selection()
|
_show_ship_selection()
|
||||||
|
|
||||||
|
|
||||||
@@ -203,10 +226,9 @@ func _show_ship_selection() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_ship_chosen(ship: Dictionary) -> void:
|
func _on_ship_chosen(ship: Dictionary) -> void:
|
||||||
GameConfig.player_ship_path = ship.path
|
PlayerRegistry.submit_local_loadout(
|
||||||
GameConfig.player_ship_scale = ship.scale
|
GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor
|
||||||
GameConfig.player_ship_speed_factor = ship.speed_factor
|
)
|
||||||
GameConfig.team_selected.emit()
|
|
||||||
queue_free()
|
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,
|
func _label(parent: Control, text: String, font_size: int, color: Color,
|
||||||
x1: float, y1: float, x2: float, y2: float,
|
x1: float, y1: float, x2: float, y2: float,
|
||||||
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> void:
|
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT) -> Label:
|
||||||
var lbl := Label.new()
|
var lbl := Label.new()
|
||||||
lbl.text = text
|
lbl.text = text
|
||||||
lbl.horizontal_alignment = align
|
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)
|
lbl.add_theme_color_override("font_color", color)
|
||||||
_place(lbl, x1, y1, x2, y2)
|
_place(lbl, x1, y1, x2, y2)
|
||||||
parent.add_child(lbl)
|
parent.add_child(lbl)
|
||||||
|
return lbl
|
||||||
|
|
||||||
|
|
||||||
func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void:
|
func _place(node: Control, x1: float, y1: float, x2: float, y2: float) -> void:
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ config/icon="res://assets/icon.svg"
|
|||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
GameConfig="*res://autoload/game_config.gd"
|
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]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=2560
|
window/size/viewport_width=1280
|
||||||
window/size/viewport_height=1440
|
window/size/viewport_height=800
|
||||||
window/size/mode=3
|
|
||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
extends Area2D
|
extends Area2D
|
||||||
|
|
||||||
|
# Set by World._spawn_bullet() before this node enters the tree.
|
||||||
var velocity: Vector2 = Vector2.ZERO
|
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:
|
func _ready() -> void:
|
||||||
body_entered.connect(_on_body_entered)
|
if multiplayer.is_server():
|
||||||
|
body_entered.connect(_on_body_entered)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
global_position += velocity * delta
|
global_position += velocity * delta
|
||||||
var bounds = GameConfig.world_bounds.grow(64.0)
|
if multiplayer.is_server():
|
||||||
if not bounds.has_point(global_position):
|
var bounds = GameConfig.world_bounds.grow(64.0)
|
||||||
queue_free()
|
if not bounds.has_point(global_position):
|
||||||
|
queue_free()
|
||||||
|
|
||||||
func _on_body_entered(body: Node) -> void:
|
func _on_body_entered(body: Node) -> void:
|
||||||
if body == source:
|
if body.get("peer_id") == source_peer_id:
|
||||||
return
|
return
|
||||||
if body.has_method("take_damage"):
|
if body.has_method("take_damage"):
|
||||||
body.take_damage(GameConfig.bullet_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="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="." unique_id=1504818491]
|
||||||
|
|
||||||
[node name="Camera2D" type="Camera2D" parent="."]
|
[node name="Camera2D" type="Camera2D" parent="."]
|
||||||
current = true
|
current = false
|
||||||
rotating = false
|
rotating = false
|
||||||
position_smoothing_enabled = true
|
position_smoothing_enabled = true
|
||||||
position_smoothing_speed = 8.0
|
position_smoothing_speed = 8.0
|
||||||
|
|||||||
+247
-40
@@ -1,39 +1,89 @@
|
|||||||
extends CharacterBody2D
|
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 _fire_cooldown: float = 0.0
|
||||||
var health: int = 0
|
var health: int = 0
|
||||||
var _dead: bool = false
|
var _dead: bool = false
|
||||||
var _invincible: bool = false
|
var _invincible: bool = false
|
||||||
var _invincible_timer: float = 0.0
|
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:
|
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
|
visible = false
|
||||||
set_physics_process(false)
|
set_physics_process(false)
|
||||||
GameConfig.team_selected.connect(_on_team_selected)
|
|
||||||
else:
|
else:
|
||||||
_init_player()
|
_init_player(info)
|
||||||
|
|
||||||
|
|
||||||
func _on_team_selected() -> void:
|
func _on_loadout_updated(updated_peer_id: int) -> void:
|
||||||
_init_player()
|
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")
|
var sprite := get_node_or_null("Sprite2D")
|
||||||
if sprite and not GameConfig.player_ship_path.is_empty():
|
var ship_path: String = info.get("ship_path", "")
|
||||||
sprite.texture = load(GameConfig.player_ship_path)
|
if sprite and not ship_path.is_empty():
|
||||||
sprite.scale = Vector2.ONE * GameConfig.player_ship_scale
|
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)
|
set_physics_process(true)
|
||||||
_respawn()
|
if multiplayer.is_server():
|
||||||
|
_server_respawn()
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if _dead:
|
if _dead:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. INVINCIBILITY BLINK
|
# INVINCIBILITY BLINK — purely visual, runs once per real tick regardless
|
||||||
|
# of role (never part of prediction replay).
|
||||||
if _invincible:
|
if _invincible:
|
||||||
_invincible_timer -= delta
|
_invincible_timer -= delta
|
||||||
modulate.a = 0.3 if int(_invincible_timer * 8) % 2 == 0 else 1.0
|
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
|
_invincible = false
|
||||||
modulate.a = 1.0
|
modulate.a = 1.0
|
||||||
|
|
||||||
# 2. ROTATION
|
if multiplayer.is_server():
|
||||||
if Input.is_action_pressed("move_right"):
|
_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
|
rotation += GameConfig.ship_rotation_speed * delta
|
||||||
if Input.is_action_pressed("move_left"):
|
if input.get("left", false):
|
||||||
rotation -= GameConfig.ship_rotation_speed * delta
|
rotation -= GameConfig.ship_rotation_speed * delta
|
||||||
|
|
||||||
# 3. THRUST
|
var thrust := GameConfig.ship_thrust * _ship_speed_factor
|
||||||
var thrust := GameConfig.ship_thrust * GameConfig.player_ship_speed_factor
|
var max_speed := GameConfig.ship_max_speed * _ship_speed_factor
|
||||||
var max_speed := GameConfig.ship_max_speed * GameConfig.player_ship_speed_factor
|
if input.get("up", false):
|
||||||
if Input.is_action_pressed("move_up"):
|
|
||||||
velocity += Vector2.UP.rotated(rotation) * thrust * delta
|
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 += Vector2.DOWN.rotated(rotation) * thrust * delta
|
||||||
|
|
||||||
velocity = velocity.limit_length(max_speed)
|
velocity = velocity.limit_length(max_speed)
|
||||||
|
|
||||||
var velocity_before_move := velocity
|
var velocity_before_move := velocity
|
||||||
move_and_slide()
|
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
|
var bounds = GameConfig.world_bounds
|
||||||
if global_position.x < bounds.position.x or global_position.x > bounds.end.x:
|
if global_position.x < bounds.position.x or global_position.x > bounds.end.x:
|
||||||
velocity.x = 0
|
velocity.x = 0
|
||||||
@@ -80,7 +235,8 @@ func _physics_process(delta: float) -> void:
|
|||||||
velocity.y = 0
|
velocity.y = 0
|
||||||
global_position.y = clamp(global_position.y, bounds.position.y, bounds.end.y)
|
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():
|
for i in get_slide_collision_count():
|
||||||
var collision := get_slide_collision(i)
|
var collision := get_slide_collision(i)
|
||||||
var collider := collision.get_collider() as Node
|
var collider := collision.get_collider() as Node
|
||||||
@@ -95,29 +251,69 @@ func _handle_environment_collisions(previous_velocity: Vector2) -> void:
|
|||||||
if impact_speed <= 0.0:
|
if impact_speed <= 0.0:
|
||||||
continue
|
continue
|
||||||
velocity = previous_velocity.bounce(normal) * GameConfig.ship_bounce_restitution
|
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)
|
var damage := int(impact_speed * GameConfig.ship_collision_damage_scale)
|
||||||
if damage > 0:
|
if damage > 0:
|
||||||
take_damage(damage)
|
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:
|
func take_damage(amount: int) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
if _invincible or _dead:
|
if _invincible or _dead:
|
||||||
return
|
return
|
||||||
health = max(0, health - amount)
|
health = max(0, health - amount)
|
||||||
_update_hud()
|
_apply_health.rpc(health)
|
||||||
if health == 0:
|
if health == 0:
|
||||||
_die()
|
_die()
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _apply_health(new_health: int) -> void:
|
||||||
|
health = new_health
|
||||||
|
_update_hud()
|
||||||
|
|
||||||
|
|
||||||
func _die() -> void:
|
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
|
_dead = true
|
||||||
visible = false
|
visible = false
|
||||||
velocity = Vector2.ZERO
|
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
|
velocity = Vector2.ZERO
|
||||||
rotation = 0.0
|
rotation = 0.0
|
||||||
health = GameConfig.ship_max_health
|
health = GameConfig.ship_max_health
|
||||||
@@ -125,6 +321,15 @@ func _respawn() -> void:
|
|||||||
visible = true
|
visible = true
|
||||||
_invincible = true
|
_invincible = true
|
||||||
_invincible_timer = GameConfig.ship_invincibility_time
|
_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()
|
_update_hud()
|
||||||
|
|
||||||
func _get_spawn_position() -> Vector2:
|
func _get_spawn_position() -> Vector2:
|
||||||
@@ -135,6 +340,8 @@ func _get_spawn_position() -> Vector2:
|
|||||||
|
|
||||||
|
|
||||||
func _update_hud() -> void:
|
func _update_hud() -> void:
|
||||||
|
if not _is_owner:
|
||||||
|
return
|
||||||
var label = get_node_or_null("/root/World/HUD/HealthLabel")
|
var label = get_node_or_null("/root/World/HUD/HealthLabel")
|
||||||
if label:
|
if label:
|
||||||
label.text = "HP: %d / %d" % [health, GameConfig.ship_max_health]
|
label.text = "HP: %d / %d" % [health, GameConfig.ship_max_health]
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn")
|
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 _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:
|
func _ready() -> void:
|
||||||
@@ -13,6 +25,82 @@ func _ready() -> void:
|
|||||||
GameConfig.world_bounds = Rect2(background.position, background.size)
|
GameConfig.world_bounds = Rect2(background.position, background.size)
|
||||||
_build_tile_collisions(map)
|
_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
|
# Tiles have no collision shapes of their own, so spawn one StaticBody2D per
|
||||||
# occupied cell. Asteroids get round hitboxes to roughly match their sprites.
|
# occupied cell. Asteroids get round hitboxes to roughly match their sprites.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[gd_scene format=3 uid="uid://c77s15ns13p6y"]
|
[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://cpausemenu01a" path="res://menu/pause_menu.tscn" id="2_pm"]
|
||||||
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
|
[ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"]
|
||||||
[ext_resource type="Script" path="res://world/world.gd" id="4_world"]
|
[ext_resource type="Script" path="res://world/world.gd" id="4_world"]
|
||||||
@@ -10,7 +9,15 @@ script = ExtResource("4_world")
|
|||||||
|
|
||||||
[node name="MapContainer" type="Node2D" parent="."]
|
[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="."]
|
[node name="HUD" type="CanvasLayer" parent="."]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user