feat: deepen prototype combat and building systems
This commit is contained in:
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
이 파일은 Northbound 프로젝트에서 작업하는 모든 세션이 공유해야 하는 현재 디자인 기준이다. 새로운 기획이나 구현이 아래 방향과 충돌하면 조용히 가정하지 말고 충돌 지점과 대안을 먼저 설명한다. 이후 사용자가 방향을 변경하면 이 문서도 함께 갱신한다.
|
이 파일은 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
|
## Core Identity
|
||||||
|
|
||||||
Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험하고, 크리스탈 거점을 연결해 작전 영역을 확장하며, 정기적으로 남하하는 적에 대응하는 4인 협동 액션 전략 게임이다.
|
Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험하고, 크리스탈 거점을 연결해 작전 영역을 확장하며, 정기적으로 남하하는 적에 대응하는 4인 협동 액션 전략 게임이다.
|
||||||
@@ -19,9 +47,12 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
|
|||||||
- 플레이어는 남쪽에서 북쪽으로 확장하고 적은 북쪽에서 남쪽으로 침공한다.
|
- 플레이어는 남쪽에서 북쪽으로 확장하고 적은 북쪽에서 남쪽으로 침공한다.
|
||||||
- 필드의 가로 영역 전체가 항상 한 화면에 들어오는 것이 핵심이다.
|
- 필드의 가로 영역 전체가 항상 한 화면에 들어오는 것이 핵심이다.
|
||||||
- 카메라는 주로 세로축으로 이동하며, 플레이어를 화면 아래쪽에 두어 전방을 더 많이 보여준다.
|
- 카메라는 주로 세로축으로 이동하며, 플레이어를 화면 아래쪽에 두어 전방을 더 많이 보여준다.
|
||||||
|
- 시작 지점에서는 남쪽 본진 코어 전체가 보이도록 전방 오프셋을 줄이고, 북진하면서 일반 전방 오프셋으로 부드럽게 전환한다.
|
||||||
- 가로 방향의 위협은 숨기기보다 모두 보여주고, 플레이어가 동시에 모든 위치에 대응할 수 없게 만든다.
|
- 가로 방향의 위협은 숨기기보다 모두 보여주고, 플레이어가 동시에 모든 위치에 대응할 수 없게 만든다.
|
||||||
- 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다.
|
- 긴 맵은 빈 이동 공간이 아니라 본진, 첫 확장, 분기, 위험 자원, 포탈 전초, 포탈, 최종 코어 같은 의미 있는 세로 구간으로 나눈다.
|
||||||
- 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다.
|
- 일반적인 16:9 활용을 이유로 가로 진행형 맵으로 변경하지 않는다.
|
||||||
|
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
|
||||||
|
- 적은 본진, 크리스탈 거점, 포탈, 타워 같은 건물을 관통하지 않는다. 건물 공격은 중심점이 아니라 충돌 표면을 기준으로 사거리와 정지 위치를 계산한다.
|
||||||
|
|
||||||
## Player and Tower Roles
|
## Player and Tower Roles
|
||||||
|
|
||||||
@@ -40,10 +71,14 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
|
|||||||
- 시야, 경보, 둔화 제공
|
- 시야, 경보, 둔화 제공
|
||||||
- 비어 있는 지역을 잠시 유지
|
- 비어 있는 지역을 잠시 유지
|
||||||
|
|
||||||
|
본진과 확보된 크리스탈 거점은 약한 기본 공격 기능을 가진다. 이 기본 방어는 소수의 약한 적을 처리하거나 적을 지연시키는 최소 안전망이며, 집중 웨이브나 정예 위협을 플레이어 없이 해결할 정도로 강하지 않다. 별도의 방어 슬롯과 거점 역할은 이 기본 기능 위에 추가된다.
|
||||||
|
|
||||||
핵심 원칙:
|
핵심 원칙:
|
||||||
|
|
||||||
> 플레이어가 전투에서 승리하고, 타워는 플레이어의 시간과 관심을 대신한다.
|
> 플레이어가 전투에서 승리하고, 타워는 플레이어의 시간과 관심을 대신한다.
|
||||||
|
|
||||||
|
플레이어의 기본 전투 스펙은 거점 지원과 준비가 의미 없게 될 정도로 높이지 않는다. 준비 없이도 위협을 직접 해결할 수는 있지만 체력, 마나, 시간 또는 후방 피해 중 하나를 크게 지불해야 한다.
|
||||||
|
|
||||||
타워만으로 전체 웨이브를 안정적으로 해결하는 전략은 게임 방향과 맞지 않는다.
|
타워만으로 전체 웨이브를 안정적으로 해결하는 전략은 게임 방향과 맞지 않는다.
|
||||||
|
|
||||||
## Combat Controls
|
## Combat Controls
|
||||||
@@ -54,6 +89,16 @@ Northbound는 플레이어들이 주력 전투원이 되어 북쪽으로 탐험
|
|||||||
- 우클릭: 마나 또는 재사용 대기시간을 사용하는 특수 공격
|
- 우클릭: 마나 또는 재사용 대기시간을 사용하는 특수 공격
|
||||||
- 이동: 거리 조절, 공격 범위 이탈, 전선 선택
|
- 이동: 거리 조절, 공격 범위 이탈, 전선 선택
|
||||||
- 별도의 회피, 방어, 패리, 복잡한 콤보 입력은 우선 사용하지 않는다.
|
- 별도의 회피, 방어, 패리, 복잡한 콤보 입력은 우선 사용하지 않는다.
|
||||||
|
- 플레이어, 적, 타워, 본진과 크리스탈 거점의 모든 공격은 공격 주체와 목표 또는 실제 판정 범위를 즉시 알아볼 수 있는 시각 피드백을 제공한다.
|
||||||
|
- 체력을 가진 모든 개체는 화면에서 현재 체력과 최대 체력을 확인할 수 있는 월드 체력바를 제공한다.
|
||||||
|
|
||||||
|
플레이어 이동 속도는 일반 적보다 빠르되, 적을 장시간 안전하게 몰고 다니며 타워나 거점 공격만으로 처리할 수 있을 정도로 높이지 않는다. 초기 프로토타입 기준 이동 속도는 초당 5m로 둔다.
|
||||||
|
|
||||||
|
이동을 제외한 플레이어의 모든 행위는 공통 행동 시스템을 사용한다. 각 행동은 이름, 지속시간, `행동 중 이동 가능`, `취소 가능` 여부를 가지며 현재 행동의 설정에 따라 이동과 취소 입력을 허용하거나 차단한다. 한 번에 하나의 행동만 활성화하며 조준 방향 전환은 이동과 별개로 허용한다. 취소 가능한 상호작용의 결과와 비용은 행동 완료 시 적용하여 중도 취소 시 결과가 남지 않게 한다.
|
||||||
|
|
||||||
|
건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다.
|
||||||
|
|
||||||
|
건설은 예외적으로 지속되는 월드 과정이다. 최초 착공 시 비용을 지불하고, 플레이어가 건설 행동을 취소하면 부분 건설물과 진행도가 현장에 남는다. 다시 상호작용하면 남은 진행도부터 이어서 건설하며 비용을 다시 지불하지 않는다.
|
||||||
|
|
||||||
전투의 깊이는 조작 난도보다 위치, 공격 대상의 우선순위, 특수 공격 사용 시점, 후방 방어와 북진 사이의 판단에서 만든다. 적 공격은 이동만으로 피할 수 있도록 충분히 예고하며 공격 속도 강화로 반응 속도를 과도하게 요구하지 않는다.
|
전투의 깊이는 조작 난도보다 위치, 공격 대상의 우선순위, 특수 공격 사용 시점, 후방 방어와 북진 사이의 판단에서 만든다. 적 공격은 이동만으로 피할 수 있도록 충분히 예고하며 공격 속도 강화로 반응 속도를 과도하게 요구하지 않는다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
namespace Northbound.Core
|
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
|
public enum Team
|
||||||
{
|
{
|
||||||
Neutral,
|
Neutral,
|
||||||
@@ -7,7 +29,13 @@ namespace Northbound.Core
|
|||||||
Enemy
|
Enemy
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IDamageable
|
public interface IHealthReadable
|
||||||
|
{
|
||||||
|
float CurrentHealth { get; }
|
||||||
|
float MaximumHealth { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IDamageable : IHealthReadable
|
||||||
{
|
{
|
||||||
Team Team { get; }
|
Team Team { get; }
|
||||||
bool IsAlive { get; }
|
bool IsAlive { get; }
|
||||||
@@ -17,8 +45,16 @@ namespace Northbound.Core
|
|||||||
public interface IInteractable
|
public interface IInteractable
|
||||||
{
|
{
|
||||||
string GetInteractionPrompt();
|
string GetInteractionPrompt();
|
||||||
|
PlayerActionDefinition GetActionDefinition();
|
||||||
bool CanInteract { get; }
|
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
|
public static class TeamRules
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
using Northbound.Gameplay;
|
using Northbound.Gameplay;
|
||||||
|
using Northbound.Gameplay.Buildings;
|
||||||
using Northbound.Gameplay.Combat;
|
using Northbound.Gameplay.Combat;
|
||||||
|
using Northbound.Gameplay.Construction;
|
||||||
using Northbound.Gameplay.Defense;
|
using Northbound.Gameplay.Defense;
|
||||||
using Northbound.Gameplay.Enemy;
|
using Northbound.Gameplay.Enemy;
|
||||||
using Northbound.Gameplay.Player;
|
using Northbound.Gameplay.Player;
|
||||||
@@ -18,11 +20,19 @@ namespace Northbound.Editor
|
|||||||
{
|
{
|
||||||
public const string ScenePath = "Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity";
|
public const string ScenePath = "Assets/Northbound/Scenes/NorthboundGameplayPrototype.unity";
|
||||||
private const string MaterialDirectory = "Assets/Northbound/Generated/Materials";
|
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")]
|
[MenuItem("Northbound/Build Gameplay Prototype")]
|
||||||
public static void BuildScene()
|
public static void BuildScene()
|
||||||
{
|
{
|
||||||
EnsureDirectory(MaterialDirectory);
|
EnsureDirectory(MaterialDirectory);
|
||||||
|
EnsureDirectory(DefinitionDirectory);
|
||||||
|
|
||||||
var ground = GetMaterial("Ground", new Color(0.11f, 0.15f, 0.14f));
|
var ground = GetMaterial("Ground", new Color(0.11f, 0.15f, 0.14f));
|
||||||
var lane = GetMaterial("Lane", new Color(0.18f, 0.24f, 0.22f));
|
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 grunt = GetMaterial("EnemyGrunt", new Color(0.85f, 0.32f, 0.25f));
|
||||||
var raider = GetMaterial("EnemyRaider", new Color(1f, 0.55f, 0.12f));
|
var raider = GetMaterial("EnemyRaider", new Color(1f, 0.55f, 0.12f));
|
||||||
var elite = GetMaterial("EnemyElite", new Color(0.75f, 0.12f, 0.75f));
|
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);
|
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
||||||
CreateEnvironment(ground, lane);
|
CreateEnvironment(ground, lane);
|
||||||
@@ -49,13 +65,13 @@ namespace Northbound.Editor
|
|||||||
|
|
||||||
var playerController = CreatePlayer(player);
|
var playerController = CreatePlayer(player);
|
||||||
CreateCamera(playerController);
|
CreateCamera(playerController);
|
||||||
CreateCore(core);
|
CreateCore(core, coreBuilding);
|
||||||
|
|
||||||
var portalPositions = new[] { -68f, -25f, 20f, 68f };
|
var portalPositions = new[] { -68f, -25f, 20f, 68f };
|
||||||
for (var index = 0; index < portalPositions.Length; index++)
|
for (var index = 0; index < portalPositions.Length; index++)
|
||||||
{
|
{
|
||||||
var initialState = index == 0 ? PortalState.Allied : PortalState.Enemy;
|
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)
|
if (initialState == PortalState.Enemy)
|
||||||
{
|
{
|
||||||
CreateGuardian(new Vector3(-3f, 1f, portalPositions[index] - 3f), grunt);
|
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, -45f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
|
||||||
CreateCrystal(new Vector3(8f, 1f, -3f), crystalDormant, crystalActive, tower);
|
CreateCrystal(new Vector3(8f, 1f, -3f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
|
||||||
CreateCrystal(new Vector3(-7f, 1f, 42f), crystalDormant, crystalActive, tower);
|
CreateCrystal(new Vector3(-7f, 1f, 42f), outpostBuilding, crystalDormant, crystalActive, basicTowerConstruction, basicTowerDefinition);
|
||||||
|
|
||||||
EditorSceneManager.SaveScene(scene, ScenePath);
|
EditorSceneManager.SaveScene(scene, ScenePath);
|
||||||
EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(ScenePath, true) };
|
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);
|
var gameObject = CreatePrimitive("Player", PrimitiveType.Capsule, new Vector3(0f, 1f, -76f), Vector3.one, material);
|
||||||
Object.DestroyImmediate(gameObject.GetComponent<CapsuleCollider>());
|
Object.DestroyImmediate(gameObject.GetComponent<CapsuleCollider>());
|
||||||
gameObject.AddComponent<CharacterController>();
|
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>();
|
var controller = gameObject.AddComponent<PlayerController>();
|
||||||
gameObject.AddComponent<PlayerCombat>();
|
gameObject.AddComponent<PlayerCombat>();
|
||||||
return controller;
|
return controller;
|
||||||
@@ -112,7 +129,7 @@ namespace Northbound.Editor
|
|||||||
{
|
{
|
||||||
var cameraObject = new GameObject("Main Camera");
|
var cameraObject = new GameObject("Main Camera");
|
||||||
cameraObject.tag = "MainCamera";
|
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);
|
cameraObject.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
|
||||||
var camera = cameraObject.AddComponent<Camera>();
|
var camera = cameraObject.AddComponent<Camera>();
|
||||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||||
@@ -123,33 +140,40 @@ namespace Northbound.Editor
|
|||||||
cameraObject.AddComponent<VerticalCamera>().Configure(player);
|
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);
|
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(
|
private static void CreatePortal(
|
||||||
int index,
|
int index,
|
||||||
float z,
|
float z,
|
||||||
PortalState initialState,
|
PortalState initialState,
|
||||||
|
BuildingDefinition definition,
|
||||||
Material enemy,
|
Material enemy,
|
||||||
Material ruined,
|
Material ruined,
|
||||||
Material allied)
|
Material allied)
|
||||||
{
|
{
|
||||||
var gameObject = CreatePrimitive($"Portal {index}", PrimitiveType.Cylinder, new Vector3(0f, 0.45f, z), new Vector3(3f, 0.45f, 3f), ruined);
|
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>();
|
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 gameObject = CreatePrimitive("Crystal Outpost", PrimitiveType.Sphere, position, new Vector3(2f, 3f, 2f), dormant);
|
||||||
var outpost = gameObject.AddComponent<CrystalOutpost>();
|
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);
|
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)
|
private static void CreateGuardian(Vector3 position, Material material)
|
||||||
@@ -184,6 +208,64 @@ namespace Northbound.Editor
|
|||||||
return material;
|
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)
|
private static void EnsureDirectory(string assetPath)
|
||||||
{
|
{
|
||||||
var absolutePath = Path.GetFullPath(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 bool IsAlive => Current > 0f;
|
||||||
public float Current { get; private set; }
|
public float Current { get; private set; }
|
||||||
public float Maximum => maximum;
|
public float Maximum => maximum;
|
||||||
|
public float CurrentHealth => Current;
|
||||||
|
public float MaximumHealth => maximum;
|
||||||
|
|
||||||
private void Awake()
|
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.Core;
|
||||||
|
using Northbound.Gameplay.Combat;
|
||||||
using Northbound.Gameplay.Enemy;
|
using Northbound.Gameplay.Enemy;
|
||||||
|
using Northbound.Gameplay.Presentation;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Northbound.Gameplay.Defense
|
namespace Northbound.Gameplay.Defense
|
||||||
{
|
{
|
||||||
public sealed class BasicTower : MonoBehaviour
|
public sealed class BasicTower : MonoBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float range = 8f;
|
[SerializeField] private TowerDefinition definition;
|
||||||
[SerializeField] private float damage = 9f;
|
|
||||||
[SerializeField] private float attackInterval = 1.2f;
|
|
||||||
|
|
||||||
private float readyAt;
|
private float readyAt;
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (Time.time < readyAt)
|
if (definition == null || Time.time < readyAt)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnemyAgent nearest = null;
|
EnemyAgent nearest = null;
|
||||||
var nearestDistance = range * range;
|
var nearestDistance = definition.Range * definition.Range;
|
||||||
foreach (var enemy in FindObjectsByType<EnemyAgent>())
|
foreach (var enemy in FindObjectsByType<EnemyAgent>())
|
||||||
{
|
{
|
||||||
var distance = Vector3.SqrMagnitude(enemy.transform.position - transform.position);
|
var distance = Vector3.SqrMagnitude(enemy.transform.position - transform.position);
|
||||||
@@ -36,8 +36,26 @@ namespace Northbound.Gameplay.Defense
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
readyAt = Time.time + attackInterval;
|
readyAt = Time.time + definition.AttackInterval;
|
||||||
nearest.TakeDamage(damage, Team.Player);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,197 @@
|
|||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
|
using Northbound.Gameplay.Construction;
|
||||||
using Northbound.Gameplay.World;
|
using Northbound.Gameplay.World;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Northbound.Gameplay.Defense
|
namespace Northbound.Gameplay.Defense
|
||||||
{
|
{
|
||||||
public sealed class DefenseSlot : MonoBehaviour, IInteractable
|
public sealed class DefenseSlot : MonoBehaviour, IInteractable, IProgressiveInteraction
|
||||||
{
|
{
|
||||||
[SerializeField] private CrystalOutpost outpost;
|
[SerializeField] private CrystalOutpost outpost;
|
||||||
[SerializeField] private float buildCost = 35f;
|
[SerializeField] private ConstructionDefinition constructionDefinition;
|
||||||
[SerializeField] private Material towerMaterial;
|
[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;
|
outpost = owner;
|
||||||
towerMaterial = material;
|
constructionDefinition = construction;
|
||||||
|
towerDefinition = tower;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetInteractionPrompt()
|
private void Update()
|
||||||
{
|
{
|
||||||
return $"Build delaying tower ({buildCost:0} mana)";
|
if (!isActivelyBuilding)
|
||||||
}
|
|
||||||
|
|
||||||
public void Interact(GameObject actor)
|
|
||||||
{
|
|
||||||
var gameState = SharedGameState.Instance;
|
|
||||||
if (!CanInteract || gameState == null || !gameState.TrySpendMana(buildCost))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var towerObject = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
var actionProgress = Mathf.Clamp01((Time.time - activeBuildStartedAt) / activeBuildDuration);
|
||||||
towerObject.name = "Basic Tower";
|
buildProgress = Mathf.Lerp(activeBuildStartProgress, 1f, actionProgress);
|
||||||
towerObject.transform.SetParent(transform, false);
|
RefreshConstructionPreview();
|
||||||
towerObject.transform.localPosition = Vector3.up * 1.25f;
|
}
|
||||||
towerObject.transform.localScale = new Vector3(0.7f, 1.25f, 0.7f);
|
|
||||||
towerObject.GetComponent<Renderer>().sharedMaterial = towerMaterial;
|
public string GetInteractionPrompt()
|
||||||
towerObject.AddComponent<BasicTower>();
|
{
|
||||||
|
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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
using Northbound.Gameplay.Player;
|
using Northbound.Gameplay.Player;
|
||||||
|
using Northbound.Gameplay.Presentation;
|
||||||
using Northbound.Gameplay.World;
|
using Northbound.Gameplay.World;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ namespace Northbound.Gameplay.Enemy
|
|||||||
|
|
||||||
public sealed class EnemyAgent : MonoBehaviour, IDamageable
|
public sealed class EnemyAgent : MonoBehaviour, IDamageable
|
||||||
{
|
{
|
||||||
|
private static int nextAvoidanceSide;
|
||||||
|
|
||||||
[SerializeField] private EnemyRole role;
|
[SerializeField] private EnemyRole role;
|
||||||
[SerializeField] private float maximumHealth = 75f;
|
[SerializeField] private float maximumHealth = 75f;
|
||||||
[SerializeField] private float moveSpeed = 2.7f;
|
[SerializeField] private float moveSpeed = 2.7f;
|
||||||
@@ -27,15 +30,21 @@ namespace Northbound.Gameplay.Enemy
|
|||||||
private float currentHealth;
|
private float currentHealth;
|
||||||
private float readyAt;
|
private float readyAt;
|
||||||
private Vector3 guardPosition;
|
private Vector3 guardPosition;
|
||||||
|
private CharacterController movementController;
|
||||||
|
private float avoidanceDirection;
|
||||||
|
|
||||||
public EnemyRole Role => role;
|
public EnemyRole Role => role;
|
||||||
public Team Team => Team.Enemy;
|
public Team Team => Team.Enemy;
|
||||||
public bool IsAlive => currentHealth > 0f;
|
public bool IsAlive => currentHealth > 0f;
|
||||||
|
public float CurrentHealth => currentHealth;
|
||||||
|
public float MaximumHealth => maximumHealth;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
currentHealth = maximumHealth;
|
currentHealth = maximumHealth;
|
||||||
guardPosition = transform.position;
|
guardPosition = transform.position;
|
||||||
|
avoidanceDirection = (nextAvoidanceSide++ & 1) == 0 ? 1f : -1f;
|
||||||
|
PrepareMovementController();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
@@ -51,19 +60,19 @@ namespace Northbound.Gameplay.Enemy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetPosition = target.transform.position;
|
var targetPosition = GetClosestTargetPoint(target);
|
||||||
var planar = targetPosition - transform.position;
|
var planar = targetPosition - transform.position;
|
||||||
planar.y = 0f;
|
planar.y = 0f;
|
||||||
if (planar.sqrMagnitude > attackRange * attackRange)
|
if (planar.sqrMagnitude > attackRange * attackRange)
|
||||||
{
|
{
|
||||||
transform.position += planar.normalized * (moveSpeed * Time.deltaTime);
|
MoveTowardsTarget(planar.normalized);
|
||||||
transform.rotation = Quaternion.LookRotation(planar.normalized);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Time.time >= readyAt && target is IDamageable damageable)
|
if (Time.time >= readyAt && target is IDamageable damageable)
|
||||||
{
|
{
|
||||||
readyAt = Time.time + attackInterval;
|
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);
|
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()
|
private MonoBehaviour SelectTarget()
|
||||||
{
|
{
|
||||||
var player = FindAnyObjectByType<PlayerController>();
|
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.Core;
|
||||||
|
using Northbound.Gameplay.Presentation;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Northbound.Gameplay.Player
|
namespace Northbound.Gameplay.Player
|
||||||
{
|
{
|
||||||
public sealed class PlayerCombat : MonoBehaviour
|
public sealed class PlayerCombat : MonoBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float normalDamage = 26f;
|
[SerializeField] private float normalDamage = 13f;
|
||||||
[SerializeField] private float normalRange = 3.2f;
|
[SerializeField] private float normalRange = 3f;
|
||||||
[SerializeField] private float normalCooldown = 0.32f;
|
[SerializeField] private float normalCooldown = 0.4f;
|
||||||
[SerializeField] private float specialDamage = 58f;
|
[SerializeField] private PlayerActionDefinition normalAction = new("Normal Attack", 0.3f, false);
|
||||||
[SerializeField] private float specialRadius = 4.8f;
|
[SerializeField] private float specialDamage = 34f;
|
||||||
[SerializeField] private float specialManaCost = 20f;
|
[SerializeField] private float specialRadius = 4.2f;
|
||||||
[SerializeField] private float specialCooldown = 4f;
|
[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 normalReadyAt;
|
||||||
private float specialReadyAt;
|
private float specialReadyAt;
|
||||||
|
private PlayerActionController actionController;
|
||||||
|
|
||||||
public float SpecialCooldownRemaining => Mathf.Max(0f, specialReadyAt - Time.time);
|
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()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt)
|
if (Input.GetMouseButtonDown(0) && Time.time >= normalReadyAt && actionController is { CanStartAction: true })
|
||||||
{
|
{
|
||||||
NormalAttack();
|
NormalAttack();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Input.GetMouseButtonDown(1) && Time.time >= specialReadyAt)
|
if (Input.GetMouseButtonDown(1) && Time.time >= specialReadyAt && actionController is { CanStartAction: true })
|
||||||
{
|
{
|
||||||
SpecialAttack();
|
SpecialAttack();
|
||||||
}
|
}
|
||||||
@@ -33,20 +46,29 @@ namespace Northbound.Gameplay.Player
|
|||||||
|
|
||||||
private void NormalAttack()
|
private void NormalAttack()
|
||||||
{
|
{
|
||||||
|
if (!actionController.TryBeginAction(normalAction))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
normalReadyAt = Time.time + normalCooldown;
|
normalReadyAt = Time.time + normalCooldown;
|
||||||
var center = transform.position + transform.forward * (normalRange * 0.55f);
|
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()
|
private void SpecialAttack()
|
||||||
{
|
{
|
||||||
var gameState = SharedGameState.Instance;
|
var gameState = SharedGameState.Instance;
|
||||||
if (gameState == null || !gameState.TrySpendMana(specialManaCost))
|
if (actionController == null || !actionController.CanStartAction || gameState == null || !gameState.TrySpendMana(specialManaCost))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionController.TryBeginAction(specialAction);
|
||||||
specialReadyAt = Time.time + specialCooldown;
|
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);
|
DamageTargets(Physics.OverlapSphere(transform.position, specialRadius), specialDamage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,31 +4,49 @@ using UnityEngine;
|
|||||||
namespace Northbound.Gameplay.Player
|
namespace Northbound.Gameplay.Player
|
||||||
{
|
{
|
||||||
[RequireComponent(typeof(CharacterController))]
|
[RequireComponent(typeof(CharacterController))]
|
||||||
|
[RequireComponent(typeof(PlayerActionController))]
|
||||||
|
[DefaultExecutionOrder(-100)]
|
||||||
public sealed class PlayerController : MonoBehaviour
|
public sealed class PlayerController : MonoBehaviour
|
||||||
{
|
{
|
||||||
[SerializeField] private float moveSpeed = 8f;
|
[SerializeField] private float moveSpeed = 5f;
|
||||||
[SerializeField] private float horizontalLimit = 15f;
|
[SerializeField] private float horizontalLimit = 15f;
|
||||||
[SerializeField] private float interactionRadius = 3.2f;
|
[SerializeField] private float interactionRadius = 3.2f;
|
||||||
|
|
||||||
private CharacterController characterController;
|
private CharacterController characterController;
|
||||||
|
private PlayerActionController actionController;
|
||||||
|
|
||||||
public Vector3 LastMoveDirection { get; private set; } = Vector3.forward;
|
public Vector3 LastMoveDirection { get; private set; } = Vector3.forward;
|
||||||
public string NearbyPrompt { get; private set; } = string.Empty;
|
public string NearbyPrompt { get; private set; } = string.Empty;
|
||||||
|
public PlayerActionController Actions => actionController;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
characterController = GetComponent<CharacterController>();
|
characterController = GetComponent<CharacterController>();
|
||||||
|
actionController = GetComponent<PlayerActionController>();
|
||||||
|
if (actionController == null)
|
||||||
|
{
|
||||||
|
actionController = gameObject.AddComponent<PlayerActionController>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
Move();
|
|
||||||
FaceMouse();
|
FaceMouse();
|
||||||
UpdateInteraction();
|
UpdateInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LateUpdate()
|
||||||
|
{
|
||||||
|
Move();
|
||||||
|
}
|
||||||
|
|
||||||
private void Move()
|
private void Move()
|
||||||
{
|
{
|
||||||
|
if (actionController != null && !actionController.CanMove)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
|
var input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
|
||||||
if (input.sqrMagnitude > 1f)
|
if (input.sqrMagnitude > 1f)
|
||||||
{
|
{
|
||||||
@@ -94,9 +112,33 @@ namespace Northbound.Gameplay.Player
|
|||||||
}
|
}
|
||||||
|
|
||||||
NearbyPrompt = nearest == null ? string.Empty : $"E: {nearest.GetInteractionPrompt()}";
|
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 System.Linq;
|
||||||
|
using Northbound.Core;
|
||||||
using Northbound.Gameplay.Player;
|
using Northbound.Gameplay.Player;
|
||||||
using Northbound.Gameplay.World;
|
using Northbound.Gameplay.World;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
@@ -9,6 +10,7 @@ namespace Northbound.Gameplay.Presentation
|
|||||||
{
|
{
|
||||||
private GUIStyle panelStyle;
|
private GUIStyle panelStyle;
|
||||||
private GUIStyle titleStyle;
|
private GUIStyle titleStyle;
|
||||||
|
private GUIStyle healthTextStyle;
|
||||||
|
|
||||||
private void OnGUI()
|
private void OnGUI()
|
||||||
{
|
{
|
||||||
@@ -35,6 +37,13 @@ namespace Northbound.Gameplay.Presentation
|
|||||||
GUILayout.Label(player.NearbyPrompt, titleStyle);
|
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)
|
if (state.IsDefeated)
|
||||||
{
|
{
|
||||||
GUILayout.Label("DEFEAT — The southern core has fallen.", titleStyle);
|
GUILayout.Label("DEFEAT — The southern core has fallen.", titleStyle);
|
||||||
@@ -46,9 +55,56 @@ namespace Northbound.Gameplay.Presentation
|
|||||||
|
|
||||||
GUILayout.EndArea();
|
GUILayout.EndArea();
|
||||||
|
|
||||||
|
DrawHealthBars();
|
||||||
DrawWorldLabels();
|
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()
|
private void DrawWorldLabels()
|
||||||
{
|
{
|
||||||
var camera = Camera.main;
|
var camera = Camera.main;
|
||||||
@@ -97,6 +153,13 @@ namespace Northbound.Gameplay.Presentation
|
|||||||
fontStyle = FontStyle.Bold,
|
fontStyle = FontStyle.Bold,
|
||||||
normal = { textColor = Color.white }
|
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 PlayerController target;
|
||||||
[SerializeField] private float visibleWorldWidth = 38f;
|
[SerializeField] private float visibleWorldWidth = 38f;
|
||||||
[SerializeField] private float forwardOffset = 6f;
|
[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 maximumZ = 70f;
|
||||||
[SerializeField] private float smoothTime = 0.18f;
|
[SerializeField] private float smoothTime = 0.18f;
|
||||||
|
|
||||||
@@ -35,7 +36,12 @@ namespace Northbound.Gameplay.Presentation
|
|||||||
return;
|
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);
|
var desired = new Vector3(0f, transform.position.y, desiredZ);
|
||||||
transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime);
|
transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,63 @@
|
|||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
|
using Northbound.Gameplay.Buildings;
|
||||||
|
using Northbound.Gameplay.Enemy;
|
||||||
|
using Northbound.Gameplay.Presentation;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Northbound.Gameplay.World
|
namespace Northbound.Gameplay.World
|
||||||
{
|
{
|
||||||
public sealed class CoreObjective : MonoBehaviour, IDamageable
|
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 Team Team => Team.Player;
|
||||||
public bool IsAlive => CurrentHealth > 0f;
|
public bool IsAlive => CurrentHealth > 0f;
|
||||||
public float CurrentHealth { get; private set; }
|
public float CurrentHealth { get; private set; }
|
||||||
public float MaximumHealth => maximumHealth;
|
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
|
||||||
|
|
||||||
private void Awake()
|
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)
|
public void TakeDamage(float amount, Team sourceTeam)
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
|
using Northbound.Gameplay.Buildings;
|
||||||
using Northbound.Gameplay.Enemy;
|
using Northbound.Gameplay.Enemy;
|
||||||
|
using Northbound.Gameplay.Presentation;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Northbound.Gameplay.World
|
namespace Northbound.Gameplay.World
|
||||||
{
|
{
|
||||||
public sealed class CrystalOutpost : MonoBehaviour, IDamageable, IInteractable
|
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 manaPerSecond = 1.25f;
|
||||||
[SerializeField] private float captureSafetyRadius = 8f;
|
[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 Renderer stateRenderer;
|
||||||
[SerializeField] private Material dormantMaterial;
|
[SerializeField] private Material dormantMaterial;
|
||||||
[SerializeField] private Material capturedMaterial;
|
[SerializeField] private Material capturedMaterial;
|
||||||
@@ -18,10 +26,13 @@ namespace Northbound.Gameplay.World
|
|||||||
public bool IsCaptured { get; private set; }
|
public bool IsCaptured { get; private set; }
|
||||||
public bool CanInteract => !IsCaptured;
|
public bool CanInteract => !IsCaptured;
|
||||||
public float CurrentHealth { get; private set; }
|
public float CurrentHealth { get; private set; }
|
||||||
|
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
|
||||||
|
|
||||||
|
private float defenseReadyAt;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
CurrentHealth = maximumHealth;
|
CurrentHealth = MaximumHealth;
|
||||||
RefreshVisual();
|
RefreshVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +46,17 @@ namespace Northbound.Gameplay.World
|
|||||||
if (IsCaptured && IsAlive)
|
if (IsCaptured && IsAlive)
|
||||||
{
|
{
|
||||||
SharedGameState.Instance?.AddMana(manaPerSecond * Time.deltaTime);
|
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;
|
stateRenderer = renderer;
|
||||||
dormantMaterial = dormant;
|
dormantMaterial = dormant;
|
||||||
capturedMaterial = captured;
|
capturedMaterial = captured;
|
||||||
@@ -51,16 +68,27 @@ namespace Northbound.Gameplay.World
|
|||||||
return HasNearbyEnemy() ? "Clear nearby enemies" : "Activate crystal outpost";
|
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())
|
if (IsCaptured || HasNearbyEnemy())
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
IsCaptured = true;
|
IsCaptured = true;
|
||||||
CurrentHealth = maximumHealth;
|
CurrentHealth = MaximumHealth;
|
||||||
RefreshVisual();
|
RefreshVisual();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void TakeDamage(float amount, Team sourceTeam)
|
public void TakeDamage(float amount, Team sourceTeam)
|
||||||
@@ -91,6 +119,35 @@ namespace Northbound.Gameplay.World
|
|||||||
return false;
|
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()
|
private void RefreshVisual()
|
||||||
{
|
{
|
||||||
if (stateRenderer != null)
|
if (stateRenderer != null)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Northbound.Core;
|
using Northbound.Core;
|
||||||
|
using Northbound.Gameplay.Buildings;
|
||||||
using Northbound.Gameplay.Player;
|
using Northbound.Gameplay.Player;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
@@ -15,8 +16,14 @@ namespace Northbound.Gameplay.World
|
|||||||
{
|
{
|
||||||
[SerializeField] private int networkIndex;
|
[SerializeField] private int networkIndex;
|
||||||
[SerializeField] private PortalState state = PortalState.Enemy;
|
[SerializeField] private PortalState state = PortalState.Enemy;
|
||||||
[SerializeField] private float maximumHealth = 240f;
|
[SerializeField] private BuildingDefinition buildingDefinition;
|
||||||
[SerializeField] private float rebuildManaCost = 40f;
|
[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 Renderer stateRenderer;
|
||||||
[SerializeField] private Material enemyMaterial;
|
[SerializeField] private Material enemyMaterial;
|
||||||
[SerializeField] private Material ruinedMaterial;
|
[SerializeField] private Material ruinedMaterial;
|
||||||
@@ -28,10 +35,11 @@ namespace Northbound.Gameplay.World
|
|||||||
public bool IsAlive => state != PortalState.Ruined;
|
public bool IsAlive => state != PortalState.Ruined;
|
||||||
public bool CanInteract => state != PortalState.Enemy;
|
public bool CanInteract => state != PortalState.Enemy;
|
||||||
public float CurrentHealth { get; private set; }
|
public float CurrentHealth { get; private set; }
|
||||||
|
public float MaximumHealth => buildingDefinition == null ? 1f : buildingDefinition.MaximumHealth;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
CurrentHealth = maximumHealth;
|
CurrentHealth = MaximumHealth;
|
||||||
RefreshVisual();
|
RefreshVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +51,7 @@ namespace Northbound.Gameplay.World
|
|||||||
public void Configure(
|
public void Configure(
|
||||||
int index,
|
int index,
|
||||||
PortalState initialState,
|
PortalState initialState,
|
||||||
|
BuildingDefinition definition,
|
||||||
Renderer renderer,
|
Renderer renderer,
|
||||||
Material enemy,
|
Material enemy,
|
||||||
Material ruined,
|
Material ruined,
|
||||||
@@ -50,11 +59,12 @@ namespace Northbound.Gameplay.World
|
|||||||
{
|
{
|
||||||
networkIndex = index;
|
networkIndex = index;
|
||||||
state = initialState;
|
state = initialState;
|
||||||
|
buildingDefinition = definition;
|
||||||
stateRenderer = renderer;
|
stateRenderer = renderer;
|
||||||
enemyMaterial = enemy;
|
enemyMaterial = enemy;
|
||||||
ruinedMaterial = ruined;
|
ruinedMaterial = ruined;
|
||||||
alliedMaterial = allied;
|
alliedMaterial = allied;
|
||||||
CurrentHealth = maximumHealth;
|
CurrentHealth = MaximumHealth;
|
||||||
RefreshVisual();
|
RefreshVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,24 +101,48 @@ namespace Northbound.Gameplay.World
|
|||||||
return "Travel through adjacent portal";
|
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)
|
if (state == PortalState.Ruined)
|
||||||
{
|
{
|
||||||
Rebuild();
|
return gameState != null && gameState.Mana + 0.001f >= rebuildManaCost;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state != PortalState.Allied || !actor.TryGetComponent<PlayerController>(out var player))
|
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 direction = player.LastMoveDirection.z >= 0f ? 1 : -1;
|
||||||
var destination = SharedGameState.Instance?.GetPortal(networkIndex + direction);
|
var destination = SharedGameState.Instance?.GetPortal(networkIndex + direction);
|
||||||
if (destination == null || destination.State != PortalState.Allied)
|
if (destination == null || destination.State != PortalState.Allied)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var controller = actor.GetComponent<CharacterController>();
|
var controller = actor.GetComponent<CharacterController>();
|
||||||
@@ -122,19 +156,22 @@ namespace Northbound.Gameplay.World
|
|||||||
{
|
{
|
||||||
controller.enabled = true;
|
controller.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Rebuild()
|
private bool Rebuild()
|
||||||
{
|
{
|
||||||
var gameState = SharedGameState.Instance;
|
var gameState = SharedGameState.Instance;
|
||||||
if (gameState == null || !gameState.TrySpendMana(rebuildManaCost))
|
if (gameState == null || !gameState.TrySpendMana(rebuildManaCost))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = PortalState.Allied;
|
state = PortalState.Allied;
|
||||||
CurrentHealth = maximumHealth;
|
CurrentHealth = MaximumHealth;
|
||||||
RefreshVisual();
|
RefreshVisual();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshVisual()
|
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}
|
target: {fileID: 597054782}
|
||||||
visibleWorldWidth: 38
|
visibleWorldWidth: 38
|
||||||
forwardOffset: 6
|
forwardOffset: 6
|
||||||
minimumZ: -70
|
forwardOffsetRampDistance: 16
|
||||||
|
minimumZ: -76
|
||||||
maximumZ: 70
|
maximumZ: 70
|
||||||
smoothTime: 0.18
|
smoothTime: 0.18
|
||||||
--- !u!81 &117007547
|
--- !u!81 &117007547
|
||||||
@@ -494,7 +495,7 @@ Transform:
|
|||||||
m_GameObject: {fileID: 117007545}
|
m_GameObject: {fileID: 117007545}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
|
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_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children: []
|
m_Children: []
|
||||||
@@ -743,7 +744,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
||||||
networkIndex: 1
|
networkIndex: 1
|
||||||
state: 0
|
state: 0
|
||||||
maximumHealth: 240
|
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
|
||||||
rebuildManaCost: 40
|
rebuildManaCost: 40
|
||||||
stateRenderer: {fileID: 437779362}
|
stateRenderer: {fileID: 437779362}
|
||||||
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
||||||
@@ -878,7 +879,7 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3}
|
m_Script: {fileID: 11500000, guid: 7e89beb034a288c45b59485f3ff66293, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerController
|
||||||
moveSpeed: 8
|
moveSpeed: 5
|
||||||
horizontalLimit: 15
|
horizontalLimit: 15
|
||||||
interactionRadius: 3.2
|
interactionRadius: 3.2
|
||||||
--- !u!114 &597054783
|
--- !u!114 &597054783
|
||||||
@@ -893,13 +894,23 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3}
|
m_Script: {fileID: 11500000, guid: 01dc4a815b7e62c4580a7b5b60a0a05e, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Player.PlayerCombat
|
||||||
normalDamage: 26
|
normalDamage: 13
|
||||||
normalRange: 3.2
|
normalRange: 3
|
||||||
normalCooldown: 0.32
|
normalCooldown: 0.4
|
||||||
specialDamage: 58
|
normalAction:
|
||||||
specialRadius: 4.8
|
actionName: Normal Attack
|
||||||
specialManaCost: 20
|
duration: 0.3
|
||||||
specialCooldown: 4
|
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
|
--- !u!114 &597054784
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -913,7 +924,7 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Combat.Health
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Combat.Health
|
||||||
team: 1
|
team: 1
|
||||||
maximum: 180
|
maximum: 100
|
||||||
destroyOnDeath: 1
|
destroyOnDeath: 1
|
||||||
--- !u!143 &597054785
|
--- !u!143 &597054785
|
||||||
CharacterController:
|
CharacterController:
|
||||||
@@ -1180,8 +1191,8 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
||||||
outpost: {fileID: 1846946633}
|
outpost: {fileID: 1846946633}
|
||||||
buildCost: 35
|
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
|
||||||
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
|
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
|
||||||
--- !u!23 &782583536
|
--- !u!23 &782583536
|
||||||
MeshRenderer:
|
MeshRenderer:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -1609,7 +1620,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
||||||
networkIndex: 2
|
networkIndex: 2
|
||||||
state: 0
|
state: 0
|
||||||
maximumHealth: 240
|
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
|
||||||
rebuildManaCost: 40
|
rebuildManaCost: 40
|
||||||
stateRenderer: {fileID: 1174730708}
|
stateRenderer: {fileID: 1174730708}
|
||||||
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
||||||
@@ -1742,9 +1753,12 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
||||||
maximumHealth: 300
|
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
|
||||||
manaPerSecond: 1.25
|
manaPerSecond: 1.25
|
||||||
captureSafetyRadius: 8
|
captureSafetyRadius: 8
|
||||||
|
defenseRange: 7.5
|
||||||
|
defenseDamage: 6
|
||||||
|
defenseInterval: 1.4
|
||||||
stateRenderer: {fileID: 1191827590}
|
stateRenderer: {fileID: 1191827590}
|
||||||
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
||||||
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
||||||
@@ -1875,7 +1889,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
||||||
networkIndex: 3
|
networkIndex: 3
|
||||||
state: 0
|
state: 0
|
||||||
maximumHealth: 240
|
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
|
||||||
rebuildManaCost: 40
|
rebuildManaCost: 40
|
||||||
stateRenderer: {fileID: 1272044565}
|
stateRenderer: {fileID: 1272044565}
|
||||||
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
||||||
@@ -2121,8 +2135,8 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
||||||
outpost: {fileID: 1191827589}
|
outpost: {fileID: 1191827589}
|
||||||
buildCost: 35
|
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
|
||||||
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
|
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
|
||||||
--- !u!23 &1483313425
|
--- !u!23 &1483313425
|
||||||
MeshRenderer:
|
MeshRenderer:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -2248,9 +2262,12 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
||||||
maximumHealth: 300
|
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
|
||||||
manaPerSecond: 1.25
|
manaPerSecond: 1.25
|
||||||
captureSafetyRadius: 8
|
captureSafetyRadius: 8
|
||||||
|
defenseRange: 7.5
|
||||||
|
defenseDamage: 6
|
||||||
|
defenseInterval: 1.4
|
||||||
stateRenderer: {fileID: 1846946634}
|
stateRenderer: {fileID: 1846946634}
|
||||||
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
||||||
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
||||||
@@ -2379,9 +2396,12 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
m_Script: {fileID: 11500000, guid: af6e0031e23718641b82d717facdef1f, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CrystalOutpost
|
||||||
maximumHealth: 300
|
buildingDefinition: {fileID: 11400000, guid: b21f75f184fe474b81f01dc1d9b107d7, type: 2}
|
||||||
manaPerSecond: 1.25
|
manaPerSecond: 1.25
|
||||||
captureSafetyRadius: 8
|
captureSafetyRadius: 8
|
||||||
|
defenseRange: 7.5
|
||||||
|
defenseDamage: 6
|
||||||
|
defenseInterval: 1.4
|
||||||
stateRenderer: {fileID: 1940329550}
|
stateRenderer: {fileID: 1940329550}
|
||||||
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
dormantMaterial: {fileID: 2100000, guid: 0aaab0f7eb2740a4fa6812cdf06c1bcc, type: 2}
|
||||||
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
capturedMaterial: {fileID: 2100000, guid: ad9405226d4d14f4c8d791a396f78042, type: 2}
|
||||||
@@ -2781,8 +2801,8 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.Defense.DefenseSlot
|
||||||
outpost: {fileID: 1940329549}
|
outpost: {fileID: 1940329549}
|
||||||
buildCost: 35
|
constructionDefinition: {fileID: 11400000, guid: c9fc6f9b4ab54e279a6610db8167a0ab, type: 2}
|
||||||
towerMaterial: {fileID: 2100000, guid: d76804fbbf65d204aa21241e7792d9a8, type: 2}
|
towerDefinition: {fileID: 11400000, guid: f5eb3436646143be94c9d95a9b10cd27, type: 2}
|
||||||
--- !u!23 &2061713006
|
--- !u!23 &2061713006
|
||||||
MeshRenderer:
|
MeshRenderer:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -2910,7 +2930,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.PortalSite
|
||||||
networkIndex: 0
|
networkIndex: 0
|
||||||
state: 2
|
state: 2
|
||||||
maximumHealth: 240
|
buildingDefinition: {fileID: 11400000, guid: d342d3173a3f4ffcaf67ad6f2129b219, type: 2}
|
||||||
rebuildManaCost: 40
|
rebuildManaCost: 40
|
||||||
stateRenderer: {fileID: 2076976473}
|
stateRenderer: {fileID: 2076976473}
|
||||||
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
enemyMaterial: {fileID: 2100000, guid: 470f8a3887afc71428ecde93cb980daf, type: 2}
|
||||||
@@ -3043,7 +3063,10 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 4486584e692a882489182d3b3f0d7116, type: 3}
|
m_Script: {fileID: 11500000, guid: 4486584e692a882489182d3b3f0d7116, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Northbound.Gameplay::Northbound.Gameplay.World.CoreObjective
|
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
|
--- !u!23 &2087299787
|
||||||
MeshRenderer:
|
MeshRenderer:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -34,7 +34,10 @@
|
|||||||
- 웨이브 시작 시 활성 적 포탈 수를 기록하는 포탈 공명
|
- 웨이브 시작 시 활성 적 포탈 수를 기록하는 포탈 공명
|
||||||
- 공명 단계당 체력 +10%, 공격력 +6%, 최대 3단계
|
- 공명 단계당 체력 +10%, 공격력 +6%, 최대 3단계
|
||||||
- 본진 기본 마나와 활성 크리스탈의 자동 공용 마나 생산
|
- 본진 기본 마나와 활성 크리스탈의 자동 공용 마나 생산
|
||||||
|
- 본진과 확보된 크리스탈 거점의 약한 기본 공격
|
||||||
- 크리스탈당 제한된 방어 슬롯과 약한 지연 타워
|
- 크리스탈당 제한된 방어 슬롯과 약한 지연 타워
|
||||||
|
- 비용·시간·행동 정책과 표현 참조를 공유 ConstructionDefinition 자산으로 관리
|
||||||
|
- 코어·거점·포탈·타워는 BuildingDefinition을 직접 참조하고 TowerDefinition과 ConstructionDefinition도 동일 건물 SO를 참조
|
||||||
- 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대
|
- 일반 적, 약탈자, 정예, 공명에서 제외되는 포탈 수비대
|
||||||
- 첫 웨이브 15초, 이후 90초 간격
|
- 첫 웨이브 15초, 이후 90초 간격
|
||||||
|
|
||||||
@@ -50,6 +53,15 @@
|
|||||||
|
|
||||||
### 2. 전투 감각
|
### 2. 전투 감각
|
||||||
|
|
||||||
|
- 플레이어의 체력과 기본 화력을 낮춰 거점 지원 없이 싸울 때 비용이 발생하게 한다.
|
||||||
|
- 플레이어 이동 속도를 초당 5m로 낮춰 안전한 장시간 카이팅을 제한한다.
|
||||||
|
- 이동을 제외한 모든 플레이어 행위를 공통 행동으로 관리하고, 각 행동의 `행동 중 이동 가능` 설정에 따라 이동을 허용하거나 차단한다.
|
||||||
|
- 각 행동에 `취소 가능` 설정을 제공하고, 취소 가능한 건설·활성화·재건의 결과는 행동 완료 시점에 적용한다.
|
||||||
|
- 건설 취소 시 부분 건설물과 진행도를 유지하고, 재상호작용 시 추가 비용 없이 남은 과정부터 재개한다.
|
||||||
|
- 적 이동은 물리 충돌을 사용하고 건물 표면에서 멈춰 공격하며, 간단한 측면 회피로 장애물을 돌아가게 한다.
|
||||||
|
- 일반 공격과 특수 공격의 실제 판정 위치·범위를 보여 주는 시각 피드백을 제공한다.
|
||||||
|
- 적과 모든 방어 시설의 공격 주체·목표를 구분할 수 있는 공격 표시를 제공한다.
|
||||||
|
- 플레이어, 적, 포탈, 크리스탈 거점과 코어에 현재/최대 체력을 표시하는 월드 체력바를 제공한다.
|
||||||
- 이동만으로 회피 가능한 적 공격 예고를 추가한다.
|
- 이동만으로 회피 가능한 적 공격 예고를 추가한다.
|
||||||
- 일반 공격의 범위·박자와 특수 공격의 마나·재사용 대기시간을 조정한다.
|
- 일반 공격의 범위·박자와 특수 공격의 마나·재사용 대기시간을 조정한다.
|
||||||
- 정예와 다수 적 사이에서 공격 대상 및 특수 공격 시점 판단이 생기는지 확인한다.
|
- 정예와 다수 적 사이에서 공격 대상 및 특수 공격 시점 판단이 생기는지 확인한다.
|
||||||
@@ -66,6 +78,7 @@
|
|||||||
|
|
||||||
### 4. 거점과 방어 시간
|
### 4. 거점과 방어 시간
|
||||||
|
|
||||||
|
- 본진과 확보된 크리스탈 거점은 소수의 약한 적을 상대하는 최소 방어 공격을 가진다.
|
||||||
- 크리스탈 확보 전 주변 적 제거 조건을 조정한다.
|
- 크리스탈 확보 전 주변 적 제거 조건을 조정한다.
|
||||||
- 약탈자가 확보된 크리스탈을 공격하게 한다.
|
- 약탈자가 확보된 크리스탈을 공격하게 한다.
|
||||||
- 타워의 화력은 낮게 두고 지연·경보 기능을 강화한다.
|
- 타워의 화력은 낮게 두고 지연·경보 기능을 강화한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user