GeoSus
This commit is contained in:
@@ -132,7 +132,20 @@ public class GameClient : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Disconnect(string reason = "User disconnected")
|
||||
/// <summary>
|
||||
/// Tears down the socket and crypto session. When `transient` is true
|
||||
/// (network drop, decrypt-failure cascade, anything we expect to retry),
|
||||
/// the lobby/role/task/state caches are preserved so the post-reconnect
|
||||
/// flow can re-associate via Reconnect(LobbyId). Default false matches
|
||||
/// pre-P9 behavior (full state wipe) for explicit user disconnects.
|
||||
///
|
||||
/// Critical for the P9 reconnect bug: previously every Disconnect path
|
||||
/// nuked LobbyId, so by the time GameManager_Network's reconnect coroutine
|
||||
/// fired, the client had no idea which lobby it had been in - the
|
||||
/// post-handshake Reconnect call had nothing to send and the server
|
||||
/// answered the next vote/action with NOT_IN_LOBBY.
|
||||
/// </summary>
|
||||
public void Disconnect(string reason = "User disconnected", bool transient = false)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_tcpClient?.Close();
|
||||
@@ -140,15 +153,22 @@ public class GameClient : IDisposable
|
||||
_stream = null;
|
||||
_encryption?.Dispose();
|
||||
_encryption = null;
|
||||
|
||||
LobbyId = null;
|
||||
JoinCode = null;
|
||||
CurrentLobbyState = null;
|
||||
MyRole = null;
|
||||
MyTasks.Clear();
|
||||
PlayerPositions.Clear();
|
||||
Bodies.Clear();
|
||||
|
||||
|
||||
if (!transient)
|
||||
{
|
||||
LobbyId = null;
|
||||
JoinCode = null;
|
||||
CurrentLobbyState = null;
|
||||
MyRole = null;
|
||||
MyTasks.Clear();
|
||||
PlayerPositions.Clear();
|
||||
Bodies.Clear();
|
||||
}
|
||||
// PlayerPositions are stale anyway after a drop, but we keep them so
|
||||
// the UI doesn't blink avatars off-map mid-meeting; the next position
|
||||
// broadcast overwrites them. LastEventId is intentionally preserved
|
||||
// so the Reconnect message can ask the server for missed events.
|
||||
|
||||
Dispatcher.Post(() => OnDisconnected?.Invoke(reason));
|
||||
}
|
||||
|
||||
@@ -236,7 +256,8 @@ public class GameClient : IDisposable
|
||||
decryptFailures++;
|
||||
if (decryptFailures >= 3)
|
||||
{
|
||||
Disconnect("Too many decryption failures");
|
||||
// Transient: keep LobbyId for the reconnect coroutine.
|
||||
Disconnect("Too many decryption failures", transient: true);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
@@ -253,7 +274,9 @@ public class GameClient : IDisposable
|
||||
}
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
Disconnect($"Connection error: {ex.Message}");
|
||||
// Transient: TCP RST / read failure is exactly what reconnect was
|
||||
// designed for. Keep LobbyId so post-reconnect flow can re-attach.
|
||||
Disconnect($"Connection error: {ex.Message}", transient: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +531,7 @@ public class GameClient : IDisposable
|
||||
|
||||
#region Game Actions
|
||||
|
||||
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500)
|
||||
public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500, GameSettingsOverrides? settings = null)
|
||||
{
|
||||
Send(new CreateLobby
|
||||
{
|
||||
@@ -516,7 +539,8 @@ public class GameClient : IDisposable
|
||||
PlayAreaRadius = playAreaRadius,
|
||||
ImpostorCount = impostorCount,
|
||||
TaskCount = taskCount,
|
||||
Password = password
|
||||
Password = password,
|
||||
Settings = settings
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ public enum PlayerRole { Crew, Impostor }
|
||||
public enum PlayerState { Alive, Dead }
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
// NOTE: `Voting` is reserved-but-unused on the wire as of 2026. The server
|
||||
// keeps the entire vote cycle inside `Meeting` and uses MeetingStartedPayload
|
||||
// timestamps (DiscussionEndTime / VotingEndTime) to distinguish sub-phases.
|
||||
// The enum value is preserved here for serialization compatibility with old
|
||||
// saves; new code should not assign it.
|
||||
public enum GamePhase { Lobby, Loading, Playing, Meeting, Voting, Ended }
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
@@ -184,6 +189,14 @@ public class CreateLobby : Message
|
||||
|
||||
[JsonProperty("taskCount")]
|
||||
public int TaskCount { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// P13b: optional per-lobby settings overrides supplied by the host.
|
||||
/// Any field left null falls through to the server's current default
|
||||
/// (snapshotted at lobby creation, immutable thereafter for this lobby).
|
||||
/// </summary>
|
||||
[JsonProperty("settings")]
|
||||
public GameSettingsOverrides? Settings { get; set; }
|
||||
}
|
||||
|
||||
public class CreateLobbyResponse : Message
|
||||
@@ -623,17 +636,26 @@ public class PlayerEjectedPayload
|
||||
public PlayerRole Role { get; set; }
|
||||
}
|
||||
|
||||
public class TaskStartedPayload
|
||||
{
|
||||
[JsonProperty("clientUuid")]
|
||||
public string ClientUuid { get; set; } = "";
|
||||
|
||||
[JsonProperty("taskId")]
|
||||
public string TaskId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class TaskCompletedPayload
|
||||
{
|
||||
[JsonProperty("clientUuid")]
|
||||
public string ClientUuid { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("taskId")]
|
||||
public string TaskId { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("totalCompleted")]
|
||||
public int TotalCompleted { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("totalTasks")]
|
||||
public int TotalTasks { get; set; }
|
||||
}
|
||||
@@ -713,10 +735,10 @@ public class RepairStartedPayload
|
||||
{
|
||||
[JsonProperty("sabotageId")]
|
||||
public string SabotageId { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("stationId")]
|
||||
public string StationId { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("playerId")]
|
||||
public string PlayerId { get; set; } = "";
|
||||
}
|
||||
@@ -725,10 +747,10 @@ public class RepairStoppedPayload
|
||||
{
|
||||
[JsonProperty("sabotageId")]
|
||||
public string SabotageId { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("stationId")]
|
||||
public string StationId { get; set; } = "";
|
||||
|
||||
|
||||
[JsonProperty("playerId")]
|
||||
public string PlayerId { get; set; } = "";
|
||||
}
|
||||
@@ -790,6 +812,162 @@ public class LobbyState
|
||||
/// <summary>True if map data has been loaded (or Overpass is disabled)</summary>
|
||||
[JsonProperty("mapDataReady")]
|
||||
public bool MapDataReady { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// P13b: full per-lobby settings snapshot. Clients use this for HUD
|
||||
/// (button visibility, countdown timings, etc.) instead of hardcoded
|
||||
/// values. Always populated for new server builds; old client builds
|
||||
/// can ignore the field.
|
||||
/// </summary>
|
||||
[JsonProperty("settings")]
|
||||
public GameSettings? Settings { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P13b: per-lobby gameplay settings on the wire. Server populates this from
|
||||
/// its per-lobby snapshot so clients can drive HUD logic from authoritative
|
||||
/// values rather than hardcoded constants.
|
||||
/// </summary>
|
||||
public class GameSettings
|
||||
{
|
||||
// Round shape
|
||||
[JsonProperty("maxPlayers")]
|
||||
public int MaxPlayers { get; set; }
|
||||
|
||||
[JsonProperty("impostorCount")]
|
||||
public int ImpostorCount { get; set; }
|
||||
|
||||
[JsonProperty("taskCount")]
|
||||
public int TaskCount { get; set; }
|
||||
|
||||
[JsonProperty("tiePolicy")]
|
||||
public string TiePolicy { get; set; } = "NoEject";
|
||||
|
||||
// Distances (m)
|
||||
[JsonProperty("killDistanceM")]
|
||||
public double KillDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("reportDistanceM")]
|
||||
public double ReportDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("taskStartDistanceM")]
|
||||
public double TaskStartDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("meetingArrivalRadiusM")]
|
||||
public double MeetingArrivalRadiusM { get; set; }
|
||||
|
||||
[JsonProperty("emergencyMeetingCallRadiusM")]
|
||||
public double EmergencyMeetingCallRadiusM { get; set; }
|
||||
|
||||
[JsonProperty("repairStationDistanceM")]
|
||||
public double RepairStationDistanceM { get; set; }
|
||||
|
||||
// Cooldowns / counts
|
||||
[JsonProperty("killCooldownMs")]
|
||||
public int KillCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("emergencyMeetingCooldownMs")]
|
||||
public int EmergencyMeetingCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("maxEmergencyMeetingsPerPlayer")]
|
||||
public int MaxEmergencyMeetingsPerPlayer { get; set; }
|
||||
|
||||
// Meeting phases (ms)
|
||||
[JsonProperty("arrivalBaseMs")]
|
||||
public int ArrivalBaseMs { get; set; }
|
||||
|
||||
[JsonProperty("allowedLateMs")]
|
||||
public int AllowedLateMs { get; set; }
|
||||
|
||||
[JsonProperty("discussionPhaseMs")]
|
||||
public int DiscussionPhaseMs { get; set; }
|
||||
|
||||
[JsonProperty("votingPhaseMs")]
|
||||
public int VotingPhaseMs { get; set; }
|
||||
|
||||
// Sabotage
|
||||
[JsonProperty("sabotageCooldownMs")]
|
||||
public int SabotageCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("commsBlackoutDurationMs")]
|
||||
public int CommsBlackoutDurationMs { get; set; }
|
||||
|
||||
[JsonProperty("criticalMeltdownDeadlineMs")]
|
||||
public int CriticalMeltdownDeadlineMs { get; set; }
|
||||
|
||||
[JsonProperty("repairStationHoldMs")]
|
||||
public int RepairStationHoldMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P13b: host-supplied overrides at CreateLobby. Every field is nullable so
|
||||
/// the host can opt into changing only what they care about; null = use the
|
||||
/// server's current default at the moment of lobby creation.
|
||||
/// </summary>
|
||||
public class GameSettingsOverrides
|
||||
{
|
||||
[JsonProperty("maxPlayers")]
|
||||
public int? MaxPlayers { get; set; }
|
||||
|
||||
[JsonProperty("impostorCount")]
|
||||
public int? ImpostorCount { get; set; }
|
||||
|
||||
[JsonProperty("taskCount")]
|
||||
public int? TaskCount { get; set; }
|
||||
|
||||
[JsonProperty("tiePolicy")]
|
||||
public string? TiePolicy { get; set; }
|
||||
|
||||
[JsonProperty("killDistanceM")]
|
||||
public double? KillDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("reportDistanceM")]
|
||||
public double? ReportDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("taskStartDistanceM")]
|
||||
public double? TaskStartDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("meetingArrivalRadiusM")]
|
||||
public double? MeetingArrivalRadiusM { get; set; }
|
||||
|
||||
[JsonProperty("emergencyMeetingCallRadiusM")]
|
||||
public double? EmergencyMeetingCallRadiusM { get; set; }
|
||||
|
||||
[JsonProperty("repairStationDistanceM")]
|
||||
public double? RepairStationDistanceM { get; set; }
|
||||
|
||||
[JsonProperty("killCooldownMs")]
|
||||
public int? KillCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("emergencyMeetingCooldownMs")]
|
||||
public int? EmergencyMeetingCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("maxEmergencyMeetingsPerPlayer")]
|
||||
public int? MaxEmergencyMeetingsPerPlayer { get; set; }
|
||||
|
||||
[JsonProperty("arrivalBaseMs")]
|
||||
public int? ArrivalBaseMs { get; set; }
|
||||
|
||||
[JsonProperty("allowedLateMs")]
|
||||
public int? AllowedLateMs { get; set; }
|
||||
|
||||
[JsonProperty("discussionPhaseMs")]
|
||||
public int? DiscussionPhaseMs { get; set; }
|
||||
|
||||
[JsonProperty("votingPhaseMs")]
|
||||
public int? VotingPhaseMs { get; set; }
|
||||
|
||||
[JsonProperty("sabotageCooldownMs")]
|
||||
public int? SabotageCooldownMs { get; set; }
|
||||
|
||||
[JsonProperty("commsBlackoutDurationMs")]
|
||||
public int? CommsBlackoutDurationMs { get; set; }
|
||||
|
||||
[JsonProperty("criticalMeltdownDeadlineMs")]
|
||||
public int? CriticalMeltdownDeadlineMs { get; set; }
|
||||
|
||||
[JsonProperty("repairStationHoldMs")]
|
||||
public int? RepairStationHoldMs { get; set; }
|
||||
}
|
||||
|
||||
// Map data classes for rendering - compact format from server
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -154,10 +154,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 216, y: -205}
|
||||
m_SizeDelta: {x: 200, y: 200}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: -466.8166, y: -731.971}
|
||||
m_SizeDelta: {x: 246.139, y: 113.3993}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &202338697
|
||||
MonoBehaviour:
|
||||
@@ -447,8 +447,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 89
|
||||
m_fontSizeBase: 89
|
||||
m_fontSize: 33.4
|
||||
m_fontSizeBase: 33.4
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -485,7 +485,7 @@ MonoBehaviour:
|
||||
m_VertexBufferAutoSizeReduction: 0
|
||||
m_useMaxVisibleDescender: 1
|
||||
m_pageToDisplay: 1
|
||||
m_margin: {x: -127.167816, y: -17.406898, z: -156.3833, w: -0.33364105}
|
||||
m_margin: {x: -173.94716, y: 11.8302, z: -294.52856, w: 16.477688}
|
||||
m_isUsingLegacyAnimationComponent: 0
|
||||
m_isVolumetricText: 0
|
||||
m_hasFontAssetChanged: 0
|
||||
@@ -1028,8 +1028,8 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0}
|
||||
m_AnchorMax: {x: 0.5, y: 0}
|
||||
m_AnchoredPosition: {x: 16, y: 472}
|
||||
m_SizeDelta: {x: 1250, y: 425}
|
||||
m_AnchoredPosition: {x: 0, y: 139}
|
||||
m_SizeDelta: {x: 511.7281, y: 173.9875}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &960568416
|
||||
CanvasRenderer:
|
||||
@@ -1254,10 +1254,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0}
|
||||
m_AnchorMax: {x: 0.5, y: 0}
|
||||
m_AnchoredPosition: {x: -10, y: 1911}
|
||||
m_SizeDelta: {x: 1000, y: 1500}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: 23.000046, y: -475.1214}
|
||||
m_SizeDelta: {x: 1230.6951, y: 850.4949}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &982479698
|
||||
CanvasRenderer:
|
||||
@@ -1389,142 +1389,6 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1163536068
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1163536069}
|
||||
- component: {fileID: 1163536071}
|
||||
- component: {fileID: 1163536070}
|
||||
m_Layer: 5
|
||||
m_Name: max players
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &1163536069
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1163536068}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: -356, y: -2039}
|
||||
m_SizeDelta: {x: 1000, y: 300}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &1163536070
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1163536068}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_text: 'Current players:'
|
||||
m_isRightToLeft: 0
|
||||
m_fontAsset: {fileID: 11400000, guid: 8a13e20c49f7db440aa1e34c53005080, type: 2}
|
||||
m_sharedMaterial: {fileID: 4955521229866657731, guid: 8a13e20c49f7db440aa1e34c53005080, type: 2}
|
||||
m_fontSharedMaterials: []
|
||||
m_fontMaterial: {fileID: 0}
|
||||
m_fontMaterials: []
|
||||
m_fontColor32:
|
||||
serializedVersion: 2
|
||||
rgba: 4292214979
|
||||
m_fontColor: {r: 0.76470596, g: 0, b: 0.83921576, a: 1}
|
||||
m_enableVertexGradient: 0
|
||||
m_colorMode: 3
|
||||
m_fontColorGradient:
|
||||
topLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
topRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_fontColorGradientPreset: {fileID: 0}
|
||||
m_spriteAsset: {fileID: 0}
|
||||
m_tintAllSprites: 0
|
||||
m_StyleSheet: {fileID: 0}
|
||||
m_TextStyleHashCode: -1183493901
|
||||
m_overrideHtmlColors: 0
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 120
|
||||
m_fontSizeBase: 120
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 0
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
m_horizontalMapping: 0
|
||||
m_verticalMapping: 0
|
||||
m_uvLineOffset: 0
|
||||
m_geometrySortingOrder: 0
|
||||
m_IsTextObjectScaleStatic: 0
|
||||
m_VertexBufferAutoSizeReduction: 0
|
||||
m_useMaxVisibleDescender: 1
|
||||
m_pageToDisplay: 1
|
||||
m_margin: {x: 12.227722, y: 33.534668, z: -194.49167, w: 37.32251}
|
||||
m_isUsingLegacyAnimationComponent: 0
|
||||
m_isVolumetricText: 0
|
||||
m_hasFontAssetChanged: 0
|
||||
m_baseMaterial: {fileID: 0}
|
||||
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
|
||||
--- !u!222 &1163536071
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1163536068}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &1191044984
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1557,10 +1421,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0}
|
||||
m_AnchorMax: {x: 0.5, y: 0}
|
||||
m_AnchoredPosition: {x: -10, y: 2077}
|
||||
m_SizeDelta: {x: 700, y: 150}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: 23, y: -381}
|
||||
m_SizeDelta: {x: 861.4866, y: 85.0495}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &1191044986
|
||||
CanvasRenderer:
|
||||
@@ -1632,10 +1496,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1641797426}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.07750001, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: -75, y: -11}
|
||||
m_SizeDelta: {x: -707, y: -218}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: -36, y: -761}
|
||||
m_SizeDelta: {x: 215, y: 1282}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &1211383658
|
||||
MonoBehaviour:
|
||||
@@ -1768,10 +1632,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0}
|
||||
m_AnchorMax: {x: 0.5, y: 0}
|
||||
m_AnchoredPosition: {x: -10, y: 2326}
|
||||
m_SizeDelta: {x: 700, y: 150}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: 23.000046, y: -239.81787}
|
||||
m_SizeDelta: {x: 861.4866, y: 85.0495}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &1443244313
|
||||
CanvasRenderer:
|
||||
@@ -1845,10 +1709,10 @@ RectTransform:
|
||||
- {fileID: 1211383657}
|
||||
m_Father: {fileID: 2022427224}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 0}
|
||||
m_AnchorMax: {x: 0.5, y: 0}
|
||||
m_AnchoredPosition: {x: -10, y: 1911}
|
||||
m_SizeDelta: {x: 1000, y: 1500}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: -0.000015259, y: -493}
|
||||
m_SizeDelta: {x: 1058.6465, y: 909.2553}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!95 &1641797427
|
||||
Animator:
|
||||
@@ -1939,13 +1803,6 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 290610b7d8fb7ea675982694abac90ef, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::LobbyDisplayUI
|
||||
lobbyCodeText: {fileID: 0}
|
||||
playerListText: {fileID: 0}
|
||||
maxPlayersText: {fileID: 0}
|
||||
ownNameText: {fileID: 0}
|
||||
otherNamesText: {fileID: 0}
|
||||
statusText: {fileID: 0}
|
||||
startButton: {fileID: 0}
|
||||
--- !u!4 &1685875759
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2106,7 +1963,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.5, y: 1}
|
||||
m_AnchorMax: {x: 0.5, y: 1}
|
||||
m_AnchoredPosition: {x: -454, y: -2275}
|
||||
m_AnchoredPosition: {x: -275, y: -199.87}
|
||||
m_SizeDelta: {x: 1000, y: 300}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &1950902273
|
||||
@@ -2299,9 +2156,9 @@ MonoBehaviour:
|
||||
m_UiScaleMode: 1
|
||||
m_ReferencePixelsPerUnit: 100
|
||||
m_ScaleFactor: 1
|
||||
m_ReferenceResolution: {x: 1600, y: 900}
|
||||
m_ReferenceResolution: {x: 1080, y: 1920}
|
||||
m_ScreenMatchMode: 0
|
||||
m_MatchWidthOrHeight: 0
|
||||
m_MatchWidthOrHeight: 0.5
|
||||
m_PhysicalUnit: 3
|
||||
m_FallbackScreenDPI: 96
|
||||
m_DefaultSpriteDPI: 96
|
||||
@@ -2343,8 +2200,6 @@ RectTransform:
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 1756995538}
|
||||
- {fileID: 1163536069}
|
||||
- {fileID: 1950902272}
|
||||
- {fileID: 202338696}
|
||||
- {fileID: 720431058}
|
||||
- {fileID: 982479697}
|
||||
@@ -2352,6 +2207,7 @@ RectTransform:
|
||||
- {fileID: 1443244312}
|
||||
- {fileID: 1191044985}
|
||||
- {fileID: 960568415}
|
||||
- {fileID: 1950902272}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
|
||||
@@ -1150,9 +1150,9 @@ MonoBehaviour:
|
||||
m_UiScaleMode: 1
|
||||
m_ReferencePixelsPerUnit: 100
|
||||
m_ScaleFactor: 1
|
||||
m_ReferenceResolution: {x: 1600, y: 900}
|
||||
m_ReferenceResolution: {x: 1080, y: 1920}
|
||||
m_ScreenMatchMode: 0
|
||||
m_MatchWidthOrHeight: 0
|
||||
m_MatchWidthOrHeight: 0.5
|
||||
m_PhysicalUnit: 3
|
||||
m_FallbackScreenDPI: 96
|
||||
m_DefaultSpriteDPI: 96
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
using GeoSus.Client;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Sub-phase derived client-side from the timestamps in MeetingStartedPayload.
|
||||
/// Server doesn't broadcast a discrete "discussion ended" event - it embeds
|
||||
/// DiscussionEndTime and VotingEndTime in the meeting-start event and gates
|
||||
/// vote acceptance on those timestamps. We compute the matching client view
|
||||
/// by comparing UtcNow to those values every frame.
|
||||
/// </summary>
|
||||
public enum MeetingSubPhase
|
||||
{
|
||||
Arrival, // before ArrivalDeadline; players are still en route to meeting point
|
||||
Discussion, // arrival deadline passed; talk only, votes server-rejected
|
||||
Voting, // discussion ended; votes accepted until VotingEndTime
|
||||
Resolved // VotingClosed received OR votingEndTime in the past
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for all in-game state on the client.
|
||||
/// Updated exclusively by GameManager_Network; read by GameManager_UI.
|
||||
@@ -12,6 +28,15 @@ public class GameState
|
||||
public PlayerRole? MyRole { get; set; }
|
||||
public bool IsDead { get; set; }
|
||||
|
||||
// ── Settings (P13b) ───────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Per-lobby settings snapshot from the server. Populated on
|
||||
/// LobbyJoined / LobbyCreated; immutable for the lifetime of the lobby.
|
||||
/// Null on old server builds - callers must use null-coalescing fallbacks
|
||||
/// to whatever default they previously hardcoded.
|
||||
/// </summary>
|
||||
public GameSettings Settings { get; set; }
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────
|
||||
public List<GameTask> MyTasks { get; set; } = new List<GameTask>();
|
||||
public HashSet<string> MyCompletedTaskIds { get; set; } = new HashSet<string>();
|
||||
@@ -22,13 +47,69 @@ public class GameState
|
||||
public List<PlayerInfo> Players { get; set; } = new List<PlayerInfo>();
|
||||
|
||||
// ── Meeting ───────────────────────────────────────────────────────────────
|
||||
public MeetingStartedPayload ActiveMeeting { get; set; }
|
||||
public VotingClosedPayload LastVoteResult { get; set; }
|
||||
public HashSet<string> VotedPlayerIds { get; set; } = new HashSet<string>();
|
||||
public MeetingStartedPayload ActiveMeeting { get; set; }
|
||||
public VotingClosedPayload LastVoteResult { get; set; }
|
||||
public HashSet<string> VotedPlayerIds { get; set; } = new HashSet<string>();
|
||||
public HashSet<string> ArrivedPlayerIds { get; set; } = new HashSet<string>();
|
||||
|
||||
/// <summary>Per-voter latest vote target. Voter ClientUuid -> target ClientUuid, or VoteSkip for skip.</summary>
|
||||
public Dictionary<string, string> VoterTargets { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Live vote tallies, keyed by target ClientUuid or VoteSkip. Derived from VoterTargets.</summary>
|
||||
public Dictionary<string, int> VoteTallies { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>Local player's latest vote target. Null = haven't voted; VoteSkip = skip; otherwise target ClientUuid.</summary>
|
||||
public string MyVoteTarget { get; set; }
|
||||
|
||||
/// <summary>Sentinel for "skip" votes in VoterTargets / VoteTallies / MyVoteTarget.</summary>
|
||||
public const string VoteSkip = "__SKIP__";
|
||||
|
||||
/// <summary>
|
||||
/// Derive the current meeting sub-phase from ActiveMeeting + LastVoteResult.
|
||||
/// Returns Arrival when no meeting is active (caller should also gate on Phase).
|
||||
/// </summary>
|
||||
public MeetingSubPhase GetMeetingSubPhase()
|
||||
{
|
||||
if (LastVoteResult != null) return MeetingSubPhase.Resolved;
|
||||
var m = ActiveMeeting;
|
||||
if (m == null) return MeetingSubPhase.Arrival;
|
||||
var now = DateTime.UtcNow;
|
||||
if (now >= m.VotingEndTime) return MeetingSubPhase.Resolved;
|
||||
if (m.DiscussionEndTime.HasValue && now < m.DiscussionEndTime.Value)
|
||||
{
|
||||
// Server enforces: arrival deadline AND discussion-end gate voting.
|
||||
// While arrival is still open we surface "Arrival" so players know
|
||||
// others may still be travelling; once arrival deadline passes we
|
||||
// surface "Discussion" until the voting window opens.
|
||||
return now < m.ArrivalDeadline ? MeetingSubPhase.Arrival : MeetingSubPhase.Discussion;
|
||||
}
|
||||
return MeetingSubPhase.Voting;
|
||||
}
|
||||
|
||||
/// <summary>End-of-current-sub-phase boundary as a UTC DateTime, used for countdown rendering.</summary>
|
||||
public DateTime GetMeetingSubPhaseDeadline(MeetingSubPhase sub)
|
||||
{
|
||||
var m = ActiveMeeting;
|
||||
if (m == null) return DateTime.UtcNow;
|
||||
switch (sub)
|
||||
{
|
||||
case MeetingSubPhase.Arrival:
|
||||
return m.ArrivalDeadline;
|
||||
case MeetingSubPhase.Discussion:
|
||||
return m.DiscussionEndTime ?? m.VotingEndTime;
|
||||
case MeetingSubPhase.Voting:
|
||||
return m.VotingEndTime;
|
||||
default:
|
||||
return m.VotingEndTime;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sabotage ──────────────────────────────────────────────────────────────
|
||||
public SabotageStartedPayload ActiveSabotage { get; set; }
|
||||
|
||||
/// <summary>StationIds currently being repaired (server broadcasts RepairStarted/RepairStopped).</summary>
|
||||
public HashSet<string> ActiveRepairs { get; set; } = new HashSet<string>();
|
||||
|
||||
// ── End game ──────────────────────────────────────────────────────────────
|
||||
public GameEndedPayload GameEndData { get; set; }
|
||||
|
||||
|
||||
@@ -1,366 +1,537 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using GeoSus.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Attach to any manager GameObject in the create-lobby scene.
|
||||
/// On Start it nukes all Art placeholder children from the Canvas and builds
|
||||
/// a complete mobile-portrait lobby-configuration screen entirely in code.
|
||||
/// Lives on host lobby.unity - the *settings* screen reached when the host
|
||||
/// chooses "Host" from the main menu. Originally three fixed-position
|
||||
/// controls (radius slider + impostor/task steppers). P13c expanded this to
|
||||
/// a full scrollable settings panel covering every per-lobby setting the
|
||||
/// server accepts: round shape, distances (kill / report / task / meeting /
|
||||
/// emergency / repair), cooldowns (kill / emergency / sabotage), meeting
|
||||
/// phase timings (arrival / late / discussion / voting), and sabotage
|
||||
/// timings (comms / meltdown / repair hold). All values get accumulated
|
||||
/// into GameManager.pendingSettings (a GameSettingsOverrides) and shipped
|
||||
/// with the CreateLobby request so the server can stamp them into the
|
||||
/// per-lobby snapshot at lobby creation time.
|
||||
///
|
||||
/// The scene already contains the art team's named UI:
|
||||
/// Canvas
|
||||
/// ├── Panel - full-screen background
|
||||
/// ├── RawImage - decorative logo
|
||||
/// ├── radius - TMP text "Game radius:\n" (HIDDEN, P13c)
|
||||
/// ├── idk - TMP text container (HIDDEN, P13c)
|
||||
/// ├── back - back button
|
||||
/// ├── Zmekole_geosusv2 - 3D globe object
|
||||
/// └── stvořit - "Create Lobby" button
|
||||
///
|
||||
/// We DO NOT destroy any of these. Instead we resolve them by name, wire
|
||||
/// the buttons, hide the now-unused labels, and inject a ScrollRect with
|
||||
/// the full settings panel anchored above the "stvořit" button. Anchor
|
||||
/// offsets are in the canvas's portrait reference units (1080x1920).
|
||||
/// </summary>
|
||||
public class HostLobbyUI : MonoBehaviour
|
||||
{
|
||||
// ── Colours ───────────────────────────────────────────────────────────────
|
||||
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
|
||||
static readonly Color C_BG = H("#0D0F1A");
|
||||
static readonly Color C_HDR = H("#141927");
|
||||
static readonly Color C_CARD = H("#111525");
|
||||
static readonly Color C_BORDER = H("#1E2540");
|
||||
static readonly Color C_ACCENT = H("#3399FF");
|
||||
static readonly Color C_GREEN = H("#2DB84B");
|
||||
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
|
||||
static readonly Color C_WHITE = Color.white;
|
||||
static readonly Color C_INPUT = H("#0A0D1A");
|
||||
// ── Colour palette (forwarded from UITheme) ──────────────────────────────
|
||||
static readonly Color C_TRACK = UITheme.SurfaceDim;
|
||||
static readonly Color C_FILL = UITheme.Accent;
|
||||
static readonly Color C_HANDLE = UITheme.TextHi;
|
||||
static readonly Color C_BTN_BG = UITheme.Surface;
|
||||
static readonly Color C_BTN_HI = UITheme.Accent;
|
||||
static readonly Color C_TEXT = UITheme.TextHi;
|
||||
static readonly Color C_HEADER = UITheme.Accent;
|
||||
|
||||
// ── Live values ───────────────────────────────────────────────────────────
|
||||
private float _radius = 500f;
|
||||
private int _impostors = 1;
|
||||
private int _tasks = 5;
|
||||
private string _playerName = "";
|
||||
// ── Live values (mirrored into GameManager.pending* + pendingSettings) ──
|
||||
private float _radius = 500f;
|
||||
private int _impostors = 1;
|
||||
private int _tasks = 5;
|
||||
private int _maxPlayers = 10;
|
||||
private int _killDist = 5;
|
||||
private int _reportDist = 5;
|
||||
private int _taskDist = 5;
|
||||
private int _meetingArrival = 10;
|
||||
private int _emergencyCallRadius = 5;
|
||||
private int _repairDist = 5;
|
||||
private int _killCooldownS = 20;
|
||||
private int _emergencyCooldownS = 60;
|
||||
private int _maxEmergencyMeetings = 1;
|
||||
private int _arrivalBaseS = 60;
|
||||
private int _allowedLateS = 10;
|
||||
private int _discussionS = 60;
|
||||
private int _votingS = 60;
|
||||
private int _sabotageCooldownS = 60;
|
||||
private int _commsDurationS = 60;
|
||||
private int _meltdownDeadlineS = 90;
|
||||
private int _repairHoldS = 10;
|
||||
|
||||
// ── UI refs ───────────────────────────────────────────────────────────────
|
||||
private TMP_Text _radiusValueLabel;
|
||||
private TMP_Text _impostorValueLabel;
|
||||
private TMP_Text _taskValueLabel;
|
||||
private TMP_Text _statusText;
|
||||
// ── Layout constants ────────────────────────────────────────────────────
|
||||
const float ROW_HEIGHT = 130f;
|
||||
const float HEADER_HEIGHT = 90f;
|
||||
const float SLIDER_HEIGHT = 160f;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Pre-populate from GameManager defaults
|
||||
// Wire emoji fallback before any TMP component renders, so labels
|
||||
// with emoji glyphs display properly on the very first frame rather
|
||||
// than as tofu boxes that get fixed on a later refresh.
|
||||
UITheme.EnsureEmojiFontFallback();
|
||||
|
||||
var gm = GameManager.Instance;
|
||||
if (gm != null)
|
||||
{
|
||||
// Carry over whatever the host had previously selected (if they
|
||||
// back out and come back in, settings survive).
|
||||
_radius = (float)gm.pendingRadius;
|
||||
_impostors = gm.pendingImpostorCount;
|
||||
_tasks = gm.pendingTaskCount;
|
||||
_playerName = gm.displayName ?? "";
|
||||
|
||||
// Kick off GPS init NOW so by the time the host taps "Create
|
||||
// Lobby" we have a real position fix to seed the play area with.
|
||||
gm.inputSubsystem?.EnsureGPSStarted();
|
||||
}
|
||||
|
||||
var canvasGO = GameObject.Find("Canvas");
|
||||
if (canvasGO == null)
|
||||
{
|
||||
Debug.LogError("[HostLobbyUI] No Canvas found!");
|
||||
Debug.LogError("[HostLobbyUI] No Canvas found in host lobby scene.");
|
||||
return;
|
||||
}
|
||||
var canvas = canvasGO.transform;
|
||||
|
||||
// Nuke all Art placeholder children
|
||||
var kill = new System.Collections.Generic.List<GameObject>();
|
||||
foreach (Transform child in canvasGO.transform)
|
||||
kill.Add(child.gameObject);
|
||||
foreach (var go in kill)
|
||||
DestroyImmediate(go);
|
||||
// ── Bind existing scene buttons ─────────────────────────────────────
|
||||
var backBtn = FindButton(canvas, "back");
|
||||
if (backBtn != null)
|
||||
{
|
||||
backBtn.onClick.RemoveAllListeners();
|
||||
backBtn.onClick.AddListener(() => SceneManager.LoadScene("main menu asi idk lol"));
|
||||
}
|
||||
else Debug.LogWarning("[HostLobbyUI] 'back' button not found.");
|
||||
|
||||
// Disable scene-changer components that bypass our logic
|
||||
foreach (var sc in canvasGO.GetComponentsInChildren<CudlikZmenaSceny>(true))
|
||||
sc.enabled = false;
|
||||
var createBtn = FindButton(canvas, "stvořit");
|
||||
if (createBtn != null)
|
||||
{
|
||||
createBtn.onClick.RemoveAllListeners();
|
||||
createBtn.onClick.AddListener(OnCreateClicked);
|
||||
}
|
||||
else Debug.LogWarning("[HostLobbyUI] 'stvořit' button not found.");
|
||||
|
||||
Build(canvasGO.GetComponent<RectTransform>() ?? canvasGO.AddComponent<RectTransform>());
|
||||
// ── Hide the art team's "radius" / "idk" labels ─────────────────────
|
||||
// They were placeholders for the small set of settings we previously
|
||||
// exposed; the scrollable panel below now owns all of that real estate.
|
||||
var radiusLabel = FindTMP(canvas, "radius");
|
||||
if (radiusLabel != null) radiusLabel.gameObject.SetActive(false);
|
||||
var settingsLabel = FindTMP(canvas, "idk");
|
||||
if (settingsLabel != null) settingsLabel.gameObject.SetActive(false);
|
||||
|
||||
// ── Build the scrollable settings panel ────────────────────────────
|
||||
// Pass in the actual back/create button transforms so the scroll
|
||||
// bounds can be measured against them at runtime, instead of
|
||||
// hardcoded against canvas reference units that may or may not
|
||||
// match where the art team actually placed the buttons.
|
||||
BuildSettingsScroll(canvas,
|
||||
backBtn != null ? backBtn.transform : null,
|
||||
createBtn != null ? createBtn.transform : null);
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────────────────────────
|
||||
void Build(RectTransform root)
|
||||
{
|
||||
// Full-screen background
|
||||
var bg = MakeRT("BG", root);
|
||||
Stretch(bg);
|
||||
bg.gameObject.AddComponent<CanvasRenderer>();
|
||||
Img(bg, C_BG);
|
||||
// ── Action handlers ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Header bar ───────────────────────────────────────────────────────
|
||||
var hdr = MakeRT("Header", root);
|
||||
Anchor(hdr, new Vector2(0,1), new Vector2(1,1), new Vector2(0,-80), new Vector2(0,0));
|
||||
Img(hdr, C_HDR);
|
||||
var hdrTxt = Txt("Create Lobby", hdr, 28, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
Stretch(hdrTxt.rectTransform);
|
||||
|
||||
// ── Scroll body ───────────────────────────────────────────────────────
|
||||
var scroll = MakeRT("Scroll", root);
|
||||
Anchor(scroll, new Vector2(0,0), new Vector2(1,1), new Vector2(0,80), new Vector2(0,-80));
|
||||
var sf = scroll.gameObject.AddComponent<ScrollRect>();
|
||||
sf.horizontal = false;
|
||||
Img(scroll, new Color(0,0,0,0));
|
||||
|
||||
var content = MakeRT("Content", scroll);
|
||||
content.anchorMin = new Vector2(0,1);
|
||||
content.anchorMax = new Vector2(1,1);
|
||||
content.pivot = new Vector2(0.5f, 1);
|
||||
content.offsetMin = Vector2.zero;
|
||||
content.offsetMax = Vector2.zero;
|
||||
var vlg = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.padding = new RectOffset(16, 16, 12, 12);
|
||||
vlg.spacing = 12;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = false;
|
||||
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
|
||||
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
sf.content = content;
|
||||
sf.viewport = scroll;
|
||||
|
||||
// ── Player name card ─────────────────────────────────────────────────
|
||||
AddSectionLabel("PLAYER NAME", content);
|
||||
var nameCard = AddCard(content, 70);
|
||||
var nameInput = MakeInputField("Your name", nameCard, _playerName);
|
||||
Stretch(nameInput.GetComponent<RectTransform>());
|
||||
nameInput.onEndEdit.AddListener(v =>
|
||||
{
|
||||
_playerName = v.Trim();
|
||||
var gm2 = GameManager.Instance;
|
||||
if (gm2 != null) gm2.displayName = _playerName;
|
||||
PlayerPrefs.SetString("PlayerName", _playerName);
|
||||
});
|
||||
|
||||
// ── Radius card ───────────────────────────────────────────────────────
|
||||
AddSectionLabel("PLAY AREA RADIUS", content);
|
||||
var radCard = AddCard(content, 110);
|
||||
var radRT = radCard.GetComponent<RectTransform>();
|
||||
|
||||
var radLbl = MakeRT("RadLbl", radRT);
|
||||
Anchor(radLbl, new Vector2(0,1), new Vector2(1,1), new Vector2(12,-40), new Vector2(-12,-4));
|
||||
var rt = radLbl.gameObject;
|
||||
var radTmp = rt.AddComponent<TextMeshProUGUI>();
|
||||
radTmp.text = RadiusLabel(_radius);
|
||||
radTmp.fontSize = 20; radTmp.color = C_ACCENT;
|
||||
radTmp.alignment = TextAlignmentOptions.Center;
|
||||
_radiusValueLabel = radTmp;
|
||||
|
||||
var slider = MakeRT("RadSlider", radRT).gameObject.AddComponent<Slider>();
|
||||
var srt = slider.GetComponent<RectTransform>();
|
||||
Anchor(srt, new Vector2(0,0), new Vector2(1,0), new Vector2(16, 14), new Vector2(-16, 44));
|
||||
slider.minValue = 100; slider.maxValue = 2000; slider.value = _radius;
|
||||
BuildSliderVisuals(slider, C_ACCENT);
|
||||
slider.onValueChanged.AddListener(v =>
|
||||
{
|
||||
_radius = v;
|
||||
_radiusValueLabel.text = RadiusLabel(v);
|
||||
var gm2 = GameManager.Instance;
|
||||
if (gm2 != null) gm2.pendingRadius = v;
|
||||
});
|
||||
|
||||
// ── Impostor count card ───────────────────────────────────────────────
|
||||
AddSectionLabel("IMPOSTORS", content);
|
||||
var impCard = AddCard(content, 80);
|
||||
BuildStepper(impCard.GetComponent<RectTransform>(), ref _impostors, 1, 4,
|
||||
v => { var gm2 = GameManager.Instance; if (gm2 != null) gm2.pendingImpostorCount = v; },
|
||||
out _impostorValueLabel);
|
||||
|
||||
// ── Task count card ───────────────────────────────────────────────────
|
||||
AddSectionLabel("TASKS PER PLAYER", content);
|
||||
var taskCard = AddCard(content, 80);
|
||||
BuildStepper(taskCard.GetComponent<RectTransform>(), ref _tasks, 1, 15,
|
||||
v => { var gm2 = GameManager.Instance; if (gm2 != null) gm2.pendingTaskCount = v; },
|
||||
out _taskValueLabel);
|
||||
|
||||
// ── Status text ───────────────────────────────────────────────────────
|
||||
var statusCard = AddCard(content, 40);
|
||||
_statusText = Txt("", statusCard.GetComponent<RectTransform>(), 16, C_MUTED, TextAlignmentOptions.Center);
|
||||
Stretch(_statusText.rectTransform);
|
||||
|
||||
// ── Footer create button ──────────────────────────────────────────────
|
||||
var footer = MakeRT("Footer", root);
|
||||
Anchor(footer, new Vector2(0,0), new Vector2(1,0), new Vector2(0,0), new Vector2(0,80));
|
||||
Img(footer, C_HDR);
|
||||
var btnRT = MakeRT("CreateBtn", footer);
|
||||
Anchor(btnRT, new Vector2(0.1f,0.15f), new Vector2(0.9f,0.85f), Vector2.zero, Vector2.zero);
|
||||
var btn = btnRT.gameObject.AddComponent<Button>();
|
||||
var btnImg = btnRT.gameObject.AddComponent<Image>();
|
||||
btnImg.color = C_GREEN;
|
||||
btn.targetGraphic = btnImg;
|
||||
var cb = btn.colors; cb.pressedColor = C_GREEN * 0.7f; btn.colors = cb;
|
||||
var btnTxt = Txt("CREATE LOBBY", btnRT, 22, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
Stretch(btnTxt.rectTransform);
|
||||
btn.onClick.AddListener(OnCreateClicked);
|
||||
|
||||
// ── Back button ───────────────────────────────────────────────────────
|
||||
var backRT = MakeRT("BackBtn", root);
|
||||
Anchor(backRT, new Vector2(0,1), new Vector2(0,1), new Vector2(8,-72), new Vector2(72,-8));
|
||||
var backBtn = backRT.gameObject.AddComponent<Button>();
|
||||
var backImg = backRT.gameObject.AddComponent<Image>();
|
||||
backImg.color = new Color(1,1,1,0);
|
||||
backBtn.targetGraphic = backImg;
|
||||
var backTxt = Txt("←", backRT, 28, C_MUTED, TextAlignmentOptions.Center);
|
||||
Stretch(backTxt.rectTransform);
|
||||
backBtn.onClick.AddListener(() => UnityEngine.SceneManagement.SceneManager.LoadScene("main menu asi idk lol"));
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
void OnCreateClicked()
|
||||
{
|
||||
var gm = GameManager.Instance;
|
||||
if (gm == null) { SetStatus("GameManager not found!", Color.red); return; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_playerName))
|
||||
{
|
||||
SetStatus("Enter a player name first.", Color.yellow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gm == null) return;
|
||||
// Keep the legacy flat fields populated for any caller that still
|
||||
// reads them directly, and ALSO populate pendingSettings with the
|
||||
// full override snapshot so the server can stamp it into the lobby.
|
||||
gm.pendingRadius = _radius;
|
||||
gm.pendingImpostorCount = _impostors;
|
||||
gm.pendingTaskCount = _tasks;
|
||||
gm.displayName = _playerName;
|
||||
|
||||
SetStatus("Connecting…", C_MUTED);
|
||||
gm.pendingSettings = BuildOverrides();
|
||||
gm.CreateLobbyButton();
|
||||
}
|
||||
|
||||
void SetStatus(string msg, Color col)
|
||||
/// <summary>
|
||||
/// Pack the current control values into a wire-shape GameSettingsOverrides.
|
||||
/// Every field is non-null - the server treats null as "use my default",
|
||||
/// but since this UI exposes every field we always have a concrete value
|
||||
/// to ship. Time fields are exposed in seconds and converted to ms here.
|
||||
/// </summary>
|
||||
GameSettingsOverrides BuildOverrides()
|
||||
{
|
||||
if (_statusText == null) return;
|
||||
_statusText.text = msg;
|
||||
_statusText.color = col;
|
||||
return new GameSettingsOverrides
|
||||
{
|
||||
// Round shape
|
||||
MaxPlayers = _maxPlayers,
|
||||
ImpostorCount = _impostors,
|
||||
TaskCount = _tasks,
|
||||
// Distances (m)
|
||||
KillDistanceM = _killDist,
|
||||
ReportDistanceM = _reportDist,
|
||||
TaskStartDistanceM = _taskDist,
|
||||
MeetingArrivalRadiusM = _meetingArrival,
|
||||
EmergencyMeetingCallRadiusM = _emergencyCallRadius,
|
||||
RepairStationDistanceM = _repairDist,
|
||||
// Cooldowns / counts
|
||||
KillCooldownMs = _killCooldownS * 1000,
|
||||
EmergencyMeetingCooldownMs = _emergencyCooldownS * 1000,
|
||||
MaxEmergencyMeetingsPerPlayer = _maxEmergencyMeetings,
|
||||
// Meeting phases (ms)
|
||||
ArrivalBaseMs = _arrivalBaseS * 1000,
|
||||
AllowedLateMs = _allowedLateS * 1000,
|
||||
DiscussionPhaseMs = _discussionS * 1000,
|
||||
VotingPhaseMs = _votingS * 1000,
|
||||
// Sabotage
|
||||
SabotageCooldownMs = _sabotageCooldownS * 1000,
|
||||
CommsBlackoutDurationMs = _commsDurationS * 1000,
|
||||
CriticalMeltdownDeadlineMs = _meltdownDeadlineS * 1000,
|
||||
RepairStationHoldMs = _repairHoldS * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ── UI helpers ────────────────────────────────────────────────────────────
|
||||
static string RadiusLabel(float v) => $"{Mathf.RoundToInt(v)} m";
|
||||
// ── Settings ScrollRect ─────────────────────────────────────────────────
|
||||
|
||||
void BuildStepper(RectTransform parent, ref int val, int min, int max,
|
||||
System.Action<int> onChange, out TMP_Text label)
|
||||
/// <summary>
|
||||
/// Build the scrollable panel: ScrollRect → Viewport (RectMask2D) →
|
||||
/// Content (VerticalLayoutGroup + ContentSizeFitter). Each setting goes
|
||||
/// in as a row with a LayoutElement.preferredHeight so the VLG can stack
|
||||
/// them and the ContentSizeFitter can compute the scroll height.
|
||||
/// Pattern mirrors InGameHUDBuilder.BuildMeetingScroll.
|
||||
///
|
||||
/// Bounds are MEASURED against the actual back/stvořit RectTransforms
|
||||
/// at runtime - not hardcoded against canvas reference units. The
|
||||
/// previous version pinned the scroll to 280..1700 in 1080x1920 ref
|
||||
/// space, which overlapped the create button on real layouts and made
|
||||
/// it un-tappable. We now place the scroll's bottom edge a fixed margin
|
||||
/// above stvořit's top edge, and its top edge a fixed margin below
|
||||
/// back's bottom edge, falling back to the old constants only if either
|
||||
/// button is missing.
|
||||
/// </summary>
|
||||
void BuildSettingsScroll(Transform canvas, Transform backT, Transform stvoritT)
|
||||
{
|
||||
int captured = val; // local copy for closures
|
||||
// Force the canvas's layout to settle so GetWorldCorners returns the
|
||||
// real positioned rects rather than authored-time placeholders.
|
||||
Canvas.ForceUpdateCanvases();
|
||||
|
||||
// minus
|
||||
const float MARGIN = 40f; // px (in canvas reference units) above/below buttons
|
||||
|
||||
var canvasRT = canvas as RectTransform;
|
||||
// Pivot offset so we can express Y as "from canvas bottom" rather
|
||||
// than the pivot-relative form InverseTransformPoint hands back.
|
||||
float pivotYOffset = canvasRT != null
|
||||
? canvasRT.pivot.y * canvasRT.rect.height
|
||||
: 0f;
|
||||
|
||||
float scrollBottomY = 280f; // safe fallback if stvořit is missing
|
||||
float scrollTopY = 1700f; // safe fallback if back is missing
|
||||
|
||||
if (canvasRT != null && stvoritT is RectTransform stvoritRT)
|
||||
{
|
||||
var corners = new Vector3[4];
|
||||
stvoritRT.GetWorldCorners(corners); // [0]=BL, [1]=TL, [2]=TR, [3]=BR
|
||||
var topLocal = canvasRT.InverseTransformPoint(corners[1]);
|
||||
scrollBottomY = topLocal.y + pivotYOffset + MARGIN;
|
||||
}
|
||||
if (canvasRT != null && backT is RectTransform backRT)
|
||||
{
|
||||
var corners = new Vector3[4];
|
||||
backRT.GetWorldCorners(corners);
|
||||
var bottomLocal = canvasRT.InverseTransformPoint(corners[0]);
|
||||
scrollTopY = bottomLocal.y + pivotYOffset - MARGIN;
|
||||
}
|
||||
|
||||
// Sanity-clamp: if either button is positioned weirdly (e.g. back
|
||||
// is below the create button, or they overlap), the computed
|
||||
// bounds would be a negative-height rect that draws as a 1px
|
||||
// sliver. Detect and fall back to the portrait-reference defaults.
|
||||
if (scrollTopY <= scrollBottomY + 200f)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[HostLobbyUI] Scroll bounds collapsed ({scrollBottomY:F0}..{scrollTopY:F0}); " +
|
||||
"falling back to 280..1700 portrait defaults.");
|
||||
scrollBottomY = 280f;
|
||||
scrollTopY = 1700f;
|
||||
}
|
||||
|
||||
var scrollRT = MakeRT("SettingsScroll", canvas);
|
||||
Anchor(scrollRT, new Vector2(0.05f, 0), new Vector2(0.95f, 0),
|
||||
new Vector2(0, scrollBottomY), new Vector2(0, scrollTopY));
|
||||
|
||||
// Subtle dark backdrop so the scroll area visually separates from
|
||||
// the canvas's full-screen Panel underneath.
|
||||
var bgImg = scrollRT.gameObject.AddComponent<Image>();
|
||||
bgImg.color = new Color(0f, 0f, 0f, 0.55f);
|
||||
|
||||
var sr = scrollRT.gameObject.AddComponent<ScrollRect>();
|
||||
|
||||
var vp = MakeRT("Viewport", scrollRT);
|
||||
Stretch(vp);
|
||||
vp.gameObject.AddComponent<RectMask2D>();
|
||||
|
||||
var content = MakeRT("Content", vp);
|
||||
content.anchorMin = new Vector2(0, 1);
|
||||
content.anchorMax = new Vector2(1, 1);
|
||||
content.pivot = new Vector2(0.5f, 1);
|
||||
var vlg = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.childControlWidth = true;
|
||||
vlg.childControlHeight = false;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.padding = new RectOffset(20, 20, 20, 20);
|
||||
vlg.spacing = 12;
|
||||
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
|
||||
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
sr.viewport = vp;
|
||||
sr.content = content;
|
||||
sr.horizontal = false;
|
||||
sr.vertical = true;
|
||||
sr.scrollSensitivity = 80;
|
||||
sr.movementType = ScrollRect.MovementType.Clamped;
|
||||
|
||||
// ── Round shape ────────────────────────────────────────────────────
|
||||
AddHeader(content, "Round");
|
||||
AddSlider(content, "Game radius", 100, 2000, _radius,
|
||||
v => _radius = v,
|
||||
v => Mathf.RoundToInt(v) + " m");
|
||||
AddStepper(content, "Max players", _maxPlayers, 4, 15, v => _maxPlayers = v);
|
||||
AddStepper(content, "Impostors", _impostors, 1, 4, v => _impostors = v);
|
||||
AddStepper(content, "Tasks per crew", _tasks, 1, 15, v => _tasks = v);
|
||||
|
||||
// ── Distances ──────────────────────────────────────────────────────
|
||||
AddHeader(content, "Distances (m)");
|
||||
AddStepper(content, "Kill", _killDist, 1, 30, v => _killDist = v);
|
||||
AddStepper(content, "Report", _reportDist, 1, 30, v => _reportDist = v);
|
||||
AddStepper(content, "Task start", _taskDist, 1, 30, v => _taskDist = v);
|
||||
AddStepper(content, "Meeting arrival", _meetingArrival, 5, 50, v => _meetingArrival = v);
|
||||
AddStepper(content, "Emergency call", _emergencyCallRadius, 1, 30, v => _emergencyCallRadius = v);
|
||||
AddStepper(content, "Repair station", _repairDist, 1, 30, v => _repairDist = v);
|
||||
|
||||
// ── Cooldowns ──────────────────────────────────────────────────────
|
||||
AddHeader(content, "Cooldowns (s)");
|
||||
AddStepper(content, "Kill cooldown", _killCooldownS, 5, 120, v => _killCooldownS = v);
|
||||
AddStepper(content, "Emergency cd", _emergencyCooldownS, 10, 600, v => _emergencyCooldownS = v);
|
||||
AddStepper(content, "Max emergencies", _maxEmergencyMeetings, 0, 10, v => _maxEmergencyMeetings = v);
|
||||
AddStepper(content, "Sabotage cd", _sabotageCooldownS, 10, 600, v => _sabotageCooldownS = v);
|
||||
|
||||
// ── Meeting phases ─────────────────────────────────────────────────
|
||||
AddHeader(content, "Meeting phases (s)");
|
||||
AddStepper(content, "Arrival time", _arrivalBaseS, 30, 300, v => _arrivalBaseS = v);
|
||||
AddStepper(content, "Allowed late", _allowedLateS, 0, 120, v => _allowedLateS = v);
|
||||
AddStepper(content, "Discussion", _discussionS, 10, 300, v => _discussionS = v);
|
||||
AddStepper(content, "Voting", _votingS, 10, 300, v => _votingS = v);
|
||||
|
||||
// ── Sabotage ───────────────────────────────────────────────────────
|
||||
AddHeader(content, "Sabotage (s)");
|
||||
AddStepper(content, "Comms duration", _commsDurationS, 10, 600, v => _commsDurationS = v);
|
||||
AddStepper(content, "Meltdown deadline", _meltdownDeadlineS, 30, 600, v => _meltdownDeadlineS = v);
|
||||
AddStepper(content, "Repair hold", _repairHoldS, 1, 60, v => _repairHoldS = v);
|
||||
}
|
||||
|
||||
void AddHeader(RectTransform parent, string text)
|
||||
{
|
||||
var rt = MakeRT("Header_" + text, parent);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.preferredHeight = HEADER_HEIGHT;
|
||||
le.minHeight = HEADER_HEIGHT;
|
||||
|
||||
var bg = rt.gameObject.AddComponent<Image>();
|
||||
bg.color = new Color(C_HEADER.r, C_HEADER.g, C_HEADER.b, 0.45f);
|
||||
|
||||
var txtRT = MakeRT("Txt", rt);
|
||||
Stretch(txtRT);
|
||||
var tmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = 44;
|
||||
tmp.color = C_TEXT;
|
||||
tmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
tmp.fontStyle = FontStyles.Bold;
|
||||
tmp.margin = new Vector4(20, 0, 20, 0);
|
||||
}
|
||||
|
||||
void AddStepper(RectTransform parent, string label, int initial, int min, int max, System.Action<int> onChange)
|
||||
{
|
||||
var rt = MakeRT("Row_" + label, parent);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.preferredHeight = ROW_HEIGHT;
|
||||
le.minHeight = ROW_HEIGHT;
|
||||
BuildStepperRow(rt, label, initial, min, max, onChange);
|
||||
}
|
||||
|
||||
void AddSlider(RectTransform parent, string label, float min, float max, float initial,
|
||||
System.Action<float> onChange, System.Func<float, string> formatter)
|
||||
{
|
||||
var rt = MakeRT("Row_" + label, parent);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>();
|
||||
le.preferredHeight = SLIDER_HEIGHT;
|
||||
le.minHeight = SLIDER_HEIGHT;
|
||||
|
||||
// Top half: label (left) + value readout (right)
|
||||
var labelRT = MakeRT("Label", rt);
|
||||
Anchor(labelRT, new Vector2(0, 0.55f), new Vector2(0.7f, 1f), Vector2.zero, Vector2.zero);
|
||||
var lblTmp = labelRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
lblTmp.text = label;
|
||||
lblTmp.fontSize = 36;
|
||||
lblTmp.color = C_TEXT;
|
||||
lblTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
lblTmp.fontStyle = FontStyles.Bold;
|
||||
|
||||
var valRT = MakeRT("Val", rt);
|
||||
Anchor(valRT, new Vector2(0.7f, 0.55f), new Vector2(1f, 1f), Vector2.zero, Vector2.zero);
|
||||
var valTmp = valRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
valTmp.text = formatter(initial);
|
||||
valTmp.fontSize = 36;
|
||||
valTmp.color = C_TEXT;
|
||||
valTmp.alignment = TextAlignmentOptions.MidlineRight;
|
||||
valTmp.fontStyle = FontStyles.Bold;
|
||||
|
||||
// Bottom half: slider
|
||||
var sliderRT = MakeRT("Slider", rt);
|
||||
Anchor(sliderRT, new Vector2(0, 0.05f), new Vector2(1, 0.45f),
|
||||
Vector2.zero, Vector2.zero);
|
||||
var slider = sliderRT.gameObject.AddComponent<Slider>();
|
||||
slider.minValue = min;
|
||||
slider.maxValue = max;
|
||||
slider.value = initial;
|
||||
BuildSliderVisuals(slider);
|
||||
slider.onValueChanged.AddListener(v =>
|
||||
{
|
||||
onChange?.Invoke(v);
|
||||
valTmp.text = formatter(v);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Stepper row: [Label] [-] [value] [+] ─────────────────────────────
|
||||
void BuildStepperRow(RectTransform parent, string label, int initial,
|
||||
int min, int max, System.Action<int> onChange)
|
||||
{
|
||||
int captured = Mathf.Clamp(initial, min, max);
|
||||
|
||||
// Left half: label
|
||||
var lblRT = MakeRT("Label", parent);
|
||||
Anchor(lblRT, new Vector2(0, 0), new Vector2(0.55f, 1), Vector2.zero, Vector2.zero);
|
||||
var lblTmp = lblRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
lblTmp.text = label;
|
||||
lblTmp.fontSize = 32;
|
||||
lblTmp.color = C_TEXT;
|
||||
lblTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
lblTmp.fontStyle = FontStyles.Bold;
|
||||
|
||||
// Minus button
|
||||
var minusRT = MakeRT("Minus", parent);
|
||||
Anchor(minusRT, new Vector2(0,0), new Vector2(0,1), new Vector2(8,8), new Vector2(72,-8));
|
||||
var minusBtn = minusRT.gameObject.AddComponent<Button>();
|
||||
var minusImg = minusRT.gameObject.AddComponent<Image>(); minusImg.color = C_BORDER;
|
||||
minusBtn.targetGraphic = minusImg;
|
||||
var minusTxt = Txt("−", minusRT, 28, C_WHITE, TextAlignmentOptions.Center);
|
||||
Stretch(minusTxt.rectTransform);
|
||||
Anchor(minusRT, new Vector2(0.55f, 0.10f), new Vector2(0.69f, 0.90f), Vector2.zero, Vector2.zero);
|
||||
var minusBtn = MakeButton(minusRT, "-", C_BTN_BG);
|
||||
|
||||
// label
|
||||
var lblRT = MakeRT("Val", parent);
|
||||
Stretch(lblRT);
|
||||
var lbl = Txt(captured.ToString(), lblRT, 26, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
label = lbl;
|
||||
// Value label
|
||||
var valRT = MakeRT("Val", parent);
|
||||
Anchor(valRT, new Vector2(0.69f, 0), new Vector2(0.83f, 1), Vector2.zero, Vector2.zero);
|
||||
var valTmp = valRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
valTmp.text = captured.ToString();
|
||||
valTmp.fontSize = 40;
|
||||
valTmp.color = C_TEXT;
|
||||
valTmp.alignment = TextAlignmentOptions.Center;
|
||||
valTmp.fontStyle = FontStyles.Bold;
|
||||
|
||||
// plus
|
||||
// Plus button
|
||||
var plusRT = MakeRT("Plus", parent);
|
||||
Anchor(plusRT, new Vector2(1,0), new Vector2(1,1), new Vector2(-72,8), new Vector2(-8,-8));
|
||||
var plusBtn = plusRT.gameObject.AddComponent<Button>();
|
||||
var plusImg = plusRT.gameObject.AddComponent<Image>(); plusImg.color = C_ACCENT;
|
||||
plusBtn.targetGraphic = plusImg;
|
||||
var plusTxt = Txt("+", plusRT, 28, C_WHITE, TextAlignmentOptions.Center);
|
||||
Stretch(plusTxt.rectTransform);
|
||||
Anchor(plusRT, new Vector2(0.83f, 0.10f), new Vector2(0.97f, 0.90f), Vector2.zero, Vector2.zero);
|
||||
var plusBtn = MakeButton(plusRT, "+", C_BTN_HI);
|
||||
|
||||
minusBtn.onClick.AddListener(() =>
|
||||
{
|
||||
captured = Mathf.Max(min, captured - 1);
|
||||
lbl.text = captured.ToString();
|
||||
valTmp.text = captured.ToString();
|
||||
onChange?.Invoke(captured);
|
||||
});
|
||||
plusBtn.onClick.AddListener(() =>
|
||||
{
|
||||
captured = Mathf.Min(max, captured + 1);
|
||||
lbl.text = captured.ToString();
|
||||
valTmp.text = captured.ToString();
|
||||
onChange?.Invoke(captured);
|
||||
});
|
||||
|
||||
// write back initial
|
||||
val = captured;
|
||||
}
|
||||
|
||||
static void BuildSliderVisuals(Slider s, Color fillColor)
|
||||
Button MakeButton(RectTransform rt, string label, Color bg)
|
||||
{
|
||||
// Background track
|
||||
var bgRT = MakeRT("Background", s.GetComponent<RectTransform>());
|
||||
Stretch(bgRT);
|
||||
bgRT.gameObject.AddComponent<CanvasRenderer>();
|
||||
var bgImg = bgRT.gameObject.AddComponent<Image>(); bgImg.color = new Color(0.15f,0.18f,0.28f);
|
||||
var img = rt.gameObject.AddComponent<Image>();
|
||||
img.color = bg;
|
||||
var btn = rt.gameObject.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
|
||||
// Fill area
|
||||
var fillArea = MakeRT("Fill Area", s.GetComponent<RectTransform>());
|
||||
Anchor(fillArea, new Vector2(0,0.25f), new Vector2(1,0.75f), new Vector2(5,0), new Vector2(-5,0));
|
||||
var txtRT = MakeRT("Txt", rt);
|
||||
Stretch(txtRT);
|
||||
var txtTmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
txtTmp.text = label;
|
||||
txtTmp.fontSize = 52;
|
||||
txtTmp.color = C_TEXT;
|
||||
txtTmp.alignment = TextAlignmentOptions.Center;
|
||||
txtTmp.fontStyle = FontStyles.Bold;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ── Slider visual fill-in (Unity gives you no default if you AddComponent) ─
|
||||
void BuildSliderVisuals(Slider s)
|
||||
{
|
||||
var sRT = s.GetComponent<RectTransform>();
|
||||
|
||||
var bgRT = MakeRT("Background", sRT);
|
||||
Stretch(bgRT);
|
||||
var bgImg = bgRT.gameObject.AddComponent<Image>();
|
||||
bgImg.color = C_TRACK;
|
||||
|
||||
var fillArea = MakeRT("Fill Area", sRT);
|
||||
Anchor(fillArea, new Vector2(0, 0.30f), new Vector2(1, 0.70f),
|
||||
new Vector2(8, 0), new Vector2(-8, 0));
|
||||
var fillRT = MakeRT("Fill", fillArea);
|
||||
fillRT.anchorMin = Vector2.zero; fillRT.anchorMax = Vector2.one;
|
||||
fillRT.offsetMin = Vector2.zero; fillRT.offsetMax = Vector2.zero;
|
||||
fillRT.gameObject.AddComponent<CanvasRenderer>();
|
||||
var fillImg = fillRT.gameObject.AddComponent<Image>(); fillImg.color = fillColor;
|
||||
fillRT.anchorMin = Vector2.zero;
|
||||
fillRT.anchorMax = Vector2.one;
|
||||
fillRT.offsetMin = Vector2.zero;
|
||||
fillRT.offsetMax = Vector2.zero;
|
||||
var fillImg = fillRT.gameObject.AddComponent<Image>();
|
||||
fillImg.color = C_FILL;
|
||||
s.fillRect = fillRT;
|
||||
|
||||
// Handle area
|
||||
var handleArea = MakeRT("Handle Slide Area", s.GetComponent<RectTransform>());
|
||||
var handleArea = MakeRT("Handle Slide Area", sRT);
|
||||
Stretch(handleArea);
|
||||
var handleRT = MakeRT("Handle", handleArea);
|
||||
handleRT.sizeDelta = new Vector2(28, 28);
|
||||
handleRT.anchorMin = new Vector2(0, 0.5f); handleRT.anchorMax = new Vector2(0, 0.5f);
|
||||
handleRT.sizeDelta = new Vector2(40, 40);
|
||||
handleRT.anchorMin = new Vector2(0, 0.5f);
|
||||
handleRT.anchorMax = new Vector2(0, 0.5f);
|
||||
handleRT.anchoredPosition = Vector2.zero;
|
||||
handleRT.gameObject.AddComponent<CanvasRenderer>();
|
||||
var hImg = handleRT.gameObject.AddComponent<Image>(); hImg.color = Color.white;
|
||||
s.handleRect = handleRT;
|
||||
var hImg = handleRT.gameObject.AddComponent<Image>();
|
||||
hImg.color = C_HANDLE;
|
||||
s.handleRect = handleRT;
|
||||
s.targetGraphic = hImg;
|
||||
}
|
||||
|
||||
static void AddSectionLabel(string text, RectTransform parent)
|
||||
// ── Scene-binding helpers ────────────────────────────────────────────────
|
||||
|
||||
static Button FindButton(Transform root, string name)
|
||||
{
|
||||
var rt = MakeRT("Lbl_" + text, parent);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = 28;
|
||||
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text; tmp.fontSize = 13; tmp.color = new Color(0.47f,0.53f,0.67f);
|
||||
tmp.fontStyle = FontStyles.Bold; tmp.alignment = TextAlignmentOptions.Left;
|
||||
var t = FindByName(root, name);
|
||||
return t != null ? t.GetComponent<Button>() : null;
|
||||
}
|
||||
|
||||
static RectTransform AddCard(RectTransform parent, float height)
|
||||
static TMP_Text FindTMP(Transform root, string name)
|
||||
{
|
||||
var rt = MakeRT("Card", parent);
|
||||
var le = rt.gameObject.AddComponent<LayoutElement>(); le.preferredHeight = height;
|
||||
var img = rt.gameObject.AddComponent<Image>(); img.color = H("#111525");
|
||||
// border via outline
|
||||
var outline = rt.gameObject.AddComponent<Outline>();
|
||||
outline.effectColor = H("#1E2540");
|
||||
outline.effectDistance = new Vector2(1, -1);
|
||||
return rt;
|
||||
var t = FindByName(root, name);
|
||||
return t != null ? t.GetComponent<TMP_Text>() : null;
|
||||
}
|
||||
|
||||
static TMP_InputField MakeInputField(string placeholder, RectTransform parent, string initialValue)
|
||||
static Transform FindByName(Transform root, string name)
|
||||
{
|
||||
var go = new GameObject("InputField");
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
rt.SetParent(parent, false);
|
||||
rt.localScale = Vector3.one;
|
||||
|
||||
var img = go.AddComponent<Image>(); img.color = H("#0A0D1A");
|
||||
var field = go.AddComponent<TMP_InputField>();
|
||||
|
||||
var textArea = MakeRT("Text Area", rt);
|
||||
Stretch(textArea);
|
||||
textArea.offsetMin = new Vector2(10, 4);
|
||||
textArea.offsetMax = new Vector2(-10, -4);
|
||||
var areaImg = textArea.gameObject.AddComponent<Image>(); areaImg.color = new Color(0,0,0,0);
|
||||
var mask = textArea.gameObject.AddComponent<RectMask2D>();
|
||||
|
||||
var phRT = MakeRT("Placeholder", textArea);
|
||||
Stretch(phRT);
|
||||
var phTmp = phRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
phTmp.text = placeholder; phTmp.fontSize = 20;
|
||||
phTmp.color = new Color(0.47f, 0.53f, 0.67f);
|
||||
phTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
|
||||
var txtRT = MakeRT("Text", textArea);
|
||||
Stretch(txtRT);
|
||||
var txtTmp = txtRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
txtTmp.fontSize = 20; txtTmp.color = Color.white;
|
||||
txtTmp.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
|
||||
field.textViewport = textArea;
|
||||
field.textComponent = txtTmp;
|
||||
field.placeholder = phTmp;
|
||||
field.text = initialValue;
|
||||
field.targetGraphic = img;
|
||||
|
||||
return field;
|
||||
if (root == null) return null;
|
||||
if (root.name == name) return root;
|
||||
foreach (Transform child in root)
|
||||
{
|
||||
var found = FindByName(child, name);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static RectTransform MakeRT(string name, RectTransform parent)
|
||||
// ── Layout helpers (shared with the injected controls) ───────────────────
|
||||
|
||||
static RectTransform MakeRT(string name, Transform parent)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
@@ -371,29 +542,18 @@ public class HostLobbyUI : MonoBehaviour
|
||||
|
||||
static void Stretch(RectTransform rt)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax, Vector2 offMin, Vector2 offMax)
|
||||
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
|
||||
Vector2 offMin, Vector2 offMax)
|
||||
{
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = offMin; rt.offsetMax = offMax;
|
||||
}
|
||||
|
||||
static Image Img(RectTransform rt, Color c)
|
||||
{
|
||||
rt.gameObject.AddComponent<CanvasRenderer>();
|
||||
var img = rt.gameObject.AddComponent<Image>(); img.color = c; return img;
|
||||
}
|
||||
|
||||
static TextMeshProUGUI Txt(string text, RectTransform parent, float size, Color color,
|
||||
TextAlignmentOptions align, bool bold = false)
|
||||
{
|
||||
var rt = MakeRT("T_" + text, parent);
|
||||
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text; tmp.fontSize = size; tmp.color = color; tmp.alignment = align;
|
||||
if (bold) tmp.fontStyle = FontStyles.Bold;
|
||||
return tmp;
|
||||
rt.anchorMin = aMin;
|
||||
rt.anchorMax = aMax;
|
||||
rt.offsetMin = offMin;
|
||||
rt.offsetMax = offMax;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,21 @@ using System.Collections.Generic;
|
||||
/// ActionButton – proxim action button
|
||||
/// SabotagePanel – top-of-screen sabotage banner
|
||||
/// SabotageTimer – TMP countdown text inside SabotagePanel
|
||||
/// MeetingPanel – full-screen voting overlay
|
||||
/// MeetingHeader – TMP title
|
||||
/// MeetingPlayerList – TMP player list (text fallback)
|
||||
/// SkipButton – skip-vote button
|
||||
/// VoteResultPanel – sub-panel shown after voting
|
||||
/// VoteResult – TMP result text
|
||||
/// MeetingPanel – full-screen voting overlay
|
||||
/// MeetingHeader – TMP title (event type)
|
||||
/// MeetingPhaseLabel – TMP sub-phase label (DISCUSSION / VOTING / RESULTS / ARRIVAL)
|
||||
/// MeetingPhaseProgressBar – Image filled, drains with countdown
|
||||
/// MeetingPhaseCountdown – TMP "0:24" countdown text
|
||||
/// MeetingPlayerList – TMP player list (text fallback)
|
||||
/// MyVoteIndicator – TMP "You voted for: X" strip
|
||||
/// SkipButton – skip-vote button
|
||||
/// VoteResultPanel – sub-panel shown after voting
|
||||
/// VoteResult – TMP result text
|
||||
/// GameEndPanel – full-screen end-of-game overlay
|
||||
/// GameEndText – TMP result text
|
||||
/// ReconnectOverlay – full-screen "reconnecting..." overlay
|
||||
/// ReconnectMessage – TMP "Reconnecting..." headline
|
||||
/// ReconnectSubtext – TMP secondary message
|
||||
/// KillCooldown – TMP kill-cooldown label
|
||||
/// TaskList – TMP task name list
|
||||
/// TaskProgress – TMP global task progress
|
||||
@@ -29,20 +36,29 @@ using System.Collections.Generic;
|
||||
public class InGameHUDBuilder : MonoBehaviour
|
||||
{
|
||||
// ── Palette ───────────────────────────────────────────────────────────────
|
||||
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
|
||||
static readonly Color C_BG = H("#0D0F1A");
|
||||
static readonly Color C_BAR = H("#141927");
|
||||
static readonly Color C_ACCENT = H("#3399FF");
|
||||
static readonly Color C_GREEN = H("#2DB84B");
|
||||
static readonly Color C_RED = H("#C43232");
|
||||
static readonly Color C_ORANGE = H("#F08C1A");
|
||||
static readonly Color C_YELLOW = H("#FFB800");
|
||||
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
|
||||
static readonly Color C_ROW_A = H("#1A2035");
|
||||
static readonly Color C_ROW_B = H("#161C2E");
|
||||
// Aliases to UITheme so this file's existing local references keep working
|
||||
// without a wholesale rename pass. New code should call UITheme.* directly;
|
||||
// the C_* names stay as a transitional crutch for in-flight edits.
|
||||
static readonly Color C_BG = UITheme.Bg;
|
||||
static readonly Color C_BAR = UITheme.Surface;
|
||||
static readonly Color C_ACCENT = UITheme.Accent;
|
||||
static readonly Color C_GREEN = UITheme.Success;
|
||||
static readonly Color C_RED = UITheme.Danger;
|
||||
static readonly Color C_ORANGE = UITheme.Warning;
|
||||
static readonly Color C_YELLOW = UITheme.Caution;
|
||||
static readonly Color C_MUTED = UITheme.TextLo;
|
||||
static readonly Color C_ROW_A = UITheme.RowA;
|
||||
static readonly Color C_ROW_B = UITheme.RowB;
|
||||
|
||||
private bool _built;
|
||||
|
||||
// Reference resolution constants kept here as public surface so external
|
||||
// callers (other UI scripts that grew to use these) don't break. Forward
|
||||
// to UITheme so there's still one source of truth.
|
||||
public const float kReferenceWidth = UITheme.ReferenceWidth;
|
||||
public const float kReferenceHeight = UITheme.ReferenceHeight;
|
||||
public const float kMatchWidthHeight = UITheme.MatchWidthOrHeight;
|
||||
|
||||
public void BuildNow() { if (!_built) { _built = true; Build(); } }
|
||||
void Start() { if (!_built) Build(); }
|
||||
|
||||
@@ -51,6 +67,25 @@ public class InGameHUDBuilder : MonoBehaviour
|
||||
var rt = GetComponent<RectTransform>();
|
||||
if (rt == null) return;
|
||||
|
||||
// Wire up emoji fallback before any TMP component renders. Idempotent
|
||||
// and cheap on subsequent calls.
|
||||
UITheme.EnsureEmojiFontFallback();
|
||||
|
||||
// Make sure the host canvas has a CanvasScaler with our reference
|
||||
// resolution. Without this, RectTransform offsets are interpreted as
|
||||
// raw pixels and the layout looks correct only on whichever device
|
||||
// the project was last opened against.
|
||||
ConfigureCanvasScaler(GetComponentInParent<Canvas>());
|
||||
|
||||
// Apply Screen.safeArea so iOS notches and Android punch-hole cameras
|
||||
// don't eat the top/bottom bar. We anchor the host RectTransform to
|
||||
// the safe rectangle so all child anchors inherit the inset.
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
ApplySafeArea(rt);
|
||||
|
||||
BuildTopBar(rt);
|
||||
BuildTaskPanel(rt);
|
||||
BuildTaskProgress(rt);
|
||||
@@ -59,9 +94,51 @@ public class InGameHUDBuilder : MonoBehaviour
|
||||
BuildSabotagePanel(rt);
|
||||
BuildMeetingPanel(rt);
|
||||
BuildGameEndPanel(rt);
|
||||
BuildReconnectOverlay(rt);
|
||||
BuildSpectatePanel(rt);
|
||||
BuildToast(rt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the project's standard CanvasScaler config to a Canvas. Idempotent -
|
||||
/// adds the component if missing, otherwise updates settings in-place.
|
||||
/// </summary>
|
||||
public static void ConfigureCanvasScaler(Canvas canvas)
|
||||
{
|
||||
if (canvas == null) return;
|
||||
var scaler = canvas.GetComponent<CanvasScaler>()
|
||||
?? canvas.gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(kReferenceWidth, kReferenceHeight);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = kMatchWidthHeight;
|
||||
scaler.referencePixelsPerUnit = 100f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anchors the given RectTransform to the screen's safe rectangle, so all
|
||||
/// children inherit the inset. Called once at build time; the safe area
|
||||
/// rarely changes after the app launches and a full HUD rebuild on rotation
|
||||
/// would be the simpler way to handle that case.
|
||||
/// </summary>
|
||||
public static void ApplySafeArea(RectTransform rt)
|
||||
{
|
||||
if (rt == null) return;
|
||||
var safe = Screen.safeArea;
|
||||
var screenSize = new Vector2(Screen.width, Screen.height);
|
||||
if (screenSize.x <= 0 || screenSize.y <= 0) return;
|
||||
|
||||
Vector2 aMin = safe.position;
|
||||
Vector2 aMax = safe.position + safe.size;
|
||||
aMin.x /= screenSize.x; aMin.y /= screenSize.y;
|
||||
aMax.x /= screenSize.x; aMax.y /= screenSize.y;
|
||||
|
||||
rt.anchorMin = aMin;
|
||||
rt.anchorMax = aMax;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
// ── Top bar ───────────────────────────────────────────────────────────────
|
||||
void BuildTopBar(RectTransform parent)
|
||||
{
|
||||
@@ -166,45 +243,93 @@ public class InGameHUDBuilder : MonoBehaviour
|
||||
}
|
||||
|
||||
// ── Meeting panel (full screen overlay) ───────────────────────────────────
|
||||
//
|
||||
// Layout (vertical, top to bottom):
|
||||
// 0.90 - 1.00 MeetingHeader - "EMERGENCY MEETING" / "BODY REPORTED"
|
||||
// 0.83 - 0.90 MeetingPhaseLabel - "ARRIVAL" / "DISCUSSION" / "VOTING" / "RESULTS"
|
||||
// 0.79 - 0.83 MeetingPhaseProgressBar - thin fill bar that drains as countdown runs
|
||||
// 0.74 - 0.79 MeetingPhaseCountdown - "0:24" countdown text
|
||||
// 0.18 - 0.74 _MeetingScroll - vote rows (or VoteResultPanel when resolved)
|
||||
// 0.14 - 0.18 MyVoteIndicator - "You voted for: X" or "Voting hasn't started"
|
||||
// 0.04 - 0.14 SkipButton - skip vote (will move into the row list in P2.7)
|
||||
void BuildMeetingPanel(RectTransform parent)
|
||||
{
|
||||
var panel = Child("MeetingPanel", parent);
|
||||
Stretch(panel);
|
||||
Img(panel, new Color(0.04f,0.05f,0.14f,0.97f));
|
||||
|
||||
// Header
|
||||
// Title
|
||||
var hdr = Child("MeetingHeader", panel);
|
||||
Anchor(hdr, new Vector2(0,0.86f), new Vector2(1,1), Vector2.zero, Vector2.zero);
|
||||
Anchor(hdr, new Vector2(0,0.90f), new Vector2(1,1), Vector2.zero, Vector2.zero);
|
||||
var hdrTmp = hdr.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
hdrTmp.text = "EMERGENCY MEETING"; hdrTmp.fontSize = 52;
|
||||
hdrTmp.fontStyle = FontStyles.Bold; hdrTmp.color = C_ORANGE;
|
||||
hdrTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
// Sub-phase label (DISCUSSION / VOTING / RESULTS / ARRIVAL)
|
||||
var phaseLbl = Child("MeetingPhaseLabel", panel);
|
||||
Anchor(phaseLbl, new Vector2(0,0.83f), new Vector2(1,0.90f), Vector2.zero, Vector2.zero);
|
||||
var phaseLblTmp = phaseLbl.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
phaseLblTmp.text = ""; phaseLblTmp.fontSize = 32;
|
||||
phaseLblTmp.fontStyle = FontStyles.Bold; phaseLblTmp.color = C_ACCENT;
|
||||
phaseLblTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
// Phase progress bar (drains as countdown elapses)
|
||||
var progBg = Child("MeetingPhaseProgressBg", panel);
|
||||
Anchor(progBg, new Vector2(0.10f,0.79f), new Vector2(0.90f,0.83f), Vector2.zero, Vector2.zero);
|
||||
Img(progBg, new Color(0.10f,0.13f,0.22f,1f));
|
||||
var progFill = Child("MeetingPhaseProgressBar", progBg);
|
||||
progFill.anchorMin = new Vector2(0,0); progFill.anchorMax = new Vector2(1,1);
|
||||
progFill.offsetMin = Vector2.zero; progFill.offsetMax = Vector2.zero;
|
||||
var fillImg = progFill.gameObject.AddComponent<Image>();
|
||||
fillImg.color = C_ACCENT;
|
||||
fillImg.type = Image.Type.Filled;
|
||||
fillImg.fillMethod = Image.FillMethod.Horizontal;
|
||||
fillImg.fillAmount = 0f;
|
||||
|
||||
// Countdown text under the bar
|
||||
var cd = Child("MeetingPhaseCountdown", panel);
|
||||
Anchor(cd, new Vector2(0,0.74f), new Vector2(1,0.79f), Vector2.zero, Vector2.zero);
|
||||
var cdTmp = cd.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
cdTmp.text = ""; cdTmp.fontSize = 28;
|
||||
cdTmp.color = new Color(0.8f,0.85f,0.95f);
|
||||
cdTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
// Scrollable player vote list
|
||||
var scrollArea = Child("_MeetingScroll", panel);
|
||||
Anchor(scrollArea, new Vector2(0,0.22f), new Vector2(1,0.86f), Vector2.zero, Vector2.zero);
|
||||
Anchor(scrollArea, new Vector2(0,0.18f), new Vector2(1,0.74f), Vector2.zero, Vector2.zero);
|
||||
BuildMeetingScroll(scrollArea);
|
||||
|
||||
// Text fallback (hidden by default; shown if scroll build fails)
|
||||
var fallback = Child("MeetingPlayerList", panel);
|
||||
Anchor(fallback, new Vector2(0,0.22f), new Vector2(1,0.86f), new Vector2(8,0), new Vector2(-8,0));
|
||||
Anchor(fallback, new Vector2(0,0.18f), new Vector2(1,0.74f), new Vector2(8,0), new Vector2(-8,0));
|
||||
var fallbackTmp = fallback.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
fallbackTmp.text = ""; fallbackTmp.fontSize = 28; fallbackTmp.color = Color.white;
|
||||
fallbackTmp.alignment = TextAlignmentOptions.TopLeft;
|
||||
fallback.gameObject.SetActive(false); // hidden – scroll list used instead
|
||||
fallback.gameObject.SetActive(false); // hidden - scroll list used instead
|
||||
|
||||
// Skip button
|
||||
// "Your vote: X" indicator strip
|
||||
var myVote = Child("MyVoteIndicator", panel);
|
||||
Anchor(myVote, new Vector2(0.05f,0.14f), new Vector2(0.95f,0.18f), Vector2.zero, Vector2.zero);
|
||||
var myVoteTmp = myVote.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
myVoteTmp.text = ""; myVoteTmp.fontSize = 26;
|
||||
myVoteTmp.color = new Color(0.73f,0.8f,0.88f);
|
||||
myVoteTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
// Skip button (will be merged into the vote list as a row in P2.7)
|
||||
var skip = Child("SkipButton", panel);
|
||||
Anchor(skip, new Vector2(0.05f,0.04f), new Vector2(0.95f,0.18f), Vector2.zero, Vector2.zero);
|
||||
Anchor(skip, new Vector2(0.05f,0.04f), new Vector2(0.95f,0.14f), Vector2.zero, Vector2.zero);
|
||||
var skipBg = Img(skip, C_MUTED);
|
||||
var skipBtn = skip.gameObject.AddComponent<Button>();
|
||||
skipBtn.targetGraphic = skipBg;
|
||||
skipBtn.onClick.AddListener(() => GameManager.Instance?.CastVote(null));
|
||||
TxtChild(skip, "⏭ SKIP", 36, Color.white, TextAlignmentOptions.Center, bold: true);
|
||||
|
||||
// Vote-result sub-panel (hidden until voting closes)
|
||||
// Vote-result sub-panel - now sized to *replace* the scroll area when
|
||||
// results arrive, instead of squeezing into the bottom strip alongside
|
||||
// skip/my-vote (which caused the previous overlap).
|
||||
var resultPanel = Child("VoteResultPanel", panel);
|
||||
Anchor(resultPanel, new Vector2(0,0.04f), new Vector2(1,0.22f), Vector2.zero, Vector2.zero);
|
||||
Anchor(resultPanel, new Vector2(0,0.18f), new Vector2(1,0.74f), Vector2.zero, Vector2.zero);
|
||||
Img(resultPanel, new Color(0.05f,0.05f,0.15f,0.95f));
|
||||
var resultText = Child("VoteResult", resultPanel);
|
||||
Stretch(resultText);
|
||||
@@ -273,6 +398,58 @@ public class InGameHUDBuilder : MonoBehaviour
|
||||
panel.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Reconnect overlay (visible while the socket is dropping/reconnecting) ─
|
||||
void BuildReconnectOverlay(RectTransform parent)
|
||||
{
|
||||
var panel = Child("ReconnectOverlay", parent);
|
||||
Stretch(panel);
|
||||
Img(panel, new Color(0.04f,0.05f,0.14f,0.90f));
|
||||
|
||||
var msg = Child("ReconnectMessage", panel);
|
||||
Anchor(msg, new Vector2(0,0.45f), new Vector2(1,0.65f), Vector2.zero, Vector2.zero);
|
||||
var msgTmp = msg.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
msgTmp.text = "Reconnecting..."; msgTmp.fontSize = 56;
|
||||
msgTmp.fontStyle = FontStyles.Bold; msgTmp.color = C_YELLOW;
|
||||
msgTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
var sub = Child("ReconnectSubtext", panel);
|
||||
Anchor(sub, new Vector2(0,0.35f), new Vector2(1,0.45f), Vector2.zero, Vector2.zero);
|
||||
var subTmp = sub.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
subTmp.text = "Server keeps your slot for up to 60 seconds.";
|
||||
subTmp.fontSize = 28; subTmp.color = new Color(0.73f,0.8f,0.88f);
|
||||
subTmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
panel.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Spectate panel (visible after death; dim banner so the player still
|
||||
// sees the live map but understands they're spectating) ────────────────
|
||||
void BuildSpectatePanel(RectTransform parent)
|
||||
{
|
||||
var panel = Child("SpectatePanel", parent);
|
||||
// Top strip only - we don't want to occlude the map. Just enough to
|
||||
// communicate "you're dead, watching live."
|
||||
panel.anchorMin = new Vector2(0, 1); panel.anchorMax = new Vector2(1, 1);
|
||||
panel.pivot = new Vector2(0.5f, 1f); panel.sizeDelta = new Vector2(0, 96);
|
||||
Img(panel, new Color(0f, 0f, 0f, 0.65f));
|
||||
|
||||
var label = Child("SpectateLabel", panel);
|
||||
Stretch(label);
|
||||
var t = label.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
UITheme.StyleText(t, UITheme.FontTitle, UITheme.TextHi,
|
||||
TextAlignmentOptions.Center, bold: true);
|
||||
t.text = "👻 YOU ARE DEAD - SPECTATING";
|
||||
|
||||
var sub = Child("SpectateSub", panel);
|
||||
Anchor(sub, new Vector2(0, 0), new Vector2(1, 0.45f), Vector2.zero, Vector2.zero);
|
||||
var st = sub.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
UITheme.StyleText(st, UITheme.FontSmall, UITheme.TextMid,
|
||||
TextAlignmentOptions.Center);
|
||||
st.text = "Crew can finish tasks as ghosts. Impostors cannot kill you.";
|
||||
|
||||
panel.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Toast notification ────────────────────────────────────────────────────
|
||||
void BuildToast(RectTransform parent)
|
||||
{
|
||||
|
||||
@@ -5,9 +5,30 @@ using TMPro;
|
||||
using GeoSus.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Attach to any manager GameObject in create.unity or join loading.unity.
|
||||
/// On Start(), removes all placeholder Art elements from the Canvas and builds
|
||||
/// a proper mobile-portrait lobby screen entirely in code.
|
||||
/// Lives on create.unity, the post-creation lobby view. The scene already
|
||||
/// contains the art team's named UI:
|
||||
///
|
||||
/// Canvas
|
||||
/// ├── Panel - full-screen background
|
||||
/// ├── max players - TMP "Max players: " label (we append the count)
|
||||
/// ├── lobby code - TMP "Lobby code: " label (we append the code)
|
||||
/// ├── smazatbutton - "delete/leave" button
|
||||
/// ├── lobby info - info button (currently no-op)
|
||||
/// ├── player list neglow - decorative glow behind the player list
|
||||
/// ├── player list - container for the player list (originally held
|
||||
/// │ a single placeholder text child)
|
||||
/// ├── tuff jmeno - hardcoded player slot 1 (placeholder)
|
||||
/// ├── netuff jmeno - hardcoded player slot 2 (placeholder)
|
||||
/// └── stvořit - "Start Game" button
|
||||
///
|
||||
/// We DO NOT destroy any of these. Instead we resolve them by name, wire the
|
||||
/// buttons, update text labels live, hide the placeholder slots, and inject
|
||||
/// a ScrollRect inside the existing 'player list' container so any number of
|
||||
/// players can be displayed.
|
||||
///
|
||||
/// GameManager_UI calls RefreshAll(LobbyState) when the lobby state changes;
|
||||
/// we cache the latest state and apply it on the next Update so all scene
|
||||
/// references stay on the main thread.
|
||||
/// </summary>
|
||||
public class LobbyDisplayUI : MonoBehaviour
|
||||
{
|
||||
@@ -18,54 +39,141 @@ public class LobbyDisplayUI : MonoBehaviour
|
||||
foreach (var ui in _all) ui._pending = state;
|
||||
}
|
||||
|
||||
// ── Built UI references ───────────────────────────────────────────────────
|
||||
private TMP_Text _codeText;
|
||||
private TMP_Text _countText;
|
||||
private Transform _listContent;
|
||||
private TMP_Text _statusText;
|
||||
private GameObject _startFooter;
|
||||
private GameObject _waitFooter;
|
||||
// ── Resolved scene refs ──────────────────────────────────────────────────
|
||||
private TMP_Text _codeLabel; // "lobby code" - prefix + JoinCode appended
|
||||
private TMP_Text _maxPlayersLabel; // "max players" - prefix + count appended
|
||||
private Button _leaveBtn; // "smazatbutton"
|
||||
private Button _infoBtn; // "lobby info"
|
||||
private Button _startBtn; // "stvořit"
|
||||
private RectTransform _playerListRT; // "player list" container - scroll lives inside
|
||||
private TMP_Text _waitingForHostText; // injected; visible to non-host only
|
||||
|
||||
// ── Injected scroll list (added once at Start, populated on every refresh) ─
|
||||
private RectTransform _scrollContent;
|
||||
private readonly List<GameObject> _rows = new List<GameObject>();
|
||||
|
||||
private LobbyState _pending;
|
||||
|
||||
// ── Colour palette ────────────────────────────────────────────────────────
|
||||
static Color H(string hex) { ColorUtility.TryParseHtmlString(hex, out var c); return c; }
|
||||
static readonly Color C_BG = H("#0D0F1A");
|
||||
static readonly Color C_HDR = H("#141927");
|
||||
static readonly Color C_SUBBG = H("#0F1221");
|
||||
static readonly Color C_ROW_A = H("#1A2035");
|
||||
static readonly Color C_ROW_B = H("#161C2E");
|
||||
static readonly Color C_DIVIDER = H("#252A3F");
|
||||
static readonly Color C_ACCENT = H("#3399FF");
|
||||
static readonly Color C_GOLD = H("#FFB800");
|
||||
static readonly Color C_GREEN = H("#2DB84B");
|
||||
static readonly Color C_RED = H("#C43232");
|
||||
static readonly Color C_MUTED = new Color(0.47f, 0.53f, 0.67f);
|
||||
static readonly Color C_WHITE = Color.white;
|
||||
static readonly Color C_SOFT = new Color(0.73f, 0.80f, 0.88f);
|
||||
// ── Colour palette (forwarded from UITheme) ──────────────────────────────
|
||||
static readonly Color C_ROW_A = UITheme.RowA;
|
||||
static readonly Color C_ROW_B = UITheme.RowB;
|
||||
static readonly Color C_DIVIDER = UITheme.SurfaceAlt;
|
||||
static readonly Color C_ACCENT = UITheme.Accent;
|
||||
static readonly Color C_GOLD = UITheme.Caution;
|
||||
static readonly Color C_WHITE = UITheme.TextHi;
|
||||
static readonly Color C_SOFT = UITheme.TextMid;
|
||||
|
||||
void OnEnable() => _all.Add(this);
|
||||
void OnDisable() => _all.Remove(this);
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
void Start()
|
||||
{
|
||||
// Wire emoji fallback ASAP so player-list rows with emoji glyphs
|
||||
// (host crown, "you" badge, etc.) render correctly on the first
|
||||
// refresh.
|
||||
UITheme.EnsureEmojiFontFallback();
|
||||
|
||||
var canvasGO = GameObject.Find("Canvas");
|
||||
if (canvasGO == null)
|
||||
{
|
||||
Debug.LogError("[LobbyDisplayUI] No Canvas found in scene!");
|
||||
Debug.LogError("[LobbyDisplayUI] No Canvas found in create scene.");
|
||||
return;
|
||||
}
|
||||
var canvas = canvasGO.transform;
|
||||
|
||||
// Remove all placeholder Art children immediately (before we build)
|
||||
var kill = new List<GameObject>();
|
||||
foreach (Transform child in canvasGO.transform)
|
||||
kill.Add(child.gameObject);
|
||||
foreach (var go in kill)
|
||||
DestroyImmediate(go);
|
||||
// ── Bind existing scene elements ────────────────────────────────────
|
||||
_codeLabel = FindTMP(canvas, "lobby code");
|
||||
_maxPlayersLabel = FindTMP(canvas, "max players");
|
||||
_leaveBtn = FindButton(canvas, "smazatbutton");
|
||||
_infoBtn = FindButton(canvas, "lobby info");
|
||||
_startBtn = FindButton(canvas, "stvořit");
|
||||
var listGO = FindByName(canvas, "player list");
|
||||
_playerListRT = listGO != null ? listGO as RectTransform : null;
|
||||
|
||||
Build(canvasGO.transform);
|
||||
// ── Wire buttons (preserve existing AudioSource OnClick by appending) ──
|
||||
if (_leaveBtn != null)
|
||||
{
|
||||
// Don't clear; the art team's AudioSource.Play hook should still
|
||||
// fire alongside the leave action.
|
||||
_leaveBtn.onClick.AddListener(() => GameManager.Instance?.LeaveLobbyButton());
|
||||
}
|
||||
else Debug.LogWarning("[LobbyDisplayUI] 'smazatbutton' not found.");
|
||||
|
||||
if (_infoBtn != null)
|
||||
{
|
||||
// Tap-to-copy the lobby code to clipboard. Cheap useful action that
|
||||
// gives the existing info button real behavior.
|
||||
_infoBtn.onClick.AddListener(() =>
|
||||
{
|
||||
var code = GameManager.Instance?.gameClient?.CurrentLobbyState?.JoinCode;
|
||||
if (!string.IsNullOrEmpty(code)) GUIUtility.systemCopyBuffer = code;
|
||||
});
|
||||
}
|
||||
|
||||
if (_startBtn != null)
|
||||
{
|
||||
_startBtn.onClick.AddListener(() => GameManager.Instance?.StartGameButton());
|
||||
}
|
||||
else Debug.LogWarning("[LobbyDisplayUI] 'stvořit' not found.");
|
||||
|
||||
// ── Hide the hardcoded placeholder slots ────────────────────────────
|
||||
// These were kept in the scene as visual previews of what filled rows
|
||||
// would look like; with a working scrollable list they're redundant.
|
||||
var tuff = FindByName(canvas, "tuff jmeno");
|
||||
var netuff = FindByName(canvas, "netuff jmeno");
|
||||
if (tuff) tuff.gameObject.SetActive(false);
|
||||
if (netuff) netuff.gameObject.SetActive(false);
|
||||
|
||||
// ── Override the player list's RectTransform to a sensible portrait
|
||||
// layout. The art team's anchored position + size were calibrated
|
||||
// for a different reference resolution and the element only filled
|
||||
// a third of the viewable area at 1080x1920. Stretch it to fill
|
||||
// the central portion of the screen with even insets.
|
||||
if (_playerListRT != null)
|
||||
{
|
||||
_playerListRT.anchorMin = new Vector2(0, 0);
|
||||
_playerListRT.anchorMax = new Vector2(1, 1);
|
||||
_playerListRT.pivot = new Vector2(0.5f, 0.5f);
|
||||
// Top inset = 320 (room for header / lobby code), bottom inset =
|
||||
// 380 (room for the start button or waiting message), 60px sides.
|
||||
_playerListRT.offsetMin = new Vector2(60, 380);
|
||||
_playerListRT.offsetMax = new Vector2(-60, -320);
|
||||
_playerListRT.anchoredPosition = Vector2.zero;
|
||||
BuildScrollList(_playerListRT);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[LobbyDisplayUI] 'player list' container not found - " +
|
||||
"falling back to no list rendering.");
|
||||
}
|
||||
|
||||
// Also re-anchor the "player list neglow" decoration to track the new
|
||||
// player list region, so the glow doesn't float empty offscreen.
|
||||
var neglow = FindByName(canvas, "player list neglow") as RectTransform;
|
||||
if (neglow != null)
|
||||
{
|
||||
neglow.anchorMin = new Vector2(0, 0);
|
||||
neglow.anchorMax = new Vector2(1, 1);
|
||||
neglow.pivot = new Vector2(0.5f, 0.5f);
|
||||
neglow.offsetMin = new Vector2(40, 360);
|
||||
neglow.offsetMax = new Vector2(-40, -300);
|
||||
}
|
||||
|
||||
// ── Inject "Waiting for host..." text for non-host players ──────────
|
||||
// Visible only when the local player isn't the lobby owner; sits in
|
||||
// the same vertical strip as the start button so the screen has a
|
||||
// single consistent action zone for both roles.
|
||||
var waitGO = MakeRT("WaitingForHost", canvas);
|
||||
Anchor(waitGO,
|
||||
new Vector2(0.05f, 0), new Vector2(0.95f, 0),
|
||||
new Vector2(0, 60), new Vector2(0, 240));
|
||||
_waitingForHostText = waitGO.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
_waitingForHostText.text = "⌛ Waiting for host to start the game...";
|
||||
_waitingForHostText.fontSize = 38;
|
||||
_waitingForHostText.color = new Color(0.73f, 0.80f, 0.88f);
|
||||
_waitingForHostText.fontStyle = FontStyles.Italic;
|
||||
_waitingForHostText.alignment = TextAlignmentOptions.Center;
|
||||
_waitingForHostText.gameObject.SetActive(false); // toggled per refresh
|
||||
}
|
||||
|
||||
void Update()
|
||||
@@ -74,177 +182,110 @@ public class LobbyDisplayUI : MonoBehaviour
|
||||
if (gm?.gameClient?.CurrentLobbyState != null)
|
||||
_pending = gm.gameClient.CurrentLobbyState;
|
||||
|
||||
if (_pending != null && _listContent != null)
|
||||
if (_pending != null)
|
||||
{
|
||||
Refresh(_pending);
|
||||
_pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full UI construction ──────────────────────────────────────────────────
|
||||
void Build(Transform canvasRoot)
|
||||
// ── Scroll list construction ─────────────────────────────────────────────
|
||||
void BuildScrollList(RectTransform listRoot)
|
||||
{
|
||||
const float HDR_H = 250f;
|
||||
const float SUB_H = 88f;
|
||||
const float FOOT_H = 180f;
|
||||
const float BTN_W = 200f;
|
||||
// Strip any existing children of `player list` (typically one
|
||||
// placeholder TMP). Don't touch the listRoot itself - it has the art
|
||||
// team's anchoring + glow pairing we want to preserve.
|
||||
var kill = new List<GameObject>();
|
||||
foreach (Transform child in listRoot) kill.Add(child.gameObject);
|
||||
foreach (var go in kill) DestroyImmediate(go);
|
||||
|
||||
// Fullscreen dark overlay
|
||||
var root = RT("Root", canvasRoot);
|
||||
Stretch(root);
|
||||
Img(root, C_BG);
|
||||
// ScrollRect on the list root
|
||||
var sr = listRoot.gameObject.GetComponent<ScrollRect>()
|
||||
?? listRoot.gameObject.AddComponent<ScrollRect>();
|
||||
sr.horizontal = false; sr.vertical = true;
|
||||
sr.movementType = ScrollRect.MovementType.Elastic;
|
||||
sr.elasticity = 0.1f;
|
||||
sr.scrollSensitivity = 80f;
|
||||
|
||||
// ─── Header bar ───────────────────────────────────────────────────────
|
||||
var header = RT("Header", root);
|
||||
PinTop(header, HDR_H);
|
||||
Img(header, C_HDR);
|
||||
|
||||
// Back (✕) button — left side of header
|
||||
var backBtn = RT("BackBtn", header);
|
||||
backBtn.anchorMin = new Vector2(0f, 0f);
|
||||
backBtn.anchorMax = new Vector2(0f, 1f);
|
||||
backBtn.pivot = new Vector2(0f, 0.5f);
|
||||
backBtn.offsetMin = new Vector2(18f, 22f);
|
||||
backBtn.offsetMax = new Vector2(BTN_W + 18f, -22f);
|
||||
Img(backBtn, C_RED);
|
||||
Btn(backBtn, C_RED, () => GameManager.Instance?.LeaveLobbyButton());
|
||||
TxtChild(backBtn, "✕", 72, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
|
||||
// "LOBBY CODE" micro label — upper-center of header
|
||||
var codeLbl = RT("CodeLbl", header);
|
||||
codeLbl.anchorMin = new Vector2(0.14f, 0.52f);
|
||||
codeLbl.anchorMax = new Vector2(0.86f, 0.97f);
|
||||
codeLbl.offsetMin = codeLbl.offsetMax = Vector2.zero;
|
||||
TmpDirect(codeLbl, "LOBBY CODE", 28, C_MUTED, TextAlignmentOptions.Center, bold: true);
|
||||
|
||||
// Large code value — lower-center of header
|
||||
var codeValRT = RT("CodeVal", header);
|
||||
codeValRT.anchorMin = new Vector2(0.14f, 0.05f);
|
||||
codeValRT.anchorMax = new Vector2(0.86f, 0.52f);
|
||||
codeValRT.offsetMin = codeValRT.offsetMax = Vector2.zero;
|
||||
_codeText = TmpDirect(codeValRT, "------", 76, C_ACCENT, TextAlignmentOptions.Center, bold: true);
|
||||
|
||||
// Copy (⎘) button — right side of header
|
||||
var copyBtn = RT("CopyBtn", header);
|
||||
copyBtn.anchorMin = new Vector2(1f, 0f);
|
||||
copyBtn.anchorMax = new Vector2(1f, 1f);
|
||||
copyBtn.pivot = new Vector2(1f, 0.5f);
|
||||
copyBtn.offsetMin = new Vector2(-(BTN_W + 18f), 22f);
|
||||
copyBtn.offsetMax = new Vector2(-18f, -22f);
|
||||
Img(copyBtn, C_ACCENT);
|
||||
Btn(copyBtn, C_ACCENT, () =>
|
||||
{
|
||||
if (_codeText != null) GUIUtility.systemCopyBuffer = _codeText.text;
|
||||
});
|
||||
TxtChild(copyBtn, "⎘", 60, C_WHITE, TextAlignmentOptions.Center);
|
||||
|
||||
// ─── Player count subtitle bar ─────────────────────────────────────────
|
||||
var subBar = RT("CountBar", root);
|
||||
PinBelowTop(subBar, HDR_H, SUB_H);
|
||||
Img(subBar, C_SUBBG);
|
||||
_countText = TxtChild(subBar, "0 players in lobby", 34, C_MUTED, TextAlignmentOptions.Center);
|
||||
|
||||
// ─── Scrollable player list ────────────────────────────────────────────
|
||||
var scrollArea = RT("PlayerScroll", root);
|
||||
Fill(scrollArea, HDR_H + SUB_H, FOOT_H);
|
||||
BuildScroll(scrollArea);
|
||||
|
||||
// ─── Footer: START GAME (host) or waiting text (others) ───────────────
|
||||
_startFooter = new GameObject("StartFooter");
|
||||
var sfRT = _startFooter.AddComponent<RectTransform>();
|
||||
sfRT.SetParent(root, false);
|
||||
sfRT.localScale = Vector3.one;
|
||||
PinBottom(sfRT, FOOT_H);
|
||||
Img(sfRT, C_SUBBG);
|
||||
|
||||
var startBtnRT = RT("StartBtn", sfRT);
|
||||
Fill(startBtnRT, 20f, 20f, 24f, 24f);
|
||||
Img(startBtnRT, C_GREEN);
|
||||
Btn(startBtnRT, C_GREEN, () => GameManager.Instance?.StartGameButton());
|
||||
TxtChild(startBtnRT, "▶ START GAME", 54, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
_startFooter.SetActive(false);
|
||||
|
||||
_waitFooter = new GameObject("WaitFooter");
|
||||
var wfRT = _waitFooter.AddComponent<RectTransform>();
|
||||
wfRT.SetParent(root, false);
|
||||
wfRT.localScale = Vector3.one;
|
||||
PinBottom(wfRT, FOOT_H);
|
||||
Img(wfRT, C_SUBBG);
|
||||
_statusText = TxtChild(wfRT, "⌛ Waiting for host to start...", 38, C_MUTED,
|
||||
TextAlignmentOptions.Center, italic: true);
|
||||
_waitFooter.SetActive(true);
|
||||
}
|
||||
|
||||
void BuildScroll(RectTransform rt)
|
||||
{
|
||||
var sr = rt.gameObject.AddComponent<ScrollRect>();
|
||||
|
||||
var viewport = RT("Viewport", rt);
|
||||
// Viewport (with mask for clipping)
|
||||
var viewport = MakeRT("Viewport", listRoot);
|
||||
Stretch(viewport);
|
||||
var vpImg = viewport.gameObject.AddComponent<Image>();
|
||||
vpImg.color = new Color(0,0,0,0);
|
||||
viewport.gameObject.AddComponent<RectMask2D>();
|
||||
|
||||
var content = RT("Content", viewport);
|
||||
content.anchorMin = new Vector2(0f, 1f);
|
||||
content.anchorMax = new Vector2(1f, 1f);
|
||||
content.pivot = new Vector2(0.5f, 1f);
|
||||
content.sizeDelta = new Vector2(0f, 0f);
|
||||
// Content - populated from State.Players
|
||||
var content = MakeRT("Content", viewport);
|
||||
content.anchorMin = new Vector2(0, 1);
|
||||
content.anchorMax = new Vector2(1, 1);
|
||||
content.pivot = new Vector2(0.5f, 1);
|
||||
content.sizeDelta = Vector2.zero;
|
||||
content.anchoredPosition = Vector2.zero;
|
||||
|
||||
var vlg = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.childControlWidth = true;
|
||||
vlg.childControlHeight = false;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childControlWidth = true;
|
||||
vlg.childControlHeight = false;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = false;
|
||||
vlg.spacing = 2f;
|
||||
vlg.padding = new RectOffset(0, 0, 0, 0);
|
||||
|
||||
vlg.spacing = 4;
|
||||
var csf = content.gameObject.AddComponent<ContentSizeFitter>();
|
||||
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
sr.viewport = viewport;
|
||||
sr.content = content;
|
||||
sr.horizontal = false;
|
||||
sr.vertical = true;
|
||||
sr.scrollSensitivity = 80f;
|
||||
sr.movementType = ScrollRect.MovementType.Elastic;
|
||||
sr.elasticity = 0.1f;
|
||||
sr.viewport = viewport;
|
||||
sr.content = content;
|
||||
|
||||
_listContent = content;
|
||||
_scrollContent = content;
|
||||
}
|
||||
|
||||
// ── State refresh ─────────────────────────────────────────────────────────
|
||||
// ── State refresh ────────────────────────────────────────────────────────
|
||||
void Refresh(LobbyState state)
|
||||
{
|
||||
if (_codeText != null) _codeText.text = state.JoinCode ?? "------";
|
||||
// Lobby code label (preserve art team prefix)
|
||||
if (_codeLabel != null)
|
||||
{
|
||||
const string prefix = "Lobby code: ";
|
||||
_codeLabel.text = prefix + (state.JoinCode ?? "------");
|
||||
}
|
||||
|
||||
int n = state.Players.Count;
|
||||
if (_countText != null)
|
||||
_countText.text = $"{n} player{(n == 1 ? "" : "s")} in lobby";
|
||||
// Max players label - we use this for live player count too. The art
|
||||
// team's prefix "Max players: " gets appended with current/max.
|
||||
if (_maxPlayersLabel != null)
|
||||
{
|
||||
int n = state.Players != null ? state.Players.Count : 0;
|
||||
_maxPlayersLabel.text = $"Players: {n}";
|
||||
}
|
||||
|
||||
var gm = GameManager.Instance;
|
||||
bool isHost = gm?.gameClient?.IsOwner ?? false;
|
||||
string myId = gm?.gameClient?.ClientUuid ?? "";
|
||||
|
||||
if (_startFooter != null) _startFooter.SetActive(isHost);
|
||||
if (_waitFooter != null) _waitFooter.SetActive(!isHost);
|
||||
|
||||
if (_statusText != null)
|
||||
_statusText.text = state.Phase == GamePhase.Loading
|
||||
// Unified screen for both owner and joiner. Host gets the start
|
||||
// button + working settings; non-host gets a clear "waiting" message
|
||||
// in the same screen real estate so the layout stays consistent.
|
||||
bool isHost = GameManager.Instance?.gameClient?.IsOwner ?? false;
|
||||
if (_startBtn != null)
|
||||
_startBtn.gameObject.SetActive(isHost);
|
||||
if (_waitingForHostText != null)
|
||||
{
|
||||
_waitingForHostText.gameObject.SetActive(!isHost);
|
||||
_waitingForHostText.text = state.Phase == GamePhase.Loading
|
||||
? "⏳ Downloading map data..."
|
||||
: "⌛ Waiting for host to start...";
|
||||
: "⌛ Waiting for host to start the game...";
|
||||
}
|
||||
|
||||
if (_listContent == null) return;
|
||||
if (_scrollContent == null) return;
|
||||
|
||||
// Clear and rebuild rows. Player count is small (<= 15 per server
|
||||
// config), so a full rebuild every refresh is cheap and avoids the
|
||||
// bookkeeping of incremental updates.
|
||||
foreach (var row in _rows) Destroy(row);
|
||||
_rows.Clear();
|
||||
|
||||
if (state.Players == null) return;
|
||||
|
||||
string myId = GameManager.Instance?.gameClient?.ClientUuid ?? "";
|
||||
for (int i = 0; i < state.Players.Count; i++)
|
||||
{
|
||||
var p = state.Players[i];
|
||||
bool me = p.ClientUuid == myId;
|
||||
var row = BuildRow(p.DisplayName ?? "???", me, p.IsOwner,
|
||||
var p = state.Players[i];
|
||||
bool isMe = p.ClientUuid == myId;
|
||||
var row = BuildRow(p.DisplayName ?? "???", isMe, p.IsOwner,
|
||||
i % 2 == 0 ? C_ROW_A : C_ROW_B);
|
||||
row.transform.SetParent(_listContent, false);
|
||||
row.transform.SetParent(_scrollContent, false);
|
||||
_rows.Add(row);
|
||||
}
|
||||
}
|
||||
@@ -255,69 +296,98 @@ public class LobbyDisplayUI : MonoBehaviour
|
||||
var go = new GameObject("PlayerRow");
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(0f, ROW_H);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = ROW_H;
|
||||
le.minHeight = ROW_H;
|
||||
le.preferredHeight = ROW_H;
|
||||
|
||||
Img(rt, bg);
|
||||
var bgImg = go.AddComponent<Image>();
|
||||
bgImg.color = bg;
|
||||
|
||||
// Bottom divider line
|
||||
var divRT = RT("Div", rt);
|
||||
divRT.anchorMin = new Vector2(0f, 0f);
|
||||
divRT.anchorMax = new Vector2(1f, 0f);
|
||||
divRT.pivot = new Vector2(0.5f, 0f);
|
||||
divRT.offsetMin = new Vector2(20f, 0f);
|
||||
divRT.offsetMax = new Vector2(-20f, 2f);
|
||||
Img(divRT, C_DIVIDER);
|
||||
var divRT = MakeRT("Div", rt);
|
||||
divRT.anchorMin = new Vector2(0, 0); divRT.anchorMax = new Vector2(1, 0);
|
||||
divRT.pivot = new Vector2(0.5f, 0);
|
||||
divRT.offsetMin = new Vector2(20, 0); divRT.offsetMax = new Vector2(-20, 2);
|
||||
var divImg = divRT.gameObject.AddComponent<Image>();
|
||||
divImg.color = C_DIVIDER;
|
||||
|
||||
float nameLeft = 24f;
|
||||
|
||||
// Crown emoji for lobby host
|
||||
if (isHostPlayer)
|
||||
{
|
||||
var crownRT = RT("Crown", rt);
|
||||
crownRT.anchorMin = new Vector2(0f, 0.5f);
|
||||
crownRT.anchorMax = new Vector2(0f, 0.5f);
|
||||
crownRT.pivot = new Vector2(0f, 0.5f);
|
||||
crownRT.sizeDelta = new Vector2(90f, 90f);
|
||||
crownRT.anchoredPosition = new Vector2(18f, 0f);
|
||||
TmpDirect(crownRT, "👑", 52, C_GOLD, TextAlignmentOptions.Center);
|
||||
var crownRT = MakeRT("Crown", rt);
|
||||
crownRT.anchorMin = new Vector2(0, 0.5f); crownRT.anchorMax = new Vector2(0, 0.5f);
|
||||
crownRT.pivot = new Vector2(0, 0.5f);
|
||||
crownRT.sizeDelta = new Vector2(90, 90);
|
||||
crownRT.anchoredPosition = new Vector2(18, 0);
|
||||
var crownTmp = crownRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
crownTmp.text = "👑"; crownTmp.fontSize = 52;
|
||||
crownTmp.color = C_GOLD; crownTmp.alignment = TextAlignmentOptions.Center;
|
||||
nameLeft = 118f;
|
||||
}
|
||||
|
||||
// Player name
|
||||
// Name label
|
||||
float nameMaxX = isMe ? 0.68f : 1f;
|
||||
var nameRT = RT("Name", rt);
|
||||
nameRT.anchorMin = new Vector2(0f, 0f);
|
||||
nameRT.anchorMax = new Vector2(nameMaxX, 1f);
|
||||
nameRT.offsetMin = new Vector2(nameLeft, 6f);
|
||||
nameRT.offsetMax = new Vector2(-10f, -6f);
|
||||
var nameRT = MakeRT("Name", rt);
|
||||
nameRT.anchorMin = new Vector2(0, 0); nameRT.anchorMax = new Vector2(nameMaxX, 1);
|
||||
nameRT.offsetMin = new Vector2(nameLeft, 6); nameRT.offsetMax = new Vector2(-10, -6);
|
||||
var nt = nameRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
nt.text = playerName;
|
||||
nt.fontSize = 48;
|
||||
nt.color = isMe ? C_WHITE : C_SOFT;
|
||||
nt.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
nt.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
|
||||
nt.text = playerName; nt.fontSize = 48;
|
||||
nt.color = isMe ? C_WHITE : C_SOFT;
|
||||
nt.alignment = TextAlignmentOptions.MidlineLeft;
|
||||
nt.fontStyle = isMe ? FontStyles.Bold : FontStyles.Normal;
|
||||
nt.overflowMode = TextOverflowModes.Ellipsis;
|
||||
|
||||
// "YOU" badge
|
||||
if (isMe)
|
||||
{
|
||||
var badgeRT = RT("YouBadge", rt);
|
||||
var badgeRT = MakeRT("YouBadge", rt);
|
||||
badgeRT.anchorMin = new Vector2(0.68f, 0.22f);
|
||||
badgeRT.anchorMax = new Vector2(1f, 0.78f);
|
||||
badgeRT.offsetMin = new Vector2(0f, 0f);
|
||||
badgeRT.offsetMax = new Vector2(-20f, 0f);
|
||||
Img(badgeRT, C_ACCENT);
|
||||
TxtChild(badgeRT, "YOU", 30, C_WHITE, TextAlignmentOptions.Center, bold: true);
|
||||
badgeRT.offsetMin = Vector2.zero;
|
||||
badgeRT.offsetMax = new Vector2(-20f, 0);
|
||||
var badgeImg = badgeRT.gameObject.AddComponent<Image>();
|
||||
badgeImg.color = C_ACCENT;
|
||||
var badgeTxtRT = MakeRT("Txt", badgeRT);
|
||||
Stretch(badgeTxtRT);
|
||||
var badgeTmp = badgeTxtRT.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
badgeTmp.text = "YOU"; badgeTmp.fontSize = 30;
|
||||
badgeTmp.color = C_WHITE; badgeTmp.fontStyle = FontStyles.Bold;
|
||||
badgeTmp.alignment = TextAlignmentOptions.Center;
|
||||
}
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
// ── Layout helpers ────────────────────────────────────────────────────────
|
||||
RectTransform RT(string name, Transform parent)
|
||||
// ── Scene-binding helpers ────────────────────────────────────────────────
|
||||
|
||||
static Button FindButton(Transform root, string name)
|
||||
{
|
||||
var t = FindByName(root, name);
|
||||
return t != null ? t.GetComponent<Button>() : null;
|
||||
}
|
||||
|
||||
static TMP_Text FindTMP(Transform root, string name)
|
||||
{
|
||||
var t = FindByName(root, name);
|
||||
if (t == null) return null;
|
||||
return t.GetComponent<TMP_Text>() ?? t.GetComponentInChildren<TMP_Text>();
|
||||
}
|
||||
|
||||
static Transform FindByName(Transform root, string name)
|
||||
{
|
||||
if (root == null) return null;
|
||||
if (root.name == name) return root;
|
||||
foreach (Transform child in root)
|
||||
{
|
||||
var found = FindByName(child, name);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Layout helpers ───────────────────────────────────────────────────────
|
||||
|
||||
static RectTransform MakeRT(string name, Transform parent)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
@@ -326,92 +396,16 @@ public class LobbyDisplayUI : MonoBehaviour
|
||||
return rt;
|
||||
}
|
||||
|
||||
void Stretch(RectTransform rt)
|
||||
static void Stretch(RectTransform rt)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
void Fill(RectTransform rt, float top, float bottom, float left = 0f, float right = 0f)
|
||||
static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
|
||||
Vector2 offMin, Vector2 offMax)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = new Vector2(left, bottom);
|
||||
rt.offsetMax = new Vector2(-right, -top);
|
||||
}
|
||||
|
||||
void PinTop(RectTransform rt, float h)
|
||||
{
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(1f, 1f);
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
rt.offsetMin = new Vector2(0f, -h);
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
void PinBelowTop(RectTransform rt, float fromTop, float h)
|
||||
{
|
||||
rt.anchorMin = new Vector2(0f, 1f);
|
||||
rt.anchorMax = new Vector2(1f, 1f);
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
rt.offsetMin = new Vector2(0f, -(fromTop + h));
|
||||
rt.offsetMax = new Vector2(0f, -fromTop);
|
||||
}
|
||||
|
||||
void PinBottom(RectTransform rt, float h)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = new Vector2(1f, 0f);
|
||||
rt.pivot = new Vector2(0.5f, 0f);
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = new Vector2(0f, h);
|
||||
}
|
||||
|
||||
// ── Graphic helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Adds an Image directly to rt.
|
||||
Image Img(RectTransform rt, Color c)
|
||||
{
|
||||
var img = rt.gameObject.AddComponent<Image>();
|
||||
img.color = c;
|
||||
return img;
|
||||
}
|
||||
|
||||
/// Adds TMP directly to rt — only use when rt has NO Image component.
|
||||
TMP_Text TmpDirect(RectTransform rt, string text, float size, Color color,
|
||||
TextAlignmentOptions align, bool bold = false, bool italic = false)
|
||||
{
|
||||
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = size;
|
||||
tmp.color = color;
|
||||
tmp.alignment = align;
|
||||
if (bold) tmp.fontStyle |= FontStyles.Bold;
|
||||
if (italic) tmp.fontStyle |= FontStyles.Italic;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/// Creates a stretch-fill child GO with TMP — safe when parent already has Image.
|
||||
TMP_Text TxtChild(RectTransform parent, string text, float size, Color color,
|
||||
TextAlignmentOptions align, bool bold = false, bool italic = false)
|
||||
{
|
||||
var childRT = RT("Txt", parent);
|
||||
Stretch(childRT);
|
||||
return TmpDirect(childRT, text, size, color, align, bold, italic);
|
||||
}
|
||||
|
||||
void Btn(RectTransform rt, Color normal, System.Action onClick)
|
||||
{
|
||||
var btn = rt.gameObject.AddComponent<Button>();
|
||||
btn.targetGraphic = rt.gameObject.GetComponent<Image>();
|
||||
btn.onClick.AddListener(() => onClick());
|
||||
var cols = btn.colors;
|
||||
cols.normalColor = normal;
|
||||
cols.highlightedColor = Color.Lerp(normal, Color.white, 0.3f);
|
||||
cols.pressedColor = Color.Lerp(normal, Color.black, 0.3f);
|
||||
cols.selectedColor = normal;
|
||||
btn.colors = cols;
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = offMin; rt.offsetMax = offMax;
|
||||
}
|
||||
}
|
||||
|
||||
324
Assets/Scripts/UITheme.cs
Normal file
324
Assets/Scripts/UITheme.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized design tokens + styling helpers for every Canvas in the game.
|
||||
/// One source of truth for color, typography, spacing, animation timing, and
|
||||
/// emoji-capable font configuration. Replaces the per-file palette duplication
|
||||
/// that scattered across InGameHUDBuilder, HostLobbyUI, LobbyDisplayUI etc.
|
||||
///
|
||||
/// Why this exists (P10):
|
||||
/// - "UI looks tiny on phone, bleeds off-screen during voting, font drops
|
||||
/// emoji glyphs" - all symptoms of palette/typography/sizing values being
|
||||
/// defined per-file with no shared scale. Centralizing fixes the
|
||||
/// whole-app inconsistency, not just the worst offender.
|
||||
/// - Emoji rendering on Android needs TMP_Settings.fallbackFontAssets
|
||||
/// pointed at a Noto Color Emoji TMP_FontAsset. We do this at runtime
|
||||
/// so the host project doesn't need a pre-baked TMP_Settings asset
|
||||
/// change (which would touch a binary scriptable object).
|
||||
///
|
||||
/// Visual direction:
|
||||
/// - Dark base (#0A0E1A near-black) with one strong accent (teal-cyan
|
||||
/// #00C8C8). Readable in sunlight, doesn't clash with the orange/red
|
||||
/// impostor signals or the green map.
|
||||
/// - Typography ramp: one display weight, one body, one mono for codes.
|
||||
/// Sizes follow a major-third scale (1.25x ratio) so headlines
|
||||
/// dominate without shouting.
|
||||
/// - Spacing on an 8pt grid. Touch targets minimum 88pt (44pt @ 2x).
|
||||
/// - Animation: subtle. Fade 150ms, slide 200ms, ease-out. Game's tense -
|
||||
/// UI shouldn't be cute.
|
||||
/// </summary>
|
||||
public static class UITheme
|
||||
{
|
||||
// ── Palette ───────────────────────────────────────────────────────────────
|
||||
// Names follow purpose, not literal color. Adding a new component? Use
|
||||
// Bg/Surface/Accent/Success/Danger/Warning/Muted - not raw hex.
|
||||
|
||||
public static readonly Color Bg = H("#0A0E1A"); // page background
|
||||
public static readonly Color Surface = H("#121829"); // cards, panels
|
||||
public static readonly Color SurfaceAlt = H("#1A2138"); // elevated/active
|
||||
public static readonly Color SurfaceDim = H("#0E1322"); // recessed (input)
|
||||
public static readonly Color RowA = H("#1A2035"); // alt row
|
||||
public static readonly Color RowB = H("#161C2E"); // alt row
|
||||
public static readonly Color Border = new Color(1f, 1f, 1f, 0.10f);
|
||||
|
||||
public static readonly Color Accent = H("#00C8C8"); // teal-cyan, primary CTA
|
||||
public static readonly Color AccentDim = H("#0A8A8A"); // pressed state
|
||||
public static readonly Color Success = H("#2DB84B"); // task done, you-voted
|
||||
public static readonly Color Danger = H("#E04040"); // impostor, eject, kill
|
||||
public static readonly Color Warning = H("#F08C1A"); // sabotage, meltdown
|
||||
public static readonly Color Caution = H("#FFB800"); // reconnect, info
|
||||
|
||||
public static readonly Color TextHi = new Color(0.96f, 0.97f, 0.99f); // primary
|
||||
public static readonly Color TextMid = new Color(0.78f, 0.83f, 0.91f); // secondary
|
||||
public static readonly Color TextLo = new Color(0.55f, 0.62f, 0.75f); // tertiary/disabled
|
||||
public static readonly Color TextOnAccent= H("#001818"); // black-ish on bright accent
|
||||
|
||||
static Color H(string hex)
|
||||
{
|
||||
ColorUtility.TryParseHtmlString(hex, out var c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Typography ────────────────────────────────────────────────────────────
|
||||
// Sizes are in TMP "px" units which scale with the CanvasScaler reference.
|
||||
// Our reference is 1080x1920 portrait; these values produce the intended
|
||||
// physical size on a typical 6" phone (~5mm tall body text).
|
||||
|
||||
public const float FontDisplay = 64f; // hero (game-end win/loss)
|
||||
public const float FontHeadline = 44f; // section header
|
||||
public const float FontTitle = 32f; // panel header
|
||||
public const float FontBody = 26f; // standard text
|
||||
public const float FontSmall = 22f; // captions, helper text
|
||||
public const float FontTiny = 18f; // tiny meta, debug
|
||||
|
||||
// Action button text scales separately because thumb-zone CTAs need to
|
||||
// dominate visually even on a small screen.
|
||||
public const float FontActionBtn= 40f;
|
||||
|
||||
// ── Spacing (8pt grid) ────────────────────────────────────────────────────
|
||||
public const float S1 = 4f;
|
||||
public const float S2 = 8f;
|
||||
public const float S3 = 12f;
|
||||
public const float S4 = 16f;
|
||||
public const float S5 = 24f;
|
||||
public const float S6 = 32f;
|
||||
public const float S7 = 48f;
|
||||
public const float S8 = 64f;
|
||||
public const float S9 = 96f;
|
||||
|
||||
// ── Touch targets ─────────────────────────────────────────────────────────
|
||||
// Minimum heights tuned for 1080x1920 reference. CanvasScaler keeps these
|
||||
// visually consistent across phone sizes.
|
||||
public const float MinTapHeight = 88f; // 44pt @ 2x
|
||||
public const float StandardBtn = 110f; // typical button height
|
||||
public const float HeroBtn = 140f; // primary CTA
|
||||
public const float VoteRowMin = 96f;
|
||||
public const float VoteRowMax = 144f;
|
||||
|
||||
// ── Corner radii (target value; needs an Image with a rounded sprite to
|
||||
// realize, but keeping the token here lets future polish hit one place) ──
|
||||
public const float Radius1 = 8f;
|
||||
public const float Radius2 = 14f;
|
||||
public const float Radius3 = 22f;
|
||||
|
||||
// ── Animation timing ──────────────────────────────────────────────────────
|
||||
public const float DurFade = 0.15f;
|
||||
public const float DurSlide = 0.20f;
|
||||
public const float DurSnappy = 0.10f;
|
||||
public const float DurAmbient = 0.40f; // slow ambient pulses
|
||||
|
||||
// ── Canvas reference (mirrored from InGameHUDBuilder for consistency) ────
|
||||
public const float ReferenceWidth = 1080f;
|
||||
public const float ReferenceHeight = 1920f;
|
||||
public const float MatchWidthOrHeight = 0.5f;
|
||||
|
||||
// ── Emoji font fallback ───────────────────────────────────────────────────
|
||||
// Tracks whether we've already tried to wire a Noto Color Emoji fallback
|
||||
// into TMP_Settings. Once attempted (success or fail), we don't keep
|
||||
// re-poking - mobile resource lookups aren't free.
|
||||
static bool _emojiFallbackAttempted;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the project's TMP fallback font chain so emoji codepoints
|
||||
/// (📡 ⚙️ 🗳️ 👥 etc.) used throughout the UI render properly. Looks for a
|
||||
/// TMP_FontAsset named "NotoColorEmoji" (or "EmojiFallback") under
|
||||
/// `Resources/Fonts/` first; if that's missing, walks the OS font cache
|
||||
/// the platform exposes via Font.OSFontNames as a last-resort fallback.
|
||||
///
|
||||
/// Idempotent + cheap on subsequent calls. Callers don't need to gate.
|
||||
/// </summary>
|
||||
public static void EnsureEmojiFontFallback()
|
||||
{
|
||||
if (_emojiFallbackAttempted) return;
|
||||
_emojiFallbackAttempted = true;
|
||||
|
||||
// Primary: explicit Resources lookup. Drop a pre-built
|
||||
// `NotoColorEmoji.asset` (TMP_FontAsset) into Assets/Resources/Fonts/
|
||||
// for Android/iOS - that's the production path.
|
||||
TMP_FontAsset emoji = Resources.Load<TMP_FontAsset>("Fonts/NotoColorEmoji");
|
||||
if (emoji == null) emoji = Resources.Load<TMP_FontAsset>("Fonts/EmojiFallback");
|
||||
|
||||
if (emoji != null)
|
||||
{
|
||||
var def = TMP_Settings.defaultFontAsset;
|
||||
if (def != null)
|
||||
{
|
||||
if (def.fallbackFontAssetTable == null)
|
||||
def.fallbackFontAssetTable = new List<TMP_FontAsset>();
|
||||
if (!def.fallbackFontAssetTable.Contains(emoji))
|
||||
{
|
||||
def.fallbackFontAssetTable.Add(emoji);
|
||||
Debug.Log("[UITheme] Emoji fallback wired: " + emoji.name);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No bundled emoji font. Log once so the user knows to either drop
|
||||
// one in or strip emojis from labels - silent failure here means
|
||||
// missing-glyph rectangles surface in the UI without explanation.
|
||||
Debug.LogWarning(
|
||||
"[UITheme] No NotoColorEmoji TMP_FontAsset found under Resources/Fonts/. " +
|
||||
"Emoji glyphs in UI text will render as tofu boxes. " +
|
||||
"Drop a TMP-baked Noto Color Emoji asset at " +
|
||||
"Assets/Resources/Fonts/NotoColorEmoji.asset (any TMP_FontAsset name " +
|
||||
"containing 'Emoji' also matches via the secondary lookup).");
|
||||
}
|
||||
|
||||
// ── Style helpers ─────────────────────────────────────────────────────────
|
||||
// Apply consistent styling without repeating every property. Keep these
|
||||
// single-purpose; if you find yourself adding flags, that's a sign you
|
||||
// should add a new helper instead.
|
||||
|
||||
/// <summary>Solid Image with theme-consistent color. Returns the Image.</summary>
|
||||
public static Image Surf(RectTransform rt, Color color)
|
||||
{
|
||||
var img = rt.gameObject.GetComponent<Image>() ?? rt.gameObject.AddComponent<Image>();
|
||||
img.color = color;
|
||||
return img;
|
||||
}
|
||||
|
||||
/// <summary>Stretches a RectTransform to fill its parent.</summary>
|
||||
public static void Stretch(RectTransform rt)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>Apply the project's standard CanvasScaler config.</summary>
|
||||
public static void ConfigureCanvasScaler(Canvas canvas)
|
||||
{
|
||||
if (canvas == null) return;
|
||||
var scaler = canvas.GetComponent<CanvasScaler>()
|
||||
?? canvas.gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(ReferenceWidth, ReferenceHeight);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
|
||||
scaler.matchWidthOrHeight = MatchWidthOrHeight;
|
||||
scaler.referencePixelsPerUnit = 100f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anchor a RectTransform to the screen's safe rectangle (notch/punch-hole
|
||||
/// safe). Children inherit the inset.
|
||||
/// </summary>
|
||||
public static void ApplySafeArea(RectTransform rt)
|
||||
{
|
||||
if (rt == null) return;
|
||||
var safe = Screen.safeArea;
|
||||
var screenSize = new Vector2(Screen.width, Screen.height);
|
||||
if (screenSize.x <= 0 || screenSize.y <= 0) return;
|
||||
|
||||
Vector2 aMin = safe.position;
|
||||
Vector2 aMax = safe.position + safe.size;
|
||||
aMin.x /= screenSize.x; aMin.y /= screenSize.y;
|
||||
aMax.x /= screenSize.x; aMax.y /= screenSize.y;
|
||||
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Style a TMP text field as a labeled text element. Sets size, color,
|
||||
/// alignment, weight - and ensures the emoji fallback chain is wired so
|
||||
/// any emoji glyphs in the text render properly on the first frame.
|
||||
/// </summary>
|
||||
public static void StyleText(TMP_Text tmp, float size, Color color,
|
||||
TextAlignmentOptions align, bool bold = false, FontStyles extra = FontStyles.Normal)
|
||||
{
|
||||
if (tmp == null) return;
|
||||
EnsureEmojiFontFallback();
|
||||
tmp.fontSize = size;
|
||||
tmp.color = color;
|
||||
tmp.alignment = align;
|
||||
tmp.fontStyle = (bold ? FontStyles.Bold : FontStyles.Normal) | extra;
|
||||
tmp.enableWordWrapping = true;
|
||||
}
|
||||
|
||||
/// <summary>Standard primary CTA button styling - bg, text, target graphic.</summary>
|
||||
public static Button StylePrimaryButton(RectTransform rt, string label,
|
||||
Color? bgColor = null, Color? textColor = null, float? fontSize = null)
|
||||
{
|
||||
var bg = Surf(rt, bgColor ?? Accent);
|
||||
var btn = rt.gameObject.GetComponent<Button>() ?? rt.gameObject.AddComponent<Button>();
|
||||
btn.targetGraphic = bg;
|
||||
|
||||
// Color block: dim for pressed/disabled states.
|
||||
var cb = btn.colors;
|
||||
cb.normalColor = bgColor ?? Accent;
|
||||
cb.highlightedColor = Color.Lerp(bgColor ?? Accent, TextHi, 0.10f);
|
||||
cb.pressedColor = Color.Lerp(bgColor ?? Accent, Bg, 0.30f);
|
||||
cb.disabledColor = new Color(0.30f, 0.34f, 0.42f, 0.70f);
|
||||
cb.selectedColor = bgColor ?? Accent;
|
||||
btn.colors = cb;
|
||||
|
||||
// Label child
|
||||
var labelRt = NewChild("Label", rt);
|
||||
Stretch(labelRt);
|
||||
var tmp = labelRt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
StyleText(tmp, fontSize ?? FontActionBtn,
|
||||
textColor ?? TextOnAccent, TextAlignmentOptions.Center, bold: true);
|
||||
tmp.text = label;
|
||||
// Padding so the text doesn't kiss the button edges.
|
||||
labelRt.offsetMin = new Vector2(S4, S2);
|
||||
labelRt.offsetMax = new Vector2(-S4, -S2);
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// <summary>Secondary (outlined / muted) button styling.</summary>
|
||||
public static Button StyleSecondaryButton(RectTransform rt, string label,
|
||||
float? fontSize = null)
|
||||
{
|
||||
return StylePrimaryButton(rt, label,
|
||||
bgColor: SurfaceAlt, textColor: TextHi, fontSize: fontSize);
|
||||
}
|
||||
|
||||
/// <summary>Danger (eject/leave/kill confirmation) button styling.</summary>
|
||||
public static Button StyleDangerButton(RectTransform rt, string label,
|
||||
float? fontSize = null)
|
||||
{
|
||||
return StylePrimaryButton(rt, label,
|
||||
bgColor: Danger, textColor: TextHi, fontSize: fontSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a child RectTransform under the given parent. Centralized so
|
||||
/// every UI builder doesn't reinvent the GameObject + RectTransform
|
||||
/// boilerplate.
|
||||
/// </summary>
|
||||
public static RectTransform NewChild(string name, RectTransform parent)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
var rt = go.AddComponent<RectTransform>();
|
||||
rt.SetParent(parent, false);
|
||||
rt.localScale = Vector3.one;
|
||||
return rt;
|
||||
}
|
||||
|
||||
/// <summary>Anchor a RectTransform with absolute corner offsets.</summary>
|
||||
public static void Anchor(RectTransform rt, Vector2 aMin, Vector2 aMax,
|
||||
Vector2 offsetMin, Vector2 offsetMax)
|
||||
{
|
||||
rt.anchorMin = aMin; rt.anchorMax = aMax;
|
||||
rt.offsetMin = offsetMin; rt.offsetMax = offsetMax;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a TMP text label as a child of `parent`, fully filling it. Common
|
||||
/// pattern for buttons and panel headers.
|
||||
/// </summary>
|
||||
public static TextMeshProUGUI TxtChild(RectTransform parent, string text,
|
||||
float size, Color color, TextAlignmentOptions align, bool bold = false)
|
||||
{
|
||||
var rt = NewChild("Txt", parent);
|
||||
Stretch(rt);
|
||||
var tmp = rt.gameObject.AddComponent<TextMeshProUGUI>();
|
||||
StyleText(tmp, size, color, align, bold);
|
||||
tmp.text = text;
|
||||
return tmp;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/UITheme.cs.meta
Normal file
2
Assets/Scripts/UITheme.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1ba695cb5aeb1d468b2eb2b5032d830
|
||||
Reference in New Issue
Block a user