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,30 @@
{
"name": "Demo2.Runtime",
"rootNamespace": "Demo2.Runtime",
"references": [
"Demo2.Domain",
"Demo2.ECS",
"ShrinkApp.Core.Runtime",
"ShrinkContext.Core.Runtime",
"ShrinkContext.AppAdapter.Runtime",
"ShrinkCommand.Runtime",
"ShrinkDataSaver.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkModFramework.Runtime",
"ShrinkNetwork.Runtime",
"ShrinkTutorial.Runtime",
"Unity.Entities",
"Unity.Collections",
"Unity.TextMeshPro",
"UniTask",
"Newtonsoft.Json"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bdbbf7733031e954c9f1199aab787649
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkApp;
using ShrinkContext;
using ShrinkContext.AppAdapter;
using UnityEngine;
namespace Demo2.Runtime
{
[ShrinkAppModuleInstaller]
public sealed class Demo2AppInstaller : IShrinkAppModuleInstaller
{
public string ModuleId => "demo2.rule-factory";
public int Order => 1450;
public IReadOnlyList<string> DependsOn => new[] { "shrink.command", "shrink.datasaver", "shrink.network" };
public void RegisterServices(ShrinkAppContext context)
{
if (!context.Services.TryGet<Demo2GameService>(out _))
context.Services.Register(Demo2GameService.Current ?? new Demo2GameService());
}
public UniTask InitializeAsync(ShrinkAppContext context) => UniTask.CompletedTask;
}
public sealed class Demo2AppComponent : IShrinkComponent
{
public const string ModuleKey = "app.module.demo2.rule-factory";
public const string ServiceKey = "demo2.rule-factory.service";
private static readonly string[] InjectKeys = { "shrink.service.command", "shrink.service.datasaver", "shrink.service.network" };
private static readonly string[] ProvideKeys = { ModuleKey, ServiceKey };
public string Name => "demo2.rule-factory";
public IReadOnlyList<string> Inject => InjectKeys;
public IReadOnlyList<string> Provide => ProvideKeys;
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
var services = config as ShrinkAppServices;
var service = services != null && services.TryGet<Demo2GameService>(out var existing) && existing != null
? existing
: Demo2GameService.Current ?? new Demo2GameService();
services?.Register(service);
ctx.Set(ModuleKey, Name);
ctx.Set(ServiceKey, service);
ctx.EffectInverse(() => { services?.TryUnregister(service); service.Dispose(); return UniTask.CompletedTask; });
return UniTask.CompletedTask;
}
}
public static class Demo2ContextComposition
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
private static void Register()
{
var previous = ShrinkAppLoaderBootstrapper.DefaultComposition;
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
{
previous?.Invoke(host);
if (host.ModuleIds.Contains("demo2.rule-factory")) host.OverrideModuleComponent("demo2.rule-factory", static () => new Demo2AppComponent());
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 63a0253219733aa478692174eb28f937
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
#nullable enable
using Demo2.Domain;
using ShrinkCommand;
namespace Demo2.Runtime
{
[ShrinkCommandSubscriber]
public static class Demo2Commands
{
[ShrinkCommand("demo2 status", Description = "显示规则工厂状态")]
private static string Status() => Service().BuildStatus();
[ShrinkCommand("demo2 spawn-item <kind> <quantity>", Description = "生成物料", Permission = "demo2.admin.spawn")]
private static string SpawnItem(Demo2MaterialKind kind, int quantity) { Service().SpawnItem(kind, quantity); return Service().BuildStatus(); }
[ShrinkCommand("demo2 set-speed <speed>", Description = "设置模拟速度", Permission = "demo2.admin.speed")]
private static string SetSpeed(int speed) { Service().SetSpeed(speed); return "speed=" + Service().Speed; }
[ShrinkCommand("demo2 start-wave", Description = "立即推进到下一战斗阶段", Permission = "demo2.admin.wave")]
private static string StartWave() { Service().StartWaveImmediately(); return Service().BuildStatus(); }
[ShrinkCommand("demo2 dump-ecs", Description = "导出 ECS 状态", Permission = "demo2.admin.dump")]
private static string DumpEcs() => Service().DumpEcs();
[ShrinkCommand("demo2 reload-mods", Description = "重载并暂存 Demo2 Mod", Permission = "demo2.admin.mods")]
private static string ReloadMods() => Service().StageModReload();
private static Demo2GameService Service() => Demo2GameService.Current ?? throw new System.InvalidOperationException("Demo2 service unavailable.");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2915c77f31121514da70e4fd14c27641
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 126e2a7bc933f11489df489559327921
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+14
View File
@@ -0,0 +1,14 @@
#nullable enable
using Demo2.Domain;
using ShrinkEventBus;
namespace Demo2.Runtime
{
public sealed class Demo2PhaseEvent : EventBase { public Demo2Phase SimulationPhase; public int Cycle; public float RemainingSeconds; public ulong StateHash; }
public sealed class Demo2BuildEvent : EventBase { public string PlayerId = string.Empty; public Demo2MachineKind Kind; public int X; public int Y; public bool Accepted; public string ErrorCode = string.Empty; }
public sealed class Demo2FaultEvent : EventBase { public int StableId; public int FaultCount; }
public sealed class Demo2SettlementEvent : EventBase { public int Cycle; public long Firepower; public long EnemyHealth; public long Excess; public bool Defended; public int CoreHealth; }
public sealed class Demo2RewardEvent : EventBase { public int Cycle; public string RuleId = string.Empty; public int Slot; }
public sealed class Demo2NetworkEvent : EventBase { public string Status = string.Empty; public string Detail = string.Empty; }
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 49fb82b7281bbba4884842a91d0d8dec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,450 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Cysharp.Threading.Tasks;
using Demo2.Domain;
using Demo2.ECS;
using Newtonsoft.Json;
using ShrinkDataSaver;
using ShrinkEventBus;
using Unity.Collections;
using Unity.Entities;
namespace Demo2.Runtime
{
public sealed class Demo2GameService : IDisposable
{
private const string SaveModuleKey = "demo2.rule-factory";
private const int SaveSlot = 2;
private readonly HashSet<string> _idempotencyTokens = new(StringComparer.Ordinal);
private readonly HashSet<string> _readyPlayers = new(StringComparer.Ordinal);
private readonly List<string> _ruleIds = new();
private readonly List<string> _rewardChoices = new();
private Demo2ContentRegistry _content;
private Demo2ContentRegistry? _pendingContent;
private Demo2EcsWorld? _world;
private Demo2SaveData _saveData = new();
private double _tickAccumulator;
private long _commandSequence;
private int _speed = 1;
private bool _waveSettled;
public static Demo2GameService? Current { get; private set; }
public Demo2EcsWorld? EcsWorld => _world;
public Demo2ContentRegistry Content => _content;
public IReadOnlyList<string> RuleIds => _ruleIds;
public IReadOnlyList<string> RewardChoices => _rewardChoices;
public Demo2Phase Phase { get; private set; } = Demo2Phase.Title;
public int Cycle { get; private set; } = 1;
public float PhaseRemainingSeconds { get; private set; }
public int Speed => _speed;
public long LastFirepower { get; private set; }
public long LastEnemyHealth { get; private set; }
public bool LastDefenseSucceeded { get; private set; }
public string LastError { get; private set; } = string.Empty;
public event Action? Changed;
public event Action<long, ulong>? TickAdvanced;
public event Action<Demo2BuildCommand>? AuthoritativeBuildAccepted;
public Demo2GameService()
{
_content = Demo2BuiltInContent.Create();
Current = this;
ShrinkSave.SetCurrentSaveVersion(Demo2SaveData.CurrentVersion);
ShrinkSave.RegisterModule(SaveModuleKey, CaptureSaveData, RestoreSaveData,
new ModuleConfig { CriticalModule = true, AutoSaveIntervalSeconds = 0 });
}
public void StartNewGame(ulong seed = 0)
{
if (seed == 0) seed = unchecked((ulong)DateTime.UtcNow.Ticks);
_world?.Dispose();
_ruleIds.Clear();
_ruleIds.Add("production.dense-ammo");
_world = new Demo2EcsWorld(seed, ResolveRules());
_saveData = new Demo2SaveData { Seed = seed, Money = 500, CoreHealth = 3, Cycle = 1 };
Cycle = 1;
_idempotencyTokens.Clear();
_readyPlayers.Clear();
_commandSequence = 0;
_speed = 1;
BeginPhase(Demo2Phase.Build);
}
public void AdvanceFrame(float unscaledDeltaTime)
{
if (_world == null || Phase is Demo2Phase.Title or Demo2Phase.Lobby or Demo2Phase.Reward or Demo2Phase.Victory or Demo2Phase.Defeat) return;
var scaled = Math.Min(0.1f, Math.Max(0, unscaledDeltaTime)) * _speed;
PhaseRemainingSeconds = Math.Max(0, PhaseRemainingSeconds - scaled);
_tickAccumulator += scaled;
var tickSeconds = 1.0 / Demo2Protocol.TickRate;
var guard = 0;
while (_tickAccumulator >= tickSeconds && guard++ < 400)
{
_tickAccumulator -= tickSeconds;
_world.Tick();
var state = _world.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton);
TickAdvanced?.Invoke(state.Tick, state.StateHash);
}
if (PhaseRemainingSeconds <= 0) AdvancePhase();
Changed?.Invoke();
}
public Demo2CommandResult SubmitBuild(Demo2BuildCommand command, bool authoritative = true)
{
if (_world == null) return Reject("world.missing", command);
if (Phase != Demo2Phase.Build) return Reject("phase.not-build", command);
if (string.IsNullOrWhiteSpace(command.IdempotencyToken)) return Reject("token.missing", command);
if (!_idempotencyTokens.Add(command.IdempotencyToken))
return new Demo2CommandResult { Accepted = true, Duplicate = true };
if (command.X < 0 || command.X >= Demo2Protocol.GridWidth || command.Y < 0 || command.Y >= Demo2Protocol.GridHeight)
return Reject("grid.out-of-range", command);
var manager = _world.EntityManager;
var existing = FindMachine(manager, command.X, command.Y);
if (!command.Remove && existing != Entity.Null) return Reject("grid.occupied", command);
if (command.Remove && existing == Entity.Null) return Reject("grid.empty", command);
var economy = manager.GetComponentData<Demo2EconomyState>(_world.Singleton);
var cost = command.Remove ? 0 : Demo2BuildCommandSystem.Cost(command.Kind);
if (!command.Remove && economy.Money < cost) return Reject("money.insufficient", command);
var refund = command.Remove ? Demo2BuildCommandSystem.Cost(manager.GetComponentData<Demo2MachineState>(existing).Kind) : 0;
_world.QueueBuild(command);
_world.Tick();
if (authoritative) AuthoritativeBuildAccepted?.Invoke(command);
EventBus.TriggerEvent(new Demo2BuildEvent { PlayerId = command.PlayerId, Kind = command.Kind, X = command.X, Y = command.Y, Accepted = true });
Changed?.Invoke();
return new Demo2CommandResult { Accepted = true, Refund = refund };
}
public Demo2CommandResult BuildLocal(Demo2MachineKind kind, int x, int y, byte rotation = 0, bool remove = false)
=> SubmitBuild(new Demo2BuildCommand
{
Sequence = ++_commandSequence,
IdempotencyToken = "local-" + _commandSequence,
PlayerId = "local",
Kind = kind,
X = x,
Y = y,
Rotation = rotation,
Remove = remove
});
public void SetReady(string playerId, bool ready)
{
if (ready) _readyPlayers.Add(playerId); else _readyPlayers.Remove(playerId);
if (Phase == Demo2Phase.Build && _readyPlayers.Count >= 1) AdvancePhase();
}
public void StartWaveImmediately()
{
if (_world == null) return;
if (Phase == Demo2Phase.Build) BeginPhase(Demo2Phase.Production);
else if (Phase == Demo2Phase.Production) BeginPhase(Demo2Phase.Defense);
}
public void SetSpeed(int speed)
{
_speed = Math.Max(1, Math.Min(20, speed));
if (_world != null)
{
var state = _world.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton);
state.SpeedMultiplier = _speed;
_world.EntityManager.SetComponentData(_world.Singleton, state);
}
Changed?.Invoke();
}
public bool ChooseReward(string ruleId, int replaceSlot = -1)
{
if (Phase != Demo2Phase.Reward || !_rewardChoices.Contains(ruleId)) return false;
if (replaceSlot >= 0 && replaceSlot < _ruleIds.Count) _ruleIds[replaceSlot] = ruleId;
else if (_ruleIds.Count < 5) _ruleIds.Add(ruleId);
else _ruleIds[4] = ruleId;
_world!.SetRules(ResolveRules());
var economy = _world.EntityManager.GetComponentData<Demo2EconomyState>(_world.Singleton);
economy.Money = SaturatingMath.Add(economy.Money, 100 + Cycle * 25);
_world.EntityManager.SetComponentData(_world.Singleton, economy);
ApplyMachineUpgrade();
EventBus.TriggerEvent(new Demo2RewardEvent { Cycle = Cycle, RuleId = ruleId, Slot = Math.Max(0, _ruleIds.IndexOf(ruleId)) });
if (Cycle >= Demo2Protocol.CycleCount) BeginPhase(Demo2Phase.Victory);
else
{
Cycle++;
if (_pendingContent != null) { _content = _pendingContent; _pendingContent = null; }
BeginPhase(Demo2Phase.Build);
}
return true;
}
public bool ReorderRule(int from, int to)
{
if (from < 0 || from >= _ruleIds.Count || to < 0 || to >= _ruleIds.Count || from == to) return false;
var value = _ruleIds[from];
_ruleIds.RemoveAt(from);
_ruleIds.Insert(to, value);
_world?.SetRules(ResolveRules());
Changed?.Invoke();
return true;
}
public void SpawnItem(Demo2MaterialKind kind, int quantity, int x = 4, int y = 7)
{
if (_world == null) return;
var state = _world.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton);
Demo2EcsFactory.CreateMaterial(_world.EntityManager, ++state.NextStableId, kind, x, y, 0, Math.Max(1, quantity));
_world.EntityManager.SetComponentData(_world.Singleton, state);
_world.Tick();
Changed?.Invoke();
}
public string StageModReload()
{
_pendingContent = Demo2ModBridge.BuildCatalogSnapshot();
return "staged=" + _pendingContent.ComputeContentHash() + "; appliesTo=next-cycle-or-next-game";
}
public Demo2Snapshot CaptureSnapshot() => _world?.CaptureSnapshot(_ruleIds) ?? new Demo2Snapshot();
public void RestoreSnapshot(Demo2Snapshot snapshot)
{
if (snapshot.SimulationVersion != Demo2Protocol.SimulationVersion) throw new InvalidOperationException("Simulation version mismatch.");
_world?.Dispose();
_ruleIds.Clear();
_ruleIds.AddRange(snapshot.RuleIds.Take(5));
_world = new Demo2EcsWorld(snapshot.Seed, ResolveRules());
_world.RestoreSnapshot(snapshot);
Cycle = snapshot.Cycle;
Phase = snapshot.Phase;
Changed?.Invoke();
}
public async UniTask SaveAsync()
{
if (Phase != Demo2Phase.Build) throw new InvalidOperationException("Demo2 can only save during Build or at a cycle boundary.");
await ShrinkSave.SaveSlotAsync(SaveSlot, new SaveOptions { SlotName = "规则工厂" });
}
public async UniTask LoadAsync()
{
await ShrinkSave.LoadSlotAsync(SaveSlot);
Changed?.Invoke();
}
public string BuildStatus()
{
if (_world == null) return "idle";
var simulation = _world.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton);
var economy = _world.EntityManager.GetComponentData<Demo2EconomyState>(_world.Singleton);
var core = _world.EntityManager.GetComponentData<Demo2CoreHealth>(_world.Singleton);
return $"cycle={Cycle}/{Demo2Protocol.CycleCount} phase={Phase} remaining={PhaseRemainingSeconds:0.0}s core={core.Value} money={economy.Money} payload={economy.Payload} multiplier={economy.MultiplierMilli / 1000f:0.00} hash={simulation.StateHash:x16}";
}
public string DumpEcs()
{
if (_world == null) return "world unavailable";
var manager = _world.EntityManager;
var text = new StringBuilder(BuildStatus()).AppendLine();
using var entities = manager.GetAllEntities(Allocator.Temp);
for (var i = 0; i < entities.Length; i++)
{
var entity = entities[i];
if (!manager.HasComponent<Demo2StableId>(entity)) continue;
var id = manager.GetComponentData<Demo2StableId>(entity).Value;
var pos = manager.GetComponentData<Demo2GridPosition>(entity);
if (manager.HasComponent<Demo2MachineState>(entity))
{
var machine = manager.GetComponentData<Demo2MachineState>(entity);
text.AppendLine($"#{id} machine={machine.Kind} grid={pos.X},{pos.Y} input={machine.StoredInput} progress={machine.ProgressTicks} faults={machine.Faults}");
}
else if (manager.HasComponent<Demo2MaterialState>(entity))
{
var material = manager.GetComponentData<Demo2MaterialState>(entity);
text.AppendLine($"#{id} item={material.Kind} grid={pos.X},{pos.Y} quantity={material.Quantity} progress={material.SubCellProgress}");
}
}
return text.ToString();
}
public void Dispose()
{
ShrinkSave.UnregisterModule(SaveModuleKey);
_world?.Dispose();
_world = null;
if (ReferenceEquals(Current, this)) Current = null;
}
private void AdvancePhase()
{
switch (Phase)
{
case Demo2Phase.Build: BeginPhase(Demo2Phase.Production); break;
case Demo2Phase.Production: BeginPhase(Demo2Phase.Defense); break;
case Demo2Phase.Defense:
if (!LastDefenseSucceeded && GetCoreHealth() <= 0) BeginPhase(Demo2Phase.Defeat);
else BeginPhase(Demo2Phase.Reward);
break;
}
}
private void BeginPhase(Demo2Phase phase)
{
Phase = phase;
_tickAccumulator = 0;
_waveSettled = false;
if (_world != null)
{
_world.SetCycle(Cycle);
_world.SetPhase(phase);
}
PhaseRemainingSeconds = phase switch
{
Demo2Phase.Build => 60,
Demo2Phase.Production => 90,
Demo2Phase.Defense => 20,
_ => 0
};
if (phase == Demo2Phase.Production) ResetCycleStatistics();
if (phase == Demo2Phase.Defense) SettleWave();
if (phase == Demo2Phase.Reward) RollRewardChoices();
_readyPlayers.Clear();
PublishPhase();
Changed?.Invoke();
}
private void SettleWave()
{
if (_world == null || _waveSettled) return;
_waveSettled = true;
_world.Tick();
var manager = _world.EntityManager;
var economy = manager.GetComponentData<Demo2EconomyState>(_world.Singleton);
var stats = manager.GetComponentData<Demo2CycleStats>(_world.Singleton);
var turretQuery = manager.CreateEntityQuery(typeof(Demo2TurretAmmo));
long ammo = 0;
using (var turrets = turretQuery.ToEntityArray(Allocator.Temp))
for (var i = 0; i < turrets.Length; i++)
{
ammo = SaturatingMath.Add(ammo, manager.GetComponentData<Demo2TurretAmmo>(turrets[i]).Value);
manager.SetComponentData(turrets[i], new Demo2TurretAmmo());
}
economy.Payload = SaturatingMath.Add(economy.Payload,
SaturatingMath.Add(SaturatingMath.Multiply(ammo, 25), SaturatingMath.Multiply(stats.Processed, 15)));
LastFirepower = SaturatingMath.MultiplyScaled(economy.Payload, economy.MultiplierMilli);
LastEnemyHealth = _content.Waves.First(value => value.Cycle == Cycle).TotalHealth;
LastDefenseSucceeded = LastFirepower >= LastEnemyHealth;
var excess = Math.Max(0, LastFirepower - LastEnemyHealth);
economy.ExcessFirepower = excess;
if (LastDefenseSucceeded) economy.Money = SaturatingMath.Add(economy.Money, 100 + excess / 20);
else
{
var core = manager.GetComponentData<Demo2CoreHealth>(_world.Singleton);
core.Value--;
manager.SetComponentData(_world.Singleton, core);
}
manager.SetComponentData(_world.Singleton, economy);
EventBus.TriggerEvent(new Demo2SettlementEvent
{
Cycle = Cycle, Firepower = LastFirepower, EnemyHealth = LastEnemyHealth, Excess = excess,
Defended = LastDefenseSucceeded, CoreHealth = GetCoreHealth()
});
}
private void RollRewardChoices()
{
_rewardChoices.Clear();
var state = _world!.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton);
var random = new DeterministicRandom(state.Seed + (ulong)(Cycle * 7919));
var candidates = _content.Rules.Keys.Where(id => !_ruleIds.Contains(id)).OrderBy(id => id, StringComparer.Ordinal).ToList();
while (_rewardChoices.Count < 3 && candidates.Count > 0)
{
var index = random.NextInt(0, candidates.Count);
_rewardChoices.Add(candidates[index]);
candidates.RemoveAt(index);
}
}
private void ResetCycleStatistics()
{
if (_world == null) return;
var manager = _world.EntityManager;
manager.SetComponentData(_world.Singleton, new Demo2CycleStats());
var economy = manager.GetComponentData<Demo2EconomyState>(_world.Singleton);
economy.Payload = 0;
economy.MultiplierMilli = SaturatingMath.Scale;
economy.Energy = 0;
economy.ExcessFirepower = 0;
manager.SetComponentData(_world.Singleton, economy);
}
private void ApplyMachineUpgrade()
{
if (_world == null) return;
var manager = _world.EntityManager;
var query = manager.CreateEntityQuery(typeof(Demo2MachineState));
using var entities = query.ToEntityArray(Allocator.Temp);
for (var i = 0; i < entities.Length; i++)
{
var machine = manager.GetComponentData<Demo2MachineState>(entities[i]);
machine.ProcessTicks = Math.Max(1, machine.ProcessTicks * 9 / 10);
manager.SetComponentData(entities[i], machine);
}
}
private Demo2SaveData CaptureSaveData()
{
if (_world == null) return _saveData;
var snapshot = CaptureSnapshot();
_saveData.Cycle = Cycle;
_saveData.CoreHealth = snapshot.CoreHealth;
_saveData.Money = snapshot.Money;
_saveData.Seed = snapshot.Seed;
_saveData.RuleIds = new List<string>(_ruleIds);
_saveData.SnapshotJson = JsonConvert.SerializeObject(snapshot);
_saveData.SnapshotHash = snapshot.StateHash.ToString("x16");
return _saveData;
}
private void RestoreSaveData(Demo2SaveData? data)
{
_saveData = data ?? new Demo2SaveData();
if (string.IsNullOrWhiteSpace(_saveData.SnapshotJson)) { StartNewGame(_saveData.Seed); return; }
var snapshot = JsonConvert.DeserializeObject<Demo2Snapshot>(_saveData.SnapshotJson)
?? throw new InvalidOperationException("Demo2 snapshot is invalid.");
if (!string.Equals(snapshot.StateHash.ToString("x16"), _saveData.SnapshotHash, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Demo2 snapshot hash validation failed.");
RestoreSnapshot(snapshot);
}
private IReadOnlyList<Demo2RuleDefinition> ResolveRules()
=> _ruleIds.Where(id => _content.Rules.ContainsKey(id)).Select(id => _content.Rules[id]).ToList();
private void PublishPhase()
{
var hash = _world == null ? 0UL : _world.EntityManager.GetComponentData<Demo2SimulationState>(_world.Singleton).StateHash;
EventBus.TriggerEvent(new Demo2PhaseEvent { SimulationPhase = Phase, Cycle = Cycle, RemainingSeconds = PhaseRemainingSeconds, StateHash = hash });
}
private Demo2CommandResult Reject(string errorCode, Demo2BuildCommand command)
{
LastError = errorCode;
EventBus.TriggerEvent(new Demo2BuildEvent { PlayerId = command.PlayerId, Kind = command.Kind, X = command.X, Y = command.Y, Accepted = false, ErrorCode = errorCode });
return new Demo2CommandResult { ErrorCode = errorCode, Refund = 0 };
}
private int GetCoreHealth() => _world == null ? 0 : _world.EntityManager.GetComponentData<Demo2CoreHealth>(_world.Singleton).Value;
private static Entity FindMachine(EntityManager manager, int x, int y)
{
var query = manager.CreateEntityQuery(typeof(Demo2GridPosition), typeof(Demo2MachineState));
using var entities = query.ToEntityArray(Allocator.Temp);
using var positions = query.ToComponentDataArray<Demo2GridPosition>(Allocator.Temp);
for (var i = 0; i < entities.Length; i++) if (positions[i].X == x && positions[i].Y == y) return entities[i];
return Entity.Null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9145899f7469b74458c0c8e66caac5d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,86 @@
#nullable enable
using System;
using Demo2.Domain;
using UnityEngine;
using UnityEngine.UIElements;
namespace Demo2.Runtime
{
public sealed class Demo2GridInput : IDisposable
{
private readonly VisualElement _surface;
private readonly Camera _worldCamera;
private bool _middleDragging;
public event Action<int, int, int>? GridClicked;
public Demo2GridInput(VisualElement surface, Camera worldCamera)
{
_surface = surface;
_worldCamera = worldCamera;
_surface.RegisterCallback<PointerDownEvent>(OnPointerDown);
_surface.RegisterCallback<PointerUpEvent>(OnPointerUp);
_surface.RegisterCallback<PointerMoveEvent>(OnPointerMove);
_surface.RegisterCallback<WheelEvent>(OnWheel);
}
public void Dispose()
{
_surface.UnregisterCallback<PointerDownEvent>(OnPointerDown);
_surface.UnregisterCallback<PointerUpEvent>(OnPointerUp);
_surface.UnregisterCallback<PointerMoveEvent>(OnPointerMove);
_surface.UnregisterCallback<WheelEvent>(OnWheel);
}
private void OnPointerDown(PointerDownEvent eventData)
{
if (eventData.button == 2)
{
_middleDragging = true;
_surface.CapturePointer(eventData.pointerId);
eventData.StopPropagation();
return;
}
if (eventData.button is not (0 or 1)) return;
var world = _worldCamera.ScreenToWorldPoint(PanelToScreen(eventData.position));
var x = Mathf.FloorToInt(world.x);
var y = Demo2Protocol.GridHeight - 1 - Mathf.FloorToInt(world.y);
if (x >= 0 && x < Demo2Protocol.GridWidth && y >= 0 && y < Demo2Protocol.GridHeight) GridClicked?.Invoke(x, y, eventData.button);
eventData.StopPropagation();
}
private void OnPointerUp(PointerUpEvent eventData)
{
if (eventData.button != 2) return;
_middleDragging = false;
if (_surface.HasPointerCapture(eventData.pointerId)) _surface.ReleasePointer(eventData.pointerId);
eventData.StopPropagation();
}
private void OnPointerMove(PointerMoveEvent eventData)
{
if (!_middleDragging) return;
var scale = _worldCamera.orthographicSize * 2f / Mathf.Max(1, _surface.resolvedStyle.height);
var position = _worldCamera.transform.position;
position -= new Vector3(eventData.deltaPosition.x, -eventData.deltaPosition.y) * scale;
position.x = Mathf.Clamp(position.x, 4f, Demo2Protocol.GridWidth - 4f);
position.y = Mathf.Clamp(position.y, 3f, Demo2Protocol.GridHeight - 3f);
_worldCamera.transform.position = position;
eventData.StopPropagation();
}
private void OnWheel(WheelEvent eventData)
{
_worldCamera.orthographicSize = Mathf.Clamp(_worldCamera.orthographicSize + eventData.delta.y * 0.7f, 5f, 15f);
eventData.StopPropagation();
}
private Vector3 PanelToScreen(Vector3 panelPosition)
{
var root = _surface.panel?.visualTree;
var width = Mathf.Max(1, root?.resolvedStyle.width ?? Screen.width);
var height = Mathf.Max(1, root?.resolvedStyle.height ?? Screen.height);
return new Vector3(panelPosition.x / width * Screen.width, Screen.height - panelPosition.y / height * Screen.height, 0);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74e4a34f11390854fbb3ba8cc21779a8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,48 @@
#nullable enable
using System;
using Demo2.Domain;
using UnityEngine;
namespace Demo2.Runtime
{
public sealed class Demo2HeadlessBootstrap : MonoBehaviour
{
private Demo2GameService? _service;
private Demo2LanRuntime? _lan;
private void Awake()
{
var args = Environment.GetCommandLineArgs();
if (!Application.isBatchMode && Array.IndexOf(args, "-demo2-headless") < 0) { enabled = false; return; }
var port = ReadInt(args, "-demo2-port", 27320);
var mode = ReadString(args, "-demo2-transport", "tcp").Equals("kcp", StringComparison.OrdinalIgnoreCase) ? Demo2TransportMode.Kcp : Demo2TransportMode.Tcp;
var seed = (ulong)ReadInt(args, "-demo2-seed", 20260817);
_service = Demo2GameService.Current ?? new Demo2GameService();
_service.StartNewGame(seed);
_lan = new Demo2LanRuntime(_service);
_lan.Host(port, mode, "headless");
Debug.Log($"[Demo2] Headless server ready. transport={mode} port={port} seed={seed}");
}
private void Update()
{
_lan?.Pump();
_service?.AdvanceFrame(Time.unscaledDeltaTime);
}
private void OnDestroy() => _lan?.Dispose();
private static int ReadInt(string[] args, string key, int fallback)
{
var index = Array.IndexOf(args, key);
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value) ? value : fallback;
}
private static string ReadString(string[] args, string key, string fallback)
{
var index = Array.IndexOf(args, key);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : fallback;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7efbce1f7d62b584db574530a79f47ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,92 @@
#nullable enable
using System.Collections.Generic;
using Demo2.Domain;
using Demo2.ECS;
using Unity.Collections;
using UnityEngine;
namespace Demo2.Runtime
{
public sealed class Demo2ItemBatchRenderer : MonoBehaviour
{
[SerializeField] private Texture2D? itemTexture;
private readonly List<Matrix4x4>[] _matrices = { new(), new(), new(), new(), new(), new() };
private readonly Material[] _materials = new Material[6];
private Mesh? _quad;
private Demo2EcsWorld? _world;
public void Bind(Demo2EcsWorld? world) => _world = world;
public void SetTexture(Texture2D texture) { itemTexture = texture; RebuildMaterials(); }
private void Awake()
{
if (Application.isBatchMode)
{
enabled = false;
return;
}
_quad = CreateQuad();
RebuildMaterials();
}
private void LateUpdate()
{
if (_world == null || _quad == null) return;
for (var i = 0; i < _matrices.Length; i++) _matrices[i].Clear();
var manager = _world.EntityManager;
var query = manager.CreateEntityQuery(typeof(Demo2GridPosition), typeof(Demo2MaterialState));
using var entities = query.ToEntityArray(Allocator.Temp);
using var positions = query.ToComponentDataArray<Demo2GridPosition>(Allocator.Temp);
using var materials = query.ToComponentDataArray<Demo2MaterialState>(Allocator.Temp);
for (var i = 0; i < entities.Length; i++)
{
var material = materials[i];
var x = positions[i].X + 0.5f;
var y = Demo2Protocol.GridHeight - positions[i].Y - 0.5f;
var offset = material.SubCellProgress / 1000f;
switch (material.Direction & 3) { case 0: x += offset; break; case 1: y += offset; break; case 2: x -= offset; break; case 3: y -= offset; break; }
var kind = Mathf.Clamp((int)material.Kind, 0, _matrices.Length - 1);
if (_matrices[kind].Count < 1023) _matrices[kind].Add(Matrix4x4.TRS(new Vector3(x, y, -0.2f), Quaternion.identity, Vector3.one * 0.34f));
}
for (var i = 0; i < _matrices.Length; i++)
if (_materials[i] != null && _matrices[i].Count > 0)
Graphics.DrawMeshInstanced(_quad, 0, _materials[i], _matrices[i]);
}
private void OnDestroy()
{
if (_quad != null) Destroy(_quad);
for (var i = 0; i < _materials.Length; i++) if (_materials[i] != null) Destroy(_materials[i]);
}
private void RebuildMaterials()
{
var colors = new[] { new Color(0.52f, 0.66f, 0.78f), new Color(0.42f, 0.32f, 0.28f), new Color(0.82f, 0.82f, 0.76f), new Color(0.86f, 0.68f, 0.22f), new Color(0.84f, 0.23f, 0.18f), new Color(0.16f, 0.82f, 0.88f) };
var shader = Shader.Find("Universal Render Pipeline/Unlit") ?? Shader.Find("Unlit/Texture");
if (shader == null)
{
enabled = false;
return;
}
for (var i = 0; i < _materials.Length; i++)
{
if (_materials[i] == null) _materials[i] = new Material(shader) { enableInstancing = true };
_materials[i].mainTexture = itemTexture;
_materials[i].color = colors[i];
}
}
private static Mesh CreateQuad()
{
var mesh = new Mesh { name = "Demo2 Item Quad" };
mesh.SetVertices(new[] { new Vector3(-.5f, -.5f), new Vector3(-.5f, .5f), new Vector3(.5f, .5f), new Vector3(.5f, -.5f) });
mesh.SetUVs(0, new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) });
mesh.SetTriangles(new[] { 0, 1, 2, 0, 2, 3 }, 0);
mesh.RecalculateBounds();
return mesh;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b94f9163cf29cdb40a14098f3a3dd6f4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,457 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using Demo2.Domain;
using Newtonsoft.Json;
using ShrinkEventBus;
using ShrinkNetwork;
namespace Demo2.Runtime
{
public sealed class Demo2LanRuntime : IDisposable
{
private sealed class Peer
{
public long SessionId;
public string PlayerId = string.Empty;
public string Token = string.Empty;
public bool Ready;
public DateTime ReconnectUntilUtc;
}
private readonly Demo2GameService _service;
private readonly ConcurrentQueue<ShrinkNetworkTransportEvent> _events = new();
private readonly Dictionary<long, Peer> _peers = new();
private readonly Dictionary<string, Peer> _reconnectable = new(StringComparer.Ordinal);
private readonly List<Demo2LobbyPlayer> _players = new();
private IShrinkNetworkTransport? _transport;
private UdpClient? _discoveryResponder;
private long _clientSessionId = 1;
private long _networkSequence;
private Demo2Phase _lastSyncedPhase = Demo2Phase.Title;
private int _lastSyncedCycle;
private int _port;
public bool IsHost { get; private set; }
public bool IsConnected { get; private set; }
public Demo2TransportMode TransportMode { get; private set; }
public string LocalPlayerId { get; private set; } = "local";
public string ReconnectToken { get; private set; } = string.Empty;
public string Status { get; private set; } = "offline";
public IReadOnlyList<Demo2LobbyPlayer> Players => _players;
public event Action? Changed;
public Demo2LanRuntime(Demo2GameService service)
{
_service = service;
_service.TickAdvanced += OnTickAdvanced;
_service.AuthoritativeBuildAccepted += OnAuthoritativeBuildAccepted;
}
public void Host(int port, Demo2TransportMode mode, string playerId = "host")
{
Stop();
IsHost = true;
IsConnected = true;
TransportMode = mode;
LocalPlayerId = string.IsNullOrWhiteSpace(playerId) ? "host" : playerId.Trim();
ReconnectToken = Guid.NewGuid().ToString("N");
_port = port;
_transport = mode == Demo2TransportMode.Tcp
? new ShrinkTcpServerTransport(IPAddress.Any, port)
: new ShrinkKcpServerTransport(IPAddress.Any, port);
BindAndStart();
_players.Add(new Demo2LobbyPlayer { PlayerId = LocalPlayerId, DisplayName = LocalPlayerId, Connected = true });
StartDiscoveryResponder(port + 1);
SetStatus("hosting", $"{mode} 0.0.0.0:{port}");
}
public void Join(string host, int port, Demo2TransportMode mode, string playerId = "client")
{
Stop();
IsHost = false;
TransportMode = mode;
LocalPlayerId = string.IsNullOrWhiteSpace(playerId) ? "client" : playerId.Trim();
_port = port;
_transport = mode == Demo2TransportMode.Tcp
? new ShrinkTcpClientTransport(host, port, 1)
: new ShrinkKcpClientTransport(host, port, sessionId: 1);
BindAndStart();
SetStatus("connecting", $"{mode} {host}:{port}");
}
public void Pump()
{
while (_events.TryDequeue(out var transportEvent))
{
switch (transportEvent.Type)
{
case ShrinkNetworkTransportEventType.Connected:
if (IsHost) _peers[transportEvent.SessionId] = new Peer { SessionId = transportEvent.SessionId };
else
{
_clientSessionId = transportEvent.SessionId;
Send(transportEvent.SessionId, "handshake", BuildHandshake());
}
break;
case ShrinkNetworkTransportEventType.Disconnected:
HandleDisconnected(transportEvent.SessionId);
break;
case ShrinkNetworkTransportEventType.Packet:
HandlePacket(transportEvent.SessionId, transportEvent.PacketData);
break;
}
}
if (IsHost)
{
var expired = _reconnectable.Where(pair => pair.Value.ReconnectUntilUtc < DateTime.UtcNow).Select(pair => pair.Key).ToArray();
foreach (var key in expired) _reconnectable.Remove(key);
if (_service.Phase != _lastSyncedPhase || _service.Cycle != _lastSyncedCycle)
{
_lastSyncedPhase = _service.Phase;
_lastSyncedCycle = _service.Cycle;
Broadcast("snapshot", Compress(_service.CaptureSnapshot()));
}
}
}
public void SendBuild(Demo2BuildCommand command)
{
if (IsHost) _service.SubmitBuild(command);
else Send(_clientSessionId, "build", command);
}
public void SetReady(bool ready)
{
if (IsHost)
{
var local = _players.FirstOrDefault(value => value.PlayerId == LocalPlayerId);
if (local != null) local.Ready = ready;
_service.SetReady(LocalPlayerId, ready);
BroadcastLobby();
}
else Send(_clientSessionId, "ready", ready);
}
public void ChooseReward(string ruleId, int slot)
{
if (IsHost) { _service.ChooseReward(ruleId, slot); Broadcast("snapshot", Compress(_service.CaptureSnapshot())); }
else Send(_clientSessionId, "reward", new RewardRequest { RuleId = ruleId, Slot = slot });
}
public async UniTask<IReadOnlyList<string>> DiscoverAsync(int discoveryPort)
{
using var udp = new UdpClient();
udp.EnableBroadcast = true;
var request = Encoding.UTF8.GetBytes("DEMO2_DISCOVER_V1");
await udp.SendAsync(request, request.Length, new IPEndPoint(IPAddress.Broadcast, discoveryPort));
var result = new List<string>();
var deadline = DateTime.UtcNow.AddSeconds(1.2);
while (DateTime.UtcNow < deadline)
{
var receive = udp.ReceiveAsync();
var completed = await Task.WhenAny(receive, Task.Delay(150));
if (completed != receive) continue;
var packet = receive.Result;
var value = Encoding.UTF8.GetString(packet.Buffer);
if (value.StartsWith("DEMO2_ROOM|", StringComparison.Ordinal)) result.Add(packet.RemoteEndPoint.Address + "|" + value);
}
return result.Distinct(StringComparer.Ordinal).ToArray();
}
public void Stop()
{
if (_transport != null) _transport.OnEvent -= Enqueue;
_transport?.Stop();
_transport = null;
_discoveryResponder?.Close();
_discoveryResponder = null;
_peers.Clear();
_reconnectable.Clear();
_players.Clear();
IsConnected = false;
IsHost = false;
Status = "offline";
}
public void Dispose()
{
Stop();
_service.TickAdvanced -= OnTickAdvanced;
_service.AuthoritativeBuildAccepted -= OnAuthoritativeBuildAccepted;
}
private void BindAndStart()
{
_transport!.OnEvent += Enqueue;
_transport.Start();
}
private void Enqueue(ShrinkNetworkTransportEvent value) => _events.Enqueue(value);
private void HandlePacket(long sessionId, byte[] packet)
{
Demo2NetworkEnvelope? envelope;
try { envelope = JsonConvert.DeserializeObject<Demo2NetworkEnvelope>(Encoding.UTF8.GetString(packet)); }
catch (Exception ex) { SetStatus("protocol-error", ex.Message); return; }
if (envelope == null) return;
if (IsHost)
{
switch (envelope.Type)
{
case "handshake": HandleHandshake(sessionId, Read<Demo2Handshake>(envelope)); break;
case "build": HandleHostBuild(sessionId, Read<Demo2BuildCommand>(envelope)); break;
case "snapshot-request": Send(sessionId, "snapshot", Compress(_service.CaptureSnapshot())); break;
case "ready": HandleReady(sessionId, Read<bool>(envelope)); break;
case "reward": HandleReward(sessionId, Read<RewardRequest>(envelope)); break;
}
return;
}
switch (envelope.Type)
{
case "handshake-result": HandleHandshakeResult(Read<Demo2HandshakeResult>(envelope)); break;
case "authoritative-build": _service.SubmitBuild(Read<Demo2BuildCommand>(envelope), false); break;
case "build-result":
var result = Read<Demo2CommandResult>(envelope);
if (!result.Accepted) SetStatus("build-rejected", result.ErrorCode);
break;
case "hash": HandleHash(Read<Demo2HashBroadcast>(envelope)); break;
case "snapshot": _service.RestoreSnapshot(Decompress(Read<string>(envelope))); break;
case "lobby": ApplyLobby(Read<Demo2LobbyState>(envelope)); break;
}
}
private void HandleHandshake(long sessionId, Demo2Handshake handshake)
{
var result = Validate(handshake);
if (!result.Accepted) { Send(sessionId, "handshake-result", result); Disconnect(sessionId, result.ErrorCode); return; }
var peer = ResolvePeer(sessionId, handshake);
result.PlayerId = peer.PlayerId;
result.ReconnectToken = peer.Token;
Send(sessionId, "handshake-result", result);
Send(sessionId, "snapshot", Compress(_service.CaptureSnapshot()));
UpsertPlayer(peer.PlayerId, true, peer.Ready);
BroadcastLobby();
SetStatus("peer-connected", peer.PlayerId);
}
private Demo2HandshakeResult Validate(Demo2Handshake handshake)
{
if (!string.Equals(handshake.GameVersion, Demo2Protocol.GameVersion, StringComparison.Ordinal))
return new Demo2HandshakeResult { ErrorCode = "version.game", Difference = $"host={Demo2Protocol.GameVersion}; client={handshake.GameVersion}" };
if (handshake.SimulationVersion != Demo2Protocol.SimulationVersion)
return new Demo2HandshakeResult { ErrorCode = "version.simulation", Difference = $"host={Demo2Protocol.SimulationVersion}; client={handshake.SimulationVersion}" };
var local = LocalMods();
var remote = handshake.Mods.OrderBy(value => value.Id, StringComparer.Ordinal).ToArray();
if (local.Count != remote.Length || local.Where((value, index) => value.Id != remote[index].Id || value.Version != remote[index].Version || value.ContentSha256 != remote[index].ContentSha256).Any())
return new Demo2HandshakeResult { ErrorCode = "mods.mismatch", Difference = "host=" + JsonConvert.SerializeObject(local) + "; client=" + JsonConvert.SerializeObject(remote) };
return new Demo2HandshakeResult { Accepted = true };
}
private Peer ResolvePeer(long sessionId, Demo2Handshake handshake)
{
Peer peer;
if (!string.IsNullOrWhiteSpace(handshake.ReconnectToken) && _reconnectable.TryGetValue(handshake.ReconnectToken, out var old) && old.ReconnectUntilUtc >= DateTime.UtcNow)
{
peer = old;
_reconnectable.Remove(old.Token);
peer.SessionId = sessionId;
}
else
{
var requested = string.IsNullOrWhiteSpace(handshake.PlayerId) ? "player" : handshake.PlayerId.Trim();
var suffix = 1;
var playerId = requested;
while (_players.Any(value => value.PlayerId == playerId)) playerId = requested + "-" + ++suffix;
peer = new Peer { SessionId = sessionId, PlayerId = playerId, Token = Guid.NewGuid().ToString("N") };
}
_peers[sessionId] = peer;
return peer;
}
private void HandleHostBuild(long sessionId, Demo2BuildCommand command)
{
if (!_peers.TryGetValue(sessionId, out var peer)) return;
command.PlayerId = peer.PlayerId;
var result = _service.SubmitBuild(command);
Send(sessionId, "build-result", result);
}
private void HandleReady(long sessionId, bool ready)
{
if (!_peers.TryGetValue(sessionId, out var peer)) return;
peer.Ready = ready;
UpsertPlayer(peer.PlayerId, true, ready);
_service.SetReady(peer.PlayerId, ready);
BroadcastLobby();
}
private void HandleReward(long sessionId, RewardRequest request)
{
if (!_peers.ContainsKey(sessionId)) return;
if (_service.ChooseReward(request.RuleId, request.Slot)) Broadcast("snapshot", Compress(_service.CaptureSnapshot()));
}
private void HandleHandshakeResult(Demo2HandshakeResult result)
{
IsConnected = result.Accepted;
if (result.Accepted)
{
LocalPlayerId = result.PlayerId;
ReconnectToken = result.ReconnectToken;
SetStatus("connected", result.PlayerId);
}
else SetStatus("rejected:" + result.ErrorCode, result.Difference);
}
private void HandleHash(Demo2HashBroadcast broadcast)
{
var world = _service.EcsWorld;
if (world == null) { Send(_clientSessionId, "snapshot-request", broadcast.Tick); return; }
var local = world.EntityManager.GetComponentData<Demo2.ECS.Demo2SimulationState>(world.Singleton);
if (local.Tick == broadcast.Tick && local.StateHash != broadcast.StateHash)
{
SetStatus("hash-mismatch", $"tick={broadcast.Tick} local={local.StateHash:x16} host={broadcast.StateHash:x16}");
Send(_clientSessionId, "snapshot-request", broadcast.Tick);
}
}
private void HandleDisconnected(long sessionId)
{
if (IsHost && _peers.TryGetValue(sessionId, out var peer))
{
_peers.Remove(sessionId);
peer.ReconnectUntilUtc = DateTime.UtcNow.AddSeconds(30);
_reconnectable[peer.Token] = peer;
UpsertPlayer(peer.PlayerId, false, peer.Ready);
BroadcastLobby();
}
else if (!IsHost) { IsConnected = false; SetStatus("disconnected", "30-second reconnect window"); }
}
private void OnTickAdvanced(long tick, ulong hash)
{
if (IsHost && tick > 0 && tick % 20 == 0) Broadcast("hash", new Demo2HashBroadcast { Tick = tick, StateHash = hash });
}
private void OnAuthoritativeBuildAccepted(Demo2BuildCommand command)
{
if (IsHost) Broadcast("authoritative-build", command);
}
private void ApplyLobby(Demo2LobbyState state)
{
_players.Clear();
_players.AddRange(state.Players);
Changed?.Invoke();
}
private void BroadcastLobby() => Broadcast("lobby", new Demo2LobbyState { IsHost = true, Transport = TransportMode, Players = _players.ToList() });
private void UpsertPlayer(string playerId, bool connected, bool ready)
{
var row = _players.FirstOrDefault(value => value.PlayerId == playerId);
if (row == null) { row = new Demo2LobbyPlayer { PlayerId = playerId, DisplayName = playerId }; _players.Add(row); }
row.Connected = connected;
row.Ready = ready;
}
private Demo2Handshake BuildHandshake() => new()
{
GameVersion = Demo2Protocol.GameVersion,
SimulationVersion = Demo2Protocol.SimulationVersion,
PlayerId = LocalPlayerId,
ReconnectToken = ReconnectToken,
Mods = LocalMods()
};
private List<Demo2ModManifest> LocalMods() => new()
{
new Demo2ModManifest { Id = "demo2.builtin", Version = Demo2Protocol.GameVersion, ContentSha256 = _service.Content.ComputeContentHash() }
};
private void Broadcast(string type, object payload)
{
if (!IsHost || _transport == null) return;
foreach (var sessionId in _peers.Keys.ToArray()) Send(sessionId, type, payload);
}
private void Send(long sessionId, string type, object payload)
{
if (_transport == null || !_transport.IsStarted) return;
var envelope = new Demo2NetworkEnvelope { Type = type, Sequence = ++_networkSequence, PayloadJson = JsonConvert.SerializeObject(payload) };
_transport.Send(sessionId, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(envelope)));
}
private static T Read<T>(Demo2NetworkEnvelope envelope) => JsonConvert.DeserializeObject<T>(envelope.PayloadJson)!;
private static string Compress(Demo2Snapshot snapshot)
{
var input = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(snapshot));
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, true)) gzip.Write(input, 0, input.Length);
return Convert.ToBase64String(output.ToArray());
}
private static Demo2Snapshot Decompress(string value)
{
var input = Convert.FromBase64String(value);
using var source = new MemoryStream(input);
using var gzip = new GZipStream(source, CompressionMode.Decompress);
using var reader = new StreamReader(gzip, Encoding.UTF8);
return JsonConvert.DeserializeObject<Demo2Snapshot>(reader.ReadToEnd()) ?? throw new InvalidDataException("Invalid Demo2 snapshot.");
}
private void Disconnect(long sessionId, string reason)
{
if (_transport is IShrinkNetworkSessionControlTransport control) control.DisconnectSession(sessionId, reason);
}
private void SetStatus(string status, string detail)
{
Status = string.IsNullOrWhiteSpace(detail) ? status : status + " | " + detail;
EventBus.TriggerEvent(new Demo2NetworkEvent { Status = status, Detail = detail });
Changed?.Invoke();
}
private void StartDiscoveryResponder(int port)
{
try
{
_discoveryResponder = new UdpClient(port);
_ = Task.Run(async () =>
{
while (_discoveryResponder != null)
{
try
{
var request = await _discoveryResponder.ReceiveAsync();
if (Encoding.UTF8.GetString(request.Buffer) != "DEMO2_DISCOVER_V1") continue;
var response = Encoding.UTF8.GetBytes($"DEMO2_ROOM|{TransportMode}|{_port}|{_players.Count}");
await _discoveryResponder.SendAsync(response, response.Length, request.RemoteEndPoint);
}
catch (ObjectDisposedException) { break; }
catch (SocketException) { break; }
}
});
}
catch (SocketException ex) { SetStatus("discovery-unavailable", ex.Message); }
}
private sealed class RewardRequest { public string RuleId = string.Empty; public int Slot; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e77c39acd0796264db73ab51f313f95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,72 @@
#nullable enable
using System.Collections.Generic;
using Demo2.Domain;
using Demo2.ECS;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace Demo2.Runtime
{
public sealed class Demo2MachineProxyPool : MonoBehaviour
{
[SerializeField] private Sprite[] machineSprites = new Sprite[8];
private readonly Dictionary<int, SpriteRenderer> _active = new();
private readonly Stack<SpriteRenderer> _pool = new();
private readonly HashSet<int> _seen = new();
private readonly List<int> _stale = new();
public void SetSprites(Sprite[] sprites) => machineSprites = sprites;
public void Refresh(Demo2EcsWorld? world)
{
if (world == null) { ReleaseAll(); return; }
var manager = world.EntityManager;
var query = manager.CreateEntityQuery(typeof(Demo2StableId), typeof(Demo2GridPosition), typeof(Demo2MachineState));
using var entities = query.ToEntityArray(Allocator.Temp);
using var ids = query.ToComponentDataArray<Demo2StableId>(Allocator.Temp);
using var positions = query.ToComponentDataArray<Demo2GridPosition>(Allocator.Temp);
_seen.Clear();
for (var i = 0; i < entities.Length; i++)
{
var id = ids[i].Value;
_seen.Add(id);
if (!_active.TryGetValue(id, out var renderer))
{
renderer = Acquire();
_active[id] = renderer;
}
var machine = manager.GetComponentData<Demo2MachineState>(entities[i]);
renderer.name = $"MachineProxy_{id}_{machine.Kind}";
renderer.transform.position = new Vector3(positions[i].X + 0.5f, Demo2Protocol.GridHeight - positions[i].Y - 0.5f, 0);
renderer.transform.rotation = Quaternion.Euler(0, 0, -90f * machine.Rotation);
renderer.transform.localScale = Vector3.one * 0.92f;
renderer.sprite = SpriteFor(machine.Kind);
renderer.color = machine.Faults > 0 ? new Color(1f, 0.52f, 0.42f) : Color.white;
}
_stale.Clear();
foreach (var pair in _active) if (!_seen.Contains(pair.Key)) _stale.Add(pair.Key);
foreach (var id in _stale) { Release(_active[id]); _active.Remove(id); }
}
private SpriteRenderer Acquire()
{
if (_pool.Count > 0) { var reused = _pool.Pop(); reused.gameObject.SetActive(true); return reused; }
var go = new GameObject("MachineProxy");
go.transform.SetParent(transform, false);
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sortingOrder = 5;
return renderer;
}
private void Release(SpriteRenderer renderer) { renderer.gameObject.SetActive(false); _pool.Push(renderer); }
private void ReleaseAll() { foreach (var renderer in _active.Values) Release(renderer); _active.Clear(); }
private Sprite? SpriteFor(Demo2MachineKind kind)
{
var index = kind == Demo2MachineKind.NightShiftSmelter ? (int)Demo2MachineKind.Smelter : (int)kind;
return index >= 0 && index < machineSprites.Length ? machineSprites[index] : null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59a0126495f75f7468aa474eb5e377c0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
#nullable enable
using Demo2.Domain;
using ShrinkModFramework;
namespace Demo2.Runtime
{
public static class Demo2ModBridge
{
public const string RegistryName = "demo2.rule-factory.mods";
public static Demo2ContentRegistry BuildCatalogSnapshot()
{
var registry = Demo2BuiltInContent.Create();
var mods = ShrinkModLoader.GetOrCreateRegistry<IDemo2Mod>(RegistryName);
foreach (var entry in mods.Entries) entry.Value.Register(registry);
return registry;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5836daddf87a4c48a0e904ba61c24ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: