feat: refine enemy engagements and reduce tower damage

This commit is contained in:
2026-09-02 11:10:53 +09:00
parent 7c05a3db89
commit 5e95e08cbb
23 changed files with 563 additions and 96 deletions
+5
View File
@@ -20,6 +20,8 @@
- `TowerDefinition`과 `ConstructionDefinition`은 건물 번호나 이름을 복사하지 않고 대상 `BuildingDefinition` SO를 직접 참조한다. - `TowerDefinition`과 `ConstructionDefinition`은 건물 번호나 이름을 복사하지 않고 대상 `BuildingDefinition` SO를 직접 참조한다.
- 타워 기능과 건설 규칙이 함께 사용되는 지점에서는 두 정의가 동일한 `BuildingDefinition`을 참조하는지 검증한다. - 타워 기능과 건설 규칙이 함께 사용되는 지점에서는 두 정의가 동일한 `BuildingDefinition`을 참조하는지 검증한다.
- 건물 ID는 저장·네트워크 식별용 영구 번호이며 배열 순서로 사용하거나 한 번 사용한 번호를 다른 건물에 재사용하지 않는다. - 건물 ID는 저장·네트워크 식별용 영구 번호이며 배열 순서로 사용하거나 한 번 사용한 번호를 다른 건물에 재사용하지 않는다.
- 플레이어의 최대 체력, 이동·상호작용 수치, 일반·특수 공격 수치와 행동 정책은 `PlayerDefinition` SO가 소유하며 플레이어 컨트롤러와 전투 컴포넌트가 이를 직접 참조한다.
- 적의 플레이어 감지 범위, 반격 기회 없는 추격 시간·거리 한도와 재감지 조건은 공용 `EnemyPursuitDefinition` SO로 관리한다.
## Gameplay Data Architecture ## Gameplay Data Architecture
@@ -52,6 +54,9 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
- 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다. - 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다.
- 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다. - 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다. - 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
- 적이 플레이어를 공격할 수 있는 거리에서는 집착도 감소를 멈추고, 실제 공격 시도 시 추격 시간·거리 여유를 회복한다. 반격 기회 없이 추격하는 동안만 집착도가 줄며, 한도를 소진하면 건물 목표로 복귀한다. 플레이어나 타워가 적을 때리는 것만으로는 집착도가 회복되지 않는다. 어그로 이탈 후에는 플레이어와 충분히 떨어진 뒤에만 다시 감지하며, 건물을 지키려면 플레이어가 반격 위험을 감수하며 교전을 유지해야 한다.
- 웨이브 적의 기본 진군은 남쪽 방향을 유지하며, 진행 방향에 있는 아군 건물을 북쪽부터 차례로 파괴한 뒤 다음 남쪽 건물로 이동한다. 코어만 직행하거나 타워를 무시하지 않는다.
- 건물을 이동 목표로 선정하는 것과 교전을 고정하는 것은 구분한다. 건물에 첫 공격을 시작하기 전에는 플레이어가 어그로를 선점할 수 있다. 첫 건물 공격 이후에는 플레이어의 접근·공격이나 신규 건설로 목표를 바꾸지 않으며, 해당 건물이 파괴되거나 공격 불가능해진 뒤에만 목표와 플레이어 감지 제한을 다시 초기화한다. 목표 전환으로 공격 주기를 초기화하지 않는다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다. - 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
## Player and Tower Roles ## Player and Tower Roles
@@ -42,6 +42,12 @@ namespace Northbound.Core
void TakeDamage(float amount, Team sourceTeam); void TakeDamage(float amount, Team sourceTeam);
} }
public interface IEnemyStructureTarget
{
bool CanBeTargetedByEnemies { get; }
IDamageable DamageReceiver { get; }
}
public interface IInteractable public interface IInteractable
{ {
string GetInteractionPrompt(); string GetInteractionPrompt();
@@ -21,6 +21,8 @@ namespace Northbound.Editor
public const string ScenePath = "Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity"; public const string ScenePath = "Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity";
private const string MaterialDirectory = "Assets/Northbound/Generated/Materials"; private const string MaterialDirectory = "Assets/Northbound/Generated/Materials";
private const string DefinitionDirectory = "Assets/Northbound/Generated/Definitions"; 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 CoreBuildingPath = DefinitionDirectory + "/SouthernCoreBuilding.asset";
private const string OutpostBuildingPath = DefinitionDirectory + "/CrystalOutpostBuilding.asset"; private const string OutpostBuildingPath = DefinitionDirectory + "/CrystalOutpostBuilding.asset";
private const string PortalBuildingPath = DefinitionDirectory + "/PortalBuilding.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 basicTowerBuilding = GetBuildingDefinition(BasicTowerBuildingPath, 2000, "Basic Tower", 150f, tower, PrimitiveType.Cylinder, new Vector3(0.7f, 1.25f, 0.7f));
var basicTowerDefinition = GetBasicTowerDefinition(basicTowerBuilding); var basicTowerDefinition = GetBasicTowerDefinition(basicTowerBuilding);
var basicTowerConstruction = GetBasicTowerConstruction(basicTowerBuilding); var basicTowerConstruction = GetBasicTowerConstruction(basicTowerBuilding);
var playerDefinition = GetPlayerDefinition();
var enemyPursuitDefinition = GetEnemyPursuitDefinition();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
CreateEnvironment(ground, lane); CreateEnvironment(ground, lane);
@@ -61,9 +65,9 @@ namespace Northbound.Editor
var systems = new GameObject("Game Systems"); var systems = new GameObject("Game Systems");
systems.AddComponent<SharedGameState>(); systems.AddComponent<SharedGameState>();
systems.AddComponent<PrototypeHud>(); systems.AddComponent<PrototypeHud>();
systems.AddComponent<WaveDirector>().Configure(grunt, raider, elite); systems.AddComponent<WaveDirector>().Configure(grunt, raider, elite, enemyPursuitDefinition);
var playerController = CreatePlayer(player); var playerController = CreatePlayer(player, playerDefinition);
CreateCamera(playerController); CreateCamera(playerController);
CreateCore(core, coreBuilding); CreateCore(core, coreBuilding);
@@ -74,8 +78,8 @@ namespace Northbound.Editor
CreatePortal(index, portalPositions[index], initialState, portalBuilding, enemyPortal, ruinedPortal, alliedPortal); CreatePortal(index, portalPositions[index], initialState, portalBuilding, enemyPortal, ruinedPortal, alliedPortal);
if (initialState == PortalState.Enemy) if (initialState == PortalState.Enemy)
{ {
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); CreateGuardian(new Vector3(3f, 1f, portalPositions[index] - 3f), grunt, enemyPursuitDefinition);
} }
} }
@@ -113,15 +117,16 @@ namespace Northbound.Editor
light.intensity = 1.25f; 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); var gameObject = CreatePrimitive("Player", PrimitiveType.Capsule, new Vector3(0f, 1f, -76f), Vector3.one, material);
Object.DestroyImmediate(gameObject.GetComponent<CapsuleCollider>()); Object.DestroyImmediate(gameObject.GetComponent<CapsuleCollider>());
gameObject.AddComponent<CharacterController>(); gameObject.AddComponent<CharacterController>();
gameObject.AddComponent<Health>().Configure(Team.Player, 100f); gameObject.AddComponent<Health>().Configure(Team.Player, definition.MaximumHealth);
gameObject.AddComponent<PlayerActionController>(); gameObject.AddComponent<PlayerActionController>();
var controller = gameObject.AddComponent<PlayerController>(); var controller = gameObject.AddComponent<PlayerController>();
gameObject.AddComponent<PlayerCombat>(); controller.Configure(definition);
gameObject.AddComponent<PlayerCombat>().Configure(definition);
return controller; return controller;
} }
@@ -176,10 +181,10 @@ namespace Northbound.Editor
slot.AddComponent<DefenseSlot>().Configure(outpost, constructionDefinition, towerDefinition); slot.AddComponent<DefenseSlot>().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); var gameObject = CreatePrimitive("Portal Guard", PrimitiveType.Capsule, position, Vector3.one, material);
gameObject.AddComponent<EnemyAgent>().Configure(EnemyRole.PortalGuard, 0, material); gameObject.AddComponent<EnemyAgent>().Configure(EnemyRole.PortalGuard, 0, material, pursuitDefinition);
} }
private static GameObject CreatePrimitive(string name, PrimitiveType type, Vector3 position, Vector3 scale, Material material) private static GameObject CreatePrimitive(string name, PrimitiveType type, Vector3 position, Vector3 scale, Material material)
@@ -255,7 +260,7 @@ namespace Northbound.Editor
definition = ScriptableObject.CreateInstance<TowerDefinition>(); definition = ScriptableObject.CreateInstance<TowerDefinition>();
definition.name = "BasicTowerDefinition"; 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); AssetDatabase.CreateAsset(definition, BasicTowerDefinitionPath);
return definition; return definition;
} }
@@ -275,6 +280,50 @@ namespace Northbound.Editor
return definition; return definition;
} }
private static PlayerDefinition GetPlayerDefinition()
{
var definition = AssetDatabase.LoadAssetAtPath<PlayerDefinition>(PlayerDefinitionPath);
if (definition != null)
{
return definition;
}
definition = ScriptableObject.CreateInstance<PlayerDefinition>();
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<EnemyPursuitDefinition>(EnemyPursuitDefinitionPath);
if (definition != null)
{
return definition;
}
definition = ScriptableObject.CreateInstance<EnemyPursuitDefinition>();
definition.name = "DefaultEnemyPursuit";
definition.ConfigurePrototype(7f, 4.5f, 10f, 9f, 3.5f);
AssetDatabase.CreateAsset(definition, EnemyPursuitDefinitionPath);
return definition;
}
private static void EnsureDirectory(string assetPath) private static void EnsureDirectory(string assetPath)
{ {
var absolutePath = Path.GetFullPath(assetPath); var absolutePath = Path.GetFullPath(assetPath);
@@ -6,12 +6,23 @@ using UnityEngine;
namespace Northbound.Gameplay.Defense namespace Northbound.Gameplay.Defense
{ {
public sealed class BasicTower : MonoBehaviour public sealed class BasicTower : MonoBehaviour, IEnemyStructureTarget
{ {
[SerializeField] private TowerDefinition definition; [SerializeField] private TowerDefinition definition;
private float readyAt; private float readyAt;
public bool CanBeTargetedByEnemies
{
get
{
var health = GetComponent<Health>();
return definition?.Building != null && health != null && health.IsAlive;
}
}
public IDamageable DamageReceiver => GetComponent<Health>();
private void Update() private void Update()
{ {
if (definition == null || Time.time < readyAt) if (definition == null || Time.time < readyAt)
@@ -8,7 +8,7 @@ namespace Northbound.Gameplay.Defense
{ {
[SerializeField] private BuildingDefinition building; [SerializeField] private BuildingDefinition building;
[SerializeField] private float range = 8f; [SerializeField] private float range = 8f;
[SerializeField] private float damage = 9f; [SerializeField] private float damage = 6f;
[SerializeField] private float attackInterval = 1.2f; [SerializeField] private float attackInterval = 1.2f;
[SerializeField] private Color attackColor = new(1f, 0.75f, 0.15f, 0.95f); [SerializeField] private Color attackColor = new(1f, 0.75f, 0.15f, 0.95f);
+205 -20
View File
@@ -24,7 +24,7 @@ namespace Northbound.Gameplay.Enemy
[SerializeField] private float attackDamage = 12f; [SerializeField] private float attackDamage = 12f;
[SerializeField] private float attackRange = 2f; [SerializeField] private float attackRange = 2f;
[SerializeField] private float attackInterval = 1.25f; [SerializeField] private float attackInterval = 1.25f;
[SerializeField] private float playerAggroRange = 7f; [SerializeField] private EnemyPursuitDefinition pursuitDefinition;
[SerializeField] private bool affectedByResonance = true; [SerializeField] private bool affectedByResonance = true;
private float currentHealth; private float currentHealth;
@@ -32,6 +32,12 @@ namespace Northbound.Gameplay.Enemy
private Vector3 guardPosition; private Vector3 guardPosition;
private CharacterController movementController; private CharacterController movementController;
private float avoidanceDirection; 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 EnemyRole Role => role;
public Team Team => Team.Enemy; public Team Team => Team.Enemy;
@@ -57,6 +63,7 @@ namespace Northbound.Gameplay.Enemy
var target = SelectTarget(); var target = SelectTarget();
if (target == null) if (target == null)
{ {
ReturnToGuardPosition();
return; return;
} }
@@ -69,17 +76,32 @@ namespace Northbound.Gameplay.Enemy
return; return;
} }
if (Time.time >= readyAt && target is IDamageable damageable) var damageable = ResolveDamageReceiver(target);
if (Time.time >= readyAt && damageable != null)
{ {
readyAt = Time.time + attackInterval; 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); AttackTracer.Spawn(transform.position, targetPosition, new Color(1f, 0.18f, 0.08f, 0.95f), 0.22f, 0.2f);
damageable.TakeDamage(attackDamage, Team.Enemy); 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; role = enemyRole;
pursuitDefinition = pursuit;
affectedByResonance = enemyRole != EnemyRole.PortalGuard; affectedByResonance = enemyRole != EnemyRole.PortalGuard;
guardPosition = transform.position; guardPosition = transform.position;
@@ -176,43 +198,206 @@ namespace Northbound.Gameplay.Enemy
private MonoBehaviour SelectTarget() private MonoBehaviour SelectTarget()
{ {
var player = FindAnyObjectByType<PlayerController>(); if (isStructureEngagementLocked)
if (player != null && Vector3.SqrMagnitude(player.transform.position - transform.position) <= playerAggroRange * playerAggroRange)
{ {
return player.GetComponent<Combat.Health>(); 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<PlayerController>();
var playerHealth = player == null ? null : player.GetComponent<Combat.Health>();
var aggroOrigin = role == EnemyRole.PortalGuard ? guardPosition : transform.position;
if (TrySelectPlayer(player, playerHealth, aggroOrigin, out var selectedPlayer))
{
return selectedPlayer;
} }
if (role == EnemyRole.PortalGuard) if (role == EnemyRole.PortalGuard)
{
if (player == null || Vector3.SqrMagnitude(player.transform.position - guardPosition) > playerAggroRange * playerAggroRange)
{ {
return null; return null;
} }
return player.GetComponent<Combat.Health>(); return strategicTarget;
} }
if (role == EnemyRole.Raider) private MonoBehaviour SelectNorthernStructureTarget()
{ {
CrystalOutpost nearest = null; MonoBehaviour selected = null;
var nearestDistance = float.MaxValue; var selectedZ = float.NegativeInfinity;
foreach (var outpost in FindObjectsByType<CrystalOutpost>()) var selectedDistance = float.MaxValue;
var backtrackDistance = pursuitDefinition == null
? 0f
: pursuitDefinition.MaximumTargetBacktrackDistance;
var northernLimit = transform.position.z + backtrackDistance;
foreach (var behaviour in FindObjectsByType<MonoBehaviour>())
{ {
var distance = Vector3.SqrMagnitude(outpost.transform.position - transform.position); if (!behaviour.isActiveAndEnabled
if (outpost.IsCaptured && distance < nearestDistance) || behaviour is not IEnemyStructureTarget structure
|| !structure.CanBeTargetedByEnemies)
{ {
nearest = outpost; continue;
nearestDistance = distance; }
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;
} }
} }
if (nearest != null) return selected;
}
private static bool IsValidStructureTarget(MonoBehaviour target)
{ {
return nearest; 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)
{
EndPlayerPursuit(true);
return false;
} }
} }
return FindAnyObjectByType<CoreObjective>(); selectedPlayer = playerHealth;
return true;
}
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);
} }
} }
} }
@@ -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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9aa8e0cc71be49c9ac69d950aa3fe0d4
@@ -6,15 +6,7 @@ namespace Northbound.Gameplay.Player
{ {
public sealed class PlayerCombat : MonoBehaviour public sealed class PlayerCombat : MonoBehaviour
{ {
[SerializeField] private float normalDamage = 13f; [SerializeField] private PlayerDefinition definition;
[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);
private float normalReadyAt; private float normalReadyAt;
private float specialReadyAt; private float specialReadyAt;
@@ -24,6 +16,13 @@ namespace Northbound.Gameplay.Player
private void Awake() private void Awake()
{ {
if (definition == null)
{
Debug.LogError($"[{nameof(PlayerCombat)}] Player definition is missing on {name}.", this);
enabled = false;
return;
}
actionController = GetComponent<PlayerActionController>(); actionController = GetComponent<PlayerActionController>();
if (actionController == null) if (actionController == null)
{ {
@@ -31,6 +30,11 @@ namespace Northbound.Gameplay.Player
} }
} }
public void Configure(PlayerDefinition playerDefinition)
{
definition = playerDefinition;
}
private void Update() private void Update()
{ {
if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt && actionController is { CanStartAction: true }) if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt && actionController is { CanStartAction: true })
@@ -46,30 +50,30 @@ namespace Northbound.Gameplay.Player
private void NormalAttack() private void NormalAttack()
{ {
if (!actionController.TryBeginAction(normalAction)) if (!actionController.TryBeginAction(definition.NormalAction))
{ {
return; return;
} }
normalReadyAt = Time.time + normalCooldown; normalReadyAt = Time.time + definition.NormalCooldown;
var center = transform.position + transform.forward * (normalRange * 0.55f); var center = transform.position + transform.forward * (definition.NormalRange * 0.55f);
var radius = normalRange * 0.55f; var radius = definition.NormalRange * 0.55f;
AttackFlash.Spawn(center, radius, new Color(0.15f, 0.85f, 1f, 0.72f), 0.16f); 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() private void SpecialAttack()
{ {
var gameState = SharedGameState.Instance; 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; return;
} }
actionController.TryBeginAction(specialAction); actionController.TryBeginAction(definition.SpecialAction);
specialReadyAt = Time.time + specialCooldown; specialReadyAt = Time.time + definition.SpecialCooldown;
AttackFlash.Spawn(transform.position, specialRadius, new Color(0.8f, 0.25f, 1f, 0.68f), 0.32f); AttackFlash.Spawn(transform.position, definition.SpecialRadius, new Color(0.8f, 0.25f, 1f, 0.68f), 0.32f);
DamageTargets(Physics.OverlapSphere(transform.position, specialRadius), specialDamage); DamageTargets(Physics.OverlapSphere(transform.position, definition.SpecialRadius), definition.SpecialDamage);
} }
private static void DamageTargets(Collider[] colliders, float damage) private static void DamageTargets(Collider[] colliders, float damage)
@@ -1,16 +1,16 @@
using Northbound.Core; using Northbound.Core;
using Northbound.Gameplay.Combat;
using UnityEngine; using UnityEngine;
namespace Northbound.Gameplay.Player namespace Northbound.Gameplay.Player
{ {
[RequireComponent(typeof(CharacterController))] [RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(Health))]
[RequireComponent(typeof(PlayerActionController))] [RequireComponent(typeof(PlayerActionController))]
[DefaultExecutionOrder(-100)] [DefaultExecutionOrder(-100)]
public sealed class PlayerController : MonoBehaviour public sealed class PlayerController : MonoBehaviour
{ {
[SerializeField] private float moveSpeed = 5f; [SerializeField] private PlayerDefinition definition;
[SerializeField] private float horizontalLimit = 15f;
[SerializeField] private float interactionRadius = 3.2f;
private CharacterController characterController; private CharacterController characterController;
private PlayerActionController actionController; private PlayerActionController actionController;
@@ -21,12 +21,30 @@ namespace Northbound.Gameplay.Player
private void Awake() private void Awake()
{ {
if (definition == null)
{
Debug.LogError($"[{nameof(PlayerController)}] Player definition is missing on {name}.", this);
enabled = false;
return;
}
characterController = GetComponent<CharacterController>(); characterController = GetComponent<CharacterController>();
actionController = GetComponent<PlayerActionController>(); actionController = GetComponent<PlayerActionController>();
if (actionController == null) if (actionController == null)
{ {
actionController = gameObject.AddComponent<PlayerActionController>(); actionController = gameObject.AddComponent<PlayerActionController>();
} }
GetComponent<Health>().Configure(Team.Player, definition.MaximumHealth);
}
public void Configure(PlayerDefinition playerDefinition)
{
definition = playerDefinition;
if (definition != null && TryGetComponent<Health>(out var health))
{
health.Configure(Team.Player, definition.MaximumHealth);
}
} }
private void Update() private void Update()
@@ -58,9 +76,9 @@ namespace Northbound.Gameplay.Player
LastMoveDirection = input.normalized; LastMoveDirection = input.normalized;
} }
characterController.Move(input * (moveSpeed * Time.deltaTime)); characterController.Move(input * (definition.MoveSpeed * Time.deltaTime));
var position = transform.position; 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; position.y = 1f;
transform.position = position; transform.position = position;
} }
@@ -93,7 +111,7 @@ namespace Northbound.Gameplay.Player
var nearestDistance = float.MaxValue; var nearestDistance = float.MaxValue;
IInteractable nearest = null; 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<MonoBehaviour>()) foreach (var behaviour in collider.GetComponentsInParent<MonoBehaviour>())
{ {
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c84b6b014f9549e2a2d94b59016ca3bb
@@ -12,6 +12,7 @@ namespace Northbound.Gameplay.Waves
[SerializeField] private Material gruntMaterial; [SerializeField] private Material gruntMaterial;
[SerializeField] private Material raiderMaterial; [SerializeField] private Material raiderMaterial;
[SerializeField] private Material eliteMaterial; [SerializeField] private Material eliteMaterial;
[SerializeField] private EnemyPursuitDefinition pursuitDefinition;
private float nextWaveAt; private float nextWaveAt;
private int wave; private int wave;
@@ -41,11 +42,12 @@ namespace Northbound.Gameplay.Waves
PublishStatus(state.ResonanceSnapshot); 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; gruntMaterial = grunt;
raiderMaterial = raider; raiderMaterial = raider;
eliteMaterial = elite; eliteMaterial = elite;
pursuitDefinition = pursuit;
} }
private void BeginWave() private void BeginWave()
@@ -97,7 +99,7 @@ namespace Northbound.Gameplay.Waves
EnemyRole.Elite => eliteMaterial, EnemyRole.Elite => eliteMaterial,
_ => gruntMaterial _ => gruntMaterial
}; };
enemy.AddComponent<EnemyAgent>().Configure(role, resonance, material); enemy.AddComponent<EnemyAgent>().Configure(role, resonance, material, pursuitDefinition);
} }
private void PublishStatus(int resonance) private void PublishStatus(int resonance)
@@ -6,7 +6,7 @@ using UnityEngine;
namespace Northbound.Gameplay.World namespace Northbound.Gameplay.World
{ {
public sealed class CoreObjective : MonoBehaviour, IDamageable public sealed class CoreObjective : MonoBehaviour, IDamageable, IEnemyStructureTarget
{ {
[SerializeField] private BuildingDefinition buildingDefinition; [SerializeField] private BuildingDefinition buildingDefinition;
[SerializeField] private float defenseRange = 10f; [SerializeField] private float defenseRange = 10f;
@@ -19,6 +19,8 @@ namespace Northbound.Gameplay.World
public bool IsAlive => CurrentHealth > 0f; public bool IsAlive => CurrentHealth > 0f;
public float CurrentHealth { get; private set; } public float CurrentHealth { get; private set; }
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
public bool CanBeTargetedByEnemies => IsAlive;
public IDamageable DamageReceiver => this;
private void Awake() private void Awake()
{ {
@@ -6,7 +6,7 @@ using UnityEngine;
namespace Northbound.Gameplay.World 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 BuildingDefinition buildingDefinition;
[SerializeField] private float manaPerSecond = 1.25f; [SerializeField] private float manaPerSecond = 1.25f;
@@ -27,6 +27,8 @@ namespace Northbound.Gameplay.World
public bool CanInteract => !IsCaptured; public bool CanInteract => !IsCaptured;
public float CurrentHealth { get; private set; } public float CurrentHealth { get; private set; }
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
public bool CanBeTargetedByEnemies => IsCaptured && IsAlive;
public IDamageable DamageReceiver => this;
private float defenseReadyAt; private float defenseReadyAt;
@@ -12,7 +12,7 @@ namespace Northbound.Gameplay.World
Allied Allied
} }
public sealed class PortalSite : MonoBehaviour, IDamageable, IInteractable public sealed class PortalSite : MonoBehaviour, IDamageable, IInteractable, IEnemyStructureTarget
{ {
[SerializeField] private int networkIndex; [SerializeField] private int networkIndex;
[SerializeField] private PortalState state = PortalState.Enemy; [SerializeField] private PortalState state = PortalState.Enemy;
@@ -36,6 +36,8 @@ namespace Northbound.Gameplay.World
public bool CanInteract => state != PortalState.Enemy; public bool CanInteract => state != PortalState.Enemy;
public float CurrentHealth { get; private set; } public float CurrentHealth { get; private set; }
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth; public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
public bool CanBeTargetedByEnemies => state == PortalState.Allied && IsAlive;
public IDamageable DamageReceiver => this;
private void Awake() private void Awake()
{ {
@@ -70,7 +72,9 @@ namespace Northbound.Gameplay.World
public void TakeDamage(float amount, Team sourceTeam) 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; return;
} }
@@ -85,7 +89,7 @@ namespace Northbound.Gameplay.World
RefreshVisual(); RefreshVisual();
var gameState = SharedGameState.Instance; var gameState = SharedGameState.Instance;
if (gameState != null && gameState.CountEnemyPortals() == 0) if (playerAttackingEnemyPortal && gameState != null && gameState.CountEnemyPortals() == 0)
{ {
gameState.MarkVictory(); gameState.MarkVictory();
} }
@@ -14,6 +14,6 @@ MonoBehaviour:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.TowerDefinition m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.TowerDefinition
building: {fileID: 11400000, guid: e49a0dcf744247b8a77b63f797a21c4c, type: 2} building: {fileID: 11400000, guid: e49a0dcf744247b8a77b63f797a21c4c, type: 2}
range: 8 range: 8
damage: 9 damage: 6
attackInterval: 1.2 attackInterval: 1.2
attackColor: {r: 1, g: 0.75, b: 0.15, a: 0.95} attackColor: {r: 1, g: 0.75, b: 0.15, a: 0.95}
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d4ec708d3ab44238953e3bec1af6bb8e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a3d795b67fc44625a25501cdf16da914
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -157,7 +157,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &10466387 --- !u!23 &10466387
MeshRenderer: MeshRenderer:
@@ -292,7 +292,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &57707079 --- !u!23 &57707079
MeshRenderer: MeshRenderer:
@@ -879,9 +879,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3} m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController
moveSpeed: 5 definition: {fileID: 11400000, guid: a3d795b67fc44625a25501cdf16da914, type: 2}
horizontalLimit: 15
interactionRadius: 3.2
--- !u!114 &597054783 --- !u!114 &597054783
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -894,23 +892,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3} m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat
normalDamage: 13 definition: {fileID: 11400000, guid: a3d795b67fc44625a25501cdf16da914, type: 2}
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
--- !u!114 &597054784 --- !u!114 &597054784
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1061,7 +1043,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &673355559 --- !u!23 &673355559
MeshRenderer: MeshRenderer:
@@ -1322,6 +1304,7 @@ MonoBehaviour:
gruntMaterial: {fileID: 2100000, guid: 0c49d825c6397b14593263a8a25a95b0, type: 2} gruntMaterial: {fileID: 2100000, guid: 0c49d825c6397b14593263a8a25a95b0, type: 2}
raiderMaterial: {fileID: 2100000, guid: 5cb05999871dcdb44b50f6506260527a, type: 2} raiderMaterial: {fileID: 2100000, guid: 5cb05999871dcdb44b50f6506260527a, type: 2}
eliteMaterial: {fileID: 2100000, guid: 00e28eb844e1d184a944503ae8ffd906, type: 2} eliteMaterial: {fileID: 2100000, guid: 00e28eb844e1d184a944503ae8ffd906, type: 2}
pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
--- !u!114 &963263111 --- !u!114 &963263111
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -2536,7 +2519,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &1999748834 --- !u!23 &1999748834
MeshRenderer: MeshRenderer:
@@ -2671,7 +2654,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &2057559070 --- !u!23 &2057559070
MeshRenderer: MeshRenderer:
@@ -3198,7 +3181,7 @@ MonoBehaviour:
attackDamage: 15 attackDamage: 15
attackRange: 2 attackRange: 2
attackInterval: 1.25 attackInterval: 1.25
playerAggroRange: 7 pursuitDefinition: {fileID: 11400000, guid: d4ec708d3ab44238953e3bec1af6bb8e, type: 2}
affectedByResonance: 0 affectedByResonance: 0
--- !u!23 &2117110600 --- !u!23 &2117110600
MeshRenderer: MeshRenderer:
+4
View File
@@ -38,6 +38,7 @@
- 크리스탈당 제한된 방어 슬롯과 약한 지연 타워 - 크리스탈당 제한된 방어 슬롯과 약한 지연 타워
- 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리 - 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리
- 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조 - 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조
- 플레이어의 체력·이동·상호작용·공격 수치와 공격 행동 정책은 PlayerDefinition 자산에서 통합 관리
- 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대 - 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대
- 첫 웨이브 15초, 이후 90초 간격 - 첫 웨이브 15초, 이후 90초 간격
@@ -59,6 +60,9 @@
- 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다. - 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다.
- 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다. - 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다.
- 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다. - 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다.
- 적은 공격 사거리 내 교전 중 집착도를 유지하고 실제 공격 시도 시 추격 여유를 회복한다. 반격 기회 없는 추격 시간·거리 한도를 소진하면 건물 또는 경계 위치로 복귀하며, 원거리 피격만으로는 추격 여유가 회복되지 않는다.
- 웨이브 적은 남쪽 진행선에 놓인 아군 포탈·거점·타워·코어를 북쪽부터 차례로 목표로 삼는다.
- 건물 첫 공격 전에는 플레이어가 어그로를 선점할 수 있지만, 건물 교전 시작 후에는 목표를 고정한다. 건물 파괴·비활성화 후에만 플레이어 선점 기회를 다시 열고 공격 주기는 유지한다.
- 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다. - 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다.
- 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다. - 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다.
- 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다. - 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다.