client: Login по референсу — 2D key art и неоновый UI
CI / test (push) Successful in 3m16s Details

- Login: полноэкранный арт Resources/Login/login_bg, неоновая панель
  560x420, два поля, кнопка «Войти», ссылка «Создать аккаунт», статус.
- NeonUi: 9-slice панель/поле/кнопка с фаской, свечение, иконки.
- LoginMenu: гамбургер, RU/EN, версия, адрес сервера; закрытие по Esc,
  кнопке и клику вне панели.
- Tab/Shift+Tab между полями, Enter отправляет форму (в открытом меню — нет).
- 3D-окружение в Login больше не строится; SceneSmoke проверяет backdrop,
  окружение требуется только для Lobby.
- EditMode 60/60, SCENE_SMOKE success=True, standalone собран.
- План: TASK-2026-021 C1-C9; ручная приёмка — за пользователем.
This commit is contained in:
MpakobecEx 2026-10-31 17:30:09 +05:00
parent dbb4888299
commit af3fb2b621
20 changed files with 1724 additions and 121 deletions

View File

@ -115,17 +115,21 @@ namespace Vanguard.Editor
var active = SceneManager.GetActiveScene().name;
var environment = GameObject.Find("Vanguard 3D Environment");
var backdrop = GameObject.Find("LoginBackdrop");
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);
var isLogin = active == SceneFlow.Login;
var isLobby = active == SceneFlow.Lobby;
var success = (isLogin || isLobby)
&& (login != null || lobby != null || onboarding != null)
&& (isLogin ? backdrop != null : environment != null);
Debug.Log(
"SCENE_SMOKE success=" + success
+ " active=" + active
+ " env=" + (environment != null)
+ " backdrop=" + (backdrop != null)
+ " login=" + (login != null)
+ " lobby=" + (lobby != null)
+ " onboarding=" + (onboarding != null)

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1500707083b88724a98ef1abd9602118
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -0,0 +1,132 @@
fileFormatVersion: 2
guid: a5cd624cf86a9a64aaee9fe1bc10ab9d
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationMethod: 0
spriteTessellationDetail: -1
spriteGeometrySubdivision: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -12,7 +12,6 @@ namespace Vanguard.App
private void Start()
{
SceneEnvironment.Build(SceneTheme.Login);
_auth = Resolve<AuthService>();
var config = Resolve<AppConfig>();

View File

@ -117,6 +117,16 @@ namespace Vanguard.UI
["Готово."] = "Done.",
["Забрать"] = "Claim",
["Закрыть"] = "Close",
["Войти"] = "Sign in",
["Подключение…"] = "Connecting…",
["Создать аккаунт"] = "Create account",
["Забыли пароль?"] = "Forgot password?",
["Логин"] = "Login",
["Меню"] = "Menu",
["Адрес сервера"] = "Server address",
["Введите логин и пароль."] = "Enter login and password.",
["Сброс пароля не поддерживается. Обратитесь к администратору."] =
"Password reset is not supported. Contact an administrator.",
["История"] = "History",
["Карта: "] = "Card: ",
["Магазин"] = "Store",

View File

@ -0,0 +1,73 @@
using UnityEngine;
using UnityEngine.UI;
namespace Vanguard.UI
{
/// <summary>
/// Фон экрана входа: key art на весь экран (cover) и затемнение по краям.
/// Арт лежит в Resources, потому что Addressables в клиенте ещё не подключены.
/// </summary>
public static class LoginBackdrop
{
private const string ResourcePath = "Login/login_bg";
public static RectTransform Create(Transform parent)
{
var rootGo = new GameObject("LoginBackdrop", typeof(RectTransform));
var root = (RectTransform)rootGo.transform;
root.SetParent(parent, false);
UiKit.Stretch(root);
root.SetAsFirstSibling();
var texture = Resources.Load<Texture2D>(ResourcePath);
if (texture != null)
{
var artGo = new GameObject(
"Art",
typeof(RectTransform),
typeof(Image),
typeof(AspectRatioFitter));
var art = (RectTransform)artGo.transform;
art.SetParent(root, false);
UiKit.Stretch(art);
var image = artGo.GetComponent<Image>();
image.sprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
100f);
image.type = Image.Type.Simple;
image.preserveAspect = false;
image.raycastTarget = false;
var fitter = artGo.GetComponent<AspectRatioFitter>();
fitter.aspectMode = AspectRatioFitter.AspectMode.EnvelopeParent;
fitter.aspectRatio = texture.width / (float)texture.height;
}
else
{
// Арта нет — тёмная заливка, экран не должен падать.
var fallback = UiKit.CreatePanel(
root, "Fallback", new Color(0.02f, 0.05f, 0.13f, 1f));
fallback.GetComponent<Image>().raycastTarget = false;
}
var shade = NeonUi.AddImage(
root,
"Shade",
NeonUi.Vignette(
480,
270,
new Color(0f, 0f, 0f, 0.08f),
new Color(0.004f, 0.016f, 0.055f, 0.72f)),
new Vector2(480f, 270f),
Vector2.zero);
shade.preserveAspect = false;
UiKit.Stretch(shade.rectTransform);
return root;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ae0147101ebe1d14da83d20cf2212c04

View File

@ -0,0 +1,198 @@
using UnityEngine;
using UnityEngine.UI;
using Vanguard.App;
namespace Vanguard.UI
{
/// <summary>Гамбургер-меню логина: язык, версия и адрес сервера.</summary>
public sealed class LoginMenu
{
private readonly GameObject _blocker;
private readonly RectTransform _panel;
public bool IsOpen => _panel != null && _panel.gameObject.activeSelf;
public LoginMenu(Transform parent, AppConfig config)
{
_blocker = new GameObject(
"MenuBlocker",
typeof(RectTransform),
typeof(Image),
typeof(Button));
var blockerRect = (RectTransform)_blocker.transform;
blockerRect.SetParent(parent, false);
UiKit.Stretch(blockerRect);
var blockerImage = _blocker.GetComponent<Image>();
blockerImage.color = new Color(0f, 0f, 0f, 0.001f);
blockerImage.raycastTarget = true;
var blockerButton = _blocker.GetComponent<Button>();
blockerButton.transition = Selectable.Transition.None;
blockerButton.onClick.AddListener(Close);
_blocker.SetActive(false);
var burger = NeonUi.AddAnchored(
parent,
"MenuButton",
NeonUi.Body(
56,
56,
12,
new Color(0.027f, 0.066f, 0.17f, 0.92f),
new Color(0.18f, 0.49f, 1f, 0.85f),
1),
new Vector2(56f, 56f),
new Vector2(1f, 1f),
new Vector2(1f, 1f),
new Vector2(-28f, -28f));
var burgerImage = burger.GetComponent<Image>();
burgerImage.raycastTarget = true;
var burgerButton = burger.gameObject.AddComponent<Button>();
burgerButton.targetGraphic = burgerImage;
burgerButton.transition = Selectable.Transition.ColorTint;
burgerButton.colors = new ColorBlock
{
normalColor = Color.white,
highlightedColor = new Color(1.15f, 1.15f, 1.15f, 1f),
pressedColor = new Color(0.9f, 0.9f, 0.9f, 1f),
selectedColor = Color.white,
disabledColor = new Color(0.6f, 0.6f, 0.6f, 0.5f),
colorMultiplier = 1f,
fadeDuration = 0.08f,
};
burgerButton.onClick.AddListener(Toggle);
var barColor = new Color(0.75f, 0.88f, 1f, 0.95f);
for (var i = -1; i <= 1; i++)
{
NeonUi.AddImage(
burger,
"Bar" + i,
NeonUi.HorizontalBar(22, 2, barColor),
new Vector2(22f, 2f),
new Vector2(0f, i * 7f));
}
var panelGo = new GameObject("MenuPanel", typeof(RectTransform), typeof(Image));
_panel = (RectTransform)panelGo.transform;
_panel.SetParent(parent, false);
_panel.anchorMin = new Vector2(1f, 1f);
_panel.anchorMax = new Vector2(1f, 1f);
_panel.pivot = new Vector2(1f, 1f);
_panel.anchoredPosition = new Vector2(-28f, -96f);
_panel.sizeDelta = new Vector2(340f, 300f);
var panelImage = panelGo.GetComponent<Image>();
panelImage.sprite = NeonUi.Body(
340,
300,
16,
new Color(0.027f, 0.066f, 0.17f, 0.96f),
new Color(0.18f, 0.49f, 1f, 0.85f),
1);
panelImage.type = Image.Type.Simple;
panelImage.raycastTarget = true;
BuildContent(config);
_panel.gameObject.SetActive(false);
}
private void BuildContent(AppConfig config)
{
UiKit.AddVerticalLayout(_panel, 16, 10);
var title = UiKit.CreateText(
_panel,
"Title",
"Меню",
18,
new Color(0.82f, 0.9f, 1f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(title, 24f);
var row = new GameObject("LanguageRow", typeof(RectTransform));
row.transform.SetParent(_panel, false);
UiKit.SetPreferredHeight(row.transform, 36f);
var languageLabel = UiKit.CreateText(
row.transform,
"Label",
"Язык",
15,
new Color(0.66f, 0.76f, 0.9f),
TextAnchor.MiddleLeft);
var labelRect = languageLabel.rectTransform;
labelRect.anchorMin = new Vector2(0f, 0f);
labelRect.anchorMax = new Vector2(0.6f, 1f);
labelRect.offsetMin = Vector2.zero;
labelRect.offsetMax = Vector2.zero;
var language = UiKit.CreateLanguageButton(row.transform, "Language");
var languageRect = language.GetComponent<RectTransform>();
languageRect.anchorMin = new Vector2(1f, 0.5f);
languageRect.anchorMax = new Vector2(1f, 0.5f);
languageRect.pivot = new Vector2(1f, 0.5f);
languageRect.sizeDelta = new Vector2(64f, 32f);
languageRect.anchoredPosition = Vector2.zero;
var version = UiKit.CreateText(
_panel,
"Version",
"Версия " + Application.version,
13,
new Color(0.52f, 0.62f, 0.78f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(version, 18f);
UiKit.MakeLiteral(version);
var caption = UiKit.CreateText(
_panel,
"ServerCaption",
"Адрес сервера",
13,
new Color(0.52f, 0.62f, 0.78f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(caption, 16f);
var server = UiKit.CreateInput(_panel, "ServerUrl", "http://host:port");
UiKit.SetPreferredHeight(server, 42f);
server.text = config.ServerBaseUrl;
UiKit.MakeLiteral(server.textComponent);
server.onEndEdit.AddListener(value =>
{
config.SetServerBaseUrl(value);
server.text = config.ServerBaseUrl;
});
var close = UiKit.CreateButton(_panel, "Close", "Закрыть", Close, 15);
var closeImage = close.GetComponent<Image>();
if (closeImage != null)
closeImage.color = new Color(0.06f, 0.12f, 0.24f, 0.95f);
UiKit.SetPreferredHeight(close, 36f);
}
private void Toggle()
{
if (_panel.gameObject.activeSelf)
Close();
else
Open();
}
private void Open()
{
_blocker.SetActive(true);
_panel.gameObject.SetActive(true);
_panel.SetAsLastSibling();
}
public void Close()
{
_panel.gameObject.SetActive(false);
_blocker.SetActive(false);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 693da1bea283cab4bac9e9caa525032d

View File

@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using Vanguard.Api;
using Vanguard.App;
@ -9,21 +10,29 @@ using Vanguard.Auth;
namespace Vanguard.UI
{
/// <summary>Экран входа/регистрации и выбора адреса сервера.</summary>
/// <summary>Экран входа по референсу: key art, неоновая панель, меню.</summary>
public sealed class LoginView
{
private const int PanelWidth = 560;
private const int PanelHeight = 420;
private readonly AppConfig _config;
private readonly AuthService _auth;
private readonly Action _onAuthenticated;
private readonly RectTransform _root;
private readonly InputField _serverUrl;
private readonly InputField _userName;
private readonly InputField _password;
private readonly Image _passwordToggle;
private readonly Button _loginButton;
private readonly Text _loginLabel;
private readonly Button _registerButton;
private readonly Text _status;
private readonly StatusTicker _ticker;
private readonly LoginMenu _menu;
private bool _busy;
private bool _passwordVisible;
public LoginView(
Transform parent,
@ -35,123 +44,476 @@ namespace Vanguard.UI
_auth = auth;
_onAuthenticated = onAuthenticated;
_root = UiKit.CreatePanel(parent, "LoginScreen", new Color(0.01f, 0.02f, 0.04f, 0.28f));
_root = UiKit.CreatePanel(parent, "LoginScreen", new Color(0f, 0f, 0f, 0f));
_root.GetComponent<Image>().raycastTarget = false;
// Мягкое неоновое свечение за панелью; 3D-стол остаётся виден по краям.
UiKit.CreateCenteredCard(
_root,
"LoginGlow",
new Vector2(760f, 900f),
new Color(0.04f, 0.22f, 0.80f, 0.12f));
LoginBackdrop.Create(_root);
var card = UiKit.CreateCenteredCard(
_root,
"LoginCard",
new Vector2(620f, 820f),
new Color(0.018f, 0.032f, 0.065f, 0.92f));
var panel = CreatePanel(_root);
_userName = CreateField(panel, "UserName", "Логин", UserIcon(), 110f);
_password = CreateField(panel, "Password", "Пароль", LockIcon(), 32f, password: true);
_passwordToggle = CreatePasswordToggle(panel);
var layout = UiKit.AddVerticalLayout(card, 32, 10);
layout.childAlignment = TextAnchor.MiddleCenter;
var tabs = _root.gameObject.AddComponent<TabFocusDriver>();
tabs.First = _userName;
tabs.Second = _password;
tabs.Submit = OnSubmitShortcut;
tabs.Cancel = OnCancelShortcut;
var eyebrow = UiKit.CreateText(
card, "Eyebrow", "VANGUARD // NETWORK", 13,
new Color(0.35f, 0.72f, 1f));
UiKit.SetPreferredHeight(eyebrow, 20f);
CreateLink(
panel,
"ForgotPassword",
"Забыли пароль?",
-14f,
TextAnchor.MiddleRight,
new Color(0.37f, 0.69f, 1f),
OnForgotClicked);
var title = UiKit.CreateText(
card, "Title", "VANGUARD", 56, new Color(0.88f, 0.94f, 1f));
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 66f);
var subtitle = UiKit.CreateText(
card, "Subtitle", "RE:MASTER · СЕТЕВОЙ КЛИЕНТ", 18,
new Color(0.52f, 0.64f, 0.86f));
UiKit.SetPreferredHeight(subtitle, 30f);
var accent = UiKit.CreatePanel(card, "Accent", new Color(0.12f, 0.52f, 1f, 0.9f));
UiKit.SetPreferredHeight(accent, 3f);
AddCaption(card, "Сервер");
_serverUrl = UiKit.CreateInput(card, "ServerUrl", "http://192.168.31.158:18080");
_serverUrl.text = _config.ServerBaseUrl;
UiKit.SetPreferredHeight(_serverUrl, 54f);
AddCaption(card, "Имя игрока");
_userName = UiKit.CreateInput(card, "UserName", "3–32 символа");
UiKit.SetPreferredHeight(_userName, 54f);
AddCaption(card, "Пароль");
_password = UiKit.CreateInput(card, "Password", "8–128 символов", password: true);
UiKit.SetPreferredHeight(_password, 54f);
_loginButton = UiKit.CreateButton(card, "LoginButton", "ВОЙТИ", OnLoginClicked, 22);
TintButton(_loginButton, new Color(0.10f, 0.42f, 0.95f, 1f), Color.white);
UiKit.SetPreferredHeight(_loginButton, 56f);
_registerButton = UiKit.CreateButton(
card, "RegisterButton", "СОЗДАТЬ АККАУНТ", OnRegisterClicked, 19);
TintButton(
_registerButton,
new Color(0.05f, 0.10f, 0.18f, 0.95f),
new Color(0.48f, 0.76f, 1f));
UiKit.SetPreferredHeight(_registerButton, 48f);
_loginButton = CreateLoginButton(panel, out _loginLabel);
_registerButton = CreateLink(
panel,
"Register",
"Создать аккаунт",
-128f,
TextAnchor.MiddleCenter,
new Color(0.50f, 0.66f, 0.88f),
OnRegisterClicked);
_status = UiKit.CreateText(
card, "Status", string.Empty, 17,
new Color(0.66f, 0.74f, 0.90f),
panel,
"Status",
string.Empty,
15,
new Color(0.62f, 0.72f, 0.88f),
TextAnchor.UpperCenter);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_status, 64f);
_status.verticalOverflow = VerticalWrapMode.Overflow;
Place(_status.rectTransform, new Vector2(500f, 44f), new Vector2(0f, -172f));
var footer = UiKit.CreateText(
card,
"Footer",
"v" + Application.version + " · стенд 192.168.31.158:18080 · localhost:5080",
14,
new Color(0.38f, 0.46f, 0.62f));
UiKit.SetPreferredHeight(footer, 24f);
CreateTagline(_root);
CreateCopyright(_root);
var language = UiKit.CreateLanguageButton(_root, "Language");
var languageRect = language.GetComponent<RectTransform>();
languageRect.anchorMin = new Vector2(1f, 1f);
languageRect.anchorMax = new Vector2(1f, 1f);
languageRect.pivot = new Vector2(1f, 1f);
languageRect.anchoredPosition = new Vector2(-24f, -24f);
languageRect.sizeDelta = new Vector2(72f, 40f);
_ticker = _root.gameObject.AddComponent<StatusTicker>();
_ticker.Hide = () => SetStatus(string.Empty, StatusColor);
_menu = new LoginMenu(_root, _config);
}
/// <summary>Enter отправляет форму; в открытом меню не срабатывает.</summary>
private void OnSubmitShortcut()
{
if (_busy || _menu == null || _menu.IsOpen)
return;
_ = SubmitAsync(register: false);
}
/// <summary>Esc закрывает меню, если оно открыто.</summary>
private void OnCancelShortcut()
{
if (_menu != null && _menu.IsOpen)
_menu.Close();
}
public void Show(string status = null)
{
UiKit.SetActive(_root, true);
if (!string.IsNullOrEmpty(status))
SetStatus(status, new Color(0.70f, 0.75f, 0.86f));
SetStatus(status, StatusColor);
}
public void Hide() => UiKit.SetActive(_root, false);
private static void AddCaption(Transform parent, string text)
// ------------------------------------------------------------ построение
private static RectTransform CreatePanel(Transform parent)
{
var caption = UiKit.CreateText(
var glow = NeonUi.AddImage(
parent,
"Caption_" + text,
text,
16,
new Color(0.55f, 0.65f, 0.82f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(caption, 22f);
"PanelGlow",
NeonUi.Glow(
PanelWidth + 48,
PanelHeight + 48,
24,
24,
new Color(0.18f, 0.49f, 1f, 0.24f)),
new Vector2(PanelWidth + 48, PanelHeight + 48),
new Vector2(0f, -170f));
var panel = UiKit.CreateCenteredCard(
parent,
"LoginPanel",
new Vector2(PanelWidth, PanelHeight),
Color.white);
panel.anchoredPosition = new Vector2(0f, -170f);
panel.GetComponent<Image>().sprite = NeonUi.Body(
PanelWidth,
PanelHeight,
24,
new Color(0.024f, 0.058f, 0.15f, 0.94f),
new Color(0.18f, 0.49f, 1f, 0.9f),
2);
panel.GetComponent<Image>().raycastTarget = false;
glow.transform.SetSiblingIndex(panel.GetSiblingIndex() - 1);
return panel;
}
private static void TintButton(Button button, Color background, Color label)
private static InputField CreateField(
Transform parent,
string name,
string placeholder,
Sprite icon,
float y,
bool password = false)
{
var image = button.GetComponent<Image>();
if (image != null)
image.color = background;
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(InputField));
var rect = (RectTransform)go.transform;
rect.SetParent(parent, false);
Place(rect, new Vector2(480f, 64f), new Vector2(0f, y));
var text = button.GetComponentInChildren<Text>();
if (text != null)
text.color = label;
var background = go.GetComponent<Image>();
background.sprite = NeonUi.Body(
480,
64,
10,
new Color(0.014f, 0.041f, 0.105f, 0.97f),
new Color(0.22f, 0.53f, 1f, 0.55f),
1);
background.type = Image.Type.Simple;
background.raycastTarget = true;
var input = go.GetComponent<InputField>();
input.targetGraphic = background;
input.lineType = InputField.LineType.SingleLine;
input.contentType = password
? InputField.ContentType.Password
: InputField.ContentType.Standard;
input.characterLimit = password ? 128 : 64;
input.caretColor = new Color(0.55f, 0.82f, 1f);
input.customCaretColor = true;
input.selectionColor = new Color(0.22f, 0.53f, 1f, 0.55f);
input.colors = FieldColors();
var text = UiKit.CreateText(rect, "Text", string.Empty, 18, Color.white, TextAnchor.MiddleLeft);
UiKit.Stretch(text.rectTransform);
text.rectTransform.offsetMin = new Vector2(52f, 0f);
text.rectTransform.offsetMax = new Vector2(-44f, 0f);
text.supportRichText = false;
UiKit.MakeLiteral(text);
var hint = UiKit.CreateText(
rect,
"Placeholder",
placeholder,
18,
new Color(0.55f, 0.63f, 0.78f),
TextAnchor.MiddleLeft);
UiKit.Stretch(hint.rectTransform);
hint.rectTransform.offsetMin = new Vector2(52f, 0f);
hint.rectTransform.offsetMax = new Vector2(-44f, 0f);
hint.supportRichText = false;
input.textComponent = text;
input.placeholder = hint;
NeonUi.AddImage(rect, "Icon", icon, new Vector2(22f, 22f), new Vector2(-212f, 0f));
return input;
}
private Image CreatePasswordToggle(Transform parent)
{
var go = new GameObject(
"PasswordToggle",
typeof(RectTransform),
typeof(Image),
typeof(Button));
var rect = (RectTransform)go.transform;
rect.SetParent(parent, false);
Place(rect, new Vector2(32f, 32f), new Vector2(208f, 32f));
var image = go.GetComponent<Image>();
image.sprite = EyeIcon();
image.color = new Color(1f, 1f, 1f, 0.75f);
image.raycastTarget = true;
var button = go.GetComponent<Button>();
button.targetGraphic = image;
button.transition = Selectable.Transition.ColorTint;
button.colors = FieldColors();
button.onClick.AddListener(TogglePassword);
return image;
}
private Button CreateLoginButton(Transform panel, out Text label)
{
NeonUi.AddImage(
panel,
"LoginButtonGlow",
NeonUi.Glow(
508,
100,
14,
12,
new Color(0.25f, 0.6f, 1f, 0.45f)),
new Vector2(508f, 100f),
new Vector2(0f, -70f));
var go = new GameObject(
"LoginButton",
typeof(RectTransform),
typeof(Image),
typeof(Button));
var rect = (RectTransform)go.transform;
rect.SetParent(panel, false);
Place(rect, new Vector2(480f, 72f), new Vector2(0f, -70f));
var body = go.GetComponent<Image>();
body.sprite = NeonUi.Bevel(
480,
72,
14,
new Color(0.18f, 0.55f, 1f, 1f),
new Color(0.10f, 0.37f, 0.79f, 1f),
new Color(0.49f, 0.77f, 1f, 1f),
2);
body.type = Image.Type.Simple;
body.raycastTarget = true;
var button = go.GetComponent<Button>();
button.targetGraphic = body;
button.transition = Selectable.Transition.ColorTint;
button.colors = new ColorBlock
{
normalColor = Color.white,
highlightedColor = new Color(1.12f, 1.12f, 1.12f, 1f),
pressedColor = new Color(0.9f, 0.9f, 0.9f, 1f),
selectedColor = Color.white,
disabledColor = new Color(0.55f, 0.55f, 0.55f, 0.6f),
colorMultiplier = 1f,
fadeDuration = 0.08f,
};
button.onClick.AddListener(OnLoginClicked);
label = UiKit.CreateText(rect, "Label", "Войти", 22, Color.white);
label.fontStyle = FontStyle.Bold;
UiKit.Stretch(label.rectTransform);
return button;
}
private static void CreateTagline(Transform root)
{
var emblem = NeonUi.AddAnchored(
root,
"TaglineEmblem",
NeonUi.Body(30, 30, 7, new Color(0.24f, 0.58f, 1f, 0.9f), new Color(0.6f, 0.85f, 1f, 0.9f), 1),
new Vector2(34f, 34f),
new Vector2(0f, 0f),
new Vector2(0.5f, 0.5f),
new Vector2(58f, 66f));
emblem.localRotation = Quaternion.Euler(0f, 0f, 45f);
var line1 = UiKit.CreateText(
root,
"Tagline1",
"FIGHT TOGETHER.",
11,
new Color(0.59f, 0.76f, 1f, 0.68f),
TextAnchor.MiddleLeft);
var rect1 = line1.rectTransform;
rect1.anchorMin = new Vector2(0f, 0f);
rect1.anchorMax = new Vector2(0f, 0f);
rect1.pivot = new Vector2(0f, 0.5f);
rect1.sizeDelta = new Vector2(360f, 16f);
rect1.anchoredPosition = new Vector2(88f, 74f);
var line2 = UiKit.CreateText(
root,
"Tagline2",
"BUILD YOUR FUTURE.",
11,
new Color(0.59f, 0.76f, 1f, 0.68f),
TextAnchor.MiddleLeft);
var rect2 = line2.rectTransform;
rect2.anchorMin = new Vector2(0f, 0f);
rect2.anchorMax = new Vector2(0f, 0f);
rect2.pivot = new Vector2(0f, 0.5f);
rect2.sizeDelta = new Vector2(360f, 16f);
rect2.anchoredPosition = new Vector2(88f, 58f);
UiKit.MakeLiteral(line1);
UiKit.MakeLiteral(line2);
}
private static void CreateCopyright(Transform root)
{
var text = UiKit.CreateText(
root,
"Copyright",
"© Cardfight!! Vanguard Fan Project",
12,
new Color(0.51f, 0.65f, 0.84f, 0.5f),
TextAnchor.MiddleRight);
var rect = text.rectTransform;
rect.anchorMin = new Vector2(1f, 0f);
rect.anchorMax = new Vector2(1f, 0f);
rect.pivot = new Vector2(1f, 0f);
rect.sizeDelta = new Vector2(420f, 20f);
rect.anchoredPosition = new Vector2(-28f, 18f);
UiKit.MakeLiteral(text);
}
// ------------------------------------------------------------ иконки
private static Sprite UserIcon()
{
var head = NeonUi.Circle(10, new Color(0.62f, 0.8f, 1f, 0.85f));
var body = NeonUi.Body(
18,
10,
5,
new Color(0.62f, 0.8f, 1f, 0.85f),
Color.clear,
0);
return Compose(22, 22, new[]
{
(head, new Vector2(0f, 5f), new Vector2(10f, 10f)),
(body, new Vector2(0f, -5f), new Vector2(18f, 10f)),
});
}
private static Sprite LockIcon()
{
var shackle = NeonUi.Ring(14, 2f, new Color(0.62f, 0.8f, 1f, 0.85f));
var body = NeonUi.Body(
18,
14,
4,
new Color(0.62f, 0.8f, 1f, 0.85f),
Color.clear,
0);
return Compose(22, 22, new[]
{
(shackle, new Vector2(0f, 4f), new Vector2(14f, 14f)),
(body, new Vector2(0f, -4f), new Vector2(18f, 14f)),
});
}
private static Sprite EyeIcon()
{
var outline = NeonUi.Ring(20, 1.5f, Color.white);
var pupil = NeonUi.Circle(7, Color.white);
return Compose(24, 24, new[]
{
(outline, Vector2.zero, new Vector2(20f, 20f)),
(pupil, Vector2.zero, new Vector2(7f, 7f)),
});
}
/// <summary>Собирает несколько спрайтов в один, чтобы не держать лишние Image.</summary>
private static Sprite Compose(
int width,
int height,
(Sprite sprite, Vector2 offset, Vector2 size)[] parts)
{
var pixels = new Color32[width * height];
foreach (var part in parts)
{
if (part.sprite == null)
continue;
var texture = part.sprite.texture;
var source = texture.GetPixels32();
var sourceWidth = texture.width;
var sourceHeight = texture.height;
var left = Mathf.RoundToInt(width * 0.5f + part.offset.x - part.size.x * 0.5f);
var bottom = Mathf.RoundToInt(height * 0.5f + part.offset.y - part.size.y * 0.5f);
var scaleX = sourceWidth / part.size.x;
var scaleY = sourceHeight / part.size.y;
for (var y = 0; y < part.size.y; y++)
{
for (var x = 0; x < part.size.x; x++)
{
var targetX = left + x;
var targetY = bottom + y;
if (targetX < 0 || targetY < 0 || targetX >= width || targetY >= height)
continue;
var sx = Mathf.Clamp(Mathf.RoundToInt(x * scaleX), 0, sourceWidth - 1);
var sy = Mathf.Clamp(Mathf.RoundToInt(y * scaleY), 0, sourceHeight - 1);
var color = source[sy * sourceWidth + sx];
if (color.a == 0)
continue;
var index = targetY * width + targetX;
pixels[index] = AlphaBlend(pixels[index], color);
}
}
}
var result = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
hideFlags = HideFlags.DontSave,
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
};
result.SetPixels32(pixels);
result.Apply(false, false);
var sprite = Sprite.Create(
result,
new Rect(0f, 0f, width, height),
new Vector2(0.5f, 0.5f),
100f);
sprite.hideFlags = HideFlags.DontSave;
return sprite;
}
private static Color32 AlphaBlend(Color32 under, Color32 over)
{
var alpha = over.a / 255f;
return new Color32(
(byte)(over.r * alpha + under.r * (1f - alpha)),
(byte)(over.g * alpha + under.g * (1f - alpha)),
(byte)(over.b * alpha + under.b * (1f - alpha)),
(byte)Mathf.Clamp(over.a + under.a * (1f - alpha), 0f, 255f));
}
// ------------------------------------------------------------ ссылки
private static Button CreateLink(
Transform parent,
string name,
string label,
float y,
TextAnchor alignment,
Color color,
UnityAction onClick)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
var rect = (RectTransform)go.transform;
rect.SetParent(parent, false);
Place(rect, new Vector2(480f, 26f), new Vector2(0f, y));
var image = go.GetComponent<Image>();
image.color = new Color(0f, 0f, 0f, 0.001f);
image.raycastTarget = true;
var button = go.GetComponent<Button>();
button.targetGraphic = image;
button.transition = Selectable.Transition.None;
button.onClick.AddListener(onClick);
var text = UiKit.CreateText(rect, "Label", label, 15, color, alignment);
UiKit.Stretch(text.rectTransform);
return button;
}
// ------------------------------------------------------------ логика
private async void OnLoginClicked()
{
await SubmitAsync(register: false);
@ -162,6 +524,25 @@ namespace Vanguard.UI
await SubmitAsync(register: true);
}
private void OnForgotClicked()
{
SetStatus(
"Сброс пароля не поддерживается. Обратитесь к администратору.",
new Color(1f, 0.72f, 0.42f));
}
private void TogglePassword()
{
_passwordVisible = !_passwordVisible;
_password.contentType = _passwordVisible
? InputField.ContentType.Standard
: InputField.ContentType.Password;
_password.ForceLabelUpdate();
_passwordToggle.color = _passwordVisible
? new Color(0.45f, 0.78f, 1f, 1f)
: new Color(1f, 1f, 1f, 0.75f);
}
private async Task SubmitAsync(bool register)
{
if (_busy)
@ -172,14 +553,11 @@ namespace Vanguard.UI
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
{
SetStatus("Введите имя игрока и пароль.", new Color(1f, 0.70f, 0.35f));
SetStatus("Введите логин и пароль.", new Color(1f, 0.70f, 0.35f));
return;
}
_config.SetServerBaseUrl(_serverUrl.text);
_serverUrl.text = _config.ServerBaseUrl;
SetBusy(true, register ? "Создаём аккаунт…" : "Подключаемся…");
SetBusy(true, register ? "Создаём аккаунт…" : "Подключение…");
try
{
@ -208,9 +586,7 @@ namespace Vanguard.UI
}
else
{
SetStatus(
"Не удалось связаться: " + detail,
new Color(1f, 0.55f, 0.45f));
SetStatus("Не удалось связаться: " + detail, new Color(1f, 0.55f, 0.45f));
}
}
finally
@ -237,19 +613,17 @@ namespace Vanguard.UI
private void SetBusy(bool busy, string status)
{
if (_loginButton == null || _registerButton == null || _serverUrl == null
|| _userName == null || _password == null)
return;
_busy = busy;
_loginButton.interactable = !busy;
_registerButton.interactable = !busy;
_serverUrl.interactable = !busy;
_userName.interactable = !busy;
_password.interactable = !busy;
if (_loginLabel != null)
_loginLabel.text = busy ? "Подключение…" : "Войти";
if (status != null)
SetStatus(status, new Color(0.70f, 0.75f, 0.86f));
SetStatus(status, StatusColor);
}
private void SetStatus(string text, Color color)
@ -259,6 +633,57 @@ namespace Vanguard.UI
_status.text = text ?? string.Empty;
_status.color = color;
if (string.IsNullOrEmpty(text))
{
_ticker.HideAt = 0f;
return;
}
_ticker.HideAt = Time.unscaledTime + 6f;
}
// ------------------------------------------------------------ утилиты
private static readonly Color StatusColor = new Color(0.66f, 0.74f, 0.90f);
private static void Place(RectTransform rect, Vector2 size, Vector2 position)
{
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.sizeDelta = size;
rect.anchoredPosition = position;
}
private static ColorBlock FieldColors()
{
return new ColorBlock
{
normalColor = Color.white,
highlightedColor = new Color(1.15f, 1.15f, 1.15f, 1f),
pressedColor = new Color(0.9f, 0.9f, 0.9f, 1f),
selectedColor = new Color(1.15f, 1.15f, 1.15f, 1f),
disabledColor = new Color(0.6f, 0.6f, 0.6f, 0.5f),
colorMultiplier = 1f,
fadeDuration = 0.08f,
};
}
/// <summary>Скрывает статус через несколько секунд.</summary>
private sealed class StatusTicker : MonoBehaviour
{
public float HideAt;
public Action Hide;
private void Update()
{
if (HideAt <= 0f || Time.unscaledTime < HideAt)
return;
HideAt = 0f;
Hide?.Invoke();
}
}
}
}

View File

@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace Vanguard.UI
{
/// <summary>
/// Процедурные 9-slice-подобные спрайты для неонового интерфейса.
/// Свечение запекается в текстуру: пост-обработка (bloom) на Screen Space
/// Overlay не действует, поэтому glow рисуется в альфе спрайта.
/// Все текстуры кешируются по ключу и не сохраняются в сцену.
/// </summary>
public static class NeonUi
{
private static readonly Dictionary<string, Sprite> Cache =
new Dictionary<string, Sprite>();
// ------------------------------------------------------------ панели
/// <summary>Заливка со скруглением и рамкой. Размер — точный, без свечения.</summary>
public static Sprite Body(
int width,
int height,
int radius,
Color fill,
Color border,
int borderWidth)
{
var key = string.Format(
"body:{0}x{1}:{2}:{3}:{4}:{5}",
width, height, radius, Hex(fill), Hex(border), borderWidth);
return Get(key, width, height, (dx, dy) =>
{
var halfWidth = width * 0.5f;
var halfHeight = height * 0.5f;
var distance = RoundedDistance(dx, dy, halfWidth, halfHeight, radius);
if (distance > 0f)
return Color.clear;
if (borderWidth > 0 && distance > -borderWidth)
return border;
return fill;
});
}
/// <summary>Кнопка со срезанными углами и вертикальным градиентом.</summary>
public static Sprite Bevel(
int width,
int height,
int cut,
Color top,
Color bottom,
Color border,
int borderWidth)
{
var key = string.Format(
"bevel:{0}x{1}:{2}:{3}:{4}:{5}:{6}",
width, height, cut, Hex(top), Hex(bottom), Hex(border), borderWidth);
return Get(key, width, height, (dx, dy) =>
{
var halfWidth = width * 0.5f;
var halfHeight = height * 0.5f;
var distance = BevelDistance(dx, dy, halfWidth, halfHeight, cut);
if (distance > 0f)
return Color.clear;
if (borderWidth > 0 && distance > -borderWidth)
return border;
var t = Mathf.Clamp01((dy + halfHeight) / height);
return Color.Lerp(bottom, top, t);
});
}
/// <summary>Мягкое свечение вокруг контура; рисуется позади Body.</summary>
public static Sprite Glow(
int width,
int height,
int radius,
int spread,
Color color)
{
var key = string.Format(
"glow:{0}x{1}:{2}:{3}:{4}",
width, height, radius, spread, Hex(color));
var innerWidth = Mathf.Max(1f, width * 0.5f - spread);
var innerHeight = Mathf.Max(1f, height * 0.5f - spread);
return Get(key, width, height, (dx, dy) =>
{
var distance = RoundedDistance(dx, dy, innerWidth, innerHeight, radius);
if (distance <= 0f)
return Color.clear;
var t = 1f - distance / spread;
if (t <= 0f)
return Color.clear;
return new Color(color.r, color.g, color.b, color.a * t * t);
});
}
/// <summary>Радиальное затемнение по краям экрана.</summary>
public static Sprite Vignette(int width, int height, Color inner, Color outer)
{
var key = "vignette:" + width + "x" + height + ":" + Hex(inner) + ":" + Hex(outer);
var halfWidth = width * 0.5f;
var halfHeight = height * 0.5f;
var maxDistance = Mathf.Sqrt(halfWidth * halfWidth + halfHeight * halfHeight);
return Get(key, width, height, (dx, dy) =>
{
var distance = Mathf.Sqrt(dx * dx + dy * dy) / maxDistance;
var t = Mathf.Clamp01((distance - 0.35f) / 0.65f);
return Color.Lerp(inner, outer, t * t);
});
}
// ------------------------------------------------------------ фигуры для иконок
public static Sprite Circle(int diameter, Color color)
{
var key = "circle:" + diameter + ":" + Hex(color);
var radius = diameter * 0.5f - 0.5f;
return Get(key, diameter, diameter, (dx, dy) =>
{
var distance = Mathf.Sqrt(dx * dx + dy * dy) - radius;
return distance > 0f ? Color.clear : Edge(color, distance);
});
}
public static Sprite Ring(int diameter, float thickness, Color color)
{
var key = "ring:" + diameter + ":" + thickness + ":" + Hex(color);
var radius = diameter * 0.5f - thickness * 0.5f - 0.5f;
var half = thickness * 0.5f;
return Get(key, diameter, diameter, (dx, dy) =>
{
var distance = Mathf.Abs(Mathf.Sqrt(dx * dx + dy * dy) - radius) - half;
return distance > 0f ? Color.clear : Edge(color, distance);
});
}
public static Sprite HorizontalBar(int width, int height, Color color)
{
var key = "bar:" + width + "x" + height + ":" + Hex(color);
var halfWidth = width * 0.5f;
var halfHeight = height * 0.5f;
return Get(key, width, height, (dx, dy) =>
{
var distance = Mathf.Max(
Mathf.Abs(dx) - halfWidth,
Mathf.Abs(dy) - halfHeight);
return distance > 0f ? Color.clear : Edge(color, distance);
});
}
// ------------------------------------------------------------ хелперы
/// <summary>Полупрозрачная сглаженная кромка в 1 px.</summary>
private static Color Edge(Color color, float distance)
{
var alpha = Mathf.Clamp01(-distance);
return new Color(color.r, color.g, color.b, color.a * alpha);
}
public static Image AddImage(
Transform parent,
string name,
Sprite sprite,
Vector2 size,
Vector2 position,
bool raycast = false)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
var rect = (RectTransform)go.transform;
rect.SetParent(parent, false);
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.sizeDelta = size;
rect.anchoredPosition = position;
var image = go.GetComponent<Image>();
image.sprite = sprite;
image.type = Image.Type.Simple;
image.preserveAspect = true;
image.raycastTarget = raycast;
return image;
}
/// <summary>Image с якорем к углу экрана (для тэглайна, копирайта, меню).</summary>
public static RectTransform AddAnchored(
Transform parent,
string name,
Sprite sprite,
Vector2 size,
Vector2 anchor,
Vector2 pivot,
Vector2 offset)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
var rect = (RectTransform)go.transform;
rect.SetParent(parent, false);
rect.anchorMin = anchor;
rect.anchorMax = anchor;
rect.pivot = pivot;
rect.sizeDelta = size;
rect.anchoredPosition = offset;
rect.localScale = Vector3.one;
var image = go.GetComponent<Image>();
image.sprite = sprite;
image.type = Image.Type.Simple;
image.preserveAspect = true;
image.raycastTarget = false;
return rect;
}
private static Sprite Get(string key, int width, int height, Func<float, float, Color> shader)
{
if (Cache.TryGetValue(key, out var cached) && cached != null)
return cached;
width = Mathf.Max(2, width);
height = Mathf.Max(2, height);
var pixels = new Color32[width * height];
var halfWidth = width * 0.5f;
var halfHeight = height * 0.5f;
for (var y = 0; y < height; y++)
{
var dy = y + 0.5f - halfHeight;
for (var x = 0; x < width; x++)
{
var dx = x + 0.5f - halfWidth;
pixels[y * width + x] = shader(dx, dy);
}
}
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
hideFlags = HideFlags.DontSave,
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
name = "NeonUi " + key,
};
texture.SetPixels32(pixels);
texture.Apply(false, false);
var sprite = Sprite.Create(
texture,
new Rect(0f, 0f, width, height),
new Vector2(0.5f, 0.5f),
100f,
0,
SpriteMeshType.FullRect);
sprite.name = key;
sprite.hideFlags = HideFlags.DontSave;
Cache[key] = sprite;
return sprite;
}
private static float RoundedDistance(float dx, float dy, float halfWidth, float halfHeight, float radius)
{
radius = Mathf.Clamp(radius, 0f, Mathf.Min(halfWidth, halfHeight) - 1f);
var qx = Mathf.Abs(dx) - (halfWidth - radius);
var qy = Mathf.Abs(dy) - (halfHeight - radius);
var ax = Mathf.Max(qx, 0f);
var ay = Mathf.Max(qy, 0f);
return Mathf.Sqrt(ax * ax + ay * ay) + Mathf.Min(Mathf.Max(qx, qy), 0f) - radius;
}
private static float BevelDistance(float dx, float dy, float halfWidth, float halfHeight, float cut)
{
var axis = Mathf.Max(
Mathf.Abs(dx) - halfWidth,
Mathf.Abs(dy) - halfHeight);
var diagonal = (Mathf.Abs(dx) + Mathf.Abs(dy) - (halfWidth + halfHeight - cut))
* 0.70710678f;
return Mathf.Max(axis, diagonal);
}
private static string Hex(Color color)
{
return ((int)(color.r * 255)).ToString("X2")
+ ((int)(color.g * 255)).ToString("X2")
+ ((int)(color.b * 255)).ToString("X2")
+ ((int)(color.a * 255)).ToString("X2");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f1289eec64534bf459d8233e48c15373

View File

@ -0,0 +1,119 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Vanguard.UI
{
/// <summary>Переключение фокуса между полями по Tab / Shift+Tab.</summary>
public static class TabFocus
{
public static void Move(
InputField first,
InputField second,
bool reverse,
EventSystem events = null)
{
if (first == null || second == null)
return;
events = events != null ? events : EventSystem.current;
if (events == null)
return;
var selected = events.currentSelectedGameObject;
InputField target;
if (selected == first.gameObject)
target = reverse ? first : second;
else if (selected == second.gameObject)
target = first;
else
target = first;
events.SetSelectedGameObject(target.gameObject);
target.ActivateInputField();
target.caretPosition = target.text.Length;
}
public static bool TabPressed(out bool reverse)
{
reverse = false;
#if ENABLE_INPUT_SYSTEM
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.tabKey.wasPressedThisFrame)
{
reverse = keyboard.shiftKey.isPressed;
return true;
}
return false;
#elif ENABLE_LEGACY_INPUT_MANAGER
if (Input.GetKeyDown(KeyCode.Tab))
{
reverse = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
return true;
}
return false;
#else
return false;
#endif
}
/// <summary>Enter или Enter на цифровой клавиатуре.</summary>
public static bool EnterPressed()
{
#if ENABLE_INPUT_SYSTEM
var keyboard = UnityEngine.InputSystem.Keyboard.current;
return keyboard != null
&& (keyboard.enterKey.wasPressedThisFrame
|| keyboard.numpadEnterKey.wasPressedThisFrame);
#elif ENABLE_LEGACY_INPUT_MANAGER
return Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter);
#else
return false;
#endif
}
public static bool EscapePressed()
{
#if ENABLE_INPUT_SYSTEM
var keyboard = UnityEngine.InputSystem.Keyboard.current;
return keyboard != null && keyboard.escapeKey.wasPressedThisFrame;
#elif ENABLE_LEGACY_INPUT_MANAGER
return Input.GetKeyDown(KeyCode.Escape);
#else
return false;
#endif
}
}
/// <summary>
/// Ставится на корень экрана: связывает поля по Tab, отправляет форму по Enter
/// и закрывает меню по Esc.
/// </summary>
public sealed class TabFocusDriver : MonoBehaviour
{
public InputField First;
public InputField Second;
public Action Submit;
public Action Cancel;
private void Update()
{
if (TabFocus.TabPressed(out var reverse))
TabFocus.Move(First, Second, reverse);
Handle(TabFocus.EnterPressed(), TabFocus.EscapePressed());
}
/// <summary>Выполняет действия клавиш; вынесено для EditMode-теста.</summary>
public void Handle(bool submit, bool cancel)
{
if (submit)
Submit?.Invoke();
if (cancel)
Cancel?.Invoke();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d6d7f03e1312bdf4aa668f0f37b0673f

View File

@ -765,6 +765,60 @@ namespace Vanguard.Tests
Cleanup();
}
[Test]
public void Tab_Focus_Cycles_Between_Login_Fields()
{
var canvas = UiKit.CreateCanvas("Test Canvas");
UiKit.EnsureEventSystem();
var events = Object.FindAnyObjectByType<EventSystem>();
Assert.IsNotNull(events, "EventSystem не создан");
var first = UiKit.CreateInput(canvas.transform, "TabFirst", "логин");
var second = UiKit.CreateInput(canvas.transform, "TabSecond", "пароль");
events.SetSelectedGameObject(first.gameObject);
TabFocus.Move(first, second, false, events);
Assert.AreEqual(
second.gameObject,
events.currentSelectedGameObject,
"Tab с логина переводит на пароль");
TabFocus.Move(first, second, false, events);
Assert.AreEqual(
first.gameObject,
events.currentSelectedGameObject,
"Tab с пароля возвращает на логин");
TabFocus.Move(first, second, true, events);
Assert.AreEqual(
first.gameObject,
events.currentSelectedGameObject,
"Shift+Tab с первого поля остаётся на нём");
Cleanup();
}
[Test]
public void Login_Hotkeys_Dispatch_Submit_And_Cancel()
{
var canvas = UiKit.CreateCanvas("Test Canvas");
var driver = canvas.gameObject.AddComponent<TabFocusDriver>();
var submitted = 0;
var cancelled = 0;
driver.Submit = () => submitted++;
driver.Cancel = () => cancelled++;
driver.Handle(submit: true, cancel: false);
Assert.AreEqual(1, submitted, "Enter отправляет форму");
Assert.AreEqual(0, cancelled, "Enter не закрывает меню");
driver.Handle(submit: false, cancel: true);
Assert.AreEqual(1, submitted, "Esc не отправляет форму повторно");
Assert.AreEqual(1, cancelled, "Esc закрывает меню");
Cleanup();
}
private static bool Exists(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);

View File

@ -3,7 +3,8 @@
**Дата:** 2026-10-30
**Анализ:** DeepSeek выполнен; код, сцены, префабы, SO и конфиги не менялись.
**Решения:** зафиксированы 2026-10-30 (раздел 8).
**Статус:** реализация не начата.
**Статус:** TASK-2026-021 (B2, Login) реализован и ждёт ручной приёмки;
TASK-2026-019 (A1 + C1–C3) — план готов, код не начат.
**Связанные документы:** `docs/client-visual-direction.md`,
`docs/3d-ux-backlog.md`, `docs/Development/ANIMATION-INVENTORY.md`,
`docs/Development/IMPLEMENTATION_PLAN.md` (TASK-2026-001…018).
@ -98,7 +99,7 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
| A7 | Additive-слой общих сервисов | P2 | не пересоздавать Canvas |
| A8 | SceneSmoke для DeckBuilder/Battle | P2 | покрытие смоуком |
| B1 | Общие материалы вместо `new Material` | P0 | батчинг, редактируемость |
| B2 | Login: полноценная 3D-сцена | P0 | первый экран |
| B2 | Login: 2D-арт по референсу | P0 | первый экран |
| B3 | Lobby и DeckBuilder: 3D-сцены | P1 | сейчас 6 и 3 куба |
| B4 | Battle: окружение вокруг поля | P1 | задник/свет без правки поля |
| B5 | Prefab-экраны UI | P1 | уйти от code-generated |
@ -203,13 +204,15 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
Критерий: Frame Debugger без лишних `SetPass`, SRP Batcher активен.
Зависит: A4.
- [ ] **B2. P0 — Login: полноценная 3D-сцена.**
Сделать: authored-стол, 4–6 карт, неоновые направляющие, задник; свет и
камера из профиля; геометрия — префабы, а не код.
Зачем: первый экран сейчас собран из 14 кубов в рантайме.
Критерий: визуально читаемый стол; в рантайме не создаётся статическая
геометрия.
Зависит: B1, A4, референс экрана от пользователя.
- [ ] **B2. P0 — Login по референсу: 2D-арт + неоновый UI.**
Решение 2026-10-31: фон — 2D key art на весь экран, 3D-окружение в Login не
строится (исключение из правила «3D в каждой сцене», см. 8.7). Состав
экрана, размеры и состояния — в `TASK-2026-021`
(`docs/Development/IMPLEMENTATION_PLAN.md`).
Зачем: текущий Login — карточка 620×820 с полем сервера и кнопкой регистрации,
композиция не соответствует референсу.
Критерий: композиция совпадает с референсом, вход и регистрация работают.
Зависит: арт-файл от пользователя; `TASK-2026-021`.
- [ ] **B3. P1 — Lobby и DeckBuilder: 3D-сцены.**
Сделать: Lobby — зал с постаментом/витриной и световыми акцентами;
@ -388,6 +391,16 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
`Lobby`, `DeckBuilder` (включая экран колод), `Battle`. B2–B4 стартуют
после получения соответствующего референса.
6. **Первый блок.** A1 + C1–C3.
7. **Login — исключение из «3D в каждой сцене»:** фон — 2D key art на весь
экран, 3D-окружение в сцене `Login` не строится. Правило «3D» остаётся для
`Lobby`, `DeckBuilder` и `Battle`.
8. **Регистрация** — вторичная ссылка «Создать аккаунт» под кнопкой «Войти».
9. **Адрес сервера** — без UI: только `AppConfig`/`PlayerPrefs`, поле из
панели убирается.
10. **«Забыли пароль?»** — ссылка есть, по клику заглушка «сброс не
поддерживается»: серверного endpoint сброса нет, отдельная задача.
11. **Референсы.** Login получен 2026-10-31 (референс в чате). Для остальных
экранов ждём.
**Риски:**

View File

@ -41,6 +41,7 @@
| TASK-2026-001 | `[!]` | Плавность карточных перемещений и корректные источники | DeepSeek: анализ; реализация выполнена, ждёт ручной проверки C10 | EditMode 46/46; ручная — пользователь |
| TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена |
| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался |
| TASK-2026-021 | `[!]` | Login по референсу: 2D-арт и неоновый UI | DeepSeek: C1–C9, Tab/Enter/Esc; ждёт приёмки | EditMode 60/60; `SCENE_SMOKE success=True`; standalone собран; ручная — пользователь |
Новые задачи добавляются в конец файла отдельными разделами. Не используй один
раздел для нескольких независимых задач.
@ -1817,8 +1818,236 @@ HUD с номером хода и фазовой шкалой, компактн
- **Проверки:** полный прогон в копию — 0 различий с `content/sets`;
94 определения / 124 принта; арт TD01 16/16; `--force-art` заменяет
`.jpg` на `.png` без дублей.
- **Не завершено:** `validate` на `ssh home`; массовая замена артов на
официальные PNG (решение пользователя).
- **Не завершено:** `validate` на `ssh home`.
- **Skill обновлён:** не требуется.
- **Git:** `0620ec5` (парсер, README, план), арты — `f2ae6b9`.
---
### TASK-2026-021 — Login по референсу: 2D-арт и неоновый UI
- **Статус:** `[!]`
- **Создано:** 2026-10-31
- **Анализ:** DeepSeek (по референсу в чате от 2026-10-31)
- **Реализация:** DeepSeek: C1–C9 выполнены
- **Проверка:** EditMode 60/60; `SCENE_SMOKE success=True`; standalone собран; ручная приёмка — за пользователем
- **Затронутые системы:** `LoginView`, `LoginSceneController`,
`SceneEnvironment`, `UiKit`, новый `NeonUi`, новый `LoginMenu`,
`Localization`, `Resources/Login/*`, `SceneSmoke`, EditMode-тесты.
#### A. Цель и требования
Привести экран `Login` к референсу: полноэкранный 2D key art, поверх —
центрированная неоновая панель с двумя полями и кнопкой «Войти».
Состав экрана (сверху вниз, координаты для 1920×1080):
1. **Фон** — один арт на весь экран, cover (без полос по краям), лёгкое
затемнение под панелью для читаемости текста.
2. **Логотип** — по центру, ширина ≈ 520 px, верх ≈ 140 px. В MVP входит в
состав арта; отдельным слоем — только если дадут прозрачный PNG.
3. **Панель** — 560×420, центр по X, центр по Y ≈ 600 (чуть ниже середины):
- радиус 24, заливка `rgba(7,17,43,0.82)`;
- рамка 2 px `#2E7DFF`, внешнее свечение ≈ 24 px `rgba(46,125,255,0.35)`;
- тонкая внутренняя рамка `rgba(150,200,255,0.18)`.
4. **Поле ввода** — 480×64, радиус 10, заливка `rgba(4,12,30,0.9)`, рамка
`rgba(46,125,255,0.45)`; слева иконка 22×22 (16 px от края), текст 18 px,
placeholder `rgba(130,150,190,0.9)`; фокус — рамка `#6CC0FF` + свечение.
- «Логин» — иконка пользователя;
- «Пароль» — иконка замка + глаз справа (показать/скрыть).
5. **«Забыли пароль?»** — 15 px, `#5FB0FF`, справа под полем пароля.
6. **Кнопка «Войти»** — 480×72, срезанные углы 14 px, градиент
`#1B5FC9 → #2E8BFF`, рамка 1.5 px `#7CC4FF`, свечение; текст 22 px bold,
белый. Hover +10 % яркости, pressed −8 %, disabled 45 % + «Подключение…».
7. **«Создать аккаунт»** — вторичная ссылка 15 px `#7FA8E0` по центру под
кнопкой (решение 2026-10-31: регистрация остаётся доступной).
8. **Статус** — 14 px под ссылками, автоскрытие через 6 с; ошибка `#FF7A6B`,
успех `#6BE08A`.
9. **Гамбургер** — 56×56, правый верхний угол 28/28, радиус 12, рамка
`#2E7DFF`, три линии 22×2. Открывает меню: `RU/EN`, версия клиента,
**адрес сервера** (dev-поле), «Закрыть». Выхода нет — сессии ещё нет.
10. **Тэглайн** — левый нижний угол 40/36: щит 44×44 + две строки
`FIGHT TOGETHER.` / `BUILD YOUR FUTURE.`, 11 px, трекинг 2,
`rgba(150,195,255,0.65)`.
11. **Копирайт** — правый нижний угол, 12 px `rgba(130,165,215,0.5)`:
`© Cardfight!! Vanguard Fan Project`.
Ограничения:
- без новых пакетов и без Addressables (арт из `Resources`, как арт карт);
- auth-контракты не меняются; серверного сброса пароля нет;
- правила, сервер, протокол и контент не трогаются;
- имя `LoginScreen` сохраняется (на него опирается `SceneSmoke`);
- 3D-окружение в `Login` не строится — это исключение из «3D в каждой
сцене», зафиксированное в `CLIENT-SCENES-3D-ANIMATIONS.md` (8.7).
Критерии готовности:
- композиция и цвета совпадают с референсом на 1920×1080;
- на 16:10 и 21:9 фон заполняет экран, панель остаётся по центру;
- вход, регистрация, ошибки и busy-состояние работают как раньше;
- RU/EN переключается из меню, строки локализованы;
- `SceneSmoke.Run` проходит на сцене `Login` без 3D-окружения.
#### B. Текущее состояние
- `LoginView` (`Runtime/UI/LoginView.cs`): панель `LoginScreen` + карточка
620×820 с eyebrow/title/subtitle, **полем адреса сервера**, полем имени,
паролем, кнопкой «ВОЙТИ», кнопкой «СОЗДАТЬ АККАУНТ», статусом, футером с
версией/стендом и кнопкой языка в правом верхнем углу.
- `LoginSceneController` вызывает `SceneEnvironment.Build(SceneTheme.Login)`:
камера, свет, пол, 14 кубов (стол, «карты», неон). 3D-стол виден по краям
панели.
- `UiKit` умеет только прямоугольные `Image` с плоским цветом: скруглений,
рамок, свечения и 9-slice нет; `CreateInput` — стандартный uGUI `InputField`.
- Шрифт — `LegacyRuntime.ttf` (кириллица есть, геометрия нейтральная).
- `SceneSmoke` (`Editor/SceneSmoke.cs:116-123`) требует наличия объекта
`Vanguard 3D Environment` в активной сцене; после отказа от 3D в Login
проверка упадёт.
- Сервер: endpoint сброса пароля отсутствует (`forgot`/`reset` не находятся),
поэтому «Забыли пароль?» — только заглушка.
Что сохранить:
- `AuthService`, `AppConfig`, обработку `ApiException` и формат ошибок;
- имя `LoginScreen` и вызов `_onAuthenticated` после успеха;
- `UiKit.CreateCanvas` и текущий CanvasScaler (1920×1080, match 0.5);
- кнопку языка как функцию (переезжает в меню, но не исчезает).
#### C. Чек-лист изменений
- [x] **C1. `CREATE` `Runtime/UI/NeonUi.cs`** — 9-slice атомы интерфейса.
- Спрайты панели, поля, кнопки с фаской, свечения генерируются в рантайме
(по образцу `BoardTextures`) либо берутся из одного PNG-атласа; кеш
статический.
- API: `Body(...)`, `Bevel(...)`, `Glow(...)`, `Circle/Ring/HorizontalBar`,
`AddImage/AddAnchored`; свечение — отдельный спрайт позади тела.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: нет.
- [x] **C2. `CREATE` `Runtime/UI/LoginBackdrop.cs`** — фон экрана.
- `Resources/Login/login_bg` → `Texture2D` → `Sprite.Create` (как
`CardImageService`), cover-масштаб через `AspectRatioFitter`, поверх —
затемняющий слой под панелью.
- Если файла нет — процедурный градиент-заглушка, без падения.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: арт-файл от пользователя.
- [x] **C3. `MODIFY` `Runtime/UI/LoginView.cs`** — новая композиция.
- Лого/тэглайн/копирайт, панель 560×420, два поля с иконками, «Забыли
пароль?», кнопка «Войти», ссылка «Создать аккаунт», статус.
- Убрать с панели поле адреса сервера, eyebrow/title/subtitle и футер с
версией; адрес и версия уезжают в меню.
- Tab / Shift+Tab между полями (`Runtime/UI/TabFocus.cs`); Enter отправляет
форму (`TabFocus.EnterPressed`, обработчик в `TabFocusDriver`); в открытом
меню Enter форму не отправляет.
- Реализация: выполнена. Проверка: EditMode 60/60, визуальный прогон.
- Зависит: C1, C2.
- [x] **C4. `CREATE` `Runtime/UI/LoginMenu.cs`** — гамбургер и панель меню.
- Кнопка 56×56 в правом верхнем углу, три линии.
- Меню 340 px: переключатель `RU/EN`, версия, поле адреса сервера
(сохраняет в `AppConfig`/`PlayerPrefs`), «Закрыть».
- Закрытие по Esc (`LoginMenu.IsOpen` + `Close`), по кнопке и клику вне
панели; не перехватывает ввод у формы, когда закрыто.
- Реализация: выполнена. Проверка: EditMode 60/60, визуальный прогон.
- Зависит: C1.
- [x] **C5. `MODIFY` `Runtime/App/LoginSceneController.cs` +
`Runtime/App/SceneEnvironment.cs`.**
- `Login` больше не вызывает `SceneEnvironment.Build`; камера — заливка
цветом фона, без пола/кубов/точечных источников.
- Ветка `SceneTheme.Login` в окружении остаётся, но не используется (удалять
только вместе с остальными темами).
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: C2.
- [x] **C6. `MODIFY` `Editor/SceneSmoke.cs`** — проверка Login без 3D.
- Для `Login` требовать `LoginScreen` и фон (`LoginBackdrop`/`LoginBackground`),
`Vanguard 3D Environment` — только для `Lobby`.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: C3.
- [x] **C7. `MODIFY` `Runtime/UI/Localization.cs`** — новые ключи.
- «Логин / Email», «Пароль», «Забыли пароль?», «Войти», «Создать аккаунт»,
«Подключение…», пункты меню, заглушка сброса пароля.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: C3.
- [x] **C8. `CREATE` ассеты** — `Resources/Login/`.
- `login_bg.png|jpg` (1920×1080+, без текста и панели), при наличии —
логотип и атлас иконок (пользователь/замок/глаз/меню/щит).
- Импорт: `Texture2D` (не Sprite), max size 2048, `sRGB`; отдельный
`.meta` в репозитории.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: файлы от пользователя.
- [x] **C9. `TEST` EditMode.**
- Панель: два поля, кнопка, ссылки; поля без адреса сервера.
- Busy/ошибка/успех меняют статус и блокируют кнопки.
- Enter отправляет форму; меню открывается/закрывается, язык переключается.
- `SceneSmoke.Run` — Login без `Vanguard 3D Environment`.
- Реализация: выполнена. Проверка: компиляция, EditMode 58/58, визуальный прогон.
- Зависит: C1–C8.
- [ ] **C10. `VERIFY` сборка и ручная приёмка.**
- Standalone на копии проекта; проверка 1920×1080, 16:10, 21:9.
- Ручная сверка с референсом — пользователем.
- Реализация: выполнена. Проверка: standalone собран в копии без `Library`,
EditMode 60/60, `SCENE_SMOKE success=True`; визуальная приёмка — за
пользователем.
- Зависит: C9.
#### D. Последовательность выполнения
1. C1: атомы (панель, поле, кнопка, свечение) — без них остальное не собрать.
2. C2: фон и затемнение; проверить cover на 16:9/21:9.
3. C3: новая композиция формы, сохранение текущей логики входа.
4. C4: меню (язык, версия, адрес сервера).
5. C5–C6: убрать 3D из Login, поправить смоук.
6. C7–C8: локализация и ассеты.
7. C9–C10: тесты, сборка, сверка с референсом.
#### E. Проверки
- EditMode в копии проекта без `Library` (команда в `docs/SESSION-STATE.md`).
- `SceneSmoke.Run` — `SCENE_SMOKE success=True active=Login`.
- Standalone-сборка и запуск на 1920×1080; при возможности 21:9.
- Проверка входа: успех, неверный пароль, нет сети (сообщения прежние).
- `git diff --check`; сцены/префабы не менялись без отдельного пункта.
#### F. Риски и неизвестные вопросы
- **Арт-файла ещё нет в проекте** — в чате только превью 1680×945. До
появления файла C2 работает на градиенте; финальный вид не принять.
- **Логотип** — часть арта; отдельный слой потребует прозрачного PNG и
аккуратного позиционирования, иначе логотип «поедет» на другом AR.
- **Свечение в uGUI** — bloom пост-обработки не действует на Screen Space
Overlay: glow только запечённый в спрайт (несколько слоёв, без шейдеров).
- **Шрифт**: `LegacyRuntime` даёт нейтральную геометрию; для совпадения с
референсом нужен TTF с кириллицей (Inter/Exo 2) — ждём решение.
- **Отказ от 3D в Login** ломает `SceneSmoke` и может задеть EditMode-тесты,
которые искали окружение; правится в C6/C9.
- **Адрес сервера без UI** — смена стенда только через меню (C4) или
`PlayerPrefs`; в README нужно оставить инструкцию.
- **Сброс пароля** — заглушка; если появится API, экран расширяется отдельной
задачей.
- Открытых вопросов, блокирующих C1–C7, нет; C2/C8 ждут арт.
#### G. Результат
- **Реализовано:** C1–C9.
- **Проверки:** компиляция без ошибок; EditMode **60/60** (в копии без
`Library`, `+Login_Hotkeys_Dispatch_Submit_And_Cancel`); `SceneSmoke.Run` —
`success=True active=Login env=False backdrop=True login=True`; standalone
собран в копии; экран снят на 1920×1080 в прошлом проходе; меню
открывается и закрывается.
- **Не завершено:**
- C10: ручная сверка с референсом, поведение Enter/Esc и меню на
standalone — за пользователем.
- **Замечания:** панель и поля полупрозрачные (0.94 / 0.97) — сквозь них
видно арт; если нужно плотнее, поднять альфу. Иконки собраны из примитивов
(`Compose`), шрифт — `LegacyRuntime`.
- **Skill обновлён:** не требуется.
- **Git:** не коммитилось.

View File

@ -6,6 +6,23 @@
---
## 2026-10-31 — TASK-2026-021: Login по референсу
- Экран `Login` переведён на 2D key art (`Resources/Login/login_bg`) с
неоновой панелью; 3D-окружение в этой сцене больше не строится
(исключение из правила «3D в каждой сцене» для `Login`).
- Новые компоненты: `NeonUi` (9-slice атомы, свечение, иконки),
`LoginBackdrop`, `LoginMenu` (язык, версия, адрес сервера), `TabFocus`.
- Tab/Shift+Tab переключают поля, Enter отправляет форму, Esc закрывает
меню; в открытом меню Enter форму не отправляет.
- `SceneSmoke` для `Login` требует `LoginBackdrop`, объект
`Vanguard 3D Environment` — только для `Lobby`.
- Проверки: EditMode **60/60** в копии без `Library`; `SCENE_SMOKE
success=True active=Login env=False backdrop=True`; standalone собран.
- Ручная сверка с референсом и поведение Enter/Esc — за пользователем.
---
## 2026-10-30 — реализацию выполняет DeepSeek
- Решение пользователя: этап реализации больше не закреплён за Kimi. Оба

View File

@ -41,6 +41,11 @@
- Переходы — `SceneFlow`; очередь/онбординг — оверлеи **Lobby**.
- 3D-окружение сейчас из примитивов (`SceneEnvironment`): камера, свет,
пол, тематические кубы. UI — code-generated uGUI, пока gray-box.
- Login переведён на референс: полноэкранный арт
`Resources/Login/login_bg`, неоновая панель, меню (язык, версия, адрес
сервера). Tab/Shift+Tab переключают поля, Enter отправляет форму, Esc
закрывает меню. 3D-окружение в `Login` больше не строится
(`TASK-2026-021`).
- Интерфейс рассчитан на reference **1920×1080** (`CanvasScaler`,
`matchWidthOrHeight = 0.5`); standalone принудительно открывается в
1920×1080 fullscreen, `defaultIsNativeResolution` выключен.
@ -273,7 +278,7 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
```
Клиентские проверки: **46/46** EditMode (включая новые тесты TASK-2026-001:
Клиентские проверки: **60/60** EditMode (включая новые тесты TASK-2026-001:
повторный render скрытых карт, открытие урона, source-позиция нового юнита,
coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила
@ -356,7 +361,11 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги
1. **TASK-2026-001 — ручная проверка пользователем** (C10): draw, call, ride,
1. **TASK-2026-021 — Login по референсу: реализован, ждёт ручной приёмки**
(C10). EditMode 60/60, `SCENE_SMOKE success=True`, standalone собран.
Далее по плану — **TASK-2026-019** (загрузочная сцена, fade, кадр без
покадровых аллокаций).
2. **TASK-2026-001 — ручная проверка пользователем** (C10): draw, call, ride,
move/swap, guard, trigger, drive/damage, retire, search, открытие скрытого
урона, рука противника, повторные клики UI во время/после анимации.
Автотесты 47/47; код изменён в `GameBoard3D`, `CardMoveAnimator`,