This commit is contained in:
2026-08-17 18:18:13 +08:00
parent 5765f411e9
commit d2cd3b9fa8
188 changed files with 32929 additions and 2 deletions
@@ -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: