This commit is contained in:
Bandwidth
2026-04-26 20:49:32 +02:00
parent e0b808faed
commit d886f97e14
66 changed files with 8327 additions and 933 deletions

View File

@@ -42,14 +42,26 @@ public class GameManager : MonoBehaviour
[Header("Lobby Settings")]
public double pendingRadius = 500;
public int pendingImpostorCount = 1;
public int pendingTaskCount = 5;
public int pendingTaskCount = 5;
/// <summary>
/// P13b/c: full settings overrides accumulated by HostLobbyUI before the
/// host taps "Create". Null = host didn't change anything beyond the three
/// flat fields above; server falls through to its current defaults for
/// every field. Each field is independently nullable so the host can
/// opt into changing only what they care about.
/// </summary>
public GameSettingsOverrides pendingSettings;
[Header("Task Minigames (round-robin)")]
// Names MUST match the scene file names in Assets/Scenes (case-sensitive)
// and each one MUST be enabled in EditorBuildSettings, or LoadSceneAsync
// will silently fail and the task button will appear dead.
[SerializeField] public string[] minigameScenes = {
"MiniGame-Kabely",
"MiniGame-InsertKeys",
"MiniGame-Kabely V10",
"MiniGame-insertkeys",
"MiniGame-FlappyBird",
"MiniGame-ThrowInHole"
"MiniGame-ThrowInHole",
"MiniGame-Satelit"
};
[Header("Debug")]
@@ -94,6 +106,13 @@ public class GameManager : MonoBehaviour
networkSubsystem.OpenConnection();
// Start GPS immediately at app launch. Acquiring a fix on a cold
// device can take 5-30 seconds; if we wait until CreateLobby is
// pressed, the lobby will be seeded with bad coords. Starting here
// means the user's normal navigation through the menus gives the
// GPS subsystem time to settle.
inputSubsystem?.EnsureGPSStarted();
// Load main menu after GameManager is ready
if (!string.IsNullOrEmpty(firstMenuScene))
SceneManager.LoadScene(firstMenuScene, LoadSceneMode.Single);
@@ -164,11 +183,18 @@ public class GameManager : MonoBehaviour
}
// ── Wire canvases (after HUD is built) ──
uiSubsystem?.BindClientScene(
FindCanvas(CanvasNameJoinCreate),
FindCanvas(CanvasNameInLobby),
FindCanvas(CanvasNameLoading),
FindCanvas(CanvasNameGame));
// Apply our standard CanvasScaler (1080x1920 reference, match=0.5)
// to every canvas in the scene before binding so layouts scale
// identically across phones and tablets without per-device tweaks.
var cJoin = FindCanvas(CanvasNameJoinCreate);
var cLobby = FindCanvas(CanvasNameInLobby);
var cLoad = FindCanvas(CanvasNameLoading);
var cGame = FindCanvas(CanvasNameGame);
InGameHUDBuilder.ConfigureCanvasScaler(cJoin);
InGameHUDBuilder.ConfigureCanvasScaler(cLobby);
InGameHUDBuilder.ConfigureCanvasScaler(cLoad);
InGameHUDBuilder.ConfigureCanvasScaler(cGame);
uiSubsystem?.BindClientScene(cJoin, cLobby, cLoad, cGame);
// ── Wire map center point and player capsule ──
var mapCenter = FindGO("MapCenterPoint");
@@ -229,6 +255,14 @@ public class GameManager : MonoBehaviour
bool isImpostor = gameClient?.MyRole == PlayerRole.Impostor;
// P13b: pull per-lobby distances from the server-snapshotted settings
// instead of hardcoding 5m for every check. ?? fallback keeps the
// pre-P13b behavior on old server builds that don't ship settings.
var settings = networkSubsystem?.State?.Settings;
double reportDist = settings?.ReportDistanceM ?? 5.0;
double emergencyDist = settings?.EmergencyMeetingCallRadiusM ?? 5.0;
double killDist = settings?.KillDistanceM ?? 5.0;
// 1. Nearby task → USE
var nearbyTask = taskSubsystem?.NearbyTask;
if (nearbyTask != null && !isImpostor)
@@ -240,7 +274,7 @@ public class GameManager : MonoBehaviour
// 2. Nearby body → REPORT
if (!uiSubsystem.IsCommsBlackout)
{
var nearbyBody = gameClient?.FindNearbyBody(5.0);
var nearbyBody = gameClient?.FindNearbyBody(reportDist);
if (nearbyBody != null)
{
gameClient.ReportBody(nearbyBody.BodyId);
@@ -251,7 +285,7 @@ public class GameManager : MonoBehaviour
if (gameClient?.CurrentLobbyState?.MapData != null)
{
double distToCenter = gameClient.MyPosition.DistanceTo(gameClient.CurrentLobbyState.MapData.Center);
if (distToCenter <= 5.0)
if (distToCenter <= emergencyDist)
{
gameClient.CallEmergencyMeeting();
return;
@@ -262,7 +296,7 @@ public class GameManager : MonoBehaviour
// 4. Impostor kill
if (isImpostor && _killCooldownSeconds <= 0)
{
var targetUuid = gameClient?.FindNearbyPlayer(5.0);
var targetUuid = gameClient?.FindNearbyPlayer(killDist);
if (!string.IsNullOrEmpty(targetUuid))
{
gameClient.Kill(targetUuid);
@@ -295,14 +329,29 @@ public class GameManager : MonoBehaviour
// Called by HostLobbyUI
public void CreateLobbyButton()
{
// Use current GPS position if available, else hardcoded fallback
double lat = 50.7727264, lon = 15.0719876;
if (inputSubsystem?.LastKnownPosition != null)
// Refuse to create a lobby without a real GPS fix. The previous
// behavior of silently using a hardcoded Czechia fallback meant the
// game always started at the same place no matter where the host was,
// and the player capsule would spawn miles away in coordinate space
// because they're at their real GPS while the map was built around
// the fallback. Both bugs share this single gate.
if (inputSubsystem?.LastKnownPosition == null)
{
lat = inputSubsystem.LastKnownPosition.Value.Lat;
lon = inputSubsystem.LastKnownPosition.Value.Lon;
// testMode bypasses the GPS gate entirely so debug runs still work.
if (!testMode)
{
Debug.LogWarning("[GameManager] CreateLobby blocked: no GPS fix yet. " +
"Make sure location permission is granted and you have signal.");
uiSubsystem?.ShowToast("Waiting for GPS fix... grant location permission and try again.");
inputSubsystem?.EnsureGPSStarted();
return;
}
}
networkSubsystem.CreateLobby(lat, lon, pendingRadius, pendingImpostorCount, pendingTaskCount);
var pos = inputSubsystem?.LastKnownPosition;
double lat = pos?.Lat ?? 0;
double lon = pos?.Lon ?? 0;
networkSubsystem.CreateLobby(lat, lon, pendingRadius, pendingImpostorCount, pendingTaskCount, pendingSettings);
if (testMode) StartCoroutine(ConnectTestClients());
}

View File

@@ -78,6 +78,21 @@ namespace Subsystems
/// <summary>Called from OnSceneLoaded when Client.unity loads so the
/// Player capsule (which lives in Client.unity) can be wired at runtime.</summary>
public void SetPlayerObject(GameObject player) { _player = player; }
/// <summary>
/// Kick off GPS initialization if it hasn't started yet. Safe to call
/// repeatedly. Hosts must call this from the lobby setup screen so
/// that by the time they click "Create Lobby" we have a real GPS
/// fix to use as the play-area center, instead of falling back to
/// the hardcoded coordinates.
/// </summary>
public void EnsureGPSStarted()
{
if (_testMode) return;
if (_coroutineHost == null) return;
if (_GPSState == GPSState.Uninitialized)
_coroutineHost.StartCoroutine(InitiallizeGPS());
}
public void positionCheck()
{
var state = _gameClient?.CurrentLobbyState;

View File

@@ -3,6 +3,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -65,6 +66,36 @@ namespace Subsystems{
private AreaSettings _areaSettings;
private const float _metersPerUnit = 1f;
// ── Layer Y separation (single source of truth for vertical stacking) ───
// Areas at the bottom, paths above areas, buildings extruded upward from
// their own base, POIs floating well above everything else. Z-fighting
// happens when adjacent geometry shares a Y; these constants keep each
// logical layer at a distinct elevation.
private const float kAreaBaseY = 0.10f;
private const float kPathY = 0.30f;
private const float kBuildingBaseY = 0.50f;
private const float kPoiY = 2.00f;
// Render-queue forcing was tried in P3 to disambiguate same-Y geometry
// but turned out to be the cause of the "blank map in mobile game view,
// fine in scene view" regression: forcing transparent-class shaders
// (default queue 3000+) into the Geometry range (2000-2150) breaks
// their depth-write/blend assumptions on mobile shader paths. The
// editor's scene view masks it because it uses different render paths
// and post-process is off there. Queue forcing removed in P8;
// disambiguation is now via Y-layering + per-area Y-stagger alone,
// which the depth buffer resolves correctly even on weak mobile GPUs.
// ── Marker sizing (top-down camera, units = meters) ─────────────────
// The camera's orthographic size pushes "1 meter" to a small fraction
// of the screen. Markers need to be visibly larger than buildings'
// footprints for instant recognition.
private const float kMarkerHeight = 8f; // pillar height
private const float kMarkerRadius = 3f; // pillar radius (cylinder X/Z)
private const float kMarkerY = 4f; // base Y so pillar centers ~mid-height
private const float kLabelY = 9f; // text label sits above pillar top
private const float kLabelFontSize = 14f; // 3D text size in world units
// Runtime marker collections
private Dictionary<string, GameObject> _taskMarkers = new Dictionary<string, GameObject>();
private Dictionary<string, GameObject> _bodyMarkers = new Dictionary<string, GameObject>();
@@ -131,7 +162,133 @@ namespace Subsystems{
GameObject a = BuildAreaMesh(area);
a.transform.parent = areaRoot.transform;
}
//TODO: POIs
GameObject poiRoot = new GameObject("POIs");
poiRoot.transform.parent = _mapCenterPoint.transform;
int poiCount = 0;
foreach (var poi in _gameClient.CurrentLobbyState.MapData.GetPOIs())
{
GameObject p = BuildPOIMarker(poi);
if (p != null) { p.transform.parent = poiRoot.transform; poiCount++; }
}
// Diagnostic - if the user reports "map missing in game view" but
// the counts here are non-zero, the bug is camera/culling related,
// not a build issue.
int buildings = _gameClient.CurrentLobbyState.MapData.GetBuildings()?.Count ?? 0;
int paths = _gameClient.CurrentLobbyState.MapData.GetPathways()?.Count ?? 0;
int areas = _gameClient.CurrentLobbyState.MapData.GetAreas()?.Count ?? 0;
Debug.Log($"[Map] BuildMap done: {buildings} buildings, {paths} paths, " +
$"{areas} areas, {poiCount} POIs. MapCenterPoint={_mapCenterPoint.name} " +
$"layer={_mapCenterPoint.layer} pos={_mapCenterPoint.transform.position} " +
$"scale={_mapCenterPoint.transform.localScale}");
}
/// <summary>
/// Build a tall, brightly-colored pillar for a Point of Interest with
/// a 3D text label above it (e.g. "FOOD", "SHOP"). The label is laid
/// flat on the XZ plane facing UP so it reads correctly under the
/// orthogonal top-down camera.
/// </summary>
private GameObject BuildPOIMarker(MapPOI poi)
{
if (poi == null) return null;
var color = ColorForPOI(poi.POIType);
string label = LabelForPOI(poi.POIType);
var pos = poi.Location.ToLocalVector3(_centerPosition);
return CreateMarkerWithLabel($"POI_{poi.POIType}_{poi.Id}", pos, color, label);
}
/// <summary>
/// Shared marker builder: tall colored cylinder pillar + 3D text label
/// above it. Used by POIs, tasks, bodies, and sabotage stations so
/// they all share a visual language ("colored pillar with a name").
/// </summary>
private GameObject CreateMarkerWithLabel(string name, Vector3 worldPos, Color color, string label)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
go.name = name;
// Strip the auto-added collider - markers are visual only.
var col = go.GetComponent<Collider>();
if (col != null) UnityEngine.Object.Destroy(col);
go.transform.position = worldPos + Vector3.up * kMarkerY;
// Cylinder's default unit is 2 tall, 1 wide. Scale Y by half of
// kMarkerHeight (built-in is 2 units), X/Z by kMarkerRadius.
go.transform.localScale = new Vector3(kMarkerRadius, kMarkerHeight * 0.5f, kMarkerRadius);
var mr = go.GetComponent<MeshRenderer>();
if (mr != null)
{
// One .material access -> single clone of the primitive's
// default mat. Don't touch renderQueue (P3 regression cause).
var inst = mr.material;
if (inst != null) inst.color = color;
}
// 3D text label - lays flat on top of the pillar facing up.
// Parented to the marker so it follows position changes.
var labelGO = new GameObject("Label");
labelGO.transform.SetParent(go.transform, worldPositionStays: false);
// Local Y offset: pillar's local scale Y is kMarkerHeight/2, but
// the cylinder primitive is 2 units tall in local space, so its
// top is at local +1. Label sits a hair above that.
labelGO.transform.localPosition = new Vector3(0, 1.05f, 0);
// Rotate 90 around X so the text quad's normal points +Y (toward
// the top-down camera). The default TMP forward is +Z.
labelGO.transform.localRotation = Quaternion.Euler(90f, 0f, 0f);
// Compensate for the cylinder's non-uniform parent scale so the
// text size in world units matches kLabelFontSize regardless of
// how the pillar was scaled.
labelGO.transform.localScale = new Vector3(
1f / kMarkerRadius,
1f / (kMarkerHeight * 0.5f),
1f / kMarkerRadius);
var tmp = labelGO.AddComponent<TextMeshPro>();
tmp.text = label;
tmp.fontSize = kLabelFontSize;
tmp.color = Color.white;
tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center;
tmp.outlineColor = Color.black;
tmp.outlineWidth = 0.25f;
// Reasonable bounds so the text mesh isn't auto-clipped.
var rt = tmp.rectTransform;
rt.sizeDelta = new Vector2(20, 4);
return go;
}
private static Color ColorForPOI(MapPOIType type)
{
switch (type)
{
case MapPOIType.FoodDrink: return new Color(1.00f, 0.55f, 0.00f); // orange
case MapPOIType.Shop: return new Color(0.20f, 0.60f, 1.00f); // blue
case MapPOIType.Health: return new Color(0.96f, 0.27f, 0.27f); // red
case MapPOIType.Transport: return new Color(0.85f, 0.85f, 0.20f); // yellow
case MapPOIType.Culture: return new Color(0.65f, 0.30f, 0.95f); // purple
case MapPOIType.Landmark: return new Color(0.95f, 0.85f, 0.40f); // gold
case MapPOIType.Recreation: return new Color(0.30f, 0.85f, 0.30f); // green
default: return new Color(0.75f, 0.75f, 0.80f); // muted grey
}
}
private static string LabelForPOI(MapPOIType type)
{
switch (type)
{
case MapPOIType.FoodDrink: return "FOOD";
case MapPOIType.Shop: return "SHOP";
case MapPOIType.Health: return "HEALTH";
case MapPOIType.Transport: return "TRANSIT";
case MapPOIType.Culture: return "CULTURE";
case MapPOIType.Landmark: return "LANDMARK";
case MapPOIType.Recreation: return "PARK";
default: return "POI";
}
}
void ClearChildren()
{
@@ -148,9 +305,12 @@ namespace Subsystems{
{
var building = new GameObject($"Building_{b.Name ?? "Unknown"}");
// Výpočet středu budovy
// Výpočet středu budovy. Lift the base above kPathY so building
// walls visibly extrude *upward* from above the road/area layer
// instead of starting at ground (which made them clip into paved
// areas that share their footprint).
Vector3 center = CalculatePolygonCenter(b.Outline);
building.transform.position = center;
building.transform.position = center + Vector3.up * kBuildingBaseY;
// Vytvoření mesh pro budovu
MeshFilter meshFilter = building.AddComponent<MeshFilter>();
@@ -181,8 +341,12 @@ namespace Subsystems{
meshFilter.mesh = mesh;
//TODO: material by type
// Použijeme barvu podle typu budovy
meshRenderer.material = mat;
// Použijeme barvu podle typu budovy. Use sharedMaterial to keep
// the project's Material asset reference - no clone, no leak.
// Y-position alone disambiguates building geometry from area/path
// layers; we don't need renderQueue overrides (which broke mobile
// rendering for transparent-class shaders in P3).
meshRenderer.sharedMaterial = mat;
// Přidání collideru pro interakci
building.AddComponent<MeshCollider>();
@@ -241,15 +405,19 @@ namespace Subsystems{
break;
}
line.material = mat;
// sharedMaterial avoids the LineRenderer cloning the project's
// shared path Material on every BuildMap call. Queue overrides
// dropped (P3 mobile-render regression cause).
line.sharedMaterial = mat;
line.widthMultiplier = width;
// Nastavení bodů cesty
// Nastavení bodů cesty - kPathY sits above all area polygons but
// below building bases, so paths visibly run on top of areas.
line.positionCount = w.Points.Count;
for (int i = 0; i < w.Points.Count; i++)
{
Vector3 pos = w.Points[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center);
pos.y = 0.1f; // Mírně nad zemí
pos.y = kPathY;
line.SetPosition(i, pos);
}
return path;
@@ -292,13 +460,58 @@ namespace Subsystems{
break;
}
meshRenderer.material = mat;
// sharedMaterial: no per-area material clone. Render-queue forcing
// dropped in P8 (caused mobile-render regression). The Y-stagger
// below alone now drives "smaller polygon on top of larger one"
// depth ordering - which is what the depth buffer was always
// designed to do, and works on mobile GPUs with weak precision
// because the stagger spread (0.04 units) is well above any
// reasonable depth-buffer epsilon.
meshRenderer.sharedMaterial = mat;
area.transform.position = new Vector3(0, 0.05f, 0); // Těsně nad zemí
// Y stagger: smaller polygons sit a hair higher than larger ones,
// so depth-test draws them on top of bigger area polygons they sit
// inside (e.g. a playground inside a park). Total spread is 0.04
// units - visually invisible but plenty for the depth buffer.
float yStagger = ComputeAreaYStagger(a.Outline);
area.transform.position = new Vector3(0, kAreaBaseY + yStagger, 0);
return area;
}
//TODO: POIs
/// <summary>
/// Returns a non-negative size proxy used to bucket areas by footprint.
/// Larger polygons return higher numbers; used inversely for queue/Y.
/// </summary>
private float AreaSizeBucket(List<Position> outline)
{
if (outline == null || outline.Count < 3) return 1f;
// Cheap bbox area in lat-lon space scaled by 1e6 - we only need a
// monotonic ordering, not a real geographic area.
double minLat = outline[0].Lat, maxLat = outline[0].Lat;
double minLon = outline[0].Lon, maxLon = outline[0].Lon;
for (int i = 1; i < outline.Count; i++)
{
if (outline[i].Lat < minLat) minLat = outline[i].Lat;
if (outline[i].Lat > maxLat) maxLat = outline[i].Lat;
if (outline[i].Lon < minLon) minLon = outline[i].Lon;
if (outline[i].Lon > maxLon) maxLon = outline[i].Lon;
}
double bbox = (maxLat - minLat) * (maxLon - minLon) * 1e6;
return (float)System.Math.Max(0.001, bbox);
}
/// <summary>
/// Smaller areas get a higher Y so they render on top of any larger
/// area they overlap. Returns a value in [0, 0.04] units.
/// </summary>
private float ComputeAreaYStagger(List<Position> outline)
{
float bucket = AreaSizeBucket(outline);
// Inverse mapping: huge area -> 0, tiny area -> 0.04.
float t = Mathf.Clamp01(1f - bucket / (bucket + 50f));
return t * 0.04f;
}
#endregion
#region Polygon Utils
private Vector3 CalculatePolygonCenter(List<Position> points)
@@ -310,19 +523,52 @@ namespace Subsystems{
}
return center / points.Count;
}
/// <summary>
/// Signed XZ shoelace area for a polygon expressed in local Vector3.
/// Positive = CCW (Unity left-handed Y-up: upward-facing normal),
/// negative = CW (downward-facing normal -> top face invisible from
/// above unless we reverse the winding before triangulating).
/// </summary>
private static float PolygonSignedAreaXZ(List<Vector3> verts)
{
float area = 0f;
int n = verts.Count;
for (int i = 0; i < n; i++)
{
var a = verts[i];
var b = verts[(i + 1) % n];
area += (b.x - a.x) * (a.z + b.z);
}
return area * 0.5f;
}
private Mesh CreateExtrudedPolygonMesh(List<Position> outline, float height)
{
Mesh mesh = new Mesh();
// Reject degenerates - Recast/Overpass can hand back 1-2 vertex
// outlines on broken ways. Empty mesh -> renderer draws nothing,
// safer than a malformed triangle list.
if (outline == null || outline.Count < 3) return mesh;
// Convert to local space first so we can run a winding check, then
// reverse if needed. Without this, CW outlines from Overpass yield
// downward-facing top normals and the building roof is invisible
// from the top-down map camera.
int vertexCount = outline.Count;
var localVerts = new List<Vector3>(vertexCount);
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++)
localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center);
if (PolygonSignedAreaXZ(localVerts) < 0f)
localVerts.Reverse();
// Vertices - spodní a horní podstava
Vector3[] vertices = new Vector3[vertexCount * 2];
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++)
{
Vector3 pos = outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center;
Vector3 pos = localVerts[i];
vertices[i] = pos; // Spodní
vertices[i + vertexCount] = pos + Vector3.up * height; // Horní
}
@@ -366,25 +612,30 @@ namespace Subsystems{
{
Mesh mesh = new Mesh();
int vertexCount = outline.Count;
Vector3[] vertices = new Vector3[vertexCount];
Vector3 center = CalculatePolygonCenter(outline);
// Reject degenerates (matches CreateExtrudedPolygonMesh).
if (outline == null || outline.Count < 3) return mesh;
int vertexCount = outline.Count;
var localVerts = new List<Vector3>(vertexCount);
Vector3 center = CalculatePolygonCenter(outline);
for (int i = 0; i < vertexCount; i++)
{
vertices[i] = outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center;
}
localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center);
// Force CCW so RecalculateNormals produces an upward-facing normal.
// CW polygons from Overpass would otherwise render as black voids
// when the top-down camera looks at their back face.
if (PolygonSignedAreaXZ(localVerts) < 0f)
localVerts.Reverse();
Vector3[] vertices = localVerts.ToArray();
// Triangulace - fan pattern
List<int> triangles = new List<int>();
if (vertexCount >= 3)
for (int i = 1; i < vertexCount - 1; i++)
{
for (int i = 1; i < vertexCount - 1; i++)
{
triangles.Add(0);
triangles.Add(i);
triangles.Add(i + 1);
}
triangles.Add(0);
triangles.Add(i);
triangles.Add(i + 1);
}
mesh.vertices = vertices;
@@ -405,20 +656,19 @@ namespace Subsystems{
if (md != null) _centerPosition = md.Center;
}
if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) return;
var taskColor = new Color(0.20f, 0.95f, 0.55f); // bright green - "GO HERE"
foreach (var task in tasks)
{
if (_taskMarkers.ContainsKey(task.TaskId)) continue;
var go = GameObject.CreatePrimitive(PrimitiveType.Sphere);
go.name = $"Task_{task.TaskId}";
var pos = task.Location.ToLocalVector3(_centerPosition);
var go = CreateMarkerWithLabel($"Task_{task.TaskId}", pos, taskColor, "TASK");
go.transform.parent = _mapCenterPoint.transform;
go.transform.position = task.Location.ToLocalVector3(_centerPosition) + Vector3.up * 1f; // Raised
go.transform.localScale = Vector3.one * 8f; // Bigger
var mr = go.GetComponent<MeshRenderer>();
if (mr) mr.material.color = Color.yellow;
// Pulsing point light so the task literally glows on the map.
var light = go.AddComponent<Light>();
light.color = Color.yellow;
light.intensity = 2;
light.range = 5;
light.color = taskColor;
light.intensity = 3f;
light.range = 25f;
_taskMarkers[task.TaskId] = go;
}
}
@@ -436,14 +686,12 @@ namespace Subsystems{
{
if (_mapCenterPoint == null) return;
if (_bodyMarkers.ContainsKey(bodyId)) return;
var go = GameObject.CreatePrimitive(PrimitiveType.Capsule);
go.name = $"Body_{bodyId}";
var pos = location.ToLocalVector3(_centerPosition);
// Bright red pillar with "BODY" label - players need to see this
// from across the map to call it in.
var go = CreateMarkerWithLabel($"Body_{bodyId}", pos,
new Color(0.96f, 0.18f, 0.18f), "BODY");
go.transform.parent = _mapCenterPoint?.transform;
go.transform.position = location.ToLocalVector3(_centerPosition) + Vector3.up * 0.15f;
go.transform.localScale = new Vector3(0.3f, 0.5f, 0.3f);
go.transform.rotation = Quaternion.Euler(90, 0, 0); // lying down
var mr = go.GetComponent<MeshRenderer>();
if (mr) mr.material.color = Color.red;
_bodyMarkers[bodyId] = go;
}
@@ -489,15 +737,20 @@ namespace Subsystems{
public void CreateSabotageMarkers(List<RepairStationInfo> stations)
{
var color = new Color(1.0f, 0.55f, 0.0f); // strong orange = repair urgency
foreach (var station in stations)
{
var go = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
go.name = $"Sabotage_{station.StationId}";
var pos = station.Location.ToLocalVector3(_centerPosition);
var go = CreateMarkerWithLabel($"Sabotage_{station.StationId}", pos,
color, "REPAIR");
go.transform.parent = _mapCenterPoint?.transform;
go.transform.position = station.Location.ToLocalVector3(_centerPosition) + Vector3.up * 1f;
go.transform.localScale = new Vector3(0.5f, 2f, 0.5f);
var mr = go.GetComponent<MeshRenderer>();
if (mr) mr.material.color = new Color(1f, 0.5f, 0f); // orange
// Repair stations also pulse light so impostors and crew see
// the urgency from across the map.
var light = go.AddComponent<Light>();
light.color = color;
light.intensity = 4f;
light.range = 30f;
_sabotageMarkers.Add(go);
}
}

View File

@@ -31,6 +31,15 @@ namespace Subsystems
public async void OpenConnection()
{
// Snapshot the lobby we believed we were in BEFORE the new connect
// attempt. If the client SDK preserved it across a transient drop
// (P9 fix), this is non-null and we'll send a Reconnect message
// post-handshake to re-associate with the lobby on the server side.
// Without it, the next CastVote / TaskComplete / etc. would arrive
// on a fresh connection the server doesn't recognize and bounce
// with NOT_IN_LOBBY.
var rejoinLobbyId = _gameClient.LobbyId;
int retries = 0;
int delayMs = 5000;
while (true)
@@ -40,6 +49,15 @@ namespace Subsystems
if (state.Result)
{
Debug.Log("Connected to server.");
// Re-attach to the prior lobby if we had one. Server-side
// HandleReconnectAsync will replay missed events and ack
// with a ReconnectResponse carrying the snapshot.
if (!string.IsNullOrEmpty(rejoinLobbyId))
{
Debug.Log($"Re-associating with lobby {rejoinLobbyId} after reconnect.");
_gameClient.Reconnect(rejoinLobbyId);
}
break;
}
retries++;
@@ -63,12 +81,25 @@ namespace Subsystems
_gameClient.OnGameEvent += OnGameEvent;
}
private void OnConnected() => Debug.Log("Successfully connected to the server.");
private void OnConnected()
{
Debug.Log("Successfully connected to the server.");
// Tear the reconnect overlay down once the socket is healthy.
// No-op if it wasn't shown.
_manager?.uiSubsystem?.HideReconnecting();
}
private void OnError(string e) => Debug.LogError($"Network error: {e}");
private void OnDisconnected(string reason)
{
Debug.Log($"Disconnected: {reason}");
// Show the reconnect overlay only if the user is mid-game; we
// don't want it flashing during a clean shutdown ("Disposed") or
// before a real game has started.
if (reason != "Disposed" && State.Phase != GamePhase.Lobby)
_manager?.uiSubsystem?.ShowReconnecting();
if (reason != "Disposed" && _manager != null)
_manager.StartCoroutine(ReconnectAfterDelay(3f));
}
@@ -93,6 +124,9 @@ namespace Subsystems
case "PositionBroadcast":
HandlePositionBroadcast(message as PositionBroadcast);
break;
case "Error":
HandleErrorMessage(message as ErrorMessage);
break;
case "Ack":
case "GameEvent":
break;
@@ -102,6 +136,27 @@ namespace Subsystems
}
}
/// <summary>
/// P9 defensive path: if the server tells us NOT_IN_LOBBY but we still
/// believe we have a lobby (LobbyId preserved across the transient
/// disconnect), the lobby association on the server's side of the new
/// connection is missing - typically a race between OpenConnection's
/// Reconnect call and an in-flight action message that beat it. Retry
/// the Reconnect; if the second attempt also bounces, the lobby really
/// is gone and we'll surface the error to the user.
/// </summary>
private void HandleErrorMessage(ErrorMessage err)
{
if (err == null) return;
Debug.Log($"Server error: code={err.ErrorCode} text={err.ErrorText}");
if (err.ErrorCode == "NOT_IN_LOBBY" && !string.IsNullOrEmpty(_gameClient.LobbyId))
{
Debug.Log($"NOT_IN_LOBBY but we still have LobbyId={_gameClient.LobbyId}; resending Reconnect.");
_gameClient.Reconnect(_gameClient.LobbyId);
}
}
private void OnGameEvent(GameEvent gameEvent)
{
// Always sync player list from lobby state after any event
@@ -149,6 +204,10 @@ namespace Subsystems
HandleMeetingStarted(gameEvent);
break;
case "PlayerArrivedAtMeeting":
HandlePlayerArrivedAtMeeting(gameEvent);
break;
case "PlayerVoted":
HandlePlayerVoted(gameEvent);
break;
@@ -169,15 +228,30 @@ namespace Subsystems
HandleSabotageStarted(gameEvent);
break;
case "RepairStarted":
HandleRepairStarted(gameEvent);
break;
case "RepairStopped":
HandleRepairStopped(gameEvent);
break;
case "SabotageRepaired":
case "SabotageMeltdown":
case "SabotageExpired":
State.ActiveSabotage = null;
State.ActiveRepairs.Clear();
_manager?.uiSubsystem?.HideSabotageTimer();
_manager?.mapSubsystem?.ClearSabotageMarkers();
break;
case "TaskStarted":
// Server now broadcasts when a player begins a task. Phase 1
// only acks; Phase 2/3 will surface this to other players.
break;
case "MapDataError":
Debug.LogError("Server could not generate map data.");
HandleMapDataError(gameEvent);
break;
default:
@@ -194,6 +268,10 @@ namespace Subsystems
if (message.Success)
{
Debug.Log($"Lobby created. Code: {message.JoinCode}");
// P13b: snapshot the server's authoritative settings into
// GameState so HUD / proximity code can read distances and
// cooldowns from a single source of truth instead of hardcodes.
State.Settings = _gameClient.CurrentLobbyState?.Settings;
SceneManager.LoadScene("create", LoadSceneMode.Single);
_manager?.uiSubsystem?.NotifyLobbyChanged();
}
@@ -209,7 +287,13 @@ namespace Subsystems
if (message.Success)
{
Debug.Log($"Joined lobby: {message.LobbyId}");
SceneManager.LoadScene("join loading", LoadSceneMode.Single);
// P13b: same settings snapshot path as host. Joiners read the
// server's snapshot taken at lobby creation; they cannot edit.
State.Settings = _gameClient.CurrentLobbyState?.Settings;
// Unified lobby: both host and joiners land on create.unity.
// LobbyDisplayUI handles the role split internally (start
// button for host, waiting text for joiners).
SceneManager.LoadScene("create", LoadSceneMode.Single);
_manager?.uiSubsystem?.NotifyLobbyChanged();
}
else
@@ -314,20 +398,52 @@ namespace Subsystems
var payload = evt.GetPayload<MeetingStartedPayload>();
if (payload == null) return;
State.Phase = GamePhase.Meeting;
State.ActiveMeeting = payload;
State.VotedPlayerIds = new HashSet<string>();
State.LastVoteResult = null;
State.Phase = GamePhase.Meeting;
State.ActiveMeeting = payload;
State.VotedPlayerIds = new HashSet<string>();
State.ArrivedPlayerIds = new HashSet<string>();
State.VoterTargets = new Dictionary<string, string>();
State.VoteTallies = new Dictionary<string, int>();
State.MyVoteTarget = null;
State.LastVoteResult = null;
SyncPlayersFromLobby();
_manager?.uiSubsystem?.ShowMeetingPanel(State.Players, payload);
}
private void HandlePlayerArrivedAtMeeting(GameEvent evt)
{
var payload = evt.GetPayload<PlayerArrivedAtMeetingPayload>();
if (payload == null) return;
State.ArrivedPlayerIds.Add(payload.ClientUuid);
}
private void HandlePlayerVoted(GameEvent evt)
{
var payload = evt.GetPayload<PlayerVotedPayload>();
if (payload == null) return;
// Server allows vote changes within a 2s rate limit, so we always
// overwrite the voter's previous target rather than appending.
string target = payload.TargetId ?? GameState.VoteSkip;
State.VotedPlayerIds.Add(payload.VoterId);
State.VoterTargets[payload.VoterId] = target;
RecomputeVoteTallies();
if (payload.VoterId == _gameClient.ClientUuid)
State.MyVoteTarget = target;
}
private void RecomputeVoteTallies()
{
State.VoteTallies.Clear();
foreach (var t in State.VoterTargets.Values)
{
if (string.IsNullOrEmpty(t)) continue;
State.VoteTallies.TryGetValue(t, out var count);
State.VoteTallies[t] = count + 1;
}
}
private void HandleVotingClosed(GameEvent evt)
@@ -358,16 +474,21 @@ namespace Subsystems
State.Phase = GamePhase.Ended;
State.GameEndData = payload;
// If the round ended while the meeting/vote-result overlay was
// still up (e.g. ejection won the game outright), the auto-close
// coroutine would otherwise fire 5s later and tear down the
// meeting panel while the GameEndPanel sits on top - leaving a
// glimpse of the dead overlay during the transition.
_manager?.uiSubsystem?.HideMeetingPanel();
_manager?.uiSubsystem?.ShowGameEndPanel(payload, _gameClient.ClientUuid);
}
private void HandleReturnedToLobby()
{
State.Phase = GamePhase.Lobby;
if (_gameClient.IsOwner)
SceneManager.LoadScene("create", LoadSceneMode.Single);
else
SceneManager.LoadScene("join loading", LoadSceneMode.Single);
_manager?.uiSubsystem?.HideMeetingPanel();
// Unified lobby: regardless of role, return to create.unity.
SceneManager.LoadScene("create", LoadSceneMode.Single);
}
private void HandleSabotageStarted(GameEvent evt)
@@ -376,6 +497,7 @@ namespace Subsystems
if (payload == null) return;
State.ActiveSabotage = payload;
State.ActiveRepairs.Clear();
_manager?.mapSubsystem?.CreateSabotageMarkers(payload.RepairStations);
if (payload.Type == SabotageType.CriticalMeltdown && payload.Deadline.HasValue)
@@ -384,6 +506,34 @@ namespace Subsystems
_manager?.uiSubsystem?.SetCommsBlackout(true);
}
private void HandleRepairStarted(GameEvent evt)
{
var payload = evt.GetPayload<RepairStartedPayload>();
if (payload == null || string.IsNullOrEmpty(payload.StationId)) return;
State.ActiveRepairs.Add(payload.StationId);
}
private void HandleRepairStopped(GameEvent evt)
{
// A player abandoned a repair station mid-fix. The station is no
// longer counted as active for the simultaneous-repair coaching;
// the marker stays on the map until the sabotage resolves.
var payload = evt.GetPayload<RepairStoppedPayload>();
if (payload != null && !string.IsNullOrEmpty(payload.StationId))
State.ActiveRepairs.Remove(payload.StationId);
}
private void HandleMapDataError(GameEvent evt)
{
// Server failed to fetch Overpass data. Without this the loading
// screen would hang forever. Drop back to lobby and surface the
// failure so the player can re-host or try a different center.
Debug.LogError("[Network] Server could not generate map data.");
State.Phase = GamePhase.Lobby;
_manager?.uiSubsystem?.ShowToast("Map fetch failed. Returning to lobby.");
LeaveLobby();
}
private void HandlePositionBroadcast(PositionBroadcast broadcast)
{
if (broadcast == null) return;
@@ -407,9 +557,9 @@ namespace Subsystems
// ── Send helpers ──────────────────────────────────────────────────────
public void CreateLobby(double lat, double lon, double radius = 500, int impostorCount = 1, int taskCount = 5)
public void CreateLobby(double lat, double lon, double radius = 500, int impostorCount = 1, int taskCount = 5, GameSettingsOverrides settings = null)
{
_gameClient.CreateLobby(new Position(lat, lon), impostorCount, taskCount, null, radius);
_gameClient.CreateLobby(new Position(lat, lon), impostorCount, taskCount, null, radius, settings);
}
public void JoinLobby(string joinCode)

View File

@@ -29,7 +29,11 @@ namespace Subsystems
// Proximity state (checked every frame in UpdateProximity)
public GeoSus.Client.GameTask NearbyTask { get; private set; }
private const float ProximityRadius = 5f; // metres / Unity units
// P13b: per-check distances pulled from the server-snapshotted lobby
// settings (null-fallback to 5m matches the old hardcoded behavior).
// Different actions use different fields so a host can tune e.g. a
// long-range "spotter" task radius without also widening kill range.
private const float ProximityRadiusFallback = 5f;
public GameManager_Tasks(GameClient gameClient, string[] minigameScenes, MonoBehaviour host)
{
@@ -64,6 +68,16 @@ namespace Subsystems
{
if (_minigameOpen) return;
// P13b: distances now come from the per-lobby settings snapshot
// instead of one hardcoded 5m radius for everything. ?? fallback
// matches the old behavior when running against an old server.
var state = GameManager.Instance?.networkSubsystem?.State;
var settings = state?.Settings;
double taskDist = settings?.TaskStartDistanceM ?? ProximityRadiusFallback;
double reportDist = settings?.ReportDistanceM ?? ProximityRadiusFallback;
double emergencyDist = settings?.EmergencyMeetingCallRadiusM?? ProximityRadiusFallback;
double killDist = settings?.KillDistanceM ?? ProximityRadiusFallback;
NearbyTask = null;
var myPos = _gameClient.MyPosition;
if (myPos.Lat == 0 && myPos.Lon == 0) return;
@@ -72,7 +86,7 @@ namespace Subsystems
{
if (entry.Completed) continue;
double dist = myPos.DistanceTo(entry.ServerTask.Location);
if (dist <= ProximityRadius)
if (dist <= taskDist)
{
NearbyTask = entry.ServerTask;
break;
@@ -94,7 +108,7 @@ namespace Subsystems
// Check body proximity
if (!ui.IsCommsBlackout)
{
var body = _gameClient.FindNearbyBody(ProximityRadius);
var body = _gameClient.FindNearbyBody(reportDist);
if (body != null)
{
ui.SetActionButton("REPORT", true, () => GameManager.Instance?.PerformAction());
@@ -105,7 +119,7 @@ namespace Subsystems
if (_gameClient.CurrentLobbyState?.MapData != null)
{
double dist = myPos.DistanceTo(_gameClient.CurrentLobbyState.MapData.Center);
if (dist <= ProximityRadius)
if (dist <= emergencyDist)
{
ui.SetActionButton("EMERGENCY", true, () => GameManager.Instance?.PerformAction());
return;
@@ -116,16 +130,28 @@ namespace Subsystems
// Impostor kill
if (isImpostor)
{
var target = _gameClient.FindNearbyPlayer(ProximityRadius);
var target = _gameClient.FindNearbyPlayer(killDist);
if (!string.IsNullOrEmpty(target))
{
ui.SetActionButton("KILL", true, () => GameManager.Instance?.PerformAction());
// Hide sabotage menu while a kill is on offer (cleaner HUD).
ui.SetSabotageMenuVisible(false);
return;
}
}
// Nothing nearby
ui.SetActionButton("", false);
// P13g: persistent sabotage menu for impostors when no proximity
// action is on offer. Hidden when state isn't suitable - dead,
// not-impostor, in meeting, sabotage already active, or comms
// blackout (the impostor's own sabotage triggers a UI lock).
bool inPlayingPhase = state != null && state.Phase == GeoSus.Client.GamePhase.Playing;
bool sabotageActive = state?.ActiveSabotage != null;
bool showSabMenu = isImpostor && !ui.IsPlayerDead && inPlayingPhase &&
!sabotageActive && !ui.IsCommsBlackout;
ui.SetSabotageMenuVisible(showSabMenu);
}
/// <summary>Called externally (e.g., GameManager.PerformAction) to launch the nearby task.</summary>
@@ -146,10 +172,33 @@ namespace Subsystems
_minigameOpen = true;
Debug.Log($"[Tasks] Launching minigame '{entry.MinigameScene}' for task '{entry.ServerTask.Name}'");
// Validate that the scene name resolves to a build-included scene.
// LoadSceneAsync silently returns null when the scene name doesn't
// match (case-sensitive) or isn't in EditorBuildSettings, which
// leaves the action button looking dead from the player's POV.
if (string.IsNullOrEmpty(entry.MinigameScene) ||
!Application.CanStreamedLevelBeLoaded(entry.MinigameScene))
{
Debug.LogError($"[Tasks] Minigame scene '{entry.MinigameScene}' is not loadable. " +
$"Check the scene name (case-sensitive) and that it's enabled in Build Settings.");
GameManager.Instance?.uiSubsystem?.ShowToast(
$"Task scene missing: {entry.MinigameScene}");
_minigameOpen = false;
yield break;
}
// Inform server that task started
_gameClient.Send(new TaskStart { TaskId = entry.ServerTask.TaskId });
var op = SceneManager.LoadSceneAsync(entry.MinigameScene, LoadSceneMode.Additive);
if (op == null)
{
Debug.LogError($"[Tasks] LoadSceneAsync returned null for '{entry.MinigameScene}'.");
GameManager.Instance?.uiSubsystem?.ShowToast(
$"Task scene failed to load: {entry.MinigameScene}");
_minigameOpen = false;
yield break;
}
yield return op;
_loadedMinigameScene = entry.MinigameScene;

View File

@@ -4,6 +4,7 @@ using Subsystems;
using GeoSus.Client;
using System.Collections.Generic;
using System;
using System.Linq;
using TMPro;
namespace Subsystems
@@ -34,15 +35,22 @@ namespace Subsystems
private TMP_Text _sabotageTimerText;
private GameObject _meetingPanel;
private TMP_Text _meetingHeader;
private TMP_Text _meetingPhaseLabel;
private TMP_Text _meetingPhaseCountdown;
private Image _meetingPhaseProgressBar;
private TMP_Text _myVoteIndicator;
private GameObject _meetingScrollGO;
private Transform _meetingScrollContent;
private TMP_Text _meetingFallbackText;
private GameObject _voteResultPanel;
private TMP_Text _voteResultText;
private Button _skipButton;
private GameObject _gameEndPanel;
private TMP_Text _gameEndText;
private RectTransform _returnToLobbyBtn;
private TMP_Text _toastText;
private GameObject _toastGO;
private GameObject _reconnectOverlay;
// ── Internal state ────────────────────────────────────────────────────
private bool _isDead;
@@ -54,6 +62,7 @@ namespace Subsystems
// Meeting vote-row references rebuilt each meeting
private readonly List<GameObject> _voteRows = new List<GameObject>();
private string _pendingVoteResultDisplay; // shown after voting
private Coroutine _meetingCloseCoroutine; // tracked so phase changes can cancel it
public GameManager_UI(GameClient gameClient) { _gameClient = gameClient; }
@@ -89,9 +98,19 @@ namespace Subsystems
_gameEndText = FindTMP(t, "GameEndText");
_toastText = FindTMP(t, "Toast");
_meetingHeader = FindTMP(t, "MeetingHeader");
_meetingPhaseLabel = FindTMP(t, "MeetingPhaseLabel");
_meetingPhaseCountdown = FindTMP(t, "MeetingPhaseCountdown");
_myVoteIndicator = FindTMP(t, "MyVoteIndicator");
_meetingFallbackText = FindTMP(t, "MeetingPlayerList");
_voteResultText = FindTMP(t, "VoteResult");
_meetingScrollContent = FindTransform(t, "MeetingContent");
_meetingScrollGO = FindTransformGO(t, "_MeetingScroll");
var progressBarGO = FindTransformGO(t, "MeetingPhaseProgressBar");
if (progressBarGO != null) _meetingPhaseProgressBar = progressBarGO.GetComponent<Image>();
var skipGO = FindTransformGO(t, "SkipButton");
if (skipGO != null) _skipButton = skipGO.GetComponent<Button>();
var actionGO = t.Find("ActionButton");
if (actionGO != null)
@@ -105,6 +124,7 @@ namespace Subsystems
_gameEndPanel = t.Find("GameEndPanel")?.gameObject;
_voteResultPanel = FindTransformGO(t, "VoteResultPanel");
_toastGO = FindTransformGO(t, "Toast");
_reconnectOverlay = FindTransformGO(t, "ReconnectOverlay");
var retBtn = FindTransform(t, "ReturnToLobbyButton");
if (retBtn != null) _returnToLobbyBtn = retBtn as RectTransform;
@@ -113,6 +133,7 @@ namespace Subsystems
if (_gameEndPanel) _gameEndPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_toastGO) _toastGO.SetActive(false);
if (_reconnectOverlay) _reconnectOverlay.SetActive(false);
}
// ── Update (called every frame from GameManager.Update) ───────────────
@@ -192,19 +213,52 @@ namespace Subsystems
if (show) _killCooldownText.text = $"Kill: {Mathf.CeilToInt(s.KillCooldownRemaining)}s";
}
// Sabotage banner
// Sabotage banner - meltdown countdown plus simultaneous-repair coaching
if (_sabotageTimerActive && _sabotageTimerText != null)
{
double remaining = (_sabotageMeltdownDeadline - DateTime.UtcNow).TotalSeconds;
_sabotageTimerText.text = remaining > 0 ? $"⚠ MELTDOWN: {remaining:F0}s" : "⚠ MELTDOWN!";
string head = remaining > 0 ? $"⚠ MELTDOWN: {remaining:F0}s" : "⚠ MELTDOWN!";
// For multi-station sabotages, surface how many of the required
// simultaneous repair stations are currently active. This is
// what makes "you're alone, you need a partner" obvious.
int required = s.ActiveSabotage?.RequiredSimultaneousRepairs ?? 0;
if (required > 1)
{
int active = s.ActiveRepairs.Count;
head += $" <size=32>{active}/{required} stations active</size>";
}
_sabotageTimerText.text = head;
}
// Keep meeting voted-indicator rows fresh each frame
TickMeetingVoteIndicators();
// Keep meeting sub-phase strip, countdown, vote gating, tallies and
// my-vote indicator fresh each frame.
UpdateMeetingPhaseStrip();
}
// ── Kill cooldown helper (called from GameManager) ────────────────────
// ── Reconnect overlay ─────────────────────────────────────────────────
/// <summary>
/// Show a full-screen "Reconnecting..." overlay. Call when the socket
/// drops mid-game; the server keeps the player slot for ~60s before
/// removing them so a brief disconnect is recoverable.
/// </summary>
public void ShowReconnecting()
{
if (_reconnectOverlay) _reconnectOverlay.SetActive(true);
}
/// <summary>
/// Hide the reconnect overlay. Call from OnConnected once the socket
/// is healthy again.
/// </summary>
public void HideReconnecting()
{
if (_reconnectOverlay) _reconnectOverlay.SetActive(false);
}
public void SetKillCooldownText(string text)
{
if (_killCooldownText == null) return;
@@ -233,6 +287,96 @@ namespace Subsystems
}
}
// ── P13g: Impostor sabotage menu ──────────────────────────────────────
// The audit found that the production HUD never had an impostor
// sabotage trigger - GameManager.StartSabotage exists, the wire path
// is intact (StartSabotage -> server -> SabotageStarted broadcast +
// station markers), but no UI ever called it. So sabotages literally
// never fired in production. This menu fixes that gap with a runtime-
// built two-button overlay (no scene file change, no prefab needed).
private GameObject _sabotageMenuRoot;
private Button _sabotageBlackoutBtn;
private Button _sabotageMeltdownBtn;
private void EnsureSabotageMenu()
{
if (_sabotageMenuRoot != null || ClientGameScreen == null) return;
var canvasRT = ClientGameScreen.transform as RectTransform;
if (canvasRT == null) return;
// Root container - top-right corner, vertical stack.
_sabotageMenuRoot = new GameObject("ImpostorSabotageMenu", typeof(RectTransform), typeof(CanvasRenderer));
var rootRT = _sabotageMenuRoot.GetComponent<RectTransform>();
rootRT.SetParent(canvasRT, worldPositionStays: false);
rootRT.anchorMin = new Vector2(1, 1);
rootRT.anchorMax = new Vector2(1, 1);
rootRT.pivot = new Vector2(1, 1);
rootRT.anchoredPosition = new Vector2(-24, -180); // below the top-right safe-area
rootRT.sizeDelta = new Vector2(360, 240);
_sabotageBlackoutBtn = BuildSabotageOption(rootRT, "📡 BLACKOUT",
new Color(0.20f, 0.55f, 1.0f), 0, () => GameManager.Instance?.StartSabotage(0));
_sabotageMeltdownBtn = BuildSabotageOption(rootRT, "☢️ MELTDOWN",
new Color(1.0f, 0.30f, 0.30f), 1, () => GameManager.Instance?.StartSabotage(1));
_sabotageMenuRoot.SetActive(false);
}
private static Button BuildSabotageOption(RectTransform parent, string label, Color tint, int slot, UnityEngine.Events.UnityAction onClick)
{
// Each button: 360w x 110h, stacked vertically with 10px gap.
var go = new GameObject($"SabBtn_{slot}", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
var rt = go.GetComponent<RectTransform>();
rt.SetParent(parent, worldPositionStays: false);
rt.anchorMin = new Vector2(0, 1);
rt.anchorMax = new Vector2(1, 1);
rt.pivot = new Vector2(0.5f, 1);
rt.anchoredPosition = new Vector2(0, -slot * 120);
rt.sizeDelta = new Vector2(0, 110);
var img = go.GetComponent<Image>();
img.color = new Color(tint.r * 0.4f, tint.g * 0.4f, tint.b * 0.4f, 0.92f);
// Border via outline component
var outline = go.AddComponent<Outline>();
outline.effectColor = tint;
outline.effectDistance = new Vector2(2, -2);
// Text child
var txtGO = new GameObject("Label", typeof(RectTransform));
var txtRT = txtGO.GetComponent<RectTransform>();
txtRT.SetParent(rt, worldPositionStays: false);
txtRT.anchorMin = Vector2.zero;
txtRT.anchorMax = Vector2.one;
txtRT.offsetMin = Vector2.zero;
txtRT.offsetMax = Vector2.zero;
var tmp = txtGO.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.alignment = TextAlignmentOptions.Center;
tmp.fontSize = 36;
tmp.color = Color.white;
tmp.fontStyle = FontStyles.Bold;
var btn = go.GetComponent<Button>();
btn.onClick.AddListener(onClick);
return btn;
}
/// <summary>
/// P13g: show the impostor sabotage menu when the local player is
/// alive impostor in the Playing phase with no active sabotage and
/// not in a meeting. Driven from GameManager_Tasks.UpdateProximity.
/// </summary>
public void SetSabotageMenuVisible(bool visible)
{
if (visible) EnsureSabotageMenu();
if (_sabotageMenuRoot != null && _sabotageMenuRoot.activeSelf != visible)
_sabotageMenuRoot.SetActive(visible);
}
// ── Player state ──────────────────────────────────────────────────────
public void OnLocalPlayerDied()
@@ -256,9 +400,14 @@ namespace Subsystems
if (_meetingHeader != null)
_meetingHeader.text = payload.Type == MeetingType.BodyReport ? "BODY REPORTED!" : "EMERGENCY MEETING!";
// Make sure the result subpanel is hidden at start of a fresh meeting,
// and the scroll list is visible (results phase will swap them).
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_meetingScrollGO) _meetingScrollGO.SetActive(true);
if (_myVoteIndicator) _myVoteIndicator.text = "";
BuildMeetingVoteRows(players);
UpdateMeetingPhaseStrip();
}
private void BuildMeetingVoteRows(List<PlayerInfo> players)
@@ -284,24 +433,47 @@ namespace Subsystems
string myId = _gameClient.ClientUuid;
bool canVote = !_isDead;
// Dynamic row height: spread the available scroll-area height
// across however many players we have. Clamps so rows never get
// tinier than legible (small phone, many players -> 80px) or
// ridiculously tall (tablet, two players -> 140px).
float rowH = ComputeVoteRowHeight(players.Count);
foreach (var player in players)
{
bool isMe = player.ClientUuid == myId;
bool isAlive = player.State == PlayerState.Alive;
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe);
var row = BuildVoteRow(player, isMe, isAlive, canVote && isAlive && !isMe, rowH);
row.transform.SetParent(_meetingScrollContent, false);
_voteRows.Add(row);
}
}
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote)
/// <summary>
/// Compute a per-row height that fills the scroll viewport when there
/// are few players, and shrinks (until scrolling kicks in) when there
/// are many. Inputs are CanvasScaler reference coordinates, so the
/// values are device-independent.
/// </summary>
private float ComputeVoteRowHeight(int playerCount)
{
if (playerCount <= 0) return 110f;
// The scroll area occupies y=0.18 to y=0.74 of the canvas (per
// InGameHUDBuilder.BuildMeetingPanel) and reference height is 1920.
const float referenceHeight = 1920f;
const float scrollFraction = 0.74f - 0.18f; // 0.56
float available = referenceHeight * scrollFraction;
float h = available / playerCount;
return Mathf.Clamp(h, 80f, 140f);
}
private GameObject BuildVoteRow(PlayerInfo player, bool isMe, bool isAlive, bool canVote, float rowH)
{
const float ROW_H = 110f;
var go = new GameObject($"VoteRow_{player.ClientUuid}");
var rt = go.AddComponent<RectTransform>();
rt.sizeDelta = new Vector2(0, ROW_H);
rt.sizeDelta = new Vector2(0, rowH);
var le = go.AddComponent<LayoutElement>();
le.minHeight = le.preferredHeight = ROW_H;
le.minHeight = le.preferredHeight = rowH;
var bg = go.AddComponent<Image>();
bg.color = isMe ? new Color(0.12f,0.18f,0.30f) : new Color(0.10f,0.12f,0.20f);
@@ -312,9 +484,9 @@ namespace Subsystems
bg.color = new Color(0.08f,0.08f,0.10f,0.7f);
}
// Name label
// Name label - left 50% (was 65%, gave width back to tally + button)
var namRT = MakeChild("Name", rt);
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.65f,1);
namRT.anchorMin = new Vector2(0,0); namRT.anchorMax = new Vector2(0.50f,1);
namRT.offsetMin = new Vector2(16,6); namRT.offsetMax = new Vector2(0,-6);
var namTmp = namRT.gameObject.AddComponent<TextMeshProUGUI>();
namTmp.text = (player.IsOwner ? "👑 " : "") + (player.DisplayName ?? "???");
@@ -323,7 +495,18 @@ namespace Subsystems
namTmp.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
namTmp.alignment = TextAlignmentOptions.MidlineLeft;
// Vote button
// Tally column - middle 18%, shows live vote count for this player
var tallyRT = MakeChild("Tally", rt);
tallyRT.anchorMin = new Vector2(0.50f,0); tallyRT.anchorMax = new Vector2(0.66f,1);
tallyRT.offsetMin = Vector2.zero; tallyRT.offsetMax = Vector2.zero;
var tallyTmp = tallyRT.gameObject.AddComponent<TextMeshProUGUI>();
tallyTmp.text = "";
tallyTmp.fontSize = 30;
tallyTmp.fontStyle = FontStyles.Bold;
tallyTmp.color = new Color(1f,0.72f,0.10f); // C_YELLOW-ish
tallyTmp.alignment = TextAlignmentOptions.Center;
// Vote button - right 30% (interactability is updated each frame)
var voteBtnRT = MakeChild("VoteBtn", rt);
voteBtnRT.anchorMin = new Vector2(0.68f,0.10f); voteBtnRT.anchorMax = new Vector2(0.95f,0.90f);
var voteBg = voteBtnRT.gameObject.AddComponent<Image>();
@@ -342,7 +525,7 @@ namespace Subsystems
voteTmp.color = Color.white;
voteTmp.alignment = TextAlignmentOptions.Center;
// Voted indicator (hidden by default; shown by TickMeetingVoteIndicators)
// Voted-by-this-player checkmark (shown when the row's player has cast a vote)
var votedRT = MakeChild("VotedTick", rt);
votedRT.anchorMin = new Vector2(0.95f,0.20f); votedRT.anchorMax = new Vector2(1f,0.80f);
var vtTmp = votedRT.gameObject.AddComponent<TextMeshProUGUI>();
@@ -353,51 +536,235 @@ namespace Subsystems
return go;
}
private void TickMeetingVoteIndicators()
/// <summary>
/// Per-frame meeting UI update. Computes the meeting sub-phase from the
/// timestamps in MeetingStartedPayload (server doesn't broadcast a
/// discrete discussion-end event) and uses it to drive the countdown
/// label, progress bar, vote-button interactivity, live tallies, and
/// "Your vote: X" indicator.
/// </summary>
private void UpdateMeetingPhaseStrip()
{
var s = _state;
if (s == null) return;
// Only run if we're actually in a meeting; phase Playing skips the work.
if (s.Phase != GamePhase.Meeting && s.LastVoteResult == null) return;
var sub = s.GetMeetingSubPhase();
// ── Sub-phase label + countdown text + progress bar ───────────────
string label;
switch (sub)
{
case MeetingSubPhase.Arrival: label = "ARRIVAL"; break;
case MeetingSubPhase.Discussion: label = "DISCUSSION"; break;
case MeetingSubPhase.Voting: label = "VOTING"; break;
case MeetingSubPhase.Resolved: label = "RESULTS"; break;
default: label = ""; break;
}
if (_meetingPhaseLabel != null) _meetingPhaseLabel.text = label;
if (s.ActiveMeeting != null && sub != MeetingSubPhase.Resolved)
{
var deadline = s.GetMeetingSubPhaseDeadline(sub);
var remaining = (deadline - DateTime.UtcNow).TotalSeconds;
if (remaining < 0) remaining = 0;
if (_meetingPhaseCountdown != null)
{
int mins = (int)(remaining / 60);
int secs = (int)(remaining % 60);
string verb = sub == MeetingSubPhase.Voting ? "Voting ends in"
: sub == MeetingSubPhase.Discussion ? "Voting begins in"
: "Arrival ends in";
_meetingPhaseCountdown.text = $"{verb} {mins}:{secs:D2}";
}
// Progress bar drains over the current sub-phase. The server
// doesn't tell us when the meeting started, so we can only
// compute a meaningful fill for Discussion (start = arrival
// deadline) and Voting (start = discussion end / arrival
// deadline). Arrival's start time is unknown here; show full.
if (_meetingPhaseProgressBar != null)
{
if (sub == MeetingSubPhase.Arrival)
{
_meetingPhaseProgressBar.fillAmount = 1f;
}
else
{
DateTime start = sub == MeetingSubPhase.Discussion
? s.ActiveMeeting.ArrivalDeadline
: (s.ActiveMeeting.DiscussionEndTime ?? s.ActiveMeeting.ArrivalDeadline);
var total = (deadline - start).TotalSeconds;
var elapsed = (DateTime.UtcNow - start).TotalSeconds;
float fill = total > 0.001
? Mathf.Clamp01(1f - (float)(elapsed / total))
: 0f;
_meetingPhaseProgressBar.fillAmount = fill;
}
}
}
else
{
if (_meetingPhaseCountdown != null) _meetingPhaseCountdown.text = "";
if (_meetingPhaseProgressBar != null) _meetingPhaseProgressBar.fillAmount = 0f;
}
// ── Vote button gating + per-row tally / voted-indicator ──────────
bool votingOpen = sub == MeetingSubPhase.Voting && !_isDead;
bool iAmArrived = s.ActiveMeeting == null
|| s.ArrivedPlayerIds.Contains(_gameClient.ClientUuid);
// Skip button mirrors the same gate
if (_skipButton != null) _skipButton.interactable = votingOpen && iAmArrived;
foreach (var row in _voteRows)
{
if (row == null) continue;
// Row name is "VoteRow_<uuid>"
string uuid = row.name.Replace("VoteRow_", "");
string rowUuid = row.name.Replace("VoteRow_", "");
// Voted-tick: this row's player has cast a vote
var tick = row.transform.Find("VotedTick")?.gameObject;
if (tick != null)
tick.SetActive(s.VotedPlayerIds.Contains(uuid));
if (tick != null) tick.SetActive(s.VotedPlayerIds.Contains(rowUuid));
// Tally text: how many votes is this row's player receiving?
var tally = row.transform.Find("Tally")?.GetComponent<TMP_Text>();
if (tally != null)
{
s.VoteTallies.TryGetValue(rowUuid, out var count);
tally.text = count > 0 ? count.ToString() : "";
}
// Vote button: gate by sub-phase + arrival + alive + not-self
var btnGO = row.transform.Find("VoteBtn")?.gameObject;
if (btnGO != null)
{
var btn = btnGO.GetComponent<Button>();
var btnImg = btnGO.GetComponent<Image>();
var rowPlayer = s.Players?.FirstOrDefault(p => p.ClientUuid == rowUuid);
bool isMe = rowUuid == _gameClient.ClientUuid;
bool rowAlive = rowPlayer?.State == PlayerState.Alive;
bool canPress = votingOpen && iAmArrived && rowAlive && !isMe;
if (btn != null) btn.interactable = canPress;
if (btnImg != null)
btnImg.color = canPress ? new Color(0.2f,0.6f,1f)
: new Color(0.2f,0.2f,0.2f,0.5f);
// Mark the row's button if it's the local player's chosen vote
if (s.MyVoteTarget != null && s.MyVoteTarget == rowUuid && btnImg != null)
btnImg.color = new Color(0.2f,0.75f,0.30f); // green = your vote
}
}
// ── My vote indicator strip ───────────────────────────────────────
if (_myVoteIndicator != null)
{
if (s.LastVoteResult != null) _myVoteIndicator.text = "";
else if (!iAmArrived) _myVoteIndicator.text = "Travel to the meeting point to vote";
else if (sub == MeetingSubPhase.Discussion) _myVoteIndicator.text = "Discussion - voting opens shortly";
else if (sub == MeetingSubPhase.Arrival) _myVoteIndicator.text = "Waiting for players to arrive";
else if (s.MyVoteTarget == null) _myVoteIndicator.text = "Cast your vote";
else if (s.MyVoteTarget == GameState.VoteSkip) _myVoteIndicator.text = "You voted: SKIP";
else
{
var target = s.Players?.FirstOrDefault(p => p.ClientUuid == s.MyVoteTarget);
_myVoteIndicator.text = $"You voted for: {target?.DisplayName ?? s.MyVoteTarget}";
}
}
}
public void AppendVoteInstruction()
{
// no-op vote instructions are embedded in the row buttons
// no-op - vote instructions are embedded in the row buttons
}
public void ShowVoteResult(VotingClosedPayload payload, List<PlayerInfo> players)
{
// Swap scroll list out, result subpanel in. They occupy the same
// anchor region (0.18-0.74) so the result text replaces the vote
// rows rather than overlapping them.
if (_meetingScrollGO != null) _meetingScrollGO.SetActive(false);
if (_voteResultPanel != null) _voteResultPanel.SetActive(true);
// Skip + my-vote strips are no longer relevant once voting ended.
if (_skipButton != null) _skipButton.gameObject.SetActive(false);
if (_myVoteIndicator != null) _myVoteIndicator.text = "";
if (_voteResultText != null)
{
// Build a compact tally summary alongside the headline.
var sb = new System.Text.StringBuilder();
if (payload.WasTie)
_voteResultText.text = "⚖ TIE — nobody ejected.";
sb.AppendLine("⚖ TIE — nobody ejected.");
else if (string.IsNullOrEmpty(payload.EjectedPlayerId))
_voteResultText.text = "Nobody ejected (skip).";
sb.AppendLine("Nobody ejected (skip).");
else
{
var ej = players?.Find(p => p.ClientUuid == payload.EjectedPlayerId);
_voteResultText.text = $"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!";
sb.AppendLine($"🚪 {ej?.DisplayName ?? payload.EjectedPlayerId} ejected!");
}
if (payload.VoteCounts != null && payload.VoteCounts.Count > 0)
{
sb.AppendLine();
foreach (var kv in payload.VoteCounts.OrderByDescending(p => p.Value))
{
if (kv.Value <= 0) continue;
string name = kv.Key == GameState.VoteSkip
? "(skip)"
: (players?.Find(p => p.ClientUuid == kv.Key)?.DisplayName ?? kv.Key);
sb.AppendLine($"<size=24>{name}: {kv.Value}</size>");
}
}
_voteResultText.text = sb.ToString();
}
// Auto-close meeting panel after 5 s
GameManager.Instance?.StartCoroutine(CloseMeetingAfterDelay(5f));
// Auto-close meeting panel after 5 s. Track the handle so we can
// cancel it if the game ends or returns to lobby before it fires
// (otherwise the coroutine fires mid-GameEndPanel and hides nothing
// useful while the meeting overlay sits visibly stacked on top).
CancelMeetingAutoClose();
var gm = GameManager.Instance;
if (gm != null) _meetingCloseCoroutine = gm.StartCoroutine(CloseMeetingAfterDelay(5f));
}
/// <summary>
/// Hide the meeting/vote panels immediately and cancel any pending
/// auto-close coroutine. Resets internal toggles (skip/result/scroll
/// visibility) so the next meeting starts from a clean state. Safe to
/// call from any phase transition.
/// </summary>
public void HideMeetingPanel()
{
CancelMeetingAutoClose();
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
if (_meetingScrollGO) _meetingScrollGO.SetActive(true);
if (_skipButton) _skipButton.gameObject.SetActive(true);
if (_myVoteIndicator) _myVoteIndicator.text = "";
if (_meetingPhaseLabel) _meetingPhaseLabel.text = "";
if (_meetingPhaseCountdown) _meetingPhaseCountdown.text = "";
if (_meetingPhaseProgressBar) _meetingPhaseProgressBar.fillAmount = 0f;
}
private void CancelMeetingAutoClose()
{
if (_meetingCloseCoroutine != null)
{
var gm = GameManager.Instance;
if (gm != null) gm.StopCoroutine(_meetingCloseCoroutine);
_meetingCloseCoroutine = null;
}
}
private System.Collections.IEnumerator CloseMeetingAfterDelay(float delay)
{
yield return new UnityEngine.WaitForSeconds(delay);
if (_meetingPanel) _meetingPanel.SetActive(false);
if (_voteResultPanel) _voteResultPanel.SetActive(false);
// Use HideMeetingPanel so we restore the scroll/skip/indicator
// state for the next meeting, not just hide the root panel.
HideMeetingPanel();
_meetingCloseCoroutine = null;
}
// ── Sabotage ──────────────────────────────────────────────────────────
@@ -417,7 +784,31 @@ namespace Subsystems
SetCommsBlackout(false);
}
public void SetCommsBlackout(bool active) => _commsBlackout = active;
/// <summary>
/// Set the comms-blackout flag and (when active) raise the sabotage
/// banner with a clear "comms down" message. The flag is read by
/// GameManager_Tasks.UpdateProximity to suppress the REPORT/EMERGENCY
/// action button while comms are jammed - this gives the player the
/// visible reason why those buttons disappeared.
/// </summary>
public void SetCommsBlackout(bool active)
{
_commsBlackout = active;
if (active)
{
if (_sabotagePanel) _sabotagePanel.SetActive(true);
if (_sabotageTimerText)
{
_sabotageTimerText.gameObject.SetActive(true);
_sabotageTimerText.text = "📡 COMMS DOWN — reports & meetings disabled";
}
}
else if (!_sabotageTimerActive)
{
// Only tear the banner down if no meltdown timer is using it.
if (_sabotagePanel) _sabotagePanel.SetActive(false);
}
}
// ── Game end ──────────────────────────────────────────────────────────
@@ -429,7 +820,20 @@ namespace Subsystems
bool won = payload.Winners?.Contains(myUuid) ?? false;
string title = won ? "<color=#FFB800>🏆 VICTORY</color>" : "<color=#C43232>💔 DEFEAT</color>";
string faction = payload.WinningFaction == "Impostor" ? "Impostors win!" : "Crew wins!";
_gameEndText.text = $"{title}\n{faction}\n<size=38>{payload.Reason}</size>";
// Non-owners can't actually return to lobby themselves; tell
// them who they're waiting on so the panel doesn't read as
// "tap leave or stare at the wall." If we can't find an
// owner record, fall back to a generic message.
string waitMessage = "";
if (!_gameClient.IsOwner)
{
var s = _state;
var host = s?.Players?.Find(p => p.IsOwner);
string hostName = host?.DisplayName ?? "the host";
waitMessage = $"\n\n<size=32>Waiting for {hostName} to return to lobby...</size>";
}
_gameEndText.text = $"{title}\n{faction}\n<size=38>{payload.Reason}</size>{waitMessage}";
}
// Show "Return to Lobby" only for the host