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,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);
}
}
}