feat: add Unity project (Assets, ProjectSettings)
This commit is contained in:
573
game/Assets/Scripts/ArenaZoneBuilder.cs
Normal file
573
game/Assets/Scripts/ArenaZoneBuilder.cs
Normal file
@@ -0,0 +1,573 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a compact 80×80 tutorial arena at runtime with connected zones:
|
||||
///
|
||||
/// Zone A (South) — Movement basics + jump gaps
|
||||
/// Zone B (Center-S) — Bounce (GelBleu) training
|
||||
/// Zone C (West) — Speed (GelOrange) training
|
||||
/// Zone D (NW) — Moving platforms
|
||||
/// Zone E (NE) — Sticky walls (GelViolet) training
|
||||
/// Zone F (North) — Combo challenge (all gels + platforms)
|
||||
/// Zone G (Center) — Central tower (final challenge)
|
||||
///
|
||||
/// Spawn: (0, 2, -30) — south edge
|
||||
///
|
||||
/// Ball capabilities reference (from PlayerController):
|
||||
/// Max jump height ≈ 5.1 units (JumpForce=10, gravity=9.81)
|
||||
/// Horizontal jump range ≈ 6-8 units at cruising speed
|
||||
/// GelBleu bounce = perfect elasticity (bounciness 1.0, combine=Max)
|
||||
/// GelOrange speed = ×3.83 multiplier (~19 u/s)
|
||||
/// GelViolet sticky = surface-relative movement, jump = surface normal
|
||||
/// Ball radius = 0.5, min platform 2×2 for playability
|
||||
///
|
||||
/// Loads PhysicMaterials from Resources/ folder.
|
||||
/// Attach to a persistent GameObject (e.g. NetworkManager).
|
||||
/// </summary>
|
||||
public class ArenaZoneBuilder : MonoBehaviour
|
||||
{
|
||||
private PhysicsMaterial _matGelBleu;
|
||||
private PhysicsMaterial _matGelOrange;
|
||||
private PhysicsMaterial _matGelViolet;
|
||||
private PhysicsMaterial _matBouncy;
|
||||
private PhysicsMaterial _matNormal;
|
||||
private Material _baseMat;
|
||||
|
||||
private readonly List<MovingPlatform> _movingPlatforms = new List<MovingPlatform>();
|
||||
|
||||
// ── Color palette ──
|
||||
private static readonly Color ColFloor = new Color(0.28f, 0.29f, 0.34f, 1f);
|
||||
private static readonly Color ColBleu = new Color(0.2f, 0.5f, 1f, 0.85f);
|
||||
private static readonly Color ColBleuLt = new Color(0.3f, 0.65f, 1f, 0.9f);
|
||||
private static readonly Color ColOrange = new Color(1f, 0.55f, 0.1f, 0.85f);
|
||||
private static readonly Color ColOrangeLt = new Color(1f, 0.65f, 0.2f, 0.9f);
|
||||
private static readonly Color ColViolet = new Color(0.6f, 0.2f, 0.8f, 0.8f);
|
||||
private static readonly Color ColVioletLt = new Color(0.7f, 0.35f, 0.9f, 0.85f);
|
||||
private static readonly Color ColNormal = new Color(0.42f, 0.43f, 0.50f, 0.9f);
|
||||
private static readonly Color ColNormalLt = new Color(0.52f, 0.53f, 0.60f, 0.95f);
|
||||
private static readonly Color ColDark = new Color(0.22f, 0.22f, 0.28f, 0.95f);
|
||||
private static readonly Color ColGold = new Color(1f, 0.84f, 0f, 0.95f);
|
||||
private static readonly Color ColWall = new Color(0.35f, 0.35f, 0.42f, 0.95f);
|
||||
private static readonly Color ColPath = new Color(0.38f, 0.40f, 0.48f, 0.9f);
|
||||
private static readonly Color ColSignBleu = new Color(0.15f, 0.4f, 0.9f, 0.95f);
|
||||
private static readonly Color ColSignOrange= new Color(0.9f, 0.45f, 0.05f, 0.95f);
|
||||
private static readonly Color ColSignViolet= new Color(0.5f, 0.15f, 0.7f, 0.95f);
|
||||
private static readonly Color ColSignGrey = new Color(0.5f, 0.5f, 0.55f, 0.95f);
|
||||
private static readonly Color ColSignGold = new Color(0.9f, 0.75f, 0f, 0.95f);
|
||||
private static readonly Color ColGuide = new Color(0.5f, 0.5f, 0.6f, 0.5f);
|
||||
|
||||
// ── Constants ──
|
||||
private const float HALF = 40f; // Arena half-size
|
||||
private const float WALL_H = 12f; // Perimeter wall height
|
||||
private const float WALL_T = 1f; // Perimeter wall thickness
|
||||
|
||||
void Start()
|
||||
{
|
||||
LoadMaterials();
|
||||
if (_baseMat == null) FindBaseMaterial();
|
||||
|
||||
BuildFloorAndWalls();
|
||||
BuildPaths();
|
||||
BuildZoneA_Movement();
|
||||
BuildZoneB_Bounce();
|
||||
BuildZoneC_Speed();
|
||||
BuildZoneD_MovingPlatforms();
|
||||
BuildZoneE_StickyWalls();
|
||||
BuildZoneF_Combo();
|
||||
BuildZoneG_CentralTower();
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] 80x80 tutorial arena built successfully.");
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
for (int i = 0; i < _movingPlatforms.Count; i++)
|
||||
{
|
||||
var mp = _movingPlatforms[i];
|
||||
if (mp.go == null) continue;
|
||||
mp.t += Time.deltaTime * mp.speed;
|
||||
float ping = Mathf.PingPong(mp.t, 1f);
|
||||
mp.go.transform.position = Vector3.Lerp(mp.posA, mp.posB, ping);
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
_movingPlatforms.Clear();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// FLOOR, WALLS & PATHS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildFloorAndWalls()
|
||||
{
|
||||
// Main floor
|
||||
CreateZone("Arena_Floor", new Vector3(0, -0.25f, 0),
|
||||
new Vector3(HALF * 2f, 0.5f, HALF * 2f), _matNormal, ColFloor);
|
||||
|
||||
// Perimeter walls (high to prevent escape)
|
||||
float h2 = WALL_H / 2f;
|
||||
float full = HALF * 2f + 2f;
|
||||
CreateZone("Wall_N", new Vector3(0, h2, HALF), new Vector3(full, WALL_H, WALL_T), _matNormal, ColWall);
|
||||
CreateZone("Wall_S", new Vector3(0, h2, -HALF), new Vector3(full, WALL_H, WALL_T), _matNormal, ColWall);
|
||||
CreateZone("Wall_E", new Vector3(HALF, h2, 0), new Vector3(WALL_T, WALL_H, full), _matNormal, ColWall);
|
||||
CreateZone("Wall_W", new Vector3(-HALF, h2, 0), new Vector3(WALL_T, WALL_H, full), _matNormal, ColWall);
|
||||
}
|
||||
|
||||
/// <summary>Ground-level paths connecting all zones, with directional color markers.</summary>
|
||||
private void BuildPaths()
|
||||
{
|
||||
float pathH = 0.06f;
|
||||
float pathY = 0.03f;
|
||||
|
||||
// Spawn -> Zone A (already at spawn)
|
||||
// Zone A -> Zone B (south to center-south)
|
||||
CreateZone("Path_A_B", new Vector3(0, pathY, -22f), new Vector3(3f, pathH, 10f), _matNormal, ColPath);
|
||||
// Zone B -> Zone G center
|
||||
CreateZone("Path_B_G", new Vector3(0, pathY, -8f), new Vector3(3f, pathH, 10f), _matNormal, ColPath);
|
||||
// Zone G -> Zone C (center to west)
|
||||
CreateZone("Path_G_C", new Vector3(-10f, pathY, 0f), new Vector3(12f, pathH, 3f), _matNormal, ColPath);
|
||||
// Zone C -> Zone D (west to northwest)
|
||||
CreateZone("Path_C_D", new Vector3(-28f, pathY, 12f), new Vector3(3f, pathH, 16f), _matNormal, ColPath);
|
||||
// Zone G -> Zone E (center to northeast)
|
||||
CreateZone("Path_G_E", new Vector3(12f, pathY, 10f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
|
||||
// Zone D/E -> Zone F (north)
|
||||
CreateZone("Path_D_F", new Vector3(-12f, pathY, 28f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
|
||||
CreateZone("Path_E_F", new Vector3(12f, pathY, 28f), new Vector3(16f, pathH, 3f), _matNormal, ColPath);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE A — MOVEMENT BASICS + JUMP GAPS (South)
|
||||
// Origin: (0, 0, -32)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneA_Movement()
|
||||
{
|
||||
// Sign
|
||||
CreateSign("Sign_A", new Vector3(-4f, 1.5f, -35f), ColSignGrey);
|
||||
|
||||
// Spawn platform (raised)
|
||||
CreateZone("A_Spawn", new Vector3(0, 0.4f, -32f), new Vector3(8f, 0.8f, 6f), _matNormal, ColNormalLt);
|
||||
|
||||
// Straight corridor with slight turns to learn rolling
|
||||
CreateZone("A_Corr1", new Vector3(0, 0.15f, -27f), new Vector3(4f, 0.3f, 5f), _matNormal, ColNormal);
|
||||
CreateZone("A_Corr2", new Vector3(3f, 0.15f, -23f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormal);
|
||||
CreateZone("A_Corr3", new Vector3(0f, 0.15f, -19.5f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormal);
|
||||
|
||||
// Low guide walls for the corridor
|
||||
CreateZone("A_GuideL1", new Vector3(-2.5f, 0.6f, -27f), new Vector3(0.3f, 0.9f, 5f), _matNormal, ColGuide);
|
||||
CreateZone("A_GuideR1", new Vector3(2.5f, 0.6f, -27f), new Vector3(0.3f, 0.9f, 5f), _matNormal, ColGuide);
|
||||
|
||||
// Jump gaps: 3 gaps of increasing difficulty
|
||||
// Gap 1: 2 units gap
|
||||
CreateZone("A_Plat1", new Vector3(-3f, 0.15f, -16f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
|
||||
// (gap of 2 units)
|
||||
CreateZone("A_Plat2", new Vector3(-3f, 0.15f, -10f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Gap 2: 3 units gap
|
||||
// (gap of 3 units)
|
||||
CreateZone("A_Plat3", new Vector3(-3f, 0.15f, -3f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Gap 3: 4.5 units gap (needs charged jump)
|
||||
// (gap of 4.5 units)
|
||||
CreateZone("A_Plat4", new Vector3(-3f, 0.15f, 5f), new Vector3(4f, 0.3f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Return ramp to ground
|
||||
CreateRamp("A_Ramp", new Vector3(-3f, 1.0f, 9f), new Vector3(4f, 0.3f, 5f),
|
||||
-12f, Vector3.right, _matNormal, ColNormal);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone A (Movement) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE B — BOUNCE TRAINING (Center-South)
|
||||
// Origin: (10, 0, -15)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneB_Bounce()
|
||||
{
|
||||
Vector3 o = new Vector3(10f, 0f, -15f);
|
||||
|
||||
// Sign
|
||||
CreateSign("Sign_B", o + new Vector3(-3f, 1.5f, -3f), ColSignBleu);
|
||||
|
||||
// Entry pad
|
||||
CreateZone("B_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// Bounce pads with targets at increasing heights
|
||||
// Pad 1 (small) -> target at 3m
|
||||
CreateZone("B_Pad1", o + new Vector3(0, 0.2f, 5f), new Vector3(3f, 0.25f, 3f), _matGelBleu, ColBleu);
|
||||
CreateZone("B_Tgt1", o + new Vector3(0, 3f, 9f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Pad 2 (medium) -> target at 4.5m
|
||||
CreateZone("B_Pad2", o + new Vector3(0, 3.2f, 9f), new Vector3(3.5f, 0.25f, 3.5f), _matGelBleu, ColBleuLt);
|
||||
CreateZone("B_Tgt2", o + new Vector3(4f, 5.5f, 12f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Pad 3 on target 2 -> top at 8m
|
||||
CreateZone("B_Pad3", o + new Vector3(4f, 5.7f, 12f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleu);
|
||||
CreateZone("B_Tgt3", o + new Vector3(0, 8f, 15f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// Bounce staircase: fall from 8m -> bounce up further
|
||||
CreateZone("B_Stair_Bnc", o + new Vector3(-5f, 0.2f, 15f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
|
||||
CreateZone("B_Stair_Mid", o + new Vector3(-5f, 5f, 19f), new Vector3(4f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
CreateZone("B_Stair_Top", o + new Vector3(-5f, 8.5f, 23f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// Return to ground via gentle ramp
|
||||
CreateRamp("B_Return", o + new Vector3(-5f, 4f, 27f), new Vector3(4f, 0.3f, 8f),
|
||||
-20f, Vector3.right, _matNormal, ColNormal);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone B (Bounce) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE C — SPEED TRAINING (West)
|
||||
// Origin: (-25, 0, -5)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneC_Speed()
|
||||
{
|
||||
Vector3 o = new Vector3(-25f, 0f, -5f);
|
||||
|
||||
// Sign
|
||||
CreateSign("Sign_C", o + new Vector3(8f, 1.5f, -2f), ColSignOrange);
|
||||
|
||||
// Entry
|
||||
CreateZone("C_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// Speed strip 1 -> short, straight, with ramp launch
|
||||
CreateZone("C_Strip1", o + new Vector3(0, 0.12f, 5f), new Vector3(4f, 0.15f, 8f), _matGelOrange, ColOrange);
|
||||
// Guide walls
|
||||
CreateZone("C_GuideL1", new Vector3(o.x - 2.5f, 0.5f, o.z + 5f), new Vector3(0.3f, 1f, 8f), _matNormal, ColGuide);
|
||||
CreateZone("C_GuideR1", new Vector3(o.x + 2.5f, 0.5f, o.z + 5f), new Vector3(0.3f, 1f, 8f), _matNormal, ColGuide);
|
||||
|
||||
// Ramp at end of strip 1
|
||||
CreateRamp("C_Ramp1", o + new Vector3(0, 0.8f, 10.5f), new Vector3(4f, 0.25f, 4f),
|
||||
20f, Vector3.right, _matGelOrange, ColOrangeLt);
|
||||
|
||||
// Landing platform (12m ahead, 2m up)
|
||||
CreateZone("C_Land1", o + new Vector3(0, 2f, 18f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// Speed strip 2 -> longer with a curve
|
||||
CreateZone("C_Strip2a", o + new Vector3(0, 2.1f, 22f), new Vector3(4f, 0.15f, 5f), _matGelOrange, ColOrange);
|
||||
CreateZone("C_Strip2b", o + new Vector3(4f, 2.1f, 26f), new Vector3(5f, 0.15f, 4f), _matGelOrange, ColOrangeLt);
|
||||
CreateZone("C_Strip2c", o + new Vector3(8f, 2.1f, 30f), new Vector3(4f, 0.15f, 5f), _matGelOrange, ColOrange);
|
||||
// Guide walls for curve
|
||||
CreateZone("C_GuideO", o + new Vector3(-2.5f, 2.6f, 22f), new Vector3(0.3f, 1f, 5f), _matNormal, ColGuide);
|
||||
CreateZone("C_GuideI", o + new Vector3(10.5f, 2.6f, 30f), new Vector3(0.3f, 1f, 5f), _matNormal, ColGuide);
|
||||
|
||||
// Final landing with another ramp
|
||||
CreateRamp("C_Ramp2", o + new Vector3(8f, 3f, 34f), new Vector3(4f, 0.25f, 4f),
|
||||
25f, Vector3.right, _matGelOrange, ColOrangeLt);
|
||||
CreateZone("C_Land2", o + new Vector3(8f, 4.5f, 38f), new Vector3(6f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone C (Speed) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE D — MOVING PLATFORMS (Northwest)
|
||||
// Origin: (-28, 0, 18)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneD_MovingPlatforms()
|
||||
{
|
||||
Vector3 o = new Vector3(-28f, 0f, 18f);
|
||||
|
||||
// Sign
|
||||
CreateSign("Sign_D", o + new Vector3(4f, 1.5f, -2f), ColSignGrey);
|
||||
|
||||
// Entry platform
|
||||
CreateZone("D_Entry", o + new Vector3(0, 0.2f, 0), new Vector3(6f, 0.4f, 6f), _matNormal, ColNormalLt);
|
||||
|
||||
// 3 horizontal sliders (oscillate along X, spaced along Z, rising)
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Vector3 a = o + new Vector3(-2f, 1.5f + i * 2f, 5f + i * 6f);
|
||||
Vector3 b = a + new Vector3(8f, 0f, 0f);
|
||||
var go = CreateZone("D_Slide" + i, a, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
|
||||
AddMovingPlatform(go, a, b, 0.3f + i * 0.1f);
|
||||
}
|
||||
|
||||
// Mid-platform
|
||||
CreateZone("D_Mid", o + new Vector3(2f, 7f, 20f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// 2 vertical lifts
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
Vector3 a = o + new Vector3(-4f + i * 8f, 7.5f, 24f + i * 5f);
|
||||
Vector3 b = a + new Vector3(0f, 5f, 0f);
|
||||
var go = CreateZone("D_Lift" + i, a, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
|
||||
AddMovingPlatform(go, a, b, 0.22f + i * 0.1f);
|
||||
}
|
||||
|
||||
// End platform (high, with view)
|
||||
CreateZone("D_End", o + new Vector3(2f, 12f, 32f), new Vector3(6f, 0.4f, 6f), _matNormal, ColNormalLt);
|
||||
|
||||
// Bouncy descent pad at ground level
|
||||
CreateZone("D_Desc", o + new Vector3(2f, 0.2f, 32f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone D (Moving Platforms) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE E — STICKY WALLS (Northeast)
|
||||
// Origin: (24, 0, 15)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneE_StickyWalls()
|
||||
{
|
||||
Vector3 o = new Vector3(24f, 0f, 15f);
|
||||
|
||||
// Sign
|
||||
CreateSign("Sign_E", o + new Vector3(-5f, 1.5f, -2f), ColSignViolet);
|
||||
|
||||
// Entry pad
|
||||
CreateZone("E_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// === Intro wall: simple vertical climb (4m high) ===
|
||||
// Wall face pointing -X (ball approaches from the left)
|
||||
CreateZone("E_Wall1", o + new Vector3(3f, 3f, 3f), new Vector3(0.5f, 6f, 5f), _matGelViolet, ColViolet);
|
||||
// Platform at top of wall
|
||||
CreateZone("E_Top1", o + new Vector3(3f, 6.2f, 3f), new Vector3(4f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// === L-shaped sticky: wall -> turn -> wall ===
|
||||
// Vertical wall going up (face -Z)
|
||||
CreateZone("E_Wall2a", o + new Vector3(0, 3.5f, 8f), new Vector3(4f, 7f, 0.5f), _matGelViolet, ColViolet);
|
||||
// Ceiling connecting to second wall (face down, -Y)
|
||||
CreateZone("E_Ceil", o + new Vector3(0, 7.1f, 10f), new Vector3(4f, 0.5f, 4f), _matGelViolet, ColVioletLt);
|
||||
// Second wall going down (face +Z)
|
||||
CreateZone("E_Wall2b", o + new Vector3(0, 3.5f, 12f), new Vector3(4f, 7f, 0.5f), _matGelViolet, ColViolet);
|
||||
// Landing after L traverse
|
||||
CreateZone("E_Land2", o + new Vector3(0, 0.2f, 14f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
|
||||
// === Vertical tunnel: two sticky walls face-to-face, zigzag up ===
|
||||
// Left wall
|
||||
CreateZone("E_Tun_L", o + new Vector3(-3.5f, 6f, 18f), new Vector3(0.5f, 12f, 4f), _matGelViolet, ColViolet);
|
||||
// Right wall
|
||||
CreateZone("E_Tun_R", o + new Vector3(3.5f, 6f, 18f), new Vector3(0.5f, 12f, 4f), _matGelViolet, ColVioletLt);
|
||||
// Small ledges alternating sides to help zigzag
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
float side = (i % 2 == 0) ? -2.5f : 2.5f;
|
||||
float h = 2f + i * 2.8f;
|
||||
CreateZone("E_Tun_Ledge" + i, o + new Vector3(side, h, 18f),
|
||||
new Vector3(2f, 0.3f, 3f), _matNormal, ColNormalLt);
|
||||
}
|
||||
// Top of tunnel
|
||||
CreateZone("E_TunTop", o + new Vector3(0, 12.2f, 18f), new Vector3(5f, 0.4f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone E (Sticky Walls) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE F — COMBO CHALLENGE (North)
|
||||
// Origin: (0, 0, 30)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneF_Combo()
|
||||
{
|
||||
Vector3 o = new Vector3(0f, 0f, 30f);
|
||||
|
||||
// Sign
|
||||
CreateSign("Sign_F", o + new Vector3(-5f, 1.5f, -4f), ColSignGold);
|
||||
|
||||
// Entry
|
||||
CreateZone("F_Entry", o + new Vector3(0, 0.15f, 0), new Vector3(5f, 0.3f, 5f), _matNormal, ColNormalLt);
|
||||
|
||||
// Step 1: Speed strip launch
|
||||
CreateZone("F_Speed", o + new Vector3(0, 0.12f, 4f), new Vector3(3f, 0.15f, 6f), _matGelOrange, ColOrange);
|
||||
CreateZone("F_GuidL", o + new Vector3(-2f, 0.5f, 4f), new Vector3(0.3f, 0.7f, 6f), _matNormal, ColGuide);
|
||||
CreateZone("F_GuidR", o + new Vector3(2f, 0.5f, 4f), new Vector3(0.3f, 0.7f, 6f), _matNormal, ColGuide);
|
||||
|
||||
// Step 2: Ramp -> bounce pad
|
||||
CreateRamp("F_Ramp", o + new Vector3(0, 0.6f, 8.5f), new Vector3(3f, 0.25f, 3f),
|
||||
22f, Vector3.right, _matGelOrange, ColOrangeLt);
|
||||
CreateZone("F_BncPad", o + new Vector3(0, 0.2f, 13f), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleu);
|
||||
|
||||
// Step 3: High platform with sticky wall leading higher
|
||||
CreateZone("F_MidPlat", o + new Vector3(0, 4.5f, 17f), new Vector3(5f, 0.4f, 4f), _matNormal, ColNormalLt);
|
||||
CreateZone("F_StickyWall", o + new Vector3(-3f, 7f, 17f), new Vector3(0.5f, 5f, 4f), _matGelViolet, ColViolet);
|
||||
|
||||
// Step 4: Moving platform to final
|
||||
Vector3 mpA = o + new Vector3(0, 9.5f, 17f);
|
||||
Vector3 mpB = o + new Vector3(0, 9.5f, 23f);
|
||||
var mp = CreateZone("F_MovPlat", mpA, new Vector3(4f, 0.5f, 4f), _matNormal, ColDark);
|
||||
AddMovingPlatform(mp, mpA, mpB, 0.25f);
|
||||
|
||||
// Step 5: Gold finish platform
|
||||
CreateZone("F_Finish", o + new Vector3(0, 10f, 27f), new Vector3(6f, 0.5f, 5f), _matNormal, ColGold);
|
||||
CreateZone("F_FinBnc", o + new Vector3(0, 10.3f, 27f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleuLt);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone F (Combo) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE G — CENTRAL TOWER (Center)
|
||||
// Origin: (0, 0, 5)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void BuildZoneG_CentralTower()
|
||||
{
|
||||
Vector3 o = new Vector3(0f, 0f, 5f);
|
||||
|
||||
// Base (accessible from ground)
|
||||
CreateZone("G_Base", o + new Vector3(0, 0.25f, 0), new Vector3(10f, 0.5f, 10f), _matNormal, ColNormal);
|
||||
|
||||
// Access ramp from south
|
||||
CreateRamp("G_Ramp", o + new Vector3(0, 0.8f, -6f), new Vector3(4f, 0.3f, 5f),
|
||||
-10f, Vector3.right, _matNormal, ColNormal);
|
||||
|
||||
// Level 1: Bounce pad -> L2
|
||||
CreateZone("G_L1_Bnc", o + new Vector3(0, 0.5f, 0), new Vector3(3.5f, 0.25f, 3.5f), _matGelBleu, ColBleu);
|
||||
|
||||
// Level 2 (3.5m): platform + speed strip
|
||||
CreateZone("G_L2", o + new Vector3(0, 3.5f, 0), new Vector3(8f, 0.4f, 8f), _matNormal, ColNormalLt);
|
||||
CreateZone("G_L2_Spd", o + new Vector3(0, 3.7f, 0), new Vector3(6f, 0.15f, 2f), _matGelOrange, ColOrange);
|
||||
CreateRamp("G_L2_Ramp", o + new Vector3(3f, 4.5f, 3f), new Vector3(3f, 0.25f, 3f),
|
||||
25f, Vector3.right, _matGelOrange, ColOrangeLt);
|
||||
|
||||
// Level 3 (7m): platform + moving platform to L4
|
||||
CreateZone("G_L3", o + new Vector3(0, 7f, 0), new Vector3(7f, 0.4f, 7f), _matNormal, ColNormalLt);
|
||||
CreateZone("G_L3_Bnc", o + new Vector3(2f, 7.25f, 2f), new Vector3(3f, 0.2f, 3f), _matGelBleu, ColBleuLt);
|
||||
|
||||
// Moving platform L3->L4
|
||||
Vector3 mp3a = o + new Vector3(5f, 8f, 0);
|
||||
Vector3 mp3b = o + new Vector3(0, 8f, 5f);
|
||||
var m3 = CreateZone("G_MP3", mp3a, new Vector3(3.5f, 0.5f, 3.5f), _matNormal, ColDark);
|
||||
AddMovingPlatform(m3, mp3a, mp3b, 0.2f);
|
||||
|
||||
// Level 4 (10.5m): platform + sticky wall to L5
|
||||
CreateZone("G_L4", o + new Vector3(0, 10.5f, 0), new Vector3(7f, 0.4f, 7f), _matNormal, ColNormalLt);
|
||||
CreateZone("G_L4_Sticky", o + new Vector3(-3.8f, 13f, 0), new Vector3(0.5f, 5f, 5f), _matGelViolet, ColViolet);
|
||||
|
||||
// Level 5 — SUMMIT (15.5m): gold platform
|
||||
CreateZone("G_L5", o + new Vector3(0, 15.5f, 0), new Vector3(8f, 0.5f, 8f), _matNormal, ColGold);
|
||||
CreateZone("G_L5_Bnc", o + new Vector3(0, 15.85f, 0), new Vector3(4f, 0.25f, 4f), _matGelBleu, ColBleuLt);
|
||||
|
||||
// Orbital moving platform for alternative L3->L4 access
|
||||
Vector3 orb_a = o + new Vector3(-6f, 9f, 0);
|
||||
Vector3 orb_b = o + new Vector3(0, 9f, -6f);
|
||||
var orb = CreateZone("G_Orb", orb_a, new Vector3(3.5f, 0.5f, 3.5f), _matNormal, ColDark);
|
||||
AddMovingPlatform(orb, orb_a, orb_b, 0.18f);
|
||||
|
||||
Debug.Log("[ArenaZoneBuilder] Zone G (Central Tower) built.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// SIGN HELPER (zone entrance markers)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void CreateSign(string name, Vector3 position, Color color)
|
||||
{
|
||||
// Tall thin panel as zone marker
|
||||
CreateZone(name, position, new Vector3(0.3f, 2.5f, 0.3f), _matNormal, color);
|
||||
// Colored cap on top
|
||||
CreateZone(name + "_Cap", position + new Vector3(0, 1.45f, 0), new Vector3(0.8f, 0.4f, 0.8f), _matNormal, color);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MATERIAL LOADING
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private void LoadMaterials()
|
||||
{
|
||||
_matGelBleu = Resources.Load<PhysicsMaterial>("GelBleu");
|
||||
_matGelOrange = Resources.Load<PhysicsMaterial>("GelOrange");
|
||||
_matGelViolet = Resources.Load<PhysicsMaterial>("GelViolet");
|
||||
_matBouncy = Resources.Load<PhysicsMaterial>("Bouncy");
|
||||
_matNormal = Resources.Load<PhysicsMaterial>("Normal");
|
||||
|
||||
if (_matGelBleu == null) Debug.LogWarning("[ArenaZoneBuilder] GelBleu not found in Resources!");
|
||||
if (_matGelOrange == null) Debug.LogWarning("[ArenaZoneBuilder] GelOrange not found in Resources!");
|
||||
if (_matGelViolet == null) Debug.LogWarning("[ArenaZoneBuilder] GelViolet not found in Resources!");
|
||||
if (_matBouncy == null) Debug.LogWarning("[ArenaZoneBuilder] Bouncy not found in Resources!");
|
||||
if (_matNormal == null) Debug.LogWarning("[ArenaZoneBuilder] Normal not found in Resources!");
|
||||
}
|
||||
|
||||
private void FindBaseMaterial()
|
||||
{
|
||||
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||
_baseMat = new Material(shader);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ZONE HELPERS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private GameObject CreateZone(string name, Vector3 position, Vector3 size,
|
||||
PhysicsMaterial physMat, Color color)
|
||||
{
|
||||
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
go.name = name;
|
||||
go.transform.position = position;
|
||||
go.transform.localScale = size;
|
||||
go.isStatic = true;
|
||||
|
||||
var col = go.GetComponent<Collider>();
|
||||
if (col != null && physMat != null) col.material = physMat;
|
||||
|
||||
var rend = go.GetComponent<Renderer>();
|
||||
if (rend != null)
|
||||
{
|
||||
var mat = new Material(_baseMat);
|
||||
SetMatColor(mat, color);
|
||||
if (color.a < 1f) SetMatTransparent(mat, color);
|
||||
rend.material = mat;
|
||||
}
|
||||
return go;
|
||||
}
|
||||
|
||||
private GameObject CreateRamp(string name, Vector3 position, Vector3 size,
|
||||
float angle, Vector3 axis, PhysicsMaterial physMat, Color color)
|
||||
{
|
||||
var go = CreateZone(name, position, size, physMat, color);
|
||||
go.transform.rotation = Quaternion.AngleAxis(angle, axis);
|
||||
return go;
|
||||
}
|
||||
|
||||
private void AddMovingPlatform(GameObject go, Vector3 posA, Vector3 posB, float speed)
|
||||
{
|
||||
go.isStatic = false;
|
||||
_movingPlatforms.Add(new MovingPlatform { go = go, posA = posA, posB = posB, speed = speed });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MATERIAL HELPERS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private static void SetMatColor(Material mat, Color color)
|
||||
{
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||
if (mat.HasProperty("_Color")) mat.color = color;
|
||||
}
|
||||
|
||||
private static void SetMatTransparent(Material mat, Color color)
|
||||
{
|
||||
if (mat.HasProperty("_Surface"))
|
||||
{
|
||||
mat.SetFloat("_Surface", 1);
|
||||
mat.SetFloat("_Blend", 0);
|
||||
}
|
||||
mat.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
|
||||
mat.DisableKeyword("_SURFACE_TYPE_OPAQUE");
|
||||
mat.EnableKeyword("_ALPHAPREMULTIPLY_ON");
|
||||
mat.renderQueue = 3000;
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
|
||||
if (mat.HasProperty("_Color")) mat.color = color;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MOVING PLATFORM DATA
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private class MovingPlatform
|
||||
{
|
||||
public GameObject go;
|
||||
public Vector3 posA;
|
||||
public Vector3 posB;
|
||||
public float speed;
|
||||
public float t;
|
||||
}
|
||||
}
|
||||
|
||||
2
game/Assets/Scripts/ArenaZoneBuilder.cs.meta
Normal file
2
game/Assets/Scripts/ArenaZoneBuilder.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31c6f8ef706b51448b461b2b027e2ea8
|
||||
66
game/Assets/Scripts/CameraOrbitKeyboard.cs
Normal file
66
game/Assets/Scripts/CameraOrbitKeyboard.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
/// <summary>
|
||||
/// Adds ZQSD (AZERTY) / WASD (QWERTY) keyboard orbit for the Cinemachine camera.
|
||||
/// Works in parallel with mouse orbit via CinemachineInputAxisController.
|
||||
/// Attach to the CinemachineCamera GameObject alongside CinemachineOrbitalFollow.
|
||||
/// </summary>
|
||||
public class CameraOrbitKeyboard : MonoBehaviour
|
||||
{
|
||||
[Header("Orbit Speed (degrees/sec)")]
|
||||
public float horizontalSpeed = 150f;
|
||||
public float verticalSpeed = 80f;
|
||||
|
||||
private CinemachineOrbitalFollow _orbital;
|
||||
private CinemachineInputAxisController _axisController;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_orbital = GetComponent<CinemachineOrbitalFollow>();
|
||||
_axisController = GetComponent<CinemachineInputAxisController>();
|
||||
if (_orbital == null)
|
||||
Debug.LogWarning("[CameraOrbitKeyboard] CinemachineOrbitalFollow not found on this GameObject.");
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_orbital == null) return;
|
||||
// Freeze camera orbit (keyboard + mouse) when keybind menu is open
|
||||
if (KeyBindingUI.IsVisible)
|
||||
{
|
||||
if (_axisController != null && _axisController.enabled)
|
||||
_axisController.enabled = false;
|
||||
return;
|
||||
}
|
||||
else if (_axisController != null && !_axisController.enabled)
|
||||
{
|
||||
_axisController.enabled = true;
|
||||
}
|
||||
var kb = Keyboard.current;
|
||||
if (kb == null) return;
|
||||
|
||||
// Physical-key mapping: W/A/S/D positions = Z/Q/S/D on AZERTY
|
||||
Key kUp = KeyBindingUI.GetKey("CamUp", Key.W);
|
||||
Key kDown = KeyBindingUI.GetKey("CamDown", Key.S);
|
||||
Key kLeft = KeyBindingUI.GetKey("CamLeft", Key.A);
|
||||
Key kRight = KeyBindingUI.GetKey("CamRight", Key.D);
|
||||
|
||||
float h = 0f, v = 0f;
|
||||
if (kb[kRight].isPressed) h += 1f;
|
||||
if (kb[kLeft].isPressed) h -= 1f;
|
||||
if (kb[kUp].isPressed) v += 1f;
|
||||
if (kb[kDown].isPressed) v -= 1f;
|
||||
|
||||
if (Mathf.Abs(h) > 0.001f || Mathf.Abs(v) > 0.001f)
|
||||
{
|
||||
_orbital.HorizontalAxis.Value += h * horizontalSpeed * Time.deltaTime;
|
||||
_orbital.VerticalAxis.Value = Mathf.Clamp(
|
||||
_orbital.VerticalAxis.Value + v * verticalSpeed * Time.deltaTime,
|
||||
_orbital.VerticalAxis.Range.x,
|
||||
_orbital.VerticalAxis.Range.y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/CameraOrbitKeyboard.cs.meta
Normal file
2
game/Assets/Scripts/CameraOrbitKeyboard.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9948893cc71ec924bb9e06e3afd66bd2
|
||||
217
game/Assets/Scripts/GameManager.cs
Normal file
217
game/Assets/Scripts/GameManager.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton game state machine. Drives all game UI and player state transitions
|
||||
/// based on server events received from NetworkManager.
|
||||
/// States: Lobby → Countdown → Playing → Eliminated/Qualified → RoundEnd → GameEnd
|
||||
/// </summary>
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
public static GameManager Instance { get; private set; }
|
||||
|
||||
[Header("Scene References")]
|
||||
public GameObject playerRoot;
|
||||
public SpectatorCamera spectatorCamera;
|
||||
public GameHUD gameHUD;
|
||||
public EliminationOverlay eliminationOverlay;
|
||||
|
||||
public GamePhase CurrentPhase { get; private set; } = GamePhase.Lobby;
|
||||
public bool IsLocalEliminated { get; private set; } = false;
|
||||
public string CurrentMode { get; private set; } = "race";
|
||||
public int CurrentRound { get; private set; } = 1;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm == null) return;
|
||||
nm.OnPhaseChanged += HandlePhaseChanged;
|
||||
nm.OnCountdownChanged += HandleCountdownChanged;
|
||||
nm.OnEliminated += HandleEliminated;
|
||||
nm.OnQualified += HandleQualified;
|
||||
nm.OnRoundStart += HandleRoundStart;
|
||||
nm.OnRoundEnd += HandleRoundEnd;
|
||||
nm.OnGameEnd += HandleGameEnd;
|
||||
nm.OnDisconnected += HandleDisconnected;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm == null) return;
|
||||
nm.OnPhaseChanged -= HandlePhaseChanged;
|
||||
nm.OnCountdownChanged -= HandleCountdownChanged;
|
||||
nm.OnEliminated -= HandleEliminated;
|
||||
nm.OnQualified -= HandleQualified;
|
||||
nm.OnRoundStart -= HandleRoundStart;
|
||||
nm.OnRoundEnd -= HandleRoundEnd;
|
||||
nm.OnGameEnd -= HandleGameEnd;
|
||||
nm.OnDisconnected -= HandleDisconnected;
|
||||
}
|
||||
|
||||
// ─── Event Handlers ───────────────────────────────────────────────────
|
||||
|
||||
void HandlePhaseChanged(string phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case "countdown":
|
||||
TransitionTo(GamePhase.Countdown);
|
||||
break;
|
||||
case "playing":
|
||||
if (!IsLocalEliminated)
|
||||
TransitionTo(GamePhase.Playing);
|
||||
break;
|
||||
case "roundEnd":
|
||||
TransitionTo(GamePhase.RoundEnd);
|
||||
break;
|
||||
case "gameEnd":
|
||||
TransitionTo(GamePhase.GameEnd);
|
||||
break;
|
||||
case "lobby":
|
||||
// New round lobby — reset eliminated state
|
||||
IsLocalEliminated = false;
|
||||
TransitionTo(GamePhase.Lobby);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void HandleCountdownChanged(float value)
|
||||
{
|
||||
gameHUD?.SetCountdown(value);
|
||||
}
|
||||
|
||||
void HandleEliminated(string sessionId, string reason)
|
||||
{
|
||||
if (sessionId == NetworkManager.Instance?.LocalSessionId)
|
||||
{
|
||||
IsLocalEliminated = true;
|
||||
TransitionTo(GamePhase.Eliminated);
|
||||
eliminationOverlay?.ShowEliminated();
|
||||
}
|
||||
}
|
||||
|
||||
void HandleQualified(string sessionId)
|
||||
{
|
||||
if (sessionId == NetworkManager.Instance?.LocalSessionId)
|
||||
{
|
||||
TransitionTo(GamePhase.Qualified);
|
||||
eliminationOverlay?.ShowQualified();
|
||||
}
|
||||
}
|
||||
|
||||
void HandleRoundStart(int round, string mode)
|
||||
{
|
||||
CurrentRound = round;
|
||||
CurrentMode = mode;
|
||||
gameHUD?.SetRoundInfo(round, mode);
|
||||
IsLocalEliminated = false;
|
||||
}
|
||||
|
||||
void HandleRoundEnd(int round)
|
||||
{
|
||||
// Overlay already shown by elimination/qualification handlers
|
||||
}
|
||||
|
||||
void HandleGameEnd(string winner)
|
||||
{
|
||||
eliminationOverlay?.ShowGameEnd(winner);
|
||||
}
|
||||
|
||||
void HandleDisconnected()
|
||||
{
|
||||
IsLocalEliminated = false;
|
||||
TransitionTo(GamePhase.Lobby);
|
||||
}
|
||||
|
||||
// ─── State Transitions ────────────────────────────────────────────────
|
||||
|
||||
void TransitionTo(GamePhase phase)
|
||||
{
|
||||
CurrentPhase = phase;
|
||||
Debug.Log($"[GameManager] → {phase}");
|
||||
|
||||
switch (phase)
|
||||
{
|
||||
case GamePhase.Lobby:
|
||||
SetPlayerActive(false);
|
||||
SetSpectatorActive(false);
|
||||
gameHUD?.SetPhase("lobby");
|
||||
break;
|
||||
|
||||
case GamePhase.Countdown:
|
||||
gameHUD?.SetPhase("countdown");
|
||||
break;
|
||||
|
||||
case GamePhase.Playing:
|
||||
SetPlayerActive(true);
|
||||
SetSpectatorActive(false);
|
||||
gameHUD?.SetPhase("playing");
|
||||
break;
|
||||
|
||||
case GamePhase.Eliminated:
|
||||
SetPlayerActive(false);
|
||||
SetSpectatorActive(true);
|
||||
gameHUD?.SetPhase("eliminated");
|
||||
break;
|
||||
|
||||
case GamePhase.Qualified:
|
||||
// Keep player active but freeze input briefly
|
||||
gameHUD?.SetPhase("qualified");
|
||||
break;
|
||||
|
||||
case GamePhase.RoundEnd:
|
||||
gameHUD?.SetPhase("roundEnd");
|
||||
break;
|
||||
|
||||
case GamePhase.GameEnd:
|
||||
SetPlayerActive(false);
|
||||
SetSpectatorActive(true);
|
||||
gameHUD?.SetPhase("gameEnd");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SetPlayerActive(bool active)
|
||||
{
|
||||
if (playerRoot == null) return;
|
||||
playerRoot.SetActive(active);
|
||||
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
|
||||
if (pc != null) pc.enabled = active;
|
||||
|
||||
if (active)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SetSpectatorActive(bool active)
|
||||
{
|
||||
if (spectatorCamera == null) return;
|
||||
if (active) spectatorCamera.Activate();
|
||||
else spectatorCamera.Deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
public enum GamePhase
|
||||
{
|
||||
Lobby,
|
||||
Countdown,
|
||||
Playing,
|
||||
Eliminated,
|
||||
Qualified,
|
||||
RoundEnd,
|
||||
GameEnd,
|
||||
}
|
||||
2
game/Assets/Scripts/GameManager.cs.meta
Normal file
2
game/Assets/Scripts/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b251a545afa938409a896585ea92a96
|
||||
154
game/Assets/Scripts/GameSetup.cs
Normal file
154
game/Assets/Scripts/GameSetup.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Global game setup applied at startup:
|
||||
/// - Application.runInBackground (physics continues on ALT-TAB)
|
||||
/// - Tall invisible arena barriers (prevent ball escape)
|
||||
/// - Visual enhancements: obstacle colors, floor tint, lighting contrast
|
||||
/// Attach to a persistent GameObject (e.g. NetworkManager).
|
||||
/// </summary>
|
||||
public class GameSetup : MonoBehaviour
|
||||
{
|
||||
[Header("Arena Boundaries")]
|
||||
public float arenaHalfSize = 45f;
|
||||
public float barrierHeight = 50f;
|
||||
public float barrierThickness = 1f;
|
||||
|
||||
[Header("Visuals")]
|
||||
public bool enhanceVisuals = true;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// --- Keep physics and network running on focus loss ---
|
||||
Application.runInBackground = true;
|
||||
Application.targetFrameRate = 60;
|
||||
|
||||
// Barriers removed — respawn system handles falls (Y < -10)
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (enhanceVisuals)
|
||||
EnhanceVisuals();
|
||||
}
|
||||
|
||||
// --- Barrier creation ---
|
||||
|
||||
private void CreateBarrier(string name, Vector3 position, Vector3 size)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.position = position;
|
||||
var col = go.AddComponent<BoxCollider>();
|
||||
col.size = size;
|
||||
// No Renderer = invisible. Static collider = immovable wall.
|
||||
}
|
||||
|
||||
// --- Visual enhancements ---
|
||||
|
||||
private void EnhanceVisuals()
|
||||
{
|
||||
TintFloor();
|
||||
ColorObstacles();
|
||||
ColorWallsAndGrids();
|
||||
EnhanceLighting();
|
||||
}
|
||||
|
||||
private void TintFloor()
|
||||
{
|
||||
var plane = GameObject.Find("Plane");
|
||||
if (plane == null) return;
|
||||
var rend = plane.GetComponent<Renderer>();
|
||||
if (rend == null) return;
|
||||
|
||||
var mat = new Material(rend.sharedMaterial);
|
||||
// Soft blue-gray instead of flat white
|
||||
Color floorColor = new Color(0.70f, 0.74f, 0.82f, 1f);
|
||||
SetMatColor(mat, floorColor);
|
||||
rend.material = mat;
|
||||
}
|
||||
|
||||
private void ColorObstacles()
|
||||
{
|
||||
Color[] palette =
|
||||
{
|
||||
new Color(0.42f, 0.55f, 0.75f), // Steel blue
|
||||
new Color(0.60f, 0.45f, 0.68f), // Muted purple
|
||||
new Color(0.48f, 0.68f, 0.55f), // Sage green
|
||||
new Color(0.74f, 0.52f, 0.42f), // Warm terracotta
|
||||
new Color(0.68f, 0.65f, 0.44f), // Sandy gold
|
||||
new Color(0.44f, 0.62f, 0.72f), // Slate teal
|
||||
};
|
||||
|
||||
for (int i = 1; i <= 18; i++)
|
||||
{
|
||||
var obs = GameObject.Find($"Obs_{i}");
|
||||
if (obs == null) continue;
|
||||
var rend = obs.GetComponent<Renderer>();
|
||||
if (rend == null) continue;
|
||||
|
||||
var mat = new Material(rend.sharedMaterial);
|
||||
SetMatColor(mat, palette[i % palette.Length]);
|
||||
rend.material = mat;
|
||||
}
|
||||
}
|
||||
|
||||
private void ColorWallsAndGrids()
|
||||
{
|
||||
Color wallColor = new Color(0.50f, 0.54f, 0.62f);
|
||||
foreach (string name in new[] { "Wall_North", "Wall_South", "Wall_East", "Wall_West" })
|
||||
{
|
||||
var wall = GameObject.Find(name);
|
||||
if (wall == null) continue;
|
||||
var rend = wall.GetComponent<Renderer>();
|
||||
if (rend == null) continue;
|
||||
var mat = new Material(rend.sharedMaterial);
|
||||
SetMatColor(mat, wallColor);
|
||||
rend.material = mat;
|
||||
}
|
||||
|
||||
Color gridColor = new Color(0.58f, 0.61f, 0.68f);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
foreach (string dir in new[] { "NS", "EW" })
|
||||
{
|
||||
var grid = GameObject.Find($"Grid_{dir}_{i}");
|
||||
if (grid == null) continue;
|
||||
var rend = grid.GetComponent<Renderer>();
|
||||
if (rend == null) continue;
|
||||
var mat = new Material(rend.sharedMaterial);
|
||||
SetMatColor(mat, gridColor);
|
||||
rend.material = mat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnhanceLighting()
|
||||
{
|
||||
// Directional light: warm white, stronger, soft shadows
|
||||
var lights = FindObjectsByType<Light>(FindObjectsSortMode.None);
|
||||
foreach (var light in lights)
|
||||
{
|
||||
if (light.type == LightType.Directional)
|
||||
{
|
||||
light.color = new Color(1f, 0.96f, 0.90f); // Warm white
|
||||
light.intensity = 1.6f;
|
||||
light.shadows = LightShadows.Soft;
|
||||
light.shadowStrength = 0.75f;
|
||||
}
|
||||
}
|
||||
|
||||
// Ambient: cool tint for contrast with warm direct light
|
||||
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
|
||||
RenderSettings.ambientLight = new Color(0.32f, 0.36f, 0.48f);
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
private static void SetMatColor(Material mat, Color color)
|
||||
{
|
||||
if (mat.HasProperty("_BaseColor"))
|
||||
mat.SetColor("_BaseColor", color);
|
||||
if (mat.HasProperty("_Color"))
|
||||
mat.color = color;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/GameSetup.cs.meta
Normal file
2
game/Assets/Scripts/GameSetup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb7b4298ea08e184781c08e50c18a677
|
||||
317
game/Assets/Scripts/KeyBindingUI.cs
Normal file
317
game/Assets/Scripts/KeyBindingUI.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime key rebinding UI. Toggle with F2.
|
||||
/// Manages rebinding for InputSystem actions (movement, jump)
|
||||
/// and custom camera orbit keys (ZQSD).
|
||||
/// All overrides persist in PlayerPrefs.
|
||||
/// </summary>
|
||||
public class KeyBindingUI : MonoBehaviour
|
||||
{
|
||||
private bool _visible = false;
|
||||
private string _rebindingAction = null;
|
||||
|
||||
/// <summary>True when the keybind config menu is open. Used to freeze camera + unlock cursor.</summary>
|
||||
public static bool IsVisible { get; private set; }
|
||||
private InputActionRebindingExtensions.RebindingOperation _rebindOp;
|
||||
|
||||
// Default camera keys (physical WASD positions = ZQSD on AZERTY)
|
||||
private static readonly Dictionary<string, Key> _defaultCameraKeys = new()
|
||||
{
|
||||
{ "CamUp", Key.W },
|
||||
{ "CamDown", Key.S },
|
||||
{ "CamLeft", Key.A },
|
||||
{ "CamRight", Key.D },
|
||||
};
|
||||
|
||||
private static readonly string[] _cameraKeyNames = { "CamUp", "CamDown", "CamLeft", "CamRight" };
|
||||
private static readonly string[] _cameraKeyLabels = { "Caméra haut", "Caméra bas", "Caméra gauche", "Caméra droite" };
|
||||
|
||||
private static Dictionary<string, Key> _currentCameraKeys;
|
||||
|
||||
private PlayerInput _playerInput;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
LoadCameraKeys();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
_playerInput = FindFirstObjectByType<PlayerInput>();
|
||||
LoadBindingOverrides();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Keyboard.current != null && Keyboard.current[Key.F2].wasPressedThisFrame)
|
||||
{
|
||||
_visible = !_visible;
|
||||
IsVisible = _visible;
|
||||
if (_visible)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
CancelRebind();
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// Process camera key rebinding
|
||||
if (_rebindingAction == null) return;
|
||||
if (!_defaultCameraKeys.ContainsKey(_rebindingAction)) return;
|
||||
|
||||
var kb = Keyboard.current;
|
||||
if (kb == null) return;
|
||||
|
||||
foreach (Key k in Enum.GetValues(typeof(Key)))
|
||||
{
|
||||
if (k == Key.None) continue;
|
||||
try
|
||||
{
|
||||
if (kb[k].wasPressedThisFrame)
|
||||
{
|
||||
if (k == Key.Escape)
|
||||
{
|
||||
_rebindingAction = null;
|
||||
return;
|
||||
}
|
||||
_currentCameraKeys[_rebindingAction] = k;
|
||||
SaveCameraKeys();
|
||||
_rebindingAction = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!_visible) return;
|
||||
|
||||
ImGuiSkin.EnsureReady();
|
||||
|
||||
// Overlay
|
||||
ImGuiSkin.DrawOverlay();
|
||||
|
||||
float w = 480, h = 540;
|
||||
|
||||
ImGuiSkin.BeginWindow(w, h, "Configuration des touches");
|
||||
|
||||
// --- Movement ---
|
||||
ImGuiSkin.DrawSectionHeader("DÉPLACEMENT");
|
||||
GUILayout.Space(4);
|
||||
DrawActionBinding("forward", "Avancer");
|
||||
DrawActionBinding("backwards", "Reculer");
|
||||
DrawActionBinding("left", "Gauche");
|
||||
DrawActionBinding("right", "Droite");
|
||||
DrawActionBinding("jump", "Sauter");
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
// --- Camera ---
|
||||
ImGuiSkin.DrawSectionHeader("CAMÉRA (+ SOURIS)");
|
||||
GUILayout.Space(4);
|
||||
for (int i = 0; i < _cameraKeyNames.Length; i++)
|
||||
DrawCameraKeyBinding(_cameraKeyNames[i], _cameraKeyLabels[i]);
|
||||
|
||||
GUILayout.Space(16);
|
||||
|
||||
// Buttons
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Réinitialiser tout", ImGuiSkin.Button, GUILayout.Height(32)))
|
||||
ResetAllBindings();
|
||||
if (GUILayout.Button("Fermer (F2)", ImGuiSkin.Button, GUILayout.Height(32)))
|
||||
{
|
||||
_visible = false;
|
||||
CancelRebind();
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(6);
|
||||
|
||||
if (_rebindingAction != null)
|
||||
{
|
||||
GUILayout.Label("Appuyez sur une touche pour assigner...", ImGuiSkin.Hint);
|
||||
}
|
||||
|
||||
// F2 hint
|
||||
GUILayout.Label("F2 — Ouvrir / Fermer ce menu", ImGuiSkin.Footer);
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
|
||||
// --- Drawing helpers ---
|
||||
|
||||
private void DrawActionBinding(string actionName, string label)
|
||||
{
|
||||
if (_playerInput == null) return;
|
||||
var action = _playerInput.actions.FindAction(actionName);
|
||||
if (action == null) return;
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(label, ImGuiSkin.Label, GUILayout.Width(150));
|
||||
|
||||
string currentBinding = "---";
|
||||
if (action.bindings.Count > 0)
|
||||
currentBinding = InputControlPath.ToHumanReadableString(
|
||||
action.bindings[0].effectivePath,
|
||||
InputControlPath.HumanReadableStringOptions.OmitDevice);
|
||||
|
||||
bool isRebinding = _rebindingAction == actionName;
|
||||
string btnText = isRebinding ? "[ ... ]" : currentBinding;
|
||||
|
||||
GUIStyle btnStyle = isRebinding ? ImGuiSkin.ButtonAccent : ImGuiSkin.ButtonSmall;
|
||||
if (GUILayout.Button(btnText, btnStyle, GUILayout.Width(170), GUILayout.Height(26)))
|
||||
{
|
||||
if (!isRebinding) StartActionRebind(actionName);
|
||||
else CancelRebind();
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawCameraKeyBinding(string keyName, string label)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(label, ImGuiSkin.Label, GUILayout.Width(150));
|
||||
|
||||
Key current = GetKey(keyName, _defaultCameraKeys[keyName]);
|
||||
bool isRebinding = _rebindingAction == keyName;
|
||||
string btnText = isRebinding ? "[ ... ]" : current.ToString();
|
||||
|
||||
GUIStyle btnStyle = isRebinding ? ImGuiSkin.ButtonAccent : ImGuiSkin.ButtonSmall;
|
||||
if (GUILayout.Button(btnText, btnStyle, GUILayout.Width(170), GUILayout.Height(26)))
|
||||
{
|
||||
if (!isRebinding) StartCameraKeyRebind(keyName);
|
||||
else CancelRebind();
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// --- Rebinding logic ---
|
||||
|
||||
private void StartActionRebind(string actionName)
|
||||
{
|
||||
CancelRebind();
|
||||
var action = _playerInput?.actions.FindAction(actionName);
|
||||
if (action == null) return;
|
||||
|
||||
_rebindingAction = actionName;
|
||||
action.Disable();
|
||||
|
||||
_rebindOp = action.PerformInteractiveRebinding(0)
|
||||
.WithControlsExcluding("Mouse")
|
||||
.OnMatchWaitForAnother(0.1f)
|
||||
.OnComplete(op =>
|
||||
{
|
||||
action.Enable();
|
||||
SaveBindingOverrides();
|
||||
_rebindingAction = null;
|
||||
op.Dispose();
|
||||
_rebindOp = null;
|
||||
})
|
||||
.OnCancel(op =>
|
||||
{
|
||||
action.Enable();
|
||||
_rebindingAction = null;
|
||||
op.Dispose();
|
||||
_rebindOp = null;
|
||||
})
|
||||
.Start();
|
||||
}
|
||||
|
||||
private void StartCameraKeyRebind(string keyName)
|
||||
{
|
||||
CancelRebind();
|
||||
_rebindingAction = keyName;
|
||||
}
|
||||
|
||||
private void CancelRebind()
|
||||
{
|
||||
if (_rebindOp != null)
|
||||
{
|
||||
_rebindOp.Cancel();
|
||||
_rebindOp.Dispose();
|
||||
_rebindOp = null;
|
||||
}
|
||||
_rebindingAction = null;
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
private void SaveBindingOverrides()
|
||||
{
|
||||
if (_playerInput == null) return;
|
||||
string json = _playerInput.actions.SaveBindingOverridesAsJson();
|
||||
PlayerPrefs.SetString("InputOverrides", json);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
private void LoadBindingOverrides()
|
||||
{
|
||||
if (_playerInput == null) return;
|
||||
string json = PlayerPrefs.GetString("InputOverrides", "");
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
_playerInput.actions.LoadBindingOverridesFromJson(json);
|
||||
}
|
||||
|
||||
private static void LoadCameraKeys()
|
||||
{
|
||||
_currentCameraKeys = new Dictionary<string, Key>();
|
||||
foreach (var kv in _defaultCameraKeys)
|
||||
{
|
||||
string saved = PlayerPrefs.GetString($"CamKey_{kv.Key}", "");
|
||||
if (!string.IsNullOrEmpty(saved) && Enum.TryParse<Key>(saved, out Key parsed))
|
||||
_currentCameraKeys[kv.Key] = parsed;
|
||||
else
|
||||
_currentCameraKeys[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveCameraKeys()
|
||||
{
|
||||
if (_currentCameraKeys == null) return;
|
||||
foreach (var kv in _currentCameraKeys)
|
||||
PlayerPrefs.SetString($"CamKey_{kv.Key}", kv.Value.ToString());
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a camera key binding. Usable globally (static).
|
||||
/// </summary>
|
||||
public static Key GetKey(string name, Key fallback)
|
||||
{
|
||||
if (_currentCameraKeys == null) LoadCameraKeys();
|
||||
return _currentCameraKeys != null && _currentCameraKeys.TryGetValue(name, out Key k) ? k : fallback;
|
||||
}
|
||||
|
||||
private void ResetAllBindings()
|
||||
{
|
||||
// Reset InputSystem actions
|
||||
if (_playerInput != null)
|
||||
{
|
||||
foreach (var action in _playerInput.actions)
|
||||
action.RemoveAllBindingOverrides();
|
||||
PlayerPrefs.DeleteKey("InputOverrides");
|
||||
}
|
||||
|
||||
// Reset camera keys
|
||||
_currentCameraKeys = new Dictionary<string, Key>(_defaultCameraKeys);
|
||||
foreach (var kv in _defaultCameraKeys)
|
||||
PlayerPrefs.DeleteKey($"CamKey_{kv.Key}");
|
||||
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
}
|
||||
2
game/Assets/Scripts/KeyBindingUI.cs.meta
Normal file
2
game/Assets/Scripts/KeyBindingUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91245bd4a2621ba49ab2e6d622041061
|
||||
8
game/Assets/Scripts/Network.meta
Normal file
8
game/Assets/Scripts/Network.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d06329eb03f74741b61872baf05e25c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
171
game/Assets/Scripts/Network/DebugNetworkUI.cs
Normal file
171
game/Assets/Scripts/Network/DebugNetworkUI.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Debug overlay:
|
||||
/// – Always-visible HUD strip at the top (player name, status, room, FPS)
|
||||
/// – Detailed panel toggled with F1 (full network + physics info)
|
||||
/// Uses Dear ImGui–style skin via ImGuiSkin.
|
||||
/// </summary>
|
||||
public class DebugNetworkUI : MonoBehaviour
|
||||
{
|
||||
private bool _detailsVisible = false;
|
||||
private Vector2 _scrollPos;
|
||||
|
||||
// FPS tracking
|
||||
private float _fpsTimer;
|
||||
private int _fpsCount;
|
||||
private float _currentFps;
|
||||
|
||||
void Update()
|
||||
{
|
||||
// FPS counter
|
||||
_fpsTimer += Time.unscaledDeltaTime;
|
||||
_fpsCount++;
|
||||
if (_fpsTimer >= 0.5f)
|
||||
{
|
||||
_currentFps = _fpsCount / _fpsTimer;
|
||||
_fpsTimer = 0f;
|
||||
_fpsCount = 0;
|
||||
}
|
||||
|
||||
// Toggle detailed panel with F1
|
||||
if (Keyboard.current != null && Keyboard.current[Key.F1].wasPressedThisFrame)
|
||||
_detailsVisible = !_detailsVisible;
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
ImGuiSkin.EnsureReady();
|
||||
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm == null) return;
|
||||
|
||||
DrawHUDStrip(nm);
|
||||
|
||||
if (_detailsVisible)
|
||||
DrawDetailPanel(nm);
|
||||
|
||||
// Hint
|
||||
GUI.Label(new Rect(10, Screen.height - 25, 300, 20), "F1 — Debug details", ImGuiSkin.Footer);
|
||||
}
|
||||
|
||||
// ───────── HUD Strip (always visible) ─────────
|
||||
|
||||
private void DrawHUDStrip(NetworkManager nm)
|
||||
{
|
||||
float h = 28;
|
||||
ImGuiSkin.DrawHudStripBg(h);
|
||||
|
||||
string dot = nm.IsConnected
|
||||
? "<color=#44FF44>\u25CF</color>"
|
||||
: "<color=#FF4444>\u25CF</color>";
|
||||
|
||||
string info;
|
||||
if (nm.IsConnected)
|
||||
{
|
||||
string name = !string.IsNullOrEmpty(nm.LocalPlayerName) ? nm.LocalPlayerName : "\u2014";
|
||||
string room = !string.IsNullOrEmpty(nm.RoomId) ? nm.RoomId[..Mathf.Min(8, nm.RoomId.Length)] : "\u2014";
|
||||
string sess = !string.IsNullOrEmpty(nm.LocalSessionId) ? nm.LocalSessionId[..Mathf.Min(6, nm.LocalSessionId.Length)] : "\u2014";
|
||||
info = $" {dot} <b>{name}</b> | Room {room} | Sess {sess} | {nm.PlayerCount}P | {nm.serverURL} | {_currentFps:F0} FPS";
|
||||
}
|
||||
else
|
||||
{
|
||||
info = $" {dot} {nm.ConnectionStatus} | {nm.serverURL} | {_currentFps:F0} FPS";
|
||||
}
|
||||
|
||||
GUI.Label(new Rect(0, 0, Screen.width, h), info, ImGuiSkin.HudLabel);
|
||||
}
|
||||
|
||||
// ───────── Detail Panel (F1) ─────────
|
||||
|
||||
private void DrawDetailPanel(NetworkManager nm)
|
||||
{
|
||||
float w = 360, h = 480;
|
||||
float x = Screen.width - w - 12;
|
||||
float y = 38;
|
||||
|
||||
ImGuiSkin.BeginWindowAt(x, y, w, h, "Network Debug");
|
||||
|
||||
// ── Connection ──
|
||||
ImGuiSkin.DrawSectionHeader("CONNECTION");
|
||||
GUILayout.Space(2);
|
||||
GUIStyle statusStyle = nm.IsConnected ? ImGuiSkin.StatusGreen : ImGuiSkin.StatusRed;
|
||||
GUILayout.Label($"\u25CF {nm.ConnectionStatus}", statusStyle);
|
||||
|
||||
ImGuiSkin.DrawField("Server", nm.serverURL);
|
||||
ImGuiSkin.DrawField("Room ID", string.IsNullOrEmpty(nm.RoomId) ? "\u2014" : nm.RoomId);
|
||||
ImGuiSkin.DrawField("Session", string.IsNullOrEmpty(nm.LocalSessionId) ? "\u2014" : nm.LocalSessionId);
|
||||
ImGuiSkin.DrawField("Players", nm.PlayerCount.ToString());
|
||||
ImGuiSkin.DrawField("FPS", $"{_currentFps:F0}");
|
||||
|
||||
if (!string.IsNullOrEmpty(nm.LastError))
|
||||
{
|
||||
GUILayout.Space(2);
|
||||
GUILayout.Label($"\u26A0 {nm.LastError}", ImGuiSkin.StatusRed);
|
||||
}
|
||||
|
||||
GUILayout.Space(6);
|
||||
|
||||
// ── Local Player ──
|
||||
ImGuiSkin.DrawSectionHeader("LOCAL PLAYER");
|
||||
GUILayout.Space(2);
|
||||
ImGuiSkin.DrawField("Name", string.IsNullOrEmpty(nm.LocalPlayerName) ? "\u2014" : nm.LocalPlayerName);
|
||||
|
||||
var state = nm.GetLocalPlayerState();
|
||||
if (state != null)
|
||||
ImGuiSkin.DrawField("Server Pos", $"({state.x:F1}, {state.y:F1}, {state.z:F1})");
|
||||
|
||||
var pc = FindFirstObjectByType<PlayerController>();
|
||||
if (pc != null && pc.isActiveAndEnabled)
|
||||
{
|
||||
var pos = pc.transform.position;
|
||||
ImGuiSkin.DrawField("Live Pos", $"({pos.x:F1}, {pos.y:F1}, {pos.z:F1})");
|
||||
var rb = pc.GetComponent<Rigidbody>();
|
||||
if (rb != null)
|
||||
{
|
||||
var v = rb.linearVelocity;
|
||||
ImGuiSkin.DrawField("Velocity", $"({v.x:F1}, {v.y:F1}, {v.z:F1}) [{v.magnitude:F1} m/s]");
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.Space(6);
|
||||
|
||||
// ── Remote Players ──
|
||||
ImGuiSkin.DrawSectionHeader("REMOTE PLAYERS");
|
||||
GUILayout.Space(2);
|
||||
_scrollPos = GUILayout.BeginScrollView(_scrollPos, ImGuiSkin.ScrollView, GUILayout.Height(100));
|
||||
|
||||
if (nm.RemotePlayers != null && nm.RemotePlayers.Count > 0)
|
||||
{
|
||||
foreach (var kvp in nm.RemotePlayers)
|
||||
{
|
||||
if (kvp.Value == null) continue;
|
||||
var rp = kvp.Value;
|
||||
string dist = "";
|
||||
if (pc != null && pc.isActiveAndEnabled)
|
||||
{
|
||||
float d = Vector3.Distance(pc.transform.position, rp.transform.position);
|
||||
dist = $" [{d:F1}m]";
|
||||
}
|
||||
GUILayout.Label($" {rp.PlayerName} ({kvp.Key[..Mathf.Min(6, kvp.Key.Length)]}){dist}", ImGuiSkin.Label);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Label(" (aucun joueur distant)", ImGuiSkin.LabelDim);
|
||||
}
|
||||
|
||||
GUILayout.EndScrollView();
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (nm.IsConnected)
|
||||
{
|
||||
if (GUILayout.Button("Déconnecter", ImGuiSkin.Button, GUILayout.Height(28)))
|
||||
nm.LeaveRoom();
|
||||
}
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
|
||||
}
|
||||
2
game/Assets/Scripts/Network/DebugNetworkUI.cs.meta
Normal file
2
game/Assets/Scripts/Network/DebugNetworkUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b20e36c3b15f32449bf872f27bee467
|
||||
337
game/Assets/Scripts/Network/LobbyUI.cs
Normal file
337
game/Assets/Scripts/Network/LobbyUI.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Lobby UI displayed at scene start. Player enters a name, picks a color,
|
||||
/// and clicks "Rejoindre" to connect to the arena.
|
||||
/// Manages the full pre-game → in-game transition:
|
||||
/// - Hides the Player hierarchy until connected
|
||||
/// - Activates a spectator camera while in lobby
|
||||
/// - Teleports the player ball to the server spawn position on join
|
||||
/// Uses Dear ImGui–style skin via ImGuiSkin.
|
||||
/// </summary>
|
||||
public class LobbyUI : MonoBehaviour
|
||||
{
|
||||
[Header("Scene References")]
|
||||
[Tooltip("The root 'Player' GameObject (contains PlayerSphere + cameras). Will be deactivated until connected.")]
|
||||
public GameObject playerRoot;
|
||||
|
||||
[Tooltip("The spectator camera GameObject (SpectatorCamera component).")]
|
||||
public SpectatorCamera spectatorCamera;
|
||||
|
||||
// Preset colors for selection
|
||||
private static readonly Color[] PresetColors = new Color[]
|
||||
{
|
||||
new Color(1f, 0.35f, 0.2f), // Orange-red
|
||||
new Color(0.2f, 0.6f, 1f), // Blue
|
||||
new Color(0.3f, 1f, 0.4f), // Green
|
||||
new Color(1f, 0.85f, 0.1f), // Yellow
|
||||
new Color(0.8f, 0.3f, 1f), // Purple
|
||||
new Color(1f, 0.5f, 0.7f), // Pink
|
||||
};
|
||||
|
||||
private static readonly string[] ColorNames = new string[]
|
||||
{
|
||||
"Rouge", "Bleu", "Vert", "Jaune", "Violet", "Rose"
|
||||
};
|
||||
|
||||
// UI state
|
||||
private bool _lobbyActive = true;
|
||||
private string _playerName = "";
|
||||
private int _selectedColorIndex = 0;
|
||||
private string _statusMessage = "";
|
||||
private bool _isConnecting = false;
|
||||
private bool _isReady = false;
|
||||
|
||||
// Cached color preview texture (avoid per-frame leak)
|
||||
private Texture2D _colorPreviewTex;
|
||||
private int _lastPreviewColorIndex = -1;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Generate a default name
|
||||
_playerName = "Joueur" + Random.Range(100, 999);
|
||||
|
||||
// --- Hide the player hierarchy until connected ---
|
||||
if (playerRoot != null)
|
||||
playerRoot.SetActive(false);
|
||||
|
||||
// --- Activate spectator camera ---
|
||||
if (spectatorCamera != null)
|
||||
{
|
||||
// Wire the gameplay camera reference so spectator knows what to re-enable
|
||||
var gameplayCam = playerRoot?.GetComponentInChildren<Camera>(true);
|
||||
if (gameplayCam != null)
|
||||
spectatorCamera.gameplayCamera = gameplayCam;
|
||||
|
||||
spectatorCamera.Activate();
|
||||
}
|
||||
|
||||
// Subscribe to network events
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnConnected += OnConnected;
|
||||
NetworkManager.Instance.OnDisconnected += OnDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnConnected -= OnConnected;
|
||||
NetworkManager.Instance.OnDisconnected -= OnDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnected()
|
||||
{
|
||||
_lobbyActive = false;
|
||||
_isConnecting = false;
|
||||
_statusMessage = "";
|
||||
|
||||
// --- Activate the player hierarchy ---
|
||||
if (playerRoot != null)
|
||||
playerRoot.SetActive(true);
|
||||
|
||||
// Teleport player ball to the server-assigned spawn position
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm != null && playerRoot != null)
|
||||
{
|
||||
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
|
||||
if (pc != null)
|
||||
{
|
||||
// Get spawn pos from the local player's state in the room
|
||||
var localState = nm.GetLocalPlayerState();
|
||||
if (localState != null)
|
||||
{
|
||||
Vector3 spawnPos = new Vector3(localState.x, localState.y, localState.z);
|
||||
var rb = pc.GetComponent<Rigidbody>();
|
||||
if (rb != null)
|
||||
{
|
||||
rb.linearVelocity = Vector3.zero;
|
||||
rb.angularVelocity = Vector3.zero;
|
||||
rb.position = spawnPos;
|
||||
}
|
||||
pc.transform.position = spawnPos;
|
||||
Debug.Log($"[Lobby] Player teleported to spawn: {spawnPos}");
|
||||
}
|
||||
pc.enabled = true;
|
||||
|
||||
// Setup local player visuals: 50% color tint + floating name label
|
||||
pc.SetupLocalPlayer(nm.LocalPlayerName, nm.LocalPlayerColor);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Switch from spectator to gameplay camera ---
|
||||
if (spectatorCamera != null)
|
||||
spectatorCamera.Deactivate();
|
||||
|
||||
// Unlock cursor for gameplay
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
|
||||
private void OnDisconnected()
|
||||
{
|
||||
_lobbyActive = true;
|
||||
_isConnecting = false;
|
||||
_isReady = false;
|
||||
_statusMessage = "Déconnecté du serveur";
|
||||
|
||||
// Show cursor for lobby
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
|
||||
// --- Deactivate the player hierarchy ---
|
||||
if (playerRoot != null)
|
||||
{
|
||||
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
|
||||
if (pc != null) pc.enabled = false;
|
||||
playerRoot.SetActive(false);
|
||||
}
|
||||
|
||||
// --- Re-enable spectator camera ---
|
||||
if (spectatorCamera != null)
|
||||
spectatorCamera.Activate();
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!_lobbyActive) return;
|
||||
|
||||
ImGuiSkin.EnsureReady();
|
||||
|
||||
if (Cursor.lockState != CursorLockMode.None)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
ImGuiSkin.DrawOverlay();
|
||||
|
||||
bool isConnected = NetworkManager.Instance != null && NetworkManager.Instance.IsConnected;
|
||||
|
||||
if (!isConnected)
|
||||
{
|
||||
// ── Pre-connect panel ────────────────────────────────────────
|
||||
float panelWidth = 420;
|
||||
float panelHeight = 440;
|
||||
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "ROLL'D");
|
||||
|
||||
GUILayout.Label("Rejoindre l'arène multijoueur", ImGuiSkin.WindowSubtitle);
|
||||
GUILayout.Space(16);
|
||||
|
||||
ImGuiSkin.DrawSectionHeader("PSEUDO");
|
||||
GUILayout.Space(4);
|
||||
_playerName = GUILayout.TextField(_playerName, 16, ImGuiSkin.TextField, GUILayout.Height(30));
|
||||
GUILayout.Space(12);
|
||||
|
||||
ImGuiSkin.DrawSectionHeader("COULEUR");
|
||||
GUILayout.Space(6);
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
for (int i = 0; i < PresetColors.Length; i++)
|
||||
{
|
||||
Color c = PresetColors[i];
|
||||
bool selected = _selectedColorIndex == i;
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = selected ? c : c * 0.7f;
|
||||
GUIStyle btnStyle = new GUIStyle(ImGuiSkin.ButtonSmall)
|
||||
{
|
||||
fontStyle = selected ? FontStyle.Bold : FontStyle.Normal,
|
||||
};
|
||||
if (selected) btnStyle.normal.textColor = Color.white;
|
||||
string label = selected ? $"▸ {ColorNames[i]}" : ColorNames[i];
|
||||
if (GUILayout.Button(label, btnStyle, GUILayout.Height(32), GUILayout.Width(60)))
|
||||
_selectedColorIndex = i;
|
||||
GUI.backgroundColor = prevBg;
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Space(4);
|
||||
|
||||
if (_colorPreviewTex == null || _lastPreviewColorIndex != _selectedColorIndex)
|
||||
{
|
||||
if (_colorPreviewTex == null)
|
||||
{
|
||||
_colorPreviewTex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
|
||||
_colorPreviewTex.hideFlags = HideFlags.HideAndDontSave;
|
||||
}
|
||||
_colorPreviewTex.SetPixel(0, 0, PresetColors[_selectedColorIndex]);
|
||||
_colorPreviewTex.Apply();
|
||||
_lastPreviewColorIndex = _selectedColorIndex;
|
||||
}
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.Box(_colorPreviewTex, GUIStyle.none, GUILayout.Width(80), GUILayout.Height(16));
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Space(16);
|
||||
|
||||
GUI.enabled = !_isConnecting && !string.IsNullOrWhiteSpace(_playerName);
|
||||
string buttonText = _isConnecting ? "Connexion..." : "▶ Rejoindre l'arène";
|
||||
if (GUILayout.Button(buttonText, ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
|
||||
JoinArena();
|
||||
GUI.enabled = true;
|
||||
GUILayout.Space(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(_statusMessage))
|
||||
{
|
||||
bool isError = _statusMessage.Contains("Erreur") || _statusMessage.Contains("Déconnecté");
|
||||
GUIStyle statusStyle = isError ? ImGuiSkin.StatusRed : new GUIStyle(ImGuiSkin.Hint);
|
||||
if (!isError) statusStyle.normal.textColor = ImGuiSkin.ColYellow;
|
||||
GUILayout.Label(_statusMessage, statusStyle);
|
||||
}
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Waiting room panel (connected, waiting for game to start) ──
|
||||
float panelWidth = 380;
|
||||
float panelHeight = 320;
|
||||
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "SALLE D'ATTENTE");
|
||||
|
||||
GUILayout.Label("En attente des joueurs...", ImGuiSkin.WindowSubtitle);
|
||||
GUILayout.Space(12);
|
||||
|
||||
// Player list
|
||||
ImGuiSkin.DrawSectionHeader("JOUEURS CONNECTÉS");
|
||||
GUILayout.Space(4);
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm != null && nm.IsConnected)
|
||||
{
|
||||
// We can't directly iterate NetworkState.players from here easily,
|
||||
// so show basic count
|
||||
var style = new GUIStyle(GUI.skin.label) { fontSize = 13 };
|
||||
style.normal.textColor = new Color(0.75f, 0.75f, 0.85f);
|
||||
GUILayout.Label($" {nm.PlayerCount} joueur(s) dans la salle", style);
|
||||
}
|
||||
GUILayout.Space(16);
|
||||
|
||||
// Ready button
|
||||
if (!_isReady)
|
||||
{
|
||||
if (GUILayout.Button("✔ Je suis prêt !", ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
|
||||
{
|
||||
_isReady = true;
|
||||
NetworkManager.Instance?.SendReady();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var readyStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 16,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
readyStyle.normal.textColor = new Color(0.3f, 1f, 0.5f);
|
||||
GUILayout.Label("✔ Prêt ! En attente des autres...", readyStyle, GUILayout.Height(44));
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
var hintStyle = new GUIStyle(ImGuiSkin.Hint);
|
||||
hintStyle.normal.textColor = new Color(0.5f, 0.5f, 0.6f);
|
||||
GUILayout.Label("La partie démarre quand tout le monde est prêt\nou automatiquement après 30 secondes.", hintStyle);
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private void JoinArena()
|
||||
{
|
||||
if (NetworkManager.Instance == null)
|
||||
{
|
||||
_statusMessage = "Erreur : NetworkManager introuvable";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_playerName))
|
||||
{
|
||||
_statusMessage = "Entrez un pseudo";
|
||||
return;
|
||||
}
|
||||
|
||||
_isConnecting = true;
|
||||
_statusMessage = "Connexion au serveur...";
|
||||
|
||||
Color selectedColor = PresetColors[_selectedColorIndex];
|
||||
NetworkManager.Instance.JoinArena(_playerName.Trim(), selectedColor);
|
||||
|
||||
// Monitor for errors after a delay
|
||||
Invoke(nameof(CheckConnectionTimeout), 10f);
|
||||
}
|
||||
|
||||
private void CheckConnectionTimeout()
|
||||
{
|
||||
if (_isConnecting && !NetworkManager.Instance.IsConnected)
|
||||
{
|
||||
_isConnecting = false;
|
||||
_statusMessage = "Erreur : Timeout de connexion. Vérifiez que le serveur est lancé.";
|
||||
if (!string.IsNullOrEmpty(NetworkManager.Instance.LastError))
|
||||
{
|
||||
_statusMessage += $"\n{NetworkManager.Instance.LastError}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Network/LobbyUI.cs.meta
Normal file
2
game/Assets/Scripts/Network/LobbyUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad2d984dd466289479165976d300cc09
|
||||
394
game/Assets/Scripts/Network/NetworkManager.cs
Normal file
394
game/Assets/Scripts/Network/NetworkManager.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Colyseus;
|
||||
using Colyseus.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton managing the Colyseus connection, room lifecycle, remote player spawning,
|
||||
/// and game-phase events (eliminated, qualified, roundStart, roundEnd, gameEnd).
|
||||
/// </summary>
|
||||
public class NetworkManager : MonoBehaviour
|
||||
{
|
||||
public static NetworkManager Instance { get; private set; }
|
||||
|
||||
[Header("Connection")]
|
||||
[Tooltip("Colyseus server endpoint (overridden by frontend via SetServerURL)")]
|
||||
public string serverURL = "ws://localhost:2567";
|
||||
|
||||
[Header("Prefab")]
|
||||
[Tooltip("Prefab for remote players (must have RemotePlayerController)")]
|
||||
public GameObject remotePlayerPrefab;
|
||||
|
||||
// --- Public state for UI ---
|
||||
public bool IsConnected { get; private set; }
|
||||
public string RoomId { get; private set; } = "";
|
||||
public string LocalSessionId { get; private set; } = "";
|
||||
public int PlayerCount { get; private set; }
|
||||
public string ConnectionStatus { get; private set; } = "Déconnecté";
|
||||
public string LastError { get; private set; } = "";
|
||||
|
||||
// Expose remote players for debug UI
|
||||
public Dictionary<string, RemotePlayerController> RemotePlayers => _remotePlayers;
|
||||
|
||||
// Local player info (set during join)
|
||||
public string LocalPlayerName { get; private set; } = "";
|
||||
public Color LocalPlayerColor { get; private set; } = Color.white;
|
||||
|
||||
// --- Events ---
|
||||
public event Action OnConnected;
|
||||
public event Action OnDisconnected;
|
||||
public event Action<string> OnPlayerJoined;
|
||||
public event Action<string> OnPlayerLeft;
|
||||
|
||||
// Game flow events
|
||||
public event Action<string> OnPhaseChanged; // phase name
|
||||
public event Action<float> OnCountdownChanged; // seconds remaining
|
||||
public event Action<string, string> OnEliminated; // sessionId, reason
|
||||
public event Action<string> OnQualified; // sessionId
|
||||
public event Action<int, string> OnRoundStart; // roundNumber, mode
|
||||
public event Action<int> OnRoundEnd; // roundNumber
|
||||
public event Action<string> OnGameEnd; // winnerName
|
||||
public event Action<float> OnDeathZoneYChanged; // for survival mode
|
||||
|
||||
// --- Internals ---
|
||||
private Client _client;
|
||||
private Room<NetworkState> _room;
|
||||
private StateCallbackStrategy<NetworkState> _callbacks;
|
||||
private readonly Dictionary<string, RemotePlayerController> _remotePlayers = new();
|
||||
private float _broadcastTimer;
|
||||
private const float BROADCAST_INTERVAL = 0.01667f; // ~60/sec
|
||||
private bool _isJoining;
|
||||
|
||||
private Transform _localPlayer;
|
||||
private Rigidbody _localPlayerRb;
|
||||
|
||||
private Vector3 _lastSentPos;
|
||||
private Vector3 _lastSentVel;
|
||||
private Vector3 _lastSentAngVel;
|
||||
private const float POS_THRESHOLD = 0.005f;
|
||||
private const float VEL_THRESHOLD = 0.05f;
|
||||
|
||||
private string _lastPhase = "";
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!IsConnected || _room == null) return;
|
||||
|
||||
_broadcastTimer += Time.deltaTime;
|
||||
if (_broadcastTimer >= BROADCAST_INTERVAL)
|
||||
{
|
||||
_broadcastTimer = 0f;
|
||||
BroadcastPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from frontend JS via SendMessage to override the server URL.</summary>
|
||||
public void SetServerURL(string url)
|
||||
{
|
||||
serverURL = url;
|
||||
Debug.Log($"[Network] Server URL set to: {url}");
|
||||
}
|
||||
|
||||
public NetworkPlayer GetLocalPlayerState()
|
||||
{
|
||||
if (_room == null || _room.State.players == null || string.IsNullOrEmpty(LocalSessionId)) return null;
|
||||
_room.State.players.TryGetValue(LocalSessionId, out var player);
|
||||
return player;
|
||||
}
|
||||
|
||||
// ─── Join / Leave ────────────────────────────────────────────────────
|
||||
|
||||
public async void JoinArena(string playerName, Color color)
|
||||
{
|
||||
if (_isJoining || IsConnected)
|
||||
{
|
||||
Debug.LogWarning("[Network] Already connecting or connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
_isJoining = true;
|
||||
ConnectionStatus = "Connexion en cours...";
|
||||
LastError = "";
|
||||
LocalPlayerName = playerName;
|
||||
LocalPlayerColor = color;
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"[Network] Connecting to {serverURL}...");
|
||||
_client = new Client(serverURL);
|
||||
|
||||
var options = new Dictionary<string, object>
|
||||
{
|
||||
{ "name", playerName },
|
||||
{ "colorR", color.r },
|
||||
{ "colorG", color.g },
|
||||
{ "colorB", color.b }
|
||||
};
|
||||
|
||||
_room = await _client.JoinOrCreate<NetworkState>("arena", options);
|
||||
LocalSessionId = _room.SessionId;
|
||||
RoomId = _room.RoomId;
|
||||
IsConnected = true;
|
||||
ConnectionStatus = "Connecté";
|
||||
|
||||
Debug.Log($"[Network] Joined room {RoomId} as {LocalSessionId}");
|
||||
|
||||
_callbacks = Callbacks.Get(_room);
|
||||
|
||||
// Players
|
||||
_callbacks.OnAdd(state => state.players, (key, player) => OnPlayerAdd(key, player));
|
||||
_callbacks.OnRemove(state => state.players, (key, player) => OnPlayerRemove(key, player));
|
||||
|
||||
// Game state changes
|
||||
_callbacks.Listen(state => state.phase, (newValue, prevValue) => _OnPhaseChanged(newValue));
|
||||
_callbacks.Listen(state => state.countdown, (newValue, prevValue) => OnCountdownChanged?.Invoke(newValue));
|
||||
_callbacks.Listen(state => state.deathZoneY, (newValue, prevValue) => OnDeathZoneYChanged?.Invoke(newValue));
|
||||
|
||||
// Server messages
|
||||
_room.OnMessage<EliminatedMsg>("eliminated", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Eliminated: {msg.sessionId} ({msg.reason})");
|
||||
OnEliminated?.Invoke(msg.sessionId, msg.reason);
|
||||
});
|
||||
_room.OnMessage<QualifiedMsg>("qualified", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Qualified: {msg.sessionId}");
|
||||
OnQualified?.Invoke(msg.sessionId);
|
||||
});
|
||||
_room.OnMessage<RoundStartMsg>("roundStart", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Round {msg.round} started ({msg.mode})");
|
||||
OnRoundStart?.Invoke(msg.round, msg.mode);
|
||||
});
|
||||
_room.OnMessage<RoundEndMsg>("roundEnd", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Round {msg.round} ended");
|
||||
OnRoundEnd?.Invoke(msg.round);
|
||||
});
|
||||
_room.OnMessage<GameEndMsg>("gameEnd", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
|
||||
OnGameEnd?.Invoke(msg.winner);
|
||||
});
|
||||
|
||||
_room.OnLeave += OnRoomLeave;
|
||||
OnConnected?.Invoke();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Network] Failed to join: {e.Message}");
|
||||
ConnectionStatus = "Erreur de connexion";
|
||||
LastError = e.Message;
|
||||
IsConnected = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isJoining = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async void LeaveRoom()
|
||||
{
|
||||
if (_room != null) await _room.Leave();
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
public async void SendReady()
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("ready", null);
|
||||
}
|
||||
|
||||
public async void SendCheckpoint(int index)
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("checkpointReached", new { index });
|
||||
}
|
||||
|
||||
public async void SendDeathZoneHit()
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("deathZoneHit", null);
|
||||
}
|
||||
|
||||
public async void SendInZone(bool inZone)
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("inZone", new { inZone });
|
||||
}
|
||||
|
||||
// ─── State Callbacks ─────────────────────────────────────────────────
|
||||
|
||||
private void _OnPhaseChanged(string phase)
|
||||
{
|
||||
if (phase == _lastPhase) return;
|
||||
_lastPhase = phase;
|
||||
Debug.Log($"[Network] Phase → {phase}");
|
||||
OnPhaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
private void OnPlayerAdd(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
Debug.Log($"[Network] Player joined: {sessionId} ({player.name})");
|
||||
PlayerCount = _room.State.players?.Count ?? 0;
|
||||
|
||||
if (sessionId == LocalSessionId) return;
|
||||
|
||||
if (remotePlayerPrefab != null)
|
||||
{
|
||||
Vector3 spawnPos = new Vector3(player.x, player.y, player.z);
|
||||
GameObject remoteBall = Instantiate(remotePlayerPrefab, spawnPos, Quaternion.identity);
|
||||
remoteBall.name = $"RemotePlayer_{player.name}_{sessionId[..6]}";
|
||||
|
||||
var controller = remoteBall.GetComponent<RemotePlayerController>()
|
||||
?? remoteBall.AddComponent<RemotePlayerController>();
|
||||
|
||||
controller.Initialize(sessionId, player.name,
|
||||
new Color(player.colorR, player.colorG, player.colorB));
|
||||
|
||||
_remotePlayers[sessionId] = controller;
|
||||
}
|
||||
|
||||
_callbacks.OnChange(player, () => OnPlayerChange(sessionId, player));
|
||||
OnPlayerJoined?.Invoke(sessionId);
|
||||
}
|
||||
|
||||
private void OnPlayerRemove(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
Debug.Log($"[Network] Player left: {sessionId}");
|
||||
PlayerCount = _room.State.players?.Count ?? 0;
|
||||
|
||||
if (_remotePlayers.TryGetValue(sessionId, out var controller))
|
||||
{
|
||||
if (controller != null && controller.gameObject != null)
|
||||
Destroy(controller.gameObject);
|
||||
_remotePlayers.Remove(sessionId);
|
||||
}
|
||||
|
||||
OnPlayerLeft?.Invoke(sessionId);
|
||||
}
|
||||
|
||||
private void OnPlayerChange(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
if (sessionId == LocalSessionId) return;
|
||||
|
||||
if (_remotePlayers.TryGetValue(sessionId, out var controller))
|
||||
{
|
||||
controller.SetTargetState(
|
||||
new Vector3(player.x, player.y, player.z),
|
||||
new Vector3(player.vx, player.vy, player.vz),
|
||||
new Quaternion(player.rx, player.ry, player.rz, player.rw),
|
||||
player.t,
|
||||
new Vector3(player.avx, player.avy, player.avz)
|
||||
);
|
||||
|
||||
// Sync team color changes (for teams mode)
|
||||
controller.UpdateTeamColor(player.team,
|
||||
new Color(player.colorR, player.colorG, player.colorB));
|
||||
|
||||
// Hide/show eliminated remote players
|
||||
controller.SetVisible(!player.isEliminated);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Position Broadcasting ────────────────────────────────────────────
|
||||
|
||||
private void BroadcastPosition()
|
||||
{
|
||||
if (_room == null || !IsConnected) return;
|
||||
|
||||
if (_localPlayer == null)
|
||||
{
|
||||
var pc = FindFirstObjectByType<PlayerController>();
|
||||
if (pc != null)
|
||||
{
|
||||
_localPlayer = pc.transform;
|
||||
_localPlayerRb = pc.GetComponent<Rigidbody>();
|
||||
}
|
||||
else return;
|
||||
}
|
||||
|
||||
Vector3 pos = _localPlayer.position;
|
||||
Vector3 vel = _localPlayerRb != null ? _localPlayerRb.linearVelocity : Vector3.zero;
|
||||
Vector3 angVel = _localPlayerRb != null ? _localPlayerRb.angularVelocity : Vector3.zero;
|
||||
|
||||
if (Vector3.Distance(pos, _lastSentPos) < POS_THRESHOLD &&
|
||||
Vector3.Distance(vel, _lastSentVel) < VEL_THRESHOLD &&
|
||||
Vector3.Distance(angVel, _lastSentAngVel) < VEL_THRESHOLD)
|
||||
return;
|
||||
|
||||
_lastSentPos = pos;
|
||||
_lastSentVel = vel;
|
||||
_lastSentAngVel = angVel;
|
||||
|
||||
Quaternion rot = _localPlayer.rotation;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "x", pos.x }, { "y", pos.y }, { "z", pos.z },
|
||||
{ "vx", vel.x }, { "vy", vel.y }, { "vz", vel.z },
|
||||
{ "rx", rot.x }, { "ry", rot.y }, { "rz", rot.z }, { "rw", rot.w },
|
||||
{ "avx", angVel.x }, { "avy", angVel.y }, { "avz", angVel.z }
|
||||
};
|
||||
|
||||
_ = _room.Send("position", data);
|
||||
}
|
||||
|
||||
// ─── Room Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
private void OnRoomLeave(int code)
|
||||
{
|
||||
Debug.Log($"[Network] Left room (code: {code})");
|
||||
Cleanup();
|
||||
OnDisconnected?.Invoke();
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = "Déconnecté";
|
||||
RoomId = "";
|
||||
PlayerCount = 0;
|
||||
LocalPlayerName = "";
|
||||
LocalPlayerColor = Color.white;
|
||||
_lastPhase = "";
|
||||
|
||||
foreach (var kvp in _remotePlayers)
|
||||
{
|
||||
if (kvp.Value != null && kvp.Value.gameObject != null)
|
||||
Destroy(kvp.Value.gameObject);
|
||||
}
|
||||
_remotePlayers.Clear();
|
||||
|
||||
_room = null;
|
||||
_client = null;
|
||||
_callbacks = null;
|
||||
_localPlayer = null;
|
||||
_localPlayerRb = null;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_room != null) _ = _room.Leave(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Serializable] public class EliminatedMsg { public string sessionId; public string name; public string reason; }
|
||||
[Serializable] public class QualifiedMsg { public string sessionId; public string name; }
|
||||
[Serializable] public class RoundStartMsg { public int round; public string mode; public int totalRounds; }
|
||||
[Serializable] public class RoundEndMsg { public int round; }
|
||||
[Serializable] public class GameEndMsg { public string winner; }
|
||||
2
game/Assets/Scripts/Network/NetworkManager.cs.meta
Normal file
2
game/Assets/Scripts/Network/NetworkManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a218ec39b39bcc459a0c0d0ca10207b
|
||||
48
game/Assets/Scripts/Network/NetworkSchema.cs
Normal file
48
game/Assets/Scripts/Network/NetworkSchema.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Colyseus.Schema;
|
||||
|
||||
// Must match server-side defineTypes field order exactly
|
||||
public partial class NetworkPlayer : Schema
|
||||
{
|
||||
[Type(0, "int32")] public int userId = 0;
|
||||
[Type(1, "float32")] public float x = 0;
|
||||
[Type(2, "float32")] public float y = 5;
|
||||
[Type(3, "float32")] public float z = 0;
|
||||
[Type(4, "float32")] public float vx = 0;
|
||||
[Type(5, "float32")] public float vy = 0;
|
||||
[Type(6, "float32")] public float vz = 0;
|
||||
[Type(7, "float32")] public float rx = 0;
|
||||
[Type(8, "float32")] public float ry = 0;
|
||||
[Type(9, "float32")] public float rz = 0;
|
||||
[Type(10, "float32")] public float rw = 1;
|
||||
[Type(11, "float64")] public double t = 0;
|
||||
[Type(12, "string")] public string name = "";
|
||||
[Type(13, "float32")] public float colorR = 1;
|
||||
[Type(14, "float32")] public float colorG = 1;
|
||||
[Type(15, "float32")] public float colorB = 1;
|
||||
[Type(16, "float32")] public float avx = 0;
|
||||
[Type(17, "float32")] public float avy = 0;
|
||||
[Type(18, "float32")] public float avz = 0;
|
||||
// Game state
|
||||
[Type(19, "boolean")] public bool isEliminated = false;
|
||||
[Type(20, "boolean")] public bool isQualified = false;
|
||||
[Type(21, "int8")] public int team = 0;
|
||||
[Type(22, "int8")] public int checkpointIndex = 0;
|
||||
[Type(23, "boolean")] public bool isReady = false;
|
||||
}
|
||||
|
||||
public partial class NetworkState : Schema
|
||||
{
|
||||
[Type(0, "map", typeof(MapSchema<NetworkPlayer>))]
|
||||
public MapSchema<NetworkPlayer> players;
|
||||
|
||||
[Type(1, "string")] public string phase = "lobby";
|
||||
[Type(2, "float32")] public float countdown = 0;
|
||||
[Type(3, "int8")] public int roundNumber = 1;
|
||||
[Type(4, "int8")] public int totalRounds = 4;
|
||||
[Type(5, "int8")] public int playersAlive = 0;
|
||||
[Type(6, "string")] public string gameMode = "race";
|
||||
[Type(7, "float32")] public float deathZoneY = -100;
|
||||
[Type(8, "int16")] public int teamScoreRed = 0;
|
||||
[Type(9, "int16")] public int teamScoreBlue = 0;
|
||||
[Type(10, "string")] public string winnerName = "";
|
||||
}
|
||||
2
game/Assets/Scripts/Network/NetworkSchema.cs.meta
Normal file
2
game/Assets/Scripts/Network/NetworkSchema.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ce16348bc0580b49860d9bd80e7bec0
|
||||
333
game/Assets/Scripts/Network/RemotePlayerController.cs
Normal file
333
game/Assets/Scripts/Network/RemotePlayerController.cs
Normal file
@@ -0,0 +1,333 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Controls a remote player's ball using snapshot interpolation.
|
||||
/// Maintains a ring buffer of recent network snapshots and interpolates
|
||||
/// between them with a fixed delay, producing smooth motion even with jitter.
|
||||
/// Uses Rigidbody.MovePosition for proper physics collision detection.
|
||||
/// </summary>
|
||||
public class RemotePlayerController : MonoBehaviour
|
||||
{
|
||||
[Header("Interpolation")]
|
||||
[Tooltip("Interpolation delay in seconds (higher = smoother, more latency)")]
|
||||
public float interpolationDelay = 0.083f; // ~83ms = 5 frames at 60Hz
|
||||
|
||||
[Tooltip("Max extrapolation time when no new data arrives")]
|
||||
public float maxExtrapolation = 0.08f; // 80ms — short to avoid overshoot
|
||||
|
||||
[Tooltip("If distance exceeds this, snap instead of interpolate")]
|
||||
public float snapDistance = 8f;
|
||||
|
||||
[Tooltip("Final smoothing factor (higher = tighter follow, lower = smoother)")]
|
||||
public float smoothingSpeed = 24f;
|
||||
|
||||
[Tooltip("Rotation slerp speed")]
|
||||
public float rotationSpeed = 24f;
|
||||
|
||||
// Public info
|
||||
public string SessionId { get; private set; }
|
||||
public string PlayerName { get; private set; }
|
||||
public Color PlayerColor { get; private set; }
|
||||
|
||||
// --- Snapshot buffer ---
|
||||
private struct Snapshot
|
||||
{
|
||||
public double serverTime; // server timestamp (ms)
|
||||
public float localTime; // Time.time when received
|
||||
public Vector3 position;
|
||||
public Vector3 velocity;
|
||||
public Quaternion rotation;
|
||||
public Vector3 angularVelocity;
|
||||
}
|
||||
|
||||
private const int BUFFER_SIZE = 16;
|
||||
private readonly Snapshot[] _buffer = new Snapshot[BUFFER_SIZE];
|
||||
private int _bufferCount;
|
||||
private int _newestIndex;
|
||||
private float _firstLocalTime; // local time of first snapshot received
|
||||
private double _firstServerTime; // server time of first snapshot received
|
||||
private bool _initialized;
|
||||
private Quaternion _currentRotation = Quaternion.identity;
|
||||
private Rigidbody _rb; // Cached for MovePosition
|
||||
|
||||
// Optional: floating name label
|
||||
private TextMesh _nameLabel;
|
||||
|
||||
/// <summary>
|
||||
/// Called by NetworkManager when spawning this remote player.
|
||||
/// </summary>
|
||||
public void Initialize(string sessionId, string playerName, Color color)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
PlayerName = playerName;
|
||||
PlayerColor = color;
|
||||
_currentRotation = transform.rotation;
|
||||
_bufferCount = 0;
|
||||
_initialized = true;
|
||||
|
||||
// Apply color tint (multiply blend to keep pattern visible)
|
||||
var renderer = GetComponent<Renderer>();
|
||||
if (renderer != null)
|
||||
{
|
||||
var mat = new Material(renderer.sharedMaterial);
|
||||
Color original = Color.white;
|
||||
if (mat.HasProperty("_BaseColor")) original = mat.GetColor("_BaseColor");
|
||||
else if (mat.HasProperty("_Color")) original = mat.GetColor("_Color");
|
||||
|
||||
float strength = 0.7f;
|
||||
Color tint = new Color(
|
||||
Mathf.Lerp(original.r, original.r * color.r * 2f, strength),
|
||||
Mathf.Lerp(original.g, original.g * color.g * 2f, strength),
|
||||
Mathf.Lerp(original.b, original.b * color.b * 2f, strength),
|
||||
original.a
|
||||
);
|
||||
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", tint);
|
||||
if (mat.HasProperty("_Color")) mat.color = tint;
|
||||
renderer.material = mat;
|
||||
}
|
||||
|
||||
// Kinematic rigidbody with MovePosition for proper collision detection
|
||||
_rb = GetComponent<Rigidbody>();
|
||||
if (_rb != null)
|
||||
{
|
||||
_rb.isKinematic = true;
|
||||
_rb.useGravity = false;
|
||||
_rb.interpolation = RigidbodyInterpolation.Interpolate;
|
||||
_rb.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
|
||||
}
|
||||
|
||||
// Add a trigger collider slightly larger than the physics collider
|
||||
// so the local player can detect bumps
|
||||
var existingCollider = GetComponent<SphereCollider>();
|
||||
float baseRadius = existingCollider != null ? existingCollider.radius : 0.5f;
|
||||
var trigger = gameObject.AddComponent<SphereCollider>();
|
||||
trigger.isTrigger = true;
|
||||
trigger.radius = baseRadius * 1.15f; // 15% larger
|
||||
|
||||
// Disable any player input on remote balls
|
||||
var playerInput = GetComponent<UnityEngine.InputSystem.PlayerInput>();
|
||||
if (playerInput != null)
|
||||
playerInput.enabled = false;
|
||||
|
||||
var playerController = GetComponent<PlayerController>();
|
||||
if (playerController != null)
|
||||
playerController.enabled = false;
|
||||
|
||||
// Create floating name label
|
||||
CreateNameLabel();
|
||||
|
||||
Debug.Log($"[RemotePlayer] Initialized: {playerName} ({sessionId[..6]}) color={color}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by NetworkManager when a state update arrives from the server.
|
||||
/// Pushes a new snapshot into the interpolation buffer.
|
||||
/// </summary>
|
||||
public void SetTargetState(Vector3 position, Vector3 velocity, Quaternion rotation, double serverTime, Vector3 angularVelocity = default)
|
||||
{
|
||||
// Bootstrap time mapping on first snapshot
|
||||
if (_bufferCount == 0)
|
||||
{
|
||||
_firstLocalTime = Time.time;
|
||||
_firstServerTime = serverTime;
|
||||
}
|
||||
|
||||
// Advance ring buffer
|
||||
_newestIndex = (_newestIndex + 1) % BUFFER_SIZE;
|
||||
_buffer[_newestIndex] = new Snapshot
|
||||
{
|
||||
serverTime = serverTime,
|
||||
localTime = Time.time,
|
||||
position = position,
|
||||
velocity = velocity,
|
||||
rotation = rotation,
|
||||
angularVelocity = angularVelocity
|
||||
};
|
||||
if (_bufferCount < BUFFER_SIZE) _bufferCount++;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!_initialized || _bufferCount == 0) return;
|
||||
|
||||
// Render time = current time minus interpolation delay
|
||||
float renderTime = Time.time - interpolationDelay;
|
||||
|
||||
// Build a sorted view of the buffer (oldest → newest by localTime)
|
||||
// to safely find the two bracketing snapshots
|
||||
int oldestIdx = (_newestIndex - _bufferCount + 1 + BUFFER_SIZE) % BUFFER_SIZE;
|
||||
|
||||
Snapshot older = default;
|
||||
Snapshot newer = default;
|
||||
bool found = false;
|
||||
|
||||
for (int i = 0; i < _bufferCount - 1; i++)
|
||||
{
|
||||
int idxA = (oldestIdx + i) % BUFFER_SIZE;
|
||||
int idxB = (oldestIdx + i + 1) % BUFFER_SIZE;
|
||||
if (_buffer[idxA].localTime <= renderTime && _buffer[idxB].localTime >= renderTime)
|
||||
{
|
||||
older = _buffer[idxA];
|
||||
newer = _buffer[idxB];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Vector3 targetPos;
|
||||
Quaternion targetRot;
|
||||
|
||||
if (found)
|
||||
{
|
||||
// Interpolate between the two bounding snapshots
|
||||
float span = newer.localTime - older.localTime;
|
||||
float t = span > 0.001f ? (renderTime - older.localTime) / span : 1f;
|
||||
t = Mathf.Clamp01(t);
|
||||
|
||||
targetPos = Vector3.Lerp(older.position, newer.position, t);
|
||||
targetRot = Quaternion.Slerp(older.rotation, newer.rotation, t);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No bracketing pair found
|
||||
var newest = _buffer[_newestIndex];
|
||||
float elapsed = renderTime - newest.localTime;
|
||||
|
||||
if (elapsed < 0)
|
||||
{
|
||||
// Render time is earlier than all snapshots — use oldest, don't extrapolate backwards
|
||||
targetPos = _buffer[oldestIdx].position;
|
||||
targetRot = _buffer[oldestIdx].rotation;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Extrapolate forward from newest, but with velocity damping
|
||||
float extTime = Mathf.Min(elapsed, maxExtrapolation);
|
||||
float dampFactor = 1f - Mathf.Clamp01(elapsed / (maxExtrapolation * 2f)); // fade to 0
|
||||
targetPos = newest.position + newest.velocity * extTime * dampFactor;
|
||||
targetRot = newest.rotation;
|
||||
}
|
||||
}
|
||||
|
||||
// Final smoothing layer: lerp from current position toward computed target
|
||||
float dist = Vector3.Distance(transform.position, targetPos);
|
||||
Vector3 newPos;
|
||||
if (dist > snapDistance)
|
||||
{
|
||||
// Teleport for large distances (spawn, reconnect)
|
||||
newPos = targetPos;
|
||||
_currentRotation = targetRot;
|
||||
}
|
||||
else
|
||||
{
|
||||
float lerpT = 1f - Mathf.Exp(-smoothingSpeed * Time.deltaTime);
|
||||
newPos = Vector3.Lerp(transform.position, targetPos, lerpT);
|
||||
}
|
||||
|
||||
// Smooth rotation
|
||||
float rotLerpT = 1f - Mathf.Exp(-rotationSpeed * Time.deltaTime);
|
||||
_currentRotation = Quaternion.Slerp(_currentRotation, targetRot, rotLerpT);
|
||||
|
||||
// Use MovePosition/MoveRotation for proper collision detection
|
||||
if (_rb != null)
|
||||
{
|
||||
_rb.MovePosition(newPos);
|
||||
_rb.MoveRotation(_currentRotation);
|
||||
}
|
||||
else
|
||||
{
|
||||
transform.position = newPos;
|
||||
transform.rotation = _currentRotation;
|
||||
}
|
||||
|
||||
// Keep name label floating ABOVE the ball (world position, not local)
|
||||
// Billboard: always face camera, locked to vertical axis
|
||||
if (_nameLabelObj != null)
|
||||
{
|
||||
_nameLabelObj.transform.position = transform.position + Vector3.up * 1.5f;
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
// Billboard locked to Y axis — only rotate around vertical
|
||||
Vector3 lookDir = _nameLabelObj.transform.position - cam.transform.position;
|
||||
lookDir.y = 0f; // Lock to horizontal plane
|
||||
if (lookDir.sqrMagnitude > 0.001f)
|
||||
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject _nameLabelObj; // Keep reference for billboard update
|
||||
|
||||
private void CreateNameLabel()
|
||||
{
|
||||
GameObject labelObj = new GameObject("NameLabel");
|
||||
// Do NOT parent to transform — ball rotation would spin the label
|
||||
labelObj.transform.position = transform.position + Vector3.up * 1.5f;
|
||||
labelObj.transform.localScale = Vector3.one * 0.1f;
|
||||
_nameLabelObj = labelObj;
|
||||
|
||||
_nameLabel = labelObj.AddComponent<TextMesh>();
|
||||
_nameLabel.text = PlayerName;
|
||||
_nameLabel.fontSize = 144;
|
||||
_nameLabel.characterSize = 0.15f;
|
||||
_nameLabel.anchor = TextAnchor.MiddleCenter;
|
||||
_nameLabel.alignment = TextAlignment.Center;
|
||||
_nameLabel.color = Color.white;
|
||||
var font = PlayerController.LabelFont;
|
||||
if (font != null) _nameLabel.font = font;
|
||||
|
||||
var meshRenderer = _nameLabel.GetComponent<MeshRenderer>();
|
||||
if (font != null && font.material != null)
|
||||
meshRenderer.material = font.material;
|
||||
else
|
||||
{
|
||||
var textShader = Shader.Find("GUI/Text Shader") ?? Shader.Find("Unlit/Texture");
|
||||
if (textShader != null) meshRenderer.material = new Material(textShader);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called by NetworkManager when the player's team or color changes (e.g. teams mode).</summary>
|
||||
public void UpdateTeamColor(int team, Color serverColor)
|
||||
{
|
||||
// Only re-tint if the color actually changed significantly
|
||||
if (PlayerColor == serverColor) return;
|
||||
PlayerColor = serverColor;
|
||||
|
||||
var renderer = GetComponent<Renderer>();
|
||||
if (renderer == null) return;
|
||||
|
||||
var mat = renderer.material;
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", serverColor);
|
||||
else mat.color = serverColor;
|
||||
|
||||
// Update name label color to match team
|
||||
if (_nameLabel != null)
|
||||
{
|
||||
_nameLabel.color = team == 1
|
||||
? new Color(1f, 0.5f, 0.5f)
|
||||
: team == 2
|
||||
? new Color(0.5f, 0.7f, 1f)
|
||||
: Color.white;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Show or hide this remote player (used when eliminated).</summary>
|
||||
public void SetVisible(bool visible)
|
||||
{
|
||||
var renderer = GetComponent<Renderer>();
|
||||
if (renderer != null) renderer.enabled = visible;
|
||||
if (_nameLabelObj != null) _nameLabelObj.SetActive(visible);
|
||||
|
||||
// Disable physics interactions when hidden
|
||||
var col = GetComponent<Collider>();
|
||||
if (col != null) col.enabled = visible;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_nameLabelObj != null) Destroy(_nameLabelObj);
|
||||
Debug.Log($"[RemotePlayer] Destroyed: {PlayerName}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5f5ad6331ffe0d4491eab78cc3b0993
|
||||
82
game/Assets/Scripts/Network/SpectatorCamera.cs
Normal file
82
game/Assets/Scripts/Network/SpectatorCamera.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Spectator camera that slowly orbits around the arena center while the player
|
||||
/// is in the lobby (not yet connected). Automatically disables itself and yields
|
||||
/// to the gameplay Cinemachine camera once the player joins.
|
||||
/// Attach to a dedicated GameObject with a Camera component.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Camera))]
|
||||
public class SpectatorCamera : MonoBehaviour
|
||||
{
|
||||
[Header("Orbit Settings")]
|
||||
[Tooltip("World-space point the camera orbits around")]
|
||||
public Vector3 orbitCenter = Vector3.zero;
|
||||
|
||||
[Tooltip("Radius of the orbit circle")]
|
||||
public float orbitRadius = 30f;
|
||||
|
||||
[Tooltip("Height above the orbit center")]
|
||||
public float orbitHeight = 18f;
|
||||
|
||||
[Tooltip("Degrees per second")]
|
||||
public float orbitSpeed = 12f;
|
||||
|
||||
[Tooltip("Downward pitch angle in degrees")]
|
||||
public float pitchAngle = 30f;
|
||||
|
||||
// Internal
|
||||
private float _angle;
|
||||
private Camera _cam;
|
||||
|
||||
// Reference to the gameplay camera (CinemachineBrain) — set by LobbyUI
|
||||
[HideInInspector] public Camera gameplayCamera;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_cam = GetComponent<Camera>();
|
||||
_angle = Random.Range(0f, 360f); // start at random angle for variety
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
_angle += orbitSpeed * Time.deltaTime;
|
||||
if (_angle >= 360f) _angle -= 360f;
|
||||
|
||||
float rad = _angle * Mathf.Deg2Rad;
|
||||
Vector3 pos = orbitCenter + new Vector3(
|
||||
Mathf.Cos(rad) * orbitRadius,
|
||||
orbitHeight,
|
||||
Mathf.Sin(rad) * orbitRadius
|
||||
);
|
||||
|
||||
transform.position = pos;
|
||||
transform.LookAt(orbitCenter + Vector3.up * 2f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to spectator view — enable this camera, disable gameplay camera.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
_cam.enabled = true;
|
||||
gameObject.SetActive(true);
|
||||
|
||||
// Disable the gameplay camera so we're the active one
|
||||
if (gameplayCamera != null)
|
||||
gameplayCamera.enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch back to gameplay view — disable this camera, enable gameplay camera.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
_cam.enabled = false;
|
||||
gameObject.SetActive(false);
|
||||
|
||||
// Re-enable the gameplay camera
|
||||
if (gameplayCamera != null)
|
||||
gameplayCamera.enabled = true;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Network/SpectatorCamera.cs.meta
Normal file
2
game/Assets/Scripts/Network/SpectatorCamera.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00ccf355f5f18234f9056c4ef6b82395
|
||||
8
game/Assets/Scripts/Race.meta
Normal file
8
game/Assets/Scripts/Race.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a40d9df5e429e614ca4f4ea42e4fa404
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
152
game/Assets/Scripts/Race/CheckpointSystem.cs
Normal file
152
game/Assets/Scripts/Race/CheckpointSystem.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Manages race checkpoints and the finish line.
|
||||
/// Place checkpoint GameObjects in order in the Inspector array.
|
||||
/// Each checkpoint needs a Collider set to "Is Trigger".
|
||||
/// The last checkpoint in the array is treated as the finish line.
|
||||
/// Attach to a persistent manager GameObject in the race scene.
|
||||
/// </summary>
|
||||
public class CheckpointSystem : MonoBehaviour
|
||||
{
|
||||
public static CheckpointSystem Instance { get; private set; }
|
||||
|
||||
[Header("Checkpoints (in order — last one = finish line)")]
|
||||
public Collider[] checkpoints;
|
||||
|
||||
[Header("Visuals")]
|
||||
[Tooltip("Material to apply to active (next) checkpoint")]
|
||||
public Material checkpointActiveMaterial;
|
||||
[Tooltip("Material to apply to passed checkpoints")]
|
||||
public Material checkpointPassedMaterial;
|
||||
[Tooltip("Material to apply to finish line")]
|
||||
public Material finishLineMaterial;
|
||||
|
||||
private int _localCheckpointIndex = 0; // how many checkpoints this local player passed
|
||||
private Renderer[] _checkpointRenderers;
|
||||
private bool _finished = false;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
_checkpointRenderers = new Renderer[checkpoints.Length];
|
||||
for (int i = 0; i < checkpoints.Length; i++)
|
||||
{
|
||||
_checkpointRenderers[i] = checkpoints[i].GetComponent<Renderer>();
|
||||
|
||||
// Tag checkpoints with their index for trigger identification
|
||||
checkpoints[i].gameObject.name = $"Checkpoint_{i}";
|
||||
}
|
||||
|
||||
// Tell HUD total checkpoints
|
||||
GameHUD.TotalCheckpoints = checkpoints.Length;
|
||||
UpdateCheckpointVisuals();
|
||||
}
|
||||
|
||||
/// <summary>Called by CheckpointTrigger on each checkpoint object.</summary>
|
||||
public void OnLocalPlayerHitCheckpoint(int index)
|
||||
{
|
||||
if (_finished) return;
|
||||
// Must hit checkpoints in order
|
||||
if (index != _localCheckpointIndex) return;
|
||||
|
||||
_localCheckpointIndex++;
|
||||
NetworkManager.Instance?.SendCheckpoint(_localCheckpointIndex);
|
||||
|
||||
Debug.Log($"[Checkpoint] Reached {_localCheckpointIndex}/{checkpoints.Length}");
|
||||
|
||||
// Update HUD
|
||||
GameHUD.Instance?.SetCheckpoint(_localCheckpointIndex, checkpoints.Length);
|
||||
|
||||
UpdateCheckpointVisuals();
|
||||
|
||||
if (_localCheckpointIndex >= checkpoints.Length)
|
||||
{
|
||||
_finished = true;
|
||||
Debug.Log("[Checkpoint] FINISH LINE reached!");
|
||||
StartCoroutine(FinishFlash());
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(FlashCheckpoint(index));
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetForRound()
|
||||
{
|
||||
_localCheckpointIndex = 0;
|
||||
_finished = false;
|
||||
UpdateCheckpointVisuals();
|
||||
}
|
||||
|
||||
private void UpdateCheckpointVisuals()
|
||||
{
|
||||
for (int i = 0; i < checkpoints.Length; i++)
|
||||
{
|
||||
if (_checkpointRenderers[i] == null) continue;
|
||||
|
||||
if (i < _localCheckpointIndex)
|
||||
{
|
||||
// Passed
|
||||
if (checkpointPassedMaterial != null)
|
||||
_checkpointRenderers[i].material = checkpointPassedMaterial;
|
||||
_checkpointRenderers[i].enabled = false; // hide passed checkpoints
|
||||
}
|
||||
else if (i == _localCheckpointIndex)
|
||||
{
|
||||
// Active (next to hit)
|
||||
_checkpointRenderers[i].enabled = true;
|
||||
if (i == checkpoints.Length - 1 && finishLineMaterial != null)
|
||||
_checkpointRenderers[i].material = finishLineMaterial;
|
||||
else if (checkpointActiveMaterial != null)
|
||||
_checkpointRenderers[i].material = checkpointActiveMaterial;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Upcoming (not yet active)
|
||||
_checkpointRenderers[i].enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FlashCheckpoint(int index)
|
||||
{
|
||||
if (index < 0 || index >= checkpoints.Length) yield break;
|
||||
var rend = _checkpointRenderers[index];
|
||||
if (rend == null) yield break;
|
||||
|
||||
Color orig = rend.material.HasProperty("_BaseColor")
|
||||
? rend.material.GetColor("_BaseColor")
|
||||
: rend.material.color;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
SetRendererColor(rend, new Color(0.3f, 1f, 0.5f));
|
||||
yield return new WaitForSeconds(0.08f);
|
||||
SetRendererColor(rend, orig);
|
||||
yield return new WaitForSeconds(0.08f);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FinishFlash()
|
||||
{
|
||||
float t = 0f;
|
||||
while (t < 2f)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
// Finish confirmed via network — GameManager/EliminationOverlay handles the "Qualifié!" overlay
|
||||
}
|
||||
|
||||
private static void SetRendererColor(Renderer rend, Color c)
|
||||
{
|
||||
if (rend.material.HasProperty("_BaseColor")) rend.material.SetColor("_BaseColor", c);
|
||||
else rend.material.color = c;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Race/CheckpointSystem.cs.meta
Normal file
2
game/Assets/Scripts/Race/CheckpointSystem.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5bf5e078a2ee9ed4fa95eacab5753f3a
|
||||
18
game/Assets/Scripts/Race/CheckpointTrigger.cs
Normal file
18
game/Assets/Scripts/Race/CheckpointTrigger.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Attach to each checkpoint GameObject (which must have a trigger Collider).
|
||||
/// Set the checkpointIndex in the Inspector to match the checkpoint's position in the sequence.
|
||||
/// </summary>
|
||||
public class CheckpointTrigger : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Index in the CheckpointSystem.checkpoints array (0-based)")]
|
||||
public int checkpointIndex = 0;
|
||||
|
||||
void OnTriggerEnter(Collider other)
|
||||
{
|
||||
// Only trigger for the local player (has PlayerController)
|
||||
if (other.GetComponent<PlayerController>() == null) return;
|
||||
CheckpointSystem.Instance?.OnLocalPlayerHitCheckpoint(checkpointIndex);
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Race/CheckpointTrigger.cs.meta
Normal file
2
game/Assets/Scripts/Race/CheckpointTrigger.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d1f3d6aaca8e97498f40d827f7c5216
|
||||
8
game/Assets/Scripts/Survival.meta
Normal file
8
game/Assets/Scripts/Survival.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df63bacc43a52494a8097ac2cc50f39f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
93
game/Assets/Scripts/Survival/DeathZone.cs
Normal file
93
game/Assets/Scripts/Survival/DeathZone.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Survival mode: a rising death zone that climbs from below.
|
||||
/// The server is authoritative on the Y position (broadcast via NetworkState.deathZoneY).
|
||||
/// This component moves the visual/collider locally, and detects local player contact.
|
||||
///
|
||||
/// Setup: Create a large Plane or Cube GameObject, attach this script,
|
||||
/// add a Box/Mesh Collider set to "Is Trigger".
|
||||
/// The object will be positioned at deathZoneY each frame.
|
||||
/// </summary>
|
||||
public class DeathZone : MonoBehaviour
|
||||
{
|
||||
[Header("Visual")]
|
||||
[Tooltip("Half-size of the death zone plane (X and Z)")]
|
||||
public float halfExtent = 200f;
|
||||
|
||||
[Tooltip("Thickness of the zone collider")]
|
||||
public float thickness = 2f;
|
||||
|
||||
[Header("Warning")]
|
||||
[Tooltip("Distance above death zone where the red tint starts")]
|
||||
public float warningDistance = 8f;
|
||||
|
||||
private bool _hitSent = false;
|
||||
private float _targetY = -100f;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Scale the collider to cover the arena
|
||||
transform.localScale = new Vector3(halfExtent * 2f, thickness, halfExtent * 2f);
|
||||
|
||||
// Subscribe to death zone Y changes from server
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnDeathZoneYChanged += OnDeathZoneYChanged;
|
||||
NetworkManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnDeathZoneYChanged -= OnDeathZoneYChanged;
|
||||
NetworkManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDeathZoneYChanged(float y)
|
||||
{
|
||||
_targetY = y;
|
||||
}
|
||||
|
||||
void OnPhaseChanged(string phase)
|
||||
{
|
||||
if (phase == "playing" || phase == "lobby")
|
||||
{
|
||||
_hitSent = false; // reset for new round
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Smooth follow of the server Y value
|
||||
Vector3 pos = transform.position;
|
||||
pos.y = Mathf.Lerp(pos.y, _targetY, Time.deltaTime * 3f);
|
||||
transform.position = pos;
|
||||
|
||||
// Update warning tint based on local player proximity
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm != null && nm.IsConnected && GameHUD.Instance != null)
|
||||
{
|
||||
var localState = nm.GetLocalPlayerState();
|
||||
if (localState != null)
|
||||
{
|
||||
float dist = localState.y - pos.y;
|
||||
float intensity = Mathf.Clamp01(1f - dist / warningDistance);
|
||||
GameHUD.Instance.SetDeathZoneWarning(intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (_hitSent) return;
|
||||
if (other.GetComponent<PlayerController>() == null) return;
|
||||
|
||||
_hitSent = true;
|
||||
Debug.Log("[DeathZone] Local player hit the death zone!");
|
||||
NetworkManager.Instance?.SendDeathZoneHit();
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Survival/DeathZone.cs.meta
Normal file
2
game/Assets/Scripts/Survival/DeathZone.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99fdfaa3e87a64d4e958f81014e6cdab
|
||||
8
game/Assets/Scripts/Teams.meta
Normal file
8
game/Assets/Scripts/Teams.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2faa616ae540deb4bacf7c0b7d661468
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
125
game/Assets/Scripts/Teams/ZoneCapture.cs
Normal file
125
game/Assets/Scripts/Teams/ZoneCapture.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Teams mode: a central capture zone. The local player sends "inZone" messages
|
||||
/// to the server while inside the zone. The server tallies scores.
|
||||
/// Visualizes zone control by tinting the zone's renderer.
|
||||
///
|
||||
/// Setup: Create a flat Box/Plane in the center of the arena.
|
||||
/// Add a BoxCollider set to "Is Trigger". Attach this script.
|
||||
/// </summary>
|
||||
public class ZoneCapture : MonoBehaviour
|
||||
{
|
||||
public static ZoneCapture Instance { get; private set; }
|
||||
|
||||
[Header("Visual")]
|
||||
[Tooltip("Neutral zone color")]
|
||||
public Color neutralColor = new Color(0.5f, 0.5f, 0.6f, 0.5f);
|
||||
[Tooltip("Red team controls color")]
|
||||
public Color redColor = new Color(1f, 0.2f, 0.2f, 0.6f);
|
||||
[Tooltip("Blue team controls color")]
|
||||
public Color blueColor = new Color(0.2f, 0.5f, 1f, 0.6f);
|
||||
|
||||
[Header("Score Reporting")]
|
||||
[Tooltip("How often (seconds) to send inZone=true to server while inside")]
|
||||
public float reportInterval = 0.5f;
|
||||
|
||||
private bool _isLocalPlayerInZone = false;
|
||||
private float _reportTimer = 0f;
|
||||
private Renderer _renderer;
|
||||
|
||||
// Zone occupant counts (received via server state — approximated by remote player positions)
|
||||
private int _redInZone = 0;
|
||||
private int _blueInZone = 0;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_renderer = GetComponent<Renderer>();
|
||||
SetZoneColor(neutralColor);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
NetworkManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
|
||||
void OnPhaseChanged(string phase)
|
||||
{
|
||||
if (phase == "lobby" || phase == "roundEnd")
|
||||
{
|
||||
_isLocalPlayerInZone = false;
|
||||
_redInZone = 0;
|
||||
_blueInZone = 0;
|
||||
SetZoneColor(neutralColor);
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!_isLocalPlayerInZone) return;
|
||||
|
||||
_reportTimer += Time.deltaTime;
|
||||
if (_reportTimer >= reportInterval)
|
||||
{
|
||||
_reportTimer = 0f;
|
||||
NetworkManager.Instance?.SendInZone(true);
|
||||
}
|
||||
|
||||
// Update zone tint based on dominance
|
||||
UpdateZoneColor();
|
||||
}
|
||||
|
||||
void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (other.GetComponent<PlayerController>() != null)
|
||||
{
|
||||
_isLocalPlayerInZone = true;
|
||||
_reportTimer = 0f;
|
||||
NetworkManager.Instance?.SendInZone(true);
|
||||
}
|
||||
|
||||
// Count remote players in zone
|
||||
var remote = other.GetComponent<RemotePlayerController>();
|
||||
if (remote != null)
|
||||
{
|
||||
// team info would need to be tracked — skip for now, server handles scoring
|
||||
}
|
||||
}
|
||||
|
||||
void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (other.GetComponent<PlayerController>() != null)
|
||||
{
|
||||
_isLocalPlayerInZone = false;
|
||||
NetworkManager.Instance?.SendInZone(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateZoneColor()
|
||||
{
|
||||
// Read team scores from NetworkManager state for visual feedback
|
||||
// For now use a pulsing neutral tint when local player is inside
|
||||
float pulse = 0.7f + 0.3f * Mathf.Sin(Time.time * 3f);
|
||||
Color c = neutralColor;
|
||||
c.a = pulse * 0.6f;
|
||||
SetZoneColor(c);
|
||||
}
|
||||
|
||||
public void SetZoneColor(Color c)
|
||||
{
|
||||
if (_renderer == null) return;
|
||||
var mat = _renderer.material;
|
||||
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", c);
|
||||
else mat.color = c;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Teams/ZoneCapture.cs.meta
Normal file
2
game/Assets/Scripts/Teams/ZoneCapture.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e7c98b369c3ccf4aac0ad3ad2bcbbff
|
||||
8
game/Assets/Scripts/UI.meta
Normal file
8
game/Assets/Scripts/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ae2c51915057f04592577f239042d63
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
142
game/Assets/Scripts/UI/EliminationOverlay.cs
Normal file
142
game/Assets/Scripts/UI/EliminationOverlay.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Full-screen overlay for elimination, qualification, and game-end events.
|
||||
/// Fade in → hold → fade out automatically.
|
||||
/// </summary>
|
||||
public class EliminationOverlay : MonoBehaviour
|
||||
{
|
||||
private enum OverlayType { None, Eliminated, Qualified, GameEnd }
|
||||
|
||||
private OverlayType _type = OverlayType.None;
|
||||
private float _alpha = 0f;
|
||||
private string _winnerName = "";
|
||||
|
||||
private static Texture2D _bgTex;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (_bgTex == null)
|
||||
{
|
||||
_bgTex = new Texture2D(1, 1);
|
||||
_bgTex.SetPixel(0, 0, Color.white);
|
||||
_bgTex.Apply();
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowEliminated() => StartCoroutine(ShowOverlay(OverlayType.Eliminated, 3f));
|
||||
public void ShowQualified() => StartCoroutine(ShowOverlay(OverlayType.Qualified, 2.5f));
|
||||
public void ShowGameEnd(string winner)
|
||||
{
|
||||
_winnerName = winner;
|
||||
StartCoroutine(ShowOverlay(OverlayType.GameEnd, 6f));
|
||||
}
|
||||
|
||||
private IEnumerator ShowOverlay(OverlayType type, float holdTime)
|
||||
{
|
||||
_type = type;
|
||||
|
||||
// Fade in
|
||||
float t = 0f;
|
||||
while (t < 0.3f)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
_alpha = Mathf.Clamp01(t / 0.3f);
|
||||
yield return null;
|
||||
}
|
||||
_alpha = 1f;
|
||||
|
||||
// Hold
|
||||
yield return new WaitForSeconds(holdTime);
|
||||
|
||||
// Fade out
|
||||
t = 0f;
|
||||
while (t < 0.4f)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
_alpha = 1f - Mathf.Clamp01(t / 0.4f);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_alpha = 0f;
|
||||
_type = OverlayType.None;
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (_type == OverlayType.None || _alpha < 0.01f) return;
|
||||
|
||||
// Background tint
|
||||
Color bgColor = _type switch
|
||||
{
|
||||
OverlayType.Eliminated => new Color(0.7f, 0.05f, 0.05f, _alpha * 0.55f),
|
||||
OverlayType.Qualified => new Color(0.05f, 0.55f, 0.15f, _alpha * 0.45f),
|
||||
OverlayType.GameEnd => new Color(0.05f, 0.05f, 0.3f, _alpha * 0.6f),
|
||||
_ => Color.clear
|
||||
};
|
||||
GUI.color = bgColor;
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
// Main text
|
||||
var mainStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 72,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
|
||||
string mainText = _type switch
|
||||
{
|
||||
OverlayType.Eliminated => "ÉLIMINÉ !",
|
||||
OverlayType.Qualified => "QUALIFIÉ !",
|
||||
OverlayType.GameEnd => "VICTOIRE !",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
Color textColor = _type switch
|
||||
{
|
||||
OverlayType.Eliminated => new Color(1f, 0.3f, 0.2f, _alpha),
|
||||
OverlayType.Qualified => new Color(0.3f, 1f, 0.5f, _alpha),
|
||||
OverlayType.GameEnd => new Color(1f, 0.85f, 0.1f, _alpha),
|
||||
_ => Color.clear
|
||||
};
|
||||
mainStyle.normal.textColor = textColor;
|
||||
GUI.Label(new Rect(0, Screen.height * 0.35f, Screen.width, 100f), mainText, mainStyle);
|
||||
|
||||
// Sub text
|
||||
var subStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 26,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
subStyle.normal.textColor = new Color(1f, 1f, 1f, _alpha * 0.85f);
|
||||
|
||||
string subText = _type switch
|
||||
{
|
||||
OverlayType.Eliminated => "Meilleure chance la prochaine fois !",
|
||||
OverlayType.Qualified => "Tu passes au round suivant !",
|
||||
OverlayType.GameEnd => $"Gagnant : {_winnerName}",
|
||||
_ => ""
|
||||
};
|
||||
GUI.Label(new Rect(0, Screen.height * 0.35f + 100f, Screen.width, 50f), subText, subStyle);
|
||||
|
||||
// Emoji accent
|
||||
var emojiStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 48,
|
||||
};
|
||||
emojiStyle.normal.textColor = new Color(1f, 1f, 1f, _alpha * 0.7f);
|
||||
string emoji = _type switch
|
||||
{
|
||||
OverlayType.Eliminated => "💀",
|
||||
OverlayType.Qualified => "✅",
|
||||
OverlayType.GameEnd => "🏆",
|
||||
_ => ""
|
||||
};
|
||||
GUI.Label(new Rect(0, Screen.height * 0.35f - 80f, Screen.width, 70f), emoji, emojiStyle);
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/UI/EliminationOverlay.cs.meta
Normal file
2
game/Assets/Scripts/UI/EliminationOverlay.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51e21afb9dba1904bb425ac1fae825cb
|
||||
324
game/Assets/Scripts/UI/GameHUD.cs
Normal file
324
game/Assets/Scripts/UI/GameHUD.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// In-game HUD: round info, countdown, players alive, timer, checkpoints.
|
||||
/// Uses ImGuiSkin for visual consistency with LobbyUI.
|
||||
/// Only shown when a game is active (not in lobby).
|
||||
/// </summary>
|
||||
public class GameHUD : MonoBehaviour
|
||||
{
|
||||
// State
|
||||
private string _phase = "lobby";
|
||||
private float _countdown = 0f;
|
||||
private int _roundNumber = 1;
|
||||
private int _totalRounds = 4;
|
||||
private string _gameMode = "race";
|
||||
private float _roundTimer = 0f;
|
||||
private bool _timerRunning = false;
|
||||
|
||||
// Checkpoint info (set by CheckpointSystem)
|
||||
private int _checkpointsCurrent = 0;
|
||||
private int _checkpointsTotal = 5;
|
||||
|
||||
// Countdown animation
|
||||
private float _lastCountdownShown = -1f;
|
||||
private float _countdownPulse = 0f;
|
||||
|
||||
// --- Static textures ---
|
||||
private static Texture2D _bgTex;
|
||||
private static Texture2D _barFillTex;
|
||||
private static Texture2D _barBgTex;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
EnsureTextures();
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnRoundStart += OnRoundStart;
|
||||
NetworkManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnRoundStart -= OnRoundStart;
|
||||
NetworkManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnRoundStart(int round, string mode)
|
||||
{
|
||||
_roundNumber = round;
|
||||
_gameMode = mode;
|
||||
_roundTimer = 0f;
|
||||
_timerRunning = true;
|
||||
_checkpointsCurrent = 0;
|
||||
}
|
||||
|
||||
void OnPhaseChanged(string phase)
|
||||
{
|
||||
_phase = phase;
|
||||
if (phase == "playing") _timerRunning = true;
|
||||
else if (phase == "roundEnd" || phase == "gameEnd") _timerRunning = false;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_timerRunning)
|
||||
_roundTimer += Time.deltaTime;
|
||||
|
||||
if (_countdown > 0f && _countdown != _lastCountdownShown)
|
||||
{
|
||||
_countdownPulse = 1f;
|
||||
_lastCountdownShown = _countdown;
|
||||
}
|
||||
_countdownPulse = Mathf.Max(0f, _countdownPulse - Time.deltaTime * 3f);
|
||||
}
|
||||
|
||||
public void SetPhase(string phase) => _phase = phase;
|
||||
public void SetCountdown(float v) => _countdown = v;
|
||||
public void SetRoundInfo(int round, string mode) { _roundNumber = round; _gameMode = mode; }
|
||||
public void SetCheckpoint(int current, int total) { _checkpointsCurrent = current; _checkpointsTotal = total; }
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (_phase == "lobby") return;
|
||||
|
||||
ImGuiSkin.EnsureReady();
|
||||
var nm = NetworkManager.Instance;
|
||||
|
||||
// ── Countdown (center, large) ─────────────────────────────────────
|
||||
if (_phase == "countdown" && _countdown > 0f)
|
||||
{
|
||||
float scale = 1f + _countdownPulse * 0.4f;
|
||||
float fontSize = 96f * scale;
|
||||
var countStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = Mathf.RoundToInt(fontSize),
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
countStyle.normal.textColor = new Color(1f, 0.85f, 0.1f, 1f);
|
||||
GUI.Label(new Rect(0, Screen.height * 0.3f, Screen.width, 120f),
|
||||
Mathf.CeilToInt(_countdown).ToString(), countStyle);
|
||||
|
||||
// "Préparez-vous !" label below
|
||||
var subStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 22,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
subStyle.normal.textColor = new Color(1f, 1f, 1f, 0.8f);
|
||||
string modeLabel = _gameMode switch {
|
||||
"race" => "COURSE",
|
||||
"survival" => "SURVIVAL",
|
||||
"teams" => "ÉQUIPES",
|
||||
_ => _gameMode.ToUpper()
|
||||
};
|
||||
GUI.Label(new Rect(0, Screen.height * 0.3f + 110f, Screen.width, 36f),
|
||||
$"— {modeLabel} —", subStyle);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Top-left: Round & Mode ─────────────────────────────────────────
|
||||
float panelX = 12f;
|
||||
float panelY = 12f;
|
||||
float panelW = 220f;
|
||||
float panelH = 70f;
|
||||
|
||||
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
|
||||
GUI.DrawTexture(new Rect(panelX, panelY, panelW, panelH), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
var roundStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
fontSize = 14,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
roundStyle.normal.textColor = new Color(1f, 0.85f, 0.1f);
|
||||
GUI.Label(new Rect(panelX + 8f, panelY + 4f, panelW - 16f, 28f),
|
||||
$"ROUND {_roundNumber} / {_totalRounds}", roundStyle);
|
||||
|
||||
var modeStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft, fontSize = 12 };
|
||||
modeStyle.normal.textColor = new Color(0.7f, 0.7f, 0.85f);
|
||||
string modeFull = _gameMode switch {
|
||||
"race" => "COURSE", "survival" => "SURVIVAL", "teams" => "ÉQUIPES", _ => _gameMode.ToUpper()
|
||||
};
|
||||
GUI.Label(new Rect(panelX + 8f, panelY + 32f, panelW - 16f, 24f), modeFull, modeStyle);
|
||||
|
||||
// ── Top-right: Players alive ──────────────────────────────────────
|
||||
int alive = nm?.GetLocalPlayerState() != null
|
||||
? (_room_playersAlive > 0 ? _room_playersAlive : 1)
|
||||
: 0;
|
||||
|
||||
if (nm != null)
|
||||
{
|
||||
// read from room state if accessible
|
||||
}
|
||||
|
||||
float prX = Screen.width - 180f;
|
||||
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
|
||||
GUI.DrawTexture(new Rect(prX, panelY, 168f, panelH), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
var aliveStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 28,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
aliveStyle.normal.textColor = new Color(0.3f, 1f, 0.5f);
|
||||
GUI.Label(new Rect(prX, panelY + 2f, 168f, 40f), $"{_cachedPlayersAlive}", aliveStyle);
|
||||
|
||||
var aliveLabel = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
|
||||
aliveLabel.normal.textColor = new Color(0.6f, 0.6f, 0.7f);
|
||||
GUI.Label(new Rect(prX, panelY + 40f, 168f, 22f), "joueurs en jeu", aliveLabel);
|
||||
|
||||
// ── Round timer (top center) ──────────────────────────────────────
|
||||
if (_timerRunning)
|
||||
{
|
||||
int mins = Mathf.FloorToInt(_roundTimer / 60f);
|
||||
int secs = Mathf.FloorToInt(_roundTimer % 60f);
|
||||
var timerStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 18,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
timerStyle.normal.textColor = new Color(0.85f, 0.85f, 0.9f, 0.9f);
|
||||
GUI.Label(new Rect(Screen.width * 0.5f - 60f, panelY, 120f, 40f),
|
||||
$"{mins:00}:{secs:00}", timerStyle);
|
||||
}
|
||||
|
||||
// ── Race: checkpoint progress (bottom center) ─────────────────────
|
||||
if (_gameMode == "race" && _phase == "playing")
|
||||
{
|
||||
float bw = 300f;
|
||||
float bx = (Screen.width - bw) / 2f;
|
||||
float by = Screen.height - 60f;
|
||||
|
||||
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
|
||||
GUI.DrawTexture(new Rect(bx - 8f, by - 8f, bw + 16f, 36f), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
// Background bar
|
||||
GUI.color = new Color(0.2f, 0.2f, 0.28f, 1f);
|
||||
GUI.DrawTexture(new Rect(bx, by, bw, 20f), _barBgTex);
|
||||
|
||||
// Fill
|
||||
float fill = _checkpointsTotal > 0 ? (float)_checkpointsCurrent / _checkpointsTotal : 0f;
|
||||
GUI.color = new Color(0.3f, 1f, 0.5f, 1f);
|
||||
GUI.DrawTexture(new Rect(bx, by, bw * fill, 20f), _barFillTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
var cpStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
|
||||
cpStyle.normal.textColor = Color.white;
|
||||
GUI.Label(new Rect(bx, by, bw, 20f),
|
||||
$"Checkpoint {_checkpointsCurrent} / {_checkpointsTotal}", cpStyle);
|
||||
}
|
||||
|
||||
// ── Teams: score display (bottom center) ──────────────────────────
|
||||
if (_gameMode == "teams" && _phase == "playing")
|
||||
{
|
||||
float tw = 260f;
|
||||
float tx = (Screen.width - tw) / 2f;
|
||||
float ty = Screen.height - 60f;
|
||||
|
||||
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
|
||||
GUI.DrawTexture(new Rect(tx - 8f, ty - 8f, tw + 16f, 36f), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
var teamStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 20, fontStyle = FontStyle.Bold };
|
||||
|
||||
// Red team score
|
||||
teamStyle.normal.textColor = new Color(1f, 0.3f, 0.3f);
|
||||
GUI.Label(new Rect(tx, ty - 2f, tw * 0.4f, 28f), $"{_cachedScoreRed}", teamStyle);
|
||||
|
||||
// Separator
|
||||
var sepStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 16 };
|
||||
sepStyle.normal.textColor = new Color(0.5f, 0.5f, 0.6f);
|
||||
GUI.Label(new Rect(tx + tw * 0.4f, ty - 2f, tw * 0.2f, 28f), "vs", sepStyle);
|
||||
|
||||
// Blue team score
|
||||
teamStyle.normal.textColor = new Color(0.3f, 0.6f, 1f);
|
||||
GUI.Label(new Rect(tx + tw * 0.6f, ty - 2f, tw * 0.4f, 28f), $"{_cachedScoreBlue}", teamStyle);
|
||||
}
|
||||
|
||||
// ── Survival: death zone warning ──────────────────────────────────
|
||||
if (_gameMode == "survival" && _phase == "playing" && _deathZoneWarning > 0.01f)
|
||||
{
|
||||
GUI.color = new Color(1f, 0.3f, 0.1f, _deathZoneWarning * 0.4f);
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), _bgTex);
|
||||
GUI.color = Color.white;
|
||||
|
||||
var warnStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 20, fontStyle = FontStyle.Bold };
|
||||
warnStyle.normal.textColor = new Color(1f, 0.4f, 0.2f, _deathZoneWarning);
|
||||
GUI.Label(new Rect(0, Screen.height * 0.8f, Screen.width, 36f), "⚠ ZONE DE MORT MONTE !", warnStyle);
|
||||
}
|
||||
}
|
||||
|
||||
// Static accessors for cross-script use
|
||||
public static GameHUD Instance { get; private set; }
|
||||
public static int TotalCheckpoints { get; set; } = 5;
|
||||
|
||||
// Cached values updated from NetworkManager state polling
|
||||
private int _cachedPlayersAlive = 0;
|
||||
private int _cachedScoreRed = 0;
|
||||
private int _cachedScoreBlue = 0;
|
||||
private float _deathZoneWarning = 0f;
|
||||
private int _room_playersAlive = 0;
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// Poll NetworkManager for display values (avoids tight coupling via events for display-only data)
|
||||
if (NetworkManager.Instance == null || !NetworkManager.Instance.IsConnected) return;
|
||||
|
||||
// Survival: check death zone proximity
|
||||
if (_gameMode == "survival")
|
||||
{
|
||||
var localState = NetworkManager.Instance.GetLocalPlayerState();
|
||||
if (localState != null)
|
||||
{
|
||||
// deathZoneY is synced via NetworkState — we read via a static accessor pattern
|
||||
// For now, warn when player Y is within 5 units above death zone
|
||||
// (actual deathZoneY is not directly accessible here without extra plumbing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called by DeathZone.cs to update the warning
|
||||
public void SetDeathZoneWarning(float intensity) => _deathZoneWarning = intensity;
|
||||
public void SetTeamScores(int red, int blue) { _cachedScoreRed = red; _cachedScoreBlue = blue; }
|
||||
public void SetPlayersAlive(int count) => _cachedPlayersAlive = count;
|
||||
|
||||
private static void EnsureTextures()
|
||||
{
|
||||
if (_bgTex == null)
|
||||
{
|
||||
_bgTex = new Texture2D(1, 1);
|
||||
_bgTex.SetPixel(0, 0, Color.white);
|
||||
_bgTex.Apply();
|
||||
}
|
||||
if (_barBgTex == null)
|
||||
{
|
||||
_barBgTex = new Texture2D(1, 1);
|
||||
_barBgTex.SetPixel(0, 0, Color.white);
|
||||
_barBgTex.Apply();
|
||||
}
|
||||
if (_barFillTex == null)
|
||||
{
|
||||
_barFillTex = new Texture2D(1, 1);
|
||||
_barFillTex.SetPixel(0, 0, Color.white);
|
||||
_barFillTex.Apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/UI/GameHUD.cs.meta
Normal file
2
game/Assets/Scripts/UI/GameHUD.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 80ec66341fb507e45bf59c98c638c70a
|
||||
362
game/Assets/Scripts/UI/ImGuiSkin.cs
Normal file
362
game/Assets/Scripts/UI/ImGuiSkin.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized Dear ImGui–style skin for Unity's OnGUI / IMGUI.
|
||||
/// Provides cached textures, pre-built GUIStyles, and helper draw methods
|
||||
/// that replicate the classic Dear ImGui dark theme.
|
||||
///
|
||||
/// Usage: Call ImGuiSkin.Init() once (auto-called on first use),
|
||||
/// then use the static styles and helpers from any OnGUI method.
|
||||
/// </summary>
|
||||
public static class ImGuiSkin
|
||||
{
|
||||
// ════════════════════════════════════════════
|
||||
// PALETTE (Dear ImGui "Dark" defaults)
|
||||
// ════════════════════════════════════════════
|
||||
public static readonly Color ColWindowBg = new(0.06f, 0.06f, 0.10f, 0.94f); // #10101A
|
||||
public static readonly Color ColFrameBg = new(0.12f, 0.12f, 0.18f, 1f); // #1E1E2E
|
||||
public static readonly Color ColFrameHover = new(0.18f, 0.18f, 0.26f, 1f);
|
||||
public static readonly Color ColHeader = new(0.16f, 0.20f, 0.36f, 1f); // #283A5C
|
||||
public static readonly Color ColHeaderHover = new(0.22f, 0.28f, 0.48f, 1f);
|
||||
public static readonly Color ColButton = new(0.16f, 0.20f, 0.36f, 1f);
|
||||
public static readonly Color ColButtonHover = new(0.22f, 0.28f, 0.48f, 1f);
|
||||
public static readonly Color ColButtonActive= new(0.10f, 0.30f, 0.60f, 1f);
|
||||
public static readonly Color ColAccent = new(0.26f, 0.59f, 0.98f, 1f); // #4296FA
|
||||
public static readonly Color ColAccentDark = new(0.20f, 0.42f, 0.78f, 1f);
|
||||
public static readonly Color ColText = new(0.92f, 0.92f, 0.92f, 1f);
|
||||
public static readonly Color ColTextDim = new(0.55f, 0.55f, 0.60f, 1f);
|
||||
public static readonly Color ColBorder = new(0.28f, 0.28f, 0.36f, 1f);
|
||||
public static readonly Color ColSeparator = new(0.28f, 0.28f, 0.36f, 0.6f);
|
||||
public static readonly Color ColOverlay = new(0f, 0f, 0f, 0.65f);
|
||||
public static readonly Color ColGreen = new(0.3f, 1f, 0.3f, 1f);
|
||||
public static readonly Color ColRed = new(1f, 0.35f, 0.35f, 1f);
|
||||
public static readonly Color ColYellow = new(1f, 0.85f, 0.25f, 1f);
|
||||
public static readonly Color ColGold = new(1f, 0.84f, 0f, 1f);
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// CACHED TEXTURES (1×1)
|
||||
// ════════════════════════════════════════════
|
||||
public static Texture2D TexWindowBg { get; private set; }
|
||||
public static Texture2D TexFrameBg { get; private set; }
|
||||
public static Texture2D TexFrameHover { get; private set; }
|
||||
public static Texture2D TexHeader { get; private set; }
|
||||
public static Texture2D TexHeaderHover{ get; private set; }
|
||||
public static Texture2D TexButton { get; private set; }
|
||||
public static Texture2D TexButtonHover{ get; private set; }
|
||||
public static Texture2D TexButtonActive{ get; private set; }
|
||||
public static Texture2D TexAccent { get; private set; }
|
||||
public static Texture2D TexAccentDark { get; private set; }
|
||||
public static Texture2D TexBorder { get; private set; }
|
||||
public static Texture2D TexOverlay { get; private set; }
|
||||
public static Texture2D TexTransparent{ get; private set; }
|
||||
|
||||
// HUD-specific
|
||||
public static Texture2D TexHudStrip { get; private set; }
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// STYLES (lazy-initialized)
|
||||
// ════════════════════════════════════════════
|
||||
private static bool _inited;
|
||||
|
||||
// Window / panel
|
||||
public static GUIStyle WindowTitle { get; private set; }
|
||||
public static GUIStyle WindowSubtitle{ get; private set; }
|
||||
|
||||
// Section headers
|
||||
public static GUIStyle SectionHeader { get; private set; }
|
||||
|
||||
// Labels
|
||||
public static GUIStyle Label { get; private set; }
|
||||
public static GUIStyle LabelDim { get; private set; }
|
||||
public static GUIStyle LabelBold { get; private set; }
|
||||
public static GUIStyle LabelCenter { get; private set; }
|
||||
public static GUIStyle LabelRich { get; private set; }
|
||||
|
||||
// Fields (key-value pairs)
|
||||
public static GUIStyle FieldKey { get; private set; }
|
||||
public static GUIStyle FieldValue { get; private set; }
|
||||
|
||||
// Buttons
|
||||
public static GUIStyle Button { get; private set; }
|
||||
public static GUIStyle ButtonAccent { get; private set; }
|
||||
public static GUIStyle ButtonSmall { get; private set; }
|
||||
|
||||
// TextField
|
||||
public static GUIStyle TextField { get; private set; }
|
||||
|
||||
// HUD strip
|
||||
public static GUIStyle HudLabel { get; private set; }
|
||||
|
||||
// Status
|
||||
public static GUIStyle StatusGreen { get; private set; }
|
||||
public static GUIStyle StatusRed { get; private set; }
|
||||
|
||||
// Hint/footer
|
||||
public static GUIStyle Hint { get; private set; }
|
||||
public static GUIStyle Footer { get; private set; }
|
||||
|
||||
// Scroll
|
||||
public static GUIStyle ScrollView { get; private set; }
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// INIT
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
if (_inited) return;
|
||||
_inited = true;
|
||||
|
||||
// --- Textures ---
|
||||
TexWindowBg = MakeTex(ColWindowBg);
|
||||
TexFrameBg = MakeTex(ColFrameBg);
|
||||
TexFrameHover = MakeTex(ColFrameHover);
|
||||
TexHeader = MakeTex(ColHeader);
|
||||
TexHeaderHover = MakeTex(ColHeaderHover);
|
||||
TexButton = MakeTex(ColButton);
|
||||
TexButtonHover = MakeTex(ColButtonHover);
|
||||
TexButtonActive= MakeTex(ColButtonActive);
|
||||
TexAccent = MakeTex(ColAccent);
|
||||
TexAccentDark = MakeTex(ColAccentDark);
|
||||
TexBorder = MakeTex(ColBorder);
|
||||
TexOverlay = MakeTex(ColOverlay);
|
||||
TexTransparent = MakeTex(Color.clear);
|
||||
TexHudStrip = MakeTex(new Color(0.04f, 0.04f, 0.08f, 0.85f));
|
||||
}
|
||||
|
||||
/// <summary>Call at the top of every OnGUI that uses this skin.</summary>
|
||||
public static void EnsureReady()
|
||||
{
|
||||
if (!_inited) Init();
|
||||
// Build styles lazily (needs GUI.skin to exist, so must be inside OnGUI)
|
||||
if (WindowTitle == null) BuildStyles();
|
||||
}
|
||||
|
||||
static void BuildStyles()
|
||||
{
|
||||
var pad4 = new RectOffset(4, 4, 2, 2);
|
||||
var pad6 = new RectOffset(6, 6, 4, 4);
|
||||
var pad8 = new RectOffset(8, 8, 4, 4);
|
||||
|
||||
// ── Window Title ──
|
||||
WindowTitle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = 22,
|
||||
fontStyle = FontStyle.Bold,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
padding = pad6,
|
||||
richText = true
|
||||
};
|
||||
WindowTitle.normal.textColor = ColAccent;
|
||||
|
||||
WindowSubtitle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = 13,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
padding = pad4,
|
||||
};
|
||||
WindowSubtitle.normal.textColor = ColTextDim;
|
||||
|
||||
// ── Section Header ──
|
||||
SectionHeader = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = 12,
|
||||
fontStyle = FontStyle.Bold,
|
||||
padding = new RectOffset(6, 4, 4, 4),
|
||||
margin = new RectOffset(0, 0, 6, 2),
|
||||
richText = true,
|
||||
};
|
||||
SectionHeader.normal.background = TexHeader;
|
||||
SectionHeader.normal.textColor = ColAccent;
|
||||
|
||||
// ── Labels ──
|
||||
Label = new GUIStyle(GUI.skin.label) { fontSize = 13, richText = true };
|
||||
Label.normal.textColor = ColText;
|
||||
|
||||
LabelDim = new GUIStyle(Label);
|
||||
LabelDim.normal.textColor = ColTextDim;
|
||||
|
||||
LabelBold = new GUIStyle(Label) { fontStyle = FontStyle.Bold };
|
||||
|
||||
LabelCenter = new GUIStyle(Label) { alignment = TextAnchor.MiddleCenter };
|
||||
|
||||
LabelRich = new GUIStyle(Label) { richText = true, wordWrap = true };
|
||||
|
||||
// ── Field key/value ──
|
||||
FieldKey = new GUIStyle(Label) { fontSize = 12 };
|
||||
FieldKey.normal.textColor = ColTextDim;
|
||||
|
||||
FieldValue = new GUIStyle(Label) { fontSize = 12 };
|
||||
|
||||
// ── Buttons ──
|
||||
Button = new GUIStyle(GUI.skin.button)
|
||||
{
|
||||
fontSize = 13,
|
||||
fontStyle = FontStyle.Bold,
|
||||
padding = pad8,
|
||||
margin = new RectOffset(2, 2, 2, 2),
|
||||
border = new RectOffset(1, 1, 1, 1),
|
||||
};
|
||||
Button.normal.background = TexButton;
|
||||
Button.normal.textColor = ColText;
|
||||
Button.hover.background = TexButtonHover;
|
||||
Button.hover.textColor = Color.white;
|
||||
Button.active.background = TexButtonActive;
|
||||
Button.active.textColor = Color.white;
|
||||
Button.focused.background = TexButtonHover;
|
||||
|
||||
ButtonAccent = new GUIStyle(Button)
|
||||
{
|
||||
fontSize = 16,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
ButtonAccent.normal.background = TexAccentDark;
|
||||
ButtonAccent.normal.textColor = Color.white;
|
||||
ButtonAccent.hover.background = TexAccent;
|
||||
ButtonAccent.active.background = TexButtonActive;
|
||||
|
||||
ButtonSmall = new GUIStyle(Button) { fontSize = 11, padding = pad4 };
|
||||
|
||||
// ── TextField ──
|
||||
TextField = new GUIStyle(GUI.skin.textField)
|
||||
{
|
||||
fontSize = 14,
|
||||
padding = new RectOffset(8, 8, 6, 6),
|
||||
border = new RectOffset(2, 2, 2, 2),
|
||||
};
|
||||
TextField.normal.background = TexFrameBg;
|
||||
TextField.normal.textColor = ColText;
|
||||
TextField.focused.background = TexFrameHover;
|
||||
TextField.focused.textColor = Color.white;
|
||||
TextField.hover.background = TexFrameHover;
|
||||
// Cursor color follows textColor
|
||||
|
||||
// ── HUD Strip ──
|
||||
HudLabel = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
fontSize = 13,
|
||||
richText = true,
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
padding = new RectOffset(10, 10, 0, 0),
|
||||
};
|
||||
HudLabel.normal.textColor = ColText;
|
||||
|
||||
// ── Status ──
|
||||
StatusGreen = new GUIStyle(Label) { fontStyle = FontStyle.Bold };
|
||||
StatusGreen.normal.textColor = ColGreen;
|
||||
|
||||
StatusRed = new GUIStyle(Label) { fontStyle = FontStyle.Bold };
|
||||
StatusRed.normal.textColor = ColRed;
|
||||
|
||||
// ── Hint / Footer ──
|
||||
Hint = new GUIStyle(Label)
|
||||
{
|
||||
fontSize = 13,
|
||||
fontStyle = FontStyle.Italic,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
Hint.normal.textColor = ColYellow;
|
||||
|
||||
Footer = new GUIStyle(Label)
|
||||
{
|
||||
fontSize = 11,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
};
|
||||
Footer.normal.textColor = new Color(1, 1, 1, 0.3f);
|
||||
|
||||
// ── ScrollView ──
|
||||
ScrollView = new GUIStyle(GUI.skin.scrollView);
|
||||
ScrollView.normal.background = TexFrameBg;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// DRAWING HELPERS
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
/// <summary>Draw a full-screen darkened overlay.</summary>
|
||||
public static void DrawOverlay()
|
||||
{
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), TexOverlay);
|
||||
}
|
||||
|
||||
/// <summary>Draw a window/panel background with a subtle border.</summary>
|
||||
public static void DrawWindowBg(Rect rect)
|
||||
{
|
||||
// Border (1px)
|
||||
GUI.DrawTexture(new Rect(rect.x - 1, rect.y - 1, rect.width + 2, rect.height + 2), TexBorder);
|
||||
// Fill
|
||||
GUI.DrawTexture(rect, TexWindowBg);
|
||||
}
|
||||
|
||||
/// <summary>Draw the HUD strip background.</summary>
|
||||
public static void DrawHudStripBg(float height)
|
||||
{
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, height), TexHudStrip);
|
||||
}
|
||||
|
||||
/// <summary>Begin a centered window panel. Returns content Rect (inset by padding).</summary>
|
||||
public static Rect BeginWindow(float width, float height, string title)
|
||||
{
|
||||
float x = (Screen.width - width) / 2f;
|
||||
float y = (Screen.height - height) / 2f;
|
||||
return BeginWindowAt(x, y, width, height, title);
|
||||
}
|
||||
|
||||
/// <summary>Begin a window at a specific position.</summary>
|
||||
public static Rect BeginWindowAt(float x, float y, float width, float height, string title)
|
||||
{
|
||||
DrawWindowBg(new Rect(x, y, width, height));
|
||||
|
||||
// Title bar
|
||||
float titleH = 32;
|
||||
GUI.DrawTexture(new Rect(x, y, width, titleH), TexHeader);
|
||||
GUI.Label(new Rect(x, y, width, titleH), title, WindowTitle);
|
||||
|
||||
// Content area
|
||||
float pad = 16;
|
||||
Rect content = new(x + pad, y + titleH + 8, width - pad * 2, height - titleH - pad - 8);
|
||||
GUILayout.BeginArea(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <summary>End a window started with BeginWindow/BeginWindowAt.</summary>
|
||||
public static void EndWindow()
|
||||
{
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
|
||||
/// <summary>Draw a section header bar (e.g. "CONNECTION", "LOCAL PLAYER").</summary>
|
||||
public static void DrawSectionHeader(string text)
|
||||
{
|
||||
GUILayout.Label(text, SectionHeader);
|
||||
}
|
||||
|
||||
/// <summary>Draw a key-value field row.</summary>
|
||||
public static void DrawField(string key, string value)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(key + ":", FieldKey, GUILayout.Width(85));
|
||||
GUILayout.Label(value, FieldValue);
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
/// <summary>Draw a thin horizontal separator line.</summary>
|
||||
public static void Separator()
|
||||
{
|
||||
GUILayout.Space(4);
|
||||
Rect r = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, GUILayout.Height(1));
|
||||
GUI.DrawTexture(r, TexBorder);
|
||||
GUILayout.Space(4);
|
||||
}
|
||||
|
||||
// ─── Internal ───
|
||||
|
||||
static Texture2D MakeTex(Color c)
|
||||
{
|
||||
var t = new Texture2D(1, 1, TextureFormat.RGBA32, false);
|
||||
t.SetPixel(0, 0, c);
|
||||
t.Apply();
|
||||
t.hideFlags = HideFlags.HideAndDontSave;
|
||||
return t;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/UI/ImGuiSkin.cs.meta
Normal file
2
game/Assets/Scripts/UI/ImGuiSkin.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e92577bb278c4764cb7fd9810a56084b
|
||||
Reference in New Issue
Block a user