client/server: анимации 3D-поля, HUD и правила (WIP)
- Board3D: CardMoveAnimator, Card3D, GameBoard3D, раскладка, слоты, меши - Battle: AnimationConfig, CameraDirector, TriggerCheckSequence - UI: BattleView, новый PlayerStatsPanelView - Settings/ProjectSettings: URP-профиль и настройки проекта - Rules/Shared: колонки Tejas и проекция матча - тесты: GameBoard3D, ClientSmoke; док ANIMATION-INVENTORY
This commit is contained in:
parent
29822be2ad
commit
26934c9213
|
|
@ -84,6 +84,7 @@ MonoBehaviour:
|
||||||
m_SupportScreenSpaceLensFlare: 1
|
m_SupportScreenSpaceLensFlare: 1
|
||||||
m_GPUResidentDrawerMode: 0
|
m_GPUResidentDrawerMode: 0
|
||||||
m_SmallMeshScreenPercentage: 0
|
m_SmallMeshScreenPercentage: 0
|
||||||
|
m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
|
||||||
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
|
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
|
||||||
m_ShadowType: 1
|
m_ShadowType: 1
|
||||||
m_LocalShadowsSupported: 0
|
m_LocalShadowsSupported: 0
|
||||||
|
|
@ -100,18 +101,18 @@ MonoBehaviour:
|
||||||
m_Keys: []
|
m_Keys: []
|
||||||
m_Values:
|
m_Values:
|
||||||
m_PrefilteringModeMainLightShadows: 3
|
m_PrefilteringModeMainLightShadows: 3
|
||||||
m_PrefilteringModeAdditionalLight: 4
|
m_PrefilteringModeAdditionalLight: 0
|
||||||
m_PrefilteringModeAdditionalLightShadows: 0
|
m_PrefilteringModeAdditionalLightShadows: 2
|
||||||
m_PrefilterXRKeywords: 1
|
m_PrefilterXRKeywords: 1
|
||||||
m_PrefilteringModeForwardPlus: 1
|
m_PrefilteringModeForwardPlus: 2
|
||||||
m_PrefilteringModeDeferredRendering: 0
|
m_PrefilteringModeDeferredRendering: 0
|
||||||
m_PrefilteringModeScreenSpaceOcclusion: 1
|
m_PrefilteringModeScreenSpaceOcclusion: 2
|
||||||
m_PrefilteringModeScreenSpaceReflection: 1
|
m_PrefilteringModeScreenSpaceReflection: 0
|
||||||
m_PrefilterWriteSmoothness: 1
|
m_PrefilterWriteSmoothness: 1
|
||||||
m_PrefilterDebugKeywords: 1
|
m_PrefilterDebugKeywords: 1
|
||||||
m_PrefilterWriteRenderingLayers: 0
|
m_PrefilterWriteRenderingLayers: 1
|
||||||
m_PrefilterHDROutput: 1
|
m_PrefilterHDROutput: 1
|
||||||
m_PrefilterAlphaOutput: 0
|
m_PrefilterAlphaOutput: 1
|
||||||
m_PrefilterSSAODepthNormals: 0
|
m_PrefilterSSAODepthNormals: 0
|
||||||
m_PrefilterSSAOSourceDepthLow: 1
|
m_PrefilterSSAOSourceDepthLow: 1
|
||||||
m_PrefilterSSAOSourceDepthMedium: 1
|
m_PrefilterSSAOSourceDepthMedium: 1
|
||||||
|
|
@ -123,20 +124,23 @@ MonoBehaviour:
|
||||||
m_PrefilterSSAOSampleCountHigh: 1
|
m_PrefilterSSAOSampleCountHigh: 1
|
||||||
m_PrefilterDBufferMRT1: 1
|
m_PrefilterDBufferMRT1: 1
|
||||||
m_PrefilterDBufferMRT2: 1
|
m_PrefilterDBufferMRT2: 1
|
||||||
m_PrefilterDBufferMRT3: 0
|
m_PrefilterDBufferMRT3: 1
|
||||||
m_PrefilterSoftShadowsQualityLow: 0
|
m_PrefilterSoftShadowsQualityLow: 1
|
||||||
m_PrefilterSoftShadowsQualityMedium: 0
|
m_PrefilterSoftShadowsQualityMedium: 1
|
||||||
m_PrefilterSoftShadowsQualityHigh: 0
|
m_PrefilterSoftShadowsQualityHigh: 1
|
||||||
m_PrefilterSoftShadows: 0
|
m_PrefilterSoftShadows: 0
|
||||||
m_PrefilterScreenCoord: 1
|
m_PrefilterScreenCoord: 1
|
||||||
m_PrefilterScreenSpaceIrradiance: 0
|
m_PrefilterScreenSpaceIrradiance: 1
|
||||||
m_PrefilterNativeRenderPass: 1
|
m_PrefilterNativeRenderPass: 1
|
||||||
m_PrefilterUseLegacyLightmaps: 0
|
m_PrefilterUseLegacyLightmaps: 0
|
||||||
m_PrefilterBicubicLightmapSampling: 0
|
m_PrefilterBicubicLightmapSampling: 1
|
||||||
m_PrefilterReflectionProbeRotation: 0
|
m_PrefilterReflectionProbeRotation: 0
|
||||||
m_PrefilterReflectionProbeBlending: 0
|
m_PrefilterReflectionProbeBlending: 0
|
||||||
m_PrefilterReflectionProbeBoxProjection: 0
|
m_PrefilterReflectionProbeBoxProjection: 0
|
||||||
m_PrefilterReflectionProbeAtlas: 0
|
m_PrefilterReflectionProbeAtlas: 0
|
||||||
|
m_PrefilterPointSamplingUpsampling: 1
|
||||||
|
m_Light2DPrefilteringMode: 0
|
||||||
|
m_Light2DKeptVariantCombos: []
|
||||||
m_ShaderVariantLogLevel: 0
|
m_ShaderVariantLogLevel: 0
|
||||||
m_ShadowCascades: 0
|
m_ShadowCascades: 0
|
||||||
m_Textures:
|
m_Textures:
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,22 @@ MonoBehaviour:
|
||||||
- rid: 6588237625532874761
|
- rid: 6588237625532874761
|
||||||
- rid: 6588237625532874762
|
- rid: 6588237625532874762
|
||||||
m_RuntimeSettings:
|
m_RuntimeSettings:
|
||||||
m_List: []
|
m_List:
|
||||||
|
- rid: 6852985685364965378
|
||||||
|
- rid: 6852985685364965380
|
||||||
|
- rid: 6852985685364965381
|
||||||
|
- rid: 6852985685364965384
|
||||||
|
- rid: 6852985685364965392
|
||||||
|
- rid: 6852985685364965394
|
||||||
|
- rid: 8712630790384254976
|
||||||
|
- rid: 4284062438471237637
|
||||||
|
- rid: 5747374308340793346
|
||||||
|
- rid: 5747374308340793347
|
||||||
|
- rid: 5747374308340793348
|
||||||
|
- rid: 6588237625532874753
|
||||||
|
- rid: 6588237625532874756
|
||||||
|
- rid: 6588237625532874759
|
||||||
|
- rid: 6588237625532874761
|
||||||
m_AssetVersion: 11
|
m_AssetVersion: 11
|
||||||
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
||||||
m_RenderingLayerNames:
|
m_RenderingLayerNames:
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,25 @@ MonoBehaviour:
|
||||||
intercept: 1.9
|
intercept: 1.9
|
||||||
perfectGuard: 2
|
perfectGuard: 2
|
||||||
inputLockTail: 0.15
|
inputLockTail: 0.15
|
||||||
|
eventPause: 1.5
|
||||||
|
postDrivePause: 1.5
|
||||||
|
postTriggerPause: 1.5
|
||||||
|
postDamagePause: 1.5
|
||||||
arrowShowDuration: 0.25
|
arrowShowDuration: 0.25
|
||||||
arrowHideDuration: 0.2
|
arrowHideDuration: 0.2
|
||||||
moveDuration: 0.5
|
moveDuration: 0.8
|
||||||
importantMoveDuration: 0.9
|
importantMoveDuration: 1.3
|
||||||
moveDelayBetweenCards: 0.18
|
moveDelayBetweenCards: 0.22
|
||||||
arcHeight: 0.45
|
arcHeight: 0.45
|
||||||
cameraZoomDuration: 0.35
|
introOverheadHold: 1.5
|
||||||
triggerRevealHold: 1.2
|
introCameraMoveDuration: 1
|
||||||
cameraReturnDuration: 0.4
|
introMainHold: 1.5
|
||||||
|
introDrawDuration: 0.6
|
||||||
|
cameraZoomDuration: 0.5
|
||||||
|
vanguardRevealDuration: 0.8
|
||||||
|
triggerRevealDuration: 0.35
|
||||||
|
triggerRevealHold: 0.75
|
||||||
|
cameraReturnDuration: 0.5
|
||||||
cameraRezoomDuration: 0.3
|
cameraRezoomDuration: 0.3
|
||||||
growDuration: 0.35
|
growDuration: 0.35
|
||||||
dashScrollSpeed: 1.5
|
dashScrollSpeed: 1.5
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6801c3b9bef9c1644a4c0af035547bd0
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -18,15 +18,23 @@ namespace Vanguard.Battle
|
||||||
public float retireDuration = 0.8f;
|
public float retireDuration = 0.8f;
|
||||||
|
|
||||||
[Header("3D UX: движение")]
|
[Header("3D UX: движение")]
|
||||||
public float moveDuration = 0.5f;
|
public float moveDuration = 0.8f;
|
||||||
public float importantMoveDuration = 0.9f;
|
public float importantMoveDuration = 1.3f;
|
||||||
public float moveDelayBetweenCards = 0.18f;
|
public float moveDelayBetweenCards = 0.22f;
|
||||||
public float arcHeight = 0.45f;
|
public float arcHeight = 0.45f;
|
||||||
|
|
||||||
|
[Header("Старт боя")]
|
||||||
|
public float introOverheadHold = 1.5f;
|
||||||
|
public float introCameraMoveDuration = 1f;
|
||||||
|
public float introMainHold = 1.5f;
|
||||||
|
public float introDrawDuration = 0.6f;
|
||||||
|
|
||||||
[Header("3D UX: камера")]
|
[Header("3D UX: камера")]
|
||||||
public float cameraZoomDuration = 0.35f;
|
public float cameraZoomDuration = 0.5f;
|
||||||
public float triggerRevealHold = 1.2f;
|
public float vanguardRevealDuration = 0.8f;
|
||||||
public float cameraReturnDuration = 0.4f;
|
public float triggerRevealDuration = 0.35f;
|
||||||
|
public float triggerRevealHold = 0.75f;
|
||||||
|
public float cameraReturnDuration = 0.5f;
|
||||||
public float cameraRezoomDuration = 0.3f;
|
public float cameraRezoomDuration = 0.3f;
|
||||||
|
|
||||||
[Header("3D UX: стрелки")]
|
[Header("3D UX: стрелки")]
|
||||||
|
|
@ -50,6 +58,12 @@ namespace Vanguard.Battle
|
||||||
[Header("Ввод")]
|
[Header("Ввод")]
|
||||||
public float inputLockTail = 0.15f;
|
public float inputLockTail = 0.15f;
|
||||||
|
|
||||||
|
[Header("Боевые паузы")]
|
||||||
|
public float eventPause = 1.5f;
|
||||||
|
public float postDrivePause = 1.5f;
|
||||||
|
public float postTriggerPause = 1.5f;
|
||||||
|
public float postDamagePause = 1.5f;
|
||||||
|
|
||||||
[Header("Стрелка атаки")]
|
[Header("Стрелка атаки")]
|
||||||
public float arrowShowDuration = 0.25f;
|
public float arrowShowDuration = 0.25f;
|
||||||
public float arrowHideDuration = 0.2f;
|
public float arrowHideDuration = 0.2f;
|
||||||
|
|
@ -89,7 +103,13 @@ namespace Vanguard.Battle
|
||||||
importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration);
|
importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration);
|
||||||
moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards);
|
moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards);
|
||||||
arcHeight = Mathf.Max(0f, arcHeight);
|
arcHeight = Mathf.Max(0f, arcHeight);
|
||||||
|
introOverheadHold = Mathf.Max(0f, introOverheadHold);
|
||||||
|
introCameraMoveDuration = Mathf.Max(0.05f, introCameraMoveDuration);
|
||||||
|
introMainHold = Mathf.Max(0f, introMainHold);
|
||||||
|
introDrawDuration = Mathf.Max(0.05f, introDrawDuration);
|
||||||
cameraZoomDuration = Mathf.Max(0.05f, cameraZoomDuration);
|
cameraZoomDuration = Mathf.Max(0.05f, cameraZoomDuration);
|
||||||
|
vanguardRevealDuration = Mathf.Max(0.05f, vanguardRevealDuration);
|
||||||
|
triggerRevealDuration = Mathf.Max(0.05f, triggerRevealDuration);
|
||||||
triggerRevealHold = Mathf.Max(0f, triggerRevealHold);
|
triggerRevealHold = Mathf.Max(0f, triggerRevealHold);
|
||||||
cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration);
|
cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration);
|
||||||
cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration);
|
cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration);
|
||||||
|
|
@ -108,6 +128,10 @@ namespace Vanguard.Battle
|
||||||
intercept = Mathf.Max(0.05f, intercept);
|
intercept = Mathf.Max(0.05f, intercept);
|
||||||
perfectGuard = Mathf.Max(0.05f, perfectGuard);
|
perfectGuard = Mathf.Max(0.05f, perfectGuard);
|
||||||
inputLockTail = Mathf.Max(0f, inputLockTail);
|
inputLockTail = Mathf.Max(0f, inputLockTail);
|
||||||
|
eventPause = Mathf.Max(0f, eventPause);
|
||||||
|
postDrivePause = Mathf.Max(0f, postDrivePause);
|
||||||
|
postTriggerPause = Mathf.Max(0f, postTriggerPause);
|
||||||
|
postDamagePause = Mathf.Max(0f, postDamagePause);
|
||||||
arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration);
|
arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration);
|
||||||
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
|
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,41 @@ namespace Vanguard.Battle
|
||||||
duration);
|
duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetImmediate(Vector3 position, Vector3 target, float fieldOfView)
|
||||||
|
{
|
||||||
|
if (_camera == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_moving = false;
|
||||||
|
var rotation = Quaternion.LookRotation(target - position, Vector3.up);
|
||||||
|
Apply(new Pose
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Rotation = rotation,
|
||||||
|
FieldOfView = fieldOfView,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MoveTo(
|
||||||
|
Vector3 position,
|
||||||
|
Vector3 target,
|
||||||
|
float fieldOfView,
|
||||||
|
float duration)
|
||||||
|
{
|
||||||
|
if (_camera == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var rotation = Quaternion.LookRotation(target - position, Vector3.up);
|
||||||
|
AnimateTo(
|
||||||
|
new Pose
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Rotation = rotation,
|
||||||
|
FieldOfView = fieldOfView,
|
||||||
|
},
|
||||||
|
duration);
|
||||||
|
}
|
||||||
|
|
||||||
public void ReturnToOrigin(float duration)
|
public void ReturnToOrigin(float duration)
|
||||||
{
|
{
|
||||||
if (!_hasOrigin)
|
if (!_hasOrigin)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vanguard.Battle;
|
using Vanguard.Battle;
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ namespace Vanguard.Battle
|
||||||
{
|
{
|
||||||
Idle,
|
Idle,
|
||||||
Zooming,
|
Zooming,
|
||||||
|
Revealing,
|
||||||
Holding,
|
Holding,
|
||||||
Returning,
|
Returning,
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +24,9 @@ namespace Vanguard.Battle
|
||||||
|
|
||||||
private Stage _stage;
|
private Stage _stage;
|
||||||
private float _deadline;
|
private float _deadline;
|
||||||
|
private float _zoomDeadline;
|
||||||
|
private float _revealDeadline;
|
||||||
|
private float _resolveDeadline;
|
||||||
private bool _chainActive;
|
private bool _chainActive;
|
||||||
private bool _restoreRequested;
|
private bool _restoreRequested;
|
||||||
private bool _lockHeld;
|
private bool _lockHeld;
|
||||||
|
|
@ -36,6 +41,18 @@ namespace Vanguard.Battle
|
||||||
|
|
||||||
public bool IsBusy => _stage != Stage.Idle;
|
public bool IsBusy => _stage != Stage.Idle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// true — камера ушла на trigger-зону, руку нужно скрыть;
|
||||||
|
/// false — камера вернулась, руку можно показать.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<bool> FocusChanged;
|
||||||
|
|
||||||
|
/// <summary>Камера закончила зум и стоит на trigger-зоне.</summary>
|
||||||
|
public event Action ZoomCompleted;
|
||||||
|
|
||||||
|
/// <summary>Карта раскрыта и выдержана пауза — можно резолвить триггер.</summary>
|
||||||
|
public event Action ResolveReady;
|
||||||
|
|
||||||
public void ShowTrigger(
|
public void ShowTrigger(
|
||||||
AnimationConfig config,
|
AnimationConfig config,
|
||||||
Vector3 worldPoint)
|
Vector3 worldPoint)
|
||||||
|
|
@ -43,6 +60,8 @@ namespace Vanguard.Battle
|
||||||
if (_camera == null)
|
if (_camera == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var wasIdle = _stage == Stage.Idle;
|
||||||
|
|
||||||
if (!_chainActive)
|
if (!_chainActive)
|
||||||
{
|
{
|
||||||
_camera.BeginChain();
|
_camera.BeginChain();
|
||||||
|
|
@ -54,13 +73,21 @@ namespace Vanguard.Battle
|
||||||
var zoom = config != null
|
var zoom = config != null
|
||||||
? config.cameraZoomDuration
|
? config.cameraZoomDuration
|
||||||
: 0.35f;
|
: 0.35f;
|
||||||
var hold = config != null
|
var reveal = config != null
|
||||||
|
? config.triggerRevealDuration
|
||||||
|
: 0.35f;
|
||||||
|
var pause = config != null
|
||||||
? config.triggerRevealHold
|
? config.triggerRevealHold
|
||||||
: 1.2f;
|
: 0.75f;
|
||||||
|
|
||||||
_camera.ZoomTo(worldPoint, zoom);
|
_camera.ZoomTo(worldPoint, zoom);
|
||||||
_stage = Stage.Zooming;
|
_stage = Stage.Zooming;
|
||||||
_deadline = Time.unscaledTime + zoom + hold;
|
_zoomDeadline = Time.unscaledTime + zoom;
|
||||||
|
_revealDeadline = _zoomDeadline + reveal;
|
||||||
|
_resolveDeadline = _revealDeadline + pause;
|
||||||
|
|
||||||
|
if (wasIdle)
|
||||||
|
OnFocusChanged(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReturnForChoice(AnimationConfig config)
|
public void ReturnForChoice(AnimationConfig config)
|
||||||
|
|
@ -99,15 +126,30 @@ namespace Vanguard.Battle
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (_stage == Stage.Zooming
|
if (_stage == Stage.Zooming
|
||||||
&& Time.unscaledTime >= _deadline)
|
&& Time.unscaledTime >= _zoomDeadline)
|
||||||
{
|
{
|
||||||
|
_stage = Stage.Revealing;
|
||||||
|
ZoomCompleted?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_stage == Stage.Revealing
|
||||||
|
&& Time.unscaledTime >= _revealDeadline)
|
||||||
|
{
|
||||||
|
_stage = Stage.Holding;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_stage == Stage.Holding
|
||||||
|
&& Time.unscaledTime >= _resolveDeadline)
|
||||||
|
{
|
||||||
|
ResolveReady?.Invoke();
|
||||||
|
|
||||||
var duration = config != null
|
var duration = config != null
|
||||||
? config.cameraReturnDuration
|
? config.cameraReturnDuration
|
||||||
: 0.4f;
|
: 0.4f;
|
||||||
|
_restoreRequested = true;
|
||||||
_camera.ReturnToOrigin(duration);
|
_camera.ReturnToOrigin(duration);
|
||||||
_stage = Stage.Returning;
|
_stage = Stage.Returning;
|
||||||
_deadline = Time.unscaledTime + duration;
|
_deadline = Time.unscaledTime + duration;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_stage == Stage.Returning
|
if (_stage == Stage.Returning
|
||||||
|
|
@ -121,6 +163,8 @@ namespace Vanguard.Battle
|
||||||
_chainActive = false;
|
_chainActive = false;
|
||||||
_camera.Restore();
|
_camera.Restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnFocusChanged(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,10 +188,18 @@ namespace Vanguard.Battle
|
||||||
|
|
||||||
public void Cancel()
|
public void Cancel()
|
||||||
{
|
{
|
||||||
|
var wasIdle = _stage == Stage.Idle;
|
||||||
_stage = Stage.Idle;
|
_stage = Stage.Idle;
|
||||||
_chainActive = false;
|
_chainActive = false;
|
||||||
Release();
|
Release();
|
||||||
_camera?.Restore();
|
_camera?.Restore();
|
||||||
|
if (!wasIdle)
|
||||||
|
OnFocusChanged(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFocusChanged(bool focused)
|
||||||
|
{
|
||||||
|
FocusChanged?.Invoke(focused);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vanguard.Battle;
|
using Vanguard.Battle;
|
||||||
|
|
@ -19,12 +20,17 @@ namespace Vanguard.Board3D
|
||||||
public Vector3 FromScale;
|
public Vector3 FromScale;
|
||||||
public float Duration;
|
public float Duration;
|
||||||
public float ArcHeight;
|
public float ArcHeight;
|
||||||
|
public bool Sequential;
|
||||||
|
public Action<Card3D> OnCompleted;
|
||||||
|
public Action<Card3D> OnStart;
|
||||||
|
public bool FromCurrent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ActiveMove
|
private sealed class ActiveMove
|
||||||
{
|
{
|
||||||
public Card3D Card;
|
public Card3D Card;
|
||||||
public float Remaining;
|
public float Remaining;
|
||||||
|
public Action<Card3D> OnCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Queue<Request> _queue = new Queue<Request>();
|
private readonly Queue<Request> _queue = new Queue<Request>();
|
||||||
|
|
@ -86,7 +92,11 @@ namespace Vanguard.Board3D
|
||||||
Quaternion fromRotation,
|
Quaternion fromRotation,
|
||||||
Vector3 fromScale,
|
Vector3 fromScale,
|
||||||
float duration,
|
float duration,
|
||||||
float arcHeight)
|
float arcHeight,
|
||||||
|
bool sequential = false,
|
||||||
|
Action<Card3D> onCompleted = null,
|
||||||
|
Action<Card3D> onStart = null,
|
||||||
|
bool fromCurrent = false)
|
||||||
{
|
{
|
||||||
if (card == null || card.IsDragging)
|
if (card == null || card.IsDragging)
|
||||||
return;
|
return;
|
||||||
|
|
@ -104,9 +114,47 @@ namespace Vanguard.Board3D
|
||||||
FromScale = fromScale,
|
FromScale = fromScale,
|
||||||
Duration = duration,
|
Duration = duration,
|
||||||
ArcHeight = arcHeight,
|
ArcHeight = arcHeight,
|
||||||
|
Sequential = sequential,
|
||||||
|
OnCompleted = onCompleted,
|
||||||
|
OnStart = onStart,
|
||||||
|
FromCurrent = fromCurrent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запускает перемещение сразу, без очереди и stagger. Нужен для
|
||||||
|
/// парных доборов, где две карты должны лететь одновременно.
|
||||||
|
/// </summary>
|
||||||
|
public void EnqueueNow(
|
||||||
|
Card3D card,
|
||||||
|
Vector3 fromPosition,
|
||||||
|
Quaternion fromRotation,
|
||||||
|
Vector3 fromScale,
|
||||||
|
float duration,
|
||||||
|
float arcHeight)
|
||||||
|
{
|
||||||
|
if (card == null || card.IsDragging)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CancelCard(card);
|
||||||
|
EnsureLock();
|
||||||
|
card.StartArcMove(
|
||||||
|
fromPosition,
|
||||||
|
fromRotation,
|
||||||
|
fromScale,
|
||||||
|
duration,
|
||||||
|
arcHeight);
|
||||||
|
|
||||||
|
if (card.IsMoving)
|
||||||
|
{
|
||||||
|
_active.Add(new ActiveMove
|
||||||
|
{
|
||||||
|
Card = card,
|
||||||
|
Remaining = Mathf.Max(0.1f, duration) + 0.05f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Tick(float deltaTime)
|
public void Tick(float deltaTime)
|
||||||
{
|
{
|
||||||
if (_active.Count > 0)
|
if (_active.Count > 0)
|
||||||
|
|
@ -130,6 +178,7 @@ namespace Vanguard.Board3D
|
||||||
if (!card.IsMoving)
|
if (!card.IsMoving)
|
||||||
{
|
{
|
||||||
_active.RemoveAt(i);
|
_active.RemoveAt(i);
|
||||||
|
active.OnCompleted?.Invoke(card);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +188,7 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
card.StopMove(snap: true);
|
card.StopMove(snap: true);
|
||||||
_active.RemoveAt(i);
|
_active.RemoveAt(i);
|
||||||
|
active.OnCompleted?.Invoke(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +206,12 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
public void CancelAll(bool snapCards = false)
|
public void CancelAll(bool snapCards = false)
|
||||||
{
|
{
|
||||||
|
foreach (var request in _queue)
|
||||||
|
{
|
||||||
|
if (request?.Card != null)
|
||||||
|
request.Card.CancelHold();
|
||||||
|
}
|
||||||
|
|
||||||
_queue.Clear();
|
_queue.Clear();
|
||||||
foreach (var active in _active)
|
foreach (var active in _active)
|
||||||
{
|
{
|
||||||
|
|
@ -173,17 +229,27 @@ namespace Vanguard.Board3D
|
||||||
if (card == null)
|
if (card == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var wasQueued = false;
|
||||||
var remaining = new Queue<Request>();
|
var remaining = new Queue<Request>();
|
||||||
while (_queue.Count > 0)
|
while (_queue.Count > 0)
|
||||||
{
|
{
|
||||||
var request = _queue.Dequeue();
|
var request = _queue.Dequeue();
|
||||||
if (request.Card != card)
|
if (request.Card != card)
|
||||||
|
{
|
||||||
remaining.Enqueue(request);
|
remaining.Enqueue(request);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
wasQueued = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (remaining.Count > 0)
|
while (remaining.Count > 0)
|
||||||
_queue.Enqueue(remaining.Dequeue());
|
_queue.Enqueue(remaining.Dequeue());
|
||||||
|
|
||||||
|
if (wasQueued)
|
||||||
|
card.CancelHold();
|
||||||
|
|
||||||
for (var i = _active.Count - 1; i >= 0; i--)
|
for (var i = _active.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
if (_active[i]?.Card != card)
|
if (_active[i]?.Card != card)
|
||||||
|
|
@ -202,6 +268,14 @@ namespace Vanguard.Board3D
|
||||||
if (_queue.Count == 0)
|
if (_queue.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Последовательные запросы (добор, возврат в колоду) идут строго
|
||||||
|
// по одной карте: следующая стартует только после предыдущей.
|
||||||
|
if (_queue.Peek().Sequential && _active.Count > 0)
|
||||||
|
{
|
||||||
|
_delay = 0f;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var request = _queue.Dequeue();
|
var request = _queue.Dequeue();
|
||||||
var card = request.Card;
|
var card = request.Card;
|
||||||
if (card == null || card.IsDragging)
|
if (card == null || card.IsDragging)
|
||||||
|
|
@ -210,10 +284,18 @@ namespace Vanguard.Board3D
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.OnStart?.Invoke(card);
|
||||||
|
|
||||||
card.StartArcMove(
|
card.StartArcMove(
|
||||||
request.FromPosition,
|
request.FromCurrent
|
||||||
request.FromRotation,
|
? card.transform.position
|
||||||
request.FromScale,
|
: request.FromPosition,
|
||||||
|
request.FromCurrent
|
||||||
|
? card.transform.rotation
|
||||||
|
: request.FromRotation,
|
||||||
|
request.FromCurrent
|
||||||
|
? card.transform.localScale
|
||||||
|
: request.FromScale,
|
||||||
request.Duration,
|
request.Duration,
|
||||||
request.ArcHeight);
|
request.ArcHeight);
|
||||||
|
|
||||||
|
|
@ -223,8 +305,13 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
Card = card,
|
Card = card,
|
||||||
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
|
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
|
||||||
|
OnCompleted = request.OnCompleted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
request.OnCompleted?.Invoke(card);
|
||||||
|
}
|
||||||
|
|
||||||
_delay = Mathf.Max(0.01f, _config != null
|
_delay = Mathf.Max(0.01f, _config != null
|
||||||
? _config.moveDelayBetweenCards
|
? _config.moveDelayBetweenCards
|
||||||
|
|
|
||||||
|
|
@ -30,27 +30,34 @@ MonoBehaviour:
|
||||||
dragThresholdPixels: 7
|
dragThresholdPixels: 7
|
||||||
smoothMoveSpeed: 16
|
smoothMoveSpeed: 16
|
||||||
smoothRotateSpeed: 14
|
smoothRotateSpeed: 14
|
||||||
handHoverLift: 0.08
|
handHoverLift: 0.1
|
||||||
handCenterZ: -5.3
|
handCenterZ: -5.3
|
||||||
handHeight: 1.12
|
handHeight: 1.12
|
||||||
handTilt: 58
|
handTilt: 58
|
||||||
handSpacing: 0.42
|
handSpacing: 0.28
|
||||||
handMaxWidth: 7.4
|
handMaxWidth: 7.4
|
||||||
handArc: 0.035
|
handArc: 0.035
|
||||||
handViewportX: 0.44
|
handViewportX: 0.5
|
||||||
handViewportY: 0.1
|
handViewportY: 0.1
|
||||||
handViewDistance: 4
|
handViewDistance: 4
|
||||||
handViewMaxWidth: 1.4
|
handViewMaxWidth: 2.52
|
||||||
handViewScale: 0.5
|
handViewScale: 0.5
|
||||||
handViewRoll: 3.2
|
handViewRoll: 3.2
|
||||||
handViewArc: 0.02
|
handViewArc: 0.02
|
||||||
opponentHandEnabled: 1
|
opponentHandEnabled: 1
|
||||||
opponentHandZ: 4.3
|
opponentHandZ: 4.3
|
||||||
opponentHandHeight: 0.75
|
opponentHandHeight: 0.75
|
||||||
opponentHandSpacing: 0.42
|
opponentHandSpacing: 0.28
|
||||||
opponentHandMaxWidth: 2.4
|
opponentHandMaxWidth: 3.78
|
||||||
opponentHandScale: 0.8
|
opponentHandScale: 0.9
|
||||||
opponentHandRoll: 2.5
|
opponentHandRoll: 5
|
||||||
|
opponentHandArc: 0.08
|
||||||
|
opponentHandViewportY: 0.76
|
||||||
|
opponentHandViewDistance: 6
|
||||||
|
opponentHandViewScale: 0.5
|
||||||
|
opponentHandViewMaxWidth: 2.4
|
||||||
|
opponentHandViewRoll: 3.2
|
||||||
|
opponentHandViewArc: 0.08
|
||||||
guardianRowGap: 0.55
|
guardianRowGap: 0.55
|
||||||
guardianSpacing: 0.62
|
guardianSpacing: 0.62
|
||||||
guardianMaxWidth: 3.6
|
guardianMaxWidth: 3.6
|
||||||
|
|
@ -60,6 +67,9 @@ MonoBehaviour:
|
||||||
cameraPosition: {x: 0, y: 4.2, z: -8}
|
cameraPosition: {x: 0, y: 4.2, z: -8}
|
||||||
cameraTarget: {x: 0, y: 0, z: 0}
|
cameraTarget: {x: 0, y: 0, z: 0}
|
||||||
cameraFov: 46
|
cameraFov: 46
|
||||||
|
introCameraPosition: {x: 0, y: 7.5, z: -5.5}
|
||||||
|
introCameraTarget: {x: 0, y: 0.4, z: 2}
|
||||||
|
introCameraFov: 55
|
||||||
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
|
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
|
||||||
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
|
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
|
||||||
matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1}
|
matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1}
|
||||||
|
|
|
||||||
|
|
@ -41,24 +41,25 @@ namespace Vanguard.Board3D
|
||||||
public float dragThresholdPixels = 7f;
|
public float dragThresholdPixels = 7f;
|
||||||
public float smoothMoveSpeed = 16f;
|
public float smoothMoveSpeed = 16f;
|
||||||
public float smoothRotateSpeed = 14f;
|
public float smoothRotateSpeed = 14f;
|
||||||
public float handHoverLift = 0.08f;
|
[Tooltip("Подъём карты при наведении, доля её высоты.")]
|
||||||
|
[Range(0f, 0.5f)] public float handHoverLift = 0.1f;
|
||||||
|
|
||||||
[Header("Рука на столе (fallback без камеры)")]
|
[Header("Рука на столе (fallback без камеры)")]
|
||||||
public float handCenterZ = -5.3f;
|
public float handCenterZ = -5.3f;
|
||||||
public float handHeight = 1.12f;
|
public float handHeight = 1.12f;
|
||||||
[Range(0f, 85f)] public float handTilt = 58f;
|
[Range(0f, 85f)] public float handTilt = 58f;
|
||||||
public float handSpacing = 0.42f;
|
public float handSpacing = 0.28f;
|
||||||
public float handMaxWidth = 7.4f;
|
public float handMaxWidth = 7.4f;
|
||||||
public float handArc = 0.035f;
|
public float handArc = 0.035f;
|
||||||
|
|
||||||
[Header("Рука в поле зрения")]
|
[Header("Рука в поле зрения")]
|
||||||
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
|
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
|
||||||
[Range(0.05f, 0.95f)] public float handViewportX = 0.44f;
|
[Range(0.05f, 0.95f)] public float handViewportX = 0.5f;
|
||||||
[Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
|
[Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
|
||||||
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
|
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
|
||||||
[Tooltip("Расстояние от камеры до веера руки, метры.")]
|
[Tooltip("Расстояние от камеры до веера руки, метры.")]
|
||||||
public float handViewDistance = 4.0f;
|
public float handViewDistance = 4.0f;
|
||||||
public float handViewMaxWidth = 1.4f;
|
public float handViewMaxWidth = 2.52f;
|
||||||
public float handViewScale = 0.5f;
|
public float handViewScale = 0.5f;
|
||||||
public float handViewRoll = 3.2f;
|
public float handViewRoll = 3.2f;
|
||||||
public float handViewArc = 0.02f;
|
public float handViewArc = 0.02f;
|
||||||
|
|
@ -67,10 +68,19 @@ namespace Vanguard.Board3D
|
||||||
public bool opponentHandEnabled = true;
|
public bool opponentHandEnabled = true;
|
||||||
public float opponentHandZ = 4.3f;
|
public float opponentHandZ = 4.3f;
|
||||||
public float opponentHandHeight = 0.75f;
|
public float opponentHandHeight = 0.75f;
|
||||||
public float opponentHandSpacing = 0.42f;
|
public float opponentHandSpacing = 0.28f;
|
||||||
public float opponentHandMaxWidth = 2.4f;
|
public float opponentHandMaxWidth = 3.78f;
|
||||||
public float opponentHandScale = 0.8f;
|
public float opponentHandScale = 0.9f;
|
||||||
public float opponentHandRoll = 2.5f;
|
public float opponentHandRoll = 5f;
|
||||||
|
public float opponentHandArc = 0.08f;
|
||||||
|
|
||||||
|
[Header("Рука противника в поле зрения")]
|
||||||
|
[Range(0.5f, 0.95f)] public float opponentHandViewportY = 0.76f;
|
||||||
|
public float opponentHandViewDistance = 6f;
|
||||||
|
public float opponentHandViewScale = 0.5f;
|
||||||
|
public float opponentHandViewMaxWidth = 2.4f;
|
||||||
|
public float opponentHandViewRoll = 3.2f;
|
||||||
|
public float opponentHandViewArc = 0.08f;
|
||||||
|
|
||||||
[Header("Круг защиты")]
|
[Header("Круг защиты")]
|
||||||
public float guardianRowGap = 0.55f;
|
public float guardianRowGap = 0.55f;
|
||||||
|
|
@ -80,6 +90,8 @@ namespace Vanguard.Board3D
|
||||||
[Header("Стопки")]
|
[Header("Стопки")]
|
||||||
[Tooltip("Расстояние от центра стола до зоны урона.")]
|
[Tooltip("Расстояние от центра стола до зоны урона.")]
|
||||||
public float damageX = 3.0f;
|
public float damageX = 3.0f;
|
||||||
|
[Tooltip("Сдвиг следующей карты урона в долях ширины карты. 0.3 = новая карта перекрывает 30% предыдущей.")]
|
||||||
|
[Range(0.1f, 0.9f)] public float damageStackOffset = 0.3f;
|
||||||
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
||||||
public float pileX = 2.95f;
|
public float pileX = 2.95f;
|
||||||
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
|
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
|
||||||
|
|
@ -95,6 +107,12 @@ namespace Vanguard.Board3D
|
||||||
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
|
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
|
||||||
public float cameraFov = 46f;
|
public float cameraFov = 46f;
|
||||||
|
|
||||||
|
[Header("Стартовая камера")]
|
||||||
|
[Tooltip("Начальная позиция: над стороной игрока, лицом к столу.")]
|
||||||
|
public Vector3 introCameraPosition = new Vector3(0f, 7.5f, -5.5f);
|
||||||
|
public Vector3 introCameraTarget = new Vector3(0f, 0.4f, 2f);
|
||||||
|
public float introCameraFov = 55f;
|
||||||
|
|
||||||
[Header("Палитра")]
|
[Header("Палитра")]
|
||||||
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
|
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
|
||||||
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
|
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
|
||||||
|
|
@ -156,6 +174,8 @@ namespace Vanguard.Board3D
|
||||||
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
|
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
|
||||||
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
|
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
|
||||||
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f);
|
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f);
|
||||||
|
damageStackOffset = Mathf.Clamp(damageStackOffset, 0.1f, 0.9f);
|
||||||
|
introCameraFov = Mathf.Clamp(introCameraFov, 20f, 90f);
|
||||||
handViewDistance = Mathf.Max(1f, handViewDistance);
|
handViewDistance = Mathf.Max(1f, handViewDistance);
|
||||||
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
|
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
|
||||||
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
|
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
|
||||||
|
|
@ -167,6 +187,7 @@ namespace Vanguard.Board3D
|
||||||
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
|
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
|
||||||
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
|
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
|
||||||
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
|
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
|
||||||
|
opponentHandArc = Mathf.Max(0f, opponentHandArc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,16 +239,22 @@ namespace Vanguard.Board3D
|
||||||
/// <summary>Текстура лица и акцентный цвет карты. Front может быть null.</summary>
|
/// <summary>Текстура лица и акцентный цвет карты. Front может быть null.</summary>
|
||||||
public sealed class BoardArt
|
public sealed class BoardArt
|
||||||
{
|
{
|
||||||
public BoardArt(Texture2D front, Color accent, string displayName = null)
|
public BoardArt(
|
||||||
|
Texture2D front,
|
||||||
|
Color accent,
|
||||||
|
string displayName = null,
|
||||||
|
string trigger = null)
|
||||||
{
|
{
|
||||||
Front = front;
|
Front = front;
|
||||||
Accent = accent;
|
Accent = accent;
|
||||||
DisplayName = displayName;
|
DisplayName = displayName;
|
||||||
|
Trigger = trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Texture2D Front { get; }
|
public Texture2D Front { get; }
|
||||||
public Color Accent { get; }
|
public Color Accent { get; }
|
||||||
public string DisplayName { get; }
|
public string DisplayName { get; }
|
||||||
|
public string Trigger { get; }
|
||||||
|
|
||||||
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
|
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,16 @@ namespace Vanguard.Board3D
|
||||||
var zSign = ZSign(localSeat);
|
var zSign = ZSign(localSeat);
|
||||||
var sideSign = localSeat == 0 ? -1f : 1f;
|
var sideSign = localSeat == 0 ? -1f : 1f;
|
||||||
var x = sideSign * _config.damageX;
|
var x = sideSign * _config.damageX;
|
||||||
|
// Карты лежат на боку: вертикальный шаг задаётся шириной карты.
|
||||||
|
// 0.3 — новая карта перекрывает 30% предыдущей.
|
||||||
|
var step = _config.cardWidth
|
||||||
|
* _config.cardScale
|
||||||
|
* _config.damageStackOffset;
|
||||||
var z = zSign * (_config.frontRowZ + 0.4f)
|
var z = zSign * (_config.frontRowZ + 0.4f)
|
||||||
+ zSign * index * 0.4f;
|
+ zSign * index * step;
|
||||||
return new Vector3(x, _config.DamageCenterY, z);
|
// Небольшой подъём по Y: новая карта рисуется поверх старой.
|
||||||
|
var y = _config.DamageCenterY + (index * 0.0015f);
|
||||||
|
return new Vector3(x, y, z);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Колода и дроп сбоку от поля, у своего края.</summary>
|
/// <summary>Колода и дроп сбоку от поля, у своего края.</summary>
|
||||||
|
|
@ -152,9 +159,11 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
var offset = index - (safeCount - 1) * 0.5f;
|
var offset = index - (safeCount - 1) * 0.5f;
|
||||||
var x = offset * spacing;
|
var x = offset * spacing;
|
||||||
var z = _config.opponentHandZ + Mathf.Abs(offset) * 0.015f;
|
// Как и своя рука: веер по X/Y/roll, а по глубине каждая
|
||||||
|
// следующая карта ближе к камере и рисуется поверх предыдущей.
|
||||||
|
var z = _config.opponentHandZ - (index * 0.05f);
|
||||||
var y = _config.opponentHandHeight
|
var y = _config.opponentHandHeight
|
||||||
- (1f - Mathf.Abs(offset) / safeCount) * 0.02f;
|
+ (1f - Mathf.Abs(offset) / safeCount) * _config.opponentHandArc;
|
||||||
var roll = -offset * _config.opponentHandRoll;
|
var roll = -offset * _config.opponentHandRoll;
|
||||||
return new HandPose(new Vector3(x, y, z), roll);
|
return new HandPose(new Vector3(x, y, z), roll);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,23 @@ namespace Vanguard.Board3D
|
||||||
new Vector3(halfW, 0f, halfH),
|
new Vector3(halfW, 0f, halfH),
|
||||||
new Vector3(-halfW, 0f, halfH),
|
new Vector3(-halfW, 0f, halfH),
|
||||||
},
|
},
|
||||||
uv = new[]
|
// Задняя грань смотрит вниз: без зеркального U рубашка
|
||||||
{
|
// читается отражённой, когда карта перевёрнута лицом вниз.
|
||||||
new Vector2(0f, 0f),
|
uv = front
|
||||||
new Vector2(1f, 0f),
|
? new[]
|
||||||
new Vector2(1f, 1f),
|
{
|
||||||
new Vector2(0f, 1f),
|
new Vector2(0f, 0f),
|
||||||
},
|
new Vector2(1f, 0f),
|
||||||
|
new Vector2(1f, 1f),
|
||||||
|
new Vector2(0f, 1f),
|
||||||
|
}
|
||||||
|
: new[]
|
||||||
|
{
|
||||||
|
new Vector2(1f, 0f),
|
||||||
|
new Vector2(0f, 0f),
|
||||||
|
new Vector2(0f, 1f),
|
||||||
|
new Vector2(1f, 1f),
|
||||||
|
},
|
||||||
normals = new[] { normal, normal, normal, normal },
|
normals = new[] { normal, normal, normal, normal },
|
||||||
triangles = new[]
|
triangles = new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,36 @@ namespace Vanguard.Board3D
|
||||||
view._renderer = renderer;
|
view._renderer = renderer;
|
||||||
view.ApplyColor(false);
|
view.ApplyColor(false);
|
||||||
|
|
||||||
|
// Тёмная пластина под рамкой: слот читается как площадка, а не
|
||||||
|
// как пустой контур. Цвет — синий для R, тёплый для V.
|
||||||
|
var plate = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||||
|
plate.name = "Slot Plate";
|
||||||
|
var plateCollider = plate.GetComponent<Collider>();
|
||||||
|
if (plateCollider != null)
|
||||||
|
{
|
||||||
|
if (Application.isPlaying)
|
||||||
|
Object.Destroy(plateCollider);
|
||||||
|
else
|
||||||
|
Object.DestroyImmediate(plateCollider);
|
||||||
|
}
|
||||||
|
|
||||||
|
plate.transform.SetParent(go.transform, false);
|
||||||
|
plate.transform.localPosition = new Vector3(0f, -0.003f, 0f);
|
||||||
|
plate.transform.localRotation = Quaternion.identity;
|
||||||
|
plate.transform.localScale = new Vector3(
|
||||||
|
config.cardWidth * 1.24f,
|
||||||
|
0.004f,
|
||||||
|
config.cardHeight * 1.24f);
|
||||||
|
var plateRenderer = plate.GetComponent<MeshRenderer>();
|
||||||
|
plateRenderer.sharedMaterial = BoardMaterials.Highlight;
|
||||||
|
BoardMaterials.SetShadows(plateRenderer, false);
|
||||||
|
var plateBlock = new MaterialPropertyBlock();
|
||||||
|
var plateColor = slot == PlaymatLayout.VanguardSlot
|
||||||
|
? new Color(0.10f, 0.07f, 0.045f, 1f)
|
||||||
|
: new Color(0.035f, 0.07f, 0.13f, 1f);
|
||||||
|
BoardMaterials.SetColor(plateBlock, plateRenderer.sharedMaterial, plateColor);
|
||||||
|
plateRenderer.SetPropertyBlock(plateBlock);
|
||||||
|
|
||||||
var collider = go.AddComponent<BoxCollider>();
|
var collider = go.AddComponent<BoxCollider>();
|
||||||
collider.size = new Vector3(
|
collider.size = new Vector3(
|
||||||
config.cardWidth * 1.12f,
|
config.cardWidth * 1.12f,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ namespace Vanguard.Board3D
|
||||||
private MeshRenderer _front;
|
private MeshRenderer _front;
|
||||||
private MeshRenderer _back;
|
private MeshRenderer _back;
|
||||||
private MeshRenderer _edge;
|
private MeshRenderer _edge;
|
||||||
|
private MeshRenderer _frame;
|
||||||
private MeshRenderer _highlight;
|
private MeshRenderer _highlight;
|
||||||
private BoxCollider _collider;
|
private BoxCollider _collider;
|
||||||
private MaterialPropertyBlock _block;
|
private MaterialPropertyBlock _block;
|
||||||
|
|
@ -35,6 +36,20 @@ namespace Vanguard.Board3D
|
||||||
private float _arcElapsed;
|
private float _arcElapsed;
|
||||||
private float _arcHeight;
|
private float _arcHeight;
|
||||||
|
|
||||||
|
private bool _revealActive;
|
||||||
|
private float _revealElapsed;
|
||||||
|
private float _revealDuration;
|
||||||
|
private int _revealDirection;
|
||||||
|
private Vector3 _revealFromPosition;
|
||||||
|
private Vector3 _revealFromScale;
|
||||||
|
|
||||||
|
// Пока запрос ждёт в очереди CardMoveAnimator, карта не должна
|
||||||
|
// плавно двигаться к target — иначе она окажется у цели до дуги.
|
||||||
|
private bool _hold;
|
||||||
|
private Vector3 _holdPosition;
|
||||||
|
private Quaternion _holdRotation;
|
||||||
|
private Vector3 _holdScale;
|
||||||
|
|
||||||
private bool _dragging;
|
private bool _dragging;
|
||||||
private Vector3 _dragOriginPosition;
|
private Vector3 _dragOriginPosition;
|
||||||
private Quaternion _dragOriginRotation;
|
private Quaternion _dragOriginRotation;
|
||||||
|
|
@ -48,9 +63,20 @@ namespace Vanguard.Board3D
|
||||||
public Vector3 TargetPosition => _targetPosition;
|
public Vector3 TargetPosition => _targetPosition;
|
||||||
public float TargetYaw => _targetRotation.eulerAngles.y;
|
public float TargetYaw => _targetRotation.eulerAngles.y;
|
||||||
public Quaternion TargetRotation => _targetRotation;
|
public Quaternion TargetRotation => _targetRotation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Переворот рубашкой вверх с сохранением вертикальной ориентации
|
||||||
|
/// рисунка рубашки.
|
||||||
|
/// </summary>
|
||||||
|
public static Quaternion FaceDownFlip =>
|
||||||
|
Quaternion.Euler(0f, 180f, 0f) * Quaternion.Euler(180f, 0f, 0f);
|
||||||
|
|
||||||
|
/// <summary>Поза рубашкой вверх для текущей базовой ориентации.</summary>
|
||||||
|
public Quaternion FaceDownRotation => _baseRotation * FaceDownFlip;
|
||||||
public bool IsAnimating =>
|
public bool IsAnimating =>
|
||||||
!_dragging
|
!_dragging
|
||||||
&& (_arcMoving
|
&& (_arcMoving
|
||||||
|
|| _revealActive
|
||||||
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
||||||
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
||||||
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
||||||
|
|
@ -97,6 +123,21 @@ namespace Vanguard.Board3D
|
||||||
_front.sharedMaterial = BoardMaterials.White;
|
_front.sharedMaterial = BoardMaterials.White;
|
||||||
BoardMaterials.SetShadows(_front, false);
|
BoardMaterials.SetShadows(_front, false);
|
||||||
|
|
||||||
|
var frameGo = new GameObject("Frame");
|
||||||
|
frameGo.transform.SetParent(transform, false);
|
||||||
|
frameGo.transform.localPosition = new Vector3(
|
||||||
|
0f,
|
||||||
|
(thickness * 0.5f) + 0.0012f,
|
||||||
|
0f);
|
||||||
|
var frameFilter = frameGo.AddComponent<MeshFilter>();
|
||||||
|
frameFilter.sharedMesh = BoardMeshLibrary.Frame(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
Mathf.Max(0.006f, width * 0.009f));
|
||||||
|
_frame = frameGo.AddComponent<MeshRenderer>();
|
||||||
|
_frame.sharedMaterial = BoardMaterials.Highlight;
|
||||||
|
BoardMaterials.SetShadows(_frame, false);
|
||||||
|
|
||||||
var backGo = new GameObject("Back");
|
var backGo = new GameObject("Back");
|
||||||
backGo.transform.SetParent(transform, false);
|
backGo.transform.SetParent(transform, false);
|
||||||
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
|
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
|
||||||
|
|
@ -108,12 +149,15 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
var highlightGo = new GameObject("Highlight");
|
var highlightGo = new GameObject("Highlight");
|
||||||
highlightGo.transform.SetParent(transform, false);
|
highlightGo.transform.SetParent(transform, false);
|
||||||
highlightGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0015f, 0f);
|
highlightGo.transform.localPosition = new Vector3(
|
||||||
|
0f,
|
||||||
|
(thickness * 0.5f) + 0.0018f,
|
||||||
|
0f);
|
||||||
var filter = highlightGo.AddComponent<MeshFilter>();
|
var filter = highlightGo.AddComponent<MeshFilter>();
|
||||||
filter.sharedMesh = BoardMeshLibrary.Frame(
|
filter.sharedMesh = BoardMeshLibrary.Frame(
|
||||||
width * 1.16f,
|
width,
|
||||||
height * 1.16f,
|
height,
|
||||||
Mathf.Max(0.025f, width * 0.035f));
|
Mathf.Max(0.006f, width * 0.009f));
|
||||||
_highlight = highlightGo.AddComponent<MeshRenderer>();
|
_highlight = highlightGo.AddComponent<MeshRenderer>();
|
||||||
_highlight.sharedMaterial = BoardMaterials.Highlight;
|
_highlight.sharedMaterial = BoardMaterials.Highlight;
|
||||||
BoardMaterials.SetShadows(_highlight, false);
|
BoardMaterials.SetShadows(_highlight, false);
|
||||||
|
|
@ -122,7 +166,7 @@ namespace Vanguard.Board3D
|
||||||
_stats = BoardText.Create(
|
_stats = BoardText.Create(
|
||||||
transform,
|
transform,
|
||||||
string.Empty,
|
string.Empty,
|
||||||
0.028f,
|
0.034f,
|
||||||
Color.white,
|
Color.white,
|
||||||
Vector3.zero);
|
Vector3.zero);
|
||||||
_stats.gameObject.SetActive(false);
|
_stats.gameObject.SetActive(false);
|
||||||
|
|
@ -140,7 +184,9 @@ namespace Vanguard.Board3D
|
||||||
name = "Card3D " + reference;
|
name = "Card3D " + reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetArt(Texture2D texture, Color accent)
|
public void SetArt(
|
||||||
|
Texture2D texture,
|
||||||
|
Color accent)
|
||||||
{
|
{
|
||||||
if (_front == null)
|
if (_front == null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -156,6 +202,19 @@ namespace Vanguard.Board3D
|
||||||
_front.sharedMaterial,
|
_front.sharedMaterial,
|
||||||
texture != null ? Color.white : accent);
|
texture != null ? Color.white : accent);
|
||||||
_front.SetPropertyBlock(_block);
|
_front.SetPropertyBlock(_block);
|
||||||
|
|
||||||
|
if (_frame != null)
|
||||||
|
{
|
||||||
|
_frame.GetPropertyBlock(_block);
|
||||||
|
_block.Clear();
|
||||||
|
BoardMaterials.SetColor(
|
||||||
|
_block,
|
||||||
|
_frame.sharedMaterial,
|
||||||
|
new Color(0.015f, 0.015f, 0.018f, 1f));
|
||||||
|
_frame.SetPropertyBlock(_block);
|
||||||
|
_frame.gameObject.SetActive(Ref.Zone != BoardZone.Hand);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
|
public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
|
||||||
|
|
@ -190,7 +249,7 @@ namespace Vanguard.Board3D
|
||||||
_stats.transform.position = new Vector3(
|
_stats.transform.position = new Vector3(
|
||||||
position.x,
|
position.x,
|
||||||
position.y + 0.015f,
|
position.y + 0.015f,
|
||||||
position.z - (_config.cardHeight * 0.62f));
|
position.z - (_config.cardHeight * 0.68f));
|
||||||
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
|
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +266,13 @@ namespace Vanguard.Board3D
|
||||||
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
|
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
|
||||||
var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f;
|
var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f;
|
||||||
var rest = Quaternion.Euler(0f, restYaw, 0f);
|
var rest = Quaternion.Euler(0f, restYaw, 0f);
|
||||||
var flip = Quaternion.Euler(faceUp ? 0f : 180f, 0f, 0f);
|
// Урон переворачивается вокруг длинной стороны карты, а не
|
||||||
|
// поперёк неё. Обычная рубашка сохраняет вертикальную ориентацию.
|
||||||
|
var flip = faceUp
|
||||||
|
? Quaternion.identity
|
||||||
|
: damageLayout
|
||||||
|
? Quaternion.Euler(0f, 0f, 180f)
|
||||||
|
: FaceDownFlip;
|
||||||
_targetRotation = rest * baseRotation * flip;
|
_targetRotation = rest * baseRotation * flip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,11 +305,60 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
_arcMoving = false;
|
_arcMoving = false;
|
||||||
_immediate = false;
|
_immediate = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
transform.position = position;
|
transform.position = position;
|
||||||
transform.rotation = rotation;
|
transform.rotation = rotation;
|
||||||
transform.localScale = scale;
|
transform.localScale = scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void HoldAtSource(
|
||||||
|
Vector3 position,
|
||||||
|
Quaternion rotation,
|
||||||
|
Vector3 scale)
|
||||||
|
{
|
||||||
|
_hold = true;
|
||||||
|
_holdPosition = position;
|
||||||
|
_holdRotation = rotation;
|
||||||
|
_holdScale = scale;
|
||||||
|
_immediate = false;
|
||||||
|
_revealActive = false;
|
||||||
|
transform.position = position;
|
||||||
|
transform.rotation = rotation;
|
||||||
|
transform.localScale = scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CancelHold()
|
||||||
|
{
|
||||||
|
_hold = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reveal(float duration, int direction = 1)
|
||||||
|
{
|
||||||
|
if (duration <= 0.001f)
|
||||||
|
{
|
||||||
|
Snap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dragging = false;
|
||||||
|
_arcMoving = false;
|
||||||
|
_hold = false;
|
||||||
|
_immediate = false;
|
||||||
|
_revealActive = true;
|
||||||
|
_revealElapsed = 0f;
|
||||||
|
_revealDuration = Mathf.Max(0.01f, duration);
|
||||||
|
_revealDirection = direction < 0 ? -1 : 1;
|
||||||
|
_revealFromPosition = _targetPosition + (Vector3.up * 0.12f);
|
||||||
|
_revealFromScale = _targetScale * 0.85f;
|
||||||
|
|
||||||
|
// Начинаем с текущей позы рубашкой вверх и переворачиваем
|
||||||
|
// вокруг длинной оси карты: слева направо, у противника зеркально.
|
||||||
|
transform.position = _revealFromPosition;
|
||||||
|
transform.rotation = _baseRotation * FaceDownFlip;
|
||||||
|
transform.localScale = _revealFromScale;
|
||||||
|
}
|
||||||
|
|
||||||
public void StartArcMove(
|
public void StartArcMove(
|
||||||
Vector3 fromPosition,
|
Vector3 fromPosition,
|
||||||
Quaternion fromRotation,
|
Quaternion fromRotation,
|
||||||
|
|
@ -257,6 +371,8 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
_arcMoving = false;
|
_arcMoving = false;
|
||||||
_immediate = false;
|
_immediate = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
|
|
||||||
var distance = Vector3.Distance(fromPosition, _targetPosition);
|
var distance = Vector3.Distance(fromPosition, _targetPosition);
|
||||||
var angle = Quaternion.Angle(fromRotation, _targetRotation);
|
var angle = Quaternion.Angle(fromRotation, _targetRotation);
|
||||||
|
|
@ -283,10 +399,12 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
public void StopMove(bool snap)
|
public void StopMove(bool snap)
|
||||||
{
|
{
|
||||||
if (!_arcMoving)
|
if (!_arcMoving && !_hold && !_revealActive)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_arcMoving = false;
|
_arcMoving = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
if (snap)
|
if (snap)
|
||||||
Snap();
|
Snap();
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +436,8 @@ namespace Vanguard.Board3D
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_dragging = true;
|
_dragging = true;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
_dragOriginPosition = transform.position;
|
_dragOriginPosition = transform.position;
|
||||||
_dragOriginRotation = transform.rotation;
|
_dragOriginRotation = transform.rotation;
|
||||||
_targetScale = _baseScale * 1.05f;
|
_targetScale = _baseScale * 1.05f;
|
||||||
|
|
@ -326,23 +446,53 @@ namespace Vanguard.Board3D
|
||||||
SetHighlight(null);
|
SetHighlight(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public float DragLift => Ref.Zone == BoardZone.Hand
|
||||||
|
? _config.cardHeight * _baseScale.x * _config.handHoverLift
|
||||||
|
: _config.dragLift;
|
||||||
|
|
||||||
public void DragTo(Vector3 worldPosition)
|
public void DragTo(Vector3 worldPosition)
|
||||||
{
|
{
|
||||||
if (!_dragging)
|
if (!_dragging)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var yaw = _targetRotation.eulerAngles.y;
|
// Сохраняем исходную ориентацию карты: карта из руки остаётся
|
||||||
|
// лицом к игроку, а не «ложится» на стол при перетаскивании.
|
||||||
|
// Высота та же, что у hover: 10% высоты карты.
|
||||||
transform.position = new Vector3(
|
transform.position = new Vector3(
|
||||||
worldPosition.x,
|
worldPosition.x,
|
||||||
_config.dragLift + (_config.cardThickness * 0.5f),
|
DragLift + (_config.cardThickness * 0.5f),
|
||||||
worldPosition.z);
|
worldPosition.z);
|
||||||
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
|
transform.rotation = _dragOriginRotation;
|
||||||
transform.localScale = _baseScale * 1.05f;
|
transform.localScale = _baseScale * 1.05f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отпустить карту в текущей точке drag-а, не возвращая её в руку.
|
||||||
|
/// Держим её поднятой до ответа сервера или явного возврата.
|
||||||
|
/// </summary>
|
||||||
|
public void EndDragAtCurrent()
|
||||||
|
{
|
||||||
|
_dragging = false;
|
||||||
|
if (_collider != null)
|
||||||
|
_collider.enabled = true;
|
||||||
|
|
||||||
|
_hold = true;
|
||||||
|
_holdPosition = transform.position;
|
||||||
|
_holdRotation = transform.rotation;
|
||||||
|
_holdScale = transform.localScale;
|
||||||
|
_targetPosition = _holdPosition;
|
||||||
|
_targetRotation = _holdRotation;
|
||||||
|
_targetScale = _holdScale;
|
||||||
|
_arcMoving = false;
|
||||||
|
_revealActive = false;
|
||||||
|
_immediate = false;
|
||||||
|
}
|
||||||
|
|
||||||
public void DropAt(Vector3 position, Quaternion rotation)
|
public void DropAt(Vector3 position, Quaternion rotation)
|
||||||
{
|
{
|
||||||
_dragging = false;
|
_dragging = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
if (_collider != null)
|
if (_collider != null)
|
||||||
_collider.enabled = true;
|
_collider.enabled = true;
|
||||||
_targetPosition = position;
|
_targetPosition = position;
|
||||||
|
|
@ -354,6 +504,8 @@ namespace Vanguard.Board3D
|
||||||
public void ReturnToOrigin()
|
public void ReturnToOrigin()
|
||||||
{
|
{
|
||||||
_dragging = false;
|
_dragging = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
if (_collider != null)
|
if (_collider != null)
|
||||||
_collider.enabled = true;
|
_collider.enabled = true;
|
||||||
_targetPosition = _dragOriginPosition;
|
_targetPosition = _dragOriginPosition;
|
||||||
|
|
@ -365,6 +517,8 @@ namespace Vanguard.Board3D
|
||||||
public void Snap()
|
public void Snap()
|
||||||
{
|
{
|
||||||
_arcMoving = false;
|
_arcMoving = false;
|
||||||
|
_hold = false;
|
||||||
|
_revealActive = false;
|
||||||
transform.position = _targetPosition;
|
transform.position = _targetPosition;
|
||||||
transform.rotation = _targetRotation;
|
transform.rotation = _targetRotation;
|
||||||
transform.localScale = _targetScale;
|
transform.localScale = _targetScale;
|
||||||
|
|
@ -376,6 +530,36 @@ namespace Vanguard.Board3D
|
||||||
if (_dragging || deltaTime <= 0f)
|
if (_dragging || deltaTime <= 0f)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_revealActive)
|
||||||
|
{
|
||||||
|
_revealElapsed += deltaTime;
|
||||||
|
var t = Mathf.Clamp01(_revealElapsed / _revealDuration);
|
||||||
|
var eased = t * t * (3f - (2f * t));
|
||||||
|
var angle = 180f * eased * _revealDirection;
|
||||||
|
transform.position = Vector3.Lerp(
|
||||||
|
_revealFromPosition,
|
||||||
|
_targetPosition,
|
||||||
|
eased);
|
||||||
|
transform.rotation = _baseRotation
|
||||||
|
* Quaternion.Euler(0f, 0f, angle)
|
||||||
|
* FaceDownFlip;
|
||||||
|
transform.localScale = Vector3.Lerp(
|
||||||
|
_revealFromScale,
|
||||||
|
_targetScale,
|
||||||
|
eased);
|
||||||
|
|
||||||
|
if (t >= 1f)
|
||||||
|
{
|
||||||
|
_revealActive = false;
|
||||||
|
Snap();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hold && !_arcMoving)
|
||||||
|
return;
|
||||||
|
|
||||||
if (_arcMoving)
|
if (_arcMoving)
|
||||||
{
|
{
|
||||||
_arcElapsed += deltaTime;
|
_arcElapsed += deltaTime;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
using Vanguard.Board3D;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
|
|
||||||
|
namespace Vanguard.UI.Overlays
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Компактная панель игрока: Колода, Душа, Рука, Сброс.
|
||||||
|
/// Все блоки одинаковые; кликабельны только Душа и Сброс.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlayerStatsPanelView
|
||||||
|
{
|
||||||
|
private sealed class StatEntry
|
||||||
|
{
|
||||||
|
public BoardZone Zone;
|
||||||
|
public Text Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly List<StatEntry> _stats = new List<StatEntry>();
|
||||||
|
private RectTransform _root;
|
||||||
|
private Text _header;
|
||||||
|
|
||||||
|
public RectTransform Root => _root;
|
||||||
|
public event Action<BoardZone> ZoneClicked;
|
||||||
|
|
||||||
|
public void Build(
|
||||||
|
Transform parent,
|
||||||
|
string name,
|
||||||
|
bool self,
|
||||||
|
Vector2 anchor,
|
||||||
|
Vector2 pivot,
|
||||||
|
Vector2 position,
|
||||||
|
Vector2 size)
|
||||||
|
{
|
||||||
|
_root = UiKit.CreatePanel(
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
new Color(0.02f, 0.035f, 0.055f, 0.9f));
|
||||||
|
_root.anchorMin = anchor;
|
||||||
|
_root.anchorMax = anchor;
|
||||||
|
_root.pivot = pivot;
|
||||||
|
_root.anchoredPosition = position;
|
||||||
|
_root.sizeDelta = size;
|
||||||
|
|
||||||
|
var image = _root.GetComponent<Image>();
|
||||||
|
if (image != null)
|
||||||
|
image.raycastTarget = false;
|
||||||
|
|
||||||
|
UiKit.AddVerticalLayout(_root, 8, 4);
|
||||||
|
|
||||||
|
_header = UiKit.CreateText(
|
||||||
|
_root,
|
||||||
|
"Header",
|
||||||
|
self ? "ВЫ" : "ПРОТИВНИК",
|
||||||
|
13,
|
||||||
|
new Color(0.86f, 0.91f, 1f),
|
||||||
|
TextAnchor.MiddleLeft);
|
||||||
|
_header.fontStyle = FontStyle.Bold;
|
||||||
|
UiKit.MakeLiteral(_header);
|
||||||
|
UiKit.SetPreferredHeight(_header, 20f);
|
||||||
|
|
||||||
|
var stats = UiKit.CreatePanel(_root, "Stats", Color.clear);
|
||||||
|
UiKit.AddHorizontalLayout(stats, 0, 4);
|
||||||
|
UiKit.SetPreferredHeight(stats, 42f);
|
||||||
|
|
||||||
|
AddStat(stats, BoardZone.Deck, "Колода");
|
||||||
|
AddStat(stats, BoardZone.Soul, "Душа", clickable: true);
|
||||||
|
AddStat(stats, BoardZone.Hand, "Рука");
|
||||||
|
AddStat(stats, BoardZone.Drop, "Сброс", clickable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddStat(
|
||||||
|
RectTransform parent,
|
||||||
|
BoardZone zone,
|
||||||
|
string label,
|
||||||
|
bool clickable = false)
|
||||||
|
{
|
||||||
|
var block = UiKit.CreatePanel(
|
||||||
|
parent,
|
||||||
|
"Stat_" + zone,
|
||||||
|
new Color(0.06f, 0.09f, 0.14f, 0.85f));
|
||||||
|
var image = block.GetComponent<Image>();
|
||||||
|
if (image != null)
|
||||||
|
image.raycastTarget = clickable;
|
||||||
|
|
||||||
|
UiKit.AddVerticalLayout(block, 2, 0);
|
||||||
|
UiKit.SetPreferredHeight(block, 40f);
|
||||||
|
|
||||||
|
var caption = UiKit.CreateText(
|
||||||
|
block,
|
||||||
|
"Caption",
|
||||||
|
label,
|
||||||
|
9,
|
||||||
|
new Color(0.62f, 0.70f, 0.86f));
|
||||||
|
UiKit.MakeLiteral(caption);
|
||||||
|
UiKit.SetPreferredHeight(caption, 12f);
|
||||||
|
|
||||||
|
var value = UiKit.CreateText(
|
||||||
|
block,
|
||||||
|
"Value",
|
||||||
|
"0",
|
||||||
|
15,
|
||||||
|
Color.white);
|
||||||
|
value.fontStyle = FontStyle.Bold;
|
||||||
|
UiKit.SetPreferredHeight(value, 18f);
|
||||||
|
|
||||||
|
if (clickable)
|
||||||
|
{
|
||||||
|
var button = block.gameObject.AddComponent<Button>();
|
||||||
|
button.targetGraphic = image;
|
||||||
|
button.onClick.AddListener(() => ZoneClicked?.Invoke(zone));
|
||||||
|
}
|
||||||
|
|
||||||
|
_stats.Add(new StatEntry { Zone = zone, Value = value });
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(PlayerView player, bool self)
|
||||||
|
{
|
||||||
|
if (_root == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_header.text = (self ? "ВЫ" : "ПРОТИВНИК")
|
||||||
|
+ (player != null && !string.IsNullOrEmpty(player.Name)
|
||||||
|
? " · " + player.Name
|
||||||
|
: string.Empty);
|
||||||
|
|
||||||
|
var hand = player != null
|
||||||
|
? Mathf.Max(player.HandCount, player.Hand?.Count ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
Set(BoardZone.Deck, player?.DeckCount ?? 0);
|
||||||
|
Set(BoardZone.Soul, player?.Soul?.Count ?? 0);
|
||||||
|
Set(BoardZone.Hand, hand);
|
||||||
|
Set(BoardZone.Drop, player?.Drop?.Count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Set(BoardZone zone, int value)
|
||||||
|
{
|
||||||
|
foreach (var stat in _stats)
|
||||||
|
{
|
||||||
|
if (stat.Zone == zone && stat.Value != null)
|
||||||
|
{
|
||||||
|
stat.Value.text = value.ToString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1b9c336ae1690a44dbdc24ee68be4d4d
|
||||||
|
|
@ -162,32 +162,24 @@ namespace Vanguard.Tests
|
||||||
.First(go => go.name == "BattleScreen");
|
.First(go => go.name == "BattleScreen");
|
||||||
screen.SetActive(true);
|
screen.SetActive(true);
|
||||||
|
|
||||||
var self = ReferenceRect(FindRect("ZoneBlocksSelf"));
|
var self = ReferenceRect(FindRect("PlayerStatsSelf"));
|
||||||
var opponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
|
var opponent = ReferenceRect(FindRect("PlayerStatsOpponent"));
|
||||||
var phase = ReferenceRect(FindRect("PhasePanel"));
|
|
||||||
var stats = ReferenceRect(FindRect("CombatStats"));
|
var stats = ReferenceRect(FindRect("CombatStats"));
|
||||||
|
|
||||||
AssertWithinScreen(self, "self zones");
|
AssertWithinScreen(self, "self stats");
|
||||||
AssertWithinScreen(opponent, "opponent zones");
|
AssertWithinScreen(opponent, "opponent stats");
|
||||||
AssertWithinScreen(phase, "phase panel");
|
|
||||||
AssertWithinScreen(stats, "combat stats");
|
AssertWithinScreen(stats, "combat stats");
|
||||||
Assert.IsFalse(self.Overlaps(opponent));
|
Assert.IsFalse(self.Overlaps(opponent));
|
||||||
Assert.IsFalse(self.Overlaps(phase));
|
|
||||||
Assert.IsFalse(opponent.Overlaps(phase));
|
|
||||||
Assert.IsFalse(opponent.Overlaps(stats));
|
Assert.IsFalse(opponent.Overlaps(stats));
|
||||||
Assert.IsFalse(phase.Overlaps(stats));
|
|
||||||
|
|
||||||
var toggle = Resources.FindObjectsOfTypeAll<Button>()
|
var toggle = Resources.FindObjectsOfTypeAll<Button>()
|
||||||
.First(button => button.name == "ToggleIntentPanel"
|
.First(button => button.name == "ToggleIntentPanel"
|
||||||
&& button.gameObject.scene.IsValid());
|
&& button.gameObject.scene.IsValid());
|
||||||
toggle.onClick.Invoke();
|
toggle.onClick.Invoke();
|
||||||
|
|
||||||
var movedOpponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
|
var debug = FindRect("DebugPanel");
|
||||||
var movedPhase = ReferenceRect(FindRect("PhasePanel"));
|
Assert.IsTrue(debug.gameObject.activeSelf, "Debug panel should open by toggle");
|
||||||
Assert.Greater(movedOpponent.xMin, opponent.xMin);
|
AssertWithinScreen(ReferenceRect(debug), "debug panel");
|
||||||
Assert.Greater(movedPhase.xMin, phase.xMin);
|
|
||||||
AssertWithinScreen(movedOpponent, "opponent zones after hide");
|
|
||||||
AssertWithinScreen(movedPhase, "phase panel after hide");
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -228,28 +220,28 @@ namespace Vanguard.Tests
|
||||||
var usedField = typeof(BattleView)
|
var usedField = typeof(BattleView)
|
||||||
.GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
.GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
var rebuild = typeof(BattleView)
|
var rebuild = typeof(BattleView)
|
||||||
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
|
|
||||||
viewField.SetValue(battle, match);
|
viewField.SetValue(battle, match);
|
||||||
|
|
||||||
usedField.SetValue(battle, true);
|
usedField.SetValue(battle, true);
|
||||||
rebuild.Invoke(battle, new object[] { match });
|
rebuild.Invoke(battle, new object[] { match });
|
||||||
Assert.IsFalse(
|
Assert.IsFalse(
|
||||||
FindRect("PhasePanel").gameObject.activeSelf,
|
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||||
"Phase panel must disappear after the mulligan exchange");
|
"Confirmation bar must disappear after the mulligan exchange");
|
||||||
Assert.IsFalse(
|
Assert.IsFalse(
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.Any(go => go.name == "Phase_FinishMulliganIntent"),
|
.Any(go => go.name == "Intent_FinishMulliganIntent"),
|
||||||
"Finish mulligan button must not remain after the exchange");
|
"Finish mulligan button must not remain after the exchange");
|
||||||
|
|
||||||
usedField.SetValue(battle, false);
|
usedField.SetValue(battle, false);
|
||||||
rebuild.Invoke(battle, new object[] { match });
|
rebuild.Invoke(battle, new object[] { match });
|
||||||
Assert.IsTrue(
|
Assert.IsTrue(
|
||||||
FindRect("PhasePanel").gameObject.activeSelf,
|
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||||
"Finish mulligan button must be available before the exchange");
|
"Finish mulligan button must be available before the exchange");
|
||||||
Assert.IsTrue(
|
Assert.IsTrue(
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.Any(go => go.name == "Phase_FinishMulliganIntent"));
|
.Any(go => go.name == "Intent_FinishMulliganIntent"));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -259,7 +251,7 @@ namespace Vanguard.Tests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Guard_Control_Is_One_Button_In_Action_Panel()
|
public void Guard_Control_Is_One_Button_In_Confirmation_Bar()
|
||||||
{
|
{
|
||||||
var config = new AppConfig();
|
var config = new AppConfig();
|
||||||
config.Load();
|
config.Load();
|
||||||
|
|
@ -290,13 +282,13 @@ namespace Vanguard.Tests
|
||||||
var viewField = typeof(BattleView)
|
var viewField = typeof(BattleView)
|
||||||
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
var rebuild = typeof(BattleView)
|
var rebuild = typeof(BattleView)
|
||||||
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
viewField.SetValue(battle, match);
|
viewField.SetValue(battle, match);
|
||||||
rebuild.Invoke(battle, new object[] { match });
|
rebuild.Invoke(battle, new object[] { match });
|
||||||
|
|
||||||
Assert.IsTrue(
|
Assert.IsTrue(
|
||||||
FindRect("PhasePanel").gameObject.activeSelf,
|
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||||
"Guard controls must be in the action panel");
|
"Guard controls must be in the confirmation bar");
|
||||||
var guardButton = Resources.FindObjectsOfTypeAll<Button>()
|
var guardButton = Resources.FindObjectsOfTypeAll<Button>()
|
||||||
.FirstOrDefault(button => button.name == "Phase_Guard");
|
.FirstOrDefault(button => button.name == "Phase_Guard");
|
||||||
Assert.IsNotNull(guardButton);
|
Assert.IsNotNull(guardButton);
|
||||||
|
|
@ -311,10 +303,6 @@ namespace Vanguard.Tests
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.Any(go => go.name == "Phase_PassGuardIntent"),
|
.Any(go => go.name == "Phase_PassGuardIntent"),
|
||||||
"Pass guard must be the same single button");
|
"Pass guard must be the same single button");
|
||||||
Assert.AreEqual(
|
|
||||||
"ДЕЙСТВИЯ",
|
|
||||||
Resources.FindObjectsOfTypeAll<Text>()
|
|
||||||
.First(text => text.name == "PhaseTitle").text);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -762,17 +750,17 @@ namespace Vanguard.Tests
|
||||||
&& card.Ref.InstanceId == 7));
|
&& card.Ref.InstanceId == 7));
|
||||||
|
|
||||||
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.FirstOrDefault(go => go.name == "ZoneBlocksSelf");
|
.FirstOrDefault(go => go.name == "PlayerStatsSelf");
|
||||||
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
.FirstOrDefault(go => go.name == "ZoneBlocksOpponent");
|
.FirstOrDefault(go => go.name == "PlayerStatsOpponent");
|
||||||
Assert.IsNotNull(selfZoneRoot);
|
Assert.IsNotNull(selfZoneRoot);
|
||||||
Assert.IsNotNull(opponentZoneRoot);
|
Assert.IsNotNull(opponentZoneRoot);
|
||||||
|
|
||||||
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
|
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
|
||||||
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
|
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
|
||||||
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Рука 1")));
|
Assert.IsTrue(selfLabels.Any(text => text.text == "Рука"));
|
||||||
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Душа 1")));
|
Assert.IsTrue(selfLabels.Any(text => text.text.StartsWith("Душа")));
|
||||||
Assert.IsTrue(opponentLabels.Any(text => text.text.Contains("Рука 0")));
|
Assert.IsTrue(opponentLabels.Any(text => text.text == "Рука"));
|
||||||
|
|
||||||
Cleanup();
|
Cleanup();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Vanguard.Battle;
|
using Vanguard.Battle;
|
||||||
|
|
@ -75,7 +76,7 @@ namespace Vanguard.Tests
|
||||||
&& card.Ref.Slot == 3);
|
&& card.Ref.Slot == 3);
|
||||||
Assert.IsTrue(rested.IsRested);
|
Assert.IsTrue(rested.IsRested);
|
||||||
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
|
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
|
||||||
Assert.AreEqual("P8000 C1", rested.StatsText);
|
Assert.AreEqual("\u25B28000", rested.StatsText);
|
||||||
|
|
||||||
var hiddenDamage = cards.Single(card =>
|
var hiddenDamage = cards.Single(card =>
|
||||||
card.Ref.Zone == BoardZone.Damage
|
card.Ref.Zone == BoardZone.Damage
|
||||||
|
|
@ -186,7 +187,611 @@ namespace Vanguard.Tests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Trigger_Zone_Uses_Local_Plus_And_Minus_Ninety_Degrees()
|
public void Hands_Fill_Mirrored_And_Newest_Is_On_Top()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var cameraGo = new GameObject("Main Camera", typeof(Camera))
|
||||||
|
{
|
||||||
|
tag = "MainCamera",
|
||||||
|
};
|
||||||
|
var camera = cameraGo.GetComponent<Camera>();
|
||||||
|
camera.fieldOfView = config.cameraFov;
|
||||||
|
camera.transform.position = config.cameraPosition;
|
||||||
|
camera.transform.rotation = Quaternion.LookRotation(
|
||||||
|
config.cameraTarget - config.cameraPosition,
|
||||||
|
Vector3.up);
|
||||||
|
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(BuildView(you: 0), new BoardRenderState());
|
||||||
|
|
||||||
|
var player = ActiveCards()
|
||||||
|
.Where(card => card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0)
|
||||||
|
.OrderBy(card => card.Ref.Index)
|
||||||
|
.ToList();
|
||||||
|
var opponent = ActiveCards()
|
||||||
|
.Where(card => card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 1)
|
||||||
|
.OrderBy(card => card.Ref.Index)
|
||||||
|
.ToList();
|
||||||
|
Assert.AreEqual(2, player.Count);
|
||||||
|
Assert.AreEqual(3, opponent.Count);
|
||||||
|
|
||||||
|
var playerOldest = camera.WorldToViewportPoint(
|
||||||
|
player[0].transform.position);
|
||||||
|
var playerNewest = camera.WorldToViewportPoint(
|
||||||
|
player[1].transform.position);
|
||||||
|
Assert.Less(
|
||||||
|
playerOldest.x,
|
||||||
|
playerNewest.x,
|
||||||
|
"Player hand fills left-to-right: the newest card is on the right");
|
||||||
|
Assert.Less(
|
||||||
|
playerNewest.z,
|
||||||
|
playerOldest.z,
|
||||||
|
"Player newest card must be closer to the camera");
|
||||||
|
|
||||||
|
var opponentOldest = camera.WorldToViewportPoint(
|
||||||
|
opponent[0].transform.position);
|
||||||
|
var opponentNewest = camera.WorldToViewportPoint(
|
||||||
|
opponent[2].transform.position);
|
||||||
|
Assert.Greater(
|
||||||
|
opponentOldest.x,
|
||||||
|
opponentNewest.x,
|
||||||
|
"Opponent hand fills right-to-left: the newest card is on the left");
|
||||||
|
Assert.Less(
|
||||||
|
opponentNewest.z,
|
||||||
|
opponentOldest.z,
|
||||||
|
"Opponent newest card must be closer to the camera");
|
||||||
|
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
view.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/008EN",
|
||||||
|
InstanceId = 13,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
view.Players[1].HandCount = 4;
|
||||||
|
board.Render(view, new BoardRenderState(), animateMoves: false);
|
||||||
|
|
||||||
|
var newPlayer = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 2);
|
||||||
|
var newOpponent = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 1
|
||||||
|
&& card.Ref.Index == 3);
|
||||||
|
Assert.Greater(
|
||||||
|
camera.WorldToViewportPoint(newPlayer.TargetPosition).x,
|
||||||
|
playerNewest.x,
|
||||||
|
"A newly drawn player card must appear to the right");
|
||||||
|
Assert.Less(
|
||||||
|
camera.WorldToViewportPoint(newOpponent.TargetPosition).x,
|
||||||
|
opponentNewest.x,
|
||||||
|
"A newly drawn opponent card must appear to the left");
|
||||||
|
|
||||||
|
var drawSource = board.Layout.DeckPosition(0)
|
||||||
|
+ (Vector3.up * 0.03f);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(newPlayer.transform.position, drawSource),
|
||||||
|
0.01f,
|
||||||
|
"A drawn card must start above the deck");
|
||||||
|
Assert.AreEqual(
|
||||||
|
config.cardScale,
|
||||||
|
newPlayer.transform.localScale.x,
|
||||||
|
0.01f,
|
||||||
|
"A drawn card must start at deck scale, not hand scale");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
Object.DestroyImmediate(cameraGo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Hand_Reflows_While_A_Card_Leaves_From_Hand()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
var leaving = new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/008EN",
|
||||||
|
InstanceId = 13,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
};
|
||||||
|
view.Players[0].Hand.Add(leaving);
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var oldestBefore = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
|
||||||
|
var next = BuildView(you: 0);
|
||||||
|
next.Players[0].RearGuards[3] = leaving;
|
||||||
|
board.Render(next, new BoardRenderState());
|
||||||
|
|
||||||
|
var oldestAfter = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Distance(oldestBefore, oldestAfter),
|
||||||
|
0.01f,
|
||||||
|
"The hand must start reflowing when a card leaves");
|
||||||
|
Assert.GreaterOrEqual(
|
||||||
|
board.MoveAnimator.PendingCount,
|
||||||
|
1,
|
||||||
|
"The leaving card must still be queued for movement");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Dropped_Hand_Card_Holds_At_Drop_Until_Returned()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(BuildView(you: 0), new BoardRenderState());
|
||||||
|
var card = ActiveCards().Single(item =>
|
||||||
|
item.Ref.Zone == BoardZone.Hand
|
||||||
|
&& item.Ref.Player == 0
|
||||||
|
&& item.Ref.Index == 0);
|
||||||
|
var handTarget = card.TargetPosition;
|
||||||
|
|
||||||
|
card.BeginDrag();
|
||||||
|
card.DragTo(new Vector3(1f, 0f, 1f));
|
||||||
|
var held = card.transform.position;
|
||||||
|
card.EndDragAtCurrent();
|
||||||
|
|
||||||
|
Assert.IsFalse(card.IsDragging);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(card.TargetPosition, held),
|
||||||
|
0.001f,
|
||||||
|
"Dropped card must stay at the drop point");
|
||||||
|
card.Tick(0.2f);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(card.transform.position, held),
|
||||||
|
0.001f,
|
||||||
|
"Dropped card must not twitch while waiting for a snapshot");
|
||||||
|
|
||||||
|
var pendingField = typeof(GameBoard3D).GetField(
|
||||||
|
"_dropPendingCard",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.IsNotNull(pendingField);
|
||||||
|
pendingField.SetValue(board, card);
|
||||||
|
|
||||||
|
board.ReturnDroppedCard();
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(card.TargetPosition, handTarget),
|
||||||
|
0.001f,
|
||||||
|
"Returned card must target its hand pose again");
|
||||||
|
card.Tick(0.5f);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(card.transform.position, handTarget),
|
||||||
|
0.05f);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Hand_Hover_Lifts_Card_By_Ten_Percent()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var cameraGo = new GameObject("Main Camera", typeof(Camera))
|
||||||
|
{
|
||||||
|
tag = "MainCamera",
|
||||||
|
};
|
||||||
|
var camera = cameraGo.GetComponent<Camera>();
|
||||||
|
camera.fieldOfView = config.cameraFov;
|
||||||
|
camera.transform.position = config.cameraPosition;
|
||||||
|
camera.transform.rotation = Quaternion.LookRotation(
|
||||||
|
config.cameraTarget - config.cameraPosition,
|
||||||
|
Vector3.up);
|
||||||
|
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(BuildView(you: 0), new BoardRenderState());
|
||||||
|
var handCard = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0);
|
||||||
|
var before = handCard.TargetPosition;
|
||||||
|
|
||||||
|
var hoverField = typeof(GameBoard3D).GetField(
|
||||||
|
"_hoveredHandCard",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
var updateHand = typeof(GameBoard3D).GetMethod(
|
||||||
|
"UpdateHandView",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.IsNotNull(hoverField);
|
||||||
|
Assert.IsNotNull(updateHand);
|
||||||
|
|
||||||
|
hoverField.SetValue(board, handCard);
|
||||||
|
updateHand.Invoke(board, null);
|
||||||
|
var lifted = handCard.TargetPosition;
|
||||||
|
var delta = lifted - before;
|
||||||
|
Assert.Greater(delta.magnitude, 0.01f);
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Dot(delta.normalized, camera.transform.up),
|
||||||
|
0.9f,
|
||||||
|
"Hover lift must go up in camera space");
|
||||||
|
|
||||||
|
hoverField.SetValue(board, null);
|
||||||
|
updateHand.Invoke(board, null);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(handCard.TargetPosition, before),
|
||||||
|
0.001f,
|
||||||
|
"Unhover must return the card to its hand pose");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
Object.DestroyImmediate(cameraGo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Hand_Draw_Stages_New_Cards_One_By_One()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var oldestTargetBefore = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
|
||||||
|
var next = BuildView(you: 0);
|
||||||
|
next.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/008EN",
|
||||||
|
InstanceId = 13,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
next.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/009EN",
|
||||||
|
InstanceId = 14,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
board.Render(next, new BoardRenderState());
|
||||||
|
|
||||||
|
Assert.IsTrue(board.StagedDrawHandActive);
|
||||||
|
Assert.AreEqual(2, board.StagedDrawCards.Count);
|
||||||
|
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
|
||||||
|
Assert.AreEqual(2, ActiveCards().Count(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0));
|
||||||
|
|
||||||
|
// Повторный snapshot без новых доборов не должен сбрасывать
|
||||||
|
// уже начатую ступенчатую раздачу.
|
||||||
|
board.Render(next, new BoardRenderState());
|
||||||
|
Assert.IsTrue(board.StagedDrawHandActive);
|
||||||
|
Assert.AreEqual(2, board.StagedDrawCards.Count);
|
||||||
|
|
||||||
|
var oldestTargetAtDrawStart = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(
|
||||||
|
oldestTargetBefore,
|
||||||
|
oldestTargetAtDrawStart),
|
||||||
|
0.001f,
|
||||||
|
"The hand must not reflow before the first draw arrives");
|
||||||
|
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
Assert.AreEqual(1, board.MoveAnimator.ActiveCount);
|
||||||
|
var firstDrawn = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 2);
|
||||||
|
firstDrawn.Tick(0.7f);
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
|
||||||
|
Assert.AreEqual(3, board.StagedDrawCards.Count);
|
||||||
|
Assert.IsTrue(board.StagedDrawHandActive);
|
||||||
|
var oldestTargetAfter = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
|
||||||
|
0.01f,
|
||||||
|
"The hand must reflow after the first drawn card arrives");
|
||||||
|
Assert.AreEqual(3, ActiveCards().Count(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Mulligan_Remaining_Cards_Reflow_After_Each_Return()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
view.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/008EN",
|
||||||
|
InstanceId = 13,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
view.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/009EN",
|
||||||
|
InstanceId = 14,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var oldestTargetBefore = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
|
||||||
|
var next = BuildView(you: 0);
|
||||||
|
next.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/010EN",
|
||||||
|
InstanceId = 15,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
next.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/011EN",
|
||||||
|
InstanceId = 16,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
board.QueueHandReturnToDeck(0, 2);
|
||||||
|
board.Render(next, new BoardRenderState());
|
||||||
|
|
||||||
|
Assert.IsTrue(board.StagedReturnHandActive);
|
||||||
|
Assert.AreEqual(4, board.StagedReturnCards.Count);
|
||||||
|
Assert.IsTrue(board.StagedDrawHandActive);
|
||||||
|
|
||||||
|
// Первый возврат.
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
board.ReturningCards[0].Tick(0.7f);
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
// Второй возврат.
|
||||||
|
board.MoveAnimator.Tick(0.3f);
|
||||||
|
board.ReturningCards[0].Tick(0.7f);
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
|
||||||
|
Assert.IsFalse(board.StagedReturnHandActive);
|
||||||
|
Assert.AreEqual(2, board.StagedReturnCards.Count);
|
||||||
|
Assert.IsTrue(board.StagedDrawHandActive);
|
||||||
|
|
||||||
|
var oldestTargetAfter = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
|
||||||
|
0.01f,
|
||||||
|
"Remaining mulligan cards must reflow after returns");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Hand_Return_To_Deck_Starts_Right_To_Left_And_Targets_Deck_Bottom()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
view.Players[0].Hand.Add(new CardView
|
||||||
|
{
|
||||||
|
PrintingId = "BT01/008EN",
|
||||||
|
InstanceId = 13,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
});
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var oldHand = ActiveCards()
|
||||||
|
.Where(card => card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0)
|
||||||
|
.ToDictionary(card => card.Ref.Index);
|
||||||
|
var source13 = oldHand[2].transform.position;
|
||||||
|
var source12 = oldHand[1].transform.position;
|
||||||
|
var oldestTargetBefore = oldHand[0].TargetPosition;
|
||||||
|
|
||||||
|
var next = BuildView(you: 0);
|
||||||
|
next.Players[0].Hand = new List<CardView>
|
||||||
|
{
|
||||||
|
next.Players[0].Hand[0],
|
||||||
|
};
|
||||||
|
board.QueueHandReturnToDeck(0, 2);
|
||||||
|
board.Render(next, new BoardRenderState());
|
||||||
|
|
||||||
|
Assert.AreEqual(2, board.ReturningCardCount);
|
||||||
|
var returning = board.ReturningCards;
|
||||||
|
Assert.AreEqual(2, returning[0].Ref.Index);
|
||||||
|
Assert.AreEqual(1, returning[1].Ref.Index);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(returning[0].transform.position, source13),
|
||||||
|
0.001f,
|
||||||
|
"The rightmost return card must wait in the hand until its turn");
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(returning[1].transform.position, source12),
|
||||||
|
0.001f,
|
||||||
|
"The next return card must stay in the hand until the first finishes");
|
||||||
|
|
||||||
|
Assert.IsTrue(board.StagedReturnHandActive);
|
||||||
|
Assert.AreEqual(3, board.StagedReturnCards.Count);
|
||||||
|
var oldestAfter = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(oldestTargetBefore, oldestAfter),
|
||||||
|
0.001f,
|
||||||
|
"The hand must not reflow before the first card leaves");
|
||||||
|
|
||||||
|
var deckBottom = board.Layout.DeckPosition(0)
|
||||||
|
- (Vector3.up * 0.04f);
|
||||||
|
Assert.IsTrue(board.TryGetHandReturnTarget(
|
||||||
|
returning[0],
|
||||||
|
out var returnTarget,
|
||||||
|
out var returnRotation));
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(returnTarget, deckBottom),
|
||||||
|
0.001f);
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Dot(returnRotation * Vector3.up, Vector3.down),
|
||||||
|
0.9f);
|
||||||
|
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
|
||||||
|
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
Assert.IsTrue(returning[0].IsMoving);
|
||||||
|
returning[0].Tick(0.7f);
|
||||||
|
board.MoveAnimator.Tick(0f);
|
||||||
|
|
||||||
|
Assert.AreEqual(1, board.ReturningCardCount);
|
||||||
|
Assert.AreEqual(2, board.StagedReturnCards.Count);
|
||||||
|
var oldestAfterFirst = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == 0
|
||||||
|
&& card.Ref.Index == 0).TargetPosition;
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Distance(oldestTargetBefore, oldestAfterFirst),
|
||||||
|
0.01f,
|
||||||
|
"The hand must reflow after the first card leaves");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CardMoveAnimator_Runs_Sequential_Requests_And_Completes_Each()
|
||||||
|
{
|
||||||
|
var input = new InputLockService();
|
||||||
|
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
|
||||||
|
var root = new GameObject("Root");
|
||||||
|
var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
|
||||||
|
var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var from = new Vector3(-1f, 0.1f, 0f);
|
||||||
|
var to = new Vector3(1f, 0.1f, 0f);
|
||||||
|
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
|
||||||
|
cardA.SetPose(to, Quaternion.identity, true, false, false);
|
||||||
|
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
|
||||||
|
cardB.SetPose(to, Quaternion.identity, true, false, false);
|
||||||
|
|
||||||
|
var completed = new List<Card3D>();
|
||||||
|
animator.Enqueue(
|
||||||
|
cardA, from, Quaternion.identity, Vector3.one,
|
||||||
|
0.1f, 0.2f, sequential: true,
|
||||||
|
onCompleted: card => completed.Add(card));
|
||||||
|
animator.Enqueue(
|
||||||
|
cardB, from, Quaternion.identity, Vector3.one,
|
||||||
|
0.1f, 0.2f, sequential: true,
|
||||||
|
onCompleted: card => completed.Add(card));
|
||||||
|
|
||||||
|
animator.Tick(0f);
|
||||||
|
Assert.AreEqual(1, animator.ActiveCount);
|
||||||
|
Assert.AreEqual(1, animator.PendingCount);
|
||||||
|
|
||||||
|
cardA.Tick(0.2f);
|
||||||
|
animator.Tick(0f);
|
||||||
|
CollectionAssert.AreEqual(new[] { cardA }, completed);
|
||||||
|
Assert.AreEqual(0, animator.ActiveCount);
|
||||||
|
Assert.AreEqual(1, animator.PendingCount);
|
||||||
|
|
||||||
|
animator.Tick(0.3f);
|
||||||
|
Assert.AreEqual(1, animator.ActiveCount);
|
||||||
|
cardB.Tick(0.2f);
|
||||||
|
animator.Tick(0f);
|
||||||
|
CollectionAssert.AreEqual(new[] { cardA, cardB }, completed);
|
||||||
|
Assert.IsFalse(animator.IsBusy);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
animator.CancelAll();
|
||||||
|
Object.DestroyImmediate(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Trigger_Zone_Uses_Local_Minus_And_Plus_Ninety_Degrees()
|
||||||
{
|
{
|
||||||
var config = PlaymatLayoutConfig.CreateDefault();
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
var board = GameBoard3D.Create(null, config);
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
|
@ -226,11 +831,11 @@ namespace Vanguard.Tests
|
||||||
var layout = new PlaymatLayout(config);
|
var layout = new PlaymatLayout(config);
|
||||||
|
|
||||||
Assert.AreEqual(
|
Assert.AreEqual(
|
||||||
90f,
|
-90f,
|
||||||
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
|
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
|
||||||
0.1f);
|
0.1f);
|
||||||
Assert.AreEqual(
|
Assert.AreEqual(
|
||||||
-90f,
|
90f,
|
||||||
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
|
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
|
||||||
0.1f);
|
0.1f);
|
||||||
}
|
}
|
||||||
|
|
@ -570,6 +1175,161 @@ namespace Vanguard.Tests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Removing_Card_From_Hand_Does_Not_Queue_Arcs_For_Remaining_Hand()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
var self = view.Players.Single(player => player.Index == view.You);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var called = self.Hand.First(card => card.InstanceId == 11);
|
||||||
|
self.Hand.Remove(called);
|
||||||
|
self.RearGuards[0] = called;
|
||||||
|
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var remaining = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.Hand
|
||||||
|
&& card.Ref.Player == view.You
|
||||||
|
&& card.Ref.InstanceId == 12);
|
||||||
|
|
||||||
|
Assert.IsFalse(
|
||||||
|
board.MoveAnimator.IsCardQueued(remaining),
|
||||||
|
"Remaining hand cards must reflow without arc requests");
|
||||||
|
Assert.IsFalse(
|
||||||
|
board.MoveAnimator.IsCardActive(remaining),
|
||||||
|
"Remaining hand cards must reflow without active arc requests");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Queued_Card_Holds_At_Source_Until_Arc_Starts()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
var view = BuildView(you: 0);
|
||||||
|
var self = view.Players.Single(player => player.Index == view.You);
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
|
||||||
|
var first = self.Hand[0];
|
||||||
|
var second = self.Hand[1];
|
||||||
|
self.Hand.RemoveAt(1);
|
||||||
|
self.Hand.RemoveAt(0);
|
||||||
|
self.RearGuards[0] = first;
|
||||||
|
self.RearGuards[1] = second;
|
||||||
|
|
||||||
|
board.Render(view, new BoardRenderState());
|
||||||
|
board.MoveAnimator.Tick(0.001f);
|
||||||
|
|
||||||
|
var firstCard = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.RearGuard
|
||||||
|
&& card.Ref.Player == view.You
|
||||||
|
&& card.Ref.Slot == 0);
|
||||||
|
var secondCard = ActiveCards().Single(card =>
|
||||||
|
card.Ref.Zone == BoardZone.RearGuard
|
||||||
|
&& card.Ref.Player == view.You
|
||||||
|
&& card.Ref.Slot == 1);
|
||||||
|
|
||||||
|
Assert.IsTrue(firstCard.IsMoving || board.MoveAnimator.IsCardActive(firstCard),
|
||||||
|
"First card should already be moving");
|
||||||
|
Assert.IsTrue(board.MoveAnimator.IsCardQueued(secondCard),
|
||||||
|
"Second card should be queued behind the first");
|
||||||
|
Assert.Greater(
|
||||||
|
Vector3.Distance(secondCard.transform.position, secondCard.TargetPosition),
|
||||||
|
0.5f,
|
||||||
|
"Queued card must stay at source, not smooth-move toward target");
|
||||||
|
|
||||||
|
board.MoveAnimator.Tick(10f);
|
||||||
|
Assert.IsTrue(secondCard.IsMoving || board.MoveAnimator.IsCardActive(secondCard),
|
||||||
|
"Second card should start moving after the first finishes");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cancel_Queued_Move_Releases_Hold()
|
||||||
|
{
|
||||||
|
var input = new InputLockService();
|
||||||
|
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
|
||||||
|
var root = new GameObject("Root");
|
||||||
|
var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
|
||||||
|
var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var from = new Vector3(-1f, 0.1f, 0f);
|
||||||
|
var to = new Vector3(1f, 0.1f, 0f);
|
||||||
|
|
||||||
|
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
|
||||||
|
cardA.SetPose(to, Quaternion.identity, true, false, false);
|
||||||
|
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
|
||||||
|
|
||||||
|
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
|
||||||
|
cardB.SetPose(to, Quaternion.identity, true, false, false);
|
||||||
|
animator.Enqueue(cardB, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
|
||||||
|
|
||||||
|
animator.Tick(0.001f);
|
||||||
|
Assert.IsTrue(animator.IsCardQueued(cardB));
|
||||||
|
|
||||||
|
animator.CancelCard(cardB);
|
||||||
|
Assert.IsFalse(animator.IsCardQueued(cardB));
|
||||||
|
|
||||||
|
cardB.Tick(0.5f);
|
||||||
|
Assert.Less(
|
||||||
|
Vector3.Distance(cardB.transform.position, cardB.TargetPosition),
|
||||||
|
0.5f,
|
||||||
|
"Canceled queued card should resume normal movement to target");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
animator.CancelAll();
|
||||||
|
Object.DestroyImmediate(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Hand_Visibility_Toggles()
|
||||||
|
{
|
||||||
|
var config = PlaymatLayoutConfig.CreateDefault();
|
||||||
|
var board = GameBoard3D.Create(null, config);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
board.SetVisible(true);
|
||||||
|
board.Render(BuildView(you: 0), new BoardRenderState());
|
||||||
|
|
||||||
|
var hand = ActiveCards()
|
||||||
|
.Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)
|
||||||
|
.ToList();
|
||||||
|
Assert.Greater(hand.Count, 0);
|
||||||
|
|
||||||
|
board.SetHandVisible(false);
|
||||||
|
Assert.IsTrue(hand.All(card => !card.gameObject.activeSelf),
|
||||||
|
"Hand cards should be hidden");
|
||||||
|
|
||||||
|
board.SetHandVisible(true);
|
||||||
|
Assert.IsTrue(hand.All(card => card.gameObject.activeSelf),
|
||||||
|
"Hand cards should be visible again");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Object.DestroyImmediate(board.gameObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
|
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@ GraphicsSettings:
|
||||||
- {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
|
- {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
|
||||||
m_PreloadedShaders: []
|
m_PreloadedShaders: []
|
||||||
m_PreloadShadersBatchTimeLimit: -1
|
m_PreloadShadersBatchTimeLimit: -1
|
||||||
|
m_GraphicsStateCollection: {fileID: 0}
|
||||||
|
m_CollectionStartupAction: 0
|
||||||
|
m_TraceSavePath: TracedCollection
|
||||||
|
m_TraceSendToEditor: 1
|
||||||
|
m_AdditionalWarmupCollections: []
|
||||||
|
m_WarmupAsync: 1
|
||||||
|
m_EnableCacheMissTracing: 0
|
||||||
|
m_WarmupProgressivelyLimit: -1
|
||||||
|
m_CacheMissCollectionPath: CacheMissCollection
|
||||||
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
|
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
m_CustomRenderPipeline: {fileID: 0}
|
m_CustomRenderPipeline: {fileID: 0}
|
||||||
m_TransparencySortMode: 0
|
m_TransparencySortMode: 0
|
||||||
|
|
@ -49,6 +58,7 @@ GraphicsSettings:
|
||||||
m_FogStripping: 0
|
m_FogStripping: 0
|
||||||
m_InstancingStripping: 0
|
m_InstancingStripping: 0
|
||||||
m_BrgStripping: 0
|
m_BrgStripping: 0
|
||||||
|
m_DefaultLightBaker: 0
|
||||||
m_LightmapKeepPlain: 1
|
m_LightmapKeepPlain: 1
|
||||||
m_LightmapKeepDirCombined: 1
|
m_LightmapKeepDirCombined: 1
|
||||||
m_LightmapKeepDynamicPlain: 1
|
m_LightmapKeepDynamicPlain: 1
|
||||||
|
|
@ -61,6 +71,11 @@ GraphicsSettings:
|
||||||
m_AlbedoSwatchInfos: []
|
m_AlbedoSwatchInfos: []
|
||||||
m_RenderPipelineGlobalSettingsMap:
|
m_RenderPipelineGlobalSettingsMap:
|
||||||
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
||||||
|
m_ShaderBuildSettings:
|
||||||
|
keywordDeclarationOverrides: []
|
||||||
|
numInternalDefines: 0
|
||||||
|
defines: []
|
||||||
|
compilerSettings: []
|
||||||
m_LightsUseLinearIntensity: 1
|
m_LightsUseLinearIntensity: 1
|
||||||
m_LightsUseColorTemperature: 1
|
m_LightsUseColorTemperature: 1
|
||||||
m_LogWhenShaderIsCompiled: 0
|
m_LogWhenShaderIsCompiled: 0
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,17 @@ UnityConnectSettings:
|
||||||
m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events
|
m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events
|
||||||
m_EventUrl: https://cdp.cloud.unity3d.com/v1/events
|
m_EventUrl: https://cdp.cloud.unity3d.com/v1/events
|
||||||
m_ConfigUrl: https://config.uca.cloud.unity3d.com
|
m_ConfigUrl: https://config.uca.cloud.unity3d.com
|
||||||
|
m_DashboardUrl: https://dashboard.unity3d.com
|
||||||
m_TestInitMode: 0
|
m_TestInitMode: 0
|
||||||
CrashReportingSettings:
|
InsightsSettings:
|
||||||
m_EventUrl: https://perf-events.cloud.unity3d.com
|
m_EventUrl: https://engine-data.unity3d.com
|
||||||
|
m_StackTraceUrl: https://engine-data.services.api.unity.com/v1/stack-traces
|
||||||
|
m_EngineDiagnosticsEnabled: 0
|
||||||
m_Enabled: 0
|
m_Enabled: 0
|
||||||
|
CrashReportingSettings:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_EventUrl: https://perf-events.cloud.unity3d.com
|
||||||
|
m_EnableCloudDiagnosticsReporting: 0
|
||||||
m_LogBufferSize: 10
|
m_LogBufferSize: 10
|
||||||
m_CaptureEditorExceptions: 1
|
m_CaptureEditorExceptions: 1
|
||||||
UnityPurchasingSettings:
|
UnityPurchasingSettings:
|
||||||
|
|
@ -22,6 +29,7 @@ UnityConnectSettings:
|
||||||
m_Enabled: 0
|
m_Enabled: 0
|
||||||
m_TestMode: 0
|
m_TestMode: 0
|
||||||
m_InitializeOnStartup: 1
|
m_InitializeOnStartup: 1
|
||||||
|
m_PackageRequiringCoreStatsPresent: 0
|
||||||
UnityAdsSettings:
|
UnityAdsSettings:
|
||||||
m_Enabled: 0
|
m_Enabled: 0
|
||||||
m_InitializeOnStartup: 1
|
m_InitializeOnStartup: 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Инвентаризация боевых анимаций
|
||||||
|
|
||||||
|
**Дата:** 2026-10-27
|
||||||
|
**Статус:** `[C]` — фактическое устройство кода; `[R]` — удалено 2026-10-27.
|
||||||
|
**Назначение:** отметить блоки, которые временно убираем для пересборки.
|
||||||
|
|
||||||
|
## Обозначения
|
||||||
|
|
||||||
|
- `[C]` — используется сейчас.
|
||||||
|
- `[R]` — удалено по решению пользователя (2026-10-27).
|
||||||
|
- `[O]` — отдельной анимации нет; эффект получается побочным путём.
|
||||||
|
- `[U]` — параметр объявлен в конфиге, но код его не читает.
|
||||||
|
|
||||||
|
## Где сейчас лежит общая логика
|
||||||
|
|
||||||
|
- `Card3D` — arc-перемещение, `Reveal`, плавный snap, rest/damage-повороты.
|
||||||
|
- `CardMoveAnimator` — очередь, stagger, последовательный добор, `InputLock`.
|
||||||
|
- `CameraDirector` — плавное движение камеры (position/rotation/FOV).
|
||||||
|
- `TriggerCheckSequence` — автомат trigger/damage-check: zoom → reveal → hold → resolve → return.
|
||||||
|
- `ArrowDirector` / `AttackArrowView` — attack/boost-стрелки.
|
||||||
|
- `GameBoard3D` — рендер-перемещения, стартовая раздача, reflow руки, trigger-зона.
|
||||||
|
- `BattleView` — интро, trigger-оркестрация, feedback-очередь и паузы.
|
||||||
|
|
||||||
|
## A. Старт боя и раздача
|
||||||
|
|
||||||
|
- **A1 [C] Стартовая камера.** `GameBoard3D.SetCameraIntro` мгновенно ставит камеру в `introCameraPosition` / `introCameraTarget` / `introCameraFov`. Держится `introOverheadHold = 1.5 с`.
|
||||||
|
- **A2 [C] Переезд камеры на основную позицию.** `CameraDirector.MoveTo`, smoothstep, `introCameraMoveDuration = 1.0 с`.
|
||||||
|
- **A3 [C] Пауза на основной позиции.** `introMainHold = 1.5 с`.
|
||||||
|
- **A4 [C] Стартовая раздача.** `GameBoard3D.AnimateHandDraw`: руки скрыты, карты лежат над колодами рубашкой вверх; своя и чужая руки идут парами, внутри пары одновременно (`EnqueueNow`), между парами ждём завершения и 0.05 с; `introDrawDuration = 0.6 с`; `InputLock("battle-intro")`.
|
||||||
|
- **A5 [C] Переворот стартового Авангарда.** `Card3D.Reveal` после муллигана; `vanguardRevealDuration = 0.8 с`; направление `+1` у себя, `-1` у противника; `InputLock("vanguard-reveal")`.
|
||||||
|
- **A6 [O] Отдельного «открытия стола» нет.** Поле и карты появляются через `Render`/`SetVisible`.
|
||||||
|
|
||||||
|
## B. Добор карт
|
||||||
|
|
||||||
|
- **B1 [C] Обычный добор.** Новая карта стартует над колодой **полного размера** (`cardScale`), рубашкой вверх в ориентации будущей карты руки, летит в руку по дуге и переворачивается лицом вверх через `StartArcMove` (Slerp). Длительность `introDrawDuration = 0.6 с`, дуга `arcHeight = 0.45`; общий старт с муллиганом через `GetDrawSource`. Очередные карты скрыты, пока не начнётся их собственная дуга — над колодой видна одна.
|
||||||
|
- **B2 [C] Последовательный добор.** `CardMoveAnimator` с `sequential: true`: следующая карта стартует только после завершения предыдущей; дополнительный stagger `moveDelayBetweenCards = 0.22 с`; `InputLock("card-move")`.
|
||||||
|
- **B3 [C] Reflow руки.** `GameBoard3D.UpdateHandView` каждый кадр пересчитывает позы; при возврате/доборе используется единый `_visualHandCards`: в нём сразу есть оставшиеся карты, возвращаемые и уже добранные; новые добавляются после завершения дуги. Рука подстраивается после каждого изменения, а не один раз под финальный `HandCount`. Отдельной длительности нет.
|
||||||
|
- **B4 [O] Отдельного flip-блока добора нет.** `cardFlipDuration` в конфиге не читается; переворот получается из Slerp между рубашкой и лицом.
|
||||||
|
- **B5 [C] Возврат из руки в колоду.** `GameBoard3D.QueueHandReturnToDeck` по `MulliganView`/`CardReturnedToDeckView`: старая карта руки уходит по дуге под низ колоды, переворачивается рубашкой вверх; длительность — общая `DrawDuration` (`introDrawDuration = 0.6`), `arcHeight = 0.45`; старт справа налево, следующая после завершения предыдущей (`sequential: true`); единый `_visualHandCards` пересчитывается после каждого ухода, включая ожидающие возврата; у противника зеркально. После завершения карта возвращается в пул.
|
||||||
|
|
||||||
|
## C. Перемещение карт на столе
|
||||||
|
|
||||||
|
- **C1 [C] Обычное arc-перемещение.** Call/Ride/Move и другие несрочные переходы: `moveDuration = 0.8 с`, `arcHeight = 0.45`.
|
||||||
|
- **C2 [C] Важное arc-перемещение.** Trigger/Damage/Drop: `importantMoveDuration = 1.3 с`.
|
||||||
|
- **C3 [C] Плавный snap.** Для локальных изменений без arc: rest, damage-поворот, reflow руки, возврат после drag. Экспоненциальное сглаживание, `cardMoveSpeed = 14`, `cardRotateSpeed = 12`.
|
||||||
|
- **C4 [C] Stagger нескольких карт.** `moveDelayBetweenCards = 0.22 с`; повторный запрос для той же карты отменяет старый.
|
||||||
|
- **C5 [C] Board-lock.** После рендера с движением `InputLock("board")` держится `cardMoveDuration * 3 + inputLockTail`, минимум 1 с.
|
||||||
|
- **C6 [C] Drag.** Карта из руки при зажатии ЛКМ **не двигается**: включается `_handTargeting`, карта остаётся в hand-раскладке и hover-подъёме, а курсор только выбирает зону. Цель берётся raycast-ом по карте/кругу (VC-карта сразу даёт Vanguard), затем fallback на плоскость. На release над VC/RC отправляется `CardDropped`; невалидный drop оставляет карту в руке. Карты поля сохраняют обычный drag: подъём `dragLift = 0.14`, следование за курсором и возврат к source до снапшота.
|
||||||
|
- **C7 [C] Rest / damage-поворот.** Rest — `+90°` вокруг Y, damage — `-90°`; плавный поворот.
|
||||||
|
- **C8 [O] Отдельной анимации Call/Ride/Retire нет.** Всё идёт общим arc/snap; `callMoveDuration` не читается. `retireDuration` используется только как длительность баннера RETIRE.
|
||||||
|
|
||||||
|
## D. Перевороты и раскрытие
|
||||||
|
|
||||||
|
- **D1 [C] Обычный face-down/face-up.** `Card3D.FaceDownFlip`, плавный поворот через `SetPose`.
|
||||||
|
- **D2 [C] Переворот урона.** Вокруг длинной стороны (`Z 180°` в damage-layout), плавный поворот.
|
||||||
|
- **D3 [C] `Card3D.Reveal`.** Подъём `+0.12`, масштаб `0.85 → 1`, поворот на `180°` вокруг длинной оси, направление `±1`.
|
||||||
|
- **D4 [C] Раскрытие Авангарда.** Вызывается внутри `GameBoard3D.Render` при переходе `!FaceUp → FaceUp`; 0.8 с.
|
||||||
|
- **D5 [C] Раскрытие trigger-карты.** `RevealTriggerCard` после завершения зума; `triggerRevealDuration = 0.35 с`, затем пауза.
|
||||||
|
- **D6 [C] Переворот при доборе.** Побочный Slerp внутри `StartArcMove`; отдельного блока нет.
|
||||||
|
- **D7 [O] Отдельного `cardFlipDuration`-блока нет.** Параметр не читается.
|
||||||
|
|
||||||
|
## E. Камера и триггеры
|
||||||
|
|
||||||
|
- **E1 [C] Camera tween.** `CameraDirector` плавно меняет position/rotation/FOV, smoothstep. Используется интро, trigger-зумом и возвратом.
|
||||||
|
- **E2 [C] Trigger-зум.** `cameraZoomDuration = 0.5 с`, FOV `34`, смещение `back 3.4 / up 2.6`; рука и HUD-баннер скрываются.
|
||||||
|
- **E3 [C] Полная trigger-последовательность.** Зум 0.5 с → раскрытие 0.35 с → пауза `triggerRevealHold = 0.75 с` → `ResolveReady` → возврат `cameraReturnDuration = 0.5 с`. Выбор цели открывается только после паузы.
|
||||||
|
- **E4 [C] Damage-check без триггера.** Та же камера/пауза/возврат, но без `Reveal`; в зоне пишется «БЕЗ ТРИГГЕРА».
|
||||||
|
- **E5 [C] Цепочка триггеров.** `TriggerCheckSequence` умеет повторный `ZoomTo` для следующего триггера; отдельный `cameraRezoomDuration` не читается.
|
||||||
|
- **E6 [C] Отмена.** `Cancel`/`Restore` мгновенно возвращают камеру и снимают lock.
|
||||||
|
|
||||||
|
## F. Атака и бой
|
||||||
|
|
||||||
|
- **F1 [C] Красная attack-стрелка.** Появление/рост `growDuration = 0.35 с`, alpha-in `arrowShowDuration = 0.25 с`, fade `ArrowConfig.fadeDuration = 0.2 с`, бегущий пунктир `dashScrollSpeed = 1.5`, дуга `arcHeight = 0.10`.
|
||||||
|
- **F2 [C] Синие boost-стрелки.** Рост `boostGrowDuration = 0.3 с`, толщина `ArrowConfig.boostThickness = 0.06`; остальное как у attack.
|
||||||
|
- **F3 [C] Стрелка следует за картами.** Точки пересчитываются каждый кадр; отдельной анимации нет.
|
||||||
|
- **F4 [R] Feedback-баннер.** Удалён: `BattleFeedback`, `BattleFlash`, кнопка «Пропустить анимации», очередь `FeedbackStep`, `InputLock("feedback")`.
|
||||||
|
- **F5 [R] Длительности feedback по событиям.** Удалены вместе с баннерами; паузы `eventPause`/`post*Pause` больше не применяются. Темп держат `CardMoveAnimator` и trigger-последовательность.
|
||||||
|
- **F6 [C] Урон.** Карта идёт в damage-зону важным arc-перемещением 1.3 с, ложится на `-90°`, переворачивается вокруг длинной стороны; счётчик `x/6` меняется мгновенно.
|
||||||
|
- **F7 [O] Отдельной анимации лечения нет.** Вспышка `HealedView` удалена вместе с feedback; карта урона меняется общим рендером.
|
||||||
|
- **F8 [O] Отдельной анимации Guard/Intercept/Perfect Guard нет.** Осталось только общее перемещение карт.
|
||||||
|
- **F9 [O] Нет attack-lunge, shake, hit-частиц, damage-вылета и destroy-эффекта.**
|
||||||
|
|
||||||
|
## G. Рука и UI
|
||||||
|
|
||||||
|
- **G1 [C] Веер руки.** Своя рука набирается слева направо, новая карта добавляется справа; рука противника зеркальна — новая карта слева. Обе руки пересчитываются каждый кадр, глубина даёт перекрытие. Отдельной длительности нет.
|
||||||
|
- **G2 [C] Hover карты в руке.** Карта под курсором плавно поднимается камера-вверх на 10% своей высоты (`handHoverLift = 0.1`), после ухода курсора плавно возвращается. Только своя рука; UI/лок/драг/анимации отключают hover.
|
||||||
|
- **G3 [O] Анимации status-toast нет.** Показ/скрытие мгновенные, живёт 4 с.
|
||||||
|
- **G4 [O] Анимаций оверлеев и меню нет.** Открываются/закрываются мгновенно.
|
||||||
|
- **G5 [O] Переходов между сценами нет.** `SceneFlow` переключает сцены без fade.
|
||||||
|
- **G6 [O] Анимации HUD-счётчиков нет.** Рука/колода/урон/щит/триггер меняются текстом мгновенно.
|
||||||
|
- **G7 [O] Анимации подсветки нет.** Highlight — мгновенная смена цвета.
|
||||||
|
- **G8 [O] Анимации изменения силы/крита нет.** Меняется текст/цвет мгновенно.
|
||||||
|
- **G9 [R] Turn banner.** Мёртвые поля `_turnBanner`/`_turnBannerText` удалены.
|
||||||
|
|
||||||
|
## H. Служебные зависимости, не визуальные анимации
|
||||||
|
|
||||||
|
- **H1 [C] InputLock.** Ключи: `card-move`, `board`, `trigger-sequence`, `battle-intro`, `vanguard-reveal`, `overlay`, `card-menu`, `ability`, `card-choice`. Ключ `feedback` удалён.
|
||||||
|
- **H2 [C] Паузы.** Остались только trigger-паузы (`triggerRevealHold = 0.75`); `eventPause`/`post*Pause` больше не используются.
|
||||||
|
- **H3 [C] Мгновенные скрытия/показы.** Рука скрывается на trigger-фокусе; trigger-зона показывается после зума и сразу `Snap`; trigger-карта появляется без fade.
|
||||||
|
- **H4 [C] Очистка.** `CardMoveAnimator.CancelAll` и `GameBoard3D.Clear` отменяют очередь и снимают hold/lock.
|
||||||
|
|
||||||
|
## I. Параметры, которые сейчас не читаются
|
||||||
|
|
||||||
|
- `cardFlipDuration`
|
||||||
|
- `callMoveDuration`
|
||||||
|
- `cameraRezoomDuration`
|
||||||
|
- `arrowHideDuration`
|
||||||
|
- `arrowFadeDuration` (используется `ArrowConfig.fadeDuration`)
|
||||||
|
- `boostThickness` в `AnimationConfig` (используется `ArrowConfig.boostThickness`)
|
||||||
|
- `cardMoveDuration` — только таймаут board-lock, не длительность движения.
|
||||||
|
- `retireDuration` — был только длительностью баннера RETIRE; после удаления баннеров не читается.
|
||||||
|
- `attackDeclare`, `guard`, `driveCheck`, `trigger`, `battleResolved`, `damage`, `heal`, `intercept`, `perfectGuard` — были только длительностями feedback-баннеров.
|
||||||
|
- `eventPause`, `postDrivePause`, `postTriggerPause`, `postDamagePause` — были только паузами feedback-очереди.
|
||||||
|
|
@ -111,6 +111,18 @@ public sealed class GameEngine
|
||||||
.Select(a => new TriggeredAbility { Definition = a, Context = context })
|
.Select(a => new TriggeredAbility { Definition = a, Context = context })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Sentinel попадает в круг защиты: автоматическая способность идеальной
|
||||||
|
// защиты с оплатой сбросом карты того же клана.
|
||||||
|
if (trigger == AbilityTrigger.OnGuard
|
||||||
|
&& State.Catalog.CardOf(source.PrintingId).IsSentinel)
|
||||||
|
{
|
||||||
|
triggered.Add(new TriggeredAbility
|
||||||
|
{
|
||||||
|
Definition = CreatePerfectGuardAbilityDefinition(source, owner),
|
||||||
|
Context = context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (triggered.Count == 0)
|
if (triggered.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -131,6 +143,75 @@ public sealed class GameEngine
|
||||||
yield return granted.Definition;
|
yield return granted.Definition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AbilityDefinition CreatePerfectGuardAbilityDefinition(CardInstance sentinel, int owner)
|
||||||
|
{
|
||||||
|
var clan = State.Catalog.CardOf(sentinel.PrintingId).Clan;
|
||||||
|
|
||||||
|
return new AbilityDefinition
|
||||||
|
{
|
||||||
|
Trigger = AbilityTrigger.OnGuard,
|
||||||
|
Optional = true,
|
||||||
|
Condition = _ => true,
|
||||||
|
CanPay = ctx =>
|
||||||
|
{
|
||||||
|
var player = ctx.State.Players[ctx.Owner];
|
||||||
|
return player.Hand.Any(c => ctx.State.Catalog.CardOf(c.PrintingId).Clan == clan);
|
||||||
|
},
|
||||||
|
Pay = ctx => AskPerfectGuardDiscard(ctx.Owner, sentinel, ctx.State.Battle),
|
||||||
|
Resolve = ctx => ApplyPerfectGuard(ctx.Owner, sentinel, ctx.State.Battle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AskPerfectGuardDiscard(int owner, CardInstance sentinel, BattleState? battle)
|
||||||
|
{
|
||||||
|
if (battle is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var player = State.Players[owner];
|
||||||
|
var clan = State.Catalog.CardOf(sentinel.PrintingId).Clan;
|
||||||
|
var options = player.Hand
|
||||||
|
.Select((card, index) => (card, index))
|
||||||
|
.Where(item => State.Catalog.CardOf(item.card.PrintingId).Clan == clan)
|
||||||
|
.Select(item => new ChoiceOption(
|
||||||
|
item.index,
|
||||||
|
State.Catalog.CardOf(item.card.PrintingId).NameEn))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
State.PendingChoice = new PendingChoice
|
||||||
|
{
|
||||||
|
Player = owner,
|
||||||
|
Prompt = "Выберите карту из руки для сброса",
|
||||||
|
Options = options,
|
||||||
|
Kind = ChoiceTargetKind.Hand,
|
||||||
|
Resolve = index =>
|
||||||
|
{
|
||||||
|
ApplyPerfectGuard(owner, sentinel, battle, index);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
State.Step = Step.Choice;
|
||||||
|
State.ActivePlayer = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPerfectGuard(int owner, CardInstance sentinel, BattleState? battle, int? discardHandIndex = null)
|
||||||
|
{
|
||||||
|
if (battle is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (discardHandIndex.HasValue)
|
||||||
|
{
|
||||||
|
var player = State.Players[owner];
|
||||||
|
var index = discardHandIndex.Value;
|
||||||
|
if (index >= 0 && index < player.Hand.Count)
|
||||||
|
{
|
||||||
|
var discarded = player.Hand[index];
|
||||||
|
player.Hand.RemoveAt(index);
|
||||||
|
player.Drop.Add(discarded);
|
||||||
|
battle.Nullified = true;
|
||||||
|
_events.Add(new PerfectGuardEvent(owner, sentinel.PrintingId, discarded.PrintingId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RaiseBatch(List<TriggeredAbility> triggered)
|
private void RaiseBatch(List<TriggeredAbility> triggered)
|
||||||
{
|
{
|
||||||
if (triggered.Count == 0)
|
if (triggered.Count == 0)
|
||||||
|
|
@ -1452,18 +1533,24 @@ public sealed class GameEngine
|
||||||
|
|
||||||
if (State.PendingTrigger is { } pending)
|
if (State.PendingTrigger is { } pending)
|
||||||
{
|
{
|
||||||
var hasRearGuard = State.Players[pending.Player].RearGuards.Any(u => u is not null);
|
|
||||||
|
|
||||||
foreach (var powerSlot in UnitSlots(pending.Player))
|
foreach (var powerSlot in UnitSlots(pending.Player))
|
||||||
{
|
{
|
||||||
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
|
if (pending.Trigger == TriggerType.Critical)
|
||||||
{
|
{
|
||||||
foreach (var effectSlot in UnitSlots(pending.Player))
|
foreach (var effectSlot in UnitSlots(pending.Player))
|
||||||
{
|
|
||||||
if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
|
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
|
||||||
|
}
|
||||||
|
else if (pending.Trigger == TriggerType.Stand)
|
||||||
|
{
|
||||||
|
var rested = RestedRearGuardSlots(pending.Player).ToList();
|
||||||
|
if (rested.Count == 0)
|
||||||
|
{
|
||||||
|
commands.Add(new ResolveTriggerCommand(powerSlot, -1));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var effectSlot in rested)
|
||||||
|
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -1575,7 +1662,7 @@ public sealed class GameEngine
|
||||||
|
|
||||||
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
|
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
|
||||||
&& (slot is 3 or 4
|
&& (slot is 3 or 4
|
||||||
|| (canSnipe && SameColumn(attackerSlot, slot)))));
|
|| (canSnipe && SameColumnAcrossPlayers(attackerSlot, slot)))));
|
||||||
|
|
||||||
foreach (var target in targets)
|
foreach (var target in targets)
|
||||||
commands.Add(new AttackCommand(attackerSlot, target));
|
commands.Add(new AttackCommand(attackerSlot, target));
|
||||||
|
|
@ -1614,16 +1701,9 @@ public sealed class GameEngine
|
||||||
|
|
||||||
if (def.IsSentinel)
|
if (def.IsSentinel)
|
||||||
{
|
{
|
||||||
|
// Оплата Sentinel теперь идёт отдельной автоматической способностью
|
||||||
|
// после размещения в круг защиты.
|
||||||
commands.Add(new GuardCommand(printingId));
|
commands.Add(new GuardCommand(printingId));
|
||||||
|
|
||||||
foreach (var discardId in p.Hand
|
|
||||||
.Where(c => c.PrintingId != printingId
|
|
||||||
&& State.Catalog.CardOf(c.PrintingId).Clan == def.Clan)
|
|
||||||
.Select(c => c.PrintingId)
|
|
||||||
.Distinct())
|
|
||||||
{
|
|
||||||
commands.Add(new GuardCommand(printingId, discardId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (def.Shield > 0)
|
else if (def.Shield > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1722,6 +1802,17 @@ public sealed class GameEngine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Круги RC, которые можно поднять Stand-триггером.</summary>
|
||||||
|
private IEnumerable<int> RestedRearGuardSlots(int player)
|
||||||
|
{
|
||||||
|
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
|
||||||
|
{
|
||||||
|
var unit = State.Players[player].RearGuards[slot];
|
||||||
|
if (unit is not null && unit.Rested)
|
||||||
|
yield return slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- старт партии ----------
|
// ---------- старт партии ----------
|
||||||
|
|
||||||
public void Start(int? firstPlayer = null)
|
public void Start(int? firstPlayer = null)
|
||||||
|
|
@ -1788,7 +1879,8 @@ public sealed class GameEngine
|
||||||
|
|
||||||
var card = p.Deck[index];
|
var card = p.Deck[index];
|
||||||
p.Deck.RemoveAt(index);
|
p.Deck.RemoveAt(index);
|
||||||
card.FaceUp = true;
|
// Стартовый Авангард лежит закрытым до конца муллигана.
|
||||||
|
card.FaceUp = false;
|
||||||
p.Vanguard = card;
|
p.Vanguard = card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1891,6 +1983,13 @@ public sealed class GameEngine
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Оба муллигана завершены: открываем стартовые Авангарды.
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
if (State.Players[i].Vanguard is { } vanguard)
|
||||||
|
vanguard.FaceUp = true;
|
||||||
|
}
|
||||||
|
|
||||||
State.ActivePlayer = State.FirstPlayer;
|
State.ActivePlayer = State.FirstPlayer;
|
||||||
StartTurn();
|
StartTurn();
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
|
|
@ -2006,6 +2105,14 @@ public sealed class GameEngine
|
||||||
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2)
|
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2)
|
||||||
or (-1, 1) or (1, -1);
|
or (-1, 1) or (1, -1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Колонки для атаки через границу игроков с учётом зеркального
|
||||||
|
/// отображения половины противника на клиенте. Атаковать могут только
|
||||||
|
/// V и передний ряд: центр V → 1, левый RC 3 → 2, правый RC 4 → 0.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SameColumnAcrossPlayers(int attackerSlot, int defenderSlot) =>
|
||||||
|
(attackerSlot, defenderSlot) is (-1, 1) or (3, 2) or (4, 0);
|
||||||
|
|
||||||
private CommandResult EnterBattle()
|
private CommandResult EnterBattle()
|
||||||
{
|
{
|
||||||
if (State.Phase != Phase.Main)
|
if (State.Phase != Phase.Main)
|
||||||
|
|
@ -2062,7 +2169,7 @@ public sealed class GameEngine
|
||||||
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
|
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
|
||||||
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
|
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
|
||||||
|
|
||||||
if (!SameColumn(attackerSlot, targetSlot))
|
if (!SameColumnAcrossPlayers(attackerSlot, targetSlot))
|
||||||
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
|
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2187,17 +2294,8 @@ public sealed class GameEngine
|
||||||
if (!def.IsSentinel && discardPrintingId is not null)
|
if (!def.IsSentinel && discardPrintingId is not null)
|
||||||
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
|
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
|
||||||
|
|
||||||
if (def.IsSentinel && discardPrintingId is not null)
|
// Sentinel сам по себе даёт ноль щита: оплата и отмена атаки разрешается
|
||||||
{
|
// отдельной автоматической способностью после размещения в круг защиты.
|
||||||
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
|
|
||||||
if (discardIndex < 0)
|
|
||||||
return CommandResult.Reject("Карты для оплаты нет в руке.");
|
|
||||||
|
|
||||||
if (State.Catalog.CardOf(defender.Hand[discardIndex].PrintingId).Clan != def.Clan)
|
|
||||||
return CommandResult.Reject("Для оплаты нужна карта того же клана.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentinel сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
|
|
||||||
var shield = def.IsSentinel ? 0 : def.Shield;
|
var shield = def.IsSentinel ? 0 : def.Shield;
|
||||||
|
|
||||||
defender.Hand.RemoveAt(index);
|
defender.Hand.RemoveAt(index);
|
||||||
|
|
@ -2207,17 +2305,6 @@ public sealed class GameEngine
|
||||||
battle.GuardShield += shield;
|
battle.GuardShield += shield;
|
||||||
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
|
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
|
||||||
|
|
||||||
if (def.IsSentinel && discardPrintingId is not null)
|
|
||||||
{
|
|
||||||
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
|
|
||||||
var discarded = defender.Hand[discardIndex];
|
|
||||||
defender.Hand.RemoveAt(discardIndex);
|
|
||||||
defender.Drop.Add(discarded);
|
|
||||||
|
|
||||||
battle.Nullified = true;
|
|
||||||
_events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId));
|
|
||||||
}
|
|
||||||
|
|
||||||
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
|
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
|
||||||
|
|
||||||
// способность на размещение в (GC) может встать на выбор игрока:
|
// способность на размещение в (GC) может встать на выбор игрока:
|
||||||
|
|
@ -2478,17 +2565,24 @@ public sealed class GameEngine
|
||||||
return CommandResult.Reject("Нет цели для +5000 силы.");
|
return CommandResult.Reject("Нет цели для +5000 силы.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
|
if (pending.Trigger == TriggerType.Critical)
|
||||||
{
|
{
|
||||||
var effectUnit = State.GetUnit(player, command.EffectTarget);
|
if (State.GetUnit(player, command.EffectTarget) is null)
|
||||||
if (effectUnit is null)
|
return CommandResult.Reject("Нет цели для Крит +1.");
|
||||||
return CommandResult.Reject("Нет цели для второго эффекта.");
|
}
|
||||||
|
else if (pending.Trigger == TriggerType.Stand)
|
||||||
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
|
{
|
||||||
|
if (command.EffectTarget < 0)
|
||||||
{
|
{
|
||||||
// если Арьергардов нет, поднимать нечего — второй эффект не применяется
|
// Если есть повёрнутый RC, игрок обязан выбрать его.
|
||||||
if (State.Players[player].RearGuards.Any(u => u is not null))
|
if (RestedRearGuardSlots(player).Any())
|
||||||
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
|
return CommandResult.Reject("Выберите повёрнутую карту в RC для подъёма.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var effectUnit = State.GetUnit(player, command.EffectTarget);
|
||||||
|
if (effectUnit is null || !effectUnit.Rested)
|
||||||
|
return CommandResult.Reject("Поднять можно только повёрнутую карту в RC.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ public static class MatchProjection
|
||||||
|
|
||||||
Vanguard = player.Vanguard is null
|
Vanguard = player.Vanguard is null
|
||||||
? null
|
? null
|
||||||
: ProjectCard(engine, player.Vanguard, forceFaceUp: true),
|
: ProjectCard(engine, player.Vanguard),
|
||||||
RearGuards = player.RearGuards
|
RearGuards = player.RearGuards
|
||||||
.Select(card => card is null
|
.Select(card => card is null
|
||||||
? null
|
? null
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,24 @@ public class GameEngineTests
|
||||||
engine.State.Catalog.CardOf(engine.State.Players[0].Vanguard!.PrintingId).Grade);
|
engine.State.Catalog.CardOf(engine.State.Players[0].Vanguard!.PrintingId).Grade);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Starting_Vanguard_Is_Face_Down_Until_Both_Mulligans_Finish()
|
||||||
|
{
|
||||||
|
var engine = NewEngine();
|
||||||
|
engine.Start(firstPlayer: 0);
|
||||||
|
|
||||||
|
Assert.False(engine.State.Players[0].Vanguard!.FaceUp);
|
||||||
|
Assert.False(engine.State.Players[1].Vanguard!.FaceUp);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted);
|
||||||
|
Assert.False(engine.State.Players[0].Vanguard.FaceUp);
|
||||||
|
Assert.False(engine.State.Players[1].Vanguard.FaceUp);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted);
|
||||||
|
Assert.True(engine.State.Players[0].Vanguard.FaceUp);
|
||||||
|
Assert.True(engine.State.Players[1].Vanguard.FaceUp);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
|
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
|
||||||
{
|
{
|
||||||
|
|
@ -428,6 +446,22 @@ public class GameEngineTests
|
||||||
Assert.False(rearGuard.Rested);
|
Assert.False(rearGuard.Rested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stand_Trigger_Rejects_Non_Rested_RearGuard()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackWithRearGuard();
|
||||||
|
StackDeck(engine.State.Players[0], Flogal, Gallatin);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
||||||
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
Assert.NotNull(engine.State.PendingTrigger);
|
||||||
|
|
||||||
|
var rearGuard = engine.State.Players[0].RearGuards[0]!;
|
||||||
|
rearGuard.Rested = false;
|
||||||
|
|
||||||
|
Assert.False(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect()
|
public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect()
|
||||||
{
|
{
|
||||||
|
|
@ -596,7 +630,19 @@ public class GameEngineTests
|
||||||
Give(engine, 1, Marron);
|
Give(engine, 1, Marron);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
||||||
Assert.True(engine.Execute(new GuardCommand(Iseult, Marron)).Accepted);
|
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
|
||||||
|
|
||||||
|
// Sentinel создаёт опциональную способность; принимаем её.
|
||||||
|
Assert.NotNull(engine.State.PendingAbility);
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
|
||||||
|
|
||||||
|
// Выбираем карту того же клана из руки для сброса.
|
||||||
|
Assert.NotNull(engine.State.PendingChoice);
|
||||||
|
var marronIndex = engine.State.Players[1].Hand
|
||||||
|
.FindIndex(c => c.PrintingId == Marron);
|
||||||
|
Assert.True(marronIndex >= 0);
|
||||||
|
Assert.True(engine.Execute(new ChooseCommand(marronIndex)).Accepted);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
Assert.Empty(engine.State.Players[1].Damage);
|
Assert.Empty(engine.State.Players[1].Damage);
|
||||||
|
|
@ -611,9 +657,31 @@ public class GameEngineTests
|
||||||
SetVanguard(engine, 1, Marron);
|
SetVanguard(engine, 1, Marron);
|
||||||
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
||||||
Give(engine, 1, Iseult);
|
Give(engine, 1, Iseult);
|
||||||
|
// Оставляем в руке только Sentinel: оплачивать способность нечем.
|
||||||
|
engine.State.Players[1].Hand.RemoveAll(c => c.PrintingId != Iseult);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
||||||
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
|
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
|
||||||
|
var pass = engine.Execute(new PassGuardCommand());
|
||||||
|
Assert.True(pass.Accepted, pass.Reason);
|
||||||
|
|
||||||
|
Assert.Single(engine.State.Players[1].Damage);
|
||||||
|
Assert.DoesNotContain(engine.Events, e => e is PerfectGuardEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Perfect_Guard_Decline_Keeps_Attack()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttack();
|
||||||
|
SetVanguard(engine, 1, Marron);
|
||||||
|
Give(engine, 1, Iseult);
|
||||||
|
Give(engine, 1, Marron);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
||||||
|
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
|
||||||
|
|
||||||
|
Assert.NotNull(engine.State.PendingAbility);
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
Assert.Single(engine.State.Players[1].Damage);
|
Assert.Single(engine.State.Players[1].Damage);
|
||||||
|
|
@ -621,14 +689,29 @@ public class GameEngineTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Perfect_Guard_Rejects_Wrong_Clan_Cost()
|
public void Perfect_Guard_Offers_Only_Same_Clan_Cards()
|
||||||
{
|
{
|
||||||
var engine = ReadyToAttack();
|
var engine = ReadyToAttack();
|
||||||
|
SetVanguard(engine, 1, Marron);
|
||||||
Give(engine, 1, Iseult);
|
Give(engine, 1, Iseult);
|
||||||
|
Give(engine, 1, Marron);
|
||||||
Give(engine, 1, Nehalem);
|
Give(engine, 1, Nehalem);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
||||||
Assert.False(engine.Execute(new GuardCommand(Iseult, Nehalem)).Accepted);
|
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
|
||||||
|
|
||||||
|
Assert.NotNull(engine.State.PendingAbility);
|
||||||
|
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
|
||||||
|
|
||||||
|
var choice = engine.State.PendingChoice;
|
||||||
|
Assert.NotNull(choice);
|
||||||
|
Assert.All(choice.Options, option =>
|
||||||
|
{
|
||||||
|
var card = engine.State.Players[1].Hand[option.Slot];
|
||||||
|
Assert.Equal(
|
||||||
|
engine.State.Catalog.CardOf(Iseult).Clan,
|
||||||
|
engine.State.Catalog.CardOf(card.PrintingId).Clan);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -1996,15 +2079,48 @@ public class GameEngineTests
|
||||||
engine.State.Players[0].RearGuards[3] = tejas;
|
engine.State.Players[0].RearGuards[3] = tejas;
|
||||||
tejas.Rested = false;
|
tejas.Rested = false;
|
||||||
|
|
||||||
|
var defender = engine.State.Players[1];
|
||||||
|
defender.RearGuards[2] = MakeCard(Barcgal);
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new AttackCommand(3, 2)).Accepted);
|
||||||
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
|
Assert.Null(defender.RearGuards[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tejas_Attacks_Back_Row_Right_Column()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
|
var tejas = MakeCard(Tejas);
|
||||||
|
engine.State.Players[0].RearGuards[4] = tejas;
|
||||||
|
tejas.Rested = false;
|
||||||
|
|
||||||
var defender = engine.State.Players[1];
|
var defender = engine.State.Players[1];
|
||||||
defender.RearGuards[0] = MakeCard(Barcgal);
|
defender.RearGuards[0] = MakeCard(Barcgal);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
Assert.True(engine.Execute(new AttackCommand(4, 0)).Accepted);
|
||||||
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
|
||||||
|
|
||||||
Assert.Null(defender.RearGuards[0]);
|
Assert.Null(defender.RearGuards[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tejas_Cannot_Attack_Opposite_Back_Row_Column()
|
||||||
|
{
|
||||||
|
var engine = ReadyToAttackRearGuard();
|
||||||
|
|
||||||
|
var tejas = MakeCard(Tejas);
|
||||||
|
engine.State.Players[0].RearGuards[3] = tejas;
|
||||||
|
tejas.Rested = false;
|
||||||
|
|
||||||
|
var defender = engine.State.Players[1];
|
||||||
|
defender.RearGuards[0] = MakeCard(Barcgal);
|
||||||
|
|
||||||
|
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
|
public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue