demo
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class ReplacedBattlePhaseMotion : MonoBehaviour
|
||||
{
|
||||
private RectTransform? _panel;
|
||||
private CanvasGroup? _group;
|
||||
private Image? _background;
|
||||
private Image? _accent;
|
||||
private TMP_Text? _title;
|
||||
private TMP_Text? _detail;
|
||||
|
||||
public bool IsVisible => _panel != null && _panel.gameObject.activeSelf;
|
||||
public string CurrentTitle => _title?.text ?? string.Empty;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
CreateOverlay();
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
public IEnumerator PlayStage(string title, string detail, Color accent, float holdSeconds = .68f)
|
||||
{
|
||||
if (!Prepare(title, detail, accent)) yield break;
|
||||
yield return FadeIn();
|
||||
yield return new WaitForSecondsRealtime(holdSeconds);
|
||||
yield return FadeOut();
|
||||
}
|
||||
|
||||
public IEnumerator PlayDie(string title, int finalFace, string result, Color accent)
|
||||
{
|
||||
if (!Prepare(title, "D6 ·", accent)) yield break;
|
||||
yield return FadeIn();
|
||||
for (var frame = 0; frame < 8; frame++)
|
||||
{
|
||||
if (_detail != null) _detail.text = "D6 " + ((frame * 5 + 2) % 6 + 1);
|
||||
if (_panel != null) _panel.localRotation = Quaternion.Euler(0, 0, frame % 2 == 0 ? -1.2f : 1.2f);
|
||||
yield return new WaitForSecondsRealtime(.065f);
|
||||
}
|
||||
if (_panel != null) _panel.localRotation = Quaternion.identity;
|
||||
if (_detail != null) _detail.text = $"D6 {finalFace}\n{result}";
|
||||
yield return new WaitForSecondsRealtime(.75f);
|
||||
yield return FadeOut();
|
||||
}
|
||||
|
||||
private bool Prepare(string title, string detail, Color accent)
|
||||
{
|
||||
if (_panel == null || _group == null || _background == null || _accent == null || _title == null || _detail == null)
|
||||
return false;
|
||||
_title.text = title;
|
||||
_detail.text = detail;
|
||||
_accent.color = accent;
|
||||
_background.color = new Color(.035f, .041f, .043f, .96f);
|
||||
_panel.localScale = Vector3.one * .92f;
|
||||
_panel.localRotation = Quaternion.identity;
|
||||
_group.alpha = 0f;
|
||||
SetVisible(true);
|
||||
_panel.SetAsLastSibling();
|
||||
return true;
|
||||
}
|
||||
|
||||
private IEnumerator FadeIn()
|
||||
{
|
||||
if (_panel == null || _group == null) yield break;
|
||||
var elapsed = 0f;
|
||||
const float duration = .16f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = 1f - Mathf.Pow(1f - Mathf.Clamp01(elapsed / duration), 3f);
|
||||
_group.alpha = t;
|
||||
_panel.localScale = Vector3.one * Mathf.Lerp(.92f, 1f, t);
|
||||
yield return null;
|
||||
}
|
||||
_group.alpha = 1f;
|
||||
_panel.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
private IEnumerator FadeOut()
|
||||
{
|
||||
if (_panel == null || _group == null) yield break;
|
||||
var elapsed = 0f;
|
||||
const float duration = .16f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
_group.alpha = 1f - t;
|
||||
_panel.localScale = Vector3.one * Mathf.Lerp(1f, .97f, t);
|
||||
yield return null;
|
||||
}
|
||||
SetVisible(false);
|
||||
}
|
||||
|
||||
private void CreateOverlay()
|
||||
{
|
||||
var panelObject = new GameObject("ResolutionPhaseOverlay", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup));
|
||||
_panel = panelObject.GetComponent<RectTransform>();
|
||||
_panel.SetParent(transform, false);
|
||||
_panel.anchorMin = new Vector2(.25f, .37f);
|
||||
_panel.anchorMax = new Vector2(.75f, .64f);
|
||||
_panel.offsetMin = Vector2.zero;
|
||||
_panel.offsetMax = Vector2.zero;
|
||||
_background = panelObject.GetComponent<Image>();
|
||||
_background.raycastTarget = false;
|
||||
_group = panelObject.GetComponent<CanvasGroup>();
|
||||
_group.blocksRaycasts = false;
|
||||
_group.interactable = false;
|
||||
|
||||
var accentObject = new GameObject("Accent", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
|
||||
var accentRect = accentObject.GetComponent<RectTransform>();
|
||||
accentRect.SetParent(_panel, false);
|
||||
accentRect.anchorMin = new Vector2(0, 0);
|
||||
accentRect.anchorMax = new Vector2(0, 1);
|
||||
accentRect.pivot = new Vector2(0, .5f);
|
||||
accentRect.sizeDelta = new Vector2(9, 0);
|
||||
_accent = accentObject.GetComponent<Image>();
|
||||
_accent.raycastTarget = false;
|
||||
|
||||
var font = GetComponentInChildren<TMP_Text>(true)?.font;
|
||||
_title = CreateText("Title", _panel, font, new Vector2(.08f, .55f), new Vector2(.92f, .92f), 31, FontStyles.Bold);
|
||||
_detail = CreateText("Detail", _panel, font, new Vector2(.08f, .10f), new Vector2(.92f, .58f), 22, FontStyles.Normal);
|
||||
_detail.enableAutoSizing = true;
|
||||
_detail.fontSizeMin = 14;
|
||||
_detail.fontSizeMax = 22;
|
||||
}
|
||||
|
||||
private static TMP_Text CreateText(string name, Transform parent, TMP_FontAsset? font, Vector2 anchorMin,
|
||||
Vector2 anchorMax, float fontSize, FontStyles style)
|
||||
{
|
||||
var textObject = new GameObject(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
|
||||
var rect = textObject.GetComponent<RectTransform>();
|
||||
rect.SetParent(parent, false);
|
||||
rect.anchorMin = anchorMin;
|
||||
rect.anchorMax = anchorMax;
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
var text = textObject.GetComponent<TextMeshProUGUI>();
|
||||
text.font = font;
|
||||
text.fontSize = fontSize;
|
||||
text.fontStyle = style;
|
||||
text.alignment = TextAlignmentOptions.Center;
|
||||
text.color = new Color(.95f, .93f, .87f, 1f);
|
||||
text.raycastTarget = false;
|
||||
return text;
|
||||
}
|
||||
|
||||
private void SetVisible(bool visible)
|
||||
{
|
||||
if (_panel != null) _panel.gameObject.SetActive(visible);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_panel != null)
|
||||
{
|
||||
_panel.localScale = Vector3.one;
|
||||
_panel.localRotation = Quaternion.identity;
|
||||
}
|
||||
if (_group != null) _group.alpha = 0f;
|
||||
SetVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e059a88be16f0c458e037d5dba90bb6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,125 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[RequireComponent(typeof(RectTransform), typeof(Button))]
|
||||
public sealed class ReplacedCardDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
private Canvas? _canvas;
|
||||
private RectTransform? _ghost;
|
||||
private ReplacedCardMotion? _motion;
|
||||
private Action<Vector2>? _dropAction;
|
||||
private Vector2 _dragStart;
|
||||
private bool _hasDragStart;
|
||||
|
||||
public void Configure(Action<Vector2> dropAction)
|
||||
{
|
||||
_dropAction = dropAction;
|
||||
}
|
||||
|
||||
public void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
CleanupGhost();
|
||||
_dragStart = eventData.position;
|
||||
_hasDragStart = true;
|
||||
_canvas = GetComponentInParent<Canvas>()?.rootCanvas;
|
||||
if (_canvas == null) return;
|
||||
_motion = GetComponent<ReplacedCardMotion>();
|
||||
_motion?.SetDragging(true);
|
||||
_ghost = CreateGhost(_canvas.transform);
|
||||
UpdateGhostPosition(eventData);
|
||||
}
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
UpdateGhostPosition(eventData);
|
||||
}
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
var moved = _hasDragStart && (eventData.position - _dragStart).sqrMagnitude >= 100f;
|
||||
_motion?.SetDragging(false);
|
||||
_motion = null;
|
||||
CleanupGhost();
|
||||
_hasDragStart = false;
|
||||
if (moved && _dropAction != null) _dropAction(eventData.position);
|
||||
else GetComponent<Button>().onClick.Invoke();
|
||||
}
|
||||
|
||||
private RectTransform CreateGhost(Transform canvasRoot)
|
||||
{
|
||||
var sourceRect = (RectTransform)transform;
|
||||
var sourceImage = GetComponent<Image>();
|
||||
var sourceLabel = transform.Find("Label")?.GetComponent<TMP_Text>();
|
||||
var ghostObject = new GameObject("CardDragGhost", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup));
|
||||
var ghostRect = ghostObject.GetComponent<RectTransform>();
|
||||
ghostRect.SetParent(canvasRoot, false);
|
||||
ghostRect.anchorMin = ghostRect.anchorMax = new Vector2(.5f, .5f);
|
||||
ghostRect.pivot = sourceRect.pivot;
|
||||
ghostRect.sizeDelta = sourceRect.rect.size;
|
||||
ghostRect.localScale = Vector3.one * 1.06f;
|
||||
ghostRect.SetAsLastSibling();
|
||||
|
||||
var ghostImage = ghostObject.GetComponent<Image>();
|
||||
ghostImage.sprite = sourceImage.sprite;
|
||||
ghostImage.type = sourceImage.type;
|
||||
ghostImage.color = sourceImage.color;
|
||||
ghostImage.material = sourceImage.material;
|
||||
ghostImage.raycastTarget = false;
|
||||
var group = ghostObject.GetComponent<CanvasGroup>();
|
||||
group.alpha = .94f;
|
||||
group.blocksRaycasts = false;
|
||||
group.interactable = false;
|
||||
|
||||
if (sourceLabel != null)
|
||||
{
|
||||
var labelObject = new GameObject("Label", typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
|
||||
var labelRect = labelObject.GetComponent<RectTransform>();
|
||||
labelRect.SetParent(ghostRect, false);
|
||||
labelRect.anchorMin = Vector2.zero;
|
||||
labelRect.anchorMax = Vector2.one;
|
||||
labelRect.offsetMin = new Vector2(8, 4);
|
||||
labelRect.offsetMax = new Vector2(-8, -4);
|
||||
var label = labelObject.GetComponent<TextMeshProUGUI>();
|
||||
label.font = sourceLabel.font;
|
||||
label.fontSize = sourceLabel.fontSize;
|
||||
label.alignment = sourceLabel.alignment;
|
||||
label.color = sourceLabel.color;
|
||||
label.text = sourceLabel.text;
|
||||
label.enableWordWrapping = sourceLabel.enableWordWrapping;
|
||||
label.raycastTarget = false;
|
||||
}
|
||||
return ghostRect;
|
||||
}
|
||||
|
||||
private void UpdateGhostPosition(PointerEventData eventData)
|
||||
{
|
||||
if (_ghost == null || _canvas == null) return;
|
||||
var canvasRect = (RectTransform)_canvas.transform;
|
||||
var camera = _canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : _canvas.worldCamera;
|
||||
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, eventData.position, camera, out var localPoint))
|
||||
_ghost.anchoredPosition = localPoint;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_motion?.SetDragging(false);
|
||||
_motion = null;
|
||||
_hasDragStart = false;
|
||||
CleanupGhost();
|
||||
}
|
||||
|
||||
private void CleanupGhost()
|
||||
{
|
||||
if (_ghost != null) Destroy(_ghost.gameObject);
|
||||
_ghost = null;
|
||||
_canvas = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4815c811a35f9924fbb77c89e659355b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,83 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class ReplacedCardMotion : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, ISelectHandler, IDeselectHandler
|
||||
{
|
||||
private CanvasGroup? _group;
|
||||
private Coroutine? _entrance;
|
||||
private Action? _focus;
|
||||
private bool _hovered;
|
||||
private bool _dragging;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_group = GetComponent<CanvasGroup>() ?? gameObject.AddComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_entrance != null || _group == null) return;
|
||||
var targetScale = _dragging ? .96f : _hovered ? 1.04f : 1f;
|
||||
var targetAlpha = _dragging ? .42f : 1f;
|
||||
transform.localScale = Vector3.Lerp(transform.localScale, Vector3.one * targetScale, 1f - Mathf.Exp(-18f * Time.unscaledDeltaTime));
|
||||
_group.alpha = Mathf.Lerp(_group.alpha, targetAlpha, 1f - Mathf.Exp(-22f * Time.unscaledDeltaTime));
|
||||
}
|
||||
|
||||
public void Configure(Action focus, float entranceDelay)
|
||||
{
|
||||
_focus = focus;
|
||||
if (_entrance != null) StopCoroutine(_entrance);
|
||||
_entrance = StartCoroutine(PlayEntrance(entranceDelay));
|
||||
}
|
||||
|
||||
public void SetDragging(bool dragging)
|
||||
{
|
||||
_dragging = dragging;
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
_hovered = true;
|
||||
_focus?.Invoke();
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) => _hovered = false;
|
||||
|
||||
public void OnSelect(BaseEventData eventData)
|
||||
{
|
||||
_hovered = true;
|
||||
_focus?.Invoke();
|
||||
}
|
||||
|
||||
public void OnDeselect(BaseEventData eventData) => _hovered = false;
|
||||
|
||||
private IEnumerator PlayEntrance(float delay)
|
||||
{
|
||||
if (_group == null) yield break;
|
||||
_group.alpha = 0f;
|
||||
transform.localScale = Vector3.one * .90f;
|
||||
if (delay > 0) yield return new WaitForSecondsRealtime(delay);
|
||||
const float duration = .20f;
|
||||
var elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
var eased = 1f - Mathf.Pow(1f - t, 3f);
|
||||
_group.alpha = eased;
|
||||
transform.localScale = Vector3.one * Mathf.Lerp(.90f, 1f, eased);
|
||||
yield return null;
|
||||
}
|
||||
_group.alpha = 1f;
|
||||
transform.localScale = Vector3.one;
|
||||
_entrance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d9ca7b6f049e864d989b5daae367daf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,482 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkEventBus;
|
||||
using ShrinkNetwork;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
public sealed class ReplacedLanRuntime : MonoBehaviour
|
||||
{
|
||||
private const int TcpPort = 18140;
|
||||
private const int KcpPort = 18141;
|
||||
private const int DiscoveryPort = 18142;
|
||||
private IShrinkNetworkTransport? _server;
|
||||
private IShrinkNetworkTransport? _client;
|
||||
private CancellationTokenSource? _discoveryCts;
|
||||
private readonly object _discoveryLock = new();
|
||||
private readonly List<string> _discoveredRooms = new();
|
||||
private readonly Dictionary<long, string> _serverPlayers = new();
|
||||
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
|
||||
private ReplacedRoomAuthority? _room;
|
||||
private ReplacedMatchState? _networkState;
|
||||
private string _roomId = string.Empty;
|
||||
private string _localPlayerId = string.Empty;
|
||||
private long _clientSessionId;
|
||||
private bool _clientUsesKcp;
|
||||
private bool _isHost;
|
||||
private bool _autoPlay;
|
||||
private bool _autoReported;
|
||||
private float _nextAutoActionAt;
|
||||
|
||||
public static ReplacedLanRuntime? Instance { get; private set; }
|
||||
public string Status { get; private set; } = "尚未连接";
|
||||
public IReadOnlyList<string> DiscoveredRooms { get { lock (_discoveryLock) return _discoveredRooms.ToArray(); } }
|
||||
public ReplacedMatchState? MatchState => _isHost ? _room?.Match?.State : _networkState;
|
||||
public ReplacedContentCatalog Content => ReplacedPersonGameService.Current?.Content.Current ?? ReplacedBuiltInContent.Create();
|
||||
public int LocalPlayerIndex { get; private set; } = -1;
|
||||
public bool IsMatchReady => MatchState != null;
|
||||
public bool AwaitingCommand { get; private set; }
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public void EnableAutoPlay() => _autoPlay = true;
|
||||
|
||||
public static ReplacedLanRuntime Ensure()
|
||||
{
|
||||
if (Instance != null) return Instance;
|
||||
var host = new GameObject("ReplacedPersonLanRuntime");
|
||||
DontDestroyOnLoad(host);
|
||||
return host.AddComponent<ReplacedLanRuntime>();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
_localPlayerId = LoadPlayerId();
|
||||
_discoveryCts = new CancellationTokenSource();
|
||||
ListenForDiscoveryAsync(_discoveryCts.Token);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_discoveryCts?.Cancel();
|
||||
_server?.Stop();
|
||||
_client?.Stop();
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
var processed = 0;
|
||||
while (processed++ < 100 && _mainThreadActions.TryDequeue(out var action)) action();
|
||||
if (!_autoPlay || Time.unscaledTime < _nextAutoActionAt) return;
|
||||
var state = MatchState;
|
||||
if (state == null || LocalPlayerIndex < 0 || AwaitingCommand) return;
|
||||
if (state.Phase == ReplacedMatchPhase.Completed)
|
||||
{
|
||||
if (!_autoReported)
|
||||
{
|
||||
_autoReported = true;
|
||||
Debug.Log($"[ReplacedPerson.LAN.Auto] completed winner={state.Winner} rounds={state.Round}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_isHost && state.Round >= 12 && _room?.Seats[1] != null)
|
||||
{
|
||||
var timedOutPlayer = _room.Seats[1]!.PlayerId;
|
||||
_room.RecordTurnTimeout(timedOutPlayer);
|
||||
_room.RecordTurnTimeout(timedOutPlayer);
|
||||
BroadcastSnapshots();
|
||||
return;
|
||||
}
|
||||
var expected = state.Phase == ReplacedMatchPhase.AwaitingLead ? state.LeadPlayer : 1 - state.LeadPlayer;
|
||||
if (expected != LocalPlayerIndex) return;
|
||||
_nextAutoActionAt = Time.unscaledTime + .15f;
|
||||
var submission = new ReplacedAi(Content).Choose(state.Clone(), LocalPlayerIndex);
|
||||
var result = Submit(submission);
|
||||
if (!result.Accepted) Debug.LogError($"[ReplacedPerson.LAN.Auto] rejected {result.ErrorCode}: {result.Message}");
|
||||
}
|
||||
|
||||
public void StartHost(bool useKcp)
|
||||
{
|
||||
ResetSession();
|
||||
_isHost = true;
|
||||
LocalPlayerIndex = 0;
|
||||
_roomId = "RP-" + Environment.MachineName;
|
||||
_room = new ReplacedRoomAuthority(_roomId, Content, Array.Empty<ReplacedPersonModManifest>(),
|
||||
unchecked((ulong)DateTime.UtcNow.Ticks));
|
||||
_room.TryJoin(JoinRequest(_roomId, _localPlayerId, Environment.UserName), DateTime.UtcNow);
|
||||
_room.SetDeckAndReady(_localPlayerId, LocalDeck());
|
||||
_server = useKcp
|
||||
? new ShrinkKcpServerTransport(IPAddress.Any, KcpPort)
|
||||
: new ShrinkTcpServerTransport(IPAddress.Any, TcpPort);
|
||||
_server.OnEvent += QueueServerEvent;
|
||||
_server.Start();
|
||||
Status = $"房间 {_roomId} 已开启 · {(useKcp ? "KCP" : "TCP")} · {LocalAddress()}:{(useKcp ? KcpPort : TcpPort)} · 等待第二名玩家";
|
||||
Debug.Log("[ReplacedPerson.LAN] " + Status);
|
||||
Publish("hosting", Status);
|
||||
Changed?.Invoke();
|
||||
BroadcastDiscoveryAsync(useKcp, _discoveryCts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Join(string address, bool useKcp)
|
||||
{
|
||||
ResetSession();
|
||||
_isHost = false;
|
||||
_clientUsesKcp = useKcp;
|
||||
_roomId = string.Empty;
|
||||
_client = useKcp
|
||||
? new ShrinkKcpClientTransport(address, KcpPort)
|
||||
: new ShrinkTcpClientTransport(address, TcpPort);
|
||||
_client.OnEvent += QueueClientEvent;
|
||||
_client.Start();
|
||||
Status = $"正在连接 {address}:{(useKcp ? KcpPort : TcpPort)} ({(useKcp ? "KCP" : "TCP")})";
|
||||
Debug.Log("[ReplacedPerson.LAN] " + Status);
|
||||
Publish("connecting", Status);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public ReplacedCommandResult Submit(ReplacedTurnSubmission submission)
|
||||
{
|
||||
var state = MatchState;
|
||||
if (state == null || LocalPlayerIndex < 0)
|
||||
return Reject("match.not-started", "LAN 对局尚未开始。");
|
||||
if (AwaitingCommand) return Reject("command.pending", "上一条指令仍在等待权威端确认。");
|
||||
var command = new ReplacedMatchCommand
|
||||
{
|
||||
PlayerIndex = LocalPlayerIndex,
|
||||
Sequence = state.LastAcceptedSequence[LocalPlayerIndex] + 1,
|
||||
IdempotencyToken = $"lan-{_localPlayerId}-{Guid.NewGuid():N}",
|
||||
Submission = submission
|
||||
};
|
||||
if (_isHost)
|
||||
{
|
||||
var result = _room!.Apply(_localPlayerId, command);
|
||||
if (result.Accepted) BroadcastSnapshots();
|
||||
return result;
|
||||
}
|
||||
if (_client == null || _clientSessionId == 0)
|
||||
return Reject("network.disconnected", "尚未连接权威端。");
|
||||
AwaitingCommand = true;
|
||||
Send(_client, _clientSessionId, new ReplacedNetworkEnvelope
|
||||
{
|
||||
Kind = ReplacedNetworkMessageKind.MatchCommand,
|
||||
RoomId = _roomId,
|
||||
PlayerId = _localPlayerId,
|
||||
Sequence = command.Sequence,
|
||||
IdempotencyToken = command.IdempotencyToken,
|
||||
Payload = JsonConvert.SerializeObject(command)
|
||||
});
|
||||
Changed?.Invoke();
|
||||
return new ReplacedCommandResult { Accepted = true, Message = "sent" };
|
||||
}
|
||||
|
||||
private void HandleServerEvent(ShrinkNetworkTransportEvent value)
|
||||
{
|
||||
if (_server == null) return;
|
||||
if (value.Type == ShrinkNetworkTransportEventType.Packet)
|
||||
{
|
||||
ReplacedNetworkEnvelope? envelope;
|
||||
try { envelope = JsonConvert.DeserializeObject<ReplacedNetworkEnvelope>(Encoding.UTF8.GetString(value.PacketData)); }
|
||||
catch (Exception exception) { SendError(_server, value.SessionId, "protocol.invalid-json", exception.Message); return; }
|
||||
if (envelope == null) { SendError(_server, value.SessionId, "protocol.empty", "empty envelope"); return; }
|
||||
HandleAuthorityEnvelope(value.SessionId, envelope);
|
||||
}
|
||||
else if (value.Type == ShrinkNetworkTransportEventType.Disconnected && _serverPlayers.Remove(value.SessionId, out var playerId))
|
||||
{
|
||||
_room?.Disconnect(playerId, DateTime.UtcNow);
|
||||
Status = "玩家断开;席位保留 30 秒";
|
||||
Publish("disconnected", Status);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueServerEvent(ShrinkNetworkTransportEvent value) =>
|
||||
_mainThreadActions.Enqueue(() => HandleServerEvent(value));
|
||||
|
||||
private void HandleAuthorityEnvelope(long sessionId, ReplacedNetworkEnvelope envelope)
|
||||
{
|
||||
if (_server == null || _room == null) return;
|
||||
try
|
||||
{
|
||||
switch (envelope.Kind)
|
||||
{
|
||||
case ReplacedNetworkMessageKind.Join:
|
||||
case ReplacedNetworkMessageKind.Reconnect:
|
||||
{
|
||||
var request = JsonConvert.DeserializeObject<ReplacedJoinRequest>(envelope.Payload) ?? new ReplacedJoinRequest();
|
||||
request.RoomId = _roomId;
|
||||
var compatibility = _room.TryJoin(request, DateTime.UtcNow);
|
||||
if (!compatibility.Compatible)
|
||||
{
|
||||
SendError(_server, sessionId, "mod.mismatch", string.Join(";", compatibility.Differences));
|
||||
return;
|
||||
}
|
||||
_serverPlayers[sessionId] = request.PlayerId;
|
||||
Debug.Log($"[ReplacedPerson.LAN] player joined id={request.PlayerId} session={sessionId}");
|
||||
SendResult(_server, sessionId, true, "joined", _roomId);
|
||||
break;
|
||||
}
|
||||
case ReplacedNetworkMessageKind.Deck:
|
||||
{
|
||||
var playerId = BoundPlayer(sessionId);
|
||||
var deck = JsonConvert.DeserializeObject<ReplacedDeck>(envelope.Payload) ?? new ReplacedDeck();
|
||||
var error = _room.SetDeck(playerId, deck);
|
||||
SendResult(_server, sessionId, string.IsNullOrEmpty(error), error, string.IsNullOrEmpty(error) ? "deck-accepted" : error);
|
||||
break;
|
||||
}
|
||||
case ReplacedNetworkMessageKind.Ready:
|
||||
{
|
||||
var playerId = BoundPlayer(sessionId);
|
||||
var request = JsonConvert.DeserializeObject<ReplacedReadyRequest>(envelope.Payload) ?? new ReplacedReadyRequest();
|
||||
var error = _room.SetReady(playerId, request.Ready);
|
||||
Debug.Log($"[ReplacedPerson.LAN] ready id={playerId} accepted={string.IsNullOrEmpty(error)} matchStarted={_room.Match != null}");
|
||||
SendResult(_server, sessionId, string.IsNullOrEmpty(error), error, string.IsNullOrEmpty(error) ? "ready" : error);
|
||||
BroadcastSnapshots();
|
||||
break;
|
||||
}
|
||||
case ReplacedNetworkMessageKind.MatchCommand:
|
||||
{
|
||||
var playerId = BoundPlayer(sessionId);
|
||||
var command = JsonConvert.DeserializeObject<ReplacedMatchCommand>(envelope.Payload) ?? new ReplacedMatchCommand();
|
||||
var result = _room.Apply(playerId, command);
|
||||
SendResult(_server, sessionId, result.Accepted, result.ErrorCode,
|
||||
result.Duplicate ? "duplicate" : result.Message, result.Duplicate);
|
||||
if (result.Accepted) BroadcastSnapshots();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
SendError(_server, sessionId, "protocol.unsupported", envelope.Kind.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SendError(_server, sessionId, "authority.exception", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClientEvent(ShrinkNetworkTransportEvent value)
|
||||
{
|
||||
if (_client == null) return;
|
||||
if (value.Type == ShrinkNetworkTransportEventType.Connected)
|
||||
{
|
||||
_clientSessionId = value.SessionId;
|
||||
var join = JoinRequest(string.Empty, _localPlayerId, Environment.UserName);
|
||||
Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Join, join));
|
||||
Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Deck, LocalDeck()));
|
||||
Send(_client, value.SessionId, Envelope(ReplacedNetworkMessageKind.Ready, new ReplacedReadyRequest { Ready = true }));
|
||||
}
|
||||
else if (value.Type == ShrinkNetworkTransportEventType.Packet)
|
||||
{
|
||||
var envelope = JsonConvert.DeserializeObject<ReplacedNetworkEnvelope>(Encoding.UTF8.GetString(value.PacketData));
|
||||
if (envelope == null) return;
|
||||
if (envelope.Kind == ReplacedNetworkMessageKind.MatchSnapshot)
|
||||
{
|
||||
var payload = JsonConvert.DeserializeObject<ReplacedMatchSnapshotPayload>(envelope.Payload);
|
||||
if (payload != null)
|
||||
{
|
||||
_roomId = payload.Room.RoomId;
|
||||
_networkState = payload.Match;
|
||||
LocalPlayerIndex = payload.LocalPlayerIndex;
|
||||
AwaitingCommand = false;
|
||||
Status = payload.Match == null ? "已准备,等待另一名玩家" : $"LAN 对局进行中 · 回合 {payload.Match.Round}";
|
||||
if (_autoPlay && payload.Match != null) Debug.Log($"[ReplacedPerson.LAN] snapshot round={payload.Match.Round} phase={payload.Match.Phase} hash={envelope.StateHash}");
|
||||
}
|
||||
}
|
||||
else if (envelope.Kind == ReplacedNetworkMessageKind.Error)
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<ReplacedNetworkResultPayload>(envelope.Payload);
|
||||
AwaitingCommand = false;
|
||||
Status = "加入或指令失败:" + (result?.Detail ?? envelope.Payload);
|
||||
}
|
||||
else if (envelope.Kind == ReplacedNetworkMessageKind.MatchEvent)
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<ReplacedNetworkResultPayload>(envelope.Payload);
|
||||
if (result?.Code == "joined") _roomId = result.Detail;
|
||||
if (result?.Accepted == false) AwaitingCommand = false;
|
||||
}
|
||||
Publish("network", Status);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
else if (value.Type == ShrinkNetworkTransportEventType.Disconnected)
|
||||
{
|
||||
AwaitingCommand = false;
|
||||
Status = "连接已断开,可在 30 秒内重连";
|
||||
Publish("disconnected", Status);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueClientEvent(ShrinkNetworkTransportEvent value) =>
|
||||
_mainThreadActions.Enqueue(() => HandleClientEvent(value));
|
||||
|
||||
private void BroadcastSnapshots()
|
||||
{
|
||||
if (_server == null || _room == null) return;
|
||||
foreach (var pair in _serverPlayers)
|
||||
{
|
||||
var payload = new ReplacedMatchSnapshotPayload
|
||||
{
|
||||
Room = _room.Snapshot(),
|
||||
Match = _room.PlayerMatchSnapshot(pair.Value),
|
||||
LocalPlayerIndex = _room.GetPlayerIndex(pair.Value)
|
||||
};
|
||||
Send(_server, pair.Key, new ReplacedNetworkEnvelope
|
||||
{
|
||||
Kind = ReplacedNetworkMessageKind.MatchSnapshot,
|
||||
RoomId = _roomId,
|
||||
PlayerId = pair.Value,
|
||||
StateHash = _room.Match?.ComputeStateHash() ?? string.Empty,
|
||||
Payload = JsonConvert.SerializeObject(payload)
|
||||
});
|
||||
}
|
||||
if (_room.Match != null) Status = $"LAN 对局进行中 · 回合 {_room.Match.State.Round}";
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private string BoundPlayer(long sessionId) => _serverPlayers.TryGetValue(sessionId, out var playerId)
|
||||
? playerId
|
||||
: throw new InvalidOperationException("session.not-joined");
|
||||
|
||||
private ReplacedNetworkEnvelope Envelope(ReplacedNetworkMessageKind kind, object payload) => new()
|
||||
{
|
||||
Kind = kind,
|
||||
RoomId = _roomId,
|
||||
PlayerId = _localPlayerId,
|
||||
Payload = JsonConvert.SerializeObject(payload)
|
||||
};
|
||||
|
||||
private static void Send(IShrinkNetworkTransport transport, long sessionId, ReplacedNetworkEnvelope envelope) =>
|
||||
transport.Send(sessionId, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(envelope)));
|
||||
|
||||
private static void SendResult(IShrinkNetworkTransport transport, long sessionId, bool accepted, string code,
|
||||
string detail, bool duplicate = false)
|
||||
{
|
||||
Send(transport, sessionId, new ReplacedNetworkEnvelope
|
||||
{
|
||||
Kind = accepted ? ReplacedNetworkMessageKind.MatchEvent : ReplacedNetworkMessageKind.Error,
|
||||
Payload = JsonConvert.SerializeObject(new ReplacedNetworkResultPayload
|
||||
{
|
||||
Accepted = accepted, Duplicate = duplicate, Code = code, Detail = detail
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private static void SendError(IShrinkNetworkTransport transport, long sessionId, string code, string detail) =>
|
||||
SendResult(transport, sessionId, false, code, detail);
|
||||
|
||||
private ReplacedDeck LocalDeck()
|
||||
{
|
||||
var deck = ReplacedPersonGameService.Current?.SaveData.ActiveDeck ?? ReplacedBuiltInContent.CreateStarterDeck();
|
||||
return deck.Clone();
|
||||
}
|
||||
|
||||
private static ReplacedJoinRequest JoinRequest(string roomId, string playerId, string displayName) => new()
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = playerId,
|
||||
DisplayName = displayName,
|
||||
Mods = new List<ReplacedPersonModManifest>()
|
||||
};
|
||||
|
||||
private void ResetSession()
|
||||
{
|
||||
_server?.Stop();
|
||||
_client?.Stop();
|
||||
_server = null;
|
||||
_client = null;
|
||||
_room = null;
|
||||
_networkState = null;
|
||||
_serverPlayers.Clear();
|
||||
while (_mainThreadActions.TryDequeue(out _)) { }
|
||||
_clientSessionId = 0;
|
||||
LocalPlayerIndex = -1;
|
||||
AwaitingCommand = false;
|
||||
_autoReported = false;
|
||||
_nextAutoActionAt = 0f;
|
||||
}
|
||||
|
||||
private async void BroadcastDiscoveryAsync(bool useKcp, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var udp = new UdpClient();
|
||||
udp.EnableBroadcast = true;
|
||||
var endpoint = new IPEndPoint(IPAddress.Broadcast, DiscoveryPort);
|
||||
while (!cancellationToken.IsCancellationRequested && _server?.IsStarted == true)
|
||||
{
|
||||
var message = $"RP14|{_roomId}|{(useKcp ? "KCP" : "TCP")}|{LocalAddress()}";
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
await udp.SendAsync(bytes, bytes.Length, endpoint);
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception exception) { Debug.LogWarning("[ReplacedPerson.LAN] Discovery broadcast: " + exception.Message); }
|
||||
}
|
||||
|
||||
private async void ListenForDiscoveryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true, ExclusiveAddressUse = false };
|
||||
udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
udp.Client.Bind(new IPEndPoint(IPAddress.Any, DiscoveryPort));
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var result = await udp.ReceiveAsync();
|
||||
var message = Encoding.UTF8.GetString(result.Buffer);
|
||||
if (!message.StartsWith("RP14|", StringComparison.Ordinal)) continue;
|
||||
lock (_discoveryLock)
|
||||
{
|
||||
var roomId = message.Split('|')[1];
|
||||
_discoveredRooms.RemoveAll(value => value.StartsWith(roomId + "|", StringComparison.Ordinal));
|
||||
_discoveredRooms.Add(message.Substring(5));
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (SocketException) when (cancellationToken.IsCancellationRequested) { }
|
||||
catch (Exception exception) { Debug.LogWarning("[ReplacedPerson.LAN] Discovery listener: " + exception.Message); }
|
||||
}
|
||||
|
||||
private static string LocalAddress()
|
||||
{
|
||||
foreach (var address in Dns.GetHostEntry(Dns.GetHostName()).AddressList)
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(address)) return address.ToString();
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
private static string LoadPlayerId()
|
||||
{
|
||||
foreach (var argument in Environment.GetCommandLineArgs())
|
||||
if (argument.StartsWith("--rp-player=", StringComparison.OrdinalIgnoreCase))
|
||||
return argument.Substring("--rp-player=".Length).Trim();
|
||||
const string key = "ReplacedPerson.PlayerId";
|
||||
var value = PlayerPrefs.GetString(key, string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
value = Guid.NewGuid().ToString("N");
|
||||
PlayerPrefs.SetString(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ReplacedCommandResult Reject(string code, string detail) => new() { ErrorCode = code, Message = detail };
|
||||
|
||||
private static void Publish(string status, string detail) =>
|
||||
EventBus.TriggerEvent(new ReplacedNetworkStatusEvent { Status = status, Detail = detail });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c2d52fc4631a814cba7e23b00f9abe9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ReplacedPerson.Runtime",
|
||||
"rootNamespace": "ReplacedPerson.Runtime",
|
||||
"references": [
|
||||
"ReplacedPerson.Core",
|
||||
"ShrinkApp.Core.Runtime",
|
||||
"ShrinkContext.Core.Runtime",
|
||||
"ShrinkContext.AppAdapter.Runtime",
|
||||
"ShrinkCommand.Runtime",
|
||||
"ShrinkDataSaver.Runtime",
|
||||
"ShrinkEventBus.Runtime",
|
||||
"ShrinkModFramework.Runtime",
|
||||
"ShrinkNetwork.Runtime",
|
||||
"ShrinkTutorial.Runtime",
|
||||
"UnityEngine.UI",
|
||||
"Unity.TextMeshPro",
|
||||
"UniTask",
|
||||
"Newtonsoft.Json"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30c04fc8fc7c7fa4aa2fceafc8f455fa
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,75 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkApp;
|
||||
using ShrinkContext;
|
||||
using ShrinkContext.AppAdapter;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[ShrinkAppModuleInstaller]
|
||||
public sealed class ReplacedPersonAppInstaller : IShrinkAppModuleInstaller
|
||||
{
|
||||
public string ModuleId => "demo.replaced-person";
|
||||
public int Order => 1400;
|
||||
public IReadOnlyList<string> DependsOn => new[] { "shrink.command", "shrink.datasaver", "shrink.network" };
|
||||
|
||||
public void RegisterServices(ShrinkAppContext context)
|
||||
{
|
||||
if (!context.Services.TryGet<ReplacedPersonGameService>(out _))
|
||||
context.Services.Register(new ReplacedPersonGameService());
|
||||
}
|
||||
|
||||
public UniTask InitializeAsync(ShrinkAppContext context) => UniTask.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed class ReplacedPersonAppComponent : IShrinkComponent
|
||||
{
|
||||
public const string ModuleKey = "app.module.demo.replaced-person";
|
||||
public const string ServiceKey = "demo.replaced-person.service";
|
||||
private static readonly string[] InjectKeys = { "shrink.service.command", "shrink.service.datasaver", "shrink.service.network" };
|
||||
private static readonly string[] ProvideKeys = { ModuleKey, ServiceKey };
|
||||
|
||||
public string Name => "demo.replaced-person";
|
||||
public IReadOnlyList<string> Inject => InjectKeys;
|
||||
public IReadOnlyList<string> Provide => ProvideKeys;
|
||||
|
||||
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
|
||||
{
|
||||
var services = config as ShrinkAppServices;
|
||||
var service = services != null && services.TryGet<ReplacedPersonGameService>(out var existing) && existing != null
|
||||
? existing
|
||||
: new ReplacedPersonGameService();
|
||||
services?.Register(service);
|
||||
ctx.Set(ModuleKey, Name);
|
||||
ctx.Set(ServiceKey, service);
|
||||
ctx.EffectInverse(() =>
|
||||
{
|
||||
services?.TryUnregister(service);
|
||||
service.Dispose();
|
||||
return UniTask.CompletedTask;
|
||||
});
|
||||
return UniTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReplacedPersonContextComposition
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||
private static void Register()
|
||||
{
|
||||
var previous = ShrinkAppLoaderBootstrapper.DefaultComposition;
|
||||
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
|
||||
{
|
||||
previous?.Invoke(host);
|
||||
if (host.ModuleIds.Contains("demo.replaced-person"))
|
||||
host.OverrideModuleComponent("demo.replaced-person", static () => new ReplacedPersonAppComponent());
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7d0bb876573a94aab85a264e463eb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,10 @@
|
||||
using ShrinkApp;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
// ShrinkAppSettings currently shares a source file with other runtime types, so Unity cannot bind it
|
||||
// as a MonoScript-backed asset. This concrete demo type provides a stable Resources asset binding.
|
||||
public sealed class ReplacedPersonAppSettings : ShrinkAppSettings
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc867c2fed5f38c4f9ad76247b992502
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,67 @@
|
||||
#nullable enable
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class ReplacedPersonAudio : MonoBehaviour
|
||||
{
|
||||
private AudioSource? _uiSource;
|
||||
private AudioSource? _fxSource;
|
||||
private AudioClip? _uiClick;
|
||||
private AudioClip? _cardDraw;
|
||||
private AudioClip? _cardSelect;
|
||||
private AudioClip? _lock;
|
||||
private AudioClip? _diceRoll;
|
||||
private AudioClip? _hit;
|
||||
private AudioClip? _victory;
|
||||
private AudioClip? _defeat;
|
||||
private AudioClip? _error;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_uiSource = CreateSource("UI Audio");
|
||||
_fxSource = CreateSource("Battle Audio");
|
||||
_uiClick = Load("ui_click");
|
||||
_cardDraw = Load("card_draw");
|
||||
_cardSelect = Load("card_select");
|
||||
_lock = Load("lock");
|
||||
_diceRoll = Load("dice_roll");
|
||||
_hit = Load("hit");
|
||||
_victory = Load("victory");
|
||||
_defeat = Load("defeat");
|
||||
_error = Load("error");
|
||||
}
|
||||
|
||||
public void PlayUiClick() => Play(_uiSource, _uiClick, .55f);
|
||||
public void PlayCardDraw() => Play(_fxSource, _cardDraw, .72f);
|
||||
public void PlayCardSelect() => Play(_uiSource, _cardSelect, .65f);
|
||||
public void PlayLock() => Play(_fxSource, _lock, .74f);
|
||||
public void PlayDice() => Play(_fxSource, _diceRoll, .78f);
|
||||
public void PlayHit() => Play(_fxSource, _hit, .70f);
|
||||
public void PlayVictory() => Play(_fxSource, _victory, .72f);
|
||||
public void PlayDefeat() => Play(_fxSource, _defeat, .62f);
|
||||
public void PlayError() => Play(_uiSource, _error, .56f);
|
||||
|
||||
private AudioSource CreateSource(string sourceName)
|
||||
{
|
||||
var sourceObject = new GameObject(sourceName, typeof(AudioSource));
|
||||
sourceObject.transform.SetParent(transform, false);
|
||||
var source = sourceObject.GetComponent<AudioSource>();
|
||||
source.playOnAwake = false;
|
||||
source.loop = false;
|
||||
source.spatialBlend = 0f;
|
||||
source.ignoreListenerPause = true;
|
||||
return source;
|
||||
}
|
||||
|
||||
private static AudioClip? Load(string name) => Resources.Load<AudioClip>("ReplacedPersonAudio/" + name);
|
||||
|
||||
private static void Play(AudioSource? source, AudioClip? clip, float volume)
|
||||
{
|
||||
if (source == null || clip == null) return;
|
||||
source.PlayOneShot(clip, volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f10afc5631c8d994ea1ba409c7172430
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
#nullable enable
|
||||
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkCommand;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[ShrinkCommandSubscriber]
|
||||
public static class ReplacedPersonCommands
|
||||
{
|
||||
[ShrinkCommand("game14 status", Description = "显示《被替代之人》对局状态")]
|
||||
private static string Status() => ReplacedPersonGameService.Current?.BuildStatus() ?? "service unavailable";
|
||||
|
||||
[ShrinkCommand("game14 start-ai <enemy>", Description = "开始 AI 对局", Permission = "game14.match.start")]
|
||||
private static string StartAi(string enemy)
|
||||
{
|
||||
var service = RequireService();
|
||||
service.StartAi(enemy);
|
||||
return service.BuildStatus();
|
||||
}
|
||||
|
||||
[ShrinkCommand("game14 give-card <card>", Description = "发放卡牌", Permission = "game14.content.give")]
|
||||
private static string GiveCard(string card)
|
||||
{
|
||||
var service = RequireService();
|
||||
service.GiveCard(card);
|
||||
return "granted:" + card;
|
||||
}
|
||||
|
||||
[ShrinkCommand("game14 reload-mods", Description = "重载并暂存下一局 Mod 内容", Permission = "game14.mods.reload")]
|
||||
private static string ReloadMods() => RequireService().ReloadMods();
|
||||
|
||||
[ShrinkCommand("game14 dump-match", Description = "导出当前对局日志", Permission = "game14.match.dump")]
|
||||
private static string DumpMatch() => RequireService().DumpMatch();
|
||||
|
||||
private static ReplacedPersonGameService RequireService() =>
|
||||
ReplacedPersonGameService.Current ?? throw new System.InvalidOperationException("ReplacedPerson service unavailable.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: febad919741d9c24984ecb87a2084f09
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,947 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkTutorial;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
public sealed class ReplacedPersonDemoController : MonoBehaviour
|
||||
{
|
||||
private enum DemoScreen { Title, Chapter, Deck, Lan, Battle, Reward, Settings }
|
||||
|
||||
private readonly Dictionary<DemoScreen, GameObject> _screens = new();
|
||||
private readonly List<int> _selectedNormalIndices = new();
|
||||
private readonly List<int> _selectedEndIndices = new();
|
||||
private ReplacedPersonGameService? _service;
|
||||
private int _rerollSlot = -1;
|
||||
private string _lastError = string.Empty;
|
||||
private string _transport = "TCP";
|
||||
private bool _networkBattle;
|
||||
private ReplacedPersonAudio? _audio;
|
||||
private DemoScreen? _activeScreen;
|
||||
private string _lastResolutionKey = string.Empty;
|
||||
private Coroutine? _resolutionSequence;
|
||||
private bool _resolutionAnimating;
|
||||
private bool _deferServiceRefresh;
|
||||
private ReplacedTurnSubmission? _presentationLocalSubmission;
|
||||
private ReplacedTurnSubmission? _presentationEnemySubmission;
|
||||
private int _presentationRound;
|
||||
private int _presentationLeadPlayer = -1;
|
||||
private int _lastPresentedRound;
|
||||
|
||||
private Transform UiRoot => transform.Find("Canvas/SafeAreaRoot")!;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureEventSystem();
|
||||
_audio = GetComponent<ReplacedPersonAudio>() ?? gameObject.AddComponent<ReplacedPersonAudio>();
|
||||
var tutorialSettings = Resources.Load<ShrinkTutorialSettings>("ReplacedPersonTutorialSettings");
|
||||
if (tutorialSettings != null) ShrinkTutorialManager.EnsureInstance(tutorialSettings);
|
||||
_service = ReplacedPersonGameService.Current ?? new ReplacedPersonGameService();
|
||||
CacheScreens();
|
||||
Bind("Screens/TitleScreen/Actions/ContinueButton", OpenChapter);
|
||||
Bind("Screens/TitleScreen/Actions/DeckButton", () => Show(DemoScreen.Deck));
|
||||
Bind("Screens/TitleScreen/Actions/LanButton", () => Show(DemoScreen.Lan));
|
||||
Bind("Screens/TitleScreen/Actions/SettingsButton", () => Show(DemoScreen.Settings));
|
||||
BindBackButtons();
|
||||
Bind("Screens/ChapterScreen/Footer/StartBattleButton", StartChapterBattle);
|
||||
Bind("Screens/BattleScreen/PlayerArea/Actions/SubmitButton", SubmitTurn);
|
||||
Bind("Screens/BattleScreen/PlayerArea/Actions/PassButton", PassTurn);
|
||||
Bind("Screens/BattleScreen/PlayerArea/Actions/Reroll1Button", () => SetReroll(0));
|
||||
Bind("Screens/BattleScreen/PlayerArea/Actions/Reroll2Button", () => SetReroll(1));
|
||||
Bind("Screens/BattleScreen/PlayerArea/Actions/Reroll3Button", () => SetReroll(2));
|
||||
Bind("Screens/RewardScreen/Footer/ContinueButton", () => Show(_networkBattle ? DemoScreen.Lan : DemoScreen.Chapter));
|
||||
Bind("Screens/RewardScreen/Content/Ornaments/GoldEyeButton", () => ToggleOrnament("ornament.gold-eye"));
|
||||
Bind("Screens/RewardScreen/Content/Ornaments/GoldChainButton", () => ToggleOrnament("ornament.gold-chain"));
|
||||
Bind("Screens/RewardScreen/Content/Ornaments/WornSuitButton", () => ToggleOrnament("ornament.worn-suit"));
|
||||
Bind("Screens/DeckScreen/Footer/SaveButton", () => _service.SaveAsync().Forget(LogException));
|
||||
Bind("Screens/DeckScreen/Footer/ResetButton", ResetDeck);
|
||||
Bind("Screens/SettingsScreen/Content/TcpButton", () => SetTransport("TCP"));
|
||||
Bind("Screens/SettingsScreen/Content/KcpButton", () => SetTransport("KCP"));
|
||||
Bind("Screens/LanScreen/Content/HostButton", HostLan);
|
||||
Bind("Screens/LanScreen/Content/JoinButton", JoinLan);
|
||||
Bind("Screens/LanScreen/Content/StartMatchButton", StartLanBattle);
|
||||
_service.Changed += RefreshCurrent;
|
||||
Show(DemoScreen.Title);
|
||||
ApplyLaunchArguments();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_service != null) _service.Changed -= RefreshCurrent;
|
||||
if (ReplacedLanRuntime.Instance != null) ReplacedLanRuntime.Instance.Changed -= RefreshCurrent;
|
||||
}
|
||||
|
||||
private void CacheScreens()
|
||||
{
|
||||
var root = UiRoot.Find("Screens");
|
||||
if (root == null) throw new InvalidOperationException("Missing UI path Canvas/SafeAreaRoot/Screens.");
|
||||
foreach (DemoScreen value in Enum.GetValues(typeof(DemoScreen)))
|
||||
{
|
||||
var child = root.Find(value + "Screen");
|
||||
if (child == null) throw new InvalidOperationException("Missing screen: " + value);
|
||||
_screens.Add(value, child.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private void BindBackButtons()
|
||||
{
|
||||
foreach (var screen in new[] { "ChapterScreen", "DeckScreen", "LanScreen", "SettingsScreen" })
|
||||
Bind($"Screens/{screen}/Header/BackButton", () => Show(DemoScreen.Title));
|
||||
Bind("Screens/BattleScreen/Header/BackButton", ExitBattle);
|
||||
}
|
||||
|
||||
private void ExitBattle() => Show(_networkBattle ? DemoScreen.Lan : DemoScreen.Chapter);
|
||||
|
||||
private void Show(DemoScreen screen)
|
||||
{
|
||||
var changed = _activeScreen != screen;
|
||||
foreach (var pair in _screens) pair.Value.SetActive(pair.Key == screen);
|
||||
_activeScreen = screen;
|
||||
switch (screen)
|
||||
{
|
||||
case DemoScreen.Chapter: RefreshChapter(); break;
|
||||
case DemoScreen.Deck: RefreshDeck(); break;
|
||||
case DemoScreen.Battle: RefreshBattle(); break;
|
||||
case DemoScreen.Reward: RefreshReward(); break;
|
||||
case DemoScreen.Settings: RefreshSettings(); break;
|
||||
case DemoScreen.Lan: RefreshLan(); break;
|
||||
}
|
||||
if (!changed) return;
|
||||
var motion = _screens[screen].GetComponent<ReplacedScreenMotion>() ?? _screens[screen].AddComponent<ReplacedScreenMotion>();
|
||||
motion.Play();
|
||||
if (screen == DemoScreen.Reward)
|
||||
{
|
||||
var state = ActiveState();
|
||||
var localWinner = ActivePlayerIndex() == 0 ? ReplacedWinner.PlayerOne : ReplacedWinner.PlayerTwo;
|
||||
if (state?.Winner == localWinner) _audio?.PlayVictory();
|
||||
else _audio?.PlayDefeat();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenChapter() => Show(DemoScreen.Chapter);
|
||||
|
||||
private void StartChapterBattle()
|
||||
{
|
||||
_networkBattle = false;
|
||||
var enemy = _service!.SaveData.ChapterProgress > 0 ? "greed.full" : "greed.tutorial";
|
||||
_selectedNormalIndices.Clear();
|
||||
_selectedEndIndices.Clear();
|
||||
_rerollSlot = -1;
|
||||
_lastError = string.Empty;
|
||||
ResetResolutionPresentation();
|
||||
_service.StartAi(enemy);
|
||||
Show(DemoScreen.Battle);
|
||||
ResetCardDetail();
|
||||
_audio?.PlayCardDraw();
|
||||
var tutorial = ShrinkTutorialManager.EnsureInstance();
|
||||
tutorial.StartTutorial("replaced-person.first-match");
|
||||
ApplyTutorialPresentation(tutorial);
|
||||
}
|
||||
|
||||
private void SubmitTurn()
|
||||
{
|
||||
var state = ActiveState();
|
||||
if (state == null) return;
|
||||
var localPlayer = ActivePlayerIndex();
|
||||
if (localPlayer < 0) return;
|
||||
var self = state.Players[localPlayer];
|
||||
var submittedRound = state.Round;
|
||||
var submittedLeadPlayer = state.LeadPlayer;
|
||||
var enemySubmission = state.Submissions[1 - localPlayer]?.Clone();
|
||||
var replayStart = !_networkBattle ? _service?.Match?.Replay.Count ?? 0 : 0;
|
||||
var endCount = state.IsLastStand ? 2 : 1;
|
||||
var submission = new ReplacedTurnSubmission
|
||||
{
|
||||
NormalCardIds = SelectedCardIds(self.NormalHand, _selectedNormalIndices, 4),
|
||||
EndCardIds = SelectedCardIds(self.EndHand, _selectedEndIndices, endCount),
|
||||
RerollSlot = _rerollSlot
|
||||
};
|
||||
_deferServiceRefresh = true;
|
||||
ReplacedCommandResult result;
|
||||
try
|
||||
{
|
||||
result = _networkBattle
|
||||
? ReplacedLanRuntime.Instance!.Submit(submission)
|
||||
: _service!.SubmitPlayer(submission);
|
||||
}
|
||||
finally { _deferServiceRefresh = false; }
|
||||
_lastError = result.Accepted ? string.Empty : result.Message;
|
||||
if (result.Accepted)
|
||||
{
|
||||
CaptureCommittedPresentation(submission, enemySubmission, submittedRound, submittedLeadPlayer, replayStart);
|
||||
_audio?.PlayLock();
|
||||
ShrinkTutorialManager.Instance?.CompleteStep("rp.matchup-submitted");
|
||||
_selectedNormalIndices.Clear();
|
||||
_selectedEndIndices.Clear();
|
||||
_rerollSlot = -1;
|
||||
}
|
||||
else _audio?.PlayError();
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private void PassTurn()
|
||||
{
|
||||
var state = ActiveState();
|
||||
var localPlayer = ActivePlayerIndex();
|
||||
if (state == null || localPlayer < 0) return;
|
||||
var submission = new ReplacedTurnSubmission { Pass = true, DiscardEndOnPass = false };
|
||||
var submittedRound = state.Round;
|
||||
var submittedLeadPlayer = state.LeadPlayer;
|
||||
var enemySubmission = state.Submissions[1 - localPlayer]?.Clone();
|
||||
var replayStart = !_networkBattle ? _service?.Match?.Replay.Count ?? 0 : 0;
|
||||
_deferServiceRefresh = true;
|
||||
ReplacedCommandResult result;
|
||||
try
|
||||
{
|
||||
result = _networkBattle
|
||||
? ReplacedLanRuntime.Instance!.Submit(submission)
|
||||
: _service!.SubmitPlayer(submission);
|
||||
}
|
||||
finally { _deferServiceRefresh = false; }
|
||||
_lastError = result.Accepted ? string.Empty : result.Message;
|
||||
if (result.Accepted)
|
||||
{
|
||||
CaptureCommittedPresentation(submission, enemySubmission, submittedRound, submittedLeadPlayer, replayStart);
|
||||
_audio?.PlayLock();
|
||||
}
|
||||
else _audio?.PlayError();
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private void SetReroll(int slot)
|
||||
{
|
||||
_rerollSlot = _rerollSlot == slot ? -1 : slot;
|
||||
ShrinkTutorialManager.Instance?.CompleteStep("rp.reroll-marked");
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private void RefreshCurrent()
|
||||
{
|
||||
if (_deferServiceRefresh) return;
|
||||
var active = _screens.FirstOrDefault(pair => pair.Value.activeSelf).Key;
|
||||
Show(active);
|
||||
}
|
||||
|
||||
private void RefreshChapter()
|
||||
{
|
||||
var progress = _service!.SaveData.ChapterProgress;
|
||||
Text("Screens/ChapterScreen/Content/ChapterLabel").text = "第一章 · 贪婪";
|
||||
Text("Screens/ChapterScreen/Content/StoryText").text = progress switch
|
||||
{
|
||||
0 => "办公室的灯没有熄灭。葛朗台把亏损归罪于每一个人,也把家人的沉默换算成数字。黑色的影子从账本背后站起:‘我就是你。’",
|
||||
1 => "梦醒之后,他仍把亲人的死亡视作一笔支出。贪婪再次逼近,金链、褶皱西装和势利的眼睛逐渐从宿主身上剥离。",
|
||||
_ => "贪婪被击败,装饰与卡牌已经进入收藏。第一章试玩内容完成。"
|
||||
};
|
||||
var buttonText = Text("Screens/ChapterScreen/Footer/StartBattleButton/Label");
|
||||
buttonText.text = progress == 0 ? "进入第一战" : progress == 1 ? "进入第二战" : "重战完整形态";
|
||||
}
|
||||
|
||||
private void RefreshBattle()
|
||||
{
|
||||
var state = ActiveState();
|
||||
if (state == null) return;
|
||||
var catalog = ActiveContent();
|
||||
var localPlayer = ActivePlayerIndex();
|
||||
if (localPlayer < 0) return;
|
||||
var resolutionKey = BuildResolutionKey(state);
|
||||
var hasNewResolution = state.LastResolution.Count > 0 && !string.Equals(_lastResolutionKey, resolutionKey, StringComparison.Ordinal);
|
||||
var needsRoundIntro = !hasNewResolution && !_resolutionAnimating && state.Phase != ReplacedMatchPhase.Completed &&
|
||||
state.Round != _lastPresentedRound;
|
||||
if (hasNewResolution || needsRoundIntro)
|
||||
{
|
||||
_resolutionAnimating = true;
|
||||
if (hasNewResolution) _lastResolutionKey = resolutionKey;
|
||||
}
|
||||
if (state.Phase == ReplacedMatchPhase.Completed && !hasNewResolution && !_resolutionAnimating)
|
||||
{
|
||||
Show(DemoScreen.Reward);
|
||||
return;
|
||||
}
|
||||
var self = state.Players[localPlayer];
|
||||
var enemy = state.Players[1 - localPlayer];
|
||||
RemoveInvalidIndices(_selectedNormalIndices, self.NormalHand.Count);
|
||||
RemoveInvalidIndices(_selectedEndIndices, self.EndHand.Count);
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = $"回合 {state.Round} 先手骰 {state.InitiativeRoll}";
|
||||
Text("Screens/BattleScreen/EnemyArea/EnemyStatus").text = $"{enemy.DisplayName} HP {Math.Max(0, enemy.Health)}/{enemy.MaxHealth} COST {enemy.Cost}/10";
|
||||
Text("Screens/BattleScreen/PlayerArea/PlayerStatus").text = $"被替代之人 HP {Math.Max(0, self.Health)}/{self.MaxHealth} COST {self.Cost}/10";
|
||||
Text("Screens/BattleScreen/EnemyArea/EnemyQueue").text = BuildSubmission(catalog, state.Submissions[1 - localPlayer], "等待敌方锁定");
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var slotObject = UiRoot.Find($"Screens/BattleScreen/EffectArea/Slot{i + 1}")!.gameObject;
|
||||
var motion = slotObject.GetComponent<ReplacedSlotResolutionMotion>() ?? slotObject.AddComponent<ReplacedSlotResolutionMotion>();
|
||||
if (!motion.IsPlaying) Text($"Screens/BattleScreen/EffectArea/Slot{i + 1}/Text").text = BuildSlot(state, i, localPlayer);
|
||||
}
|
||||
Text("Screens/BattleScreen/LogPanel/LogText").text = string.Join("\n", state.Log.TakeLast(7));
|
||||
Text("Screens/BattleScreen/PlayerArea/SelectionText").text =
|
||||
(_selectedNormalIndices.Count == 0 ? "普通牌执行:未选择" : "普通牌执行:" + FormatSelectedCards(self.NormalHand, _selectedNormalIndices, id => catalog.Cards[id].Name, " → ")) +
|
||||
"\n" + (_selectedEndIndices.Count == 0 ? "结束牌:未选择" : "结束牌:" + FormatSelectedCards(self.EndHand, _selectedEndIndices, id => catalog.EndCards[id].Name, " / ")) +
|
||||
(_rerollSlot >= 0 ? $" 重掷槽 {_rerollSlot + 1}" : string.Empty) +
|
||||
(string.IsNullOrWhiteSpace(_lastError) ? string.Empty : "\n<color=#E46F5D>" + _lastError + "</color>");
|
||||
|
||||
PopulateCardButtons("Screens/BattleScreen/PlayerArea/NormalHand", self.NormalHand, false);
|
||||
PopulateCardButtons("Screens/BattleScreen/PlayerArea/EndHand", self.EndHand, true);
|
||||
var playerTurn = (state.Phase == ReplacedMatchPhase.AwaitingLead ? state.LeadPlayer : 1 - state.LeadPlayer) == localPlayer &&
|
||||
(!_networkBattle || ReplacedLanRuntime.Instance?.AwaitingCommand != true) && !_resolutionAnimating;
|
||||
Button("Screens/BattleScreen/PlayerArea/Actions/SubmitButton").interactable = playerTurn;
|
||||
Button("Screens/BattleScreen/PlayerArea/Actions/PassButton").interactable = playerTurn;
|
||||
SetHandInteraction("Screens/BattleScreen/PlayerArea/NormalHand", playerTurn);
|
||||
SetHandInteraction("Screens/BattleScreen/PlayerArea/EndHand", playerTurn);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var button = Button($"Screens/BattleScreen/PlayerArea/Actions/Reroll{i + 1}Button");
|
||||
button.interactable = playerTurn;
|
||||
button.image.color = _rerollSlot == i ? new Color(0.75f, 0.19f, 0.16f, 1f) : new Color(0.20f, 0.22f, 0.23f, 1f);
|
||||
}
|
||||
if (hasNewResolution)
|
||||
{
|
||||
if (_resolutionSequence != null) StopCoroutine(_resolutionSequence);
|
||||
_resolutionSequence = StartCoroutine(PlayResolutionSequence(state.LastResolution.ToArray(), localPlayer));
|
||||
}
|
||||
else if (needsRoundIntro)
|
||||
{
|
||||
if (_resolutionSequence != null) StopCoroutine(_resolutionSequence);
|
||||
_resolutionSequence = StartCoroutine(PlayRoundIntroSequence(state, localPlayer));
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateCardButtons(string path, IReadOnlyList<string> cards, bool endings)
|
||||
{
|
||||
var root = UiRoot.Find(path)!;
|
||||
var template = root.Find("CardButtonTemplate")!.gameObject;
|
||||
for (var i = root.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = root.GetChild(i);
|
||||
if (child.gameObject != template) Destroy(child.gameObject);
|
||||
}
|
||||
for (var i = 0; i < cards.Count; i++)
|
||||
{
|
||||
var id = cards[i];
|
||||
var handIndex = i;
|
||||
var item = Instantiate(template, root);
|
||||
item.name = (endings ? "End_" : "Normal_") + i;
|
||||
item.SetActive(true);
|
||||
var selectedIndices = endings ? _selectedEndIndices : _selectedNormalIndices;
|
||||
var order = selectedIndices.IndexOf(handIndex);
|
||||
var selected = order >= 0;
|
||||
var orderPrefix = selected ? $"[{order + 1}] " : string.Empty;
|
||||
var label = item.transform.Find("Label")!.GetComponent<TMP_Text>();
|
||||
if (endings)
|
||||
{
|
||||
var definition = ActiveContent().EndCards[id];
|
||||
label.text = $"{orderPrefix}{definition.Name}\n结束牌\n{definition.Cost}C";
|
||||
}
|
||||
else
|
||||
{
|
||||
var definition = ActiveContent().Cards[id];
|
||||
label.text = $"{orderPrefix}{definition.Name}\n{TypeName(definition.EffectType)} {definition.BaseValue}+{definition.Factor}D6\n{definition.Cost}C";
|
||||
}
|
||||
var button = item.GetComponent<Button>();
|
||||
button.image.color = selected ? new Color(0.74f, 0.19f, 0.16f, 0.96f) : new Color(0.90f, 0.87f, 0.78f, 0.96f);
|
||||
label.color = selected ? new Color(.96f, .94f, .88f, 1f) : new Color(.12f, .13f, .13f, 1f);
|
||||
var motion = item.GetComponent<ReplacedCardMotion>() ?? item.AddComponent<ReplacedCardMotion>();
|
||||
motion.Configure(() => ShowCardDetail(id, endings), i * .025f);
|
||||
var dragHandler = item.GetComponent<ReplacedCardDragHandler>();
|
||||
dragHandler.Configure(position => ReorderCard(handIndex, endings, position));
|
||||
button.onClick.AddListener(() =>
|
||||
{
|
||||
_audio?.PlayCardSelect();
|
||||
ShowCardDetail(id, endings);
|
||||
ToggleCard(handIndex, endings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTutorialPresentation(ShrinkTutorialManager tutorial)
|
||||
{
|
||||
var font = GetComponentInChildren<TMP_Text>(true)?.font;
|
||||
foreach (var text in tutorial.GetComponentsInChildren<TMP_Text>(true))
|
||||
{
|
||||
if (font != null) text.font = font;
|
||||
if (string.Equals(text.text, "Skip", StringComparison.OrdinalIgnoreCase)) text.text = "跳过";
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleCard(int handIndex, bool ending)
|
||||
{
|
||||
var state = ActiveState()!;
|
||||
var localPlayer = ActivePlayerIndex();
|
||||
var hand = ending ? state.Players[localPlayer].EndHand : state.Players[localPlayer].NormalHand;
|
||||
if (handIndex < 0 || handIndex >= hand.Count) return;
|
||||
if (ending)
|
||||
{
|
||||
var max = state.IsLastStand ? 2 : 1;
|
||||
if (_selectedEndIndices.Contains(handIndex)) _selectedEndIndices.Remove(handIndex);
|
||||
else
|
||||
{
|
||||
if (_selectedEndIndices.Count >= max) _selectedEndIndices.RemoveAt(0);
|
||||
_selectedEndIndices.Add(handIndex);
|
||||
}
|
||||
ShrinkTutorialManager.Instance?.CompleteStep("rp.end-selected");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_selectedNormalIndices.Contains(handIndex)) _selectedNormalIndices.Remove(handIndex);
|
||||
else if (_selectedNormalIndices.Count < 4) _selectedNormalIndices.Add(handIndex);
|
||||
ShrinkTutorialManager.Instance?.CompleteStep("rp.card-selected");
|
||||
}
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private void ReorderCard(int handIndex, bool ending, Vector2 screenPosition)
|
||||
{
|
||||
var state = ActiveState();
|
||||
var localPlayer = ActivePlayerIndex();
|
||||
if (state == null || localPlayer < 0) return;
|
||||
var hand = ending ? state.Players[localPlayer].EndHand : state.Players[localPlayer].NormalHand;
|
||||
if (handIndex < 0 || handIndex >= hand.Count) return;
|
||||
|
||||
var selectedIndices = ending ? _selectedEndIndices : _selectedNormalIndices;
|
||||
var max = ending && state.IsLastStand ? 2 : ending ? 1 : 4;
|
||||
var wasSelected = selectedIndices.Remove(handIndex);
|
||||
if (!wasSelected && selectedIndices.Count >= max)
|
||||
{
|
||||
if (!ending)
|
||||
{
|
||||
_lastError = "普通牌最多选择 4 张";
|
||||
_audio?.PlayError();
|
||||
RefreshBattle();
|
||||
return;
|
||||
}
|
||||
selectedIndices.RemoveAt(0);
|
||||
}
|
||||
|
||||
var insertIndex = FindDropOrder(selectedIndices, ending, screenPosition);
|
||||
selectedIndices.Insert(Mathf.Clamp(insertIndex, 0, selectedIndices.Count), handIndex);
|
||||
_lastError = string.Empty;
|
||||
_audio?.PlayCardSelect();
|
||||
ShowCardDetail(hand[handIndex], ending);
|
||||
ShrinkTutorialManager.Instance?.CompleteStep(ending ? "rp.end-selected" : "rp.card-selected");
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private int FindDropOrder(IReadOnlyList<int> selectedIndices, bool ending, Vector2 screenPosition)
|
||||
{
|
||||
if (selectedIndices.Count == 0) return 0;
|
||||
var handRoot = UiRoot.Find(HandPath(ending));
|
||||
if (handRoot == null) return selectedIndices.Count;
|
||||
var canvas = handRoot.GetComponentInParent<Canvas>()?.rootCanvas;
|
||||
var camera = canvas == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
|
||||
var closestOrder = -1;
|
||||
var closestDistance = float.MaxValue;
|
||||
var closestCenterX = 0f;
|
||||
for (var order = 0; order < selectedIndices.Count; order++)
|
||||
{
|
||||
var card = handRoot.Find((ending ? "End_" : "Normal_") + selectedIndices[order]) as RectTransform;
|
||||
if (card == null) continue;
|
||||
var center = RectTransformUtility.WorldToScreenPoint(camera, card.TransformPoint(card.rect.center));
|
||||
var distance = Mathf.Abs(screenPosition.x - center.x);
|
||||
if (distance >= closestDistance) continue;
|
||||
closestOrder = order;
|
||||
closestDistance = distance;
|
||||
closestCenterX = center.x;
|
||||
}
|
||||
if (closestOrder < 0) return selectedIndices.Count;
|
||||
return screenPosition.x < closestCenterX ? closestOrder : closestOrder + 1;
|
||||
}
|
||||
|
||||
private static string HandPath(bool ending) => ending
|
||||
? "Screens/BattleScreen/PlayerArea/EndHand"
|
||||
: "Screens/BattleScreen/PlayerArea/NormalHand";
|
||||
|
||||
private void SetHandInteraction(string path, bool interactable)
|
||||
{
|
||||
var root = UiRoot.Find(path);
|
||||
if (root == null) return;
|
||||
foreach (var button in root.GetComponentsInChildren<Button>(false))
|
||||
{
|
||||
if (button.gameObject.name == "CardButtonTemplate") continue;
|
||||
button.interactable = interactable;
|
||||
var group = button.GetComponent<CanvasGroup>();
|
||||
if (group != null) group.interactable = interactable;
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureCommittedPresentation(ReplacedTurnSubmission localSubmission,
|
||||
ReplacedTurnSubmission? enemySubmission, int round, int leadPlayer, int replayStart)
|
||||
{
|
||||
_presentationLocalSubmission = localSubmission.Clone();
|
||||
_presentationEnemySubmission = enemySubmission?.Clone();
|
||||
_presentationRound = round;
|
||||
_presentationLeadPlayer = leadPlayer;
|
||||
if (_presentationEnemySubmission != null || _networkBattle || _service?.Match == null) return;
|
||||
var enemyCommand = _service.Match.Replay.Skip(replayStart).FirstOrDefault(command => command.PlayerIndex == 1);
|
||||
_presentationEnemySubmission = enemyCommand?.Submission.Clone();
|
||||
}
|
||||
|
||||
private void RefreshDeck()
|
||||
{
|
||||
var deck = _service!.SaveData.ActiveDeck;
|
||||
var catalog = _service.Content.Current;
|
||||
var errors = _service.ValidateDeck();
|
||||
Text("Screens/DeckScreen/Content/DeckStatus").text =
|
||||
$"流派:{DeckArchetype(deck, catalog)} 普通牌 {deck.NormalCards.Count}/12 结束牌 {deck.EndCards.Count}/4" +
|
||||
(errors.Count == 0 ? " <color=#8FCB9B>牌组合法</color>" : " <color=#E46F5D>" + string.Join(",", errors) + "</color>");
|
||||
PopulateDeckList("Screens/DeckScreen/Content/CurrentDeck/Viewport/Content", deck.NormalCards, id =>
|
||||
{
|
||||
deck.NormalCards.Remove(id); RefreshDeck();
|
||||
}, catalog);
|
||||
PopulateDeckList("Screens/DeckScreen/Content/Collection/Viewport/Content",
|
||||
_service.SaveData.CollectedCards.Where(catalog.Cards.ContainsKey).Distinct().ToList(), id =>
|
||||
{
|
||||
var owned = _service.SaveData.CollectedCards.Count(value => value == id);
|
||||
var inDeck = deck.NormalCards.Count(value => value == id);
|
||||
if (deck.NormalCards.Count < 12 && inDeck < Math.Min(2, owned)) deck.NormalCards.Add(id);
|
||||
RefreshDeck();
|
||||
}, catalog);
|
||||
}
|
||||
|
||||
private void PopulateDeckList(string path, IReadOnlyList<string> ids, Action<string> click, ReplacedContentCatalog catalog)
|
||||
{
|
||||
var root = UiRoot.Find(path)!;
|
||||
var template = root.Find("RowTemplate")!.gameObject;
|
||||
for (var i = root.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = root.GetChild(i);
|
||||
if (child.gameObject != template) Destroy(child.gameObject);
|
||||
}
|
||||
for (var i = 0; i < ids.Count; i++)
|
||||
{
|
||||
var id = ids[i];
|
||||
var row = Instantiate(template, root);
|
||||
row.SetActive(true);
|
||||
row.name = "Card_" + i;
|
||||
var card = catalog.Cards[id];
|
||||
row.transform.Find("Label")!.GetComponent<TMP_Text>().text = $"{card.Name} {TypeName(card.EffectType)} {card.BaseValue}+{card.Factor}D6 {card.Cost}C";
|
||||
row.GetComponent<Button>().onClick.AddListener(() =>
|
||||
{
|
||||
_audio?.PlayCardSelect();
|
||||
click(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetDeck()
|
||||
{
|
||||
_service!.SaveData.ActiveDeck = ReplacedBuiltInContent.CreateStarterDeck();
|
||||
RefreshDeck();
|
||||
}
|
||||
|
||||
private void RefreshReward()
|
||||
{
|
||||
var service = _service!;
|
||||
var state = ActiveState();
|
||||
var localWinner = ActivePlayerIndex() == 0 ? ReplacedWinner.PlayerOne : ReplacedWinner.PlayerTwo;
|
||||
var won = state?.Winner == localWinner;
|
||||
Text("Screens/RewardScreen/Content/ResultText").text = won ? "胜利" : "意识被压回黑暗";
|
||||
Text("Screens/RewardScreen/Content/RewardText").text = _networkBattle
|
||||
? (won ? "LAN 对局获胜。联网对局不修改章节奖励。" : "LAN 对局结束。可以返回大厅重新准备。")
|
||||
: won
|
||||
? "已收集:\n" + string.Join("\n", service.SaveData.Ornaments.Concat(service.SaveData.CollectedCards.Where(id => id.StartsWith("greed.", StringComparison.Ordinal))))
|
||||
: "宿主的负面情绪仍在。可以重新进入战斗。";
|
||||
Text("Screens/RewardScreen/Content/AppearanceText").text = service.SaveData.EquippedOrnaments.Count == 0
|
||||
? "当前装饰:无"
|
||||
: "当前装饰:" + string.Join(" / ", service.SaveData.EquippedOrnaments.Select(OrnamentName));
|
||||
RefreshOrnamentButton("GoldEyeButton", "ornament.gold-eye", "金色势利眼");
|
||||
RefreshOrnamentButton("GoldChainButton", "ornament.gold-chain", "金链");
|
||||
RefreshOrnamentButton("WornSuitButton", "ornament.worn-suit", "褶皱西装");
|
||||
}
|
||||
|
||||
private void ToggleOrnament(string id)
|
||||
{
|
||||
_service!.EquipOrnament(id);
|
||||
RefreshReward();
|
||||
}
|
||||
|
||||
private void RefreshOrnamentButton(string objectName, string id, string label)
|
||||
{
|
||||
var button = Button("Screens/RewardScreen/Content/Ornaments/" + objectName);
|
||||
var owned = _service!.SaveData.Ornaments.Contains(id);
|
||||
var equipped = _service.SaveData.EquippedOrnaments.Contains(id);
|
||||
button.interactable = owned;
|
||||
button.image.color = equipped ? new Color(.67f, .13f, .11f, 1f) : owned ? new Color(.25f, .27f, .27f, 1f) : new Color(.11f, .12f, .12f, .65f);
|
||||
button.transform.Find("Label")!.GetComponent<TMP_Text>().text = owned ? label : label + " · 未获得";
|
||||
}
|
||||
|
||||
private static string OrnamentName(string id) => id switch
|
||||
{
|
||||
"ornament.gold-eye" => "金色势利眼",
|
||||
"ornament.gold-chain" => "金链",
|
||||
"ornament.worn-suit" => "褶皱西装",
|
||||
_ => id
|
||||
};
|
||||
|
||||
private void RefreshSettings()
|
||||
{
|
||||
Text("Screens/SettingsScreen/Content/TransportStatus").text = "LAN 传输:" + _transport;
|
||||
Button("Screens/SettingsScreen/Content/TcpButton").image.color = _transport == "TCP" ? Color.white : Color.gray;
|
||||
Button("Screens/SettingsScreen/Content/KcpButton").image.color = _transport == "KCP" ? Color.white : Color.gray;
|
||||
}
|
||||
|
||||
private void SetTransport(string value)
|
||||
{
|
||||
_transport = value;
|
||||
PlayerPrefs.SetString("ReplacedPerson.Transport", value);
|
||||
RefreshSettings();
|
||||
}
|
||||
|
||||
private void RefreshLan()
|
||||
{
|
||||
var runtime = ReplacedLanRuntime.Instance;
|
||||
Text("Screens/LanScreen/Content/StatusText").text = runtime?.Status ?? "尚未连接";
|
||||
Text("Screens/LanScreen/Content/DiscoveryText").text = runtime == null || runtime.DiscoveredRooms.Count == 0
|
||||
? "局域网发现会自动广播可用房间"
|
||||
: "已发现:\n" + string.Join("\n", runtime.DiscoveredRooms.Take(3));
|
||||
Button("Screens/LanScreen/Content/StartMatchButton").interactable = runtime?.IsMatchReady == true;
|
||||
}
|
||||
|
||||
private void HostLan()
|
||||
{
|
||||
var runtime = EnsureLan();
|
||||
runtime.StartHost(_transport == "KCP");
|
||||
RefreshLan();
|
||||
}
|
||||
|
||||
private void JoinLan()
|
||||
{
|
||||
var input = UiRoot.Find("Screens/LanScreen/Content/AddressInput")!.GetComponent<TMP_InputField>();
|
||||
var runtime = EnsureLan();
|
||||
runtime.Join(string.IsNullOrWhiteSpace(input.text) ? "127.0.0.1" : input.text.Trim(), _transport == "KCP");
|
||||
RefreshLan();
|
||||
}
|
||||
|
||||
private ReplacedLanRuntime EnsureLan()
|
||||
{
|
||||
var runtime = ReplacedLanRuntime.Ensure();
|
||||
runtime.Changed -= RefreshCurrent;
|
||||
runtime.Changed += RefreshCurrent;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private void ApplyLaunchArguments()
|
||||
{
|
||||
var arguments = Environment.GetCommandLineArgs();
|
||||
var host = arguments.FirstOrDefault(value => value.StartsWith("--rp-host=", StringComparison.OrdinalIgnoreCase));
|
||||
var join = arguments.FirstOrDefault(value => value.StartsWith("--rp-join=", StringComparison.OrdinalIgnoreCase));
|
||||
if (host == null && join == null) return;
|
||||
var runtime = EnsureLan();
|
||||
if (arguments.Any(value => string.Equals(value, "--rp-auto", StringComparison.OrdinalIgnoreCase))) runtime.EnableAutoPlay();
|
||||
if (host != null)
|
||||
{
|
||||
_transport = host.Substring("--rp-host=".Length).Trim().ToUpperInvariant() == "KCP" ? "KCP" : "TCP";
|
||||
runtime.StartHost(_transport == "KCP");
|
||||
}
|
||||
else
|
||||
{
|
||||
var values = join!.Substring("--rp-join=".Length).Split(',');
|
||||
_transport = values[0].Trim().ToUpperInvariant() == "KCP" ? "KCP" : "TCP";
|
||||
runtime.Join(values.Length > 1 ? values[1].Trim() : "127.0.0.1", _transport == "KCP");
|
||||
}
|
||||
Show(DemoScreen.Lan);
|
||||
}
|
||||
|
||||
private void StartLanBattle()
|
||||
{
|
||||
var runtime = ReplacedLanRuntime.Instance;
|
||||
if (runtime?.IsMatchReady != true) return;
|
||||
_networkBattle = true;
|
||||
_selectedNormalIndices.Clear();
|
||||
_selectedEndIndices.Clear();
|
||||
_rerollSlot = -1;
|
||||
_lastError = string.Empty;
|
||||
ResetResolutionPresentation();
|
||||
Show(DemoScreen.Battle);
|
||||
ResetCardDetail();
|
||||
_audio?.PlayCardDraw();
|
||||
}
|
||||
|
||||
private string BuildSubmission(ReplacedContentCatalog catalog, ReplacedTurnSubmission? submission, string fallback)
|
||||
{
|
||||
if (submission == null) return fallback;
|
||||
if (submission.Pass) return "跳过";
|
||||
return string.Join(" → ", submission.NormalCardIds.Select(id => catalog.Cards[id].Name)) +
|
||||
" ▷ " + string.Join(" / ", submission.EndCardIds.Select(id => catalog.EndCards[id].Name));
|
||||
}
|
||||
|
||||
private ReplacedMatchState? ActiveState() => _networkBattle
|
||||
? ReplacedLanRuntime.Instance?.MatchState
|
||||
: _service?.Match?.State;
|
||||
|
||||
private ReplacedContentCatalog ActiveContent() => _networkBattle
|
||||
? ReplacedLanRuntime.Instance?.Content ?? ReplacedBuiltInContent.Create()
|
||||
: _service?.Match?.Content ?? _service!.Content.Current;
|
||||
|
||||
private int ActivePlayerIndex() => _networkBattle ? ReplacedLanRuntime.Instance?.LocalPlayerIndex ?? -1 : 0;
|
||||
|
||||
private static string BuildSlot(ReplacedMatchState state, int slot, int localPlayer)
|
||||
{
|
||||
var value = state.LastResolution.FirstOrDefault(item => item.SlotIndex == slot);
|
||||
if (value == null) return $"槽 {slot + 1}\n等待拼点";
|
||||
var localIsOne = localPlayer == 0;
|
||||
var localType = localIsOne ? value.PlayerOneType : value.PlayerTwoType;
|
||||
var enemyType = localIsOne ? value.PlayerTwoType : value.PlayerOneType;
|
||||
var localValue = localIsOne ? value.PlayerOneValue : value.PlayerTwoValue;
|
||||
var enemyValue = localIsOne ? value.PlayerTwoValue : value.PlayerOneValue;
|
||||
var localRoll = localIsOne ? value.PlayerOneRoll : value.PlayerTwoRoll;
|
||||
var enemyRoll = localIsOne ? value.PlayerTwoRoll : value.PlayerOneRoll;
|
||||
var localRerolled = localIsOne ? value.PlayerOneRerolled : value.PlayerTwoRerolled;
|
||||
var enemyRerolled = localIsOne ? value.PlayerTwoRerolled : value.PlayerOneRerolled;
|
||||
return $"槽 {slot + 1}\n骰面 {DiceRoll(localRoll, localRerolled)} : {DiceRoll(enemyRoll, enemyRerolled)}\n" +
|
||||
$"总点 {TypeName(localType)} {localValue} : {enemyValue} {TypeName(enemyType)}\n{value.Summary}";
|
||||
}
|
||||
|
||||
private static string DiceRoll(int value, bool rerolled) => value <= 0 ? "-" : rerolled ? value + "(重掷)" : value.ToString();
|
||||
|
||||
private static string TypeName(ReplacedEffectType type) => type switch
|
||||
{
|
||||
ReplacedEffectType.Attack => "攻",
|
||||
ReplacedEffectType.Dodge => "闪",
|
||||
ReplacedEffectType.Counter => "反",
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
private void ShowCardDetail(string id, bool ending)
|
||||
{
|
||||
var title = UiRoot.Find("Screens/BattleScreen/LogPanel/CardDetailTitle")?.GetComponent<TMP_Text>();
|
||||
var detail = UiRoot.Find("Screens/BattleScreen/LogPanel/CardDetailText")?.GetComponent<TMP_Text>();
|
||||
if (title == null || detail == null) return;
|
||||
var catalog = ActiveContent();
|
||||
if (ending)
|
||||
{
|
||||
var card = catalog.EndCards[id];
|
||||
title.text = $"{card.Name} · 结束牌 · {card.Cost} COST";
|
||||
detail.text = card.Description + "\n槽位修正:" + string.Join(" / ", Enumerable.Range(0, 3)
|
||||
.Select(index => $"槽{index + 1} +{card.SlotBase[index]}始数 +{card.SlotFactor[index]}因数"));
|
||||
return;
|
||||
}
|
||||
|
||||
var normal = catalog.Cards[id];
|
||||
var minimum = normal.BaseValue + normal.Factor;
|
||||
var maximum = normal.BaseValue + normal.Factor * 6;
|
||||
title.text = $"{normal.Name} · {FullTypeName(normal.EffectType)} · {normal.Cost} COST";
|
||||
detail.text = $"{normal.Description}\n拼点:{normal.BaseValue} + {normal.Factor} × D6,范围 {minimum}–{maximum}。{EffectRule(normal.EffectType)}";
|
||||
}
|
||||
|
||||
private void ResetCardDetail()
|
||||
{
|
||||
var title = UiRoot.Find("Screens/BattleScreen/LogPanel/CardDetailTitle")?.GetComponent<TMP_Text>();
|
||||
var detail = UiRoot.Find("Screens/BattleScreen/LogPanel/CardDetailText")?.GetComponent<TMP_Text>();
|
||||
if (title != null) title.text = "卡牌效果";
|
||||
if (detail != null) detail.text = "悬停、点击或拖动卡牌查看完整效果。";
|
||||
}
|
||||
|
||||
private void ResetResolutionPresentation()
|
||||
{
|
||||
if (_resolutionSequence != null) StopCoroutine(_resolutionSequence);
|
||||
_resolutionSequence = null;
|
||||
_resolutionAnimating = false;
|
||||
_lastResolutionKey = string.Empty;
|
||||
_lastPresentedRound = 0;
|
||||
_presentationLocalSubmission = null;
|
||||
_presentationEnemySubmission = null;
|
||||
_presentationRound = 0;
|
||||
_presentationLeadPlayer = -1;
|
||||
}
|
||||
|
||||
private IEnumerator PlayResolutionSequence(ReplacedSlotResolution[] resolutions, int localPlayer)
|
||||
{
|
||||
var catalog = ActiveContent();
|
||||
var round = _presentationRound > 0 ? _presentationRound : Math.Max(1, (ActiveState()?.Round ?? 1) - 1);
|
||||
var leadPlayer = _presentationLeadPlayer >= 0 ? _presentationLeadPlayer : localPlayer;
|
||||
if (leadPlayer == localPlayer)
|
||||
{
|
||||
yield return PlayLockStage(true, round, BuildSubmission(catalog, _presentationLocalSubmission, "牌序已锁定"));
|
||||
yield return PlayLockStage(false, round, BuildSubmission(catalog, _presentationEnemySubmission, "牌序已锁定"));
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return PlayLockStage(false, round, BuildSubmission(catalog, _presentationEnemySubmission, "牌序已锁定"));
|
||||
yield return PlayLockStage(true, round, BuildSubmission(catalog, _presentationLocalSubmission, "牌序已锁定"));
|
||||
}
|
||||
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = $"回合 {round} 效果槽投掷 D6";
|
||||
_audio?.PlayDice();
|
||||
yield return EnsureBattlePhaseMotion().PlayStage("效果槽投掷", "三个槽位同时投掷 D6", new Color(.68f, .48f, .14f, 1f), .28f);
|
||||
foreach (var resolution in resolutions.OrderBy(value => value.SlotIndex))
|
||||
{
|
||||
var slot = UiRoot.Find($"Screens/BattleScreen/EffectArea/Slot{resolution.SlotIndex + 1}");
|
||||
slot?.GetComponent<ReplacedSlotResolutionMotion>()?.Play(resolution, localPlayer);
|
||||
}
|
||||
|
||||
yield return new WaitForSecondsRealtime(.96f);
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = $"回合 {round} 骰点定格";
|
||||
yield return new WaitForSecondsRealtime(.73f);
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = $"回合 {round} 拼点结算";
|
||||
var localDamaged = resolutions.Any(resolution => localPlayer == 0 ? resolution.DamageToPlayerOne > 0 : resolution.DamageToPlayerTwo > 0);
|
||||
var enemyDamaged = resolutions.Any(resolution => localPlayer == 0 ? resolution.DamageToPlayerTwo > 0 : resolution.DamageToPlayerOne > 0);
|
||||
if (localDamaged || enemyDamaged) _audio?.PlayHit();
|
||||
if (localDamaged) StartCoroutine(PulseDamage(Text("Screens/BattleScreen/PlayerArea/PlayerStatus")));
|
||||
if (enemyDamaged) StartCoroutine(PulseDamage(Text("Screens/BattleScreen/EnemyArea/EnemyStatus")));
|
||||
yield return new WaitForSecondsRealtime(.78f);
|
||||
|
||||
_presentationLocalSubmission = null;
|
||||
_presentationEnemySubmission = null;
|
||||
_presentationRound = 0;
|
||||
_presentationLeadPlayer = -1;
|
||||
var state = ActiveState();
|
||||
if (state?.Phase == ReplacedMatchPhase.Completed)
|
||||
{
|
||||
_resolutionAnimating = false;
|
||||
_resolutionSequence = null;
|
||||
Show(DemoScreen.Reward);
|
||||
yield break;
|
||||
}
|
||||
if (state != null) yield return PlayRoundIntroContent(state, localPlayer);
|
||||
_resolutionAnimating = false;
|
||||
_resolutionSequence = null;
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private IEnumerator PlayRoundIntroSequence(ReplacedMatchState state, int localPlayer)
|
||||
{
|
||||
yield return PlayRoundIntroContent(state, localPlayer);
|
||||
_resolutionAnimating = false;
|
||||
_resolutionSequence = null;
|
||||
RefreshBattle();
|
||||
}
|
||||
|
||||
private IEnumerator PlayRoundIntroContent(ReplacedMatchState state, int localPlayer)
|
||||
{
|
||||
var round = state.Round;
|
||||
var initiativeRoll = state.InitiativeRoll;
|
||||
var leadPlayer = state.LeadPlayer;
|
||||
var enemySubmission = state.Submissions[1 - localPlayer]?.Clone();
|
||||
var catalog = ActiveContent();
|
||||
var leadName = leadPlayer == localPlayer ? "我方先手" : state.Players[1 - localPlayer].DisplayName + "先手";
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = $"回合 {round} 决定先手";
|
||||
_audio?.PlayDice();
|
||||
yield return EnsureBattlePhaseMotion().PlayDie("先手骰", initiativeRoll, leadName, new Color(.72f, .55f, .22f, 1f));
|
||||
_lastPresentedRound = round;
|
||||
if (leadPlayer != localPlayer && enemySubmission != null)
|
||||
{
|
||||
yield return PlayLockStage(false, round, BuildSubmission(catalog, enemySubmission, "牌序已锁定"));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PlayLockStage(bool local, int round, string detail)
|
||||
{
|
||||
Text("Screens/BattleScreen/Header/RoundText").text = local
|
||||
? $"回合 {round} 我方锁定"
|
||||
: $"回合 {round} 敌方锁定";
|
||||
if (local)
|
||||
Text("Screens/BattleScreen/PlayerArea/SelectionText").text = "我方已锁定\n" + detail;
|
||||
else
|
||||
Text("Screens/BattleScreen/EnemyArea/EnemyQueue").text = detail;
|
||||
if (!local) _audio?.PlayLock();
|
||||
var title = local ? "我方锁定" : "敌方锁定";
|
||||
var accent = local ? new Color(.76f, .19f, .16f, 1f) : new Color(.28f, .35f, .34f, 1f);
|
||||
yield return EnsureBattlePhaseMotion().PlayStage(title, detail, accent);
|
||||
}
|
||||
|
||||
private ReplacedBattlePhaseMotion EnsureBattlePhaseMotion()
|
||||
{
|
||||
var battle = UiRoot.Find("Screens/BattleScreen")!;
|
||||
return battle.GetComponent<ReplacedBattlePhaseMotion>() ?? battle.gameObject.AddComponent<ReplacedBattlePhaseMotion>();
|
||||
}
|
||||
|
||||
private static IEnumerator PulseDamage(TMP_Text text)
|
||||
{
|
||||
var original = text.color;
|
||||
text.color = new Color(1f, .27f, .22f, 1f);
|
||||
text.transform.localScale = Vector3.one * 1.08f;
|
||||
var elapsed = 0f;
|
||||
const float duration = .28f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
text.color = Color.Lerp(new Color(1f, .27f, .22f, 1f), original, t);
|
||||
text.transform.localScale = Vector3.one * Mathf.Lerp(1.08f, 1f, t);
|
||||
yield return null;
|
||||
}
|
||||
text.color = original;
|
||||
text.transform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
private static string BuildResolutionKey(ReplacedMatchState state)
|
||||
{
|
||||
if (state.LastResolution.Count == 0) return string.Empty;
|
||||
return state.Round + "|" + string.Join("|", state.LastResolution.OrderBy(value => value.SlotIndex).Select(value =>
|
||||
$"{value.SlotIndex}:{value.PlayerOneRoll}:{value.PlayerTwoRoll}:{value.PlayerOneValue}:{value.PlayerTwoValue}:{value.DamageToPlayerOne}:{value.DamageToPlayerTwo}:{value.Summary}"));
|
||||
}
|
||||
|
||||
private static string FullTypeName(ReplacedEffectType type) => type switch
|
||||
{
|
||||
ReplacedEffectType.Attack => "攻击",
|
||||
ReplacedEffectType.Dodge => "闪避",
|
||||
ReplacedEffectType.Counter => "反击",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
private static string EffectRule(ReplacedEffectType type) => type switch
|
||||
{
|
||||
ReplacedEffectType.Attack => "攻击胜出时造成点数差伤害,单槽最多 8 点。",
|
||||
ReplacedEffectType.Dodge => "对攻击胜出时取消攻击,并让下回合额外抽 1 张普通牌。",
|
||||
ReplacedEffectType.Counter => "对攻击胜出时取消攻击并反伤点数差,单槽最多 6 点。",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private static string DeckArchetype(ReplacedDeck deck, ReplacedContentCatalog catalog)
|
||||
{
|
||||
var types = deck.NormalCards.Where(catalog.Cards.ContainsKey).Select(id => catalog.Cards[id].EffectType).ToArray();
|
||||
var counters = types.Count(value => value == ReplacedEffectType.Counter);
|
||||
var attacks = types.Count(value => value == ReplacedEffectType.Attack);
|
||||
var dodges = types.Count(value => value == ReplacedEffectType.Dodge);
|
||||
if (counters >= 6) return "反击蓄势";
|
||||
if (attacks >= 6) return "连续强攻";
|
||||
if (dodges >= 6) return "闪避游击";
|
||||
return "混合对位";
|
||||
}
|
||||
|
||||
private static List<string> SelectedCardIds(IReadOnlyList<string> hand, IEnumerable<int> selectedIndices, int maximum)
|
||||
{
|
||||
return selectedIndices.Where(index => index >= 0 && index < hand.Count)
|
||||
.Distinct()
|
||||
.Take(maximum)
|
||||
.Select(index => hand[index])
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatSelectedCards(IReadOnlyList<string> hand, IEnumerable<int> selectedIndices,
|
||||
Func<string, string> name, string separator)
|
||||
{
|
||||
return string.Join(separator, selectedIndices.Where(index => index >= 0 && index < hand.Count)
|
||||
.Select((index, order) => $"{order + 1}. {name(hand[index])}"));
|
||||
}
|
||||
|
||||
private static void RemoveInvalidIndices(List<int> indices, int handCount)
|
||||
{
|
||||
indices.RemoveAll(index => index < 0 || index >= handCount);
|
||||
}
|
||||
|
||||
private void Bind(string path, UnityEngine.Events.UnityAction action) => Button(path).onClick.AddListener(() =>
|
||||
{
|
||||
_audio?.PlayUiClick();
|
||||
action();
|
||||
});
|
||||
private Button Button(string path) => UiRoot.Find(path)?.GetComponent<Button>() ?? throw new InvalidOperationException("Missing Button: " + path);
|
||||
private TMP_Text Text(string path) => UiRoot.Find(path)?.GetComponent<TMP_Text>() ?? throw new InvalidOperationException("Missing TMP text: " + path);
|
||||
private void LogException(Exception exception) { _lastError = exception.Message; Debug.LogException(exception); RefreshCurrent(); }
|
||||
|
||||
private static void EnsureEventSystem()
|
||||
{
|
||||
if (EventSystem.current != null) return;
|
||||
var value = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
|
||||
DontDestroyOnLoad(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 885585ddceefe5443b15cb383dcd4a68
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
#nullable enable
|
||||
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkEventBus;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
public sealed class ReplacedMatchPhaseEvent : EventBase
|
||||
{
|
||||
public int Round { get; set; }
|
||||
public ReplacedMatchPhase MatchPhase { get; set; }
|
||||
public int LeadPlayer { get; set; }
|
||||
public string StateHash { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ReplacedCardsCommittedEvent : EventBase
|
||||
{
|
||||
public int PlayerIndex { get; set; }
|
||||
public string[] NormalCardIds { get; set; } = System.Array.Empty<string>();
|
||||
public string[] EndCardIds { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class ReplacedDiceEvent : EventBase
|
||||
{
|
||||
public int Slot { get; set; }
|
||||
public int PlayerOneRoll { get; set; }
|
||||
public int PlayerTwoRoll { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReplacedDamageEvent : EventBase
|
||||
{
|
||||
public int TargetPlayer { get; set; }
|
||||
public int Amount { get; set; }
|
||||
public int RemainingHealth { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReplacedRewardEvent : EventBase
|
||||
{
|
||||
public string RewardId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ReplacedSaveEvent : EventBase
|
||||
{
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReplacedNetworkStatusEvent : EventBase
|
||||
{
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string Detail { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f1e4875f9052e1a44a05ff6670dab21e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,255 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkDataSaver;
|
||||
using ShrinkEventBus;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
public sealed class ReplacedPersonGameService : IDisposable
|
||||
{
|
||||
private const string SaveModuleKey = "demo.replaced-person";
|
||||
private long _sequence;
|
||||
private ReplacedPersonSaveData _saveData;
|
||||
|
||||
public static ReplacedPersonGameService? Current { get; private set; }
|
||||
public ReplacedHotReloadCatalog Content { get; }
|
||||
public ReplacedMatchEngine? Match { get; private set; }
|
||||
public ReplacedPersonSaveData SaveData => _saveData;
|
||||
public string ActiveEnemyId { get; private set; } = string.Empty;
|
||||
public ulong ActiveSeed { get; private set; }
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public ReplacedPersonGameService()
|
||||
{
|
||||
Content = new ReplacedHotReloadCatalog(ReplacedBuiltInContent.Create());
|
||||
_saveData = ReplacedPersonSaveMigration.CreateNew();
|
||||
Current = this;
|
||||
ShrinkSave.SetCurrentSaveVersion(ReplacedPersonSaveData.CurrentVersion);
|
||||
ShrinkSave.RegisterModule(SaveModuleKey, () => _saveData, data =>
|
||||
{
|
||||
_saveData = ReplacedPersonSaveMigration.Migrate(data ?? ReplacedPersonSaveMigration.CreateNew());
|
||||
Changed?.Invoke();
|
||||
}, new ModuleConfig { CriticalModule = true, AutoSaveIntervalSeconds = 60f });
|
||||
}
|
||||
|
||||
public ReplacedMatchEngine StartAi(string enemyId = "greed.tutorial", ulong? seed = null)
|
||||
{
|
||||
var catalog = Content.SnapshotForNewMatch();
|
||||
if (!catalog.Enemies.TryGetValue(enemyId, out var enemy))
|
||||
throw new ArgumentException("Unknown enemy: " + enemyId, nameof(enemyId));
|
||||
var enemyDeck = new ReplacedDeck
|
||||
{
|
||||
NormalCards = new List<string>(enemy.NormalDeck),
|
||||
EndCards = new List<string>(enemy.EndDeck)
|
||||
};
|
||||
var deckErrors = ReplacedDeckValidator.Validate(_saveData.ActiveDeck, catalog);
|
||||
if (deckErrors.Count > 0)
|
||||
_saveData.ActiveDeck = ReplacedBuiltInContent.CreateStarterDeck();
|
||||
ActiveEnemyId = enemyId;
|
||||
ActiveSeed = seed ?? unchecked((ulong)DateTime.UtcNow.Ticks);
|
||||
Match = new ReplacedMatchEngine(catalog, _saveData.ActiveDeck, enemyDeck, ActiveSeed, "被替代之人", enemy.Name);
|
||||
_sequence = 0;
|
||||
PublishStateEvents(null, null);
|
||||
DriveAiIfNeeded();
|
||||
Changed?.Invoke();
|
||||
return Match;
|
||||
}
|
||||
|
||||
public ReplacedCommandResult SubmitPlayer(ReplacedTurnSubmission submission)
|
||||
{
|
||||
if (Match == null) return new ReplacedCommandResult { ErrorCode = "match.missing", Message = "尚未开始对局。" };
|
||||
var command = NewCommand(0, submission);
|
||||
var before = Match.State.Clone();
|
||||
var result = Match.Apply(command);
|
||||
if (result.Accepted && !result.Duplicate)
|
||||
{
|
||||
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
|
||||
{
|
||||
PlayerIndex = 0,
|
||||
NormalCardIds = submission.NormalCardIds.ToArray(),
|
||||
EndCardIds = submission.EndCardIds.ToArray()
|
||||
});
|
||||
PublishStateEvents(before, Match.State);
|
||||
DriveAiIfNeeded();
|
||||
CompleteRewardsIfNeeded();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void GiveCard(string cardId)
|
||||
{
|
||||
if (!Content.Current.Cards.ContainsKey(cardId)) throw new ArgumentException("Unknown card: " + cardId);
|
||||
_saveData.CollectedCards.Add(cardId);
|
||||
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = cardId });
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void EquipOrnament(string ornamentId)
|
||||
{
|
||||
if (!_saveData.Ornaments.Contains(ornamentId)) return;
|
||||
if (_saveData.EquippedOrnaments.Contains(ornamentId)) _saveData.EquippedOrnaments.Remove(ornamentId);
|
||||
else _saveData.EquippedOrnaments.Add(ornamentId);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ValidateDeck() => ReplacedDeckValidator.Validate(_saveData.ActiveDeck, Content.Current);
|
||||
|
||||
public async UniTask SaveAsync(int slot = 14)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ShrinkSave.SaveSlotAsync(slot, new SaveOptions { SlotName = "被替代之人" });
|
||||
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = true });
|
||||
}
|
||||
catch
|
||||
{
|
||||
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = false });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async UniTask LoadAsync(int slot = 14)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ShrinkSave.LoadSlotAsync(slot);
|
||||
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = true });
|
||||
Changed?.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = false });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public string BuildStatus()
|
||||
{
|
||||
if (Match == null)
|
||||
return $"idle chapter={_saveData.ChapterProgress} cards={_saveData.CollectedCards.Count} ornaments={_saveData.Ornaments.Count}";
|
||||
var state = Match.State;
|
||||
return $"round={state.Round} phase={state.Phase} lead={state.LeadPlayer} hp={state.Players[0].Health}/{state.Players[1].Health} cost={state.Players[0].Cost}/{state.Players[1].Cost} hash={Match.ComputeStateHash()}";
|
||||
}
|
||||
|
||||
public string DumpMatch()
|
||||
{
|
||||
if (Match == null) return "no active match";
|
||||
return string.Join("\n", Match.State.Log) + "\nstateHash=" + Match.ComputeStateHash();
|
||||
}
|
||||
|
||||
public string ReloadMods()
|
||||
{
|
||||
var snapshot = ReplacedPersonModBridge.BuildCatalogSnapshot();
|
||||
Content.Stage(snapshot);
|
||||
return $"staged={Content.PendingHash}; active={Content.CurrentHash}; appliesTo=next-match";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ShrinkSave.UnregisterModule(SaveModuleKey);
|
||||
if (ReferenceEquals(Current, this)) Current = null;
|
||||
}
|
||||
|
||||
private void DriveAiIfNeeded()
|
||||
{
|
||||
while (Match != null && Match.State.Phase != ReplacedMatchPhase.Completed)
|
||||
{
|
||||
var expected = Match.State.Phase == ReplacedMatchPhase.AwaitingLead
|
||||
? Match.State.LeadPlayer
|
||||
: 1 - Match.State.LeadPlayer;
|
||||
if (expected != 1) break;
|
||||
var ai = new ReplacedAi(Match.Content);
|
||||
var submission = ai.Choose(Match.State.Clone(), 1);
|
||||
var command = NewCommand(1, submission);
|
||||
var before = Match.State.Clone();
|
||||
var result = Match.Apply(command);
|
||||
if (!result.Accepted) throw new InvalidOperationException("AI produced illegal command: " + result.ErrorCode);
|
||||
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
|
||||
{
|
||||
PlayerIndex = 1,
|
||||
NormalCardIds = submission.NormalCardIds.ToArray(),
|
||||
EndCardIds = submission.EndCardIds.ToArray()
|
||||
});
|
||||
PublishStateEvents(before, Match.State);
|
||||
}
|
||||
CompleteRewardsIfNeeded();
|
||||
}
|
||||
|
||||
private ReplacedMatchCommand NewCommand(int playerIndex, ReplacedTurnSubmission submission) => new()
|
||||
{
|
||||
Sequence = ++_sequence,
|
||||
IdempotencyToken = $"local-{playerIndex}-{_sequence}",
|
||||
PlayerIndex = playerIndex,
|
||||
Submission = submission
|
||||
};
|
||||
|
||||
private void PublishStateEvents(ReplacedMatchState? before, ReplacedMatchState? after)
|
||||
{
|
||||
if (Match == null) return;
|
||||
var state = after ?? Match.State;
|
||||
EventBus.TriggerEvent(new ReplacedMatchPhaseEvent
|
||||
{
|
||||
Round = state.Round,
|
||||
MatchPhase = state.Phase,
|
||||
LeadPlayer = state.LeadPlayer,
|
||||
StateHash = Match.ComputeStateHash()
|
||||
});
|
||||
if (before == null || !SameResolution(before.LastResolution, state.LastResolution))
|
||||
foreach (var slot in state.LastResolution)
|
||||
{
|
||||
EventBus.TriggerEvent(new ReplacedDiceEvent
|
||||
{
|
||||
Slot = slot.SlotIndex,
|
||||
PlayerOneRoll = slot.PlayerOneRoll,
|
||||
PlayerTwoRoll = slot.PlayerTwoRoll
|
||||
});
|
||||
}
|
||||
if (before == null) return;
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var damage = Math.Max(0, before.Players[i].Health - state.Players[i].Health);
|
||||
if (damage > 0)
|
||||
EventBus.TriggerEvent(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health });
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameResolution(IReadOnlyList<ReplacedSlotResolution> left, IReadOnlyList<ReplacedSlotResolution> right)
|
||||
{
|
||||
if (left.Count != right.Count) return false;
|
||||
for (var i = 0; i < left.Count; i++)
|
||||
{
|
||||
var a = left[i];
|
||||
var b = right[i];
|
||||
if (a.SlotIndex != b.SlotIndex || a.PlayerOneRoll != b.PlayerOneRoll || a.PlayerTwoRoll != b.PlayerTwoRoll ||
|
||||
a.PlayerOneValue != b.PlayerOneValue || a.PlayerTwoValue != b.PlayerTwoValue ||
|
||||
a.DamageToPlayerOne != b.DamageToPlayerOne || a.DamageToPlayerTwo != b.DamageToPlayerTwo ||
|
||||
!string.Equals(a.Summary, b.Summary, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CompleteRewardsIfNeeded()
|
||||
{
|
||||
if (Match?.State.Phase != ReplacedMatchPhase.Completed || Match.State.Winner != ReplacedWinner.PlayerOne)
|
||||
return;
|
||||
if (!Content.Current.Enemies.TryGetValue(ActiveEnemyId, out var enemy)) return;
|
||||
foreach (var reward in enemy.RewardIds)
|
||||
{
|
||||
var target = reward.StartsWith("ornament.", StringComparison.Ordinal) ? _saveData.Ornaments : _saveData.CollectedCards;
|
||||
if (target.Contains(reward)) continue;
|
||||
target.Add(reward);
|
||||
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = reward });
|
||||
}
|
||||
_saveData.ChapterProgress = Math.Max(_saveData.ChapterProgress, ActiveEnemyId == "greed.full" ? 2 : 1);
|
||||
_saveData.RecentReplay = Match.Replay.Select(value => value.Clone()).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26ea89774bd6b554695216e19dabc806
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
|
||||
using ReplacedPerson.Core;
|
||||
using ShrinkModFramework;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
public static class ReplacedPersonModBridge
|
||||
{
|
||||
public const string RegistryName = "replaced-person.mods";
|
||||
|
||||
public static ReplacedContentCatalog BuildCatalogSnapshot()
|
||||
{
|
||||
var registry = new ReplacedPersonRegistry(ReplacedBuiltInContent.Create());
|
||||
var mods = ShrinkModLoader.GetOrCreateRegistry<IReplacedPersonMod>(RegistryName);
|
||||
foreach (var entry in mods.Entries)
|
||||
entry.Value.Register(registry);
|
||||
return registry.CreateSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c13614f09905f8c4190e7ddd52680d03
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[ExecuteAlways]
|
||||
public sealed class ReplacedSafeArea : MonoBehaviour
|
||||
{
|
||||
private RectTransform? _rectTransform;
|
||||
private Rect _lastSafeArea;
|
||||
private Vector2Int _lastScreen;
|
||||
|
||||
private void OnEnable() => Apply();
|
||||
private void OnRectTransformDimensionsChange() => Apply();
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
if (Screen.width <= 0 || Screen.height <= 0) return;
|
||||
var safe = Screen.safeArea;
|
||||
var screen = new Vector2Int(Screen.width, Screen.height);
|
||||
if (safe == _lastSafeArea && screen == _lastScreen) return;
|
||||
if (_rectTransform == null) _rectTransform = GetComponent<RectTransform>();
|
||||
if (_rectTransform == null) return;
|
||||
_rectTransform.anchorMin = new Vector2(safe.xMin / Screen.width, safe.yMin / Screen.height);
|
||||
_rectTransform.anchorMax = new Vector2(safe.xMax / Screen.width, safe.yMax / Screen.height);
|
||||
_rectTransform.offsetMin = Vector2.zero;
|
||||
_rectTransform.offsetMax = Vector2.zero;
|
||||
_lastSafeArea = safe;
|
||||
_lastScreen = screen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 006a7f2ab32b30c4da0aeb090e252e49
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,50 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class ReplacedScreenMotion : MonoBehaviour
|
||||
{
|
||||
private CanvasGroup? _group;
|
||||
private Coroutine? _animation;
|
||||
|
||||
public void Play()
|
||||
{
|
||||
if (_group == null) _group = GetComponent<CanvasGroup>() ?? gameObject.AddComponent<CanvasGroup>();
|
||||
if (_animation != null) StopCoroutine(_animation);
|
||||
_animation = StartCoroutine(Animate());
|
||||
}
|
||||
|
||||
private IEnumerator Animate()
|
||||
{
|
||||
if (_group == null) yield break;
|
||||
_group.alpha = 0f;
|
||||
transform.localScale = Vector3.one * .985f;
|
||||
const float duration = .22f;
|
||||
var elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / duration);
|
||||
var eased = 1f - Mathf.Pow(1f - t, 3f);
|
||||
_group.alpha = eased;
|
||||
transform.localScale = Vector3.one * Mathf.Lerp(.985f, 1f, eased);
|
||||
yield return null;
|
||||
}
|
||||
_group.alpha = 1f;
|
||||
transform.localScale = Vector3.one;
|
||||
_animation = null;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_animation != null) StopCoroutine(_animation);
|
||||
_animation = null;
|
||||
if (_group != null) _group.alpha = 1f;
|
||||
transform.localScale = Vector3.one;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34b12eb7381cd8047a33a393981e16d9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections;
|
||||
using ReplacedPerson.Core;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace ReplacedPerson.Runtime
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class ReplacedSlotResolutionMotion : MonoBehaviour
|
||||
{
|
||||
private Image? _background;
|
||||
private TMP_Text? _mainText;
|
||||
private RectTransform? _leftToken;
|
||||
private RectTransform? _rightToken;
|
||||
private CanvasGroup? _leftGroup;
|
||||
private CanvasGroup? _rightGroup;
|
||||
private TMP_Text? _leftLabel;
|
||||
private TMP_Text? _rightLabel;
|
||||
private Coroutine? _animation;
|
||||
private Color _baseColor;
|
||||
private float _baseFontSize;
|
||||
|
||||
public bool IsPlaying => _animation != null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_background = GetComponent<Image>();
|
||||
_mainText = transform.Find("Text")?.GetComponent<TMP_Text>();
|
||||
if (_background != null) _baseColor = _background.color;
|
||||
if (_mainText != null) _baseFontSize = _mainText.fontSize;
|
||||
CreateToken("PlayerToken", out _leftToken, out _leftGroup, out _leftLabel);
|
||||
CreateToken("EnemyToken", out _rightToken, out _rightGroup, out _rightLabel);
|
||||
SetTokensActive(false);
|
||||
}
|
||||
|
||||
public void Play(ReplacedSlotResolution resolution, int localPlayer)
|
||||
{
|
||||
if (_animation != null) StopCoroutine(_animation);
|
||||
_animation = StartCoroutine(Animate(resolution, localPlayer));
|
||||
}
|
||||
|
||||
private IEnumerator Animate(ReplacedSlotResolution resolution, int localPlayer)
|
||||
{
|
||||
if (_background == null || _mainText == null || _leftToken == null || _rightToken == null ||
|
||||
_leftGroup == null || _rightGroup == null || _leftLabel == null || _rightLabel == null)
|
||||
yield break;
|
||||
|
||||
var localIsOne = localPlayer == 0;
|
||||
var localType = localIsOne ? resolution.PlayerOneType : resolution.PlayerTwoType;
|
||||
var enemyType = localIsOne ? resolution.PlayerTwoType : resolution.PlayerOneType;
|
||||
var localRoll = localIsOne ? resolution.PlayerOneRoll : resolution.PlayerTwoRoll;
|
||||
var enemyRoll = localIsOne ? resolution.PlayerTwoRoll : resolution.PlayerOneRoll;
|
||||
var localValue = localIsOne ? resolution.PlayerOneValue : resolution.PlayerTwoValue;
|
||||
var enemyValue = localIsOne ? resolution.PlayerTwoValue : resolution.PlayerOneValue;
|
||||
var localRerolled = localIsOne ? resolution.PlayerOneRerolled : resolution.PlayerTwoRerolled;
|
||||
var enemyRerolled = localIsOne ? resolution.PlayerTwoRerolled : resolution.PlayerOneRerolled;
|
||||
var localDamage = localIsOne ? resolution.DamageToPlayerOne : resolution.DamageToPlayerTwo;
|
||||
var enemyDamage = localIsOne ? resolution.DamageToPlayerTwo : resolution.DamageToPlayerOne;
|
||||
|
||||
SetTokensActive(true);
|
||||
_leftGroup.alpha = 1f;
|
||||
_rightGroup.alpha = 1f;
|
||||
_leftLabel.text = "我方\n" + TypeName(localType);
|
||||
_rightLabel.text = "敌方\n" + TypeName(enemyType);
|
||||
_leftToken.GetComponent<Image>().color = TypeColor(localType);
|
||||
_rightToken.GetComponent<Image>().color = TypeColor(enemyType);
|
||||
_mainText.text = $"槽 {resolution.SlotIndex + 1}\n对位";
|
||||
_leftToken.anchoredPosition = new Vector2(-280, 0);
|
||||
_rightToken.anchoredPosition = new Vector2(280, 0);
|
||||
transform.localScale = Vector3.one;
|
||||
|
||||
_mainText.fontSize = Mathf.Max(_baseFontSize, 23f);
|
||||
const float approachDuration = .28f;
|
||||
var elapsed = 0f;
|
||||
while (elapsed < approachDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.SmoothStep(0, 1, Mathf.Clamp01(elapsed / approachDuration));
|
||||
_leftToken.anchoredPosition = Vector2.Lerp(new Vector2(-280, 0), new Vector2(-82, 0), t);
|
||||
_rightToken.anchoredPosition = Vector2.Lerp(new Vector2(280, 0), new Vector2(82, 0), t);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
transform.localScale = Vector3.one * 1.045f;
|
||||
_mainText.text = $"槽 {resolution.SlotIndex + 1}\n投掷中";
|
||||
_leftLabel.fontSize = 21;
|
||||
_rightLabel.fontSize = 21;
|
||||
yield return new WaitForSecondsRealtime(.04f);
|
||||
transform.localScale = Vector3.one;
|
||||
for (var frame = 0; frame < 8; frame++)
|
||||
{
|
||||
var leftFace = (frame * 5 + 1) % 6 + 1;
|
||||
var rightFace = (frame * 3 + 4) % 6 + 1;
|
||||
_leftLabel.text = $"我方\nD6 {leftFace}";
|
||||
_rightLabel.text = $"敌方\nD6 {rightFace}";
|
||||
_leftToken.localRotation = Quaternion.Euler(0, 0, frame % 2 == 0 ? -2f : 2f);
|
||||
_rightToken.localRotation = Quaternion.Euler(0, 0, frame % 2 == 0 ? 2f : -2f);
|
||||
yield return new WaitForSecondsRealtime(.075f);
|
||||
}
|
||||
|
||||
_leftToken.localRotation = Quaternion.identity;
|
||||
_rightToken.localRotation = Quaternion.identity;
|
||||
_leftLabel.text = $"我方\nD6 {Roll(localRoll, localRerolled)}";
|
||||
_rightLabel.text = $"敌方\nD6 {Roll(enemyRoll, enemyRerolled)}";
|
||||
_mainText.fontSize = Mathf.Max(_baseFontSize, 27f);
|
||||
_mainText.text = $"槽 {resolution.SlotIndex + 1}\n骰点 {localRoll} : {enemyRoll}";
|
||||
yield return new WaitForSecondsRealtime(.75f);
|
||||
var flash = localDamage > 0 ? new Color(.55f, .10f, .09f, 1f) : enemyDamage > 0 ? new Color(.45f, .32f, .10f, 1f) : new Color(.24f, .29f, .27f, 1f);
|
||||
_background.color = flash;
|
||||
_leftLabel.text = $"我方\n{TypeName(localType)} {localValue}";
|
||||
_rightLabel.text = $"敌方\n{TypeName(enemyType)} {enemyValue}";
|
||||
_mainText.fontSize = Mathf.Max(_baseFontSize, 21f);
|
||||
_mainText.text = $"槽 {resolution.SlotIndex + 1}\n{TypeName(localType)} {localValue} : {enemyValue} {TypeName(enemyType)}\n{resolution.Summary}";
|
||||
yield return new WaitForSecondsRealtime(.55f);
|
||||
|
||||
elapsed = 0f;
|
||||
const float fadeDuration = .22f;
|
||||
while (elapsed < fadeDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
var t = Mathf.Clamp01(elapsed / fadeDuration);
|
||||
_leftGroup.alpha = 1f - t;
|
||||
_rightGroup.alpha = 1f - t;
|
||||
_background.color = Color.Lerp(flash, _baseColor, t);
|
||||
yield return null;
|
||||
}
|
||||
_background.color = _baseColor;
|
||||
_mainText.fontSize = _baseFontSize;
|
||||
transform.localScale = Vector3.one;
|
||||
SetTokensActive(false);
|
||||
_animation = null;
|
||||
}
|
||||
|
||||
private void CreateToken(string tokenName, out RectTransform token, out CanvasGroup group, out TMP_Text label)
|
||||
{
|
||||
var tokenObject = new GameObject(tokenName, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(CanvasGroup));
|
||||
token = tokenObject.GetComponent<RectTransform>();
|
||||
token.SetParent(transform, false);
|
||||
token.anchorMin = token.anchorMax = new Vector2(.5f, .5f);
|
||||
token.sizeDelta = new Vector2(142, 86);
|
||||
tokenObject.GetComponent<Image>().raycastTarget = false;
|
||||
group = tokenObject.GetComponent<CanvasGroup>();
|
||||
group.blocksRaycasts = false;
|
||||
|
||||
var labelObject = new GameObject("Label", typeof(RectTransform), typeof(CanvasRenderer), typeof(TextMeshProUGUI));
|
||||
var labelRect = labelObject.GetComponent<RectTransform>();
|
||||
labelRect.SetParent(token, false);
|
||||
labelRect.anchorMin = Vector2.zero;
|
||||
labelRect.anchorMax = Vector2.one;
|
||||
labelRect.offsetMin = new Vector2(8, 5);
|
||||
labelRect.offsetMax = new Vector2(-8, -5);
|
||||
label = labelObject.GetComponent<TextMeshProUGUI>();
|
||||
label.font = _mainText != null ? _mainText.font : null;
|
||||
label.fontSize = 20;
|
||||
label.alignment = TextAlignmentOptions.Center;
|
||||
label.color = new Color(.96f, .94f, .88f, 1f);
|
||||
label.raycastTarget = false;
|
||||
}
|
||||
|
||||
private void SetTokensActive(bool active)
|
||||
{
|
||||
if (_leftToken != null) _leftToken.gameObject.SetActive(active);
|
||||
if (_rightToken != null) _rightToken.gameObject.SetActive(active);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_animation != null) StopCoroutine(_animation);
|
||||
_animation = null;
|
||||
if (_background != null) _background.color = _baseColor;
|
||||
if (_mainText != null) _mainText.fontSize = _baseFontSize;
|
||||
transform.localScale = Vector3.one;
|
||||
if (_leftToken != null) _leftToken.localRotation = Quaternion.identity;
|
||||
if (_rightToken != null) _rightToken.localRotation = Quaternion.identity;
|
||||
SetTokensActive(false);
|
||||
}
|
||||
|
||||
private static string Roll(int value, bool rerolled) => rerolled ? value + "(重掷)" : value.ToString();
|
||||
|
||||
private static string TypeName(ReplacedEffectType type) => type switch
|
||||
{
|
||||
ReplacedEffectType.Attack => "攻击",
|
||||
ReplacedEffectType.Dodge => "闪避",
|
||||
ReplacedEffectType.Counter => "反击",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
private static Color TypeColor(ReplacedEffectType type) => type switch
|
||||
{
|
||||
ReplacedEffectType.Attack => new Color(.63f, .13f, .11f, .98f),
|
||||
ReplacedEffectType.Dodge => new Color(.18f, .31f, .29f, .98f),
|
||||
ReplacedEffectType.Counter => new Color(.42f, .31f, .16f, .98f),
|
||||
_ => new Color(.22f, .23f, .23f, .98f)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb5c58605d386e6448f8a273ba97cb90
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user