From 5e95e08cbb02655d77866366abb426c55cdea860 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 2 Sep 2026 11:10:53 +0900 Subject: [PATCH] feat: refine enemy engagements and reduce tower damage --- AGENTS.md | 5 + Assets/Northbound/Core/GameplayContracts.cs | 6 + .../Editor/PrototypeSceneBuilder.cs | 69 ++++- .../Northbound/Gameplay/Defense/BasicTower.cs | 13 +- .../Gameplay/Defense/TowerDefinition.cs | 2 +- .../Northbound/Gameplay/Enemy/EnemyAgent.cs | 235 ++++++++++++++++-- .../Gameplay/Enemy/EnemyPursuitDefinition.cs | 39 +++ .../Enemy/EnemyPursuitDefinition.cs.meta | 2 + .../Gameplay/Player/PlayerCombat.cs | 42 ++-- .../Gameplay/Player/PlayerController.cs | 30 ++- .../Gameplay/Player/PlayerDefinition.cs | 78 ++++++ .../Gameplay/Player/PlayerDefinition.cs.meta | 2 + .../Northbound/Gameplay/Waves/WaveDirector.cs | 6 +- .../Gameplay/World/CoreObjective.cs | 4 +- .../Gameplay/World/CrystalOutpost.cs | 4 +- .../Northbound/Gameplay/World/PortalSite.cs | 10 +- .../Definitions/BasicTowerDefinition.asset | 2 +- .../Definitions/DefaultEnemyPursuit.asset | 19 ++ .../DefaultEnemyPursuit.asset.meta | 8 + .../PrototypePlayerDefinition.asset | 36 +++ .../PrototypePlayerDefinition.asset.meta | 8 + .../Scenes/NorthboundGameplayPrototype.unity | 35 +-- PROTOTYPE_ROADMAP.md | 4 + 23 files changed, 563 insertions(+), 96 deletions(-) create mode 100644 Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs create mode 100644 Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs.meta create mode 100644 Assets/Northbound/Gameplay/Player/PlayerDefinition.cs create mode 100644 Assets/Northbound/Gameplay/Player/PlayerDefinition.cs.meta create mode 100644 Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset create mode 100644 Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset create mode 100644 Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset.meta diff --git a/AGENTS.md b/AGENTS.md index 38a35eb..ccba995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ - `TowerDefinition`과 `ConstructionDefinition`은 건물 번호나 이름을 복사하지 않고 대상 `BuildingDefinition` SO를 직접 참조한다. - 타워 기능과 건설 규칙이 함께 사용되는 지점에서는 두 정의가 동일한 `BuildingDefinition`을 참조하는지 검증한다. - 건물 ID는 저장·네트워크 식별용 영구 번호이며 배열 순서로 사용하거나 한 번 사용한 번호를 다른 건물에 재사용하지 않는다. +- 플레이어의 최대 체력, 이동·상호작용 수치, 일반·특수 공격 수치와 행동 정책은 `PlayerDefinition` SO가 소유하며 플레이어 컨트롤러와 전투 컴포넌트가 이를 직접 참조한다. +- 적의 플레이어 감지 범위, 반격 기회 없는 추격 시간·거리 한도와 재감지 조건은 공용 `EnemyPursuitDefinition` SO로 관리한다. ## Gameplay Data Architecture @@ -52,6 +54,9 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험 - 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다. - 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다. - 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다. +- 적이 플레이어를 공격할 수 있는 거리에서는 집착도 감소를 멈추고, 실제 공격 시도 시 추격 시간·거리 여유를 회복한다. 반격 기회 없이 추격하는 동안만 집착도가 줄며, 한도를 소진하면 건물 목표로 복귀한다. 플레이어나 타워가 적을 때리는 것만으로는 집착도가 회복되지 않는다. 어그로 이탈 후에는 플레이어와 충분히 떨어진 뒤에만 다시 감지하며, 건물을 지키려면 플레이어가 반격 위험을 감수하며 교전을 유지해야 한다. +- 웨이브 적의 기본 진군은 남쪽 방향을 유지하며, 진행 방향에 있는 아군 건물을 북쪽부터 차례로 파괴한 뒤 다음 남쪽 건물로 이동한다. 코어만 직행하거나 타워를 무시하지 않는다. +- 건물을 이동 목표로 선정하는 것과 교전을 고정하는 것은 구분한다. 건물에 첫 공격을 시작하기 전에는 플레이어가 어그로를 선점할 수 있다. 첫 건물 공격 이후에는 플레이어의 접근·공격이나 신규 건설로 목표를 바꾸지 않으며, 해당 건물이 파괴되거나 공격 불가능해진 뒤에만 목표와 플레이어 감지 제한을 다시 초기화한다. 목표 전환으로 공격 주기를 초기화하지 않는다. - 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다. ## Player and Tower Roles diff --git a/Assets/Northbound/Core/GameplayContracts.cs b/Assets/Northbound/Core/GameplayContracts.cs index e2041a8..5d16059 100644 --- a/Assets/Northbound/Core/GameplayContracts.cs +++ b/Assets/Northbound/Core/GameplayContracts.cs @@ -42,6 +42,12 @@ namespace Northbound.Core void TakeDamage(float amount, Team sourceTeam); } + public interface IEnemyStructureTarget + { + bool CanBeTargetedByEnemies { get; } + IDamageable DamageReceiver { get; } + } + public interface IInteractable { string GetInteractionPrompt(); diff --git a/Assets/Northbound/Editor/PrototypeSceneBuilder.cs b/Assets/Northbound/Editor/PrototypeSceneBuilder.cs index 81e4845..57b1460 100644 --- a/Assets/Northbound/Editor/PrototypeSceneBuilder.cs +++ b/Assets/Northbound/Editor/PrototypeSceneBuilder.cs @@ -21,6 +21,8 @@ namespace Northbound.Editor public const string ScenePath = "Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity"; private const string MaterialDirectory = "Assets/Northbound/Generated/Materials"; private const string DefinitionDirectory = "Assets/Northbound/Generated/Definitions"; + private const string PlayerDefinitionPath = DefinitionDirectory + "/PrototypePlayerDefinition.asset"; + private const string EnemyPursuitDefinitionPath = DefinitionDirectory + "/DefaultEnemyPursuit.asset"; private const string CoreBuildingPath = DefinitionDirectory + "/SouthernCoreBuilding.asset"; private const string OutpostBuildingPath = DefinitionDirectory + "/CrystalOutpostBuilding.asset"; private const string PortalBuildingPath = DefinitionDirectory + "/PortalBuilding.asset"; @@ -53,6 +55,8 @@ namespace Northbound.Editor var basicTowerBuilding = GetBuildingDefinition(BasicTowerBuildingPath, 2000, "Basic Tower", 150f, tower, PrimitiveType.Cylinder, new Vector3(0.7f, 1.25f, 0.7f)); var basicTowerDefinition = GetBasicTowerDefinition(basicTowerBuilding); var basicTowerConstruction = GetBasicTowerConstruction(basicTowerBuilding); + var playerDefinition = GetPlayerDefinition(); + var enemyPursuitDefinition = GetEnemyPursuitDefinition(); var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); CreateEnvironment(ground, lane); @@ -61,9 +65,9 @@ namespace Northbound.Editor var systems = new GameObject("Game Systems"); systems.AddComponent(); systems.AddComponent(); - systems.AddComponent().Configure(grunt, raider, elite); + systems.AddComponent().Configure(grunt, raider, elite, enemyPursuitDefinition); - var playerController = CreatePlayer(player); + var playerController = CreatePlayer(player, playerDefinition); CreateCamera(playerController); CreateCore(core, coreBuilding); @@ -74,8 +78,8 @@ namespace Northbound.Editor CreatePortal(index, portalPositions[index], initialState, portalBuilding, enemyPortal, ruinedPortal, alliedPortal); if (initialState == PortalState.Enemy) { - CreateGuardian(new Vector3(-3f, 1f, portalPositions[index] - 3f), grunt); - CreateGuardian(new Vector3(3f, 1f, portalPositions[index] - 3f), grunt); + CreateGuardian(new Vector3(-3f, 1f, portalPositions[index] - 3f), grunt, enemyPursuitDefinition); + CreateGuardian(new Vector3(3f, 1f, portalPositions[index] - 3f), grunt, enemyPursuitDefinition); } } @@ -113,15 +117,16 @@ namespace Northbound.Editor light.intensity = 1.25f; } - private static PlayerController CreatePlayer(Material material) + private static PlayerController CreatePlayer(Material material, PlayerDefinition definition) { var gameObject = CreatePrimitive("Player", PrimitiveType.Capsule, new Vector3(0f, 1f, -76f), Vector3.one, material); Object.DestroyImmediate(gameObject.GetComponent()); gameObject.AddComponent(); - gameObject.AddComponent().Configure(Team.Player, 100f); + gameObject.AddComponent().Configure(Team.Player, definition.MaximumHealth); gameObject.AddComponent(); var controller = gameObject.AddComponent(); - gameObject.AddComponent(); + controller.Configure(definition); + gameObject.AddComponent().Configure(definition); return controller; } @@ -176,10 +181,10 @@ namespace Northbound.Editor slot.AddComponent().Configure(outpost, constructionDefinition, towerDefinition); } - private static void CreateGuardian(Vector3 position, Material material) + private static void CreateGuardian(Vector3 position, Material material, EnemyPursuitDefinition pursuitDefinition) { var gameObject = CreatePrimitive("Portal Guard", PrimitiveType.Capsule, position, Vector3.one, material); - gameObject.AddComponent().Configure(EnemyRole.PortalGuard, 0, material); + gameObject.AddComponent().Configure(EnemyRole.PortalGuard, 0, material, pursuitDefinition); } private static GameObject CreatePrimitive(string name, PrimitiveType type, Vector3 position, Vector3 scale, Material material) @@ -255,7 +260,7 @@ namespace Northbound.Editor definition = ScriptableObject.CreateInstance(); definition.name = "BasicTowerDefinition"; - definition.ConfigurePrototype(building, 8f, 9f, 1.2f, new Color(1f, 0.75f, 0.15f, 0.95f)); + definition.ConfigurePrototype(building, 8f, 6f, 1.2f, new Color(1f, 0.75f, 0.15f, 0.95f)); AssetDatabase.CreateAsset(definition, BasicTowerDefinitionPath); return definition; } @@ -275,6 +280,50 @@ namespace Northbound.Editor return definition; } + private static PlayerDefinition GetPlayerDefinition() + { + var definition = AssetDatabase.LoadAssetAtPath(PlayerDefinitionPath); + if (definition != null) + { + return definition; + } + + definition = ScriptableObject.CreateInstance(); + definition.name = "PrototypePlayerDefinition"; + definition.ConfigurePrototype( + "Prototype Player", + 100f, + 5f, + 15f, + 3.2f, + 13f, + 3f, + 0.4f, + new PlayerActionDefinition("Normal Attack", 0.3f, false), + 34f, + 4.2f, + 25f, + 5.5f, + new PlayerActionDefinition("Special Attack", 0.65f, false)); + AssetDatabase.CreateAsset(definition, PlayerDefinitionPath); + return definition; + } + + private static EnemyPursuitDefinition GetEnemyPursuitDefinition() + { + var definition = AssetDatabase.LoadAssetAtPath(EnemyPursuitDefinitionPath); + if (definition != null) + { + return definition; + } + + definition = ScriptableObject.CreateInstance(); + definition.name = "DefaultEnemyPursuit"; + definition.ConfigurePrototype(7f, 4.5f, 10f, 9f, 3.5f); + AssetDatabase.CreateAsset(definition, EnemyPursuitDefinitionPath); + return definition; + } + private static void EnsureDirectory(string assetPath) { var absolutePath = Path.GetFullPath(assetPath); diff --git a/Assets/Northbound/Gameplay/Defense/BasicTower.cs b/Assets/Northbound/Gameplay/Defense/BasicTower.cs index a8ea7d7..1078735 100644 --- a/Assets/Northbound/Gameplay/Defense/BasicTower.cs +++ b/Assets/Northbound/Gameplay/Defense/BasicTower.cs @@ -6,12 +6,23 @@ using UnityEngine; namespace Northbound.Gameplay.Defense { - public sealed class BasicTower : MonoBehaviour + public sealed class BasicTower : MonoBehaviour, IEnemyStructureTarget { [SerializeField] private TowerDefinition definition; private float readyAt; + public bool CanBeTargetedByEnemies + { + get + { + var health = GetComponent(); + return definition?.Building != null && health != null && health.IsAlive; + } + } + + public IDamageable DamageReceiver => GetComponent(); + private void Update() { if (definition == null || Time.time < readyAt) diff --git a/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs index 0230895..d4a2d2e 100644 --- a/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs +++ b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs @@ -8,7 +8,7 @@ namespace Northbound.Gameplay.Defense { [SerializeField] private BuildingDefinition building; [SerializeField] private float range = 8f; - [SerializeField] private float damage = 9f; + [SerializeField] private float damage = 6f; [SerializeField] private float attackInterval = 1.2f; [SerializeField] private Color attackColor = new(1f, 0.75f, 0.15f, 0.95f); diff --git a/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs b/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs index bca204b..1e16975 100644 --- a/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs +++ b/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs @@ -24,7 +24,7 @@ namespace Northbound.Gameplay.Enemy [SerializeField] private float attackDamage = 12f; [SerializeField] private float attackRange = 2f; [SerializeField] private float attackInterval = 1.25f; - [SerializeField] private float playerAggroRange = 7f; + [SerializeField] private EnemyPursuitDefinition pursuitDefinition; [SerializeField] private bool affectedByResonance = true; private float currentHealth; @@ -32,6 +32,12 @@ namespace Northbound.Gameplay.Enemy private Vector3 guardPosition; private CharacterController movementController; private float avoidanceDirection; + private bool isPursuingPlayer; + private bool isPlayerAggroSuppressed; + private float unansweredPursuitTime; + private Vector3 playerPursuitOrigin; + private MonoBehaviour strategicTarget; + private bool isStructureEngagementLocked; public EnemyRole Role => role; public Team Team => Team.Enemy; @@ -57,6 +63,7 @@ namespace Northbound.Gameplay.Enemy var target = SelectTarget(); if (target == null) { + ReturnToGuardPosition(); return; } @@ -69,17 +76,32 @@ namespace Northbound.Gameplay.Enemy return; } - if (Time.time >= readyAt && target is IDamageable damageable) + var damageable = ResolveDamageReceiver(target); + if (Time.time >= readyAt && damageable != null) { readyAt = Time.time + attackInterval; + if (IsValidStructureTarget(target)) + { + // Selecting a march destination does not commit us; the first attack does. + strategicTarget = target; + isStructureEngagementLocked = true; + EndPlayerPursuit(true); + } + if (isPursuingPlayer) + { + // Only an enemy attack opportunity replenishes pursuit, not incoming damage. + unansweredPursuitTime = 0f; + playerPursuitOrigin = transform.position; + } AttackTracer.Spawn(transform.position, targetPosition, new Color(1f, 0.18f, 0.08f, 0.95f), 0.22f, 0.2f); damageable.TakeDamage(attackDamage, Team.Enemy); } } - public void Configure(EnemyRole enemyRole, int resonance, Material material) + public void Configure(EnemyRole enemyRole, int resonance, Material material, EnemyPursuitDefinition pursuit) { role = enemyRole; + pursuitDefinition = pursuit; affectedByResonance = enemyRole != EnemyRole.PortalGuard; guardPosition = transform.position; @@ -176,43 +198,206 @@ namespace Northbound.Gameplay.Enemy private MonoBehaviour SelectTarget() { - var player = FindAnyObjectByType(); - if (player != null && Vector3.SqrMagnitude(player.transform.position - transform.position) <= playerAggroRange * playerAggroRange) + if (isStructureEngagementLocked) { - return player.GetComponent(); + if (IsValidStructureTarget(strategicTarget)) + { + // Neither nearby players nor newly built structures interrupt this engagement. + return strategicTarget; + } + + isStructureEngagementLocked = false; + strategicTarget = null; + // A destroyed/lost structure opens a new engagement, including player detection. + EndPlayerPursuit(false); + } + + if (role != EnemyRole.PortalGuard) + { + var northernTarget = SelectNorthernStructureTarget(); + if (!IsValidStructureTarget(strategicTarget) + || northernTarget != null && northernTarget.transform.position.z > strategicTarget.transform.position.z + 0.01f) + { + strategicTarget = northernTarget; + } + } + + var player = FindAnyObjectByType(); + var playerHealth = player == null ? null : player.GetComponent(); + var aggroOrigin = role == EnemyRole.PortalGuard ? guardPosition : transform.position; + if (TrySelectPlayer(player, playerHealth, aggroOrigin, out var selectedPlayer)) + { + return selectedPlayer; } if (role == EnemyRole.PortalGuard) { - if (player == null || Vector3.SqrMagnitude(player.transform.position - guardPosition) > playerAggroRange * playerAggroRange) - { - return null; - } - - return player.GetComponent(); + return null; } - if (role == EnemyRole.Raider) + return strategicTarget; + } + + private MonoBehaviour SelectNorthernStructureTarget() + { + MonoBehaviour selected = null; + var selectedZ = float.NegativeInfinity; + var selectedDistance = float.MaxValue; + var backtrackDistance = pursuitDefinition == null + ? 0f + : pursuitDefinition.MaximumTargetBacktrackDistance; + var northernLimit = transform.position.z + backtrackDistance; + + foreach (var behaviour in FindObjectsByType()) { - CrystalOutpost nearest = null; - var nearestDistance = float.MaxValue; - foreach (var outpost in FindObjectsByType()) + if (!behaviour.isActiveAndEnabled + || behaviour is not IEnemyStructureTarget structure + || !structure.CanBeTargetedByEnemies) { - var distance = Vector3.SqrMagnitude(outpost.transform.position - transform.position); - if (outpost.IsCaptured && distance < nearestDistance) + continue; + } + + var receiver = structure.DamageReceiver; + if (receiver == null || !receiver.IsAlive || receiver.Team != Team.Player) + { + continue; + } + + var candidateZ = behaviour.transform.position.z; + if (candidateZ > northernLimit) + { + continue; + } + + var distance = PlanarSqrDistance(behaviour.transform.position, transform.position); + var sameFrontline = Mathf.Abs(candidateZ - selectedZ) <= 0.01f; + var raiderPrefersOutpost = role == EnemyRole.Raider + && sameFrontline + && behaviour is CrystalOutpost + && selected is not CrystalOutpost; + var raiderKeepsOutpost = role == EnemyRole.Raider + && sameFrontline + && selected is CrystalOutpost + && behaviour is not CrystalOutpost; + if (candidateZ > selectedZ + 0.01f + || raiderPrefersOutpost + || sameFrontline && !raiderKeepsOutpost && distance < selectedDistance) + { + selected = behaviour; + selectedZ = candidateZ; + selectedDistance = distance; + } + } + + return selected; + } + + private static bool IsValidStructureTarget(MonoBehaviour target) + { + if (target == null || !target.isActiveAndEnabled + || target is not IEnemyStructureTarget structure || !structure.CanBeTargetedByEnemies) + { + return false; + } + + var receiver = structure.DamageReceiver; + return receiver != null && receiver.IsAlive && receiver.Team == Team.Player; + } + + private static IDamageable ResolveDamageReceiver(MonoBehaviour target) + { + return target is IEnemyStructureTarget structure + ? structure.DamageReceiver + : target as IDamageable; + } + + private bool TrySelectPlayer( + PlayerController player, + Combat.Health playerHealth, + Vector3 aggroOrigin, + out MonoBehaviour selectedPlayer) + { + selectedPlayer = null; + if (pursuitDefinition == null || player == null || playerHealth == null || !playerHealth.IsAlive) + { + EndPlayerPursuit(false); + return false; + } + + var distanceFromAggroOrigin = PlanarSqrDistance(player.transform.position, aggroOrigin); + if (isPursuingPlayer) + { + // Use the same collider-surface range check as the attack itself. + // Cooldown time in reach preserves engagement but does not refill it. + var canReachPlayer = PlanarSqrDistance(GetClosestTargetPoint(playerHealth), transform.position) + <= attackRange * attackRange; + if (!canReachPlayer) + { + unansweredPursuitTime += Time.deltaTime; + var exceededDuration = unansweredPursuitTime >= pursuitDefinition.DisengageDelay; + var exceededDistance = PlanarSqrDistance(transform.position, playerPursuitOrigin) + >= pursuitDefinition.MaximumUnansweredPursuitDistance * pursuitDefinition.MaximumUnansweredPursuitDistance; + if (exceededDuration || exceededDistance) { - nearest = outpost; - nearestDistance = distance; + EndPlayerPursuit(true); + return false; } } - if (nearest != null) - { - return nearest; - } + selectedPlayer = playerHealth; + return true; } - return FindAnyObjectByType(); + if (isPlayerAggroSuppressed) + { + if (distanceFromAggroOrigin <= pursuitDefinition.RearmDistance * pursuitDefinition.RearmDistance) + { + return false; + } + + isPlayerAggroSuppressed = false; + } + + if (PlanarSqrDistance(player.transform.position, aggroOrigin) + > pursuitDefinition.AggroRange * pursuitDefinition.AggroRange) + { + return false; + } + + isPursuingPlayer = true; + playerPursuitOrigin = transform.position; + unansweredPursuitTime = 0f; + selectedPlayer = playerHealth; + return true; + } + + private void EndPlayerPursuit(bool suppressAggro) + { + isPursuingPlayer = false; + isPlayerAggroSuppressed = suppressAggro; + unansweredPursuitTime = 0f; + } + + private void ReturnToGuardPosition() + { + if (role != EnemyRole.PortalGuard) + { + return; + } + + var planar = guardPosition - transform.position; + planar.y = 0f; + if (planar.sqrMagnitude > 0.1f) + { + MoveTowardsTarget(planar.normalized); + } + } + + private static float PlanarSqrDistance(Vector3 a, Vector3 b) + { + a.y = 0f; + b.y = 0f; + return Vector3.SqrMagnitude(a - b); } } } diff --git a/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs b/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs new file mode 100644 index 0000000..cefe571 --- /dev/null +++ b/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs @@ -0,0 +1,39 @@ +using UnityEngine; +using UnityEngine.Serialization; + +namespace Northbound.Gameplay.Enemy +{ + [CreateAssetMenu(fileName = "EnemyPursuitDefinition", menuName = "Northbound/Enemy Pursuit Definition")] + public sealed class EnemyPursuitDefinition : ScriptableObject + { + [SerializeField] private float aggroRange = 7f; + [FormerlySerializedAs("maximumPursuitDuration")] + [Tooltip("Seconds spent outside attack reach since the enemy's last attack attempt before giving up.")] + [SerializeField] private float disengageDelay = 4.5f; + [FormerlySerializedAs("maximumPursuitDistance")] + [Tooltip("Maximum displacement without an enemy attack opportunity. Resets when the enemy attacks.")] + [SerializeField] private float maximumUnansweredPursuitDistance = 10f; + [SerializeField] private float rearmDistance = 9f; + [SerializeField] private float maximumTargetBacktrackDistance = 3.5f; + + public float AggroRange => Mathf.Max(0f, aggroRange); + public float DisengageDelay => Mathf.Max(0f, disengageDelay); + public float MaximumUnansweredPursuitDistance => Mathf.Max(0f, maximumUnansweredPursuitDistance); + public float RearmDistance => Mathf.Max(AggroRange, rearmDistance); + public float MaximumTargetBacktrackDistance => Mathf.Max(0f, maximumTargetBacktrackDistance); + + public void ConfigurePrototype( + float detectionRange, + float disengageTime, + float unansweredPursuitDistance, + float resetDistance, + float targetBacktrackDistance) + { + aggroRange = Mathf.Max(0f, detectionRange); + disengageDelay = Mathf.Max(0f, disengageTime); + maximumUnansweredPursuitDistance = Mathf.Max(0f, unansweredPursuitDistance); + rearmDistance = Mathf.Max(aggroRange, resetDistance); + maximumTargetBacktrackDistance = Mathf.Max(0f, targetBacktrackDistance); + } + } +} diff --git a/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs.meta b/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs.meta new file mode 100644 index 0000000..70f2130 --- /dev/null +++ b/Assets/Northbound/Gameplay/Enemy/EnemyPursuitDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9aa8e0cc71be49c9ac69d950aa3fe0d4 diff --git a/Assets/Northbound/Gameplay/Player/PlayerCombat.cs b/Assets/Northbound/Gameplay/Player/PlayerCombat.cs index 7dd8232..33fb849 100644 --- a/Assets/Northbound/Gameplay/Player/PlayerCombat.cs +++ b/Assets/Northbound/Gameplay/Player/PlayerCombat.cs @@ -6,15 +6,7 @@ namespace Northbound.Gameplay.Player { public sealed class PlayerCombat : MonoBehaviour { - [SerializeField] private float normalDamage = 13f; - [SerializeField] private float normalRange = 3f; - [SerializeField] private float normalCooldown = 0.4f; - [SerializeField] private PlayerActionDefinition normalAction = new("Normal Attack", 0.3f, false); - [SerializeField] private float specialDamage = 34f; - [SerializeField] private float specialRadius = 4.2f; - [SerializeField] private float specialManaCost = 25f; - [SerializeField] private float specialCooldown = 5.5f; - [SerializeField] private PlayerActionDefinition specialAction = new("Special Attack", 0.65f, false); + [SerializeField] private PlayerDefinition definition; private float normalReadyAt; private float specialReadyAt; @@ -24,6 +16,13 @@ namespace Northbound.Gameplay.Player private void Awake() { + if (definition == null) + { + Debug.LogError($"[{nameof(PlayerCombat)}] Player definition is missing on {name}.", this); + enabled = false; + return; + } + actionController = GetComponent(); if (actionController == null) { @@ -31,6 +30,11 @@ namespace Northbound.Gameplay.Player } } + public void Configure(PlayerDefinition playerDefinition) + { + definition = playerDefinition; + } + private void Update() { if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt && actionController is { CanStartAction: true }) @@ -46,30 +50,30 @@ namespace Northbound.Gameplay.Player private void NormalAttack() { - if (!actionController.TryBeginAction(normalAction)) + if (!actionController.TryBeginAction(definition.NormalAction)) { return; } - normalReadyAt = Time.time + normalCooldown; - var center = transform.position + transform.forward * (normalRange * 0.55f); - var radius = normalRange * 0.55f; + normalReadyAt = Time.time + definition.NormalCooldown; + var center = transform.position + transform.forward * (definition.NormalRange * 0.55f); + var radius = definition.NormalRange * 0.55f; AttackFlash.Spawn(center, radius, new Color(0.15f, 0.85f, 1f, 0.72f), 0.16f); - DamageTargets(Physics.OverlapSphere(center, radius), normalDamage); + DamageTargets(Physics.OverlapSphere(center, radius), definition.NormalDamage); } private void SpecialAttack() { var gameState = SharedGameState.Instance; - if (actionController == null || !actionController.CanStartAction || gameState == null || !gameState.TrySpendMana(specialManaCost)) + if (actionController == null || !actionController.CanStartAction || gameState == null || !gameState.TrySpendMana(definition.SpecialManaCost)) { return; } - actionController.TryBeginAction(specialAction); - specialReadyAt = Time.time + specialCooldown; - AttackFlash.Spawn(transform.position, specialRadius, new Color(0.8f, 0.25f, 1f, 0.68f), 0.32f); - DamageTargets(Physics.OverlapSphere(transform.position, specialRadius), specialDamage); + actionController.TryBeginAction(definition.SpecialAction); + specialReadyAt = Time.time + definition.SpecialCooldown; + AttackFlash.Spawn(transform.position, definition.SpecialRadius, new Color(0.8f, 0.25f, 1f, 0.68f), 0.32f); + DamageTargets(Physics.OverlapSphere(transform.position, definition.SpecialRadius), definition.SpecialDamage); } private static void DamageTargets(Collider[] colliders, float damage) diff --git a/Assets/Northbound/Gameplay/Player/PlayerController.cs b/Assets/Northbound/Gameplay/Player/PlayerController.cs index 3b0adaf..32c22eb 100644 --- a/Assets/Northbound/Gameplay/Player/PlayerController.cs +++ b/Assets/Northbound/Gameplay/Player/PlayerController.cs @@ -1,16 +1,16 @@ using Northbound.Core; +using Northbound.Gameplay.Combat; using UnityEngine; namespace Northbound.Gameplay.Player { [RequireComponent(typeof(CharacterController))] + [RequireComponent(typeof(Health))] [RequireComponent(typeof(PlayerActionController))] [DefaultExecutionOrder(-100)] public sealed class PlayerController : MonoBehaviour { - [SerializeField] private float moveSpeed = 5f; - [SerializeField] private float horizontalLimit = 15f; - [SerializeField] private float interactionRadius = 3.2f; + [SerializeField] private PlayerDefinition definition; private CharacterController characterController; private PlayerActionController actionController; @@ -21,12 +21,30 @@ namespace Northbound.Gameplay.Player private void Awake() { + if (definition == null) + { + Debug.LogError($"[{nameof(PlayerController)}] Player definition is missing on {name}.", this); + enabled = false; + return; + } + characterController = GetComponent(); actionController = GetComponent(); if (actionController == null) { actionController = gameObject.AddComponent(); } + + GetComponent().Configure(Team.Player, definition.MaximumHealth); + } + + public void Configure(PlayerDefinition playerDefinition) + { + definition = playerDefinition; + if (definition != null && TryGetComponent(out var health)) + { + health.Configure(Team.Player, definition.MaximumHealth); + } } private void Update() @@ -58,9 +76,9 @@ namespace Northbound.Gameplay.Player LastMoveDirection = input.normalized; } - characterController.Move(input * (moveSpeed * Time.deltaTime)); + characterController.Move(input * (definition.MoveSpeed * Time.deltaTime)); var position = transform.position; - position.x = Mathf.Clamp(position.x, -horizontalLimit, horizontalLimit); + position.x = Mathf.Clamp(position.x, -definition.HorizontalLimit, definition.HorizontalLimit); position.y = 1f; transform.position = position; } @@ -93,7 +111,7 @@ namespace Northbound.Gameplay.Player var nearestDistance = float.MaxValue; IInteractable nearest = null; - foreach (var collider in Physics.OverlapSphere(transform.position, interactionRadius)) + foreach (var collider in Physics.OverlapSphere(transform.position, definition.InteractionRadius)) { foreach (var behaviour in collider.GetComponentsInParent()) { diff --git a/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs b/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs new file mode 100644 index 0000000..969df72 --- /dev/null +++ b/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs @@ -0,0 +1,78 @@ +using Northbound.Core; +using UnityEngine; + +namespace Northbound.Gameplay.Player +{ + [CreateAssetMenu(fileName = "PlayerDefinition", menuName = "Northbound/Player Definition")] + public sealed class PlayerDefinition : ScriptableObject + { + [Header("Identity")] + [SerializeField] private string displayName = "Player"; + + [Header("Survivability and Movement")] + [SerializeField] private float maximumHealth = 100f; + [SerializeField] private float moveSpeed = 5f; + [SerializeField] private float horizontalLimit = 15f; + [SerializeField] private float interactionRadius = 3.2f; + + [Header("Normal Attack")] + [SerializeField] private float normalDamage = 13f; + [SerializeField] private float normalRange = 3f; + [SerializeField] private float normalCooldown = 0.4f; + [SerializeField] private PlayerActionDefinition normalAction = new("Normal Attack", 0.3f, false); + + [Header("Special Attack")] + [SerializeField] private float specialDamage = 34f; + [SerializeField] private float specialRadius = 4.2f; + [SerializeField] private float specialManaCost = 25f; + [SerializeField] private float specialCooldown = 5.5f; + [SerializeField] private PlayerActionDefinition specialAction = new("Special Attack", 0.65f, false); + + public string DisplayName => displayName; + public float MaximumHealth => Mathf.Max(1f, maximumHealth); + public float MoveSpeed => Mathf.Max(0f, moveSpeed); + public float HorizontalLimit => Mathf.Max(0f, horizontalLimit); + public float InteractionRadius => Mathf.Max(0f, interactionRadius); + public float NormalDamage => Mathf.Max(0f, normalDamage); + public float NormalRange => Mathf.Max(0f, normalRange); + public float NormalCooldown => Mathf.Max(0f, normalCooldown); + public PlayerActionDefinition NormalAction => normalAction; + public float SpecialDamage => Mathf.Max(0f, specialDamage); + public float SpecialRadius => Mathf.Max(0f, specialRadius); + public float SpecialManaCost => Mathf.Max(0f, specialManaCost); + public float SpecialCooldown => Mathf.Max(0f, specialCooldown); + public PlayerActionDefinition SpecialAction => specialAction; + + public void ConfigurePrototype( + string playerName, + float health, + float speed, + float mapHorizontalLimit, + float interactRadius, + float basicDamage, + float basicRange, + float basicCooldown, + PlayerActionDefinition basicAction, + float abilityDamage, + float abilityRadius, + float abilityManaCost, + float abilityCooldown, + PlayerActionDefinition abilityAction) + { + displayName = playerName; + maximumHealth = Mathf.Max(1f, health); + moveSpeed = Mathf.Max(0f, speed); + horizontalLimit = Mathf.Max(0f, mapHorizontalLimit); + interactionRadius = Mathf.Max(0f, interactRadius); + normalDamage = Mathf.Max(0f, basicDamage); + normalRange = Mathf.Max(0f, basicRange); + normalCooldown = Mathf.Max(0f, basicCooldown); + normalAction = basicAction; + specialDamage = Mathf.Max(0f, abilityDamage); + specialRadius = Mathf.Max(0f, abilityRadius); + specialManaCost = Mathf.Max(0f, abilityManaCost); + specialCooldown = Mathf.Max(0f, abilityCooldown); + specialAction = abilityAction; + } + } +} diff --git a/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs.meta b/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs.meta new file mode 100644 index 0000000..8e245ea --- /dev/null +++ b/Assets/Northbound/Gameplay/Player/PlayerDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c84b6b014f9549e2a2d94b59016ca3bb diff --git a/Assets/Northbound/Gameplay/Waves/WaveDirector.cs b/Assets/Northbound/Gameplay/Waves/WaveDirector.cs index 2d07f6c..699133b 100644 --- a/Assets/Northbound/Gameplay/Waves/WaveDirector.cs +++ b/Assets/Northbound/Gameplay/Waves/WaveDirector.cs @@ -12,6 +12,7 @@ namespace Northbound.Gameplay.Waves [SerializeField] private Material gruntMaterial; [SerializeField] private Material raiderMaterial; [SerializeField] private Material eliteMaterial; + [SerializeField] private EnemyPursuitDefinition pursuitDefinition; private float nextWaveAt; private int wave; @@ -41,11 +42,12 @@ namespace Northbound.Gameplay.Waves PublishStatus(state.ResonanceSnapshot); } - public void Configure(Material grunt, Material raider, Material elite) + public void Configure(Material grunt, Material raider, Material elite, EnemyPursuitDefinition pursuit) { gruntMaterial = grunt; raiderMaterial = raider; eliteMaterial = elite; + pursuitDefinition = pursuit; } private void BeginWave() @@ -97,7 +99,7 @@ namespace Northbound.Gameplay.Waves EnemyRole.Elite => eliteMaterial, _ => gruntMaterial }; - enemy.AddComponent().Configure(role, resonance, material); + enemy.AddComponent().Configure(role, resonance, material, pursuitDefinition); } private void PublishStatus(int resonance) diff --git a/Assets/Northbound/Gameplay/World/CoreObjective.cs b/Assets/Northbound/Gameplay/World/CoreObjective.cs index bbb5b7e..4fc03ac 100644 --- a/Assets/Northbound/Gameplay/World/CoreObjective.cs +++ b/Assets/Northbound/Gameplay/World/CoreObjective.cs @@ -6,7 +6,7 @@ using UnityEngine; namespace Northbound.Gameplay.World { - public sealed class CoreObjective : MonoBehaviour, IDamageable + public sealed class CoreObjective : MonoBehaviour, IDamageable, IEnemyStructureTarget { [SerializeField] private BuildingDefinition buildingDefinition; [SerializeField] private float defenseRange = 10f; @@ -19,6 +19,8 @@ namespace Northbound.Gameplay.World public bool IsAlive => CurrentHealth > 0f; public float CurrentHealth { get; private set; } public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; + public bool CanBeTargetedByEnemies => IsAlive; + public IDamageable DamageReceiver => this; private void Awake() { diff --git a/Assets/Northbound/Gameplay/World/CrystalOutpost.cs b/Assets/Northbound/Gameplay/World/CrystalOutpost.cs index 1b8be67..be0fddd 100644 --- a/Assets/Northbound/Gameplay/World/CrystalOutpost.cs +++ b/Assets/Northbound/Gameplay/World/CrystalOutpost.cs @@ -6,7 +6,7 @@ using UnityEngine; namespace Northbound.Gameplay.World { - public sealed class CrystalOutpost : MonoBehaviour, IDamageable, IInteractable + public sealed class CrystalOutpost : MonoBehaviour, IDamageable, IInteractable, IEnemyStructureTarget { [SerializeField] private BuildingDefinition buildingDefinition; [SerializeField] private float manaPerSecond = 1.25f; @@ -27,6 +27,8 @@ namespace Northbound.Gameplay.World public bool CanInteract => !IsCaptured; public float CurrentHealth { get; private set; } public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; + public bool CanBeTargetedByEnemies => IsCaptured && IsAlive; + public IDamageable DamageReceiver => this; private float defenseReadyAt; diff --git a/Assets/Northbound/Gameplay/World/PortalSite.cs b/Assets/Northbound/Gameplay/World/PortalSite.cs index 0c7d5ed..27ef0da 100644 --- a/Assets/Northbound/Gameplay/World/PortalSite.cs +++ b/Assets/Northbound/Gameplay/World/PortalSite.cs @@ -12,7 +12,7 @@ namespace Northbound.Gameplay.World Allied } - public sealed class PortalSite : MonoBehaviour, IDamageable, IInteractable + public sealed class PortalSite : MonoBehaviour, IDamageable, IInteractable, IEnemyStructureTarget { [SerializeField] private int networkIndex; [SerializeField] private PortalState state = PortalState.Enemy; @@ -36,6 +36,8 @@ namespace Northbound.Gameplay.World public bool CanInteract => state != PortalState.Enemy; public float CurrentHealth { get; private set; } public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; + public bool CanBeTargetedByEnemies => state == PortalState.Allied && IsAlive; + public IDamageable DamageReceiver => this; private void Awake() { @@ -70,7 +72,9 @@ namespace Northbound.Gameplay.World public void TakeDamage(float amount, Team sourceTeam) { - if (state != PortalState.Enemy || sourceTeam != Team.Player) + var playerAttackingEnemyPortal = state == PortalState.Enemy && sourceTeam == Team.Player; + var enemyAttackingAlliedPortal = state == PortalState.Allied && sourceTeam == Team.Enemy; + if (!playerAttackingEnemyPortal && !enemyAttackingAlliedPortal) { return; } @@ -85,7 +89,7 @@ namespace Northbound.Gameplay.World RefreshVisual(); var gameState = SharedGameState.Instance; - if (gameState != null && gameState.CountEnemyPortals() == 0) + if (playerAttackingEnemyPortal && gameState != null && gameState.CountEnemyPortals() == 0) { gameState.MarkVictory(); } diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset index 5c10267..f75bb02 100644 --- a/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset +++ b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset @@ -14,6 +14,6 @@ MonoBehaviour: m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.TowerDefinition building: {fileID: 11400000, guid: e49a0dcf744247b8a77b63f797a21c4c, type: 2} range: 8 - damage: 9 + damage: 6 attackInterval: 1.2 attackColor: {r: 1, g: 0.75, b: 0.15, a: 0.95} diff --git a/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset b/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset new file mode 100644 index 0000000..f6eebe6 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9aa8e0cc71be49c9ac69d950aa3fe0d4, type: 3} + m_Name: DefaultEnemyPursuit + m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Enemy.EnemyPursuitDefinition + aggroRange: 7 + disengageDelay: 4.5 + maximumUnansweredPursuitDistance: 10 + rearmDistance: 9 + maximumTargetBacktrackDistance: 3.5 diff --git a/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset.meta b/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset.meta new file mode 100644 index 0000000..35108ac --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/DefaultEnemyPursuit.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d4ec708d3ab44238953e3bec1af6bb8e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset b/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset new file mode 100644 index 0000000..de302a0 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset @@ -0,0 +1,36 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c84b6b014f9549e2a2d94b59016ca3bb, type: 3} + m_Name: PrototypePlayerDefinition + m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerDefinition + displayName: Prototype Player + maximumHealth: 100 + moveSpeed: 4.2 + horizontalLimit: 15 + interactionRadius: 3.2 + normalDamage: 13 + normalRange: 3 + normalCooldown: 0.4 + normalAction: + actionName: Normal Attack + duration: 0.3 + allowsMovement: 0 + canBeCancelled: 0 + specialDamage: 34 + specialRadius: 4.2 + specialManaCost: 25 + specialCooldown: 5.5 + specialAction: + actionName: Special Attack + duration: 0.65 + allowsMovement: 0 + canBeCancelled: 0 diff --git a/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset.meta b/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset.meta new file mode 100644 index 0000000..ea865f6 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/PrototypePlayerDefinition.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a3d795b67fc44625a25501cdf16da914 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity b/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity index 7835d67..066b407 100644 --- a/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity +++ b/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity @@ -157,7 +157,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &10466387 MeshRenderer: @@ -292,7 +292,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &57707079 MeshRenderer: @@ -879,9 +879,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3} m_Name: m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController - moveSpeed: 5 - horizontalLimit: 15 - interactionRadius: 3.2 + definition: {fileID: 11400000, guid: a3d795b67fc44625a25501cdf16da914, type: 2} --- !u!114 &597054783 MonoBehaviour: m_ObjectHideFlags: 0 @@ -894,23 +892,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3} m_Name: m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat - normalDamage: 13 - normalRange: 3 - normalCooldown: 0.4 - normalAction: - actionName: Normal Attack - duration: 0.3 - allowsMovement: 0 - canBeCancelled: 0 - specialDamage: 34 - specialRadius: 4.2 - specialManaCost: 25 - specialCooldown: 5.5 - specialAction: - actionName: Special Attack - duration: 0.65 - allowsMovement: 0 - canBeCancelled: 0 + definition: {fileID: 11400000, guid: a3d795b67fc44625a25501cdf16da914, type: 2} --- !u!114 &597054784 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1061,7 +1043,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &673355559 MeshRenderer: @@ -1322,6 +1304,7 @@ MonoBehaviour: gruntMaterial: {fileID: 2100000, guid: 0c49d825c6397b14593263a8a25a95b0, type: 2} raiderMaterial: {fileID: 2100000, guid: 5cb05999871dcdb44b50f6506260527a, type: 2} eliteMaterial: {fileID: 2100000, guid: 00e28eb844e1d184a944503ae8ffd906, type: 2} + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} --- !u!114 &963263111 MonoBehaviour: m_ObjectHideFlags: 0 @@ -2536,7 +2519,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &1999748834 MeshRenderer: @@ -2671,7 +2654,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &2057559070 MeshRenderer: @@ -3198,7 +3181,7 @@ MonoBehaviour: attackDamage: 15 attackRange: 2 attackInterval: 1.25 - playerAggroRange: 7 + pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2} affectedByResonance: 0 --- !u!23 &2117110600 MeshRenderer: diff --git a/PROTOTYPE_ROADMAP.md b/PROTOTYPE_ROADMAP.md index 0a8bda0..d1fa75c 100644 --- a/PROTOTYPE_ROADMAP.md +++ b/PROTOTYPE_ROADMAP.md @@ -38,6 +38,7 @@ - 크리스탈당 제한된 방어 슬롯과 약한 지연 타워 - 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리 - 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조 +- 플레이어의 체력·이동·상호작용·공격 수치와 공격 행동 정책은 PlayerDefinition 자산에서 통합 관리 - 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대 - 첫 웨이브 15초, 이후 90초 간격 @@ -59,6 +60,9 @@ - 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다. - 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다. - 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다. +- 적은 공격 사거리 내 교전 중 집착도를 유지하고 실제 공격 시도 시 추격 여유를 회복한다. 반격 기회 없는 추격 시간·거리 한도를 소진하면 건물 또는 경계 위치로 복귀하며, 원거리 피격만으로는 추격 여유가 회복되지 않는다. +- 웨이브 적은 남쪽 진행선에 놓인 아군 포탈·거점·타워·코어를 북쪽부터 차례로 목표로 삼는다. +- 건물 첫 공격 전에는 플레이어가 어그로를 선점할 수 있지만, 건물 교전 시작 후에는 목표를 고정한다. 건물 파괴·비활성화 후에만 플레이어 선점 기회를 다시 열고 공격 주기는 유지한다. - 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다. - 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다. - 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다.