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 active = SceneManager.GetActiveScene().name;
|
||||||
var environment = GameObject.Find("Vanguard 3D Environment");
|
var environment = GameObject.Find("Vanguard 3D Environment");
|
||||||
|
var backdrop = GameObject.Find("LoginBackdrop");
|
||||||
var login = GameObject.Find("LoginScreen");
|
var login = GameObject.Find("LoginScreen");
|
||||||
var lobby = GameObject.Find("MetaScreen");
|
var lobby = GameObject.Find("MetaScreen");
|
||||||
var onboarding = GameObject.Find("OnboardingScreen");
|
var onboarding = GameObject.Find("OnboardingScreen");
|
||||||
var success = environment != null
|
var isLogin = active == SceneFlow.Login;
|
||||||
&& (active == SceneFlow.Login || active == SceneFlow.Lobby)
|
var isLobby = active == SceneFlow.Lobby;
|
||||||
&& (login != null || lobby != null || onboarding != null);
|
var success = (isLogin || isLobby)
|
||||||
|
&& (login != null || lobby != null || onboarding != null)
|
||||||
|
&& (isLogin ? backdrop != null : environment != null);
|
||||||
|
|
||||||
Debug.Log(
|
Debug.Log(
|
||||||
"SCENE_SMOKE success=" + success
|
"SCENE_SMOKE success=" + success
|
||||||
+ " active=" + active
|
+ " active=" + active
|
||||||
+ " env=" + (environment != null)
|
+ " env=" + (environment != null)
|
||||||
|
+ " backdrop=" + (backdrop != null)
|
||||||
+ " login=" + (login != null)
|
+ " login=" + (login != null)
|
||||||
+ " lobby=" + (lobby != null)
|
+ " lobby=" + (lobby != null)
|
||||||
+ " onboarding=" + (onboarding != 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()
|
private void Start()
|
||||||
{
|
{
|
||||||
SceneEnvironment.Build(SceneTheme.Login);
|
|
||||||
_auth = Resolve<AuthService>();
|
_auth = Resolve<AuthService>();
|
||||||
var config = Resolve<AppConfig>();
|
var config = Resolve<AppConfig>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,16 @@ namespace Vanguard.UI
|
||||||
["Готово."] = "Done.",
|
["Готово."] = "Done.",
|
||||||
["Забрать"] = "Claim",
|
["Забрать"] = "Claim",
|
||||||
["Закрыть"] = "Close",
|
["Закрыть"] = "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",
|
["История"] = "History",
|
||||||
["Карта: "] = "Card: ",
|
["Карта: "] = "Card: ",
|
||||||
["Магазин"] = "Store",
|
["Магазин"] = "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.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.Events;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using Vanguard.Api;
|
using Vanguard.Api;
|
||||||
using Vanguard.App;
|
using Vanguard.App;
|
||||||
|
|
@ -9,21 +10,29 @@ using Vanguard.Auth;
|
||||||
|
|
||||||
namespace Vanguard.UI
|
namespace Vanguard.UI
|
||||||
{
|
{
|
||||||
/// <summary>Экран входа/регистрации и выбора адреса сервера.</summary>
|
/// <summary>Экран входа по референсу: key art, неоновая панель, меню.</summary>
|
||||||
public sealed class LoginView
|
public sealed class LoginView
|
||||||
{
|
{
|
||||||
|
private const int PanelWidth = 560;
|
||||||
|
private const int PanelHeight = 420;
|
||||||
|
|
||||||
private readonly AppConfig _config;
|
private readonly AppConfig _config;
|
||||||
private readonly AuthService _auth;
|
private readonly AuthService _auth;
|
||||||
private readonly Action _onAuthenticated;
|
private readonly Action _onAuthenticated;
|
||||||
|
|
||||||
private readonly RectTransform _root;
|
private readonly RectTransform _root;
|
||||||
private readonly InputField _serverUrl;
|
|
||||||
private readonly InputField _userName;
|
private readonly InputField _userName;
|
||||||
private readonly InputField _password;
|
private readonly InputField _password;
|
||||||
|
private readonly Image _passwordToggle;
|
||||||
private readonly Button _loginButton;
|
private readonly Button _loginButton;
|
||||||
|
private readonly Text _loginLabel;
|
||||||
private readonly Button _registerButton;
|
private readonly Button _registerButton;
|
||||||
private readonly Text _status;
|
private readonly Text _status;
|
||||||
|
private readonly StatusTicker _ticker;
|
||||||
|
private readonly LoginMenu _menu;
|
||||||
|
|
||||||
private bool _busy;
|
private bool _busy;
|
||||||
|
private bool _passwordVisible;
|
||||||
|
|
||||||
public LoginView(
|
public LoginView(
|
||||||
Transform parent,
|
Transform parent,
|
||||||
|
|
@ -35,123 +44,476 @@ namespace Vanguard.UI
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_onAuthenticated = onAuthenticated;
|
_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-стол остаётся виден по краям.
|
LoginBackdrop.Create(_root);
|
||||||
UiKit.CreateCenteredCard(
|
|
||||||
_root,
|
|
||||||
"LoginGlow",
|
|
||||||
new Vector2(760f, 900f),
|
|
||||||
new Color(0.04f, 0.22f, 0.80f, 0.12f));
|
|
||||||
|
|
||||||
var card = UiKit.CreateCenteredCard(
|
var panel = CreatePanel(_root);
|
||||||
_root,
|
_userName = CreateField(panel, "UserName", "Логин", UserIcon(), 110f);
|
||||||
"LoginCard",
|
_password = CreateField(panel, "Password", "Пароль", LockIcon(), 32f, password: true);
|
||||||
new Vector2(620f, 820f),
|
_passwordToggle = CreatePasswordToggle(panel);
|
||||||
new Color(0.018f, 0.032f, 0.065f, 0.92f));
|
|
||||||
|
|
||||||
var layout = UiKit.AddVerticalLayout(card, 32, 10);
|
var tabs = _root.gameObject.AddComponent<TabFocusDriver>();
|
||||||
layout.childAlignment = TextAnchor.MiddleCenter;
|
tabs.First = _userName;
|
||||||
|
tabs.Second = _password;
|
||||||
|
tabs.Submit = OnSubmitShortcut;
|
||||||
|
tabs.Cancel = OnCancelShortcut;
|
||||||
|
|
||||||
var eyebrow = UiKit.CreateText(
|
CreateLink(
|
||||||
card, "Eyebrow", "VANGUARD // NETWORK", 13,
|
panel,
|
||||||
new Color(0.35f, 0.72f, 1f));
|
"ForgotPassword",
|
||||||
UiKit.SetPreferredHeight(eyebrow, 20f);
|
"Забыли пароль?",
|
||||||
|
-14f,
|
||||||
|
TextAnchor.MiddleRight,
|
||||||
|
new Color(0.37f, 0.69f, 1f),
|
||||||
|
OnForgotClicked);
|
||||||
|
|
||||||
var title = UiKit.CreateText(
|
_loginButton = CreateLoginButton(panel, out _loginLabel);
|
||||||
card, "Title", "VANGUARD", 56, new Color(0.88f, 0.94f, 1f));
|
_registerButton = CreateLink(
|
||||||
title.fontStyle = FontStyle.Bold;
|
panel,
|
||||||
UiKit.SetPreferredHeight(title, 66f);
|
"Register",
|
||||||
|
"Создать аккаунт",
|
||||||
var subtitle = UiKit.CreateText(
|
-128f,
|
||||||
card, "Subtitle", "RE:MASTER · СЕТЕВОЙ КЛИЕНТ", 18,
|
TextAnchor.MiddleCenter,
|
||||||
new Color(0.52f, 0.64f, 0.86f));
|
new Color(0.50f, 0.66f, 0.88f),
|
||||||
UiKit.SetPreferredHeight(subtitle, 30f);
|
OnRegisterClicked);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
_status = UiKit.CreateText(
|
_status = UiKit.CreateText(
|
||||||
card, "Status", string.Empty, 17,
|
panel,
|
||||||
new Color(0.66f, 0.74f, 0.90f),
|
"Status",
|
||||||
|
string.Empty,
|
||||||
|
15,
|
||||||
|
new Color(0.62f, 0.72f, 0.88f),
|
||||||
TextAnchor.UpperCenter);
|
TextAnchor.UpperCenter);
|
||||||
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
|
_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(
|
CreateTagline(_root);
|
||||||
card,
|
CreateCopyright(_root);
|
||||||
"Footer",
|
|
||||||
"v" + Application.version + " · стенд 192.168.31.158:18080 · localhost:5080",
|
|
||||||
14,
|
|
||||||
new Color(0.38f, 0.46f, 0.62f));
|
|
||||||
UiKit.SetPreferredHeight(footer, 24f);
|
|
||||||
|
|
||||||
var language = UiKit.CreateLanguageButton(_root, "Language");
|
_ticker = _root.gameObject.AddComponent<StatusTicker>();
|
||||||
var languageRect = language.GetComponent<RectTransform>();
|
_ticker.Hide = () => SetStatus(string.Empty, StatusColor);
|
||||||
languageRect.anchorMin = new Vector2(1f, 1f);
|
|
||||||
languageRect.anchorMax = new Vector2(1f, 1f);
|
_menu = new LoginMenu(_root, _config);
|
||||||
languageRect.pivot = new Vector2(1f, 1f);
|
}
|
||||||
languageRect.anchoredPosition = new Vector2(-24f, -24f);
|
|
||||||
languageRect.sizeDelta = new Vector2(72f, 40f);
|
/// <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)
|
public void Show(string status = null)
|
||||||
{
|
{
|
||||||
UiKit.SetActive(_root, true);
|
UiKit.SetActive(_root, true);
|
||||||
if (!string.IsNullOrEmpty(status))
|
if (!string.IsNullOrEmpty(status))
|
||||||
SetStatus(status, new Color(0.70f, 0.75f, 0.86f));
|
SetStatus(status, StatusColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Hide() => UiKit.SetActive(_root, false);
|
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,
|
parent,
|
||||||
"Caption_" + text,
|
"PanelGlow",
|
||||||
text,
|
NeonUi.Glow(
|
||||||
16,
|
PanelWidth + 48,
|
||||||
new Color(0.55f, 0.65f, 0.82f),
|
PanelHeight + 48,
|
||||||
TextAnchor.MiddleLeft);
|
24,
|
||||||
UiKit.SetPreferredHeight(caption, 22f);
|
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>();
|
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(InputField));
|
||||||
if (image != null)
|
var rect = (RectTransform)go.transform;
|
||||||
image.color = background;
|
rect.SetParent(parent, false);
|
||||||
|
Place(rect, new Vector2(480f, 64f), new Vector2(0f, y));
|
||||||
|
|
||||||
var text = button.GetComponentInChildren<Text>();
|
var background = go.GetComponent<Image>();
|
||||||
if (text != null)
|
background.sprite = NeonUi.Body(
|
||||||
text.color = label;
|
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()
|
private async void OnLoginClicked()
|
||||||
{
|
{
|
||||||
await SubmitAsync(register: false);
|
await SubmitAsync(register: false);
|
||||||
|
|
@ -162,6 +524,25 @@ namespace Vanguard.UI
|
||||||
await SubmitAsync(register: true);
|
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)
|
private async Task SubmitAsync(bool register)
|
||||||
{
|
{
|
||||||
if (_busy)
|
if (_busy)
|
||||||
|
|
@ -172,14 +553,11 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
|
||||||
{
|
{
|
||||||
SetStatus("Введите имя игрока и пароль.", new Color(1f, 0.70f, 0.35f));
|
SetStatus("Введите логин и пароль.", new Color(1f, 0.70f, 0.35f));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_config.SetServerBaseUrl(_serverUrl.text);
|
SetBusy(true, register ? "Создаём аккаунт…" : "Подключение…");
|
||||||
_serverUrl.text = _config.ServerBaseUrl;
|
|
||||||
|
|
||||||
SetBusy(true, register ? "Создаём аккаунт…" : "Подключаемся…");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -208,9 +586,7 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SetStatus(
|
SetStatus("Не удалось связаться: " + detail, new Color(1f, 0.55f, 0.45f));
|
||||||
"Не удалось связаться: " + detail,
|
|
||||||
new Color(1f, 0.55f, 0.45f));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -237,19 +613,17 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
private void SetBusy(bool busy, string status)
|
private void SetBusy(bool busy, string status)
|
||||||
{
|
{
|
||||||
if (_loginButton == null || _registerButton == null || _serverUrl == null
|
|
||||||
|| _userName == null || _password == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_busy = busy;
|
_busy = busy;
|
||||||
_loginButton.interactable = !busy;
|
_loginButton.interactable = !busy;
|
||||||
_registerButton.interactable = !busy;
|
_registerButton.interactable = !busy;
|
||||||
_serverUrl.interactable = !busy;
|
|
||||||
_userName.interactable = !busy;
|
_userName.interactable = !busy;
|
||||||
_password.interactable = !busy;
|
_password.interactable = !busy;
|
||||||
|
|
||||||
|
if (_loginLabel != null)
|
||||||
|
_loginLabel.text = busy ? "Подключение…" : "Войти";
|
||||||
|
|
||||||
if (status != null)
|
if (status != null)
|
||||||
SetStatus(status, new Color(0.70f, 0.75f, 0.86f));
|
SetStatus(status, StatusColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetStatus(string text, Color color)
|
private void SetStatus(string text, Color color)
|
||||||
|
|
@ -259,6 +633,57 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
_status.text = text ?? string.Empty;
|
_status.text = text ?? string.Empty;
|
||||||
_status.color = color;
|
_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();
|
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) =>
|
private static bool Exists(string name) =>
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);
|
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
**Дата:** 2026-10-30
|
**Дата:** 2026-10-30
|
||||||
**Анализ:** DeepSeek выполнен; код, сцены, префабы, SO и конфиги не менялись.
|
**Анализ:** DeepSeek выполнен; код, сцены, префабы, SO и конфиги не менялись.
|
||||||
**Решения:** зафиксированы 2026-10-30 (раздел 8).
|
**Решения:** зафиксированы 2026-10-30 (раздел 8).
|
||||||
**Статус:** реализация не начата.
|
**Статус:** TASK-2026-021 (B2, Login) реализован и ждёт ручной приёмки;
|
||||||
|
TASK-2026-019 (A1 + C1–C3) — план готов, код не начат.
|
||||||
**Связанные документы:** `docs/client-visual-direction.md`,
|
**Связанные документы:** `docs/client-visual-direction.md`,
|
||||||
`docs/3d-ux-backlog.md`, `docs/Development/ANIMATION-INVENTORY.md`,
|
`docs/3d-ux-backlog.md`, `docs/Development/ANIMATION-INVENTORY.md`,
|
||||||
`docs/Development/IMPLEMENTATION_PLAN.md` (TASK-2026-001…018).
|
`docs/Development/IMPLEMENTATION_PLAN.md` (TASK-2026-001…018).
|
||||||
|
|
@ -98,7 +99,7 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
|
||||||
| A7 | Additive-слой общих сервисов | P2 | не пересоздавать Canvas |
|
| A7 | Additive-слой общих сервисов | P2 | не пересоздавать Canvas |
|
||||||
| A8 | SceneSmoke для DeckBuilder/Battle | P2 | покрытие смоуком |
|
| A8 | SceneSmoke для DeckBuilder/Battle | P2 | покрытие смоуком |
|
||||||
| B1 | Общие материалы вместо `new Material` | P0 | батчинг, редактируемость |
|
| B1 | Общие материалы вместо `new Material` | P0 | батчинг, редактируемость |
|
||||||
| B2 | Login: полноценная 3D-сцена | P0 | первый экран |
|
| B2 | Login: 2D-арт по референсу | P0 | первый экран |
|
||||||
| B3 | Lobby и DeckBuilder: 3D-сцены | P1 | сейчас 6 и 3 куба |
|
| B3 | Lobby и DeckBuilder: 3D-сцены | P1 | сейчас 6 и 3 куба |
|
||||||
| B4 | Battle: окружение вокруг поля | P1 | задник/свет без правки поля |
|
| B4 | Battle: окружение вокруг поля | P1 | задник/свет без правки поля |
|
||||||
| B5 | Prefab-экраны UI | P1 | уйти от code-generated |
|
| B5 | Prefab-экраны UI | P1 | уйти от code-generated |
|
||||||
|
|
@ -203,13 +204,15 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
|
||||||
Критерий: Frame Debugger без лишних `SetPass`, SRP Batcher активен.
|
Критерий: Frame Debugger без лишних `SetPass`, SRP Batcher активен.
|
||||||
Зависит: A4.
|
Зависит: A4.
|
||||||
|
|
||||||
- [ ] **B2. P0 — Login: полноценная 3D-сцена.**
|
- [ ] **B2. P0 — Login по референсу: 2D-арт + неоновый UI.**
|
||||||
Сделать: authored-стол, 4–6 карт, неоновые направляющие, задник; свет и
|
Решение 2026-10-31: фон — 2D key art на весь экран, 3D-окружение в Login не
|
||||||
камера из профиля; геометрия — префабы, а не код.
|
строится (исключение из правила «3D в каждой сцене», см. 8.7). Состав
|
||||||
Зачем: первый экран сейчас собран из 14 кубов в рантайме.
|
экрана, размеры и состояния — в `TASK-2026-021`
|
||||||
Критерий: визуально читаемый стол; в рантайме не создаётся статическая
|
(`docs/Development/IMPLEMENTATION_PLAN.md`).
|
||||||
геометрия.
|
Зачем: текущий Login — карточка 620×820 с полем сервера и кнопкой регистрации,
|
||||||
Зависит: B1, A4, референс экрана от пользователя.
|
композиция не соответствует референсу.
|
||||||
|
Критерий: композиция совпадает с референсом, вход и регистрация работают.
|
||||||
|
Зависит: арт-файл от пользователя; `TASK-2026-021`.
|
||||||
|
|
||||||
- [ ] **B3. P1 — Lobby и DeckBuilder: 3D-сцены.**
|
- [ ] **B3. P1 — Lobby и DeckBuilder: 3D-сцены.**
|
||||||
Сделать: Lobby — зал с постаментом/витриной и световыми акцентами;
|
Сделать: Lobby — зал с постаментом/витриной и световыми акцентами;
|
||||||
|
|
@ -388,6 +391,16 @@ TASK с полями «Реализация» и «Проверка» по `AGEN
|
||||||
`Lobby`, `DeckBuilder` (включая экран колод), `Battle`. B2–B4 стартуют
|
`Lobby`, `DeckBuilder` (включая экран колод), `Battle`. B2–B4 стартуют
|
||||||
после получения соответствующего референса.
|
после получения соответствующего референса.
|
||||||
6. **Первый блок.** A1 + C1–C3.
|
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-001 | `[!]` | Плавность карточных перемещений и корректные источники | DeepSeek: анализ; реализация выполнена, ждёт ручной проверки C10 | EditMode 46/46; ручная — пользователь |
|
||||||
| TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена |
|
| TASK-2026-019 | `[ ]` | Загрузочная сцена и кадр без покадровых аллокаций | DeepSeek: план готов; реализация ожидает | не выполнена |
|
||||||
| TASK-2026-020 | `[x]` | Парсер карт и артов с официального сайта | DeepSeek: анализ и реализация | идемпотентность 0 diff; `validate` не запускался |
|
| 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`;
|
- **Проверки:** полный прогон в копию — 0 различий с `content/sets`;
|
||||||
94 определения / 124 принта; арт TD01 16/16; `--force-art` заменяет
|
94 определения / 124 принта; арт TD01 16/16; `--force-art` заменяет
|
||||||
`.jpg` на `.png` без дублей.
|
`.jpg` на `.png` без дублей.
|
||||||
- **Не завершено:** `validate` на `ssh home`; массовая замена артов на
|
- **Не завершено:** `validate` на `ssh home`.
|
||||||
официальные PNG (решение пользователя).
|
- **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 обновлён:** не требуется.
|
- **Skill обновлён:** не требуется.
|
||||||
- **Git:** не коммитилось.
|
- **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
|
## 2026-10-30 — реализацию выполняет DeepSeek
|
||||||
|
|
||||||
- Решение пользователя: этап реализации больше не закреплён за Kimi. Оба
|
- Решение пользователя: этап реализации больше не закреплён за Kimi. Оба
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@
|
||||||
- Переходы — `SceneFlow`; очередь/онбординг — оверлеи **Lobby**.
|
- Переходы — `SceneFlow`; очередь/онбординг — оверлеи **Lobby**.
|
||||||
- 3D-окружение сейчас из примитивов (`SceneEnvironment`): камера, свет,
|
- 3D-окружение сейчас из примитивов (`SceneEnvironment`): камера, свет,
|
||||||
пол, тематические кубы. UI — code-generated uGUI, пока gray-box.
|
пол, тематические кубы. 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`,
|
- Интерфейс рассчитан на reference **1920×1080** (`CanvasScaler`,
|
||||||
`matchWidthOrHeight = 0.5`); standalone принудительно открывается в
|
`matchWidthOrHeight = 0.5`); standalone принудительно открывается в
|
||||||
1920×1080 fullscreen, `defaultIsNativeResolution` выключен.
|
1920×1080 fullscreen, `defaultIsNativeResolution` выключен.
|
||||||
|
|
@ -273,7 +278,7 @@ Standalone Windows:
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **46/46** EditMode (включая новые тесты TASK-2026-001:
|
Клиентские проверки: **60/60** EditMode (включая новые тесты TASK-2026-001:
|
||||||
повторный render скрытых карт, открытие урона, source-позиция нового юнита,
|
повторный render скрытых карт, открытие урона, source-позиция нового юнита,
|
||||||
coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
|
coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
|
||||||
строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила
|
строк, 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, открытие скрытого
|
move/swap, guard, trigger, drive/damage, retire, search, открытие скрытого
|
||||||
урона, рука противника, повторные клики UI во время/после анимации.
|
урона, рука противника, повторные клики UI во время/после анимации.
|
||||||
Автотесты 47/47; код изменён в `GameBoard3D`, `CardMoveAnimator`,
|
Автотесты 47/47; код изменён в `GameBoard3D`, `CardMoveAnimator`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue