465 lines
16 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
using WebSocketSharp;
using WebSocketSharp.Server;
namespace Streamingle.Contents.BossRaid
{
/// <summary>
/// BossRaid 웹 제어판 서버.
/// HTTP로 UI를 서빙하고, WebSocket으로 실시간 상태/제어를 처리합니다.
/// </summary>
public class BossRaidWebServer : MonoBehaviour
{
#region Fields
[Header("서버 설정")]
[SerializeField] private int httpPort = 64220;
[SerializeField] private int wsPort = 64221;
private HttpListener _httpListener;
private Thread _httpThread;
private WebSocketServer _wsServer;
private bool _isRunning;
private BossRaidManager _raidManager;
private BossController _bossController;
private BossRaidSafety _safety;
private BossRaidAudio _audio;
private readonly ConcurrentQueue<Action> _mainThreadActions = new ConcurrentQueue<Action>();
// 웹 리소스 (Resources 폴더에서 로드)
private string _htmlTemplate;
private string _cssContent;
private string _jsContent;
#endregion
#region Unity Messages
private void Awake()
{
_raidManager = GetComponentInParent<BossRaidManager>();
if (_raidManager == null)
_raidManager = GetComponent<BossRaidManager>();
_audio = GetComponentInParent<BossRaidAudio>();
if (_audio == null)
_audio = FindObjectOfType<BossRaidAudio>();
}
private void Start()
{
LoadWebResources();
StartServers();
}
private void Update()
{
// 메인 스레드 액션 처리
while (_mainThreadActions.TryDequeue(out var action))
{
try { action?.Invoke(); }
catch (Exception e) { Debug.LogError($"[BossRaidWeb] 메인 스레드 액션 오류: {e}"); }
}
}
private void OnDestroy()
{
StopServers();
}
#endregion
#region Public Methods
/// <summary>
/// 모든 클라이언트에 상태 업데이트를 브로드캐스트합니다.
/// </summary>
public void BroadcastState()
{
if (_wsServer == null || !_isRunning) return;
var state = BuildStateJson();
var json = JsonConvert.SerializeObject(new { type = "state_update", data = state });
try
{
_wsServer.WebSocketServices["/bossraid"].Sessions.Broadcast(json);
}
catch (Exception e)
{
Debug.LogWarning($"[BossRaidWeb] 브로드캐스트 실패: {e.Message}");
}
}
#endregion
#region Private Methods - Server
private void StartServers()
{
try
{
// === HTTP 서버 (기존 대시보드 서버와 동일 패턴) ===
_httpListener = new HttpListener();
bool wildcardBound = false;
try
{
_httpListener.Prefixes.Add($"http://+:{httpPort}/");
_httpListener.Start();
wildcardBound = true;
Debug.Log($"[BossRaidWeb] HTTP 와일드카드 바인딩 성공 (LAN 접속 가능)");
}
catch (Exception)
{
// 와일드카드 실패 시 리스너 재생성
try { _httpListener.Close(); } catch { }
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://localhost:{httpPort}/");
_httpListener.Prefixes.Add($"http://127.0.0.1:{httpPort}/");
string localIP = GetLocalIPAddress();
if (!string.IsNullOrEmpty(localIP))
{
try { _httpListener.Prefixes.Add($"http://{localIP}:{httpPort}/"); }
catch { }
}
_httpListener.Start();
Debug.Log($"[BossRaidWeb] HTTP localhost/127.0.0.1 바인딩 성공");
}
_isRunning = true;
_httpThread = new Thread(HttpListenerLoop) { IsBackground = true };
_httpThread.Start();
// === WebSocket 서버 ===
_wsServer = new WebSocketServer($"ws://0.0.0.0:{wsPort}");
_wsServer.KeepClean = true;
_wsServer.WaitTime = TimeSpan.FromSeconds(3);
_wsServer.AddWebSocketService<BossRaidWebSocketService>("/bossraid",
service => service.Initialize(this));
_wsServer.Start();
Debug.Log($"[BossRaidWeb] 서버 시작 - HTTP: http://localhost:{httpPort} | WS: ws://localhost:{wsPort}/bossraid");
// 이벤트 구독
SubscribeRaidEvents();
}
catch (Exception e)
{
Debug.LogError($"[BossRaidWeb] 서버 시작 실패: {e}");
}
}
private static string GetLocalIPAddress()
{
try
{
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
foreach (var addr in ni.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return addr.Address.ToString();
}
}
}
catch { }
return null;
}
private void StopServers()
{
_isRunning = false;
UnsubscribeRaidEvents();
try { _httpListener?.Stop(); } catch { }
try { _wsServer?.Stop(); } catch { }
Debug.Log("[BossRaidWeb] 서버 종료");
}
private void HttpListenerLoop()
{
while (_isRunning && _httpListener != null && _httpListener.IsListening)
{
try
{
var context = _httpListener.GetContext();
HandleHttpRequest(context);
}
catch (HttpListenerException) { break; }
catch (Exception e)
{
if (_isRunning) Debug.LogWarning($"[BossRaidWeb] HTTP 오류: {e.Message}");
}
}
}
private void HandleHttpRequest(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
string path = request.Url.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(path) || path == "/index.html") path = "/";
byte[] buffer;
string contentType;
switch (path)
{
case "/":
contentType = "text/html; charset=utf-8";
buffer = Encoding.UTF8.GetBytes(_htmlTemplate ?? GetFallbackHtml());
break;
case "/style.css":
contentType = "text/css; charset=utf-8";
buffer = Encoding.UTF8.GetBytes(_cssContent ?? "");
break;
case "/script.js":
contentType = "application/javascript; charset=utf-8";
string js = (_jsContent ?? "").Replace("{{WS_PORT}}", wsPort.ToString());
buffer = Encoding.UTF8.GetBytes(js);
break;
default:
response.StatusCode = 404;
buffer = Encoding.UTF8.GetBytes("Not Found");
contentType = "text/plain";
break;
}
response.ContentType = contentType;
response.Headers.Add("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
response.ContentLength64 = buffer.Length;
response.OutputStream.Write(buffer, 0, buffer.Length);
response.OutputStream.Close();
}
private void LoadWebResources()
{
var html = Resources.Load<TextAsset>("BossRaidWeb/bossraid_template");
var css = Resources.Load<TextAsset>("BossRaidWeb/bossraid_style");
var js = Resources.Load<TextAsset>("BossRaidWeb/bossraid_script");
_htmlTemplate = html != null ? html.text : GetFallbackHtml();
_cssContent = css != null ? css.text : "";
_jsContent = js != null ? js.text : "";
if (html == null) Debug.LogWarning("[BossRaidWeb] HTML 템플릿 로드 실패, 폴백 사용");
}
private string GetFallbackHtml()
{
return "<html><body><h1>BossRaid Web Controller</h1><p>Resources/BossRaidWeb/ 리소스를 찾을 수 없습니다.</p></body></html>";
}
#endregion
#region Private Methods - Events
private void SubscribeRaidEvents()
{
if (_raidManager == null) return;
_raidManager.OnRaidStateChanged += OnRaidStateChanged;
// Boss 이벤트는 StartRaid 후에 연결됨 — Update에서 동적으로 체크
InvokeRepeating(nameof(CheckBossEvents), 0.5f, 0.5f);
}
private void UnsubscribeRaidEvents()
{
if (_raidManager != null)
_raidManager.OnRaidStateChanged -= OnRaidStateChanged;
CancelInvoke(nameof(CheckBossEvents));
UnsubscribeBossEvents();
}
private void CheckBossEvents()
{
var newBoss = _raidManager?.Boss;
if (newBoss != null && newBoss != _bossController)
{
UnsubscribeBossEvents();
_bossController = newBoss;
_safety = _raidManager.Safety;
_bossController.OnHPChanged += OnHPChanged;
_bossController.OnDamaged += OnDamaged;
_bossController.OnPhaseChanged += OnPhaseChanged;
_bossController.OnDeath += OnBossDeath;
}
}
private void UnsubscribeBossEvents()
{
if (_bossController != null)
{
_bossController.OnHPChanged -= OnHPChanged;
_bossController.OnDamaged -= OnDamaged;
_bossController.OnPhaseChanged -= OnPhaseChanged;
_bossController.OnDeath -= OnBossDeath;
_bossController = null;
}
}
private void OnRaidStateChanged(BossRaidManager.RaidState state) => BroadcastState();
private void OnHPChanged(int current, int max) => BroadcastState();
private void OnDamaged(int dmg, bool crit, int hp, int max) => BroadcastState();
private void OnPhaseChanged(int idx, BossData.PhaseData phase) => BroadcastState();
private void OnBossDeath() => BroadcastState();
#endregion
#region Private Methods - State
private object BuildStateJson()
{
var boss = _raidManager?.Boss;
return new
{
raidState = _raidManager?.CurrentState.ToString() ?? "Idle",
bossName = boss?.Data?.bossName ?? "",
currentHP = boss?.CurrentHP ?? 0,
maxHP = boss?.MaxHP ?? 0,
hpRatio = boss?.HPRatio ?? 0f,
phase = boss?.CurrentPhaseIndex ?? 0,
phaseName = boss?.CurrentPhase?.phaseName ?? "",
bossState = boss?.CurrentState.ToString() ?? "",
isDead = boss?.IsDead ?? false,
isPaused = _safety?.IsPaused ?? false,
hpLockRatio = _safety?.HPLockRatio ?? 0f,
bgmVolume = _audio?.BGMVolume ?? 0.5f,
sfxVolume = _audio?.SFXVolume ?? 1f,
};
}
#endregion
#region Internal - WebSocket Message Handling
internal void HandleWebSocketMessage(string message)
{
try
{
var msg = JObject.Parse(message);
string action = msg["action"]?.ToString();
_mainThreadActions.Enqueue(() =>
{
switch (action)
{
case "start_raid":
_raidManager?.StartRaid();
break;
case "stop_raid":
_raidManager?.StopRaid();
break;
case "manual_hit":
int dmg = msg["damage"]?.Value<int>() ?? -1;
bool crit = msg["critical"]?.Value<bool>() ?? false;
_raidManager?.ManualHit(dmg, crit);
break;
case "auto_hit":
_raidManager?.ManualHit();
break;
case "toggle_pause":
_raidManager?.TogglePause();
break;
case "set_hp":
float ratio = msg["ratio"]?.Value<float>() ?? 1f;
_raidManager?.SetBossHP(ratio);
break;
case "force_phase":
int phase = msg["phase"]?.Value<int>() ?? 0;
_raidManager?.ForcePhase(phase);
break;
case "force_kill":
_raidManager?.ForceKill();
break;
case "set_hp_lock":
float lockRatio = msg["ratio"]?.Value<float>() ?? 0f;
if (_safety != null) _safety.HPLockRatio = lockRatio;
BroadcastState();
break;
case "set_cooldown":
float cd = msg["value"]?.Value<float>() ?? 0.3f;
if (_safety != null) _safety.HitCooldown = cd;
break;
case "set_damage_cap":
int cap = msg["value"]?.Value<int>() ?? 0;
if (_safety != null) _safety.MaxDamagePerHit = cap;
break;
case "set_bgm_volume":
float bgmVol = msg["value"]?.Value<float>() ?? 0.5f;
if (_audio != null) _audio.BGMVolume = bgmVol;
BroadcastState();
break;
case "set_sfx_volume":
float sfxVol = msg["value"]?.Value<float>() ?? 1f;
if (_audio != null) _audio.SFXVolume = sfxVol;
BroadcastState();
break;
case "get_state":
BroadcastState();
break;
default:
Debug.LogWarning($"[BossRaidWeb] 알 수 없는 액션: {action}");
break;
}
});
}
catch (Exception e)
{
Debug.LogError($"[BossRaidWeb] 메시지 파싱 오류: {e.Message}");
}
}
#endregion
}
#region WebSocket Service
public class BossRaidWebSocketService : WebSocketBehavior
{
private BossRaidWebServer _server;
public void Initialize(BossRaidWebServer server)
{
_server = server;
}
protected override void OnOpen()
{
Debug.Log("[BossRaidWeb] 클라이언트 연결");
_server?.BroadcastState();
}
protected override void OnMessage(MessageEventArgs e)
{
_server?.HandleWebSocketMessage(e.Data);
}
protected override void OnClose(CloseEventArgs e)
{
Debug.Log($"[BossRaidWeb] 클라이언트 연결 해제: {e.Reason}");
}
}
#endregion
}