GeoSus
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user