Files
2026-08-17 18:18:13 +08:00

948 lines
48 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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);
}
}
}