demo2
This commit is contained in:
@@ -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:
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user