client: Login по референсу — 2D key art и неоновый UI
CI / test (push) Successful in 3m16s
Details
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:
parent
dbb4888299
commit
af3fb2b621
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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:
|
||||
|
|
@ -12,7 +12,6 @@ namespace Vanguard.App
|
|||
|
||||
private void Start()
|
||||
{
|
||||
SceneEnvironment.Build(SceneTheme.Login);
|
||||
_auth = Resolve<AuthService>();
|
||||
var config = Resolve<AppConfig>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ae0147101ebe1d14da83d20cf2212c04
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 693da1bea283cab4bac9e9caa525032d
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f1289eec64534bf459d8233e48c15373
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d6d7f03e1312bdf4aa668f0f37b0673f
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (референс в чате). Для остальных
|
||||
экранов ждём.
|
||||
|
||||
**Риски:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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:** не коммитилось.
|
||||
|
||||
|
|
|
|||
|
|
@ -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. Оба
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue