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_GPUResidentDrawerMode: 0
|
||||
m_SmallMeshScreenPercentage: 0
|
||||
m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
|
||||
m_ShadowType: 1
|
||||
m_LocalShadowsSupported: 0
|
||||
|
|
@ -100,18 +101,18 @@ MonoBehaviour:
|
|||
m_Keys: []
|
||||
m_Values:
|
||||
m_PrefilteringModeMainLightShadows: 3
|
||||
m_PrefilteringModeAdditionalLight: 4
|
||||
m_PrefilteringModeAdditionalLightShadows: 0
|
||||
m_PrefilteringModeAdditionalLight: 0
|
||||
m_PrefilteringModeAdditionalLightShadows: 2
|
||||
m_PrefilterXRKeywords: 1
|
||||
m_PrefilteringModeForwardPlus: 1
|
||||
m_PrefilteringModeForwardPlus: 2
|
||||
m_PrefilteringModeDeferredRendering: 0
|
||||
m_PrefilteringModeScreenSpaceOcclusion: 1
|
||||
m_PrefilteringModeScreenSpaceReflection: 1
|
||||
m_PrefilteringModeScreenSpaceOcclusion: 2
|
||||
m_PrefilteringModeScreenSpaceReflection: 0
|
||||
m_PrefilterWriteSmoothness: 1
|
||||
m_PrefilterDebugKeywords: 1
|
||||
m_PrefilterWriteRenderingLayers: 0
|
||||
m_PrefilterWriteRenderingLayers: 1
|
||||
m_PrefilterHDROutput: 1
|
||||
m_PrefilterAlphaOutput: 0
|
||||
m_PrefilterAlphaOutput: 1
|
||||
m_PrefilterSSAODepthNormals: 0
|
||||
m_PrefilterSSAOSourceDepthLow: 1
|
||||
m_PrefilterSSAOSourceDepthMedium: 1
|
||||
|
|
@ -123,20 +124,23 @@ MonoBehaviour:
|
|||
m_PrefilterSSAOSampleCountHigh: 1
|
||||
m_PrefilterDBufferMRT1: 1
|
||||
m_PrefilterDBufferMRT2: 1
|
||||
m_PrefilterDBufferMRT3: 0
|
||||
m_PrefilterSoftShadowsQualityLow: 0
|
||||
m_PrefilterSoftShadowsQualityMedium: 0
|
||||
m_PrefilterSoftShadowsQualityHigh: 0
|
||||
m_PrefilterDBufferMRT3: 1
|
||||
m_PrefilterSoftShadowsQualityLow: 1
|
||||
m_PrefilterSoftShadowsQualityMedium: 1
|
||||
m_PrefilterSoftShadowsQualityHigh: 1
|
||||
m_PrefilterSoftShadows: 0
|
||||
m_PrefilterScreenCoord: 1
|
||||
m_PrefilterScreenSpaceIrradiance: 0
|
||||
m_PrefilterScreenSpaceIrradiance: 1
|
||||
m_PrefilterNativeRenderPass: 1
|
||||
m_PrefilterUseLegacyLightmaps: 0
|
||||
m_PrefilterBicubicLightmapSampling: 0
|
||||
m_PrefilterBicubicLightmapSampling: 1
|
||||
m_PrefilterReflectionProbeRotation: 0
|
||||
m_PrefilterReflectionProbeBlending: 0
|
||||
m_PrefilterReflectionProbeBoxProjection: 0
|
||||
m_PrefilterReflectionProbeAtlas: 0
|
||||
m_PrefilterPointSamplingUpsampling: 1
|
||||
m_Light2DPrefilteringMode: 0
|
||||
m_Light2DKeptVariantCombos: []
|
||||
m_ShaderVariantLogLevel: 0
|
||||
m_ShadowCascades: 0
|
||||
m_Textures:
|
||||
|
|
|
|||
|
|
@ -72,7 +72,22 @@ MonoBehaviour:
|
|||
- rid: 6588237625532874761
|
||||
- rid: 6588237625532874762
|
||||
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_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
||||
m_RenderingLayerNames:
|
||||
|
|
|
|||
|
|
@ -28,15 +28,25 @@ MonoBehaviour:
|
|||
intercept: 1.9
|
||||
perfectGuard: 2
|
||||
inputLockTail: 0.15
|
||||
eventPause: 1.5
|
||||
postDrivePause: 1.5
|
||||
postTriggerPause: 1.5
|
||||
postDamagePause: 1.5
|
||||
arrowShowDuration: 0.25
|
||||
arrowHideDuration: 0.2
|
||||
moveDuration: 0.5
|
||||
importantMoveDuration: 0.9
|
||||
moveDelayBetweenCards: 0.18
|
||||
moveDuration: 0.8
|
||||
importantMoveDuration: 1.3
|
||||
moveDelayBetweenCards: 0.22
|
||||
arcHeight: 0.45
|
||||
cameraZoomDuration: 0.35
|
||||
triggerRevealHold: 1.2
|
||||
cameraReturnDuration: 0.4
|
||||
introOverheadHold: 1.5
|
||||
introCameraMoveDuration: 1
|
||||
introMainHold: 1.5
|
||||
introDrawDuration: 0.6
|
||||
cameraZoomDuration: 0.5
|
||||
vanguardRevealDuration: 0.8
|
||||
triggerRevealDuration: 0.35
|
||||
triggerRevealHold: 0.75
|
||||
cameraReturnDuration: 0.5
|
||||
cameraRezoomDuration: 0.3
|
||||
growDuration: 0.35
|
||||
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;
|
||||
|
||||
[Header("3D UX: движение")]
|
||||
public float moveDuration = 0.5f;
|
||||
public float importantMoveDuration = 0.9f;
|
||||
public float moveDelayBetweenCards = 0.18f;
|
||||
public float moveDuration = 0.8f;
|
||||
public float importantMoveDuration = 1.3f;
|
||||
public float moveDelayBetweenCards = 0.22f;
|
||||
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: камера")]
|
||||
public float cameraZoomDuration = 0.35f;
|
||||
public float triggerRevealHold = 1.2f;
|
||||
public float cameraReturnDuration = 0.4f;
|
||||
public float cameraZoomDuration = 0.5f;
|
||||
public float vanguardRevealDuration = 0.8f;
|
||||
public float triggerRevealDuration = 0.35f;
|
||||
public float triggerRevealHold = 0.75f;
|
||||
public float cameraReturnDuration = 0.5f;
|
||||
public float cameraRezoomDuration = 0.3f;
|
||||
|
||||
[Header("3D UX: стрелки")]
|
||||
|
|
@ -50,6 +58,12 @@ namespace Vanguard.Battle
|
|||
[Header("Ввод")]
|
||||
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("Стрелка атаки")]
|
||||
public float arrowShowDuration = 0.25f;
|
||||
public float arrowHideDuration = 0.2f;
|
||||
|
|
@ -89,7 +103,13 @@ namespace Vanguard.Battle
|
|||
importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration);
|
||||
moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards);
|
||||
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);
|
||||
vanguardRevealDuration = Mathf.Max(0.05f, vanguardRevealDuration);
|
||||
triggerRevealDuration = Mathf.Max(0.05f, triggerRevealDuration);
|
||||
triggerRevealHold = Mathf.Max(0f, triggerRevealHold);
|
||||
cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration);
|
||||
cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration);
|
||||
|
|
@ -108,6 +128,10 @@ namespace Vanguard.Battle
|
|||
intercept = Mathf.Max(0.05f, intercept);
|
||||
perfectGuard = Mathf.Max(0.05f, perfectGuard);
|
||||
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);
|
||||
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,41 @@ namespace Vanguard.Battle
|
|||
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)
|
||||
{
|
||||
if (!_hasOrigin)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using UnityEngine;
|
||||
using Vanguard.Battle;
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ namespace Vanguard.Battle
|
|||
{
|
||||
Idle,
|
||||
Zooming,
|
||||
Revealing,
|
||||
Holding,
|
||||
Returning,
|
||||
}
|
||||
|
|
@ -22,6 +24,9 @@ namespace Vanguard.Battle
|
|||
|
||||
private Stage _stage;
|
||||
private float _deadline;
|
||||
private float _zoomDeadline;
|
||||
private float _revealDeadline;
|
||||
private float _resolveDeadline;
|
||||
private bool _chainActive;
|
||||
private bool _restoreRequested;
|
||||
private bool _lockHeld;
|
||||
|
|
@ -36,6 +41,18 @@ namespace Vanguard.Battle
|
|||
|
||||
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(
|
||||
AnimationConfig config,
|
||||
Vector3 worldPoint)
|
||||
|
|
@ -43,6 +60,8 @@ namespace Vanguard.Battle
|
|||
if (_camera == null)
|
||||
return;
|
||||
|
||||
var wasIdle = _stage == Stage.Idle;
|
||||
|
||||
if (!_chainActive)
|
||||
{
|
||||
_camera.BeginChain();
|
||||
|
|
@ -54,13 +73,21 @@ namespace Vanguard.Battle
|
|||
var zoom = config != null
|
||||
? config.cameraZoomDuration
|
||||
: 0.35f;
|
||||
var hold = config != null
|
||||
var reveal = config != null
|
||||
? config.triggerRevealDuration
|
||||
: 0.35f;
|
||||
var pause = config != null
|
||||
? config.triggerRevealHold
|
||||
: 1.2f;
|
||||
: 0.75f;
|
||||
|
||||
_camera.ZoomTo(worldPoint, zoom);
|
||||
_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)
|
||||
|
|
@ -99,15 +126,30 @@ namespace Vanguard.Battle
|
|||
return;
|
||||
|
||||
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
|
||||
? config.cameraReturnDuration
|
||||
: 0.4f;
|
||||
_restoreRequested = true;
|
||||
_camera.ReturnToOrigin(duration);
|
||||
_stage = Stage.Returning;
|
||||
_deadline = Time.unscaledTime + duration;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_stage == Stage.Returning
|
||||
|
|
@ -121,6 +163,8 @@ namespace Vanguard.Battle
|
|||
_chainActive = false;
|
||||
_camera.Restore();
|
||||
}
|
||||
|
||||
OnFocusChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,10 +188,18 @@ namespace Vanguard.Battle
|
|||
|
||||
public void Cancel()
|
||||
{
|
||||
var wasIdle = _stage == Stage.Idle;
|
||||
_stage = Stage.Idle;
|
||||
_chainActive = false;
|
||||
Release();
|
||||
_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 UnityEngine;
|
||||
using Vanguard.Battle;
|
||||
|
|
@ -19,12 +20,17 @@ namespace Vanguard.Board3D
|
|||
public Vector3 FromScale;
|
||||
public float Duration;
|
||||
public float ArcHeight;
|
||||
public bool Sequential;
|
||||
public Action<Card3D> OnCompleted;
|
||||
public Action<Card3D> OnStart;
|
||||
public bool FromCurrent;
|
||||
}
|
||||
|
||||
private sealed class ActiveMove
|
||||
{
|
||||
public Card3D Card;
|
||||
public float Remaining;
|
||||
public Action<Card3D> OnCompleted;
|
||||
}
|
||||
|
||||
private readonly Queue<Request> _queue = new Queue<Request>();
|
||||
|
|
@ -86,7 +92,11 @@ namespace Vanguard.Board3D
|
|||
Quaternion fromRotation,
|
||||
Vector3 fromScale,
|
||||
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)
|
||||
return;
|
||||
|
|
@ -104,9 +114,47 @@ namespace Vanguard.Board3D
|
|||
FromScale = fromScale,
|
||||
Duration = duration,
|
||||
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)
|
||||
{
|
||||
if (_active.Count > 0)
|
||||
|
|
@ -130,6 +178,7 @@ namespace Vanguard.Board3D
|
|||
if (!card.IsMoving)
|
||||
{
|
||||
_active.RemoveAt(i);
|
||||
active.OnCompleted?.Invoke(card);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +188,7 @@ namespace Vanguard.Board3D
|
|||
{
|
||||
card.StopMove(snap: true);
|
||||
_active.RemoveAt(i);
|
||||
active.OnCompleted?.Invoke(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +206,12 @@ namespace Vanguard.Board3D
|
|||
|
||||
public void CancelAll(bool snapCards = false)
|
||||
{
|
||||
foreach (var request in _queue)
|
||||
{
|
||||
if (request?.Card != null)
|
||||
request.Card.CancelHold();
|
||||
}
|
||||
|
||||
_queue.Clear();
|
||||
foreach (var active in _active)
|
||||
{
|
||||
|
|
@ -173,17 +229,27 @@ namespace Vanguard.Board3D
|
|||
if (card == null)
|
||||
return;
|
||||
|
||||
var wasQueued = false;
|
||||
var remaining = new Queue<Request>();
|
||||
while (_queue.Count > 0)
|
||||
{
|
||||
var request = _queue.Dequeue();
|
||||
if (request.Card != card)
|
||||
{
|
||||
remaining.Enqueue(request);
|
||||
}
|
||||
else
|
||||
{
|
||||
wasQueued = true;
|
||||
}
|
||||
}
|
||||
|
||||
while (remaining.Count > 0)
|
||||
_queue.Enqueue(remaining.Dequeue());
|
||||
|
||||
if (wasQueued)
|
||||
card.CancelHold();
|
||||
|
||||
for (var i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_active[i]?.Card != card)
|
||||
|
|
@ -202,6 +268,14 @@ namespace Vanguard.Board3D
|
|||
if (_queue.Count == 0)
|
||||
return;
|
||||
|
||||
// Последовательные запросы (добор, возврат в колоду) идут строго
|
||||
// по одной карте: следующая стартует только после предыдущей.
|
||||
if (_queue.Peek().Sequential && _active.Count > 0)
|
||||
{
|
||||
_delay = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
var request = _queue.Dequeue();
|
||||
var card = request.Card;
|
||||
if (card == null || card.IsDragging)
|
||||
|
|
@ -210,10 +284,18 @@ namespace Vanguard.Board3D
|
|||
return;
|
||||
}
|
||||
|
||||
request.OnStart?.Invoke(card);
|
||||
|
||||
card.StartArcMove(
|
||||
request.FromPosition,
|
||||
request.FromRotation,
|
||||
request.FromScale,
|
||||
request.FromCurrent
|
||||
? card.transform.position
|
||||
: request.FromPosition,
|
||||
request.FromCurrent
|
||||
? card.transform.rotation
|
||||
: request.FromRotation,
|
||||
request.FromCurrent
|
||||
? card.transform.localScale
|
||||
: request.FromScale,
|
||||
request.Duration,
|
||||
request.ArcHeight);
|
||||
|
||||
|
|
@ -223,8 +305,13 @@ namespace Vanguard.Board3D
|
|||
{
|
||||
Card = card,
|
||||
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
|
||||
OnCompleted = request.OnCompleted,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
request.OnCompleted?.Invoke(card);
|
||||
}
|
||||
|
||||
_delay = Mathf.Max(0.01f, _config != null
|
||||
? _config.moveDelayBetweenCards
|
||||
|
|
|
|||
|
|
@ -30,27 +30,34 @@ MonoBehaviour:
|
|||
dragThresholdPixels: 7
|
||||
smoothMoveSpeed: 16
|
||||
smoothRotateSpeed: 14
|
||||
handHoverLift: 0.08
|
||||
handHoverLift: 0.1
|
||||
handCenterZ: -5.3
|
||||
handHeight: 1.12
|
||||
handTilt: 58
|
||||
handSpacing: 0.42
|
||||
handSpacing: 0.28
|
||||
handMaxWidth: 7.4
|
||||
handArc: 0.035
|
||||
handViewportX: 0.44
|
||||
handViewportX: 0.5
|
||||
handViewportY: 0.1
|
||||
handViewDistance: 4
|
||||
handViewMaxWidth: 1.4
|
||||
handViewMaxWidth: 2.52
|
||||
handViewScale: 0.5
|
||||
handViewRoll: 3.2
|
||||
handViewArc: 0.02
|
||||
opponentHandEnabled: 1
|
||||
opponentHandZ: 4.3
|
||||
opponentHandHeight: 0.75
|
||||
opponentHandSpacing: 0.42
|
||||
opponentHandMaxWidth: 2.4
|
||||
opponentHandScale: 0.8
|
||||
opponentHandRoll: 2.5
|
||||
opponentHandSpacing: 0.28
|
||||
opponentHandMaxWidth: 3.78
|
||||
opponentHandScale: 0.9
|
||||
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
|
||||
guardianSpacing: 0.62
|
||||
guardianMaxWidth: 3.6
|
||||
|
|
@ -60,6 +67,9 @@ MonoBehaviour:
|
|||
cameraPosition: {x: 0, y: 4.2, z: -8}
|
||||
cameraTarget: {x: 0, y: 0, z: 0}
|
||||
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}
|
||||
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, 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 smoothMoveSpeed = 16f;
|
||||
public float smoothRotateSpeed = 14f;
|
||||
public float handHoverLift = 0.08f;
|
||||
[Tooltip("Подъём карты при наведении, доля её высоты.")]
|
||||
[Range(0f, 0.5f)] public float handHoverLift = 0.1f;
|
||||
|
||||
[Header("Рука на столе (fallback без камеры)")]
|
||||
public float handCenterZ = -5.3f;
|
||||
public float handHeight = 1.12f;
|
||||
[Range(0f, 85f)] public float handTilt = 58f;
|
||||
public float handSpacing = 0.42f;
|
||||
public float handSpacing = 0.28f;
|
||||
public float handMaxWidth = 7.4f;
|
||||
public float handArc = 0.035f;
|
||||
|
||||
[Header("Рука в поле зрения")]
|
||||
[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 — середина.")]
|
||||
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
|
||||
[Tooltip("Расстояние от камеры до веера руки, метры.")]
|
||||
public float handViewDistance = 4.0f;
|
||||
public float handViewMaxWidth = 1.4f;
|
||||
public float handViewMaxWidth = 2.52f;
|
||||
public float handViewScale = 0.5f;
|
||||
public float handViewRoll = 3.2f;
|
||||
public float handViewArc = 0.02f;
|
||||
|
|
@ -67,10 +68,19 @@ namespace Vanguard.Board3D
|
|||
public bool opponentHandEnabled = true;
|
||||
public float opponentHandZ = 4.3f;
|
||||
public float opponentHandHeight = 0.75f;
|
||||
public float opponentHandSpacing = 0.42f;
|
||||
public float opponentHandMaxWidth = 2.4f;
|
||||
public float opponentHandScale = 0.8f;
|
||||
public float opponentHandRoll = 2.5f;
|
||||
public float opponentHandSpacing = 0.28f;
|
||||
public float opponentHandMaxWidth = 3.78f;
|
||||
public float opponentHandScale = 0.9f;
|
||||
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("Круг защиты")]
|
||||
public float guardianRowGap = 0.55f;
|
||||
|
|
@ -80,6 +90,8 @@ namespace Vanguard.Board3D
|
|||
[Header("Стопки")]
|
||||
[Tooltip("Расстояние от центра стола до зоны урона.")]
|
||||
public float damageX = 3.0f;
|
||||
[Tooltip("Сдвиг следующей карты урона в долях ширины карты. 0.3 = новая карта перекрывает 30% предыдущей.")]
|
||||
[Range(0.1f, 0.9f)] public float damageStackOffset = 0.3f;
|
||||
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
||||
public float pileX = 2.95f;
|
||||
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
|
||||
|
|
@ -95,6 +107,12 @@ namespace Vanguard.Board3D
|
|||
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
|
||||
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("Палитра")]
|
||||
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
|
||||
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
|
||||
|
|
@ -156,6 +174,8 @@ namespace Vanguard.Board3D
|
|||
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
|
||||
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
|
||||
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);
|
||||
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
|
||||
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
|
||||
|
|
@ -167,6 +187,7 @@ namespace Vanguard.Board3D
|
|||
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
|
||||
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
|
||||
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
|
||||
opponentHandArc = Mathf.Max(0f, opponentHandArc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,16 +239,22 @@ namespace Vanguard.Board3D
|
|||
/// <summary>Текстура лица и акцентный цвет карты. Front может быть null.</summary>
|
||||
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;
|
||||
Accent = accent;
|
||||
DisplayName = displayName;
|
||||
Trigger = trigger;
|
||||
}
|
||||
|
||||
public Texture2D Front { get; }
|
||||
public Color Accent { get; }
|
||||
public string DisplayName { get; }
|
||||
public string Trigger { get; }
|
||||
|
||||
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,9 +67,16 @@ namespace Vanguard.Board3D
|
|||
var zSign = ZSign(localSeat);
|
||||
var sideSign = localSeat == 0 ? -1f : 1f;
|
||||
var x = sideSign * _config.damageX;
|
||||
// Карты лежат на боку: вертикальный шаг задаётся шириной карты.
|
||||
// 0.3 — новая карта перекрывает 30% предыдущей.
|
||||
var step = _config.cardWidth
|
||||
* _config.cardScale
|
||||
* _config.damageStackOffset;
|
||||
var z = zSign * (_config.frontRowZ + 0.4f)
|
||||
+ zSign * index * 0.4f;
|
||||
return new Vector3(x, _config.DamageCenterY, z);
|
||||
+ zSign * index * step;
|
||||
// Небольшой подъём по Y: новая карта рисуется поверх старой.
|
||||
var y = _config.DamageCenterY + (index * 0.0015f);
|
||||
return new Vector3(x, y, z);
|
||||
}
|
||||
|
||||
/// <summary>Колода и дроп сбоку от поля, у своего края.</summary>
|
||||
|
|
@ -152,9 +159,11 @@ namespace Vanguard.Board3D
|
|||
|
||||
var offset = index - (safeCount - 1) * 0.5f;
|
||||
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
|
||||
- (1f - Mathf.Abs(offset) / safeCount) * 0.02f;
|
||||
+ (1f - Mathf.Abs(offset) / safeCount) * _config.opponentHandArc;
|
||||
var roll = -offset * _config.opponentHandRoll;
|
||||
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),
|
||||
},
|
||||
uv = new[]
|
||||
{
|
||||
new Vector2(0f, 0f),
|
||||
new Vector2(1f, 0f),
|
||||
new Vector2(1f, 1f),
|
||||
new Vector2(0f, 1f),
|
||||
},
|
||||
// Задняя грань смотрит вниз: без зеркального U рубашка
|
||||
// читается отражённой, когда карта перевёрнута лицом вниз.
|
||||
uv = front
|
||||
? new[]
|
||||
{
|
||||
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 },
|
||||
triangles = new[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,6 +59,36 @@ namespace Vanguard.Board3D
|
|||
view._renderer = renderer;
|
||||
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>();
|
||||
collider.size = new Vector3(
|
||||
config.cardWidth * 1.12f,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Vanguard.Board3D
|
|||
private MeshRenderer _front;
|
||||
private MeshRenderer _back;
|
||||
private MeshRenderer _edge;
|
||||
private MeshRenderer _frame;
|
||||
private MeshRenderer _highlight;
|
||||
private BoxCollider _collider;
|
||||
private MaterialPropertyBlock _block;
|
||||
|
|
@ -35,6 +36,20 @@ namespace Vanguard.Board3D
|
|||
private float _arcElapsed;
|
||||
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 Vector3 _dragOriginPosition;
|
||||
private Quaternion _dragOriginRotation;
|
||||
|
|
@ -48,9 +63,20 @@ namespace Vanguard.Board3D
|
|||
public Vector3 TargetPosition => _targetPosition;
|
||||
public float TargetYaw => _targetRotation.eulerAngles.y;
|
||||
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 =>
|
||||
!_dragging
|
||||
&& (_arcMoving
|
||||
|| _revealActive
|
||||
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
||||
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
||||
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
||||
|
|
@ -97,6 +123,21 @@ namespace Vanguard.Board3D
|
|||
_front.sharedMaterial = BoardMaterials.White;
|
||||
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");
|
||||
backGo.transform.SetParent(transform, false);
|
||||
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
|
||||
|
|
@ -108,12 +149,15 @@ namespace Vanguard.Board3D
|
|||
|
||||
var highlightGo = new GameObject("Highlight");
|
||||
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>();
|
||||
filter.sharedMesh = BoardMeshLibrary.Frame(
|
||||
width * 1.16f,
|
||||
height * 1.16f,
|
||||
Mathf.Max(0.025f, width * 0.035f));
|
||||
width,
|
||||
height,
|
||||
Mathf.Max(0.006f, width * 0.009f));
|
||||
_highlight = highlightGo.AddComponent<MeshRenderer>();
|
||||
_highlight.sharedMaterial = BoardMaterials.Highlight;
|
||||
BoardMaterials.SetShadows(_highlight, false);
|
||||
|
|
@ -122,7 +166,7 @@ namespace Vanguard.Board3D
|
|||
_stats = BoardText.Create(
|
||||
transform,
|
||||
string.Empty,
|
||||
0.028f,
|
||||
0.034f,
|
||||
Color.white,
|
||||
Vector3.zero);
|
||||
_stats.gameObject.SetActive(false);
|
||||
|
|
@ -140,7 +184,9 @@ namespace Vanguard.Board3D
|
|||
name = "Card3D " + reference;
|
||||
}
|
||||
|
||||
public void SetArt(Texture2D texture, Color accent)
|
||||
public void SetArt(
|
||||
Texture2D texture,
|
||||
Color accent)
|
||||
{
|
||||
if (_front == null)
|
||||
return;
|
||||
|
|
@ -156,6 +202,19 @@ namespace Vanguard.Board3D
|
|||
_front.sharedMaterial,
|
||||
texture != null ? Color.white : accent);
|
||||
_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)
|
||||
|
|
@ -190,7 +249,7 @@ namespace Vanguard.Board3D
|
|||
_stats.transform.position = new Vector3(
|
||||
position.x,
|
||||
position.y + 0.015f,
|
||||
position.z - (_config.cardHeight * 0.62f));
|
||||
position.z - (_config.cardHeight * 0.68f));
|
||||
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +266,13 @@ namespace Vanguard.Board3D
|
|||
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
|
||||
var restYaw = rested ? (damageLayout ? -90f : 90f) : 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;
|
||||
}
|
||||
|
||||
|
|
@ -240,11 +305,60 @@ namespace Vanguard.Board3D
|
|||
{
|
||||
_arcMoving = false;
|
||||
_immediate = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
transform.position = position;
|
||||
transform.rotation = rotation;
|
||||
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(
|
||||
Vector3 fromPosition,
|
||||
Quaternion fromRotation,
|
||||
|
|
@ -257,6 +371,8 @@ namespace Vanguard.Board3D
|
|||
|
||||
_arcMoving = false;
|
||||
_immediate = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
|
||||
var distance = Vector3.Distance(fromPosition, _targetPosition);
|
||||
var angle = Quaternion.Angle(fromRotation, _targetRotation);
|
||||
|
|
@ -283,10 +399,12 @@ namespace Vanguard.Board3D
|
|||
|
||||
public void StopMove(bool snap)
|
||||
{
|
||||
if (!_arcMoving)
|
||||
if (!_arcMoving && !_hold && !_revealActive)
|
||||
return;
|
||||
|
||||
_arcMoving = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
if (snap)
|
||||
Snap();
|
||||
}
|
||||
|
|
@ -318,6 +436,8 @@ namespace Vanguard.Board3D
|
|||
return;
|
||||
|
||||
_dragging = true;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
_dragOriginPosition = transform.position;
|
||||
_dragOriginRotation = transform.rotation;
|
||||
_targetScale = _baseScale * 1.05f;
|
||||
|
|
@ -326,23 +446,53 @@ namespace Vanguard.Board3D
|
|||
SetHighlight(null);
|
||||
}
|
||||
|
||||
public float DragLift => Ref.Zone == BoardZone.Hand
|
||||
? _config.cardHeight * _baseScale.x * _config.handHoverLift
|
||||
: _config.dragLift;
|
||||
|
||||
public void DragTo(Vector3 worldPosition)
|
||||
{
|
||||
if (!_dragging)
|
||||
return;
|
||||
|
||||
var yaw = _targetRotation.eulerAngles.y;
|
||||
// Сохраняем исходную ориентацию карты: карта из руки остаётся
|
||||
// лицом к игроку, а не «ложится» на стол при перетаскивании.
|
||||
// Высота та же, что у hover: 10% высоты карты.
|
||||
transform.position = new Vector3(
|
||||
worldPosition.x,
|
||||
_config.dragLift + (_config.cardThickness * 0.5f),
|
||||
DragLift + (_config.cardThickness * 0.5f),
|
||||
worldPosition.z);
|
||||
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
|
||||
transform.rotation = _dragOriginRotation;
|
||||
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)
|
||||
{
|
||||
_dragging = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
if (_collider != null)
|
||||
_collider.enabled = true;
|
||||
_targetPosition = position;
|
||||
|
|
@ -354,6 +504,8 @@ namespace Vanguard.Board3D
|
|||
public void ReturnToOrigin()
|
||||
{
|
||||
_dragging = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
if (_collider != null)
|
||||
_collider.enabled = true;
|
||||
_targetPosition = _dragOriginPosition;
|
||||
|
|
@ -365,6 +517,8 @@ namespace Vanguard.Board3D
|
|||
public void Snap()
|
||||
{
|
||||
_arcMoving = false;
|
||||
_hold = false;
|
||||
_revealActive = false;
|
||||
transform.position = _targetPosition;
|
||||
transform.rotation = _targetRotation;
|
||||
transform.localScale = _targetScale;
|
||||
|
|
@ -376,6 +530,36 @@ namespace Vanguard.Board3D
|
|||
if (_dragging || deltaTime <= 0f)
|
||||
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)
|
||||
{
|
||||
_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");
|
||||
screen.SetActive(true);
|
||||
|
||||
var self = ReferenceRect(FindRect("ZoneBlocksSelf"));
|
||||
var opponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
|
||||
var phase = ReferenceRect(FindRect("PhasePanel"));
|
||||
var self = ReferenceRect(FindRect("PlayerStatsSelf"));
|
||||
var opponent = ReferenceRect(FindRect("PlayerStatsOpponent"));
|
||||
var stats = ReferenceRect(FindRect("CombatStats"));
|
||||
|
||||
AssertWithinScreen(self, "self zones");
|
||||
AssertWithinScreen(opponent, "opponent zones");
|
||||
AssertWithinScreen(phase, "phase panel");
|
||||
AssertWithinScreen(self, "self stats");
|
||||
AssertWithinScreen(opponent, "opponent stats");
|
||||
AssertWithinScreen(stats, "combat stats");
|
||||
Assert.IsFalse(self.Overlaps(opponent));
|
||||
Assert.IsFalse(self.Overlaps(phase));
|
||||
Assert.IsFalse(opponent.Overlaps(phase));
|
||||
Assert.IsFalse(opponent.Overlaps(stats));
|
||||
Assert.IsFalse(phase.Overlaps(stats));
|
||||
|
||||
var toggle = Resources.FindObjectsOfTypeAll<Button>()
|
||||
.First(button => button.name == "ToggleIntentPanel"
|
||||
&& button.gameObject.scene.IsValid());
|
||||
toggle.onClick.Invoke();
|
||||
|
||||
var movedOpponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
|
||||
var movedPhase = ReferenceRect(FindRect("PhasePanel"));
|
||||
Assert.Greater(movedOpponent.xMin, opponent.xMin);
|
||||
Assert.Greater(movedPhase.xMin, phase.xMin);
|
||||
AssertWithinScreen(movedOpponent, "opponent zones after hide");
|
||||
AssertWithinScreen(movedPhase, "phase panel after hide");
|
||||
var debug = FindRect("DebugPanel");
|
||||
Assert.IsTrue(debug.gameObject.activeSelf, "Debug panel should open by toggle");
|
||||
AssertWithinScreen(ReferenceRect(debug), "debug panel");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -228,28 +220,28 @@ namespace Vanguard.Tests
|
|||
var usedField = typeof(BattleView)
|
||||
.GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
var rebuild = typeof(BattleView)
|
||||
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
viewField.SetValue(battle, match);
|
||||
|
||||
usedField.SetValue(battle, true);
|
||||
rebuild.Invoke(battle, new object[] { match });
|
||||
Assert.IsFalse(
|
||||
FindRect("PhasePanel").gameObject.activeSelf,
|
||||
"Phase panel must disappear after the mulligan exchange");
|
||||
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||
"Confirmation bar must disappear after the mulligan exchange");
|
||||
Assert.IsFalse(
|
||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||
.Any(go => go.name == "Phase_FinishMulliganIntent"),
|
||||
.Any(go => go.name == "Intent_FinishMulliganIntent"),
|
||||
"Finish mulligan button must not remain after the exchange");
|
||||
|
||||
usedField.SetValue(battle, false);
|
||||
rebuild.Invoke(battle, new object[] { match });
|
||||
Assert.IsTrue(
|
||||
FindRect("PhasePanel").gameObject.activeSelf,
|
||||
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||
"Finish mulligan button must be available before the exchange");
|
||||
Assert.IsTrue(
|
||||
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||
.Any(go => go.name == "Phase_FinishMulliganIntent"));
|
||||
.Any(go => go.name == "Intent_FinishMulliganIntent"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -259,7 +251,7 @@ namespace Vanguard.Tests
|
|||
}
|
||||
|
||||
[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();
|
||||
config.Load();
|
||||
|
|
@ -290,13 +282,13 @@ namespace Vanguard.Tests
|
|||
var viewField = typeof(BattleView)
|
||||
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
var rebuild = typeof(BattleView)
|
||||
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
viewField.SetValue(battle, match);
|
||||
rebuild.Invoke(battle, new object[] { match });
|
||||
|
||||
Assert.IsTrue(
|
||||
FindRect("PhasePanel").gameObject.activeSelf,
|
||||
"Guard controls must be in the action panel");
|
||||
FindRect("ConfirmationBar").gameObject.activeSelf,
|
||||
"Guard controls must be in the confirmation bar");
|
||||
var guardButton = Resources.FindObjectsOfTypeAll<Button>()
|
||||
.FirstOrDefault(button => button.name == "Phase_Guard");
|
||||
Assert.IsNotNull(guardButton);
|
||||
|
|
@ -311,10 +303,6 @@ namespace Vanguard.Tests
|
|||
Resources.FindObjectsOfTypeAll<GameObject>()
|
||||
.Any(go => go.name == "Phase_PassGuardIntent"),
|
||||
"Pass guard must be the same single button");
|
||||
Assert.AreEqual(
|
||||
"ДЕЙСТВИЯ",
|
||||
Resources.FindObjectsOfTypeAll<Text>()
|
||||
.First(text => text.name == "PhaseTitle").text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -762,17 +750,17 @@ namespace Vanguard.Tests
|
|||
&& card.Ref.InstanceId == 7));
|
||||
|
||||
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||
.FirstOrDefault(go => go.name == "ZoneBlocksSelf");
|
||||
.FirstOrDefault(go => go.name == "PlayerStatsSelf");
|
||||
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||
.FirstOrDefault(go => go.name == "ZoneBlocksOpponent");
|
||||
.FirstOrDefault(go => go.name == "PlayerStatsOpponent");
|
||||
Assert.IsNotNull(selfZoneRoot);
|
||||
Assert.IsNotNull(opponentZoneRoot);
|
||||
|
||||
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
|
||||
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
|
||||
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Рука 1")));
|
||||
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Душа 1")));
|
||||
Assert.IsTrue(opponentLabels.Any(text => text.text.Contains("Рука 0")));
|
||||
Assert.IsTrue(selfLabels.Any(text => text.text == "Рука"));
|
||||
Assert.IsTrue(selfLabels.Any(text => text.text.StartsWith("Душа")));
|
||||
Assert.IsTrue(opponentLabels.Any(text => text.text == "Рука"));
|
||||
|
||||
Cleanup();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using Vanguard.Battle;
|
||||
|
|
@ -75,7 +76,7 @@ namespace Vanguard.Tests
|
|||
&& card.Ref.Slot == 3);
|
||||
Assert.IsTrue(rested.IsRested);
|
||||
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 =>
|
||||
card.Ref.Zone == BoardZone.Damage
|
||||
|
|
@ -186,7 +187,611 @@ namespace Vanguard.Tests
|
|||
}
|
||||
|
||||
[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 board = GameBoard3D.Create(null, config);
|
||||
|
|
@ -226,11 +831,11 @@ namespace Vanguard.Tests
|
|||
var layout = new PlaymatLayout(config);
|
||||
|
||||
Assert.AreEqual(
|
||||
90f,
|
||||
-90f,
|
||||
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
|
||||
0.1f);
|
||||
Assert.AreEqual(
|
||||
-90f,
|
||||
90f,
|
||||
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
|
||||
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]
|
||||
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ GraphicsSettings:
|
|||
- {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
|
||||
m_PreloadedShaders: []
|
||||
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_CustomRenderPipeline: {fileID: 0}
|
||||
m_TransparencySortMode: 0
|
||||
|
|
@ -49,6 +58,7 @@ GraphicsSettings:
|
|||
m_FogStripping: 0
|
||||
m_InstancingStripping: 0
|
||||
m_BrgStripping: 0
|
||||
m_DefaultLightBaker: 0
|
||||
m_LightmapKeepPlain: 1
|
||||
m_LightmapKeepDirCombined: 1
|
||||
m_LightmapKeepDynamicPlain: 1
|
||||
|
|
@ -61,6 +71,11 @@ GraphicsSettings:
|
|||
m_AlbedoSwatchInfos: []
|
||||
m_RenderPipelineGlobalSettingsMap:
|
||||
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
||||
m_ShaderBuildSettings:
|
||||
keywordDeclarationOverrides: []
|
||||
numInternalDefines: 0
|
||||
defines: []
|
||||
compilerSettings: []
|
||||
m_LightsUseLinearIntensity: 1
|
||||
m_LightsUseColorTemperature: 1
|
||||
m_LogWhenShaderIsCompiled: 0
|
||||
|
|
|
|||
|
|
@ -9,10 +9,17 @@ UnityConnectSettings:
|
|||
m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events
|
||||
m_EventUrl: https://cdp.cloud.unity3d.com/v1/events
|
||||
m_ConfigUrl: https://config.uca.cloud.unity3d.com
|
||||
m_DashboardUrl: https://dashboard.unity3d.com
|
||||
m_TestInitMode: 0
|
||||
CrashReportingSettings:
|
||||
m_EventUrl: https://perf-events.cloud.unity3d.com
|
||||
InsightsSettings:
|
||||
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
|
||||
CrashReportingSettings:
|
||||
serializedVersion: 2
|
||||
m_EventUrl: https://perf-events.cloud.unity3d.com
|
||||
m_EnableCloudDiagnosticsReporting: 0
|
||||
m_LogBufferSize: 10
|
||||
m_CaptureEditorExceptions: 1
|
||||
UnityPurchasingSettings:
|
||||
|
|
@ -22,6 +29,7 @@ UnityConnectSettings:
|
|||
m_Enabled: 0
|
||||
m_TestMode: 0
|
||||
m_InitializeOnStartup: 1
|
||||
m_PackageRequiringCoreStatsPresent: 0
|
||||
UnityAdsSettings:
|
||||
m_Enabled: 0
|
||||
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 })
|
||||
.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)
|
||||
return;
|
||||
|
||||
|
|
@ -131,6 +143,75 @@ public sealed class GameEngine
|
|||
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)
|
||||
{
|
||||
if (triggered.Count == 0)
|
||||
|
|
@ -1452,18 +1533,24 @@ public sealed class GameEngine
|
|||
|
||||
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))
|
||||
{
|
||||
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
|
||||
if (pending.Trigger == TriggerType.Critical)
|
||||
{
|
||||
foreach (var effectSlot in UnitSlots(pending.Player))
|
||||
{
|
||||
if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
|
||||
continue;
|
||||
|
||||
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
|
||||
|
|
@ -1575,7 +1662,7 @@ public sealed class GameEngine
|
|||
|
||||
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
|
||||
&& (slot is 3 or 4
|
||||
|| (canSnipe && SameColumn(attackerSlot, slot)))));
|
||||
|| (canSnipe && SameColumnAcrossPlayers(attackerSlot, slot)))));
|
||||
|
||||
foreach (var target in targets)
|
||||
commands.Add(new AttackCommand(attackerSlot, target));
|
||||
|
|
@ -1614,16 +1701,9 @@ public sealed class GameEngine
|
|||
|
||||
if (def.IsSentinel)
|
||||
{
|
||||
// Оплата Sentinel теперь идёт отдельной автоматической способностью
|
||||
// после размещения в круг защиты.
|
||||
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)
|
||||
{
|
||||
|
|
@ -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)
|
||||
|
|
@ -1788,7 +1879,8 @@ public sealed class GameEngine
|
|||
|
||||
var card = p.Deck[index];
|
||||
p.Deck.RemoveAt(index);
|
||||
card.FaceUp = true;
|
||||
// Стартовый Авангард лежит закрытым до конца муллигана.
|
||||
card.FaceUp = false;
|
||||
p.Vanguard = card;
|
||||
}
|
||||
|
||||
|
|
@ -1891,6 +1983,13 @@ public sealed class GameEngine
|
|||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
// Оба муллигана завершены: открываем стартовые Авангарды.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
if (State.Players[i].Vanguard is { } vanguard)
|
||||
vanguard.FaceUp = true;
|
||||
}
|
||||
|
||||
State.ActivePlayer = State.FirstPlayer;
|
||||
StartTurn();
|
||||
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)
|
||||
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()
|
||||
{
|
||||
if (State.Phase != Phase.Main)
|
||||
|
|
@ -2062,7 +2169,7 @@ public sealed class GameEngine
|
|||
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
|
||||
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
|
||||
|
||||
if (!SameColumn(attackerSlot, targetSlot))
|
||||
if (!SameColumnAcrossPlayers(attackerSlot, targetSlot))
|
||||
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
|
||||
}
|
||||
|
||||
|
|
@ -2187,17 +2294,8 @@ public sealed class GameEngine
|
|||
if (!def.IsSentinel && discardPrintingId is not null)
|
||||
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
|
||||
|
||||
if (def.IsSentinel && discardPrintingId is not null)
|
||||
{
|
||||
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 сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
|
||||
// Sentinel сам по себе даёт ноль щита: оплата и отмена атаки разрешается
|
||||
// отдельной автоматической способностью после размещения в круг защиты.
|
||||
var shield = def.IsSentinel ? 0 : def.Shield;
|
||||
|
||||
defender.Hand.RemoveAt(index);
|
||||
|
|
@ -2207,17 +2305,6 @@ public sealed class GameEngine
|
|||
battle.GuardShield += 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);
|
||||
|
||||
// способность на размещение в (GC) может встать на выбор игрока:
|
||||
|
|
@ -2478,17 +2565,24 @@ public sealed class GameEngine
|
|||
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 (effectUnit is null)
|
||||
return CommandResult.Reject("Нет цели для второго эффекта.");
|
||||
|
||||
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
|
||||
if (State.GetUnit(player, command.EffectTarget) is null)
|
||||
return CommandResult.Reject("Нет цели для Крит +1.");
|
||||
}
|
||||
else if (pending.Trigger == TriggerType.Stand)
|
||||
{
|
||||
if (command.EffectTarget < 0)
|
||||
{
|
||||
// если Арьергардов нет, поднимать нечего — второй эффект не применяется
|
||||
if (State.Players[player].RearGuards.Any(u => u is not null))
|
||||
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
|
||||
// Если есть повёрнутый RC, игрок обязан выбрать его.
|
||||
if (RestedRearGuardSlots(player).Any())
|
||||
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
|
||||
? null
|
||||
: ProjectCard(engine, player.Vanguard, forceFaceUp: true),
|
||||
: ProjectCard(engine, player.Vanguard),
|
||||
RearGuards = player.RearGuards
|
||||
.Select(card => card is null
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -153,6 +153,24 @@ public class GameEngineTests
|
|||
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]
|
||||
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
|
||||
{
|
||||
|
|
@ -428,6 +446,22 @@ public class GameEngineTests
|
|||
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]
|
||||
public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect()
|
||||
{
|
||||
|
|
@ -596,7 +630,19 @@ public class GameEngineTests
|
|||
Give(engine, 1, Marron);
|
||||
|
||||
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.Empty(engine.State.Players[1].Damage);
|
||||
|
|
@ -611,9 +657,31 @@ public class GameEngineTests
|
|||
SetVanguard(engine, 1, Marron);
|
||||
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
||||
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 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.Single(engine.State.Players[1].Damage);
|
||||
|
|
@ -621,14 +689,29 @@ public class GameEngineTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Perfect_Guard_Rejects_Wrong_Clan_Cost()
|
||||
public void Perfect_Guard_Offers_Only_Same_Clan_Cards()
|
||||
{
|
||||
var engine = ReadyToAttack();
|
||||
SetVanguard(engine, 1, Marron);
|
||||
Give(engine, 1, Iseult);
|
||||
Give(engine, 1, Marron);
|
||||
Give(engine, 1, Nehalem);
|
||||
|
||||
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]
|
||||
|
|
@ -1996,15 +2079,48 @@ public class GameEngineTests
|
|||
engine.State.Players[0].RearGuards[3] = tejas;
|
||||
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];
|
||||
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.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]
|
||||
public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue