diff --git a/game/Assets/Scripts/GameSetup.cs b/game/Assets/Scripts/GameSetup.cs
index 1447ad6..f0985cc 100644
--- a/game/Assets/Scripts/GameSetup.cs
+++ b/game/Assets/Scripts/GameSetup.cs
@@ -1,154 +1,13 @@
using UnityEngine;
///
-/// Global game setup applied at startup:
-/// - Application.runInBackground (physics continues on ALT-TAB)
-/// - Tall invisible arena barriers (prevent ball escape)
-/// - Visual enhancements: obstacle colors, floor tint, lighting contrast
-/// Attach to a persistent GameObject (e.g. NetworkManager).
+/// Global startup settings. Scene geometry and materials are set directly in the Editor.
///
public class GameSetup : MonoBehaviour
{
- [Header("Arena Boundaries")]
- public float arenaHalfSize = 45f;
- public float barrierHeight = 50f;
- public float barrierThickness = 1f;
-
- [Header("Visuals")]
- public bool enhanceVisuals = true;
-
void Awake()
{
- // --- Keep physics and network running on focus loss ---
Application.runInBackground = true;
Application.targetFrameRate = 60;
-
- // Barriers removed — respawn system handles falls (Y < -10)
- }
-
- void Start()
- {
- if (enhanceVisuals)
- EnhanceVisuals();
- }
-
- // --- Barrier creation ---
-
- private void CreateBarrier(string name, Vector3 position, Vector3 size)
- {
- var go = new GameObject(name);
- go.transform.position = position;
- var col = go.AddComponent();
- col.size = size;
- // No Renderer = invisible. Static collider = immovable wall.
- }
-
- // --- Visual enhancements ---
-
- private void EnhanceVisuals()
- {
- TintFloor();
- ColorObstacles();
- ColorWallsAndGrids();
- EnhanceLighting();
- }
-
- private void TintFloor()
- {
- var plane = GameObject.Find("Plane");
- if (plane == null) return;
- var rend = plane.GetComponent();
- if (rend == null) return;
-
- var mat = new Material(rend.sharedMaterial);
- // Soft blue-gray instead of flat white
- Color floorColor = new Color(0.70f, 0.74f, 0.82f, 1f);
- SetMatColor(mat, floorColor);
- rend.material = mat;
- }
-
- private void ColorObstacles()
- {
- Color[] palette =
- {
- new Color(0.42f, 0.55f, 0.75f), // Steel blue
- new Color(0.60f, 0.45f, 0.68f), // Muted purple
- new Color(0.48f, 0.68f, 0.55f), // Sage green
- new Color(0.74f, 0.52f, 0.42f), // Warm terracotta
- new Color(0.68f, 0.65f, 0.44f), // Sandy gold
- new Color(0.44f, 0.62f, 0.72f), // Slate teal
- };
-
- for (int i = 1; i <= 18; i++)
- {
- var obs = GameObject.Find($"Obs_{i}");
- if (obs == null) continue;
- var rend = obs.GetComponent();
- if (rend == null) continue;
-
- var mat = new Material(rend.sharedMaterial);
- SetMatColor(mat, palette[i % palette.Length]);
- rend.material = mat;
- }
- }
-
- private void ColorWallsAndGrids()
- {
- Color wallColor = new Color(0.50f, 0.54f, 0.62f);
- foreach (string name in new[] { "Wall_North", "Wall_South", "Wall_East", "Wall_West" })
- {
- var wall = GameObject.Find(name);
- if (wall == null) continue;
- var rend = wall.GetComponent();
- if (rend == null) continue;
- var mat = new Material(rend.sharedMaterial);
- SetMatColor(mat, wallColor);
- rend.material = mat;
- }
-
- Color gridColor = new Color(0.58f, 0.61f, 0.68f);
- for (int i = 1; i <= 4; i++)
- {
- foreach (string dir in new[] { "NS", "EW" })
- {
- var grid = GameObject.Find($"Grid_{dir}_{i}");
- if (grid == null) continue;
- var rend = grid.GetComponent();
- if (rend == null) continue;
- var mat = new Material(rend.sharedMaterial);
- SetMatColor(mat, gridColor);
- rend.material = mat;
- }
- }
- }
-
- private void EnhanceLighting()
- {
- // Directional light: warm white, stronger, soft shadows
- var lights = FindObjectsByType(FindObjectsSortMode.None);
- foreach (var light in lights)
- {
- if (light.type == LightType.Directional)
- {
- light.color = new Color(1f, 0.96f, 0.90f); // Warm white
- light.intensity = 1.6f;
- light.shadows = LightShadows.Soft;
- light.shadowStrength = 0.75f;
- }
- }
-
- // Ambient: cool tint for contrast with warm direct light
- RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
- RenderSettings.ambientLight = new Color(0.32f, 0.36f, 0.48f);
- }
-
- // --- Utility ---
-
- private static void SetMatColor(Material mat, Color color)
- {
- if (mat.HasProperty("_BaseColor"))
- mat.SetColor("_BaseColor", color);
- if (mat.HasProperty("_Color"))
- mat.color = color;
}
}
diff --git a/game/Assets/Scripts/Network/DebugNetworkUI.cs b/game/Assets/Scripts/Network/DebugNetworkUI.cs
index c547dad..aac97be 100644
--- a/game/Assets/Scripts/Network/DebugNetworkUI.cs
+++ b/game/Assets/Scripts/Network/DebugNetworkUI.cs
@@ -67,11 +67,11 @@ public class DebugNetworkUI : MonoBehaviour
string name = !string.IsNullOrEmpty(nm.LocalPlayerName) ? nm.LocalPlayerName : "\u2014";
string room = !string.IsNullOrEmpty(nm.RoomId) ? nm.RoomId[..Mathf.Min(8, nm.RoomId.Length)] : "\u2014";
string sess = !string.IsNullOrEmpty(nm.LocalSessionId) ? nm.LocalSessionId[..Mathf.Min(6, nm.LocalSessionId.Length)] : "\u2014";
- info = $" {dot} {name} | Room {room} | Sess {sess} | {nm.PlayerCount}P | {nm.serverURL} | {_currentFps:F0} FPS";
+ info = $" {dot} {name} | Room {room} | Sess {sess} | {nm.PlayerCount}P | {"wss://rolld.io:2567"} | {_currentFps:F0} FPS";
}
else
{
- info = $" {dot} {nm.ConnectionStatus} | {nm.serverURL} | {_currentFps:F0} FPS";
+ info = $" {dot} {nm.ConnectionStatus} | {"wss://rolld.io:2567"} | {_currentFps:F0} FPS";
}
GUI.Label(new Rect(0, 0, Screen.width, h), info, ImGuiSkin.HudLabel);
@@ -93,7 +93,7 @@ public class DebugNetworkUI : MonoBehaviour
GUIStyle statusStyle = nm.IsConnected ? ImGuiSkin.StatusGreen : ImGuiSkin.StatusRed;
GUILayout.Label($"\u25CF {nm.ConnectionStatus}", statusStyle);
- ImGuiSkin.DrawField("Server", nm.serverURL);
+ ImGuiSkin.DrawField("Server", "wss://rolld.io:2567");
ImGuiSkin.DrawField("Room ID", string.IsNullOrEmpty(nm.RoomId) ? "\u2014" : nm.RoomId);
ImGuiSkin.DrawField("Session", string.IsNullOrEmpty(nm.LocalSessionId) ? "\u2014" : nm.LocalSessionId);
ImGuiSkin.DrawField("Players", nm.PlayerCount.ToString());
diff --git a/game/Assets/Scripts/Network/LobbyUI.cs b/game/Assets/Scripts/Network/LobbyUI.cs
index a4e8545..549be34 100644
--- a/game/Assets/Scripts/Network/LobbyUI.cs
+++ b/game/Assets/Scripts/Network/LobbyUI.cs
@@ -327,7 +327,7 @@ public class LobbyUI : MonoBehaviour
if (_isConnecting && !NetworkManager.Instance.IsConnected)
{
_isConnecting = false;
- _statusMessage = "Erreur : Timeout de connexion. Vérifiez que le serveur est lancé.";
+ _statusMessage = "Erreur : Impossible de joindre rolld.io. Réessayez dans quelques instants.";
if (!string.IsNullOrEmpty(NetworkManager.Instance.LastError))
{
_statusMessage += $"\n{NetworkManager.Instance.LastError}";
diff --git a/game/Assets/Scripts/Network/NetworkManager.cs b/game/Assets/Scripts/Network/NetworkManager.cs
index 6614474..5d0e80f 100644
--- a/game/Assets/Scripts/Network/NetworkManager.cs
+++ b/game/Assets/Scripts/Network/NetworkManager.cs
@@ -12,9 +12,7 @@ public class NetworkManager : MonoBehaviour
{
public static NetworkManager Instance { get; private set; }
- [Header("Connection")]
- [Tooltip("Colyseus server endpoint (overridden by frontend via SetServerURL)")]
- public string serverURL = "ws://localhost:2567";
+ private const string serverURL = "wss://rolld.io:2567";
[Header("Prefab")]
[Tooltip("Prefab for remote players (must have RemotePlayerController)")]
@@ -94,13 +92,6 @@ public class NetworkManager : MonoBehaviour
}
}
- /// Called from frontend JS via SendMessage to override the server URL.
- public void SetServerURL(string url)
- {
- serverURL = url;
- Debug.Log($"[Network] Server URL set to: {url}");
- }
-
public NetworkPlayer GetLocalPlayerState()
{
if (_room == null || _room.State.players == null || string.IsNullOrEmpty(LocalSessionId)) return null;
diff --git a/rolld_backend/game/src/rooms/ArenaRoom.js b/rolld_backend/game/src/rooms/ArenaRoom.js
index 2f90731..ce5eb7d 100644
--- a/rolld_backend/game/src/rooms/ArenaRoom.js
+++ b/rolld_backend/game/src/rooms/ArenaRoom.js
@@ -1,19 +1,35 @@
const { Room } = require("@colyseus/core");
const { GameState, Player } = require("../schema/GameState");
+const ROUND_MODES = ["race", "survival", "teams"];
+const LOBBY_TIMEOUT = 30; // seconds before auto-start
+const COUNTDOWN_DURATION = 3;
+const ROUND_END_DURATION = 5;
+const RACE_TIMEOUT = 180; // 3 min
+const SURVIVAL_START_DELAY = 20; // seconds before deathzone rises
+const SURVIVAL_RISE_RATE = 0.3; // units/sec
+const SURVIVAL_MAX_Y = 15;
+const TEAMS_DURATION = 90;
+const QUALIFY_RATIO = 0.6; // top 60% qualify in race
+
class ArenaRoom extends Room {
maxClients = 20;
onCreate(options) {
this.setState(new GameState());
- this.setPatchRate(16); // ~62.5 Hz state broadcast
- console.log(`[ArenaRoom] Room ${this.roomId} created (patchRate=16ms ~62Hz)`);
+ this.setPatchRate(16); // ~62.5 Hz
+
+ this._phaseTimer = null;
+ this._survivalInterval = null;
+ this._teamInterval = null;
+ this._lobbyTimer = null;
+ this._inZonePlayers = new Set(); // sessionIds currently in zone
+
+ console.log(`[ArenaRoom] Room ${this.roomId} created`);
- // Handle position updates from clients
this.onMessage("position", (client, data) => {
const player = this.state.players.get(client.sessionId);
- if (!player) return;
-
+ if (!player || player.isEliminated) return;
player.x = data.x ?? player.x;
player.y = data.y ?? player.y;
player.z = data.z ?? player.z;
@@ -30,90 +46,378 @@ class ArenaRoom extends Room {
player.t = Date.now();
});
- // Handle chat messages (optional, for future)
- this.onMessage("chat", (client, data) => {
- this.broadcast("chat", {
- sender: client.sessionId,
- name: this.state.players.get(client.sessionId)?.name || "???",
- message: data.message,
- });
+ this.onMessage("ready", (client) => {
+ const player = this.state.players.get(client.sessionId);
+ if (!player || this.state.phase !== "lobby") return;
+ player.isReady = true;
+ console.log(`[ArenaRoom] ${client.sessionId} ready`);
+ this._checkAllReady();
+ });
+
+ this.onMessage("checkpointReached", (client, data) => {
+ if (this.state.phase !== "playing" || this.state.gameMode !== "race") return;
+ const player = this.state.players.get(client.sessionId);
+ if (!player || player.isEliminated || player.isQualified) return;
+ const expected = player.checkpointIndex;
+ if (data.index !== expected) return; // must hit in order
+ player.checkpointIndex = data.index + 1;
+ // The last checkpoint (index 4 = finish) qualifies the player
+ // CheckpointSystem sends index after increment, so finish = totalCheckpoints
+ const TOTAL_CHECKPOINTS = 5;
+ if (player.checkpointIndex >= TOTAL_CHECKPOINTS) {
+ this._qualifyPlayer(client.sessionId, "finish");
+ }
+ });
+
+ this.onMessage("deathZoneHit", (client) => {
+ if (this.state.phase !== "playing" || this.state.gameMode !== "survival") return;
+ this._eliminatePlayer(client.sessionId, "deathzone");
+ });
+
+ this.onMessage("inZone", (client, data) => {
+ if (this.state.phase !== "playing" || this.state.gameMode !== "teams") return;
+ const player = this.state.players.get(client.sessionId);
+ if (!player || player.isEliminated) return;
+ if (data.inZone) {
+ this._inZonePlayers.add(client.sessionId);
+ } else {
+ this._inZonePlayers.delete(client.sessionId);
+ }
});
}
onJoin(client, options) {
- console.log(`[ArenaRoom] ${client.sessionId} joined (name: ${options.name || "anonymous"})`);
-
+ console.log(`[ArenaRoom] ${client.sessionId} joined (${options.name || "anonymous"})`);
const player = new Player();
player.name = options.name || "Joueur";
player.colorR = options.colorR ?? 1;
player.colorG = options.colorG ?? 0.4;
player.colorB = options.colorB ?? 0.2;
-
- // Find a spawn position away from other players
- const spawnPos = this._findSpawnPosition();
- player.x = spawnPos.x;
- player.y = spawnPos.y;
- player.z = spawnPos.z;
+ const spawn = this._findSpawnPosition();
+ player.x = spawn.x;
+ player.y = spawn.y;
+ player.z = spawn.z;
player.t = Date.now();
-
this.state.players.set(client.sessionId, player);
+ this._updatePlayersAlive();
+
+ // Auto-start lobby timer on first player
+ if (this.state.players.size === 1 && this.state.phase === "lobby") {
+ this._startLobbyTimer();
+ }
}
onLeave(client, consented) {
- console.log(`[ArenaRoom] ${client.sessionId} left (consented: ${consented})`);
+ console.log(`[ArenaRoom] ${client.sessionId} left`);
+ this._inZonePlayers.delete(client.sessionId);
this.state.players.delete(client.sessionId);
+ this._updatePlayersAlive();
+ if (this.state.phase === "playing") {
+ this._checkRoundEndCondition();
+ }
}
onDispose() {
+ this._clearAllTimers();
console.log(`[ArenaRoom] Room ${this.roomId} disposed`);
}
- /**
- * Find a spawn position elevated and away from existing players.
- * Tries up to 10 random positions, picks the one farthest from others.
- * Falls back to random if no good spot found.
- */
- _findSpawnPosition() {
- const MIN_DIST = 3.0;
- const SPAWN_Y = 5; // elevated spawn — ball drops naturally
- const RANGE = 20;
- let bestPos = { x: 0, y: SPAWN_Y, z: 0 };
- let bestMinDist = 0;
+ // ─── Phase transitions ──────────────────────────────────────────────
- const existingPositions = [];
- this.state.players.forEach((p) => {
- existingPositions.push({ x: p.x, z: p.z });
+ _startLobbyTimer() {
+ if (this._lobbyTimer) return;
+ this._lobbyTimer = setTimeout(() => this._startCountdown(), LOBBY_TIMEOUT * 1000);
+ console.log(`[ArenaRoom] Lobby timer started (${LOBBY_TIMEOUT}s)`);
+ }
+
+ _checkAllReady() {
+ if (this.state.players.size < 2) return;
+ let allReady = true;
+ this.state.players.forEach((p) => { if (!p.isReady) allReady = false; });
+ if (allReady) {
+ clearTimeout(this._lobbyTimer);
+ this._lobbyTimer = null;
+ this._startCountdown();
+ }
+ }
+
+ _startCountdown() {
+ if (this.state.phase !== "lobby") return;
+ this.state.phase = "countdown";
+ this.state.countdown = COUNTDOWN_DURATION;
+ console.log(`[ArenaRoom] Countdown started`);
+
+ const tick = () => {
+ this.state.countdown -= 1;
+ if (this.state.countdown <= 0) {
+ this._startPlaying();
+ } else {
+ this._phaseTimer = setTimeout(tick, 1000);
+ }
+ };
+ this._phaseTimer = setTimeout(tick, 1000);
+ }
+
+ _startPlaying() {
+ const modeIndex = (this.state.roundNumber - 1) % ROUND_MODES.length;
+ this.state.gameMode = ROUND_MODES[modeIndex];
+ this.state.phase = "playing";
+ this.state.countdown = 0;
+
+ // Reset player state for new round
+ let teamToggle = 0;
+ this.state.players.forEach((p, id) => {
+ p.isEliminated = false;
+ p.isQualified = false;
+ p.isReady = false;
+ p.checkpointIndex = 0;
+ if (this.state.gameMode === "teams") {
+ p.team = (teamToggle++ % 2 === 0) ? 1 : 2;
+ } else {
+ p.team = 0;
+ }
});
- // If no existing players, just random
- if (existingPositions.length === 0) {
- return {
- x: (Math.random() - 0.5) * RANGE,
- y: SPAWN_Y,
- z: (Math.random() - 0.5) * RANGE,
- };
+ this.state.deathZoneY = -50;
+ this.state.teamScoreRed = 0;
+ this.state.teamScoreBlue = 0;
+ this._inZonePlayers.clear();
+ this._updatePlayersAlive();
+
+ this.broadcast("roundStart", {
+ round: this.state.roundNumber,
+ mode: this.state.gameMode,
+ totalRounds: this.state.totalRounds,
+ });
+
+ console.log(`[ArenaRoom] Round ${this.state.roundNumber} started (mode: ${this.state.gameMode})`);
+
+ if (this.state.gameMode === "race") {
+ this._phaseTimer = setTimeout(() => this._endRaceTimeout(), RACE_TIMEOUT * 1000);
+ } else if (this.state.gameMode === "survival") {
+ this._phaseTimer = setTimeout(() => this._startSurvivalRise(), SURVIVAL_START_DELAY * 1000);
+ } else if (this.state.gameMode === "teams") {
+ this._startTeamsScoring();
+ this._phaseTimer = setTimeout(() => this._endTeamsRound(), TEAMS_DURATION * 1000);
+ }
+ }
+
+ _endRound() {
+ if (this.state.phase !== "playing") return;
+ this._clearAllTimers();
+ this.state.phase = "roundEnd";
+ this.broadcast("roundEnd", { round: this.state.roundNumber });
+ console.log(`[ArenaRoom] Round ${this.state.roundNumber} ended`);
+
+ // Check if all rounds done
+ if (this.state.roundNumber >= this.state.totalRounds) {
+ this._phaseTimer = setTimeout(() => this._endGame(), ROUND_END_DURATION * 1000);
+ } else {
+ this._phaseTimer = setTimeout(() => this._nextRound(), ROUND_END_DURATION * 1000);
+ }
+ }
+
+ _nextRound() {
+ this.state.roundNumber += 1;
+ this.state.phase = "lobby";
+ this.state.playersAlive = 0;
+ this.state.players.forEach((p) => {
+ if (!p.isEliminated) {
+ p.isReady = false;
+ const spawn = this._findSpawnPosition();
+ p.x = spawn.x; p.y = spawn.y; p.z = spawn.z;
+ }
+ });
+ this._updatePlayersAlive();
+ this._lobbyTimer = null;
+ this._startLobbyTimer();
+ console.log(`[ArenaRoom] Lobby for round ${this.state.roundNumber}`);
+ }
+
+ _endGame() {
+ this.state.phase = "gameEnd";
+ // Find winner: last qualified player, or player with most checkpoints
+ let winner = "";
+ let best = -1;
+ this.state.players.forEach((p) => {
+ const score = p.isQualified ? 1000 : p.checkpointIndex;
+ if (score > best) { best = score; winner = p.name; }
+ });
+ this.state.winnerName = winner;
+ this.broadcast("gameEnd", { winner });
+ console.log(`[ArenaRoom] Game over — winner: ${winner}`);
+ }
+
+ // ─── Race mode ──────────────────────────────────────────────────────
+
+ _endRaceTimeout() {
+ // Eliminate anyone who hasn't qualified
+ this.state.players.forEach((p, id) => {
+ if (!p.isQualified && !p.isEliminated) {
+ this._eliminatePlayer(id, "timeout");
+ }
+ });
+ this._endRound();
+ }
+
+ // ─── Survival mode ──────────────────────────────────────────────────
+
+ _startSurvivalRise() {
+ console.log(`[ArenaRoom] DeathZone starts rising`);
+ this._survivalInterval = setInterval(() => {
+ this.state.deathZoneY += SURVIVAL_RISE_RATE * (16 / 1000);
+ if (this.state.deathZoneY > SURVIVAL_MAX_Y) {
+ this.state.deathZoneY = SURVIVAL_MAX_Y;
+ }
+ }, 16);
+ }
+
+ // ─── Teams mode ─────────────────────────────────────────────────────
+
+ _startTeamsScoring() {
+ this._teamInterval = setInterval(() => {
+ let redInZone = 0;
+ let blueInZone = 0;
+ this._inZonePlayers.forEach((id) => {
+ const p = this.state.players.get(id);
+ if (!p || p.isEliminated) return;
+ if (p.team === 1) redInZone++;
+ else if (p.team === 2) blueInZone++;
+ });
+ if (redInZone > blueInZone) this.state.teamScoreRed = Math.min(this.state.teamScoreRed + 1, 32767);
+ else if (blueInZone > redInZone) this.state.teamScoreBlue = Math.min(this.state.teamScoreBlue + 1, 32767);
+ }, 1000);
+ }
+
+ _endTeamsRound() {
+ // Eliminate losing team
+ const redWins = this.state.teamScoreRed >= this.state.teamScoreBlue;
+ const losingTeam = redWins ? 2 : 1;
+ this.state.players.forEach((p, id) => {
+ if (p.team === losingTeam && !p.isEliminated) {
+ this._eliminatePlayer(id, "teams_lost");
+ } else if (!p.isEliminated) {
+ this._qualifyPlayer(id, "teams_won");
+ }
+ });
+ this._endRound();
+ }
+
+ // ─── Elimination helpers ─────────────────────────────────────────────
+
+ _eliminatePlayer(sessionId, reason) {
+ const player = this.state.players.get(sessionId);
+ if (!player || player.isEliminated || player.isQualified) return;
+ player.isEliminated = true;
+ this._updatePlayersAlive();
+ this.broadcast("eliminated", { sessionId, name: player.name, reason });
+ console.log(`[ArenaRoom] ${player.name} (${sessionId}) eliminated: ${reason}`);
+ this._checkRoundEndCondition();
+ }
+
+ _qualifyPlayer(sessionId, reason) {
+ const player = this.state.players.get(sessionId);
+ if (!player || player.isQualified || player.isEliminated) return;
+ player.isQualified = true;
+ this._updatePlayersAlive();
+ this.broadcast("qualified", { sessionId, name: player.name });
+ console.log(`[ArenaRoom] ${player.name} (${sessionId}) qualified: ${reason}`);
+
+ if (this.state.gameMode === "race") {
+ const aliveCount = this._getAliveCount();
+ const totalActive = this._getActiveCount();
+ const qualifiedCount = this._getQualifiedCount();
+ // Eliminate once qualify_ratio reached
+ const toQualify = Math.ceil(totalActive * QUALIFY_RATIO);
+ if (qualifiedCount >= toQualify) {
+ this.state.players.forEach((p, id) => {
+ if (!p.isQualified && !p.isEliminated) {
+ this._eliminatePlayer(id, "too_slow");
+ }
+ });
+ this._endRound();
+ }
+ } else if (this.state.gameMode === "survival") {
+ // In survival: only 1 qualifies (last one), rest get eliminated by zone
+ this._checkRoundEndCondition();
+ }
+ }
+
+ _checkRoundEndCondition() {
+ if (this.state.phase !== "playing") return;
+ const alive = this._getAliveCount();
+ const qualified = this._getQualifiedCount();
+ const total = this._getActiveCount();
+
+ if (this.state.gameMode === "survival") {
+ if (alive <= 1) {
+ // Qualify the last survivor
+ this.state.players.forEach((p, id) => {
+ if (!p.isEliminated && !p.isQualified) {
+ this._qualifyPlayer(id, "last_survivor");
+ }
+ });
+ this._endRound();
+ }
+ } else if (alive === 0 || alive + qualified >= total) {
+ this._endRound();
+ }
+ }
+
+ _getAliveCount() {
+ let n = 0;
+ this.state.players.forEach((p) => { if (!p.isEliminated && !p.isQualified) n++; });
+ return n;
+ }
+
+ _getQualifiedCount() {
+ let n = 0;
+ this.state.players.forEach((p) => { if (p.isQualified) n++; });
+ return n;
+ }
+
+ _getActiveCount() {
+ return this.state.players.size;
+ }
+
+ _updatePlayersAlive() {
+ this.state.playersAlive = this._getAliveCount();
+ }
+
+ _clearAllTimers() {
+ if (this._phaseTimer) { clearTimeout(this._phaseTimer); this._phaseTimer = null; }
+ if (this._lobbyTimer) { clearTimeout(this._lobbyTimer); this._lobbyTimer = null; }
+ if (this._survivalInterval) { clearInterval(this._survivalInterval); this._survivalInterval = null; }
+ if (this._teamInterval) { clearInterval(this._teamInterval); this._teamInterval = null; }
+ }
+
+ // ─── Spawn helper ────────────────────────────────────────────────────
+
+ _findSpawnPosition() {
+ const MIN_DIST = 3.0;
+ const SPAWN_Y = 5;
+ const RANGE = 20;
+ const existing = [];
+ this.state.players.forEach((p) => existing.push({ x: p.x, z: p.z }));
+
+ if (existing.length === 0) {
+ return { x: (Math.random() - 0.5) * RANGE, y: SPAWN_Y, z: (Math.random() - 0.5) * RANGE };
}
- for (let attempt = 0; attempt < 10; attempt++) {
+ let best = { x: 0, y: SPAWN_Y, z: 0 };
+ let bestDist = 0;
+ for (let i = 0; i < 10; i++) {
const cx = (Math.random() - 0.5) * RANGE;
const cz = (Math.random() - 0.5) * RANGE;
- let minDist = Infinity;
- for (const p of existingPositions) {
- const dx = cx - p.x;
- const dz = cz - p.z;
- const d = Math.sqrt(dx * dx + dz * dz);
- if (d < minDist) minDist = d;
- }
- if (minDist >= MIN_DIST) {
- return { x: cx, y: SPAWN_Y, z: cz };
- }
- if (minDist > bestMinDist) {
- bestMinDist = minDist;
- bestPos = { x: cx, y: SPAWN_Y, z: cz };
+ let minD = Infinity;
+ for (const p of existing) {
+ const d = Math.sqrt((cx - p.x) ** 2 + (cz - p.z) ** 2);
+ if (d < minD) minD = d;
}
+ if (minD >= MIN_DIST) return { x: cx, y: SPAWN_Y, z: cz };
+ if (minD > bestDist) { bestDist = minD; best = { x: cx, y: SPAWN_Y, z: cz }; }
}
-
- return bestPos;
+ return best;
}
}
diff --git a/rolld_backend/game/src/schema/GameState.js b/rolld_backend/game/src/schema/GameState.js
index f25be27..a8e610f 100644
--- a/rolld_backend/game/src/schema/GameState.js
+++ b/rolld_backend/game/src/schema/GameState.js
@@ -21,6 +21,12 @@ class Player extends Schema {
this.avx = 0;
this.avy = 0;
this.avz = 0;
+ // Game state
+ this.isEliminated = false;
+ this.isQualified = false;
+ this.isReady = false;
+ this.team = 0;
+ this.checkpointIndex = 0;
}
}
@@ -43,17 +49,42 @@ defineTypes(Player, {
avx: "float32",
avy: "float32",
avz: "float32",
+ isEliminated: "boolean",
+ isQualified: "boolean",
+ isReady: "boolean",
+ team: "int8",
+ checkpointIndex: "int8",
});
class GameState extends Schema {
constructor() {
super();
this.players = new MapSchema();
+ this.phase = "lobby";
+ this.countdown = 0;
+ this.roundNumber = 1;
+ this.totalRounds = 3;
+ this.playersAlive = 0;
+ this.gameMode = "race";
+ this.deathZoneY = -50;
+ this.teamScoreRed = 0;
+ this.teamScoreBlue = 0;
+ this.winnerName = "";
}
}
defineTypes(GameState, {
players: { map: Player },
+ phase: "string",
+ countdown: "float32",
+ roundNumber: "int8",
+ totalRounds: "int8",
+ playersAlive: "int8",
+ gameMode: "string",
+ deathZoneY: "float32",
+ teamScoreRed: "int16",
+ teamScoreBlue: "int16",
+ winnerName: "string",
});
module.exports = { GameState, Player };