This commit is contained in:
2026-08-18 02:03:34 +08:00
parent d2cd3b9fa8
commit 517c4cf46e
157 changed files with 16536 additions and 37 deletions
@@ -0,0 +1,499 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using Demo2.Domain;
using Demo2.ECS;
using ShrinkTutorial;
using TMPro;
using Unity.Entities;
using UnityEngine;
using UnityEngine.UIElements;
namespace Demo2.Runtime
{
public sealed class Demo2DemoController : MonoBehaviour
{
private enum ScreenKind { Title, Lobby, Factory, Reward, End }
[SerializeField] private Camera? worldCamera;
[SerializeField] private Demo2MachineProxyPool? proxyPool;
[SerializeField] private Demo2ItemBatchRenderer? itemBatchRenderer;
[SerializeField] private UIDocument? uiDocument;
[SerializeField] private StyleSheet? uiStyleSheet;
[SerializeField] private Sprite? titleBackground;
[SerializeField] private Sprite[] machineIcons = Array.Empty<Sprite>();
[SerializeField] private Font? uiFont;
[SerializeField] private TMP_FontAsset? tutorialFont;
[SerializeField] private RectTransform? tutorialAnchorRoot;
private readonly Dictionary<ScreenKind, VisualElement> _screens = new();
private readonly Dictionary<string, RectTransform> _tutorialAnchors = new(StringComparer.Ordinal);
private Demo2GameService? _service;
private Demo2LanRuntime? _lan;
private Demo2GridInput? _gridInput;
private VisualElement? _root;
private ScreenKind _screen;
private Demo2MachineKind _selectedKind = Demo2MachineKind.Conveyor;
private byte _rotation;
private bool _removeMode;
private Demo2TransportMode _transportMode = Demo2TransportMode.Tcp;
private bool _dirty = true;
private bool _uiReady;
private float _visualTimer;
private long _networkBuildSequence;
private int _selectedRecipeId;
private static readonly (string anchorId, string elementName)[] TutorialTargets =
{
("demo2.miner", "miner-button"),
("demo2.conveyor", "conveyor-button"),
("demo2.grid", "grid-input-surface"),
("demo2.smelter", "smelter-button"),
("demo2.recipe", "recipe-button"),
("demo2.turret", "turret-button"),
("demo2.ready", "ready-button"),
("demo2.reward", "reward-choose-1")
};
private void Awake()
{
if (Application.isBatchMode || Array.IndexOf(Environment.GetCommandLineArgs(), "-demo2-headless") >= 0)
{
enabled = false;
return;
}
_service = Demo2GameService.Current ?? new Demo2GameService();
_lan = new Demo2LanRuntime(_service);
if (tutorialFont != null && !TMP_Settings.fallbackFontAssets.Contains(tutorialFont)) TMP_Settings.fallbackFontAssets.Add(tutorialFont);
var tutorialSettings = Resources.Load<ShrinkTutorialSettings>("Demo2TutorialSettings");
if (tutorialSettings != null) ShrinkTutorialManager.EnsureInstance(tutorialSettings);
_service.Changed += MarkDirty;
_lan.Changed += MarkDirty;
}
private void Start()
{
if (uiDocument == null) throw new InvalidOperationException("Demo2 UIDocument is not assigned.");
_root = uiDocument.rootVisualElement;
if (uiStyleSheet != null && !_root.styleSheets.Contains(uiStyleSheet)) _root.styleSheets.Add(uiStyleSheet);
if (uiFont != null) _root.style.unityFont = uiFont;
CacheScreens();
CacheTutorialAnchors();
ApplyArt();
BindTitle();
BindLobby();
BindFactory();
BindReward();
BindEnd();
if (worldCamera == null) throw new InvalidOperationException("Demo2 WorldCamera is not assigned.");
_gridInput = new Demo2GridInput(Require<VisualElement>("grid-input-surface"), worldCamera);
_gridInput.GridClicked += OnGridClicked;
_root.RegisterCallback<GeometryChangedEvent>(_ =>
{
ApplySafeArea();
SyncTutorialAnchors();
});
_uiReady = true;
Show(ScreenKind.Title);
_root.schedule.Execute(ApplySafeArea).ExecuteLater(1);
}
private void Update()
{
if (!_uiReady) return;
_lan?.Pump();
if (_screen == ScreenKind.Factory && ShrinkTutorialManager.Instance?.IsRunning != true) _service?.AdvanceFrame(Time.unscaledDeltaTime);
RoutePhaseScreen();
_visualTimer -= Time.unscaledDeltaTime;
if (_visualTimer <= 0)
{
_visualTimer = 0.1f;
proxyPool?.Refresh(_service?.EcsWorld);
itemBatchRenderer?.Bind(_service?.EcsWorld);
_dirty = true;
}
if (_dirty) Refresh();
}
private void OnDestroy()
{
if (_service != null) _service.Changed -= MarkDirty;
if (_lan != null) _lan.Changed -= MarkDirty;
if (_gridInput != null)
{
_gridInput.GridClicked -= OnGridClicked;
_gridInput.Dispose();
}
_lan?.Dispose();
}
private void CacheScreens()
{
_screens[ScreenKind.Title] = Require<VisualElement>("title-screen");
_screens[ScreenKind.Lobby] = Require<VisualElement>("lobby-screen");
_screens[ScreenKind.Factory] = Require<VisualElement>("factory-screen");
_screens[ScreenKind.Reward] = Require<VisualElement>("reward-screen");
_screens[ScreenKind.End] = Require<VisualElement>("end-screen");
}
private void CacheTutorialAnchors()
{
if (tutorialAnchorRoot == null) return;
foreach (var target in TutorialTargets)
{
var child = tutorialAnchorRoot.Find(target.anchorId);
if (child is RectTransform rect) _tutorialAnchors[target.anchorId] = rect;
}
}
private void ApplyArt()
{
if (titleBackground != null) Require<VisualElement>("title-background").style.backgroundImage = new StyleBackground(titleBackground);
var names = new[] { "miner", "conveyor", "splitter", "smelter", "assembler", "loader", "turret", "generator" };
for (var i = 0; i < names.Length && i < machineIcons.Length; i++)
if (machineIcons[i] != null) Require<VisualElement>(names[i] + "-icon").style.backgroundImage = new StyleBackground(machineIcons[i]);
}
private void BindTitle()
{
Bind("solo-button", StartSolo);
Bind("host-button", HostLan);
Bind("join-button", JoinLan);
Bind("transport-button", ToggleTransport);
}
private void BindLobby()
{
Bind("lobby-back-button", LeaveLobby);
Bind("lobby-ready-button", () => _lan?.SetReady(true));
Bind("lobby-start-button", StartHostedMatch);
}
private void BindFactory()
{
var pairs = new[]
{
(Demo2MachineKind.Miner, "miner-button"),
(Demo2MachineKind.Conveyor, "conveyor-button"),
(Demo2MachineKind.Splitter, "splitter-button"),
(Demo2MachineKind.Smelter, "smelter-button"),
(Demo2MachineKind.Assembler, "assembler-button"),
(Demo2MachineKind.Loader, "loader-button"),
(Demo2MachineKind.Turret, "turret-button"),
(Demo2MachineKind.Generator, "generator-button")
};
foreach (var pair in pairs)
{
var local = pair.Item1;
Bind(pair.Item2, () => SelectMachine(local));
}
Bind("rotate-button", () => { _rotation = (byte)((_rotation + 1) & 3); _dirty = true; });
Bind("remove-button", () => { _removeMode = !_removeMode; _dirty = true; });
Bind("ready-button", ReadyForProduction);
Bind("speed-1-button", () => _service?.SetSpeed(1));
Bind("speed-4-button", () => _service?.SetSpeed(4));
Bind("speed-10-button", () => _service?.SetSpeed(10));
Bind("save-button", () => _service?.SaveAsync().Forget(LogException));
Bind("load-button", () => _service?.LoadAsync().Forget(LogException));
Bind("exit-button", ExitToTitle);
Bind("recipe-button", ConfirmRecipe);
Bind("rule-up-1", () => _service?.ReorderRule(0, 0));
Bind("rule-up-2", () => _service?.ReorderRule(1, 0));
Bind("rule-up-3", () => _service?.ReorderRule(2, 1));
Bind("rule-up-4", () => _service?.ReorderRule(3, 2));
Bind("rule-up-5", () => _service?.ReorderRule(4, 3));
}
private void BindReward()
{
Bind("reward-choose-1", () => ChooseReward(0));
Bind("reward-choose-2", () => ChooseReward(1));
Bind("reward-choose-3", () => ChooseReward(2));
}
private void BindEnd()
{
Bind("restart-button", StartSolo);
Bind("title-button", ExitToTitle);
}
private void StartSolo()
{
_lan?.Stop();
_service?.StartNewGame();
Show(ScreenKind.Factory);
ShrinkTutorialManager.Instance?.StartTutorial("demo2.first-cycle");
}
private void HostLan()
{
_service?.StartNewGame();
_lan?.Host(27320, _transportMode, "host");
Show(ScreenKind.Lobby);
}
private void JoinLan()
{
var value = Require<TextField>("direct-ip-input").value;
var host = string.IsNullOrWhiteSpace(value) ? "127.0.0.1" : value.Trim();
_service?.StartNewGame(20260817);
_lan?.Join(host, 27320, _transportMode, "client");
Show(ScreenKind.Lobby);
}
private void StartHostedMatch()
{
if (_lan?.IsHost == true) Show(ScreenKind.Factory);
}
private void LeaveLobby()
{
_lan?.Stop();
Show(ScreenKind.Title);
}
private void ExitToTitle()
{
_lan?.Stop();
Show(ScreenKind.Title);
}
private void ToggleTransport()
{
_transportMode = _transportMode == Demo2TransportMode.Tcp ? Demo2TransportMode.Kcp : Demo2TransportMode.Tcp;
_dirty = true;
}
private void SelectMachine(Demo2MachineKind kind)
{
_selectedKind = kind;
_selectedRecipeId = _service != null && _service.Content.Machines.TryGetValue(kind, out var machine) ? machine.RecipeId : 0;
_removeMode = false;
_dirty = true;
if (kind == Demo2MachineKind.Miner) ShrinkTutorialManager.Instance?.CompleteStep("demo2.miner-selected");
if (kind == Demo2MachineKind.Conveyor) ShrinkTutorialManager.Instance?.CompleteStep("demo2.conveyor-selected");
if (kind == Demo2MachineKind.Smelter) ShrinkTutorialManager.Instance?.CompleteStep("demo2.smelter-selected");
if (kind == Demo2MachineKind.Turret) ShrinkTutorialManager.Instance?.CompleteStep("demo2.turret-selected");
}
private void ConfirmRecipe()
{
if (_service != null && _selectedRecipeId == 0 && _service.Content.Recipes.Count > 0)
_selectedRecipeId = _service.Content.Recipes.Keys.OrderBy(value => value).First();
ShrinkTutorialManager.Instance?.CompleteStep("demo2.recipe-selected");
_dirty = true;
}
private void ReadyForProduction()
{
if (_service == null) return;
if (_lan?.IsConnected == true) _lan.SetReady(true);
else _service.SetReady("local", true);
ShrinkTutorialManager.Instance?.CompleteStep("demo2.production-started");
}
private void OnGridClicked(int x, int y, int button)
{
if (_service == null || _service.Phase != Demo2Phase.Build) return;
var remove = _removeMode || button == 1;
var command = new Demo2BuildCommand
{
Sequence = ++_networkBuildSequence,
IdempotencyToken = (_lan?.LocalPlayerId ?? "local") + "-" + _networkBuildSequence,
PlayerId = _lan?.LocalPlayerId ?? "local",
Kind = _selectedKind,
X = x,
Y = y,
Rotation = _rotation,
RecipeId = _selectedRecipeId,
Remove = remove
};
if (_lan?.IsConnected == true) _lan.SendBuild(command);
else
{
var result = _service.SubmitBuild(command);
if (result.Accepted && !remove)
{
if (_selectedKind == Demo2MachineKind.Miner) ShrinkTutorialManager.Instance?.CompleteStep("demo2.miner-placed");
if (_selectedKind == Demo2MachineKind.Conveyor) ShrinkTutorialManager.Instance?.CompleteStep("demo2.conveyor-placed");
if (_selectedKind == Demo2MachineKind.Turret) ShrinkTutorialManager.Instance?.CompleteStep("demo2.turret-placed");
}
}
}
private void ChooseReward(int index)
{
if (_service == null || index < 0 || index >= _service.RewardChoices.Count) return;
var ruleId = _service.RewardChoices[index];
if (_lan?.IsConnected == true) _lan.ChooseReward(ruleId, -1);
else if (_service.ChooseReward(ruleId)) ShrinkTutorialManager.Instance?.CompleteStep("demo2.rule-chosen");
}
private void RoutePhaseScreen()
{
if (_service == null || _screen is ScreenKind.Title or ScreenKind.Lobby) return;
if (_service.Phase == Demo2Phase.Reward && _screen != ScreenKind.Reward) Show(ScreenKind.Reward);
else if (_service.Phase is Demo2Phase.Victory or Demo2Phase.Defeat && _screen != ScreenKind.End) Show(ScreenKind.End);
else if (_service.Phase is Demo2Phase.Build or Demo2Phase.Production or Demo2Phase.Defense && _screen != ScreenKind.Factory) Show(ScreenKind.Factory);
}
private void Show(ScreenKind screen)
{
_screen = screen;
foreach (var pair in _screens) pair.Value.style.display = pair.Key == screen ? DisplayStyle.Flex : DisplayStyle.None;
if (worldCamera != null) worldCamera.gameObject.SetActive(screen == ScreenKind.Factory);
if (proxyPool != null) proxyPool.gameObject.SetActive(screen == ScreenKind.Factory);
if (itemBatchRenderer != null) itemBatchRenderer.gameObject.SetActive(screen == ScreenKind.Factory);
_dirty = true;
_root?.schedule.Execute(SyncTutorialAnchors).ExecuteLater(1);
}
private void Refresh()
{
_dirty = false;
SetText("transport-button", "传输:" + _transportMode.ToString().ToUpperInvariant());
if (_service == null) return;
SetText("lobby-status", _lan?.Status ?? "offline");
SetText("lobby-players", BuildPlayerList());
Require<Button>("lobby-start-button").SetEnabled(_lan?.IsHost == true);
if (_service.EcsWorld != null)
{
var manager = _service.EcsWorld.EntityManager;
var economy = manager.GetComponentData<Demo2EconomyState>(_service.EcsWorld.Singleton);
var core = manager.GetComponentData<Demo2CoreHealth>(_service.EcsWorld.Singleton);
SetText("core-text", "核心 " + new string('◆', Math.Max(0, core.Value)));
SetText("money-text", "资金 " + SaturatingMath.Format(economy.Money));
SetText("phase-text", $"周期 {_service.Cycle}/5 · {PhaseName(_service.Phase)}");
SetText("timer-text", TimeSpan.FromSeconds(_service.PhaseRemainingSeconds).ToString(@"mm\:ss"));
SetText("target-text", $"目标火力 {SaturatingMath.Format(_service.Content.Waves[Math.Max(0, _service.Cycle - 1)].TotalHealth)}\n载荷 × 倍率\n{SaturatingMath.Format(economy.Payload)} × {economy.MultiplierMilli / 1000f:0.00}");
}
SetText("selection-text", (_removeMode ? "拆除模式" : "建造 " + MachineName(_selectedKind)) + $" · 朝向 {_rotation * 90}°");
SetText("error-text", _service.LastError);
SetText("network-text", _lan?.Status ?? "单人本地");
var recipeName = _service.Content.Recipes.TryGetValue(_selectedRecipeId, out var recipe) ? recipe.Name : "无";
SetText("recipe-button", "配方:" + recipeName);
RefreshMachineSelection();
RefreshRules();
RefreshRewards();
SetText("end-title", _service.Phase == Demo2Phase.Victory ? "工厂守住了核心" : "核心防线失守");
SetText("end-summary", $"最终周期 {_service.Cycle}/5\n火力 {SaturatingMath.Format(_service.LastFirepower)} / 敌军 {SaturatingMath.Format(_service.LastEnemyHealth)}");
}
private void RefreshMachineSelection()
{
var names = new[] { "miner", "conveyor", "splitter", "smelter", "assembler", "loader", "turret", "generator" };
var kinds = new[] { Demo2MachineKind.Miner, Demo2MachineKind.Conveyor, Demo2MachineKind.Splitter, Demo2MachineKind.Smelter, Demo2MachineKind.Assembler, Demo2MachineKind.Loader, Demo2MachineKind.Turret, Demo2MachineKind.Generator };
for (var i = 0; i < names.Length; i++) Require<Button>(names[i] + "-button").EnableInClassList("selected-machine", !_removeMode && kinds[i] == _selectedKind);
Require<Button>("remove-button").EnableInClassList("selected-remove", _removeMode);
}
private void RefreshRules()
{
for (var i = 0; i < 5; i++)
{
if (i < _service!.RuleIds.Count && _service.Content.Rules.TryGetValue(_service.RuleIds[i], out var rule))
{
SetText("rule-name-" + (i + 1), $"{i + 1}. {rule.Name}");
SetText("rule-description-" + (i + 1), rule.Description);
}
else
{
SetText("rule-name-" + (i + 1), $"{i + 1}. 空槽位");
SetText("rule-description-" + (i + 1), "防守成功后选择规则");
}
}
}
private void RefreshRewards()
{
for (var i = 0; i < 3; i++)
{
var button = Require<Button>("reward-choose-" + (i + 1));
Demo2RuleDefinition? rule = null;
var available = i < _service!.RewardChoices.Count && _service.Content.Rules.TryGetValue(_service.RewardChoices[i], out rule);
button.SetEnabled(available);
if (!available || rule == null) continue;
SetText("reward-category-" + (i + 1), CategoryName(rule.Category));
SetText("reward-name-" + (i + 1), rule.Name);
SetText("reward-description-" + (i + 1), rule.Description);
}
}
private string BuildPlayerList()
{
if (_lan == null || _lan.Players.Count == 0) return "等待房间同步…";
return string.Join("\n", _lan.Players.Select(player => (player.Connected ? "● " : "○ ") + player.DisplayName + (player.Ready ? " 已准备" : " 未准备")));
}
private void ApplySafeArea()
{
if (_root == null) return;
var safe = Require<VisualElement>("safe-area");
var area = Screen.safeArea;
var width = Mathf.Max(1, Screen.width);
var height = Mathf.Max(1, Screen.height);
var panelWidth = _root.resolvedStyle.width;
var panelHeight = _root.resolvedStyle.height;
if (float.IsNaN(panelWidth) || float.IsNaN(panelHeight) || panelWidth <= 0 || panelHeight <= 0) return;
safe.style.left = area.xMin / width * panelWidth;
safe.style.right = (width - area.xMax) / width * panelWidth;
safe.style.top = (height - area.yMax) / height * panelHeight;
safe.style.bottom = area.yMin / height * panelHeight;
}
private void SyncTutorialAnchors()
{
if (_root == null || tutorialAnchorRoot == null) return;
var panelWidth = Mathf.Max(1, _root.resolvedStyle.width);
var panelHeight = Mathf.Max(1, _root.resolvedStyle.height);
tutorialAnchorRoot.sizeDelta = new Vector2(Screen.width, Screen.height);
foreach (var target in TutorialTargets)
{
if (!_tutorialAnchors.TryGetValue(target.anchorId, out var anchor)) continue;
var element = _root.Q<VisualElement>(target.elementName);
var displayed = element != null && IsDisplayed(element);
anchor.gameObject.SetActive(displayed);
if (!displayed || element == null) continue;
var bounds = element.worldBound;
var x = bounds.xMin / panelWidth * Screen.width;
var y = Screen.height - bounds.yMax / panelHeight * Screen.height;
var width = bounds.width / panelWidth * Screen.width;
var height = bounds.height / panelHeight * Screen.height;
anchor.anchorMin = Vector2.zero;
anchor.anchorMax = Vector2.zero;
anchor.pivot = Vector2.zero;
anchor.anchoredPosition = new Vector2(x, y);
anchor.sizeDelta = new Vector2(width, height);
}
}
private static bool IsDisplayed(VisualElement element)
{
for (var current = element; current != null; current = current.parent)
if (current.resolvedStyle.display == DisplayStyle.None) return false;
return true;
}
private void Bind(string name, Action action) => Require<Button>(name).clicked += action;
private T Require<T>(string name) where T : VisualElement
=> _root?.Q<T>(name) ?? throw new InvalidOperationException("Missing Demo2 UI Toolkit element: " + name);
private void SetText(string name, string value)
{
var text = Require<TextElement>(name);
text.text = value;
}
private void MarkDirty() => _dirty = true;
private static string PhaseName(Demo2Phase phase) => phase switch { Demo2Phase.Build => "建造", Demo2Phase.Production => "自动生产", Demo2Phase.Defense => "炮塔结算", Demo2Phase.Reward => "规则选牌", _ => phase.ToString() };
private static string MachineName(Demo2MachineKind kind) => kind switch { Demo2MachineKind.Miner => "采集器", Demo2MachineKind.Conveyor => "传送带", Demo2MachineKind.Splitter => "分流器", Demo2MachineKind.Smelter => "熔炼机", Demo2MachineKind.Assembler => "组装机", Demo2MachineKind.Loader => "装填器", Demo2MachineKind.Turret => "炮塔", Demo2MachineKind.Generator => "能量站", _ => kind.ToString() };
private static string CategoryName(Demo2RuleCategory category) => category switch { Demo2RuleCategory.Production => "生产链修正", Demo2RuleCategory.Logistics => "物流条件", Demo2RuleCategory.MachineState => "机器状态", Demo2RuleCategory.CycleSettlement => "周期结算", Demo2RuleCategory.RiskExchange => "风险交换", _ => category.ToString() };
private static void LogException(Exception exception) => Debug.LogException(exception);
}
}