522 lines
16 KiB
C#
522 lines
16 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using KAsyncEngineLib;
|
|
|
|
namespace Tornado3_2026Election.Services;
|
|
|
|
public sealed class TornadoManager : IDisposable
|
|
{
|
|
private static readonly TimeSpan AutoReconnectInterval = TimeSpan.FromSeconds(5);
|
|
|
|
private readonly string _host;
|
|
private readonly int _port;
|
|
private readonly LogService _logService;
|
|
private readonly StaDispatcher _dispatcher;
|
|
private readonly Timer _reconnectTimer;
|
|
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
|
|
|
|
private IKAEngine? _engine;
|
|
private KAEventHandler? _eventHandler;
|
|
private bool _connected;
|
|
private bool _isTcpConnected;
|
|
private bool _autoReconnectEnabled;
|
|
private bool _disposed;
|
|
private int _reconnectInProgress;
|
|
|
|
public bool IsConnected => _isTcpConnected;
|
|
|
|
public event EventHandler? ConnectionChanged;
|
|
|
|
public TornadoManager(string host, int port, LogService logService)
|
|
{
|
|
_host = string.IsNullOrWhiteSpace(host)
|
|
? throw new ArgumentException("Host is required.", nameof(host))
|
|
: host;
|
|
_port = port > 0
|
|
? port
|
|
: throw new ArgumentOutOfRangeException(nameof(port), "Port must be a positive integer.");
|
|
_logService = logService;
|
|
_dispatcher = new StaDispatcher("Karisma Async Engine");
|
|
_reconnectTimer = new Timer(OnReconnectTimerTick, null, AutoReconnectInterval, AutoReconnectInterval);
|
|
}
|
|
|
|
public Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() => EnsureConnectedCore(), cancellationToken);
|
|
}
|
|
|
|
public Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
if (string.IsNullOrWhiteSpace(scenePath))
|
|
{
|
|
throw new ArgumentException("Scene path is required.", nameof(scenePath));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sceneAlias))
|
|
{
|
|
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
|
}
|
|
|
|
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
|
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return sceneAlias;
|
|
}
|
|
|
|
var forceReload = _scenePaths.ContainsKey(sceneAlias);
|
|
var scene = forceReload
|
|
? _engine!.LoadSceneForce(scenePath, sceneAlias)
|
|
: _engine!.LoadScene(scenePath, sceneAlias);
|
|
|
|
_logService.Info(
|
|
$"Karisma {(forceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
|
|
|
|
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
|
_scenePaths[sceneAlias] = scenePath;
|
|
_logService.Info($"Karisma scene loaded: alias={sceneAlias} path={scenePath}");
|
|
return sceneAlias;
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task ApplyValuesAsync(string sceneAlias, IReadOnlyDictionary<string, string> values, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scene = GetSceneCore(sceneAlias);
|
|
_engine!.BeginTransaction();
|
|
try
|
|
{
|
|
foreach (var pair in values)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(pair.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(pair.Key);
|
|
if (sceneObject is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
sceneObject.SetValue(pair.Value ?? string.Empty);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_engine!.EndTransaction();
|
|
}
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
|
if (cutIn)
|
|
{
|
|
scenePlayer.CutIn(layerNo);
|
|
return;
|
|
}
|
|
|
|
scenePlayer.Play(layerNo);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
|
if (cutOut)
|
|
{
|
|
scenePlayer.CutOut(layerNo);
|
|
return;
|
|
}
|
|
|
|
scenePlayer.PlayOut(layerNo);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
if (string.IsNullOrWhiteSpace(animationName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
GetScenePlayerCore(outputChannelIndex).Trigger(layerNo, animationName);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_autoReconnectEnabled = false;
|
|
_reconnectTimer.Dispose();
|
|
|
|
try
|
|
{
|
|
_dispatcher.InvokeAsync(() =>
|
|
{
|
|
foreach (var scene in _scenes.Values)
|
|
{
|
|
try
|
|
{
|
|
scene.UnloadScene();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_connected)
|
|
{
|
|
_engine?.Disconnect();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
_scenes.Clear();
|
|
_scenePaths.Clear();
|
|
_scenePlayers.Clear();
|
|
_engine = null;
|
|
_eventHandler = null;
|
|
_connected = false;
|
|
}, CancellationToken.None).GetAwaiter().GetResult();
|
|
}
|
|
finally
|
|
{
|
|
_dispatcher.Dispose();
|
|
}
|
|
}
|
|
|
|
private void EnsureConnectedCore()
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
if (_connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_engine ??= (IKAEngine)new KAEngineClass();
|
|
_eventHandler ??= new KarismaEventHandler(_logService, HandleConnect, HandleClose);
|
|
_autoReconnectEnabled = true;
|
|
|
|
var requested = _engine.Connect(_host, _port, _eventHandler);
|
|
_logService.Info($"Karisma Connect() return={(requested != 0 ? "TRUE" : "FALSE")} raw={requested} target={_host}:{_port}");
|
|
if (requested == 0)
|
|
{
|
|
throw new InvalidOperationException($"Karisma Async Engine connection request failed: {_host}:{_port}");
|
|
}
|
|
|
|
_connected = true;
|
|
_logService.Info($"Karisma Async Engine connect requested: {_host}:{_port}");
|
|
}
|
|
|
|
private void HandleConnect(int errorCode)
|
|
{
|
|
if (errorCode == 0)
|
|
{
|
|
UpdateTcpConnectionState(true);
|
|
return;
|
|
}
|
|
|
|
_connected = false;
|
|
UpdateTcpConnectionState(false);
|
|
}
|
|
|
|
private void HandleClose(int errorCode)
|
|
{
|
|
_connected = false;
|
|
UpdateTcpConnectionState(false);
|
|
}
|
|
|
|
private void UpdateTcpConnectionState(bool isConnected)
|
|
{
|
|
if (_isTcpConnected == isConnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isTcpConnected = isConnected;
|
|
ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private void OnReconnectTimerTick(object? state)
|
|
{
|
|
if (_disposed || !_autoReconnectEnabled || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref _reconnectInProgress, 1) == 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = TryAutoReconnectAsync();
|
|
}
|
|
|
|
private async Task TryAutoReconnectAsync()
|
|
{
|
|
try
|
|
{
|
|
if (_disposed || !_autoReconnectEnabled || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
if (_disposed || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
EnsureConnectedCore();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning($"Karisma auto reconnect attempt failed: {ex.Message}");
|
|
}
|
|
}, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _reconnectInProgress, 0);
|
|
}
|
|
}
|
|
|
|
private KAScene GetSceneCore(string sceneAlias)
|
|
{
|
|
if (_scenes.TryGetValue(sceneAlias, out var scene))
|
|
{
|
|
return scene;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Karisma scene is not loaded: {sceneAlias}");
|
|
}
|
|
|
|
private KAScenePlayer GetScenePlayerCore(int outputChannelIndex)
|
|
{
|
|
if (_scenePlayers.TryGetValue(outputChannelIndex, out var scenePlayer))
|
|
{
|
|
return scenePlayer;
|
|
}
|
|
|
|
scenePlayer = _engine!.GetScenePlayer(outputChannelIndex)
|
|
?? throw new InvalidOperationException($"Karisma scene player is unavailable: output={outputChannelIndex}");
|
|
_scenePlayers[outputChannelIndex] = scenePlayer;
|
|
return scenePlayer;
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
}
|
|
|
|
private sealed class StaDispatcher : IDisposable
|
|
{
|
|
private const uint WmApp = 0x8000;
|
|
private const uint WmDispatch = WmApp + 1;
|
|
|
|
private readonly ConcurrentQueue<Action> _queue = new();
|
|
private readonly Thread _thread;
|
|
private readonly ManualResetEventSlim _threadReady = new();
|
|
private bool _disposed;
|
|
private uint _threadId;
|
|
|
|
public StaDispatcher(string threadName)
|
|
{
|
|
_thread = new Thread(Run)
|
|
{
|
|
IsBackground = true,
|
|
Name = threadName
|
|
};
|
|
_thread.SetApartmentState(ApartmentState.STA);
|
|
_thread.Start();
|
|
_threadReady.Wait();
|
|
}
|
|
|
|
public Task InvokeAsync(Action action, CancellationToken cancellationToken)
|
|
{
|
|
return InvokeAsync(() =>
|
|
{
|
|
action();
|
|
return true;
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<T> InvokeAsync<T>(Func<T> action, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_queue.Enqueue(() =>
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
completion.TrySetCanceled(cancellationToken);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
completion.TrySetResult(action());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
completion.TrySetException(ex);
|
|
}
|
|
});
|
|
|
|
if (!PostThreadMessage(_threadId, WmDispatch, UIntPtr.Zero, IntPtr.Zero))
|
|
{
|
|
throw new InvalidOperationException("Failed to signal Karisma STA dispatcher.");
|
|
}
|
|
|
|
return cancellationToken.CanBeCanceled
|
|
? completion.Task.WaitAsync(cancellationToken)
|
|
: completion.Task;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero);
|
|
_thread.Join();
|
|
_threadReady.Dispose();
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
PeekMessage(out _, IntPtr.Zero, 0, 0, 0);
|
|
_threadId = GetCurrentThreadId();
|
|
_threadReady.Set();
|
|
|
|
while (GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
|
{
|
|
if (message.message == WmDispatch)
|
|
{
|
|
DrainQueue();
|
|
continue;
|
|
}
|
|
|
|
TranslateMessage(ref message);
|
|
DispatchMessage(ref message);
|
|
}
|
|
|
|
DrainQueue();
|
|
}
|
|
|
|
private void DrainQueue()
|
|
{
|
|
while (_queue.TryDequeue(out var action))
|
|
{
|
|
action();
|
|
}
|
|
}
|
|
|
|
private const uint WmQuit = 0x0012;
|
|
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint GetCurrentThreadId();
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern int GetMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool TranslateMessage([In] ref NativeMessage lpMsg);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr DispatchMessage([In] ref NativeMessage lpMsg);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool PeekMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct NativeMessage
|
|
{
|
|
public IntPtr hwnd;
|
|
public uint message;
|
|
public UIntPtr wParam;
|
|
public IntPtr lParam;
|
|
public uint time;
|
|
public NativePoint pt;
|
|
public uint lPrivate;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct NativePoint
|
|
{
|
|
public int x;
|
|
public int y;
|
|
}
|
|
}
|
|
}
|