Files
rolld/game/Assets/Scripts/UI/ChatUI.cs
kerboul 103f8859d4 feat(Car): wire NWH Vehicle Physics 2 — scripts only, scene/prefabs still TODO
Code-only first pass. The local Player.prefab (ball) is still in the scene
until the user creates the PlayerCar / RemoteCar prefab variants in the Editor.

New scripts:
- VehicleLocalSetup: attaches name label + colored capsule marker to the local
  vehicle, registers its Rigidbody with NetworkManager for broadcast.
- RemoteVehicleSync: snapshot interpolation for remote vehicles. Disables the
  NWH VehicleController + every WheelController + every AudioSource on init.
  Makes the rigidbody kinematic, MovePosition-driven from network. Disables all
  non-trigger colliders during the 1.5s spawn grace window to avoid ejecting
  overlapping locals at connect.

Adapted scripts:
- NetworkManager: RegisterLocalVehicle(Transform, Rigidbody) replaces the
  FindFirstObjectByType<PlayerController>() lookup. Remote spawn now wires
  RemoteVehicleSync instead of RemotePlayerController.
- LobbyUI: OnConnected drives VehicleLocalSetup.SetupLocal; OnDisconnected
  toggles NWH.VehicleController instead of PlayerController.
- GameManager.SetPlayerActive: toggles VehicleController, not PlayerController.
- DebugNetworkUI: live position read from VehicleController.vehicleRigidbody.
- ChatUI: drops PlayerController.ResetInputs() (NWH polls Input System each
  frame; no manual reset needed when chat opens).
- StatsTracker: drops dead _pc field; Rigidbody still gets resolved via
  GetComponent on the host GameObject (will be the vehicle on Car).

Frontend (deployed earlier on master): build_ball replaces pretty_build assets.
2026-05-20 18:34:40 +02:00

270 lines
9.5 KiB
C#

