feat: deepen prototype combat and building systems

This commit is contained in:
2026-09-01 13:04:59 +09:00
parent 0c8cfaf454
commit 3e75357a33
43 changed files with 1413 additions and 108 deletions
+38 -2
View File
@@ -1,5 +1,27 @@
namespace Northbound.Core
{
[System.Serializable]
public struct PlayerActionDefinition
{
[UnityEngine.SerializeField] private string actionName;
[UnityEngine.SerializeField] private float duration;
[UnityEngine.SerializeField] private bool allowsMovement;
[UnityEngine.SerializeField] private bool canBeCancelled;
public string ActionName => actionName;
public float Duration => UnityEngine.Mathf.Max(0f, duration);
public bool AllowsMovement => allowsMovement;
public bool CanBeCancelled => canBeCancelled;
public PlayerActionDefinition(string name, float actionDuration, bool canMoveDuringAction, bool isCancellable = false)
{
actionName = name;
duration = UnityEngine.Mathf.Max(0f, actionDuration);
allowsMovement = canMoveDuringAction;
canBeCancelled = isCancellable;
}
}
public enum Team
{
Neutral,
@@ -7,7 +29,13 @@ namespace Northbound.Core
Enemy
}
public interface IDamageable
public interface IHealthReadable
{
float CurrentHealth { get; }
float MaximumHealth { get; }
}
public interface IDamageable : IHealthReadable
{
Team Team { get; }
bool IsAlive { get; }
@@ -17,8 +45,16 @@ namespace Northbound.Core
public interface IInteractable
{
string GetInteractionPrompt();
PlayerActionDefinition GetActionDefinition();
bool CanInteract { get; }
void Interact(UnityEngine.GameObject actor);
bool CanStartInteraction(UnityEngine.GameObject actor);
bool Interact(UnityEngine.GameObject actor);
}
public interface IProgressiveInteraction
{
bool BeginInteraction(UnityEngine.GameObject actor);
void CancelInteraction(UnityEngine.GameObject actor, float normalizedActionProgress);
}
public static class TeamRules
@@ -1,7 +1,9 @@
using System.IO;
using Northbound.Core;
using Northbound.Gameplay;
using Northbound.Gameplay.Buildings;
using Northbound.Gameplay.Combat;
using Northbound.Gameplay.Construction;
using Northbound.Gameplay.Defense;
using Northbound.Gameplay.Enemy;
using Northbound.Gameplay.Player;
@@ -18,11 +20,19 @@ 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 CoreBuildingPath = DefinitionDirectory + "/SouthernCoreBuilding.asset";
private const string OutpostBuildingPath = DefinitionDirectory + "/CrystalOutpostBuilding.asset";
private const string PortalBuildingPath = DefinitionDirectory + "/PortalBuilding.asset";
private const string BasicTowerBuildingPath = DefinitionDirectory + "/BasicTowerBuilding.asset";
private const string BasicTowerDefinitionPath = DefinitionDirectory + "/BasicTowerDefinition.asset";
private const string BasicTowerConstructionPath = DefinitionDirectory + "/BasicTowerConstruction.asset";
[MenuItem("Northbound/Build Gameplay Prototype")]
public static void BuildScene()
{
EnsureDirectory(MaterialDirectory);
EnsureDirectory(DefinitionDirectory);
var ground = GetMaterial("Ground", new Color(0.11f, 0.15f, 0.14f));
var lane = GetMaterial("Lane", new Color(0.18f, 0.24f, 0.22f));
@@ -37,6 +47,12 @@ namespace Northbound.Editor
var grunt = GetMaterial("EnemyGrunt", new Color(0.85f, 0.32f, 0.25f));
var raider = GetMaterial("EnemyRaider", new Color(1f, 0.55f, 0.12f));
var elite = GetMaterial("EnemyElite", new Color(0.75f, 0.12f, 0.75f));
var coreBuilding = GetBuildingDefinition(CoreBuildingPath, 1000, "Southern Core", 1000f, core, PrimitiveType.Cube, new Vector3(8f, 4f, 5f));
var outpostBuilding = GetBuildingDefinition(OutpostBuildingPath, 1100, "Crystal Outpost", 300f, crystalActive, PrimitiveType.Sphere, new Vector3(2f, 3f, 2f));
var portalBuilding = GetBuildingDefinition(PortalBuildingPath, 1200, "Portal", 240f, alliedPortal, PrimitiveType.Cylinder, new Vector3(3f, 0.45f, 3f));
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 scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
CreateEnvironment(ground, lane);
@@ -49,13 +65,13 @@ namespace Northbound.Editor
var playerController = CreatePlayer(player);
CreateCamera(playerController);
CreateCore(core);
CreateCore(core, coreBuilding);
var portalPositions = new[] { -68f, -25f, 20f, 68f };
for (var index = 0; index < portalPositions.Length; index++)
{
var initialState = index == 0 ? PortalState.Allied : PortalState.Enemy;
CreatePortal(index, portalPositions[index], initialState, enemyPortal, ruinedPortal, alliedPortal);
CreatePortal(index, portalPositions[index], initialState, portalBuilding, enemyPortal, ruinedPortal, alliedPortal);
if (initialState == PortalState.Enemy)
{
CreateGuardian(new Vector3(-3f, 1f, portalPositions[index] - 3f), grunt);
@@ -63,9 +79,9 @@ namespace Northbound.Editor
}
}
CreateCrystal(new Vector3(-8f, 1f, -45f), crystalDormant, crystalActive, tower);
CreateCrystal(new Vector3(8f, 1f, -3f), crystalDormant, crystalActive, tower);
CreateCrystal(new Vector3(-7f, 1f, 42f), crystalDormant, crystalActive, tower);
CreateCrystal(new Vector3(-8f, 1f, -45f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
CreateCrystal(new Vector3(8f, 1f, -3f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
CreateCrystal(new Vector3(-7f, 1f, 42f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
EditorSceneManager.SaveScene(scene, ScenePath);
EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(ScenePath, true) };
@@ -102,7 +118,8 @@ namespace Northbound.Editor
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, 180f);
gameObject.AddComponent<Health>().Configure(Team.Player, 100f);
gameObject.AddComponent<PlayerActionController>();
var controller = gameObject.AddComponent<PlayerController>();
gameObject.AddComponent<PlayerCombat>();
return controller;
@@ -112,7 +129,7 @@ namespace Northbound.Editor
{
var cameraObject = new GameObject("Main Camera");
cameraObject.tag = "MainCamera";
cameraObject.transform.position = new Vector3(0f, 30f, -70f);
cameraObject.transform.position = new Vector3(0f, 30f, -76f);
cameraObject.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
var camera = cameraObject.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
@@ -123,33 +140,40 @@ namespace Northbound.Editor
cameraObject.AddComponent<VerticalCamera>().Configure(player);
}
private static void CreateCore(Material material)
private static void CreateCore(Material material, BuildingDefinition definition)
{
var gameObject = CreatePrimitive("Southern Core", PrimitiveType.Cube, new Vector3(0f, 2f, -84f), new Vector3(8f, 4f, 5f), material);
gameObject.AddComponent<CoreObjective>();
gameObject.AddComponent<CoreObjective>().Configure(definition);
}
private static void CreatePortal(
int index,
float z,
PortalState initialState,
BuildingDefinition definition,
Material enemy,
Material ruined,
Material allied)
{
var gameObject = CreatePrimitive($"Portal {index}", PrimitiveType.Cylinder, new Vector3(0f, 0.45f, z), new Vector3(3f, 0.45f, 3f), ruined);
var renderer = gameObject.GetComponent<Renderer>();
gameObject.AddComponent<PortalSite>().Configure(index, initialState, renderer, enemy, ruined, allied);
gameObject.AddComponent<PortalSite>().Configure(index, initialState, definition, renderer, enemy, ruined, allied);
}
private static void CreateCrystal(Vector3 position, Material dormant, Material active, Material tower)
private static void CreateCrystal(
Vector3 position,
BuildingDefinition outpostDefinition,
Material dormant,
Material active,
ConstructionDefinition constructionDefinition,
TowerDefinition towerDefinition)
{
var gameObject = CreatePrimitive("Crystal Outpost", PrimitiveType.Sphere, position, new Vector3(2f, 3f, 2f), dormant);
var outpost = gameObject.AddComponent<CrystalOutpost>();
outpost.Configure(gameObject.GetComponent<Renderer>(), dormant, active);
outpost.Configure(outpostDefinition, gameObject.GetComponent<Renderer>(), dormant, active);
var slot = CreatePrimitive("Defense Slot", PrimitiveType.Cube, position + new Vector3(3.3f, -0.65f, 0f), new Vector3(1.8f, 0.3f, 1.8f), dormant);
slot.AddComponent<DefenseSlot>().Configure(outpost, tower);
slot.AddComponent<DefenseSlot>().Configure(outpost, constructionDefinition, towerDefinition);
}
private static void CreateGuardian(Vector3 position, Material material)
@@ -184,6 +208,64 @@ namespace Northbound.Editor
return material;
}
private static BuildingDefinition GetBuildingDefinition(
string path,
int buildingId,
string displayName,
float maximumHealth,
Material prototypeMaterial,
PrimitiveType prototypePrimitive,
Vector3 prototypeScale)
{
var definition = AssetDatabase.LoadAssetAtPath<BuildingDefinition>(path);
if (definition != null)
{
return definition;
}
definition = ScriptableObject.CreateInstance<BuildingDefinition>();
definition.name = Path.GetFileNameWithoutExtension(path);
definition.ConfigurePrototype(
buildingId,
displayName,
maximumHealth,
prototypeMaterial,
prototypePrimitive,
prototypeScale);
AssetDatabase.CreateAsset(definition, path);
return definition;
}
private static TowerDefinition GetBasicTowerDefinition(BuildingDefinition building)
{
var definition = AssetDatabase.LoadAssetAtPath<TowerDefinition>(BasicTowerDefinitionPath);
if (definition != null)
{
return definition;
}
definition = ScriptableObject.CreateInstance<TowerDefinition>();
definition.name = "BasicTowerDefinition";
definition.ConfigurePrototype(building, 8f, 9f, 1.2f, new Color(1f, 0.75f, 0.15f, 0.95f));
AssetDatabase.CreateAsset(definition, BasicTowerDefinitionPath);
return definition;
}
private static ConstructionDefinition GetBasicTowerConstruction(BuildingDefinition building)
{
var definition = AssetDatabase.LoadAssetAtPath<ConstructionDefinition>(BasicTowerConstructionPath);
if (definition != null)
{
return definition;
}
definition = ScriptableObject.CreateInstance<ConstructionDefinition>();
definition.name = "BasicTowerConstruction";
definition.ConfigurePrototype(building, 35f, 3f, false, true);
AssetDatabase.CreateAsset(definition, BasicTowerConstructionPath);
return definition;
}
private static void EnsureDirectory(string assetPath)
{
var absolutePath = Path.GetFullPath(assetPath);
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b02cc11687084bacaae00cc7963c9d14
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
using UnityEngine;
namespace Northbound.Gameplay.Buildings
{
[CreateAssetMenu(fileName = "BuildingDefinition", menuName = "Northbound/Building Definition")]
public sealed class BuildingDefinition : ScriptableObject
{
[SerializeField] private int buildingId;
[SerializeField] private string displayName = "Building";
[SerializeField, TextArea] private string description;
[SerializeField] private float maximumHealth = 100f;
[SerializeField] private GameObject completedPrefab;
[SerializeField] private GameObject constructionPreviewPrefab;
[SerializeField] private Material prototypeMaterial;
[SerializeField] private PrimitiveType prototypePrimitive = PrimitiveType.Cube;
[SerializeField] private Vector3 prototypeScale = Vector3.one;
public int BuildingId => buildingId;
public string DisplayName => displayName;
public string Description => description;
public float MaximumHealth => Mathf.Max(1f, maximumHealth);
public GameObject CompletedPrefab => completedPrefab;
public GameObject ConstructionPreviewPrefab => constructionPreviewPrefab;
public Material PrototypeMaterial => prototypeMaterial;
public PrimitiveType PrototypePrimitive => prototypePrimitive;
public Vector3 PrototypeScale => prototypeScale;
public void ConfigurePrototype(
int id,
string definitionName,
float health,
Material material,
PrimitiveType primitive,
Vector3 scale)
{
buildingId = id;
displayName = definitionName;
maximumHealth = Mathf.Max(1f, health);
prototypeMaterial = material;
prototypePrimitive = primitive;
prototypeScale = scale;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85b66c82f35d4c4c9626b843a50e12f1
@@ -15,6 +15,8 @@ namespace Northbound.Gameplay.Combat
public bool IsAlive => Current > 0f;
public float Current { get; private set; }
public float Maximum => maximum;
public float CurrentHealth => Current;
public float MaximumHealth => maximum;
private void Awake()
{
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76cb6d4e020445f28c20adf669b4f56f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
using Northbound.Core;
using Northbound.Gameplay.Buildings;
using UnityEngine;
namespace Northbound.Gameplay.Construction
{
[CreateAssetMenu(fileName = "ConstructionDefinition", menuName = "Northbound/Construction Definition")]
public sealed class ConstructionDefinition : ScriptableObject
{
[SerializeField] private BuildingDefinition building;
[SerializeField] private float manaCost = 35f;
[SerializeField] private float buildDuration = 3f;
[SerializeField] private bool allowsMovementDuringBuild;
[SerializeField] private bool canCancelBuild = true;
public BuildingDefinition Building => building;
public float ManaCost => Mathf.Max(0f, manaCost);
public float BuildDuration => Mathf.Max(0.05f, buildDuration);
public PlayerActionDefinition CreateBuildAction(float currentProgress)
{
var remainingDuration = BuildDuration * (1f - Mathf.Clamp01(currentProgress));
return new PlayerActionDefinition(
$"Build {building.DisplayName}",
Mathf.Max(0.05f, remainingDuration),
allowsMovementDuringBuild,
canCancelBuild);
}
public void ConfigurePrototype(
BuildingDefinition buildingDefinition,
float cost,
float duration,
bool allowsMovement,
bool canCancel)
{
building = buildingDefinition;
manaCost = Mathf.Max(0f, cost);
buildDuration = Mathf.Max(0.05f, duration);
allowsMovementDuringBuild = allowsMovement;
canCancelBuild = canCancel;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ca73b95ed924661b0f122f634acb09f
@@ -1,26 +1,26 @@
using Northbound.Core;
using Northbound.Gameplay.Combat;
using Northbound.Gameplay.Enemy;
using Northbound.Gameplay.Presentation;
using UnityEngine;
namespace Northbound.Gameplay.Defense
{
public sealed class BasicTower : MonoBehaviour
{
[SerializeField] private float range = 8f;
[SerializeField] private float damage = 9f;
[SerializeField] private float attackInterval = 1.2f;
[SerializeField] private TowerDefinition definition;
private float readyAt;
private void Update()
{
if (Time.time < readyAt)
if (definition == null || Time.time < readyAt)
{
return;
}
EnemyAgent nearest = null;
var nearestDistance = range * range;
var nearestDistance = definition.Range * definition.Range;
foreach (var enemy in FindObjectsByType<EnemyAgent>())
{
var distance = Vector3.SqrMagnitude(enemy.transform.position - transform.position);
@@ -36,8 +36,26 @@ namespace Northbound.Gameplay.Defense
return;
}
readyAt = Time.time + attackInterval;
nearest.TakeDamage(damage, Team.Player);
readyAt = Time.time + definition.AttackInterval;
AttackTracer.Spawn(transform.position, nearest.transform.position, definition.AttackColor, 0.16f, 0.18f);
nearest.TakeDamage(definition.Damage, Team.Player);
}
public void Configure(TowerDefinition towerDefinition)
{
definition = towerDefinition;
if (definition?.Building == null)
{
return;
}
var health = GetComponent<Health>();
if (health == null)
{
health = gameObject.AddComponent<Health>();
}
health.Configure(Team.Player, definition.Building.MaximumHealth);
}
}
}
+175 -21
View File
@@ -1,43 +1,197 @@
using Northbound.Core;
using Northbound.Gameplay.Construction;
using Northbound.Gameplay.World;
using UnityEngine;
namespace Northbound.Gameplay.Defense
{
public sealed class DefenseSlot : MonoBehaviour, IInteractable
public sealed class DefenseSlot : MonoBehaviour, IInteractable, IProgressiveInteraction
{
[SerializeField] private CrystalOutpost outpost;
[SerializeField] private float buildCost = 35f;
[SerializeField] private Material towerMaterial;
[SerializeField] private ConstructionDefinition constructionDefinition;
[SerializeField] private TowerDefinition towerDefinition;
public bool CanInteract => outpost != null && outpost.IsCaptured && GetComponentInChildren<BasicTower>() == null;
private float buildProgress;
private bool constructionFunded;
private bool isActivelyBuilding;
private float activeBuildStartedAt;
private float activeBuildStartProgress;
private float activeBuildDuration;
private GameObject constructionPreview;
public void Configure(CrystalOutpost owner, Material material)
public bool CanInteract => constructionDefinition != null
&& constructionDefinition.Building != null
&& towerDefinition != null
&& towerDefinition.Building == constructionDefinition.Building
&& outpost != null
&& outpost.IsCaptured
&& GetComponentInChildren<BasicTower>() == null;
public void Configure(
CrystalOutpost owner,
ConstructionDefinition construction,
TowerDefinition tower)
{
outpost = owner;
towerMaterial = material;
constructionDefinition = construction;
towerDefinition = tower;
}
public string GetInteractionPrompt()
private void Update()
{
return $"Build delaying tower ({buildCost:0} mana)";
}
public void Interact(GameObject actor)
{
var gameState = SharedGameState.Instance;
if (!CanInteract || gameState == null || !gameState.TrySpendMana(buildCost))
if (!isActivelyBuilding)
{
return;
}
var towerObject = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
towerObject.name = "Basic Tower";
towerObject.transform.SetParent(transform, false);
towerObject.transform.localPosition = Vector3.up * 1.25f;
towerObject.transform.localScale = new Vector3(0.7f, 1.25f, 0.7f);
towerObject.GetComponent<Renderer>().sharedMaterial = towerMaterial;
towerObject.AddComponent<BasicTower>();
var actionProgress = Mathf.Clamp01((Time.time - activeBuildStartedAt) / activeBuildDuration);
buildProgress = Mathf.Lerp(activeBuildStartProgress, 1f, actionProgress);
RefreshConstructionPreview();
}
public string GetInteractionPrompt()
{
return constructionFunded
? $"Resume {constructionDefinition.Building.DisplayName} ({buildProgress * 100f:0}%)"
: $"Build {constructionDefinition.Building.DisplayName} ({constructionDefinition.ManaCost:0} mana)";
}
public PlayerActionDefinition GetActionDefinition()
{
return constructionDefinition.CreateBuildAction(buildProgress);
}
public bool CanStartInteraction(GameObject actor)
{
var gameState = SharedGameState.Instance;
return CanInteract
&& gameState != null
&& (constructionFunded || gameState.Mana + 0.001f >= constructionDefinition.ManaCost);
}
public bool BeginInteraction(GameObject actor)
{
var gameState = SharedGameState.Instance;
if (!CanInteract || gameState == null)
{
return false;
}
if (!constructionFunded)
{
if (!gameState.TrySpendMana(constructionDefinition.ManaCost))
{
return false;
}
constructionFunded = true;
}
activeBuildStartProgress = buildProgress;
activeBuildDuration = Mathf.Max(0.05f, constructionDefinition.BuildDuration * (1f - buildProgress));
activeBuildStartedAt = Time.time;
isActivelyBuilding = true;
EnsureConstructionPreview();
RefreshConstructionPreview();
return true;
}
public void CancelInteraction(GameObject actor, float normalizedActionProgress)
{
buildProgress = Mathf.Max(
buildProgress,
Mathf.Lerp(activeBuildStartProgress, 1f, Mathf.Clamp01(normalizedActionProgress)));
isActivelyBuilding = false;
RefreshConstructionPreview();
}
public bool Interact(GameObject actor)
{
if (!CanInteract || !constructionFunded)
{
return false;
}
buildProgress = 1f;
isActivelyBuilding = false;
if (constructionPreview != null)
{
Destroy(constructionPreview);
constructionPreview = null;
}
var towerObject = CreateCompletedObject();
var tower = towerObject.GetComponent<BasicTower>();
if (tower == null)
{
tower = towerObject.AddComponent<BasicTower>();
}
tower.Configure(towerDefinition);
constructionFunded = false;
return true;
}
private void EnsureConstructionPreview()
{
if (constructionPreview != null)
{
return;
}
var building = constructionDefinition.Building;
constructionPreview = building.ConstructionPreviewPrefab != null
? Instantiate(building.ConstructionPreviewPrefab, transform)
: GameObject.CreatePrimitive(building.PrototypePrimitive);
constructionPreview.name = "Tower Construction";
constructionPreview.transform.SetParent(transform, false);
if (constructionPreview.TryGetComponent<Renderer>(out var renderer))
{
renderer.sharedMaterial = building.PrototypeMaterial;
}
if (constructionPreview.TryGetComponent<Collider>(out var collider))
{
collider.enabled = false;
Destroy(collider);
}
}
private void RefreshConstructionPreview()
{
if (constructionPreview == null)
{
return;
}
var finalScale = constructionDefinition.Building.PrototypeScale;
var height = Mathf.Lerp(0.08f, finalScale.y, buildProgress);
constructionPreview.transform.localPosition = Vector3.up * height;
constructionPreview.transform.localScale = new Vector3(finalScale.x, height, finalScale.z);
}
private GameObject CreateCompletedObject()
{
var building = constructionDefinition.Building;
if (building.CompletedPrefab != null)
{
var completed = Instantiate(building.CompletedPrefab, transform);
completed.name = building.DisplayName;
return completed;
}
var prototype = GameObject.CreatePrimitive(building.PrototypePrimitive);
prototype.name = building.DisplayName;
prototype.transform.SetParent(transform, false);
prototype.transform.localPosition = Vector3.up * building.PrototypeScale.y;
prototype.transform.localScale = building.PrototypeScale;
if (prototype.TryGetComponent<Renderer>(out var renderer))
{
renderer.sharedMaterial = building.PrototypeMaterial;
}
return prototype;
}
}
}
@@ -0,0 +1,35 @@
using Northbound.Gameplay.Buildings;
using UnityEngine;
namespace Northbound.Gameplay.Defense
{
[CreateAssetMenu(fileName = "TowerDefinition", menuName = "Northbound/Tower Definition")]
public sealed class TowerDefinition : ScriptableObject
{
[SerializeField] private BuildingDefinition building;
[SerializeField] private float range = 8f;
[SerializeField] private float damage = 9f;
[SerializeField] private float attackInterval = 1.2f;
[SerializeField] private Color attackColor = new(1f, 0.75f, 0.15f, 0.95f);
public BuildingDefinition Building => building;
public float Range => Mathf.Max(0f, range);
public float Damage => Mathf.Max(0f, damage);
public float AttackInterval => Mathf.Max(0.05f, attackInterval);
public Color AttackColor => attackColor;
public void ConfigurePrototype(
BuildingDefinition buildingDefinition,
float attackRange,
float attackDamage,
float interval,
Color tracerColor)
{
building = buildingDefinition;
range = Mathf.Max(0f, attackRange);
damage = Mathf.Max(0f, attackDamage);
attackInterval = Mathf.Max(0.05f, interval);
attackColor = tracerColor;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b8cf5bfa976c4a68b37f6a2f2b9c10d6
+74 -3
View File
@@ -1,5 +1,6 @@
using Northbound.Core;
using Northbound.Gameplay.Player;
using Northbound.Gameplay.Presentation;
using Northbound.Gameplay.World;
using UnityEngine;
@@ -15,6 +16,8 @@ namespace Northbound.Gameplay.Enemy
public sealed class EnemyAgent : MonoBehaviour, IDamageable
{
private static int nextAvoidanceSide;
[SerializeField] private EnemyRole role;
[SerializeField] private float maximumHealth = 75f;
[SerializeField] private float moveSpeed = 2.7f;
@@ -27,15 +30,21 @@ namespace Northbound.Gameplay.Enemy
private float currentHealth;
private float readyAt;
private Vector3 guardPosition;
private CharacterController movementController;
private float avoidanceDirection;
public EnemyRole Role => role;
public Team Team => Team.Enemy;
public bool IsAlive => currentHealth > 0f;
public float CurrentHealth => currentHealth;
public float MaximumHealth => maximumHealth;
private void Awake()
{
currentHealth = maximumHealth;
guardPosition = transform.position;
avoidanceDirection = (nextAvoidanceSide++ & 1) == 0 ? 1f : -1f;
PrepareMovementController();
}
private void Update()
@@ -51,19 +60,19 @@ namespace Northbound.Gameplay.Enemy
return;
}
var targetPosition = target.transform.position;
var targetPosition = GetClosestTargetPoint(target);
var planar = targetPosition - transform.position;
planar.y = 0f;
if (planar.sqrMagnitude > attackRange * attackRange)
{
transform.position += planar.normalized * (moveSpeed * Time.deltaTime);
transform.rotation = Quaternion.LookRotation(planar.normalized);
MoveTowardsTarget(planar.normalized);
return;
}
if (Time.time >= readyAt && target is IDamageable damageable)
{
readyAt = Time.time + attackInterval;
AttackTracer.Spawn(transform.position, targetPosition, new Color(1f, 0.18f, 0.08f, 0.95f), 0.22f, 0.2f);
damageable.TakeDamage(attackDamage, Team.Enemy);
}
}
@@ -103,6 +112,68 @@ namespace Northbound.Gameplay.Enemy
}
}
private void PrepareMovementController()
{
foreach (var collider in GetComponents<Collider>())
{
if (collider is CharacterController)
{
continue;
}
collider.enabled = false;
Destroy(collider);
}
movementController = GetComponent<CharacterController>();
if (movementController == null)
{
movementController = gameObject.AddComponent<CharacterController>();
}
movementController.center = Vector3.zero;
movementController.height = 2f;
movementController.radius = 0.45f;
movementController.stepOffset = 0.3f;
movementController.skinWidth = 0.08f;
movementController.minMoveDistance = 0f;
movementController.detectCollisions = true;
movementController.enableOverlapRecovery = true;
}
private Vector3 GetClosestTargetPoint(MonoBehaviour target)
{
var targetCollider = target.GetComponent<Collider>();
if (targetCollider == null)
{
targetCollider = target.GetComponentInChildren<Collider>();
}
var targetPoint = targetCollider == null
? target.transform.position
: targetCollider.ClosestPoint(transform.position);
targetPoint.y = transform.position.y;
return targetPoint;
}
private void MoveTowardsTarget(Vector3 direction)
{
if (movementController == null)
{
return;
}
var step = moveSpeed * Time.deltaTime;
var collision = movementController.Move(direction * step);
if ((collision & CollisionFlags.Sides) != 0)
{
var sideways = Vector3.Cross(Vector3.up, direction) * avoidanceDirection;
movementController.Move(sideways * (step * 0.85f));
}
transform.rotation = Quaternion.LookRotation(direction);
}
private MonoBehaviour SelectTarget()
{
var player = FindAnyObjectByType<PlayerController>();
@@ -0,0 +1,90 @@
using System;
using Northbound.Core;
using UnityEngine;
namespace Northbound.Gameplay.Player
{
public sealed class PlayerActionController : MonoBehaviour
{
[SerializeField] private KeyCode cancelKey = KeyCode.Escape;
private PlayerActionDefinition currentAction;
private float actionEndsAt;
private bool hasActiveAction;
private Action completion;
private Action<float> cancellation;
public bool IsActing => hasActiveAction;
public bool CanStartAction => !IsActing;
public bool CanMove => !IsActing || currentAction.AllowsMovement;
public string CurrentActionName => IsActing ? currentAction.ActionName : string.Empty;
public bool CurrentActionCanBeCancelled => IsActing && currentAction.CanBeCancelled;
public float RemainingTime => IsActing ? Mathf.Max(0f, actionEndsAt - Time.time) : 0f;
private void Update()
{
if (!IsActing)
{
return;
}
if (currentAction.CanBeCancelled && Input.GetKeyDown(cancelKey))
{
TryCancelCurrentAction();
return;
}
if (Time.time >= actionEndsAt)
{
CompleteCurrentAction();
}
}
public bool TryBeginAction(PlayerActionDefinition action, Action onCompleted = null, Action<float> onCancelled = null)
{
if (!CanStartAction)
{
return false;
}
currentAction = action;
actionEndsAt = Time.time + action.Duration;
completion = onCompleted;
cancellation = onCancelled;
hasActiveAction = true;
return true;
}
public bool TryCancelCurrentAction()
{
if (!CurrentActionCanBeCancelled)
{
return false;
}
var callback = cancellation;
var progress = currentAction.Duration <= 0f
? 1f
: Mathf.Clamp01(1f - RemainingTime / currentAction.Duration);
ClearCurrentAction();
callback?.Invoke(progress);
return true;
}
private void CompleteCurrentAction()
{
var callback = completion;
ClearCurrentAction();
callback?.Invoke();
}
private void ClearCurrentAction()
{
hasActiveAction = false;
actionEndsAt = 0f;
currentAction = default;
completion = null;
cancellation = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e697b8ae03934a50b1366870e44fdc0e
@@ -1,31 +1,44 @@
using Northbound.Core;
using Northbound.Gameplay.Presentation;
using UnityEngine;
namespace Northbound.Gameplay.Player
{
public sealed class PlayerCombat : MonoBehaviour
{
[SerializeField] private float normalDamage = 26f;
[SerializeField] private float normalRange = 3.2f;
[SerializeField] private float normalCooldown = 0.32f;
[SerializeField] private float specialDamage = 58f;
[SerializeField] private float specialRadius = 4.8f;
[SerializeField] private float specialManaCost = 20f;
[SerializeField] private float specialCooldown = 4f;
[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);
private float normalReadyAt;
private float specialReadyAt;
private PlayerActionController actionController;
public float SpecialCooldownRemaining => Mathf.Max(0f, specialReadyAt - Time.time);
private void Awake()
{
actionController = GetComponent<PlayerActionController>();
if (actionController == null)
{
actionController = gameObject.AddComponent<PlayerActionController>();
}
}
private void Update()
{
if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt)
if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt && actionController is { CanStartAction: true })
{
NormalAttack();
}
if (Input.GetMouseButtonDown(1) && Time.time >= specialReadyAt)
if (Input.GetMouseButtonDown(1) && Time.time >= specialReadyAt && actionController is { CanStartAction: true })
{
SpecialAttack();
}
@@ -33,20 +46,29 @@ namespace Northbound.Gameplay.Player
private void NormalAttack()
{
if (!actionController.TryBeginAction(normalAction))
{
return;
}
normalReadyAt = Time.time + normalCooldown;
var center = transform.position + transform.forward * (normalRange * 0.55f);
DamageTargets(Physics.OverlapSphere(center, normalRange * 0.55f), normalDamage);
var radius = normalRange * 0.55f;
AttackFlash.Spawn(center, radius, new Color(0.15f, 0.85f, 1f, 0.72f), 0.16f);
DamageTargets(Physics.OverlapSphere(center, radius), normalDamage);
}
private void SpecialAttack()
{
var gameState = SharedGameState.Instance;
if (gameState == null || !gameState.TrySpendMana(specialManaCost))
if (actionController == null || !actionController.CanStartAction || gameState == null || !gameState.TrySpendMana(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);
}
@@ -4,31 +4,49 @@ using UnityEngine;
namespace Northbound.Gameplay.Player
{
[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerActionController))]
[DefaultExecutionOrder(-100)]
public sealed class PlayerController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 8f;
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float horizontalLimit = 15f;
[SerializeField] private float interactionRadius = 3.2f;
private CharacterController characterController;
private PlayerActionController actionController;
public Vector3 LastMoveDirection { get; private set; } = Vector3.forward;
public string NearbyPrompt { get; private set; } = string.Empty;
public PlayerActionController Actions => actionController;
private void Awake()
{
characterController = GetComponent<CharacterController>();
actionController = GetComponent<PlayerActionController>();
if (actionController == null)
{
actionController = gameObject.AddComponent<PlayerActionController>();
}
}
private void Update()
{
Move();
FaceMouse();
UpdateInteraction();
}
private void LateUpdate()
{
Move();
}
private void Move()
{
if (actionController != null && !actionController.CanMove)
{
return;
}
var input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
if (input.sqrMagnitude > 1f)
{
@@ -94,9 +112,33 @@ namespace Northbound.Gameplay.Player
}
NearbyPrompt = nearest == null ? string.Empty : $"E: {nearest.GetInteractionPrompt()}";
if (nearest != null && Input.GetKeyDown(KeyCode.E))
if (nearest != null && Input.GetKeyDown(KeyCode.E) && actionController is { CanStartAction: true })
{
nearest.Interact(gameObject);
var action = nearest.GetActionDefinition();
if (nearest.CanStartInteraction(gameObject))
{
var interaction = nearest;
var interactionBehaviour = nearest as MonoBehaviour;
var progressive = nearest as IProgressiveInteraction;
if (progressive != null && !progressive.BeginInteraction(gameObject))
{
return;
}
if (!actionController.TryBeginAction(
action,
() =>
{
if (interactionBehaviour != null)
{
interaction.Interact(gameObject);
}
},
progress => progressive?.CancelInteraction(gameObject, progress)))
{
progressive?.CancelInteraction(gameObject, 0f);
}
}
}
}
}
@@ -0,0 +1,80 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Northbound.Gameplay.Presentation
{
public sealed class AttackFlash : MonoBehaviour
{
private Material flashMaterial;
private Color flashColor;
private float lifetime;
private float diameter;
private float startedAt;
public static void Spawn(Vector3 center, float radius, Color color, float duration)
{
var flash = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
flash.name = "Attack Flash";
flash.transform.position = new Vector3(center.x, 0.12f, center.z);
if (flash.TryGetComponent<Collider>(out var collider))
{
Destroy(collider);
}
var renderer = flash.GetComponent<Renderer>();
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.receiveShadows = false;
var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Unlit/Color") ?? Shader.Find("Standard");
var material = new Material(shader)
{
color = color
};
renderer.sharedMaterial = material;
flash.AddComponent<AttackFlash>().Configure(material, radius, color, duration);
}
private void Configure(Material material, float radius, Color color, float duration)
{
flashMaterial = material;
flashColor = color;
lifetime = Mathf.Max(0.05f, duration);
diameter = Mathf.Max(0.1f, radius * 2f);
startedAt = Time.time;
ApplyVisual(0f);
}
private void Update()
{
var progress = Mathf.Clamp01((Time.time - startedAt) / lifetime);
ApplyVisual(progress);
if (progress >= 1f)
{
Destroy(gameObject);
}
}
private void OnDestroy()
{
if (flashMaterial != null)
{
Destroy(flashMaterial);
}
}
private void ApplyVisual(float progress)
{
var scale = Mathf.Lerp(0.65f, 1.05f, progress);
transform.localScale = new Vector3(diameter * scale, 0.035f, diameter * scale);
if (flashMaterial != null)
{
var color = flashColor;
color.a *= 1f - progress;
flashMaterial.color = color;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 217dbed02e4f4b19884bfd2764d1e2bd
@@ -0,0 +1,84 @@
using UnityEngine;
namespace Northbound.Gameplay.Presentation
{
public sealed class AttackTracer : MonoBehaviour
{
private LineRenderer line;
private Material tracerMaterial;
private Color tracerColor;
private float initialWidth;
private float lifetime;
private float startedAt;
public static void Spawn(Vector3 origin, Vector3 target, Color color, float width, float duration)
{
var tracerObject = new GameObject("Attack Tracer");
var tracer = tracerObject.AddComponent<AttackTracer>();
tracer.Configure(origin, target, color, width, duration);
}
private void Configure(Vector3 origin, Vector3 target, Color color, float width, float duration)
{
tracerColor = color;
initialWidth = Mathf.Max(0.02f, width);
lifetime = Mathf.Max(0.05f, duration);
startedAt = Time.time;
line = gameObject.AddComponent<LineRenderer>();
line.positionCount = 2;
line.useWorldSpace = true;
line.alignment = LineAlignment.View;
line.textureMode = LineTextureMode.Stretch;
line.numCapVertices = 4;
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
line.receiveShadows = false;
var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Unlit/Color") ?? Shader.Find("Standard");
tracerMaterial = new Material(shader)
{
color = Color.white
};
line.sharedMaterial = tracerMaterial;
line.SetPosition(0, origin + Vector3.up * 0.45f);
line.SetPosition(1, target + Vector3.up * 0.45f);
ApplyVisual(0f);
}
private void Update()
{
var progress = Mathf.Clamp01((Time.time - startedAt) / lifetime);
ApplyVisual(progress);
if (progress >= 1f)
{
Destroy(gameObject);
}
}
private void OnDestroy()
{
if (tracerMaterial != null)
{
Destroy(tracerMaterial);
}
}
private void ApplyVisual(float progress)
{
if (line == null)
{
return;
}
var color = tracerColor;
color.a *= 1f - progress;
line.startColor = color;
line.endColor = color;
var width = initialWidth * (1f - progress);
line.startWidth = width;
line.endWidth = width * 0.55f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dd96f758922941958231d1bcf62bfb53
@@ -1,4 +1,5 @@
using System.Linq;
using Northbound.Core;
using Northbound.Gameplay.Player;
using Northbound.Gameplay.World;
using UnityEngine;
@@ -9,6 +10,7 @@ namespace Northbound.Gameplay.Presentation
{
private GUIStyle panelStyle;
private GUIStyle titleStyle;
private GUIStyle healthTextStyle;
private void OnGUI()
{
@@ -35,6 +37,13 @@ namespace Northbound.Gameplay.Presentation
GUILayout.Label(player.NearbyPrompt, titleStyle);
}
if (player?.Actions is { IsActing: true } actions)
{
GUILayout.Space(6f);
var cancelHint = actions.CurrentActionCanBeCancelled ? " ESC Cancel" : string.Empty;
GUILayout.Label($"Action: {actions.CurrentActionName} {actions.RemainingTime:0.0}s{cancelHint}", titleStyle);
}
if (state.IsDefeated)
{
GUILayout.Label("DEFEAT — The southern core has fallen.", titleStyle);
@@ -46,9 +55,56 @@ namespace Northbound.Gameplay.Presentation
GUILayout.EndArea();
DrawHealthBars();
DrawWorldLabels();
}
private void DrawHealthBars()
{
var camera = Camera.main;
if (camera == null)
{
return;
}
foreach (var behaviour in FindObjectsByType<MonoBehaviour>())
{
if (behaviour is not IDamageable health || health.MaximumHealth <= 0f)
{
continue;
}
var renderer = behaviour.GetComponent<Renderer>();
var worldPosition = renderer == null
? behaviour.transform.position + Vector3.up * 2.2f
: new Vector3(renderer.bounds.center.x, renderer.bounds.max.y + 0.4f, renderer.bounds.center.z);
var screen = camera.WorldToScreenPoint(worldPosition);
if (screen.z <= 0f || screen.x < 0f || screen.x > Screen.width || screen.y < 0f || screen.y > Screen.height)
{
continue;
}
const float width = 64f;
const float height = 9f;
var left = screen.x - width * 0.5f;
var top = Screen.height - screen.y;
var background = new Rect(left - 1f, top - 1f, width + 2f, height + 2f);
var fill = new Rect(left, top, width * Mathf.Clamp01(health.CurrentHealth / health.MaximumHealth), height);
var previousColor = GUI.color;
GUI.color = new Color(0.03f, 0.04f, 0.04f, 0.9f);
GUI.DrawTexture(background, Texture2D.whiteTexture);
GUI.color = health.Team == Team.Enemy
? new Color(1f, 0.18f, 0.12f, 0.95f)
: new Color(0.15f, 0.9f, 0.72f, 0.95f);
GUI.DrawTexture(fill, Texture2D.whiteTexture);
GUI.color = previousColor;
var textRectangle = new Rect(left - 12f, top - 2f, width + 24f, height + 4f);
GUI.Label(textRectangle, $"{health.CurrentHealth:0}/{health.MaximumHealth:0}", healthTextStyle);
}
}
private void DrawWorldLabels()
{
var camera = Camera.main;
@@ -97,6 +153,13 @@ namespace Northbound.Gameplay.Presentation
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white }
};
healthTextStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 9,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white }
};
}
}
}
@@ -9,7 +9,8 @@ namespace Northbound.Gameplay.Presentation
[SerializeField] private PlayerController target;
[SerializeField] private float visibleWorldWidth = 38f;
[SerializeField] private float forwardOffset = 6f;
[SerializeField] private float minimumZ = -70f;
[SerializeField] private float forwardOffsetRampDistance = 16f;
[SerializeField] private float minimumZ = -76f;
[SerializeField] private float maximumZ = 70f;
[SerializeField] private float smoothTime = 0.18f;
@@ -35,7 +36,12 @@ namespace Northbound.Gameplay.Presentation
return;
}
var desiredZ = Mathf.Clamp(target.transform.position.z + forwardOffset, minimumZ, maximumZ);
var offsetBlend = Mathf.InverseLerp(
minimumZ,
minimumZ + Mathf.Max(0.01f, forwardOffsetRampDistance),
target.transform.position.z);
var dynamicForwardOffset = forwardOffset * offsetBlend;
var desiredZ = Mathf.Clamp(target.transform.position.z + dynamicForwardOffset, minimumZ, maximumZ);
var desired = new Vector3(0f, transform.position.y, desiredZ);
transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime);
}
@@ -1,20 +1,63 @@
using Northbound.Core;
using Northbound.Gameplay.Buildings;
using Northbound.Gameplay.Enemy;
using Northbound.Gameplay.Presentation;
using UnityEngine;
namespace Northbound.Gameplay.World
{
public sealed class CoreObjective : MonoBehaviour, IDamageable
{
[SerializeField] private float maximumHealth = 1000f;
[SerializeField] private BuildingDefinition buildingDefinition;
[SerializeField] private float defenseRange = 10f;
[SerializeField] private float defenseDamage = 8f;
[SerializeField] private float defenseInterval = 1.25f;
private float defenseReadyAt;
public Team Team => Team.Player;
public bool IsAlive => CurrentHealth > 0f;
public float CurrentHealth { get; private set; }
public float MaximumHealth => maximumHealth;
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
private void Awake()
{
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
}
public void Configure(BuildingDefinition definition)
{
buildingDefinition = definition;
CurrentHealth = MaximumHealth;
}
private void Update()
{
if (!IsAlive || Time.time < defenseReadyAt)
{
return;
}
EnemyAgent nearest = null;
var nearestDistance = defenseRange * defenseRange;
foreach (var enemy in FindObjectsByType<EnemyAgent>())
{
var distance = Vector3.SqrMagnitude(enemy.transform.position - transform.position);
if (enemy.IsAlive && distance < nearestDistance)
{
nearest = enemy;
nearestDistance = distance;
}
}
if (nearest == null)
{
return;
}
defenseReadyAt = Time.time + defenseInterval;
AttackTracer.Spawn(transform.position, nearest.transform.position, new Color(0.1f, 1f, 0.75f, 0.95f), 0.2f, 0.2f);
nearest.TakeDamage(defenseDamage, Team.Player);
}
public void TakeDamage(float amount, Team sourceTeam)
@@ -1,14 +1,22 @@
using Northbound.Core;
using Northbound.Gameplay.Buildings;
using Northbound.Gameplay.Enemy;
using Northbound.Gameplay.Presentation;
using UnityEngine;
namespace Northbound.Gameplay.World
{
public sealed class CrystalOutpost : MonoBehaviour, IDamageable, IInteractable
{
[SerializeField] private float maximumHealth = 300f;
[SerializeField] private BuildingDefinition buildingDefinition;
[SerializeField] private float manaPerSecond = 1.25f;
[SerializeField] private float captureSafetyRadius = 8f;
[SerializeField] private float defenseRange = 7.5f;
[SerializeField] private float defenseDamage = 6f;
[SerializeField] private float defenseInterval = 1.4f;
[SerializeField] private float captureActionDuration = 0.4f;
[SerializeField] private bool allowsMovementWhileCapturing;
[SerializeField] private bool canCancelCapturing = true;
[SerializeField] private Renderer stateRenderer;
[SerializeField] private Material dormantMaterial;
[SerializeField] private Material capturedMaterial;
@@ -18,10 +26,13 @@ namespace Northbound.Gameplay.World
public bool IsCaptured { get; private set; }
public bool CanInteract => !IsCaptured;
public float CurrentHealth { get; private set; }
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
private float defenseReadyAt;
private void Awake()
{
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
RefreshVisual();
}
@@ -35,11 +46,17 @@ namespace Northbound.Gameplay.World
if (IsCaptured && IsAlive)
{
SharedGameState.Instance?.AddMana(manaPerSecond * Time.deltaTime);
DefendOutpost();
}
}
public void Configure(Renderer renderer, Material dormant, Material captured)
public void Configure(
BuildingDefinition definition,
Renderer renderer,
Material dormant,
Material captured)
{
buildingDefinition = definition;
stateRenderer = renderer;
dormantMaterial = dormant;
capturedMaterial = captured;
@@ -51,16 +68,27 @@ namespace Northbound.Gameplay.World
return HasNearbyEnemy() ? "Clear nearby enemies" : "Activate crystal outpost";
}
public void Interact(GameObject actor)
public PlayerActionDefinition GetActionDefinition()
{
return new PlayerActionDefinition("Activate Outpost", captureActionDuration, allowsMovementWhileCapturing, canCancelCapturing);
}
public bool CanStartInteraction(GameObject actor)
{
return !IsCaptured && !HasNearbyEnemy();
}
public bool Interact(GameObject actor)
{
if (IsCaptured || HasNearbyEnemy())
{
return;
return false;
}
IsCaptured = true;
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
RefreshVisual();
return true;
}
public void TakeDamage(float amount, Team sourceTeam)
@@ -91,6 +119,35 @@ namespace Northbound.Gameplay.World
return false;
}
private void DefendOutpost()
{
if (Time.time < defenseReadyAt)
{
return;
}
EnemyAgent nearest = null;
var nearestDistance = defenseRange * defenseRange;
foreach (var enemy in FindObjectsByType<EnemyAgent>())
{
var distance = Vector3.SqrMagnitude(enemy.transform.position - transform.position);
if (enemy.IsAlive && distance < nearestDistance)
{
nearest = enemy;
nearestDistance = distance;
}
}
if (nearest == null)
{
return;
}
defenseReadyAt = Time.time + defenseInterval;
AttackTracer.Spawn(transform.position, nearest.transform.position, new Color(0.75f, 0.3f, 1f, 0.95f), 0.18f, 0.2f);
nearest.TakeDamage(defenseDamage, Team.Player);
}
private void RefreshVisual()
{
if (stateRenderer != null)
+48 -11
View File
@@ -1,4 +1,5 @@
using Northbound.Core;
using Northbound.Gameplay.Buildings;
using Northbound.Gameplay.Player;
using UnityEngine;
@@ -15,8 +16,14 @@ namespace Northbound.Gameplay.World
{
[SerializeField] private int networkIndex;
[SerializeField] private PortalState state = PortalState.Enemy;
[SerializeField] private float maximumHealth = 240f;
[SerializeField] private BuildingDefinition buildingDefinition;
[SerializeField] private float rebuildManaCost = 40f;
[SerializeField] private float rebuildActionDuration = 0.5f;
[SerializeField] private bool allowsMovementWhileRebuilding;
[SerializeField] private bool canCancelRebuilding = true;
[SerializeField] private float travelActionDuration = 0.25f;
[SerializeField] private bool allowsMovementWhileTraveling;
[SerializeField] private bool canCancelTraveling;
[SerializeField] private Renderer stateRenderer;
[SerializeField] private Material enemyMaterial;
[SerializeField] private Material ruinedMaterial;
@@ -28,10 +35,11 @@ namespace Northbound.Gameplay.World
public bool IsAlive => state != PortalState.Ruined;
public bool CanInteract => state != PortalState.Enemy;
public float CurrentHealth { get; private set; }
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
private void Awake()
{
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
RefreshVisual();
}
@@ -43,6 +51,7 @@ namespace Northbound.Gameplay.World
public void Configure(
int index,
PortalState initialState,
BuildingDefinition definition,
Renderer renderer,
Material enemy,
Material ruined,
@@ -50,11 +59,12 @@ namespace Northbound.Gameplay.World
{
networkIndex = index;
state = initialState;
buildingDefinition = definition;
stateRenderer = renderer;
enemyMaterial = enemy;
ruinedMaterial = ruined;
alliedMaterial = allied;
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
RefreshVisual();
}
@@ -91,24 +101,48 @@ namespace Northbound.Gameplay.World
return "Travel through adjacent portal";
}
public void Interact(GameObject actor)
public PlayerActionDefinition GetActionDefinition()
{
return state == PortalState.Ruined
? new PlayerActionDefinition("Rebuild Portal", rebuildActionDuration, allowsMovementWhileRebuilding, canCancelRebuilding)
: new PlayerActionDefinition("Travel Portal", travelActionDuration, allowsMovementWhileTraveling, canCancelTraveling);
}
public bool CanStartInteraction(GameObject actor)
{
var gameState = SharedGameState.Instance;
if (state == PortalState.Ruined)
{
Rebuild();
return;
return gameState != null && gameState.Mana + 0.001f >= rebuildManaCost;
}
if (state != PortalState.Allied || !actor.TryGetComponent<PlayerController>(out var player))
{
return;
return false;
}
var direction = player.LastMoveDirection.z >= 0f ? 1 : -1;
var destination = gameState?.GetPortal(networkIndex + direction);
return destination != null && destination.State == PortalState.Allied;
}
public bool Interact(GameObject actor)
{
if (state == PortalState.Ruined)
{
return Rebuild();
}
if (state != PortalState.Allied || !actor.TryGetComponent<PlayerController>(out var player))
{
return false;
}
var direction = player.LastMoveDirection.z >= 0f ? 1 : -1;
var destination = SharedGameState.Instance?.GetPortal(networkIndex + direction);
if (destination == null || destination.State != PortalState.Allied)
{
return;
return false;
}
var controller = actor.GetComponent<CharacterController>();
@@ -122,19 +156,22 @@ namespace Northbound.Gameplay.World
{
controller.enabled = true;
}
return true;
}
private void Rebuild()
private bool Rebuild()
{
var gameState = SharedGameState.Instance;
if (gameState == null || !gameState.TrySpendMana(rebuildManaCost))
{
return;
return false;
}
state = PortalState.Allied;
CurrentHealth = maximumHealth;
CurrentHealth = MaximumHealth;
RefreshVisual();
return true;
}
private void RefreshVisual()
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 41d8f260b39346e5ac08604a31c47d9c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%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: 85b66c82f35d4c4c9626b843a50e12f1, type: 3}
m_Name: BasicTowerBuilding
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Buildings.BuildingDefinition
buildingId: 2000
displayName: Basic Tower
description:
maximumHealth: 150
completedPrefab: {fileID: 0}
constructionPreviewPrefab: {fileID: 0}
prototypeMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
prototypePrimitive: 2
prototypeScale: {x: 0.7, y: 1.25, z: 0.7}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e49a0dcf744247b8a77b63f797a21c4c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -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: 4ca73b95ed924661b0f122f634acb09f, type: 3}
m_Name: BasicTowerConstruction
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Construction.ConstructionDefinition
building: {fileID: 11400000, guid: e49a0dcf744247b8a77b63f797a21c4c, type: 2}
manaCost: 35
buildDuration: 3
allowsMovementDuringBuild: 0
canCancelBuild: 1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c9fc6f9b4ab54e279a6610db8167a0ab
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -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: b8cf5bfa976c4a68b37f6a2f2b9c10d6, type: 3}
m_Name: BasicTowerDefinition
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.TowerDefinition
building: {fileID: 11400000, guid: e49a0dcf744247b8a77b63f797a21c4c, type: 2}
range: 8
damage: 9
attackInterval: 1.2
attackColor: {r: 1, g: 0.75, b: 0.15, a: 0.95}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f5eb3436646143be94c9d95a9b10cd27
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%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: 85b66c82f35d4c4c9626b843a50e12f1, type: 3}
m_Name: CrystalOutpostBuilding
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Buildings.BuildingDefinition
buildingId: 1100
displayName: Crystal Outpost
description:
maximumHealth: 300
completedPrefab: {fileID: 0}
constructionPreviewPrefab: {fileID: 0}
prototypeMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
prototypePrimitive: 0
prototypeScale: {x: 2, y: 3, z: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b21f75f184fe474b81f01dc1d9b107d7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%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: 85b66c82f35d4c4c9626b843a50e12f1, type: 3}
m_Name: PortalBuilding
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Buildings.BuildingDefinition
buildingId: 1200
displayName: Portal
description:
maximumHealth: 240
completedPrefab: {fileID: 0}
constructionPreviewPrefab: {fileID: 0}
prototypeMaterial: {fileID: 2100000, guid: 08b78577b60196a4c886e390284f06c5, type: 2}
prototypePrimitive: 2
prototypeScale: {x: 3, y: 0.45, z: 3}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d342d3173a3f4ffcaf67ad6f2129b219
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%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: 85b66c82f35d4c4c9626b843a50e12f1, type: 3}
m_Name: SouthernCoreBuilding
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Buildings.BuildingDefinition
buildingId: 1000
displayName: Southern Core
description:
maximumHealth: 1000
completedPrefab: {fileID: 0}
constructionPreviewPrefab: {fileID: 0}
prototypeMaterial: {fileID: 2100000, guid: 460f24417f2a5ca43afb996319e7666e, type: 2}
prototypePrimitive: 3
prototypeScale: {x: 8, y: 4, z: 5}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a1f30d3740434a3f8a9a4b035222f77e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -423,7 +423,8 @@ MonoBehaviour:
target: {fileID: 597054782}
visibleWorldWidth: 38
forwardOffset: 6
minimumZ: -70
forwardOffsetRampDistance: 16
minimumZ: -76
maximumZ: 70
smoothTime: 0.18
--- !u!81 &117007547
@@ -494,7 +495,7 @@ Transform:
m_GameObject: {fileID: 117007545}
serializedVersion: 2
m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
m_LocalPosition: {x: 0, y: 30, z: -70}
m_LocalPosition: {x: 0, y: 30, z: -76}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -743,7 +744,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
networkIndex: 1
state: 0
maximumHealth: 240
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
rebuildManaCost: 40
stateRenderer: {fileID: 437779362}
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
@@ -878,7 +879,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController
moveSpeed: 8
moveSpeed: 5
horizontalLimit: 15
interactionRadius: 3.2
--- !u!114 &597054783
@@ -893,13 +894,23 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat
normalDamage: 26
normalRange: 3.2
normalCooldown: 0.32
specialDamage: 58
specialRadius: 4.8
specialManaCost: 20
specialCooldown: 4
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
--- !u!114 &597054784
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -913,7 +924,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Combat.Health
team: 1
maximum: 180
maximum: 100
destroyOnDeath: 1
--- !u!143 &597054785
CharacterController:
@@ -1180,8 +1191,8 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
outpost: {fileID: 1846946633}
buildCost: 35
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
--- !u!23 &782583536
MeshRenderer:
m_ObjectHideFlags: 0
@@ -1609,7 +1620,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
networkIndex: 2
state: 0
maximumHealth: 240
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
rebuildManaCost: 40
stateRenderer: {fileID: 1174730708}
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
@@ -1742,9 +1753,12 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
maximumHealth: 300
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
manaPerSecond: 1.25
captureSafetyRadius: 8
defenseRange: 7.5
defenseDamage: 6
defenseInterval: 1.4
stateRenderer: {fileID: 1191827590}
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
@@ -1875,7 +1889,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
networkIndex: 3
state: 0
maximumHealth: 240
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
rebuildManaCost: 40
stateRenderer: {fileID: 1272044565}
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
@@ -2121,8 +2135,8 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
outpost: {fileID: 1191827589}
buildCost: 35
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
--- !u!23 &1483313425
MeshRenderer:
m_ObjectHideFlags: 0
@@ -2248,9 +2262,12 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
maximumHealth: 300
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
manaPerSecond: 1.25
captureSafetyRadius: 8
defenseRange: 7.5
defenseDamage: 6
defenseInterval: 1.4
stateRenderer: {fileID: 1846946634}
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
@@ -2379,9 +2396,12 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
maximumHealth: 300
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
manaPerSecond: 1.25
captureSafetyRadius: 8
defenseRange: 7.5
defenseDamage: 6
defenseInterval: 1.4
stateRenderer: {fileID: 1940329550}
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
@@ -2781,8 +2801,8 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
outpost: {fileID: 1940329549}
buildCost: 35
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
--- !u!23 &2061713006
MeshRenderer:
m_ObjectHideFlags: 0
@@ -2910,7 +2930,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
networkIndex: 0
state: 2
maximumHealth: 240
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
rebuildManaCost: 40
stateRenderer: {fileID: 2076976473}
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
@@ -3043,7 +3063,10 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 4486584e692a882489182d3b3f0d7116, type: 3}
m_Name:
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CoreObjective
maximumHealth: 1000
buildingDefinition: {fileID: 11400000, guid: a1f30d3740434a3f8a9a4b035222f77e, type: 2}
defenseRange: 10
defenseDamage: 8
defenseInterval: 1.25
--- !u!23 &2087299787
MeshRenderer:
m_ObjectHideFlags: 0