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를 직접 참조한다.
- 타워 기능과 건설 규칙이 함께 사용되는 지점에서는 두 정의가 동일한 `BuildingDefinition`을 참조하는지 검증한다.
- 건물 ID는 저장·네트워크 식별용 영구 번호이며 배열 순서로 사용하거나 한 번 사용한 번호를 다른 건물에 재사용하지 않는다.
- 플레이어의 최대 체력, 이동·상호작용 수치, 일반·특수 공격 수치와 행동 정책은 `PlayerDefinition` SO가 소유하며 플레이어 컨트롤러와 전투 컴포넌트가 이를 직접 참조한다.
- 적의 플레이어 감지 범위, 반격 기회 없는 추격 시간·거리 한도와 재감지 조건은 공용 `EnemyPursuitDefinition` SO로 관리한다.
## Gameplay Data Architecture
@@ -52,6 +54,9 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
- 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다.
- 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
- 적이 플레이어를 공격할 수 있는 거리에서는 집착도 감소를 멈추고, 실제 공격 시도 시 추격 시간·거리 여유를 회복한다. 반격 기회 없이 추격하는 동안만 집착도가 줄며, 한도를 소진하면 건물 목표로 복귀한다. 플레이어나 타워가 적을 때리는 것만으로는 집착도가 회복되지 않는다. 어그로 이탈 후에는 플레이어와 충분히 떨어진 뒤에만 다시 감지하며, 건물을 지키려면 플레이어가 반격 위험을 감수하며 교전을 유지해야 한다.
- 웨이브 적의 기본 진군은 남쪽 방향을 유지하며, 진행 방향에 있는 아군 건물을 북쪽부터 차례로 파괴한 뒤 다음 남쪽 건물로 이동한다. 코어만 직행하거나 타워를 무시하지 않는다.
- 건물을 이동 목표로 선정하는 것과 교전을 고정하는 것은 구분한다. 건물에 첫 공격을 시작하기 전에는 플레이어가 어그로를 선점할 수 있다. 첫 건물 공격 이후에는 플레이어의 접근·공격이나 신규 건설로 목표를 바꾸지 않으며, 해당 건물이 파괴되거나 공격 불가능해진 뒤에만 목표와 플레이어 감지 제한을 다시 초기화한다. 목표 전환으로 공격 주기를 초기화하지 않는다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
## Player and Tower Roles
@@ -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();
@@ -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<SharedGameState>();
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);
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<CapsuleCollider>());
gameObject.AddComponent<CharacterController>();
gameObject.AddComponent<Health>().Configure(Team.Player, 100f);
gameObject.AddComponent<Health>().Configure(Team.Player, definition.MaximumHealth);
gameObject.AddComponent<PlayerActionController>();
var controller = gameObject.AddComponent<PlayerController>();
gameObject.AddComponent<PlayerCombat>();
controller.Configure(definition);
gameObject.AddComponent<PlayerCombat>().Configure(definition);
return controller;
}
@@ -176,10 +181,10 @@ namespace Northbound.Editor
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);
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)
@@ -255,7 +260,7 @@ namespace Northbound.Editor
definition = ScriptableObject.CreateInstance<TowerDefinition>();
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<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)
{
var absolutePath = Path.GetFullPath(assetPath);
@@ -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<Health>();
return definition?.Building != null && health != null && health.IsAlive;
}
}
public IDamageable DamageReceiver => GetComponent<Health>();
private void Update()
{
if (definition == null || Time.time < readyAt)
@@ -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);
+210 -25
View File
@@ -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<PlayerController>();
if (player != null && Vector3.SqrMagnitude(player.transform.position - transform.position) <= playerAggroRange * playerAggroRange)
if (isStructureEngagementLocked)
{
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 (player == null || Vector3.SqrMagnitude(player.transform.position - guardPosition) > playerAggroRange * playerAggroRange)
{
return null;
}
return player.GetComponent<Combat.Health>();
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<MonoBehaviour>())
{
CrystalOutpost nearest = null;
var nearestDistance = float.MaxValue;
foreach (var outpost in FindObjectsByType<CrystalOutpost>())
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<CoreObjective>();
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
{
[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<PlayerActionController>();
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)
@@ -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<CharacterController>();
actionController = GetComponent<PlayerActionController>();
if (actionController == null)
{
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()
@@ -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<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 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<EnemyAgent>().Configure(role, resonance, material);
enemy.AddComponent<EnemyAgent>().Configure(role, resonance, material, pursuitDefinition);
}
private void PublishStatus(int resonance)
@@ -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()
{
@@ -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;
@@ -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();
}
@@ -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}
@@ -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
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:
+4
View File
@@ -38,6 +38,7 @@
- 크리스탈당 제한된 방어 슬롯과 약한 지연 타워
- 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리
- 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조
- 플레이어의 체력·이동·상호작용·공격 수치와 공격 행동 정책은 PlayerDefinition 자산에서 통합 관리
- 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대
- 첫 웨이브 15초, 이후 90초 간격
@@ -59,6 +60,9 @@
- 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다.
- 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다.
- 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다.
- 적은 공격 사거리 내 교전 중 집착도를 유지하고 실제 공격 시도 시 추격 여유를 회복한다. 반격 기회 없는 추격 시간·거리 한도를 소진하면 건물 또는 경계 위치로 복귀하며, 원거리 피격만으로는 추격 여유가 회복되지 않는다.
- 웨이브 적은 남쪽 진행선에 놓인 아군 포탈·거점·타워·코어를 북쪽부터 차례로 목표로 삼는다.
- 건물 첫 공격 전에는 플레이어가 어그로를 선점할 수 있지만, 건물 교전 시작 후에는 목표를 고정한다. 건물 파괴·비활성화 후에만 플레이어 선점 기회를 다시 열고 공격 주기는 유지한다.
- 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다.
- 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다.
- 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다.