diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md
index 8a4972c..2f17f17 100644
--- a/IMPLEMENTATION_CHECKLIST.md
+++ b/IMPLEMENTATION_CHECKLIST.md
@@ -234,8 +234,9 @@
- [x] Unity 6 LTS, URP, Addressables, Input System, Localization.
DOTween — следующим шагом (пока UI без внешних твинов).
-- [ ] 3D-сцены `Login`, `Lobby`, `Collection`, `DeckBuilder`, `Battle`
- и `SceneFlow`; `Boot` уже есть как служебный bootstrap.
+- [x] 3D-сцены `Login`, `Lobby`, `Collection`, `DeckBuilder`, `Battle`
+ и `SceneFlow`; `Boot` — стартовая служебная сцена. 3D-окружение пока
+ из примитивов, UI — code-generated uGUI.
- [x] Composition root + DI (VContainer), без синглтон-свалок.
- [x] `ApiClient` (HTTP/JSON), `PlayerState`, таймауты и разбор `ApiError`.
- [x] `GameConnection` (WebSocket + MessagePack), snapshot/events,
diff --git a/README.md b/README.md
index 089d7d6..812a5fa 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
| EF-миграции | готово (`InitialCreate`) |
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
-| Клиент | Unity 6.6: 3D-сцены Login/Lobby/Collection/DeckBuilder/Battle; сейчас gray-box на одном Canvas |
+| Клиент | Unity 6.6: 3D-сцены Login/Lobby/Collection/DeckBuilder/Battle + SceneFlow; UI gray-box |
| Лаунчер | не начато |
## Требования
diff --git a/client/Assets/Vanguard/Editor.meta b/client/Assets/Vanguard/Editor.meta
new file mode 100644
index 0000000..2323856
--- /dev/null
+++ b/client/Assets/Vanguard/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7fb54fe32f66fc44fac44b13550d9cc9
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/client/Assets/Vanguard/Editor/SceneBuilder.cs b/client/Assets/Vanguard/Editor/SceneBuilder.cs
new file mode 100644
index 0000000..325e9a8
--- /dev/null
+++ b/client/Assets/Vanguard/Editor/SceneBuilder.cs
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Vanguard.App;
+
+namespace Vanguard.Editor
+{
+ ///
+ /// Создаёт сцены-экраны и прописывает их в Build Settings.
+ /// Запуск: меню Vanguard → Rebuild Client Scenes или
+ /// Unity -batchmode -quit -executeMethod Vanguard.Editor.SceneBuilder.BuildAll.
+ ///
+ public static class SceneBuilder
+ {
+ private const string SceneDir = "Assets/Vanguard/Scenes";
+ private const string BootPath = "Assets/Scenes/Boot.unity";
+
+ [MenuItem("Vanguard/Rebuild Client Scenes")]
+ public static void BuildAll()
+ {
+ if (!Directory.Exists(SceneDir))
+ {
+ Directory.CreateDirectory(SceneDir);
+ AssetDatabase.Refresh();
+ }
+
+ Create("Login");
+ Create("Lobby");
+ Create("Collection");
+ Create("DeckBuilder");
+ Create("Battle");
+
+ var scenes = new List();
+ Add(scenes, BootPath);
+ Add(scenes, SceneDir + "/Login.unity");
+ Add(scenes, SceneDir + "/Lobby.unity");
+ Add(scenes, SceneDir + "/Collection.unity");
+ Add(scenes, SceneDir + "/DeckBuilder.unity");
+ Add(scenes, SceneDir + "/Battle.unity");
+
+ EditorBuildSettings.scenes = scenes.ToArray();
+ AssetDatabase.SaveAssets();
+ AssetDatabase.Refresh();
+
+ Debug.Log("Vanguard scenes rebuilt: " + scenes.Count);
+ }
+
+ private static void Create(string sceneName) where T : MonoBehaviour
+ {
+ var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
+
+ var root = new GameObject(sceneName + " Scene");
+ root.AddComponent();
+
+ var cameraGo = new GameObject("Main Camera");
+ cameraGo.tag = "MainCamera";
+ var camera = cameraGo.AddComponent();
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = new Color(0.02f, 0.03f, 0.055f);
+ camera.transform.position = new Vector3(0f, 3.2f, -10f);
+ camera.transform.rotation = Quaternion.Euler(12f, 0f, 0f);
+
+ var lightGo = new GameObject("Directional Light");
+ var light = lightGo.AddComponent();
+ light.type = LightType.Directional;
+ light.color = new Color(0.82f, 0.88f, 1f);
+ light.intensity = 0.9f;
+ light.shadows = LightShadows.Soft;
+ light.transform.rotation = Quaternion.Euler(48f, -32f, 0f);
+
+ EditorSceneManager.SaveScene(scene, SceneDir + "/" + sceneName + ".unity");
+ }
+
+ private static void Add(ICollection scenes, string path)
+ {
+ if (File.Exists(path))
+ scenes.Add(new EditorBuildSettingsScene(path, true));
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Editor/SceneBuilder.cs.meta b/client/Assets/Vanguard/Editor/SceneBuilder.cs.meta
new file mode 100644
index 0000000..47904d3
--- /dev/null
+++ b/client/Assets/Vanguard/Editor/SceneBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c24d38169d69d16429c902bae594a19d
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Editor/SceneSmoke.cs b/client/Assets/Vanguard/Editor/SceneSmoke.cs
new file mode 100644
index 0000000..1077088
--- /dev/null
+++ b/client/Assets/Vanguard/Editor/SceneSmoke.cs
@@ -0,0 +1,62 @@
+using System;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Vanguard.App;
+
+namespace Vanguard.Editor
+{
+ ///
+ /// Быстрый smoke-запуск сцен из командной строки:
+ /// Unity -batchmode -nographics -executeMethod Vanguard.Editor.SceneSmoke.Run.
+ /// Открывает Boot, входит в Play Mode, ждёт загрузку Login/Lobby и выходит.
+ ///
+ public static class SceneSmoke
+ {
+ private static double _started;
+ private static int _checks;
+
+ [MenuItem("Vanguard/Play Scene Smoke")]
+ public static void Run()
+ {
+ EditorSceneManager.OpenScene("Assets/Scenes/Boot.unity");
+ _started = EditorApplication.timeSinceStartup;
+ _checks = 0;
+ EditorApplication.update += Tick;
+ EditorApplication.EnterPlaymode();
+ }
+
+ private static void Tick()
+ {
+ if (!EditorApplication.isPlaying)
+ return;
+
+ _checks++;
+ if (EditorApplication.timeSinceStartup - _started < 6.0)
+ return;
+
+ EditorApplication.update -= Tick;
+
+ var active = SceneManager.GetActiveScene().name;
+ var environment = GameObject.Find("Vanguard 3D Environment");
+ var login = GameObject.Find("LoginScreen");
+ var lobby = GameObject.Find("MetaScreen");
+ var onboarding = GameObject.Find("OnboardingScreen");
+ var success = environment != null
+ && (active == SceneFlow.Login || active == SceneFlow.Lobby)
+ && (login != null || lobby != null || onboarding != null);
+
+ Debug.Log(
+ "SCENE_SMOKE success=" + success
+ + " active=" + active
+ + " env=" + (environment != null)
+ + " login=" + (login != null)
+ + " lobby=" + (lobby != null)
+ + " onboarding=" + (onboarding != null)
+ + " error=" + VanguardApp.LastInitializationError);
+
+ EditorApplication.Exit(success ? 0 : 1);
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Editor/SceneSmoke.cs.meta b/client/Assets/Vanguard/Editor/SceneSmoke.cs.meta
new file mode 100644
index 0000000..3f9d53c
--- /dev/null
+++ b/client/Assets/Vanguard/Editor/SceneSmoke.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dfcd15474d289f848b7fa616f9b1d4e2
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef b/client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef
similarity index 50%
rename from client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef
rename to client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef
index db84e10..103dcca 100644
--- a/client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef
+++ b/client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef
@@ -1,19 +1,18 @@
{
- "name": "Vanguard.Tests.PlayMode",
- "rootNamespace": "Vanguard.Tests",
+ "name": "Vanguard.Editor",
+ "rootNamespace": "Vanguard.Editor",
"references": [
"Vanguard.Runtime"
],
- "includePlatforms": [],
+ "includePlatforms": [
+ "Editor"
+ ],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
- "autoReferenced": false,
+ "autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
- "noEngineReferences": false,
- "optionalUnityReferences": [
- "TestAssemblies"
- ]
+ "noEngineReferences": false
}
diff --git a/client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef.meta b/client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef.meta
similarity index 76%
rename from client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef.meta
rename to client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef.meta
index 4d19767..b84ade5 100644
--- a/client/Assets/Vanguard/Tests/PlayMode/Vanguard.Tests.PlayMode.asmdef.meta
+++ b/client/Assets/Vanguard/Editor/Vanguard.Editor.asmdef.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 199ad6dea713aef4cb50a4d8d01c9ca4
+guid: f65969750402d974c8bc90b95a61ae96
AssemblyDefinitionImporter:
externalObjects: {}
userData:
diff --git a/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs b/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs
new file mode 100644
index 0000000..d8402c0
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs
@@ -0,0 +1,35 @@
+using UnityEngine;
+using Vanguard.Meta;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ public sealed class BattleSceneController : SceneControllerBase
+ {
+ private BattleView _view;
+
+ private void Start()
+ {
+ SceneEnvironment.Build(SceneTheme.Battle);
+
+ var info = Flow.ConsumePendingMatch();
+ if (info == null)
+ {
+ _ = Flow.LoadLobbyAsync();
+ return;
+ }
+
+ var canvas = CreateCanvas("Battle Canvas");
+ _view = new BattleView(
+ canvas.transform,
+ Resolve(),
+ () => _ = Flow.LoadLobbyAsync());
+ _view.Show(info);
+ }
+
+ private void Update()
+ {
+ _view?.Tick();
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs.meta b/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs.meta
new file mode 100644
index 0000000..1651fe8
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/BattleSceneController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9a62d9e570dfb024d96981ddfffdf2ad
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs
new file mode 100644
index 0000000..0472bf6
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs
@@ -0,0 +1,23 @@
+using UnityEngine;
+using Vanguard.Meta;
+using Vanguard.State;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ public sealed class CollectionSceneController : SceneControllerBase
+ {
+ private CollectionView _view;
+
+ private void Start()
+ {
+ SceneEnvironment.Build(SceneTheme.Collection);
+ var canvas = CreateCanvas("Collection Canvas");
+ _view = new CollectionView(
+ canvas.transform,
+ Resolve(),
+ Resolve(),
+ () => _ = Flow.LoadLobbyAsync());
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs.meta b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs.meta
new file mode 100644
index 0000000..40f31ba
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/CollectionSceneController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8ccb09cec5c89814493fae05cf2d6051
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs
new file mode 100644
index 0000000..321df07
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs
@@ -0,0 +1,25 @@
+using UnityEngine;
+using Vanguard.Api.Contracts;
+using Vanguard.Meta;
+using Vanguard.State;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ public sealed class DeckBuilderSceneController : SceneControllerBase
+ {
+ private DecksView _view;
+
+ private void Start()
+ {
+ SceneEnvironment.Build(SceneTheme.DeckBuilder);
+ var canvas = CreateCanvas("DeckBuilder Canvas");
+ _view = new DecksView(
+ canvas.transform,
+ Resolve(),
+ Resolve(),
+ () => _ = Flow.LoadLobbyAsync(),
+ deck => _ = Flow.OpenLobbyWithQueueAsync(deck));
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs.meta b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs.meta
new file mode 100644
index 0000000..dce0b78
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/DeckBuilderSceneController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c87789aeb7d84cc438babc89db55d1fc
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs
new file mode 100644
index 0000000..e4f94a7
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs
@@ -0,0 +1,98 @@
+using UnityEngine;
+using Vanguard.Api.Contracts;
+using Vanguard.Auth;
+using Vanguard.Battle;
+using Vanguard.Meta;
+using Vanguard.State;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ public sealed class LobbySceneController : SceneControllerBase
+ {
+ private AuthService _auth;
+ private MetaService _meta;
+ private PlayerState _player;
+ private MetaView _metaView;
+ private OnboardingView _onboarding;
+ private QueueView _queue;
+
+ private void Start()
+ {
+ SceneEnvironment.Build(SceneTheme.Lobby);
+
+ _auth = Resolve();
+ _meta = Resolve();
+ _player = Resolve();
+ var canvas = CreateCanvas("Lobby Canvas");
+
+ _metaView = new MetaView(
+ canvas.transform,
+ _auth,
+ _player,
+ OnLogout,
+ () => _ = Flow.LoadCollectionAsync(),
+ () => _ = Flow.LoadDeckBuilderAsync(),
+ () => _ = Flow.LoadDeckBuilderAsync(),
+ ShowOnboarding);
+ _onboarding = new OnboardingView(
+ canvas.transform, _auth, _meta, OnOnboarded, OnLogout);
+ _queue = new QueueView(
+ canvas.transform, _meta, _auth, OnQueueBack, OnMatched);
+
+ ShowDefault();
+ }
+
+ private void ShowDefault()
+ {
+ var pendingDeck = Flow.ConsumePendingQueueDeck();
+ if (pendingDeck != null)
+ {
+ HideAll();
+ _queue.Show(pendingDeck);
+ return;
+ }
+
+ HideAll();
+ if (_player.OnboardingCompleted)
+ _metaView.Show();
+ else
+ _onboarding.Show();
+ }
+
+ private void ShowOnboarding()
+ {
+ HideAll();
+ _onboarding.Show();
+ }
+
+ private void OnOnboarded()
+ {
+ HideAll();
+ _metaView.Show();
+ }
+
+ private void OnQueueBack()
+ {
+ _queue.Hide();
+ _metaView.Show();
+ }
+
+ private void OnMatched(MatchConnectionInfo info)
+ {
+ _ = Flow.LoadBattleAsync(info);
+ }
+
+ private void OnLogout()
+ {
+ _ = Flow.LoadLoginAsync();
+ }
+
+ private void HideAll()
+ {
+ _metaView.Hide();
+ _onboarding.Hide();
+ _queue.Hide();
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs.meta b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs.meta
new file mode 100644
index 0000000..bf7ccda
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/LobbySceneController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a1fe2c339c891e849a96ecc6a5c22748
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs b/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs
new file mode 100644
index 0000000..d5d4d19
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using UnityEngine;
+using Vanguard.Auth;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ public sealed class LoginSceneController : SceneControllerBase
+ {
+ private AuthService _auth;
+ private LoginView _view;
+
+ private void Start()
+ {
+ SceneEnvironment.Build(SceneTheme.Login);
+ _auth = Resolve();
+ var config = Resolve();
+
+ var canvas = CreateCanvas("Login Canvas");
+ _view = new LoginView(canvas.transform, config, _auth, OnAuthenticated);
+ _view.Show("Проверяем сессию…");
+ _ = RestoreAsync();
+ }
+
+ private async Task RestoreAsync()
+ {
+ try
+ {
+ if (await _auth.TryRestoreAsync())
+ {
+ await Flow.LoadLobbyAsync();
+ return;
+ }
+ }
+ catch (System.Exception exception)
+ {
+ Debug.LogException(exception);
+ }
+
+ _view.Show("Введите данные игрока.");
+ }
+
+ private void OnAuthenticated()
+ {
+ _ = Flow.LoadLobbyAsync();
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs.meta b/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs.meta
new file mode 100644
index 0000000..9c0b910
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/LoginSceneController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dc0007ae5cfaa2647a674301f0042159
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs b/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs
new file mode 100644
index 0000000..cc55c92
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs
@@ -0,0 +1,31 @@
+using UnityEngine;
+using VContainer;
+using Vanguard.UI;
+
+namespace Vanguard.App
+{
+ /// База контроллера сцены: доступ к персистентным сервисам и Canvas.
+ public abstract class SceneControllerBase : MonoBehaviour
+ {
+ protected VanguardApp App { get; private set; }
+ protected IObjectResolver Container => App.Container;
+ protected SceneFlow Flow => App.Flow;
+
+ protected virtual void Awake()
+ {
+ App = VanguardApp.Instance;
+ if (App == null)
+ {
+ Debug.LogError("VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента.");
+ enabled = false;
+ }
+ }
+
+ protected Canvas CreateCanvas(string name)
+ {
+ return UiKit.CreateCanvas(name, persistent: false);
+ }
+
+ protected T Resolve() where T : class => Container.Resolve();
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs.meta b/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs.meta
new file mode 100644
index 0000000..bec8752
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneControllerBase.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 861c568d3c51e8a4e9997279e60fee32
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs
new file mode 100644
index 0000000..df3fcbd
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs
@@ -0,0 +1,251 @@
+using UnityEngine;
+
+namespace Vanguard.App
+{
+ public enum SceneTheme
+ {
+ Login,
+ Lobby,
+ Collection,
+ DeckBuilder,
+ Battle,
+ }
+
+ ///
+ /// Простой 3D-фон для экранов: камера, свет, пол и тематические
+ /// примитивы. Это заготовка под будущие модели и арт; UI остаётся
+ /// отдельным Canvas-слоем.
+ ///
+ public static class SceneEnvironment
+ {
+ private const string RootName = "Vanguard 3D Environment";
+
+ public static void Build(SceneTheme theme)
+ {
+ if (GameObject.Find(RootName) != null)
+ return;
+
+ var root = new GameObject(RootName);
+ var palette = GetPalette(theme);
+
+ SetCamera(theme, palette);
+ SetLight(theme, palette);
+ RenderSettings.ambientLight = palette.Ambient;
+ RenderSettings.fog = true;
+ RenderSettings.fogMode = FogMode.Exponential;
+ RenderSettings.fogColor = palette.Background;
+ RenderSettings.fogDensity = 0.012f;
+
+ CreateFloor(root.transform, palette);
+ Decorate(theme, root.transform, palette);
+ }
+
+ private static void SetCamera(SceneTheme theme, ScenePalette palette)
+ {
+ var camera = Camera.main;
+ if (camera == null)
+ {
+ var go = new GameObject("Main Camera");
+ go.tag = "MainCamera";
+ camera = go.AddComponent();
+ }
+
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = palette.Background;
+ camera.fieldOfView = 52f;
+ camera.transform.position = theme == SceneTheme.Battle
+ ? new Vector3(0f, 7.5f, -13f)
+ : new Vector3(0f, 3.2f, -10f);
+ camera.transform.rotation = theme == SceneTheme.Battle
+ ? Quaternion.Euler(28f, 0f, 0f)
+ : Quaternion.Euler(12f, 0f, 0f);
+ }
+
+ private static void SetLight(SceneTheme theme, ScenePalette palette)
+ {
+ var light = Object.FindAnyObjectByType();
+ if (light == null)
+ {
+ var go = new GameObject("Directional Light");
+ light = go.AddComponent();
+ light.type = LightType.Directional;
+ }
+
+ light.color = palette.Light;
+ light.intensity = theme == SceneTheme.Battle ? 1.15f : 0.9f;
+ light.transform.rotation = Quaternion.Euler(48f, -32f, 0f);
+ light.shadows = LightShadows.Soft;
+ }
+
+ private static void CreateFloor(Transform root, ScenePalette palette)
+ {
+ var floor = GameObject.CreatePrimitive(PrimitiveType.Plane);
+ floor.name = "Floor";
+ floor.transform.SetParent(root, false);
+ floor.transform.localScale = new Vector3(4f, 1f, 4f);
+ floor.transform.position = new Vector3(0f, -0.02f, 2f);
+ Paint(floor, palette.Floor, 0f);
+ }
+
+ private static void Decorate(SceneTheme theme, Transform root, ScenePalette palette)
+ {
+ switch (theme)
+ {
+ case SceneTheme.Login:
+ Cube(root, "Column L", new Vector3(-5.5f, 2.4f, 2f), new Vector3(1f, 5f, 1f), palette.Structure);
+ Cube(root, "Column R", new Vector3(5.5f, 2.4f, 2f), new Vector3(1f, 5f, 1f), palette.Structure);
+ Cube(root, "Backdrop", new Vector3(0f, 3.2f, 8f), new Vector3(16f, 7f, 0.4f), palette.Structure);
+ Cube(root, "Emblem", new Vector3(0f, 3.4f, 7.7f), new Vector3(2.4f, 2.4f, 0.2f), palette.Accent, 1.6f);
+ break;
+
+ case SceneTheme.Lobby:
+ for (var i = -1; i <= 1; i += 2)
+ {
+ Cube(root, "Pillar " + i, new Vector3(i * 7f, 2.5f, 3f), new Vector3(1.2f, 5f, 1.2f), palette.Structure);
+ Cube(root, "Table " + i, new Vector3(i * 3.6f, 0.55f, 0.5f), new Vector3(3f, 1f, 3f), palette.Structure);
+ }
+ Cube(root, "Back Wall", new Vector3(0f, 3f, 9f), new Vector3(18f, 6f, 0.5f), palette.Structure);
+ Cube(root, "Lobby Light", new Vector3(0f, 5.2f, 4f), new Vector3(6f, 0.2f, 1f), palette.Accent, 1.8f);
+ break;
+
+ case SceneTheme.Collection:
+ for (var i = -2; i <= 2; i++)
+ {
+ Cube(root, "Shelf " + i, new Vector3(i * 3.2f, 1.6f, 6.5f), new Vector3(2.2f, 3.2f, 0.5f), palette.Structure);
+ Cube(root, "Shelf Glow " + i, new Vector3(i * 3.2f, 3.35f, 6.2f), new Vector3(2.2f, 0.1f, 0.5f), palette.Accent, 1.2f);
+ }
+ Cube(root, "Table", new Vector3(0f, 0.4f, 1.5f), new Vector3(7f, 0.8f, 4f), palette.Structure);
+ break;
+
+ case SceneTheme.DeckBuilder:
+ Cube(root, "Table", new Vector3(0f, 0.5f, 2f), new Vector3(11f, 1f, 6f), palette.Structure);
+ Cube(root, "Deck Stack", new Vector3(-3.8f, 1.3f, 3.2f), new Vector3(1.5f, 1.4f, 2.2f), palette.Accent, 0.8f);
+ Cube(root, "Mat", new Vector3(1.5f, 1.06f, 3f), new Vector3(5f, 0.08f, 3.5f), palette.Floor);
+ break;
+
+ case SceneTheme.Battle:
+ Cube(root, "Battle Table", new Vector3(0f, 0.2f, 1.5f), new Vector3(13f, 0.4f, 7f), palette.Structure);
+ Cube(root, "Center Line", new Vector3(0f, 0.44f, 1.5f), new Vector3(13f, 0.04f, 0.12f), palette.Accent, 1.2f);
+ for (var i = 0; i < 5; i++)
+ {
+ var x = (i - 2) * 2.2f;
+ Cube(root, "Zone " + i, new Vector3(x, 0.44f, 3.1f), new Vector3(1.8f, 0.04f, 2.2f), palette.Zone, 0.5f);
+ Cube(root, "Enemy Zone " + i, new Vector3(x, 0.44f, -0.1f), new Vector3(1.8f, 0.04f, 2.2f), palette.EnemyZone, 0.5f);
+ }
+ break;
+ }
+ }
+
+ private static void Cube(
+ Transform parent,
+ string name,
+ Vector3 position,
+ Vector3 scale,
+ Color color,
+ float emission = 0f)
+ {
+ var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
+ cube.name = name;
+ cube.transform.SetParent(parent, false);
+ cube.transform.position = position;
+ cube.transform.localScale = scale;
+ Paint(cube, color, emission);
+ }
+
+ private static void Paint(GameObject target, Color color, float emission)
+ {
+ var renderer = target.GetComponent();
+ if (renderer == null)
+ return;
+
+ var shader = Shader.Find("Universal Render Pipeline/Lit");
+ if (shader == null)
+ shader = Shader.Find("Standard");
+
+ var material = new Material(shader);
+ if (material.HasProperty("_BaseColor"))
+ material.SetColor("_BaseColor", color);
+ else if (material.HasProperty("_Color"))
+ material.color = color;
+
+ if (emission > 0f)
+ {
+ material.EnableKeyword("_EMISSION");
+ material.SetColor("_EmissionColor", color * emission);
+ }
+
+ renderer.material = material;
+ }
+
+ private static ScenePalette GetPalette(SceneTheme theme)
+ {
+ switch (theme)
+ {
+ case SceneTheme.Login:
+ return new ScenePalette(
+ new Color(0.015f, 0.02f, 0.045f),
+ new Color(0.035f, 0.045f, 0.075f),
+ new Color(0.10f, 0.12f, 0.18f),
+ new Color(0.20f, 0.42f, 0.95f),
+ new Color(0.75f, 0.82f, 1f),
+ new Color(0.04f, 0.05f, 0.09f),
+ new Color(0.10f, 0.20f, 0.40f),
+ new Color(0.40f, 0.12f, 0.10f));
+
+ case SceneTheme.Battle:
+ return new ScenePalette(
+ new Color(0.035f, 0.025f, 0.03f),
+ new Color(0.08f, 0.06f, 0.07f),
+ new Color(0.18f, 0.12f, 0.12f),
+ new Color(0.85f, 0.55f, 0.24f),
+ new Color(1f, 0.88f, 0.66f),
+ new Color(0.05f, 0.08f, 0.14f),
+ new Color(0.10f, 0.22f, 0.42f),
+ new Color(0.42f, 0.10f, 0.10f));
+
+ default:
+ return new ScenePalette(
+ new Color(0.02f, 0.03f, 0.055f),
+ new Color(0.06f, 0.075f, 0.11f),
+ new Color(0.12f, 0.15f, 0.20f),
+ new Color(0.30f, 0.55f, 0.85f),
+ new Color(0.82f, 0.88f, 1f),
+ new Color(0.08f, 0.11f, 0.17f),
+ new Color(0.16f, 0.30f, 0.52f),
+ new Color(0.38f, 0.12f, 0.12f));
+ }
+ }
+
+ private sealed class ScenePalette
+ {
+ public ScenePalette(
+ Color background,
+ Color floor,
+ Color structure,
+ Color accent,
+ Color light,
+ Color ambient,
+ Color zone,
+ Color enemyZone)
+ {
+ Background = background;
+ Floor = floor;
+ Structure = structure;
+ Accent = accent;
+ Light = light;
+ Ambient = ambient;
+ Zone = zone;
+ EnemyZone = enemyZone;
+ }
+
+ public Color Background { get; }
+ public Color Floor { get; }
+ public Color Structure { get; }
+ public Color Accent { get; }
+ public Color Light { get; }
+ public Color Ambient { get; }
+ public Color Zone { get; }
+ public Color EnemyZone { get; }
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs.meta b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs.meta
new file mode 100644
index 0000000..2ab1a8b
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d1a7a96d0d591fe458e5ec20dfebeb73
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/SceneFlow.cs b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs
new file mode 100644
index 0000000..25cb020
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs
@@ -0,0 +1,84 @@
+using System.Threading.Tasks;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Vanguard.Api.Contracts;
+using Vanguard.Battle;
+
+namespace Vanguard.App
+{
+ ///
+ /// Переходы между сценами-экранами. Boot и VanguardApp остаются
+ /// персистентными; данные перехода (выбранная колода, тикет) временно
+ /// лежат здесь и забираются целевой сценой.
+ ///
+ public sealed class SceneFlow
+ {
+ public const string Boot = "Boot";
+ public const string Login = "Login";
+ public const string Lobby = "Lobby";
+ public const string Collection = "Collection";
+ public const string DeckBuilder = "DeckBuilder";
+ public const string Battle = "Battle";
+
+ public DeckDto PendingQueueDeck { get; private set; }
+ public MatchConnectionInfo PendingMatch { get; private set; }
+
+ public Task LoadLoginAsync()
+ {
+ PendingQueueDeck = null;
+ PendingMatch = null;
+ return LoadAsync(Login);
+ }
+
+ public Task LoadLobbyAsync() => LoadAsync(Lobby);
+
+ public Task LoadCollectionAsync() => LoadAsync(Collection);
+
+ public Task LoadDeckBuilderAsync() => LoadAsync(DeckBuilder);
+
+ public Task OpenLobbyWithQueueAsync(DeckDto deck)
+ {
+ PendingQueueDeck = deck;
+ return LoadAsync(Lobby);
+ }
+
+ public Task LoadBattleAsync(MatchConnectionInfo info)
+ {
+ PendingMatch = info;
+ return LoadAsync(Battle);
+ }
+
+ public DeckDto ConsumePendingQueueDeck()
+ {
+ var deck = PendingQueueDeck;
+ PendingQueueDeck = null;
+ return deck;
+ }
+
+ public MatchConnectionInfo ConsumePendingMatch()
+ {
+ var match = PendingMatch;
+ PendingMatch = null;
+ return match;
+ }
+
+ public async Task LoadAsync(string sceneName)
+ {
+ if (string.IsNullOrEmpty(sceneName))
+ return;
+
+ if (SceneManager.GetActiveScene().name == sceneName)
+ return;
+
+ var operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single);
+ if (operation == null)
+ {
+ Debug.LogError("Сцена не добавлена в Build Settings: " + sceneName);
+ return;
+ }
+
+ while (!operation.isDone)
+ await Task.Yield();
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/App/SceneFlow.cs.meta b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs.meta
new file mode 100644
index 0000000..638a6fb
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c543dd07e75d9c948a29d8a429860c95
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs
index 32f40e9..f3e6dfe 100644
--- a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs
+++ b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs
@@ -1,30 +1,32 @@
using System;
-using System.Threading.Tasks;
using UnityEngine;
using VContainer;
using Vanguard.Api;
using Vanguard.Auth;
using Vanguard.Meta;
using Vanguard.State;
-using Vanguard.UI;
namespace Vanguard.App
{
///
- /// Composition root клиента. Создаётся из runtime-инициализатора, поэтому
- /// не зависит от содержимого сцены; сцены появятся позже.
+ /// Composition root клиента. Живёт между сценами, хранит DI-контейнер и
+ /// общие сервисы; сцены отвечают только за 3D-вид и свой UI.
///
public sealed class VanguardApp : MonoBehaviour
{
+ public static VanguardApp Instance { get; private set; }
public static string LastInitializationError { get; private set; }
private IObjectResolver _container;
- private AppRootView _root;
+
+ public IObjectResolver Container => _container;
+ public SceneFlow Flow { get; private set; }
private void Awake()
{
try
{
+ Instance = this;
DontDestroyOnLoad(gameObject);
Application.targetFrameRate = 60;
@@ -38,13 +40,10 @@ namespace Vanguard.App
builder.Register(Lifetime.Singleton);
builder.Register(Lifetime.Singleton);
builder.Register(Lifetime.Singleton);
- builder.Register(Lifetime.Singleton);
+ builder.Register(Lifetime.Singleton);
_container = builder.Build();
- _root = _container.Resolve();
- _root.Initialize();
-
- _ = RestoreSessionAsync();
+ Flow = _container.Resolve();
}
catch (Exception exception)
{
@@ -53,33 +52,17 @@ namespace Vanguard.App
}
}
- private async Task RestoreSessionAsync()
+ private void Start()
{
- var auth = _container.Resolve();
-
- try
- {
- if (await auth.TryRestoreAsync())
- {
- _root.ShowAfterLogin();
- return;
- }
- }
- catch (Exception exception)
- {
- Debug.LogException(exception);
- }
-
- _root.ShowLogin("Введите данные игрока.");
- }
-
- private void Update()
- {
- _root?.Tick();
+ if (Flow != null)
+ _ = Flow.LoadLoginAsync();
}
private void OnDestroy()
{
+ if (Instance == this)
+ Instance = null;
+
if (_container is IDisposable disposable)
disposable.Dispose();
}
diff --git a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs
deleted file mode 100644
index 1a3c72e..0000000
--- a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using UnityEngine;
-using Vanguard.Api.Contracts;
-using Vanguard.App;
-using Vanguard.Auth;
-using Vanguard.Battle;
-using Vanguard.Meta;
-using Vanguard.State;
-
-namespace Vanguard.UI
-{
- /// Владелец Canvas и переключения экранов.
- public sealed class AppRootView
- {
- private readonly AppConfig _config;
- private readonly AuthService _auth;
- private readonly PlayerState _player;
- private readonly MetaService _metaService;
-
- private LoginView _login;
- private MetaView _metaView;
- private OnboardingView _onboarding;
- private CollectionView _collection;
- private DecksView _decks;
- private QueueView _queue;
- private BattleView _battle;
-
- public AppRootView(
- AppConfig config,
- AuthService auth,
- PlayerState player,
- MetaService metaService)
- {
- _config = config;
- _auth = auth;
- _player = player;
- _metaService = metaService;
- }
-
- public void Initialize()
- {
- var canvas = UiKit.CreateCanvas("Vanguard Canvas");
-
- _login = new LoginView(canvas.transform, _config, _auth, OnAuthenticated);
- _metaView = new MetaView(
- canvas.transform,
- _auth,
- _player,
- OnLoggedOut,
- ShowCollection,
- ShowDecks,
- ShowDecks,
- ShowOnboarding);
- _onboarding = new OnboardingView(
- canvas.transform, _auth, _metaService, OnOnboarded, OnLoggedOut);
- _collection = new CollectionView(
- canvas.transform, _metaService, _player, ShowMeta);
- _decks = new DecksView(
- canvas.transform, _metaService, _player, ShowMeta, ShowQueue);
- _queue = new QueueView(
- canvas.transform, _metaService, _auth, ShowDecks, ShowBattle);
- _battle = new BattleView(
- canvas.transform, _metaService, ShowMeta);
-
- ShowLogin("Проверяем сессию…");
- }
-
- public void Tick()
- {
- _battle?.Tick();
- }
-
- public void ShowLogin(string status = null)
- {
- HideAll();
- _login.Show(status);
- }
-
- public void ShowAfterLogin()
- {
- if (_player.OnboardingCompleted)
- ShowMeta();
- else
- ShowOnboarding();
- }
-
- public void ShowMeta()
- {
- HideAll();
- _metaView.Show();
- }
-
- public void ShowOnboarding()
- {
- HideAll();
- _onboarding.Show();
- }
-
- public void ShowCollection()
- {
- HideAll();
- _collection.Show();
- }
-
- public void ShowDecks()
- {
- HideAll();
- _decks.Show();
- }
-
- public void ShowQueue(DeckDto deck)
- {
- HideAll();
- _queue.Show(deck);
- }
-
- public void ShowBattle(MatchConnectionInfo info)
- {
- HideAll();
- _battle.Show(info);
- }
-
- private void HideAll()
- {
- _login.Hide();
- _metaView.Hide();
- _onboarding.Hide();
- _collection.Hide();
- _decks.Hide();
- _queue.Hide();
- _battle.Hide();
- }
-
- private void OnAuthenticated()
- {
- ShowAfterLogin();
- }
-
- private void OnOnboarded()
- {
- ShowMeta();
- }
-
- private void OnLoggedOut()
- {
- ShowLogin("Сессия завершена. Войдите снова.");
- }
- }
-}
diff --git a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs.meta b/client/Assets/Vanguard/Runtime/UI/AppRootView.cs.meta
deleted file mode 100644
index fe5719b..0000000
--- a/client/Assets/Vanguard/Runtime/UI/AppRootView.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: ff1fdb94bf828d64099d3458f8e34b12
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/UI/UiKit.cs b/client/Assets/Vanguard/Runtime/UI/UiKit.cs
index 51a2fa0..c16a3f8 100644
--- a/client/Assets/Vanguard/Runtime/UI/UiKit.cs
+++ b/client/Assets/Vanguard/Runtime/UI/UiKit.cs
@@ -29,12 +29,12 @@ namespace Vanguard.UI
}
}
- public static Canvas CreateCanvas(string name)
+ public static Canvas CreateCanvas(string name, bool persistent = false)
{
EnsureEventSystem();
var go = new GameObject(name, typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
- if (Application.isPlaying)
+ if (Application.isPlaying && persistent)
Object.DontDestroyOnLoad(go);
var canvas = go.GetComponent