using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Networking;
/// <summary>
/// General chat panel. T to open, Escape to close.
/// Polls GET /chat/history every 3s and sends via POST /chat/send (or Colyseus if connected).
/// Uses ImGuiSkin for visual consistency.
/// </summary>
public class ChatUI : MonoBehaviour
{
public static ChatUI Instance { get; private set; }
public static bool IsVisible { get; private set; }
private const string SERVER_URL = "https://game.rolld.kerboul.me";
private const float POLL_INTERVAL = 3f;
private const int MAX_DISPLAY = 50;
private bool _visible;
private string _inputText = "";
private Vector2 _scrollPos;
private float _pollTimer;
private long _lastTimestamp;
private bool _autoScroll = true;
private readonly List<ChatMessage> _messages = new();
private int _unreadCount;
// Cached textures for badge
private static Texture2D _badgeTex;
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
// Initial load
StartCoroutine(DoPoll());
}
void Update()
{
if (Keyboard.current != null)
{
if (!_visible && Keyboard.current[Key.T].wasPressedThisFrame)
Toggle();
else if (_visible && Keyboard.current[Key.Escape].wasPressedThisFrame)
Toggle();
}
if (_visible)
{
_pollTimer += Time.deltaTime;
if (_pollTimer >= POLL_INTERVAL) { _pollTimer = 0f; StartCoroutine(DoPoll()); }
// Send on Enter (only when chat input has text)
if (!string.IsNullOrWhiteSpace(_inputText) &&
Keyboard.current != null && Keyboard.current[Key.Enter].wasPressedThisFrame)
{
TrySend();
}
}
}
private void Toggle()
{
_visible = !_visible;
IsVisible = _visible;
if (_visible)
{
_unreadCount = 0;
_autoScroll = true;
_pollTimer = POLL_INTERVAL; // poll immediately
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
// Movement keys are handled by NWH InputSystemVehicleInputProvider via Input System;
// no manual reset needed when chat opens (NWH polls Input System each frame).
}
else
{
// Only re-lock if no other UI is open
if (!KeyBindingUI.IsVisible)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
}
void OnGUI()
{
if (!_visible)
{
DrawBadge();
return;
}
ImGuiSkin.EnsureReady();
// Panel bottom-right, doesn't obstruct the center
float w = 460f;
float h = 440f;
float x = Screen.width - w - 12f;
float y = Screen.height - h - 12f;
ImGuiSkin.BeginWindowAt(x, y, w, h, "CHAT GÉNÉRAL");
// ── Message history ───────────────────────────────────────────
float listH = h - 130f;
_scrollPos = GUILayout.BeginScrollView(_scrollPos, ImGuiSkin.ScrollView, GUILayout.Height(listH));
foreach (var msg in _messages)
{
var ts = System.DateTimeOffset.FromUnixTimeMilliseconds(msg.timestamp).ToLocalTime();
string timeStr = ts.ToString("HH:mm");
var timeStyle = new GUIStyle(ImGuiSkin.LabelDim) { fontSize = 10, fixedWidth = 36f };
var nameStyle = new GUIStyle(ImGuiSkin.LabelBold);
nameStyle.normal.textColor = ImGuiSkin.ColAccent;
var textStyle = new GUIStyle(ImGuiSkin.LabelRich);
GUILayout.BeginHorizontal();
GUILayout.Label(timeStr, timeStyle);
GUILayout.Label(msg.name + " :", nameStyle, GUILayout.Width(100f));
GUILayout.Label(msg.text, textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(1f);
}
if (_autoScroll) _scrollPos.y = float.MaxValue;
GUILayout.EndScrollView();
ImGuiSkin.Separator();
GUILayout.Space(4f);
// ── Input row ────────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUI.SetNextControlName("ChatInput");
_inputText = GUILayout.TextField(_inputText, 200, ImGuiSkin.TextField, GUILayout.Height(28f));
bool canSend = !string.IsNullOrWhiteSpace(_inputText) && PlayerName.Length > 0;
if (PlayerName.Length == 0)
GUILayout.Label("Connectez-vous au jeu pour envoyer des messages.", ImGuiSkin.LabelDim);
GUI.enabled = canSend;
if (GUILayout.Button("Envoyer", ImGuiSkin.Button, GUILayout.Width(80f), GUILayout.Height(28f)))
TrySend();
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4f);
GUILayout.Label("T — Ouvrir · Échap — Fermer · Entrée — Envoyer", ImGuiSkin.Footer);
ImGuiSkin.EndWindow();
// Auto-focus input field
GUI.FocusControl("ChatInput");
}
private void DrawBadge()
{
if (_unreadCount <= 0) return;
if (_badgeTex == null)
{
_badgeTex = new Texture2D(1, 1);
_badgeTex.SetPixel(0, 0, Color.white);
_badgeTex.Apply();
}
float bx = Screen.width - 68f;
float by = Screen.height - 32f;
GUI.color = new Color(0.9f, 0.2f, 0.2f, 0.9f);
GUI.DrawTexture(new Rect(bx, by, 56f, 22f), _badgeTex);
GUI.color = Color.white;
var s = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11, fontStyle = FontStyle.Bold };
s.normal.textColor = Color.white;
GUI.Label(new Rect(bx, by, 56f, 22f), $"💬 {_unreadCount}", s);
}
// ─── Send ────────────────────────────────────────────────────────────
private string PlayerName
{
get
{
var nm = NetworkManager.Instance?.LocalPlayerName;
if (!string.IsNullOrEmpty(nm)) return nm;
return PlayerPrefs.GetString("rolld_player_name", "");
}
}
private void TrySend()
{
string text = _inputText.Trim();
if (string.IsNullOrEmpty(text) || PlayerName.Length == 0) return;
_inputText = "";
_autoScroll = true;
// Always use HTTP — the server endpoint broadcasts to Colyseus rooms anyway
StartCoroutine(DoSend(PlayerName, text));
}
// ─── HTTP polling ─────────────────────────────────────────────────────
private IEnumerator DoPoll()
{
string url = $"{SERVER_URL}/chat/history?since={_lastTimestamp}";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) yield break;
var wrapper = JsonUtility.FromJson<MessageListWrapper>($"{{\"items\":{req.downloadHandler.text}}}");
if (wrapper?.items == null) yield break;
int added = 0;
foreach (var msg in wrapper.items)
{
if (msg.timestamp > _lastTimestamp)
{
_messages.Add(msg);
_lastTimestamp = msg.timestamp;
added++;
}
}
if (_messages.Count > MAX_DISPLAY)
_messages.RemoveRange(0, _messages.Count - MAX_DISPLAY);
if (added > 0 && !_visible)
_unreadCount += added;
}
private IEnumerator DoSend(string name, string text)
{
var payload = new SendPayload { name = name, text = text };
string json = JsonUtility.ToJson(payload);
byte[] body = Encoding.UTF8.GetBytes(json);
using var req = new UnityWebRequest($"{SERVER_URL}/chat/send", "POST");
req.uploadHandler = new UploadHandlerRaw(body);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
// Immediate poll so the message appears right away
StartCoroutine(DoPoll());
}
// Called by NetworkManager when a "chat" message arrives via Colyseus
public void ReceiveChatMessage(ChatMessage msg)
{
if (_messages.Count >= MAX_DISPLAY)
_messages.RemoveAt(0);
_messages.Add(msg);
if (msg.timestamp > _lastTimestamp) _lastTimestamp = msg.timestamp;
if (!_visible) _unreadCount++;
_autoScroll = true;
}
// ─── DTOs ─────────────────────────────────────────────────────────────
[System.Serializable]
public class ChatMessage { public int id; public long timestamp; public string name; public string text; }
[System.Serializable]
private class SendPayload { public string name; public string text; }
[System.Serializable]
private class MessageListWrapper { public List<ChatMessage> items; }
}