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
+45
View File
@@ -2,6 +2,34 @@
이 파일은 Northbound 프로젝트에서 작업하는 모든 세션이 공유해야 하는 현재 디자인 기준이다. 새로운 기획이나 구현이 아래 방향과 충돌하면 조용히 가정하지 말고 충돌 지점과 대안을 먼저 설명한다. 이후 사용자가 방향을 변경하면 이 문서도 함께 갱신한다.
## Unity Verification Workflow
- Unity 검증을 위해 프로젝트를 임시 폴더나 복제 프로젝트로 복사하지 않는다.
- 현재 프로젝트가 Unity에서 열려 있어 배치 모드 검증을 실행할 수 없으면 우회 검증을 만들지 않는다.
- 검증에 사용자의 확인이나 Unity 창 조작이 필요하면 사용자에게 직접 요청한다.
## Gameplay Data Architecture
- 비용, 시간, 체력, 공격 수치, 행동 정책처럼 여러 개체가 공유하거나 반복 사용하는 설정은 씬 인스턴스와 행동 코드에 중복 저장하지 않는다.
- 공유 설정은 목적에 맞는 재사용 가능한 정의 타입과 ScriptableObject 자산으로 관리하고, 씬 개체는 해당 정의를 참조한다.
- 정적 정의 데이터와 런타임 상태를 분리한다. 예를 들어 건설 비용·전체 시간·완성 프리팹은 정의에, 현재 진행도·비용 지불 여부·일시 정지 상태는 건설 지점 인스턴스에 둔다.
- 개체별 로직은 공통 정의를 소비하되 정의 자체에 특정 씬 개체나 단일 사용처의 상태를 넣지 않는다.
- 프로토타입 기본 도형 표현은 정의의 선택적 폴백으로만 허용하고, 완성·미리보기 프리팹을 연결할 수 있는 구조를 유지한다.
- 새로운 개체 종류를 추가할 때 기존 코드에 수치 분기를 늘리기보다 새 정의 자산을 추가하는 방향을 우선한다.
- 건물의 기준 정보는 `BuildingDefinition`이 소유한다. 건물 ID, 이름, 설명, 최대 체력, 완성·건설 미리보기 프리팹과 프로토타입 표현은 이 정의에 둔다.
- `TowerDefinition`과 `ConstructionDefinition`은 건물 번호나 이름을 복사하지 않고 대상 `BuildingDefinition` SO를 직접 참조한다.
- 타워 기능과 건설 규칙이 함께 사용되는 지점에서는 두 정의가 동일한 `BuildingDefinition`을 참조하는지 검증한다.
- 건물 ID는 저장·네트워크 식별용 영구 번호이며 배열 순서로 사용하거나 한 번 사용한 번호를 다른 건물에 재사용하지 않는다.
## Gameplay Data Architecture
- 비용, 시간, 체력, 공격 수치, 행동 정책처럼 여러 개체가 공유하거나 반복 사용하는 설정은 씬 인스턴스와 행동 코드에 중복 저장하지 않는다.
- 공유 설정은 목적에 맞는 재사용 가능한 정의 타입과 ScriptableObject 자산으로 관리하고, 씬 개체는 해당 정의를 참조한다.
- 정적 정의 데이터와 런타임 상태를 분리한다. 예를 들어 건설 비용·전체 시간·완성 프리팹은 정의에, 현재 진행도·비용 지불 여부·일시 정지 상태는 건설 지점 인스턴스에 둔다.
- 개체별 로직은 공통 정의를 소비하되 정의 자체에 특정 씬 개체나 단일 사용처의 상태를 넣지 않는다.
- 프로토타입 기본 도형 표현은 정의의 선택적 폴백으로만 허용하고, 완성·미리보기 프리팹을 연결할 수 있는 구조를 유지한다.
- 새로운 개체 종류를 추가할 때 기존 코드에 수치 분기를 늘리기보다 새 정의 자산을 추가하는 방향을 우선한다.
## Core Identity
Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험하고, 크리스탈 거점을 연결해 작전 영역을 확장하며, 정기적으로 남하하는 적에 대응하는 4인 협동 액션 전략 게임이다.
@@ -19,9 +47,12 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
- 플레이어는 남쪽에서 북쪽으로 확장하고 적은 북쪽에서 남쪽으로 침공한다.
- 필드의 가로 영역 전체가 항상 한 화면에 들어오는 것이 핵심이다.
- 카메라는 주로 세로축으로 이동하며, 플레이어를 화면 아래쪽에 두어 전방을 더 많이 보여준다.
- 시작 지점에서는 남쪽 본진 코어 전체가 보이도록 전방 오프셋을 줄이고, 북진하면서 일반 전방 오프셋으로 부드럽게 전환한다.
- 가로 방향의 위협은 숨기기보다 모두 보여주고, 플레이어가 동시에 모든 위치에 대응할 수 없게 만든다.
- 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다.
- 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
## Player and Tower Roles
@@ -40,10 +71,14 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
- 시야, 경보, 둔화 제공
- 비어 있는 지역을 잠시 유지
본진과 확보된 크리스탈 거점은 약한 기본 공격 기능을 가진다. 이 기본 방어는 소수의 약한 적을 처리하거나 적을 지연시키는 최소 안전망이며, 집중 웨이브나 정예 위협을 플레이어 없이 해결할 정도로 강하지 않다. 별도의 방어 슬롯과 거점 역할은 이 기본 기능 위에 추가된다.
핵심 원칙:
> 플레이어가 전투에서 승리하고, 타워는 플레이어의 시간과 관심을 대신한다.
플레이어의 기본 전투 스펙은 거점 지원과 준비가 의미 없게 될 정도로 높이지 않는다. 준비 없이도 위협을 직접 해결할 수는 있지만 체력, 마나, 시간 또는 후방 피해 중 하나를 크게 지불해야 한다.
타워만으로 전체 웨이브를 안정적으로 해결하는 전략은 게임 방향과 맞지 않는다.
## Combat Controls
@@ -54,6 +89,16 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
- 우클릭: 마나 또는 재사용 대기시간을 사용하는 특수 공격
- 이동: 거리 조절, 공격 범위 이탈, 전선 선택
- 별도의 회피, 방어, 패리, 복잡한 콤보 입력은 우선 사용하지 않는다.
- 플레이어, 적, 타워, 본진과 크리스탈 거점의 모든 공격은 공격 주체와 목표 또는 실제 판정 범위를 즉시 알아볼 수 있는 시각 피드백을 제공한다.
- 체력을 가진 모든 개체는 화면에서 현재 체력과 최대 체력을 확인할 수 있는 월드 체력바를 제공한다.
플레이어 이동 속도는 일반 적보다 빠르되, 적을 장시간 안전하게 몰고 다니며 타워나 거점 공격만으로 처리할 수 있을 정도로 높이지 않는다. 초기 프로토타입 기준 이동 속도는 초당 5m로 둔다.
이동을 제외한 플레이어의 모든 행위는 공통 행동 시스템을 사용한다. 각 행동은 이름, 지속시간, `행동 중 이동 가능`, `취소 가능` 여부를 가지며 현재 행동의 설정에 따라 이동과 취소 입력을 허용하거나 차단한다. 한 번에 하나의 행동만 활성화하며 조준 방향 전환은 이동과 별개로 허용한다. 취소 가능한 상호작용의 결과와 비용은 행동 완료 시 적용하여 중도 취소 시 결과가 남지 않게 한다.
건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다.
건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다.
전투의 깊이는 조작 난도보다 위치, 공격 대상의 우선순위, 특수 공격 사용 시점, 후방 방어와 북진 사이의 판단에서 만든다. 적 공격은 이동만으로 피할 수 있도록 충분히 예고하며 공격 속도 강화로 반응 속도를 과도하게 요구하지 않는다.
+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
+13
View File
@@ -34,7 +34,10 @@
- 웨이브 시작 시 활성 적 포탈 수를 기록하는 포탈 공명
- 공명 단계당 체력 +10%, 공격력 +6%, 최대 3단계
- 본진 기본 마나와 활성 크리스탈의 자동 공용 마나 생산
- 본진과 확보된 크리스탈 거점의 약한 기본 공격
- 크리스탈당 제한된 방어 슬롯과 약한 지연 타워
- 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리
- 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조
- 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대
- 첫 웨이브 15초, 이후 90초 간격
@@ -50,6 +53,15 @@
### 2. 전투 감각
- 플레이어의 체력과 기본 화력을 낮춰 거점 지원 없이 싸울 때 비용이 발생하게 한다.
- 플레이어 이동 속도를 초당 5m로 낮춰 안전한 장시간 카이팅을 제한한다.
- 이동을 제외한 모든 플레이어 행위를 공통 행동으로 관리하고, 각 행동의 `행동 중 이동 가능` 설정에 따라 이동을 허용하거나 차단한다.
- 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다.
- 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다.
- 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다.
- 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다.
- 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다.
- 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다.
- 이동만으로 회피 가능한 적 공격 예고를 추가한다.
- 일반 공격의 범위·박자와 특수 공격의 마나·재사용 대기시간을 조정한다.
- 정예와 다수 적 사이에서 공격 대상 및 특수 공격 시점 판단이 생기는지 확인한다.
@@ -66,6 +78,7 @@
### 4. 거점과 방어 시간
- 본진과 확보된 크리스탈 거점은 소수의 약한 적을 상대하는 최소 방어 공격을 가진다.
- 크리스탈 확보 전 주변 적 제거 조건을 조정한다.
- 약탈자가 확보된 크리스탈을 공격하게 한다.
- 타워의 화력은 낮게 두고 지연·경보 기능을 강화한다.