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
@@ -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: