From 3e75357a337ff84223ac998cea7bfcbbef88006e Mon Sep 17 00:00:00 2001 From: dal4segno Date: Tue, 1 Sep 2026 13:04:59 +0900 Subject: [PATCH] feat: deepen prototype combat and building systems --- AGENTS.md | 45 ++++ Assets/Northbound/Core/GameplayContracts.cs | 40 +++- .../Editor/PrototypeSceneBuilder.cs | 108 ++++++++-- Assets/Northbound/Gameplay/Buildings.meta | 8 + .../Gameplay/Buildings/BuildingDefinition.cs | 44 ++++ .../Buildings/BuildingDefinition.cs.meta | 2 + Assets/Northbound/Gameplay/Combat/Health.cs | 2 + Assets/Northbound/Gameplay/Construction.meta | 8 + .../Construction/ConstructionDefinition.cs | 44 ++++ .../ConstructionDefinition.cs.meta | 2 + .../Northbound/Gameplay/Defense/BasicTower.cs | 32 ++- .../Gameplay/Defense/DefenseSlot.cs | 196 ++++++++++++++++-- .../Gameplay/Defense/TowerDefinition.cs | 35 ++++ .../Gameplay/Defense/TowerDefinition.cs.meta | 2 + .../Northbound/Gameplay/Enemy/EnemyAgent.cs | 77 ++++++- .../Gameplay/Player/PlayerActionController.cs | 90 ++++++++ .../Player/PlayerActionController.cs.meta | 2 + .../Gameplay/Player/PlayerCombat.cs | 44 +++- .../Gameplay/Player/PlayerController.cs | 50 ++++- .../Gameplay/Presentation/AttackFlash.cs | 80 +++++++ .../Gameplay/Presentation/AttackFlash.cs.meta | 2 + .../Gameplay/Presentation/AttackTracer.cs | 84 ++++++++ .../Presentation/AttackTracer.cs.meta | 2 + .../Gameplay/Presentation/PrototypeHud.cs | 63 ++++++ .../Gameplay/Presentation/VerticalCamera.cs | 10 +- .../Gameplay/World/CoreObjective.cs | 49 ++++- .../Gameplay/World/CrystalOutpost.cs | 69 +++++- .../Northbound/Gameplay/World/PortalSite.cs | 59 +++++- Assets/Northbound/Generated/Definitions.meta | 8 + .../Definitions/BasicTowerBuilding.asset | 23 ++ .../Definitions/BasicTowerBuilding.asset.meta | 8 + .../Definitions/BasicTowerConstruction.asset | 19 ++ .../BasicTowerConstruction.asset.meta | 8 + .../Definitions/BasicTowerDefinition.asset | 19 ++ .../BasicTowerDefinition.asset.meta | 8 + .../Definitions/CrystalOutpostBuilding.asset | 23 ++ .../CrystalOutpostBuilding.asset.meta | 8 + .../Definitions/PortalBuilding.asset | 23 ++ .../Definitions/PortalBuilding.asset.meta | 8 + .../Definitions/SouthernCoreBuilding.asset | 23 ++ .../SouthernCoreBuilding.asset.meta | 8 + .../Scenes/NorthboundGameplayPrototype.unity | 73 ++++--- PROTOTYPE_ROADMAP.md | 13 ++ 43 files changed, 1413 insertions(+), 108 deletions(-) create mode 100644 Assets/Northbound/Gameplay/Buildings.meta create mode 100644 Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs create mode 100644 Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs.meta create mode 100644 Assets/Northbound/Gameplay/Construction.meta create mode 100644 Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs create mode 100644 Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs.meta create mode 100644 Assets/Northbound/Gameplay/Defense/TowerDefinition.cs create mode 100644 Assets/Northbound/Gameplay/Defense/TowerDefinition.cs.meta create mode 100644 Assets/Northbound/Gameplay/Player/PlayerActionController.cs create mode 100644 Assets/Northbound/Gameplay/Player/PlayerActionController.cs.meta create mode 100644 Assets/Northbound/Gameplay/Presentation/AttackFlash.cs create mode 100644 Assets/Northbound/Gameplay/Presentation/AttackFlash.cs.meta create mode 100644 Assets/Northbound/Gameplay/Presentation/AttackTracer.cs create mode 100644 Assets/Northbound/Gameplay/Presentation/AttackTracer.cs.meta create mode 100644 Assets/Northbound/Generated/Definitions.meta create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset create mode 100644 Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset create mode 100644 Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/PortalBuilding.asset create mode 100644 Assets/Northbound/Generated/Definitions/PortalBuilding.asset.meta create mode 100644 Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset create mode 100644 Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset.meta diff --git a/AGENTS.md b/AGENTS.md index 2731489..38a35eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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로 둔다. + +이동을 제외한 플레이어의 모든 행위는 공통 행동 시스템을 사용한다. 각 행동은 이름, 지속시간, `행동 중 이동 가능`, `취소 가능` 여부를 가지며 현재 행동의 설정에 따라 이동과 취소 입력을 허용하거나 차단한다. 한 번에 하나의 행동만 활성화하며 조준 방향 전환은 이동과 별개로 허용한다. 취소 가능한 상호작용의 결과와 비용은 행동 완료 시 적용하여 중도 취소 시 결과가 남지 않게 한다. + +건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다. + +건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다. 전투의 깊이는 조작 난도보다 위치, 공격 대상의 우선순위, 특수 공격 사용 시점, 후방 방어와 북진 사이의 판단에서 만든다. 적 공격은 이동만으로 피할 수 있도록 충분히 예고하며 공격 속도 강화로 반응 속도를 과도하게 요구하지 않는다. diff --git a/Assets/Northbound/Core/GameplayContracts.cs b/Assets/Northbound/Core/GameplayContracts.cs index a255ce8..e2041a8 100644 --- a/Assets/Northbound/Core/GameplayContracts.cs +++ b/Assets/Northbound/Core/GameplayContracts.cs @@ -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 diff --git a/Assets/Northbound/Editor/PrototypeSceneBuilder.cs b/Assets/Northbound/Editor/PrototypeSceneBuilder.cs index 5278d77..b85b7fd 100644 --- a/Assets/Northbound/Editor/PrototypeSceneBuilder.cs +++ b/Assets/Northbound/Editor/PrototypeSceneBuilder.cs @@ -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()); gameObject.AddComponent(); - gameObject.AddComponent().Configure(Team.Player, 180f); + gameObject.AddComponent().Configure(Team.Player, 100f); + gameObject.AddComponent(); var controller = gameObject.AddComponent(); gameObject.AddComponent(); 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.clearFlags = CameraClearFlags.SolidColor; @@ -123,33 +140,40 @@ namespace Northbound.Editor cameraObject.AddComponent().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(); + gameObject.AddComponent().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(); - gameObject.AddComponent().Configure(index, initialState, renderer, enemy, ruined, allied); + gameObject.AddComponent().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(); - outpost.Configure(gameObject.GetComponent(), dormant, active); + outpost.Configure(outpostDefinition, gameObject.GetComponent(), 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().Configure(outpost, tower); + slot.AddComponent().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(path); + if (definition != null) + { + return definition; + } + + definition = ScriptableObject.CreateInstance(); + 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(BasicTowerDefinitionPath); + if (definition != null) + { + return definition; + } + + definition = ScriptableObject.CreateInstance(); + 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(BasicTowerConstructionPath); + if (definition != null) + { + return definition; + } + + definition = ScriptableObject.CreateInstance(); + 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); diff --git a/Assets/Northbound/Gameplay/Buildings.meta b/Assets/Northbound/Gameplay/Buildings.meta new file mode 100644 index 0000000..2ab5e66 --- /dev/null +++ b/Assets/Northbound/Gameplay/Buildings.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b02cc11687084bacaae00cc7963c9d14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs b/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs new file mode 100644 index 0000000..cfdddcf --- /dev/null +++ b/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs @@ -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; + } + } +} diff --git a/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs.meta b/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs.meta new file mode 100644 index 0000000..43fcb6e --- /dev/null +++ b/Assets/Northbound/Gameplay/Buildings/BuildingDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 85b66c82f35d4c4c9626b843a50e12f1 diff --git a/Assets/Northbound/Gameplay/Combat/Health.cs b/Assets/Northbound/Gameplay/Combat/Health.cs index a006bbb..0004c32 100644 --- a/Assets/Northbound/Gameplay/Combat/Health.cs +++ b/Assets/Northbound/Gameplay/Combat/Health.cs @@ -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() { diff --git a/Assets/Northbound/Gameplay/Construction.meta b/Assets/Northbound/Gameplay/Construction.meta new file mode 100644 index 0000000..4cb9557 --- /dev/null +++ b/Assets/Northbound/Gameplay/Construction.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76cb6d4e020445f28c20adf669b4f56f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs b/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs new file mode 100644 index 0000000..a1654fe --- /dev/null +++ b/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs @@ -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; + } + } +} diff --git a/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs.meta b/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs.meta new file mode 100644 index 0000000..08b8508 --- /dev/null +++ b/Assets/Northbound/Gameplay/Construction/ConstructionDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4ca73b95ed924661b0f122f634acb09f diff --git a/Assets/Northbound/Gameplay/Defense/BasicTower.cs b/Assets/Northbound/Gameplay/Defense/BasicTower.cs index f377c1b..a8ea7d7 100644 --- a/Assets/Northbound/Gameplay/Defense/BasicTower.cs +++ b/Assets/Northbound/Gameplay/Defense/BasicTower.cs @@ -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()) { 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(); + if (health == null) + { + health = gameObject.AddComponent(); + } + + health.Configure(Team.Player, definition.Building.MaximumHealth); } } } diff --git a/Assets/Northbound/Gameplay/Defense/DefenseSlot.cs b/Assets/Northbound/Gameplay/Defense/DefenseSlot.cs index b9134ce..7b4cbba 100644 --- a/Assets/Northbound/Gameplay/Defense/DefenseSlot.cs +++ b/Assets/Northbound/Gameplay/Defense/DefenseSlot.cs @@ -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() == 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() == 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().sharedMaterial = towerMaterial; - towerObject.AddComponent(); + 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(); + if (tower == null) + { + tower = towerObject.AddComponent(); + } + + 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(out var renderer)) + { + renderer.sharedMaterial = building.PrototypeMaterial; + } + + if (constructionPreview.TryGetComponent(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(out var renderer)) + { + renderer.sharedMaterial = building.PrototypeMaterial; + } + + return prototype; } } } diff --git a/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs new file mode 100644 index 0000000..0230895 --- /dev/null +++ b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs @@ -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; + } + } +} diff --git a/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs.meta b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs.meta new file mode 100644 index 0000000..c61afdc --- /dev/null +++ b/Assets/Northbound/Gameplay/Defense/TowerDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b8cf5bfa976c4a68b37f6a2f2b9c10d6 diff --git a/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs b/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs index 46c92b7..bca204b 100644 --- a/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs +++ b/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs @@ -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()) + { + if (collider is CharacterController) + { + continue; + } + + collider.enabled = false; + Destroy(collider); + } + + movementController = GetComponent(); + if (movementController == null) + { + movementController = gameObject.AddComponent(); + } + + 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(); + if (targetCollider == null) + { + targetCollider = target.GetComponentInChildren(); + } + + 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(); diff --git a/Assets/Northbound/Gameplay/Player/PlayerActionController.cs b/Assets/Northbound/Gameplay/Player/PlayerActionController.cs new file mode 100644 index 0000000..3fd1062 --- /dev/null +++ b/Assets/Northbound/Gameplay/Player/PlayerActionController.cs @@ -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 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 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; + } + } +} diff --git a/Assets/Northbound/Gameplay/Player/PlayerActionController.cs.meta b/Assets/Northbound/Gameplay/Player/PlayerActionController.cs.meta new file mode 100644 index 0000000..bcd4392 --- /dev/null +++ b/Assets/Northbound/Gameplay/Player/PlayerActionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e697b8ae03934a50b1366870e44fdc0e diff --git a/Assets/Northbound/Gameplay/Player/PlayerCombat.cs b/Assets/Northbound/Gameplay/Player/PlayerCombat.cs index be596e3..7dd8232 100644 --- a/Assets/Northbound/Gameplay/Player/PlayerCombat.cs +++ b/Assets/Northbound/Gameplay/Player/PlayerCombat.cs @@ -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(); + if (actionController == null) + { + actionController = gameObject.AddComponent(); + } + } + 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); } diff --git a/Assets/Northbound/Gameplay/Player/PlayerController.cs b/Assets/Northbound/Gameplay/Player/PlayerController.cs index 03132d9..3b0adaf 100644 --- a/Assets/Northbound/Gameplay/Player/PlayerController.cs +++ b/Assets/Northbound/Gameplay/Player/PlayerController.cs @@ -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(); + actionController = GetComponent(); + if (actionController == null) + { + actionController = gameObject.AddComponent(); + } } 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); + } + } } } } diff --git a/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs b/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs new file mode 100644 index 0000000..d69e63b --- /dev/null +++ b/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs @@ -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(out var collider)) + { + Destroy(collider); + } + + var renderer = flash.GetComponent(); + 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().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; + } + } + } +} diff --git a/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs.meta b/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs.meta new file mode 100644 index 0000000..b5caa9c --- /dev/null +++ b/Assets/Northbound/Gameplay/Presentation/AttackFlash.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 217dbed02e4f4b19884bfd2764d1e2bd diff --git a/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs b/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs new file mode 100644 index 0000000..e0ac6a0 --- /dev/null +++ b/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs @@ -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(); + 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(); + 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; + } + } +} diff --git a/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs.meta b/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs.meta new file mode 100644 index 0000000..58d081a --- /dev/null +++ b/Assets/Northbound/Gameplay/Presentation/AttackTracer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dd96f758922941958231d1bcf62bfb53 diff --git a/Assets/Northbound/Gameplay/Presentation/PrototypeHud.cs b/Assets/Northbound/Gameplay/Presentation/PrototypeHud.cs index a31b520..40adbc5 100644 --- a/Assets/Northbound/Gameplay/Presentation/PrototypeHud.cs +++ b/Assets/Northbound/Gameplay/Presentation/PrototypeHud.cs @@ -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()) + { + if (behaviour is not IDamageable health || health.MaximumHealth <= 0f) + { + continue; + } + + var renderer = behaviour.GetComponent(); + 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 } + }; } } } diff --git a/Assets/Northbound/Gameplay/Presentation/VerticalCamera.cs b/Assets/Northbound/Gameplay/Presentation/VerticalCamera.cs index 4069def..152f13a 100644 --- a/Assets/Northbound/Gameplay/Presentation/VerticalCamera.cs +++ b/Assets/Northbound/Gameplay/Presentation/VerticalCamera.cs @@ -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); } diff --git a/Assets/Northbound/Gameplay/World/CoreObjective.cs b/Assets/Northbound/Gameplay/World/CoreObjective.cs index 4f99a5f..bbb5b7e 100644 --- a/Assets/Northbound/Gameplay/World/CoreObjective.cs +++ b/Assets/Northbound/Gameplay/World/CoreObjective.cs @@ -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()) + { + 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) diff --git a/Assets/Northbound/Gameplay/World/CrystalOutpost.cs b/Assets/Northbound/Gameplay/World/CrystalOutpost.cs index 368e5fd..1b8be67 100644 --- a/Assets/Northbound/Gameplay/World/CrystalOutpost.cs +++ b/Assets/Northbound/Gameplay/World/CrystalOutpost.cs @@ -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()) + { + 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) diff --git a/Assets/Northbound/Gameplay/World/PortalSite.cs b/Assets/Northbound/Gameplay/World/PortalSite.cs index 74b2528..0c7d5ed 100644 --- a/Assets/Northbound/Gameplay/World/PortalSite.cs +++ b/Assets/Northbound/Gameplay/World/PortalSite.cs @@ -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(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(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(); @@ -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() diff --git a/Assets/Northbound/Generated/Definitions.meta b/Assets/Northbound/Generated/Definitions.meta new file mode 100644 index 0000000..85cca7a --- /dev/null +++ b/Assets/Northbound/Generated/Definitions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 41d8f260b39346e5ac08604a31c47d9c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset b/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset new file mode 100644 index 0000000..3745d4c --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset @@ -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} diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset.meta b/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset.meta new file mode 100644 index 0000000..7a1d84e --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerBuilding.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e49a0dcf744247b8a77b63f797a21c4c +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset b/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset new file mode 100644 index 0000000..d3cb1f1 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset @@ -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 diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset.meta b/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset.meta new file mode 100644 index 0000000..781a543 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerConstruction.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9fc6f9b4ab54e279a6610db8167a0ab +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset new file mode 100644 index 0000000..5c10267 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset @@ -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} diff --git a/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset.meta b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset.meta new file mode 100644 index 0000000..ceb8ca4 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/BasicTowerDefinition.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f5eb3436646143be94c9d95a9b10cd27 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset b/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset new file mode 100644 index 0000000..a724eaa --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset @@ -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} diff --git a/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset.meta b/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset.meta new file mode 100644 index 0000000..5bc32bf --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/CrystalOutpostBuilding.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b21f75f184fe474b81f01dc1d9b107d7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/PortalBuilding.asset b/Assets/Northbound/Generated/Definitions/PortalBuilding.asset new file mode 100644 index 0000000..5523eec --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/PortalBuilding.asset @@ -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} diff --git a/Assets/Northbound/Generated/Definitions/PortalBuilding.asset.meta b/Assets/Northbound/Generated/Definitions/PortalBuilding.asset.meta new file mode 100644 index 0000000..05286b8 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/PortalBuilding.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d342d3173a3f4ffcaf67ad6f2129b219 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset b/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset new file mode 100644 index 0000000..2b4f187 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset @@ -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} diff --git a/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset.meta b/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset.meta new file mode 100644 index 0000000..76003e4 --- /dev/null +++ b/Assets/Northbound/Generated/Definitions/SouthernCoreBuilding.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a1f30d3740434a3f8a9a4b035222f77e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity b/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity index 076720c..7835d67 100644 --- a/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity +++ b/Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity @@ -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 diff --git a/PROTOTYPE_ROADMAP.md b/PROTOTYPE_ROADMAP.md index 55f838c..0a8bda0 100644 --- a/PROTOTYPE_ROADMAP.md +++ b/PROTOTYPE_ROADMAP.md @@ -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. 거점과 방어 시간 +- 본진과 확보된 크리스탈 거점은 소수의 약한 적을 상대하는 최소 방어 공격을 가진다. - 크리스탈 확보 전 주변 적 제거 조건을 조정한다. - 약탈자가 확보된 크리스탈을 공격하게 한다. - 타워의 화력은 낮게 두고 지연·경보 기능을 강화한다.