More major updates to tools, added map area volume, made gold manager network managed per player.
This commit is contained in:
parent
b44eeaeeff
commit
56dc775c68
18 changed files with 632 additions and 283 deletions
|
|
@ -13,4 +13,9 @@ MonoBehaviour:
|
||||||
m_Name: DefaultNetworkPrefabs
|
m_Name: DefaultNetworkPrefabs
|
||||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkPrefabsList
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkPrefabsList
|
||||||
IsDefault: 1
|
IsDefault: 1
|
||||||
List: []
|
List:
|
||||||
|
- Override: 0
|
||||||
|
Prefab: {fileID: 3493329038866903420, guid: 9a9c23b8584ab444aa5066a48579a9ec, type: 3}
|
||||||
|
SourcePrefabToOverride: {fileID: 0}
|
||||||
|
SourceHashToOverride: 0
|
||||||
|
OverridingTargetPrefab: {fileID: 0}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,11 @@ namespace TD.Levels
|
||||||
[Tooltip("If true, GoalVolume gizmos draw whether or not the volume is selected.")]
|
[Tooltip("If true, GoalVolume gizmos draw whether or not the volume is selected.")]
|
||||||
public bool alwaysShowGoals = false;
|
public bool alwaysShowGoals = false;
|
||||||
|
|
||||||
|
[Tooltip("If true, MapAreaVolume gizmos draw whether or not the volume is selected. " +
|
||||||
|
"Defaults to true — the map boundary is generally useful to see while authoring " +
|
||||||
|
"the rest of the map, not just when the MapAreaVolume itself is selected.")]
|
||||||
|
public bool alwaysShowMapArea = true;
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Bake API
|
// Bake API
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,13 @@ namespace TD.Levels
|
||||||
"Length = GridSize.x * GridSize.y.")]
|
"Length = GridSize.x * GridSize.y.")]
|
||||||
public PlayerSlot[] OwnerGrid;
|
public PlayerSlot[] OwnerGrid;
|
||||||
|
|
||||||
|
[Tooltip("Per-tile map-area membership. True if the tile is part of the playable map " +
|
||||||
|
"(inside any MapAreaVolume); false for void tiles outside the map. This is the " +
|
||||||
|
"outermost gating layer — gameplay queries (placement, ownership, walkability) " +
|
||||||
|
"are only meaningful for tiles where MapAreaGrid is true. Length = GridSize.x * " +
|
||||||
|
"GridSize.y.")]
|
||||||
|
public bool[] MapAreaGrid;
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Per-zone and per-goal structures (populated by bake from volume data).
|
// Per-zone and per-goal structures (populated by bake from volume data).
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
103
Assets/_Project/Levels/MapAreaVolume.cs
Normal file
103
Assets/_Project/Levels/MapAreaVolume.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Core;
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
using UnityEditor;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace TD.Levels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authoring volume defining the playable map area — the bounds the builder can move within
|
||||||
|
/// and the camera can pan to. Has no gameplay payload (no owner, no placement validity, no
|
||||||
|
/// spawner data, no leak target). Multiple instances are allowed; their union defines the
|
||||||
|
/// "map area" as referenced in the per-tile data layering spec.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Map area = where the player's attention can go.
|
||||||
|
/// Gameplay area = union of PlayerZone, Spawner, LeakExit, Goal volumes (where rules apply).
|
||||||
|
/// Buffer area = map area minus gameplay area (visible terrain, no gameplay, no building).
|
||||||
|
///
|
||||||
|
/// Coverage rule: every gameplay volume's tiles must be a subset of the map area. The bake
|
||||||
|
/// validates this in Phase 5 (P5-12) and fails with a hard error if any gameplay volume
|
||||||
|
/// pokes outside the map area.
|
||||||
|
///
|
||||||
|
/// Drawn as a thicker outline only (no fill) so it reads as a boundary marker rather than
|
||||||
|
/// as a translucent overlay on top of the gameplay volumes. The "always show" toggle on
|
||||||
|
/// LevelAuthoring defaults to true for this volume type because the map boundary is
|
||||||
|
/// generally useful to see during all authoring, not just when this volume is selected.
|
||||||
|
/// </remarks>
|
||||||
|
public class MapAreaVolume : VolumeBase
|
||||||
|
{
|
||||||
|
// Map area draws BELOW player zones (which sit at 0.05). Just above the LevelAuthoring
|
||||||
|
// map-bounds line (0.01) so it doesn't z-fight with that wireframe.
|
||||||
|
private const float FillYLevel = 0.02f;
|
||||||
|
|
||||||
|
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
|
||||||
|
{
|
||||||
|
return authoring.alwaysShowMapArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrawGizmosSelected()
|
||||||
|
{
|
||||||
|
DrawGizmosCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrawGizmos()
|
||||||
|
{
|
||||||
|
if (ShouldDrawAlwaysOn())
|
||||||
|
{
|
||||||
|
DrawGizmosCore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawGizmosCore()
|
||||||
|
{
|
||||||
|
Color baseColor = PlayerColors.MapArea;
|
||||||
|
DrawThickRectangularOutline(baseColor, yLevel: FillYLevel);
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
var col = Collider;
|
||||||
|
if (col != null)
|
||||||
|
{
|
||||||
|
Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
|
||||||
|
Handles.Label(labelPos, "Map Area");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws the volume's tight tile rectangle as a thicker outline. Unity's Gizmos.DrawLine
|
||||||
|
// has no width parameter, so we approximate thickness by drawing the rectangle three
|
||||||
|
// times with small lateral offsets. The offsets are in tile units; 0.04 reads as a
|
||||||
|
// visibly thicker line at typical scene-view zoom without looking like multiple lines.
|
||||||
|
private void DrawThickRectangularOutline(Color outlineColor, float yLevel)
|
||||||
|
{
|
||||||
|
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
|
||||||
|
|
||||||
|
const float thickness = 0.04f;
|
||||||
|
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
|
||||||
|
|
||||||
|
// Three concentric rectangles: the original edge, slightly inset, slightly outset.
|
||||||
|
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, 0f, outlineColor);
|
||||||
|
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, +thickness, outlineColor);
|
||||||
|
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, -thickness, outlineColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawOutlineAtInset(Vector2Int minTile, Vector2Int maxTile, float halfTile,
|
||||||
|
float yLevel, float inset, Color color)
|
||||||
|
{
|
||||||
|
Vector3 sw = new Vector3(minTile.x - halfTile - inset, yLevel, minTile.y - halfTile - inset);
|
||||||
|
Vector3 se = new Vector3(maxTile.x + halfTile + inset, yLevel, minTile.y - halfTile - inset);
|
||||||
|
Vector3 ne = new Vector3(maxTile.x + halfTile + inset, yLevel, maxTile.y + halfTile + inset);
|
||||||
|
Vector3 nw = new Vector3(minTile.x - halfTile - inset, yLevel, maxTile.y + halfTile + inset);
|
||||||
|
|
||||||
|
Color prev = Gizmos.color;
|
||||||
|
Gizmos.color = color;
|
||||||
|
Gizmos.DrawLine(sw, se);
|
||||||
|
Gizmos.DrawLine(se, ne);
|
||||||
|
Gizmos.DrawLine(ne, nw);
|
||||||
|
Gizmos.DrawLine(nw, sw);
|
||||||
|
Gizmos.color = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Levels/MapAreaVolume.cs.meta
Normal file
2
Assets/_Project/Levels/MapAreaVolume.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bacb3662546735544bc6c56b5bf5830a
|
||||||
8
Assets/_Project/Prefabs/Player.meta
Normal file
8
Assets/_Project/Prefabs/Player.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 36f79ad6231ec054a9ceed380b33ce11
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
74
Assets/_Project/Prefabs/Player/Player.prefab
Normal file
74
Assets/_Project/Prefabs/Player/Player.prefab
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!1 &3493329038866903420
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 8600750867913649879}
|
||||||
|
- component: {fileID: 2152427255203126265}
|
||||||
|
- component: {fileID: 2918837822014987993}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: Player
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!4 &8600750867913649879
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 3493329038866903420}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 36.397, y: 0.5, z: 6.33766}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!114 &2152427255203126265
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 3493329038866903420}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
||||||
|
GlobalObjectIdHash: 2568017024
|
||||||
|
InScenePlacedSourceGlobalObjectIdHash: 0
|
||||||
|
DeferredDespawnTick: 0
|
||||||
|
Ownership: 1
|
||||||
|
AlwaysReplicateAsRoot: 0
|
||||||
|
SynchronizeTransform: 1
|
||||||
|
ActiveSceneSynchronization: 0
|
||||||
|
SceneMigrationSynchronization: 0
|
||||||
|
SpawnWithObservers: 1
|
||||||
|
DontDestroyWithOwner: 0
|
||||||
|
AutoObjectParentSync: 1
|
||||||
|
SyncOwnerTransformWhenParented: 1
|
||||||
|
AllowOwnerToParent: 0
|
||||||
|
--- !u!114 &2918837822014987993
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 3493329038866903420}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 6b9796562d7cc274f832657f21a61cce, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerGoldManager
|
||||||
|
ShowTopMostFoldoutHeaderGroup: 1
|
||||||
|
startingGold: 100
|
||||||
7
Assets/_Project/Prefabs/Player/Player.prefab.meta
Normal file
7
Assets/_Project/Prefabs/Player/Player.prefab.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a9c23b8584ab444aa5066a48579a9ec
|
||||||
|
PrefabImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -237,78 +237,6 @@ Transform:
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1 &239104687
|
|
||||||
GameObject:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
serializedVersion: 6
|
|
||||||
m_Component:
|
|
||||||
- component: {fileID: 239104690}
|
|
||||||
- component: {fileID: 239104688}
|
|
||||||
- component: {fileID: 239104689}
|
|
||||||
m_Layer: 0
|
|
||||||
m_Name: GoldManager
|
|
||||||
m_TagString: Untagged
|
|
||||||
m_Icon: {fileID: 0}
|
|
||||||
m_NavMeshLayer: 0
|
|
||||||
m_StaticEditorFlags: 0
|
|
||||||
m_IsActive: 1
|
|
||||||
--- !u!114 &239104688
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 239104687}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
|
|
||||||
m_Name:
|
|
||||||
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
|
|
||||||
GlobalObjectIdHash: 3224213014
|
|
||||||
InScenePlacedSourceGlobalObjectIdHash: 0
|
|
||||||
DeferredDespawnTick: 0
|
|
||||||
Ownership: 1
|
|
||||||
AlwaysReplicateAsRoot: 0
|
|
||||||
SynchronizeTransform: 1
|
|
||||||
ActiveSceneSynchronization: 0
|
|
||||||
SceneMigrationSynchronization: 0
|
|
||||||
SpawnWithObservers: 1
|
|
||||||
DontDestroyWithOwner: 0
|
|
||||||
AutoObjectParentSync: 1
|
|
||||||
SyncOwnerTransformWhenParented: 1
|
|
||||||
AllowOwnerToParent: 0
|
|
||||||
--- !u!114 &239104689
|
|
||||||
MonoBehaviour:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 239104687}
|
|
||||||
m_Enabled: 1
|
|
||||||
m_EditorHideFlags: 0
|
|
||||||
m_Script: {fileID: 11500000, guid: d44ebdd0b2fc4144c8f8a181a714b738, type: 3}
|
|
||||||
m_Name:
|
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.GoldManager
|
|
||||||
ShowTopMostFoldoutHeaderGroup: 1
|
|
||||||
startingGold: 100
|
|
||||||
--- !u!4 &239104690
|
|
||||||
Transform:
|
|
||||||
m_ObjectHideFlags: 0
|
|
||||||
m_CorrespondingSourceObject: {fileID: 0}
|
|
||||||
m_PrefabInstance: {fileID: 0}
|
|
||||||
m_PrefabAsset: {fileID: 0}
|
|
||||||
m_GameObject: {fileID: 239104687}
|
|
||||||
serializedVersion: 2
|
|
||||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
|
||||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
|
||||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
|
||||||
m_ConstrainProportionsScale: 0
|
|
||||||
m_Children: []
|
|
||||||
m_Father: {fileID: 0}
|
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
|
||||||
--- !u!1 &304575571
|
--- !u!1 &304575571
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -682,6 +610,7 @@ MonoBehaviour:
|
||||||
alwaysShowSpawners: 1
|
alwaysShowSpawners: 1
|
||||||
alwaysShowLeakExits: 1
|
alwaysShowLeakExits: 1
|
||||||
alwaysShowGoals: 1
|
alwaysShowGoals: 1
|
||||||
|
alwaysShowMapArea: 1
|
||||||
--- !u!4 &441239881
|
--- !u!4 &441239881
|
||||||
Transform:
|
Transform:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -701,6 +630,7 @@ Transform:
|
||||||
- {fileID: 1078485324}
|
- {fileID: 1078485324}
|
||||||
- {fileID: 1064792476}
|
- {fileID: 1064792476}
|
||||||
- {fileID: 1360337263}
|
- {fileID: 1360337263}
|
||||||
|
- {fileID: 923592499}
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1 &832575517
|
--- !u!1 &832575517
|
||||||
|
|
@ -752,6 +682,72 @@ Transform:
|
||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!1 &923592498
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 6
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 923592499}
|
||||||
|
- component: {fileID: 923592501}
|
||||||
|
- component: {fileID: 923592500}
|
||||||
|
m_Layer: 0
|
||||||
|
m_Name: MapArea
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!4 &923592499
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 923592498}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 441239881}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!114 &923592500
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 923592498}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: bacb3662546735544bc6c56b5bf5830a, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.MapAreaVolume
|
||||||
|
--- !u!65 &923592501
|
||||||
|
BoxCollider:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 923592498}
|
||||||
|
m_Material: {fileID: 0}
|
||||||
|
m_IncludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_ExcludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_LayerOverridePriority: 0
|
||||||
|
m_IsTrigger: 0
|
||||||
|
m_ProvidesContacts: 0
|
||||||
|
m_Enabled: 1
|
||||||
|
serializedVersion: 3
|
||||||
|
m_Size: {x: 70, y: 0.5, z: 39}
|
||||||
|
m_Center: {x: 0.5, y: 0, z: 19}
|
||||||
--- !u!1 &1064792475
|
--- !u!1 &1064792475
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
@ -1137,7 +1133,7 @@ MonoBehaviour:
|
||||||
NetworkConfig:
|
NetworkConfig:
|
||||||
ProtocolVersion: 0
|
ProtocolVersion: 0
|
||||||
NetworkTransport: {fileID: 1682341400}
|
NetworkTransport: {fileID: 1682341400}
|
||||||
PlayerPrefab: {fileID: 0}
|
PlayerPrefab: {fileID: 3493329038866903420, guid: 9a9c23b8584ab444aa5066a48579a9ec, type: 3}
|
||||||
Prefabs:
|
Prefabs:
|
||||||
NetworkPrefabsLists:
|
NetworkPrefabsLists:
|
||||||
- {fileID: 11400000, guid: 481ab1d7456efd044bc3e349aacd92ae, type: 2}
|
- {fileID: 11400000, guid: 481ab1d7456efd044bc3e349aacd92ae, type: 2}
|
||||||
|
|
@ -1254,7 +1250,6 @@ SceneRoots:
|
||||||
- {fileID: 410087041}
|
- {fileID: 410087041}
|
||||||
- {fileID: 832575519}
|
- {fileID: 832575519}
|
||||||
- {fileID: 1682341402}
|
- {fileID: 1682341402}
|
||||||
- {fileID: 239104690}
|
|
||||||
- {fileID: 441239881}
|
- {fileID: 441239881}
|
||||||
- {fileID: 1464027364}
|
- {fileID: 1464027364}
|
||||||
- {fileID: 167151709}
|
- {fileID: 167151709}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9b13dafb78f7bc013cba5dd80719fd2e86d11f2601ad7103adc85bfd7aed067d
|
oid sha256:62cfc398409e4285b50681d79ae4767183db364b3f9feb9072ddf9dd60ec07d9
|
||||||
size 5198
|
size 12253
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,20 @@ namespace TD.Core
|
||||||
{
|
{
|
||||||
// Player colors. Hex values are RGB; alpha is set per-gizmo at draw time.
|
// Player colors. Hex values are RGB; alpha is set per-gizmo at draw time.
|
||||||
// Values are tuned to be saturated enough to read against Unity's default scene background.
|
// Values are tuned to be saturated enough to read against Unity's default scene background.
|
||||||
private static readonly Color Player1Red = HexRGB(0xE0, 0x3A, 0x3A); // red
|
private static readonly Color Player1Red = HexRGB(0xE0, 0x3A, 0x3A); // red
|
||||||
private static readonly Color Player2Green = HexRGB(0x3A, 0xC0, 0x4A); // green
|
private static readonly Color Player2Green = HexRGB(0x3A, 0xC0, 0x4A); // green
|
||||||
private static readonly Color Player3Blue = HexRGB(0x3A, 0x7A, 0xE0); // blue
|
private static readonly Color Player3Blue = HexRGB(0x3A, 0x7A, 0xE0); // blue
|
||||||
private static readonly Color Player4Purple = HexRGB(0xA0, 0x4A, 0xC0); // purple
|
private static readonly Color Player4Purple = HexRGB(0xA0, 0x4A, 0xC0); // purple
|
||||||
private static readonly Color Player5Yellow = HexRGB(0xE0, 0xC8, 0x3A); // yellow
|
private static readonly Color Player5Yellow = HexRGB(0xE0, 0xC8, 0x3A); // yellow
|
||||||
private static readonly Color Player6Gray = HexRGB(0xB0, 0xB0, 0xB8); // gray (slightly cool)
|
private static readonly Color Player6Gray = HexRGB(0xB0, 0xB0, 0xB8); // gray (slightly cool)
|
||||||
private static readonly Color Player7Teal = HexRGB(0x3A, 0xC0, 0xB8); // teal
|
private static readonly Color Player7Teal = HexRGB(0x3A, 0xC0, 0xB8); // teal
|
||||||
private static readonly Color Player8Olive = HexRGB(0x9A, 0x9A, 0x3A); // olive
|
private static readonly Color Player8Olive = HexRGB(0x9A, 0x9A, 0x3A); // olive
|
||||||
private static readonly Color Player9DarkGray = HexRGB(0x60, 0x60, 0x68); // dark gray (slightly cool)
|
private static readonly Color Player9DarkGray = HexRGB(0x60, 0x60, 0x68); // dark gray (slightly cool)
|
||||||
|
|
||||||
// Non-player colors.
|
// Non-player colors.
|
||||||
private static readonly Color GoalGold = HexRGB(0xE0, 0xB0, 0x20); // gold
|
private static readonly Color GoalGold = HexRGB(0xE0, 0xB0, 0x20); // gold
|
||||||
private static readonly Color ErrorPink = HexRGB(0xFF, 0x4A, 0xC8); // diagnostic
|
private static readonly Color MapAreaCyan = HexRGB(0xB0, 0xD0, 0xE0); // muted cyan (background)
|
||||||
|
private static readonly Color ErrorPink = HexRGB(0xFF, 0x4A, 0xC8); // diagnostic
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the canonical color for a player slot. Returns the diagnostic error pink if
|
/// Returns the canonical color for a player slot. Returns the diagnostic error pink if
|
||||||
|
|
@ -60,6 +61,13 @@ namespace TD.Core
|
||||||
/// <summary>The canonical color used for goal volumes. Not tied to any player.</summary>
|
/// <summary>The canonical color used for goal volumes. Not tied to any player.</summary>
|
||||||
public static Color Goal => GoalGold;
|
public static Color Goal => GoalGold;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The canonical color used for MapAreaVolume gizmos. Muted cyan — distinct from any
|
||||||
|
/// player color and from goal gold. Intended to read as background context rather than
|
||||||
|
/// as a gameplay element.
|
||||||
|
/// </summary>
|
||||||
|
public static Color MapArea => MapAreaCyan;
|
||||||
|
|
||||||
/// <summary>Diagnostic color used when a volume has an invalid/missing owner.</summary>
|
/// <summary>Diagnostic color used when a volume has an invalid/missing owner.</summary>
|
||||||
public static Color Error => ErrorPink;
|
public static Color Error => ErrorPink;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ namespace TD.Levels.Editor
|
||||||
|
|
||||||
Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
|
Debug.Log($"[LevelBake] {outcome} — '{ctx.Authoring.mapName}' " +
|
||||||
$"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
|
$"({ctx.PlayerZoneVolumes.Count} player zones, {ctx.SpawnerVolumes.Count} spawners, " +
|
||||||
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals; " +
|
$"{ctx.LeakExitVolumes.Count} leak exits, {ctx.GoalVolumes.Count} goals, " +
|
||||||
|
$"{ctx.MapAreaVolumes.Count} map areas; " +
|
||||||
$"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");
|
$"grid {ctx.GridSize.x}×{ctx.GridSize.y}; {report.WarningCount} warnings)");
|
||||||
|
|
||||||
if (report.WarningCount > 0)
|
if (report.WarningCount > 0)
|
||||||
|
|
@ -124,16 +125,18 @@ namespace TD.Levels.Editor
|
||||||
var rootTransform = ctx.Authoring.transform;
|
var rootTransform = ctx.Authoring.transform;
|
||||||
|
|
||||||
// Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
|
// Scoped scan: only volumes parented under _LevelAuthoring's transform are part of the bake.
|
||||||
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
|
ctx.PlayerZoneVolumes = rootTransform.GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false).ToList();
|
||||||
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
|
ctx.SpawnerVolumes = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false).ToList();
|
||||||
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
|
ctx.LeakExitVolumes = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false).ToList();
|
||||||
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
|
ctx.GoalVolumes = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false).ToList();
|
||||||
|
ctx.MapAreaVolumes = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false).ToList();
|
||||||
|
|
||||||
ctx.AllVolumes = new List<VolumeBase>();
|
ctx.AllVolumes = new List<VolumeBase>();
|
||||||
ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
|
ctx.AllVolumes.AddRange(ctx.PlayerZoneVolumes);
|
||||||
ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
|
ctx.AllVolumes.AddRange(ctx.SpawnerVolumes);
|
||||||
ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
|
ctx.AllVolumes.AddRange(ctx.LeakExitVolumes);
|
||||||
ctx.AllVolumes.AddRange(ctx.GoalVolumes);
|
ctx.AllVolumes.AddRange(ctx.GoalVolumes);
|
||||||
|
ctx.AllVolumes.AddRange(ctx.MapAreaVolumes);
|
||||||
|
|
||||||
// Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
|
// Full-scene scan: catch volumes that exist but aren't parented under _LevelAuthoring.
|
||||||
var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
|
var orphanCandidates = UnityEngine.Object.FindObjectsByType<VolumeBase>(FindObjectsInactive.Exclude);
|
||||||
|
|
@ -149,9 +152,10 @@ namespace TD.Levels.Editor
|
||||||
// hash input ordering and stable iteration in subsequent phases.
|
// hash input ordering and stable iteration in subsequent phases.
|
||||||
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
ctx.AllVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
ctx.PlayerZoneVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
ctx.SpawnerVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
ctx.LeakExitVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
ctx.GoalVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
|
ctx.MapAreaVolumes.Sort((a, b) => string.CompareOrdinal(CanonicalPath(a, rootTransform), CanonicalPath(b, rootTransform)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds the canonical path string for a transform, relative to the root, with sibling
|
// Builds the canonical path string for a transform, relative to the root, with sibling
|
||||||
|
|
@ -214,6 +218,17 @@ namespace TD.Levels.Editor
|
||||||
report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
|
report.Error("P2-2", $"Found {ctx.GoalVolumes.Count} GoalVolume(s); LevelAuthoring expects {auth.expectedGoalCount}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2-22: at least one MapAreaVolume is present.
|
||||||
|
// The map area is required (not inferred) so that gameplay bounds stay decoupled from
|
||||||
|
// visual geometry. See "Lessons Learned" — inferring playable bounds from terrain
|
||||||
|
// mesh extent was deliberately rejected.
|
||||||
|
if (ctx.MapAreaVolumes.Count == 0)
|
||||||
|
{
|
||||||
|
report.Error("P2-22", "No MapAreaVolume found. Every map must have at least one " +
|
||||||
|
"MapAreaVolume defining the playable bounds (where the builder " +
|
||||||
|
"can move and the camera can pan).");
|
||||||
|
}
|
||||||
|
|
||||||
// P2-16: author non-empty (soft warning)
|
// P2-16: author non-empty (soft warning)
|
||||||
if (string.IsNullOrWhiteSpace(auth.author))
|
if (string.IsNullOrWhiteSpace(auth.author))
|
||||||
{
|
{
|
||||||
|
|
@ -403,6 +418,7 @@ namespace TD.Levels.Editor
|
||||||
ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
|
ctx.PerVolumeTiles = new Dictionary<VolumeBase, HashSet<Vector2Int>>();
|
||||||
ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
|
ctx.PerOwnerZoneTiles = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
|
||||||
ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
|
ctx.PerGoalTiles = new List<HashSet<Vector2Int>>();
|
||||||
|
ctx.MapAreaTiles = new HashSet<Vector2Int>();
|
||||||
|
|
||||||
bool initializedAggregate = false;
|
bool initializedAggregate = false;
|
||||||
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
|
int aggMinX = 0, aggMinY = 0, aggMaxX = 0, aggMaxY = 0;
|
||||||
|
|
@ -461,6 +477,14 @@ namespace TD.Levels.Editor
|
||||||
{
|
{
|
||||||
ctx.PerGoalTiles.Add(tiles);
|
ctx.PerGoalTiles.Add(tiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map area: union all MapAreaVolume tiles into a single set. The map area is the
|
||||||
|
// union of all MapAreaVolumes, so a tile is "in the map" if ANY MapAreaVolume
|
||||||
|
// covers it.
|
||||||
|
if (v is MapAreaVolume)
|
||||||
|
{
|
||||||
|
foreach (var t in tiles) ctx.MapAreaTiles.Add(t);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initializedAggregate)
|
if (!initializedAggregate)
|
||||||
|
|
@ -498,12 +522,27 @@ namespace TD.Levels.Editor
|
||||||
// 2D arrays during composition; flattened to 1D in Phase 6.
|
// 2D arrays during composition; flattened to 1D in Phase 6.
|
||||||
ctx.PlacementGrid2D = new PlacementState[width, height]; // defaults to Outside (0)
|
ctx.PlacementGrid2D = new PlacementState[width, height]; // defaults to Outside (0)
|
||||||
ctx.WalkabilityGrid2D = new bool[width, height]; // defaults to false
|
ctx.WalkabilityGrid2D = new bool[width, height]; // defaults to false
|
||||||
|
ctx.MapAreaGrid2D = new bool[width, height]; // defaults to false
|
||||||
|
|
||||||
|
// Map-area composition: a tile is "in map" iff any MapAreaVolume covers it. We have
|
||||||
|
// the union pre-computed in ctx.MapAreaTiles from Phase 3.
|
||||||
|
foreach (var t in ctx.MapAreaTiles)
|
||||||
|
{
|
||||||
|
int gx = t.x - ctx.GridOriginTile.x;
|
||||||
|
int gy = t.y - ctx.GridOriginTile.y;
|
||||||
|
ctx.MapAreaGrid2D[gx, gy] = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
|
// Volume-iterating algorithm: O(total tile-coverage) rather than O(grid * volumes).
|
||||||
foreach (var v in ctx.AllVolumes)
|
foreach (var v in ctx.AllVolumes)
|
||||||
{
|
{
|
||||||
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
|
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
|
||||||
|
|
||||||
|
// MapAreaVolume contributes ONLY to the map-area grid (handled above) — not to
|
||||||
|
// walkability or placement. Buffer tiles (in-map but not gameplay) must remain
|
||||||
|
// non-walkable so enemies cannot path through them.
|
||||||
|
if (v is MapAreaVolume) continue;
|
||||||
|
|
||||||
bool isInvalid = IsInvalidValidity(v);
|
bool isInvalid = IsInvalidValidity(v);
|
||||||
bool isPlayerZone = v is PlayerZoneVolume;
|
bool isPlayerZone = v is PlayerZoneVolume;
|
||||||
|
|
||||||
|
|
@ -536,10 +575,10 @@ namespace TD.Levels.Editor
|
||||||
switch (v)
|
switch (v)
|
||||||
{
|
{
|
||||||
case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
|
case PlayerZoneVolume pz: return pz.placementValidity == PlacementValidity.Invalid;
|
||||||
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
|
case SpawnerVolume sv: return sv.placementValidity == PlacementValidity.Invalid;
|
||||||
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
|
case LeakExitVolume lv: return lv.placementValidity == PlacementValidity.Invalid;
|
||||||
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
|
case GoalVolume gv: return gv.placementValidity == PlacementValidity.Invalid;
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -774,6 +813,45 @@ namespace TD.Levels.Editor
|
||||||
report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
|
report.Warning("P5-11", $"Walkability grid has {components} disconnected regions. " +
|
||||||
"Some areas of the map are unreachable from others.");
|
"Some areas of the map are unreachable from others.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P5-12: every gameplay volume's tiles must be a subset of the map area.
|
||||||
|
// Coverage rule from the design spec — gameplay can't poke outside the playable map.
|
||||||
|
// Skipped if no MapAreaVolume exists (P2-22 already errored; this would just spam).
|
||||||
|
if (ctx.MapAreaTiles.Count > 0)
|
||||||
|
{
|
||||||
|
ValidateGameplayContainedInMapArea(ctx, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P5-12: each gameplay volume (PlayerZone, Spawner, LeakExit, Goal) must have all its
|
||||||
|
// tiles inside ctx.MapAreaTiles. Reports one error per offending volume with the count of
|
||||||
|
// out-of-map tiles to avoid drowning the report in per-tile errors.
|
||||||
|
private static void ValidateGameplayContainedInMapArea(BakeContext ctx, BakeReport report)
|
||||||
|
{
|
||||||
|
foreach (var v in ctx.AllVolumes)
|
||||||
|
{
|
||||||
|
if (v is MapAreaVolume) continue; // The map area itself doesn't need to contain itself.
|
||||||
|
if (!ctx.PerVolumeTiles.TryGetValue(v, out var tiles)) continue;
|
||||||
|
|
||||||
|
int outsideCount = 0;
|
||||||
|
Vector2Int firstOutside = Vector2Int.zero;
|
||||||
|
foreach (var t in tiles)
|
||||||
|
{
|
||||||
|
if (!ctx.MapAreaTiles.Contains(t))
|
||||||
|
{
|
||||||
|
if (outsideCount == 0) firstOutside = t;
|
||||||
|
outsideCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outsideCount > 0)
|
||||||
|
{
|
||||||
|
report.Error("P5-12",
|
||||||
|
$"{v.GetType().Name} '{v.name}' has {outsideCount} tile(s) outside the map area " +
|
||||||
|
$"(first offender: {firstOutside}). Every gameplay volume must be fully contained " +
|
||||||
|
"within the union of MapAreaVolumes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
|
private static int CountWalkableComponents(HashSet<Vector2Int> walkable)
|
||||||
|
|
@ -830,6 +908,7 @@ namespace TD.Levels.Editor
|
||||||
data.PlacementGrid = new PlacementState[total];
|
data.PlacementGrid = new PlacementState[total];
|
||||||
data.WalkabilityGrid = new bool[total];
|
data.WalkabilityGrid = new bool[total];
|
||||||
data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
|
data.OwnerGrid = new PlayerSlot[total]; // defaults to PlayerSlot.None (=0)
|
||||||
|
data.MapAreaGrid = new bool[total]; // defaults to false
|
||||||
|
|
||||||
for (int x = 0; x < width; x++)
|
for (int x = 0; x < width; x++)
|
||||||
{
|
{
|
||||||
|
|
@ -838,6 +917,7 @@ namespace TD.Levels.Editor
|
||||||
int idx = y * width + x;
|
int idx = y * width + x;
|
||||||
data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
|
data.PlacementGrid[idx] = ctx.PlacementGrid2D[x, y];
|
||||||
data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
|
data.WalkabilityGrid[idx] = ctx.WalkabilityGrid2D[x, y];
|
||||||
|
data.MapAreaGrid[idx] = ctx.MapAreaGrid2D[x, y];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -984,26 +1064,26 @@ namespace TD.Levels.Editor
|
||||||
|
|
||||||
// -- Hash computation -------------------------------------------------
|
// -- Hash computation -------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the authoring hash for a LevelAuthoring's scene state,
|
/// Computes the authoring hash for a LevelAuthoring's scene state,
|
||||||
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
|
/// suitable for comparing against <see cref="LevelData.AuthoringHash"/>
|
||||||
/// to detect drift from baked data.
|
/// to detect drift from baked data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Delegates to the same hashing routine the bake itself uses, so equal
|
/// Delegates to the same hashing routine the bake itself uses, so equal
|
||||||
/// hashes mean the scene would bake to equivalent LevelData. Hashes
|
/// hashes mean the scene would bake to equivalent LevelData. Hashes
|
||||||
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
|
/// only Layer 1 authoring inputs (volumes + metadata); visual scene
|
||||||
/// content (terrain, lighting, decorations) is NOT included.
|
/// content (terrain, lighting, decorations) is NOT included.
|
||||||
///
|
///
|
||||||
/// Performs its own volume discovery and canonical sort. Does NOT run
|
/// Performs its own volume discovery and canonical sort. Does NOT run
|
||||||
/// pre-validation or rasterization, so it returns a hash even for scenes
|
/// pre-validation or rasterization, so it returns a hash even for scenes
|
||||||
/// that would fail the bake (e.g. missing zones, overlapping volumes).
|
/// that would fail the bake (e.g. missing zones, overlapping volumes).
|
||||||
/// That's intentional: the play-mode hook needs a hash even when the
|
/// That's intentional: the play-mode hook needs a hash even when the
|
||||||
/// scene is broken, so it can report "drift" as the actionable problem
|
/// scene is broken, so it can report "drift" as the actionable problem
|
||||||
/// rather than silently differing from the last successful bake.
|
/// rather than silently differing from the last successful bake.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string ComputeAuthoringHash(LevelAuthoring authoring)
|
public static string ComputeAuthoringHash(LevelAuthoring authoring)
|
||||||
{
|
{
|
||||||
if (authoring == null)
|
if (authoring == null)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
|
|
@ -1018,13 +1098,15 @@ namespace TD.Levels.Editor
|
||||||
var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
|
var spawners = rootTransform.GetComponentsInChildren<SpawnerVolume>(includeInactive: false);
|
||||||
var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
|
var leakExits = rootTransform.GetComponentsInChildren<LeakExitVolume>(includeInactive: false);
|
||||||
var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
|
var goals = rootTransform.GetComponentsInChildren<GoalVolume>(includeInactive: false);
|
||||||
|
var mapAreas = rootTransform.GetComponentsInChildren<MapAreaVolume>(includeInactive: false);
|
||||||
|
|
||||||
ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
|
ctx.AllVolumes = new System.Collections.Generic.List<VolumeBase>(
|
||||||
playerZones.Length + spawners.Length + leakExits.Length + goals.Length);
|
playerZones.Length + spawners.Length + leakExits.Length + goals.Length + mapAreas.Length);
|
||||||
ctx.AllVolumes.AddRange(playerZones);
|
ctx.AllVolumes.AddRange(playerZones);
|
||||||
ctx.AllVolumes.AddRange(spawners);
|
ctx.AllVolumes.AddRange(spawners);
|
||||||
ctx.AllVolumes.AddRange(leakExits);
|
ctx.AllVolumes.AddRange(leakExits);
|
||||||
ctx.AllVolumes.AddRange(goals);
|
ctx.AllVolumes.AddRange(goals);
|
||||||
|
ctx.AllVolumes.AddRange(mapAreas);
|
||||||
|
|
||||||
// Match Phase 1's canonical sort exactly. The hash routine re-orders
|
// Match Phase 1's canonical sort exactly. The hash routine re-orders
|
||||||
// by canonical path internally, but sorting AllVolumes here keeps
|
// by canonical path internally, but sorting AllVolumes here keeps
|
||||||
|
|
@ -1240,6 +1322,7 @@ namespace TD.Levels.Editor
|
||||||
target.PlacementGrid = src.PlacementGrid;
|
target.PlacementGrid = src.PlacementGrid;
|
||||||
target.WalkabilityGrid = src.WalkabilityGrid;
|
target.WalkabilityGrid = src.WalkabilityGrid;
|
||||||
target.OwnerGrid = src.OwnerGrid;
|
target.OwnerGrid = src.OwnerGrid;
|
||||||
|
target.MapAreaGrid = src.MapAreaGrid;
|
||||||
target.PlayerZones = src.PlayerZones;
|
target.PlayerZones = src.PlayerZones;
|
||||||
target.Goals = src.Goals;
|
target.Goals = src.Goals;
|
||||||
|
|
||||||
|
|
@ -1272,6 +1355,7 @@ namespace TD.Levels.Editor
|
||||||
public List<SpawnerVolume> SpawnerVolumes;
|
public List<SpawnerVolume> SpawnerVolumes;
|
||||||
public List<LeakExitVolume> LeakExitVolumes;
|
public List<LeakExitVolume> LeakExitVolumes;
|
||||||
public List<GoalVolume> GoalVolumes;
|
public List<GoalVolume> GoalVolumes;
|
||||||
|
public List<MapAreaVolume> MapAreaVolumes;
|
||||||
|
|
||||||
// Phase 2 outputs
|
// Phase 2 outputs
|
||||||
public HashSet<PlayerSlot> ExpectedOwners;
|
public HashSet<PlayerSlot> ExpectedOwners;
|
||||||
|
|
@ -1281,6 +1365,7 @@ namespace TD.Levels.Editor
|
||||||
public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
|
public Dictionary<VolumeBase, HashSet<Vector2Int>> PerVolumeTiles;
|
||||||
public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
|
public Dictionary<PlayerSlot, HashSet<Vector2Int>> PerOwnerZoneTiles;
|
||||||
public List<HashSet<Vector2Int>> PerGoalTiles;
|
public List<HashSet<Vector2Int>> PerGoalTiles;
|
||||||
|
public HashSet<Vector2Int> MapAreaTiles;
|
||||||
public Vector2Int MapMinTile;
|
public Vector2Int MapMinTile;
|
||||||
public Vector2Int MapMaxTile;
|
public Vector2Int MapMaxTile;
|
||||||
|
|
||||||
|
|
@ -1289,6 +1374,7 @@ namespace TD.Levels.Editor
|
||||||
public Vector2Int GridSize;
|
public Vector2Int GridSize;
|
||||||
public PlacementState[,] PlacementGrid2D;
|
public PlacementState[,] PlacementGrid2D;
|
||||||
public bool[,] WalkabilityGrid2D;
|
public bool[,] WalkabilityGrid2D;
|
||||||
|
public bool[,] MapAreaGrid2D;
|
||||||
|
|
||||||
// Phase 5 outputs
|
// Phase 5 outputs
|
||||||
public HashSet<PlayerSlot> GoalAdjacentZones;
|
public HashSet<PlayerSlot> GoalAdjacentZones;
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
using Unity.Netcode;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
namespace TD.Gameplay
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// GoldManager — canonical server-authoritative template for this project.
|
|
||||||
///
|
|
||||||
/// Every gameplay system (towers, enemies, waves, damage) should follow
|
|
||||||
/// the same three-beat pattern demonstrated here:
|
|
||||||
/// 1. State lives in NetworkVariables, which only the server can write.
|
|
||||||
/// 2. Clients REQUEST changes via [Rpc(SendTo.Server, ...)] methods.
|
|
||||||
/// They never change state directly.
|
|
||||||
/// 3. The server VALIDATES the request before applying it.
|
|
||||||
/// Never trust the client.
|
|
||||||
///
|
|
||||||
/// Cosmetic-only reactions (sounds, VFX, UI popups) can use
|
|
||||||
/// [Rpc(SendTo.ClientsAndHost)] or [Rpc(SendTo.NotServer)] to broadcast.
|
|
||||||
/// </summary>
|
|
||||||
public class GoldManager : NetworkBehaviour
|
|
||||||
{
|
|
||||||
// --- Tunables (editable in Inspector) -----------------------------
|
|
||||||
|
|
||||||
[Tooltip("How much gold every player starts with when the game begins.")]
|
|
||||||
[SerializeField] private int startingGold = 100;
|
|
||||||
|
|
||||||
// --- Networked state ----------------------------------------------
|
|
||||||
|
|
||||||
// A NetworkVariable<T> automatically syncs from server to clients.
|
|
||||||
// readPerm = Everyone: all clients can read the current value.
|
|
||||||
// writePerm = Server: only the server can change it.
|
|
||||||
private readonly NetworkVariable<int> currentGold = new NetworkVariable<int>(
|
|
||||||
value: 0,
|
|
||||||
readPerm: NetworkVariableReadPermission.Everyone,
|
|
||||||
writePerm: NetworkVariableWritePermission.Server
|
|
||||||
);
|
|
||||||
|
|
||||||
// Public read-only accessor for other scripts (UI, tower placement).
|
|
||||||
public int CurrentGold => currentGold.Value;
|
|
||||||
|
|
||||||
// --- Lifecycle ----------------------------------------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// OnNetworkSpawn runs on every peer (server + all clients) when this
|
|
||||||
/// NetworkBehaviour becomes active on the network. Replaces Start()
|
|
||||||
/// for networked setup.
|
|
||||||
/// </summary>
|
|
||||||
public override void OnNetworkSpawn()
|
|
||||||
{
|
|
||||||
Debug.Log($"[GoldManager] OnNetworkSpawn ran. IsServer={IsServer}, IsClient={IsClient}, IsHost={IsHost}");
|
|
||||||
|
|
||||||
currentGold.OnValueChanged += HandleGoldChanged;
|
|
||||||
Debug.Log($"[GoldManager] Subscribed to OnValueChanged. Current value before init: {currentGold.Value}");
|
|
||||||
|
|
||||||
if (IsServer)
|
|
||||||
{
|
|
||||||
currentGold.Value = startingGold;
|
|
||||||
Debug.Log($"[GoldManager] Server initialized gold. Current value after set: {currentGold.Value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnNetworkDespawn()
|
|
||||||
{
|
|
||||||
// Always unsubscribe to avoid callback leaks.
|
|
||||||
currentGold.OnValueChanged -= HandleGoldChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleGoldChanged(int previous, int current)
|
|
||||||
{
|
|
||||||
// Fires on every peer whenever the value syncs. Use Log here so
|
|
||||||
// you can see syncing in the Console during development.
|
|
||||||
Debug.Log($"[GoldManager] Gold changed: {previous} -> {current}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Public API (called by client-side code) ----------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Client-side entry point for spending gold. Called by gameplay code
|
|
||||||
/// like TowerPlacement when the local player clicks "build tower."
|
|
||||||
///
|
|
||||||
/// The actual spending happens on the server via the Rpc.
|
|
||||||
/// </summary>
|
|
||||||
public void RequestSpendGold(int amount)
|
|
||||||
{
|
|
||||||
SpendGoldRpc(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
|
||||||
/// Not Rpc-wrapped — this is called directly by server game logic in
|
|
||||||
/// response to server-authoritative events.
|
|
||||||
/// </summary>
|
|
||||||
public void AwardGold(int amount)
|
|
||||||
{
|
|
||||||
if (!IsServer)
|
|
||||||
{
|
|
||||||
Debug.LogError("[GoldManager] AwardGold called on a client! " +
|
|
||||||
"Only server code should call this directly.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (amount <= 0) return;
|
|
||||||
currentGold.Value += amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Server-side RPC ----------------------------------------------
|
|
||||||
|
|
||||||
// [Rpc(SendTo.Server, ...)] means: a client calls this locally, but
|
|
||||||
// NGO routes the call and executes the method on the server.
|
|
||||||
//
|
|
||||||
// RequireOwnership = false lets any client call it (correct for a
|
|
||||||
// shared GoldManager). For per-player NetworkObjects you'd usually
|
|
||||||
// leave the default ownership requirement in place.
|
|
||||||
//
|
|
||||||
// Naming convention: methods with [Rpc] attributes must end with "Rpc".
|
|
||||||
// The source generator relies on this suffix.
|
|
||||||
[Rpc(SendTo.Server, RequireOwnership = false)]
|
|
||||||
private void SpendGoldRpc(int amount, RpcParams rpcParams = default)
|
|
||||||
{
|
|
||||||
// This method body runs on the server only.
|
|
||||||
// Validate everything — do not trust the client.
|
|
||||||
|
|
||||||
// Validation 1: reject non-positive amounts. A negative amount
|
|
||||||
// would let a malicious client GAIN gold if we just subtracted.
|
|
||||||
if (amount <= 0)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"[GoldManager] Rejected spend of {amount} " +
|
|
||||||
$"from client {rpcParams.Receive.SenderClientId}: " +
|
|
||||||
$"amount must be positive.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation 2: can't spend more than current balance.
|
|
||||||
if (currentGold.Value < amount)
|
|
||||||
{
|
|
||||||
Debug.LogWarning($"[GoldManager] Rejected spend of {amount} " +
|
|
||||||
$"from client {rpcParams.Receive.SenderClientId}: " +
|
|
||||||
$"insufficient funds (have {currentGold.Value}).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server applies the change. NetworkVariable syncs to clients
|
|
||||||
// automatically at the next network tick.
|
|
||||||
currentGold.Value -= amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
fileFormatVersion: 2
|
|
||||||
guid: d44ebdd0b2fc4144c8f8a181a714b738
|
|
||||||
|
|
@ -264,6 +264,23 @@ namespace TD.Gameplay
|
||||||
return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y;
|
return x >= 0 && x < level.GridSize.x && y >= 0 && y < level.GridSize.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if <paramref name="tile"/> is part of the playable map area (inside any
|
||||||
|
/// MapAreaVolume at bake time). Returns false for out-of-bounds tiles and for in-bounds
|
||||||
|
/// "void" tiles outside the map area. This is the outermost gate — gameplay queries
|
||||||
|
/// (IsWalkable, GetPlacement, GetOwner) are only meaningful where IsInMap is true.
|
||||||
|
///
|
||||||
|
/// Use this for: builder movement clamp, camera pan clamp, minimap rendering bounds.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsInMap(Vector2Int tile)
|
||||||
|
{
|
||||||
|
if (!TryFlatIndex(tile, out int idx)) return false;
|
||||||
|
// Defensive: existing maps that haven't been re-baked since MapAreaGrid was added
|
||||||
|
// will have a null array. Treat that as "not in map" so callers don't false-positive.
|
||||||
|
if (level.MapAreaGrid == null || level.MapAreaGrid.Length == 0) return false;
|
||||||
|
return level.MapAreaGrid[idx];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if <paramref name="tile"/> is currently walkable. Returns
|
/// True if <paramref name="tile"/> is currently walkable. Returns
|
||||||
/// false for out-of-bounds tiles. Reflects the runtime walkability
|
/// false for out-of-bounds tiles. Reflects the runtime walkability
|
||||||
|
|
|
||||||
178
Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
Normal file
178
Assets/_Project/Scripts/Gameplay/PlayerGoldManager.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-player gold pool. One instance is spawned per connected client (via
|
||||||
|
/// <see cref="NetworkManager.PlayerPrefab"/>) and owned by that client.
|
||||||
|
///
|
||||||
|
/// Replaces the earlier singleton-style GoldManager. Same server-authoritative
|
||||||
|
/// pattern (NetworkVariable + server-validated Rpc), but every player has their
|
||||||
|
/// own pool instead of sharing one.
|
||||||
|
///
|
||||||
|
/// Three-beat pattern (unchanged from the original template):
|
||||||
|
/// 1. State lives in NetworkVariables (server-only writes).
|
||||||
|
/// 2. The owning client REQUESTS spends via a server-targeted Rpc.
|
||||||
|
/// 3. The server VALIDATES before applying.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Lookup ergonomics: each instance registers itself in a static dictionary
|
||||||
|
/// keyed by OwnerClientId during OnNetworkSpawn and removes itself during
|
||||||
|
/// OnNetworkDespawn. Server code that needs to award gold to a specific
|
||||||
|
/// player can call <see cref="GetForClient(ulong)"/>.
|
||||||
|
///
|
||||||
|
/// This component is expected to live on the Player Prefab assigned to
|
||||||
|
/// NetworkManager. NGO handles spawn-on-connect and ownership assignment
|
||||||
|
/// automatically.
|
||||||
|
/// </remarks>
|
||||||
|
public class PlayerGoldManager : NetworkBehaviour
|
||||||
|
{
|
||||||
|
// --- Static registry ---------------------------------------------
|
||||||
|
|
||||||
|
// Keyed by OwnerClientId. Populated on every peer when an instance
|
||||||
|
// spawns; the same client can be looked up on the server (for awards)
|
||||||
|
// and on clients (for UI). Kept private — access goes through GetForClient.
|
||||||
|
private static readonly Dictionary<ulong, PlayerGoldManager> s_byClientId
|
||||||
|
= new Dictionary<ulong, PlayerGoldManager>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the PlayerGoldManager owned by the given client, or null if
|
||||||
|
/// none is currently spawned. Safe to call on server or client.
|
||||||
|
/// </summary>
|
||||||
|
public static PlayerGoldManager GetForClient(ulong clientId)
|
||||||
|
{
|
||||||
|
s_byClientId.TryGetValue(clientId, out var manager);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience: the local client's own gold manager. Returns null on a
|
||||||
|
/// dedicated server or before the local player has spawned.
|
||||||
|
/// </summary>
|
||||||
|
public static PlayerGoldManager Local
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var nm = NetworkManager.Singleton;
|
||||||
|
if (nm == null || !nm.IsClient) return null;
|
||||||
|
return GetForClient(nm.LocalClientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tunables ----------------------------------------------------
|
||||||
|
|
||||||
|
[Tooltip("How much gold this player starts with when the match begins.")]
|
||||||
|
[SerializeField] private int startingGold = 100;
|
||||||
|
|
||||||
|
// --- Networked state ---------------------------------------------
|
||||||
|
|
||||||
|
// readPerm = Everyone so any client can read any other player's gold
|
||||||
|
// (needed for scoreboard / opponent UI). writePerm = Server keeps
|
||||||
|
// authority where it belongs.
|
||||||
|
private readonly NetworkVariable<int> currentGold = new NetworkVariable<int>(
|
||||||
|
value: 0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server
|
||||||
|
);
|
||||||
|
|
||||||
|
public int CurrentGold => currentGold.Value;
|
||||||
|
|
||||||
|
// --- Lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
public override void OnNetworkSpawn()
|
||||||
|
{
|
||||||
|
Debug.Log($"[PlayerGoldManager] OnNetworkSpawn. OwnerClientId={OwnerClientId}, " +
|
||||||
|
$"IsOwner={IsOwner}, IsServer={IsServer}");
|
||||||
|
|
||||||
|
currentGold.OnValueChanged += HandleGoldChanged;
|
||||||
|
s_byClientId[OwnerClientId] = this;
|
||||||
|
|
||||||
|
if (IsServer)
|
||||||
|
{
|
||||||
|
currentGold.Value = startingGold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNetworkDespawn()
|
||||||
|
{
|
||||||
|
currentGold.OnValueChanged -= HandleGoldChanged;
|
||||||
|
|
||||||
|
// Only remove if the entry still points to this instance — guards
|
||||||
|
// against the (unlikely) case where a new instance for the same
|
||||||
|
// client has already overwritten the slot.
|
||||||
|
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
|
||||||
|
{
|
||||||
|
s_byClientId.Remove(OwnerClientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleGoldChanged(int previous, int current)
|
||||||
|
{
|
||||||
|
Debug.Log($"[PlayerGoldManager] Client {OwnerClientId} gold: {previous} -> {current}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API --------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owning-client entry point for spending gold. Sends a server Rpc;
|
||||||
|
/// the server validates and applies. Calling this on a non-owning
|
||||||
|
/// client will be rejected by the Rpc's Owner permission.
|
||||||
|
/// </summary>
|
||||||
|
public void RequestSpendGold(int amount)
|
||||||
|
{
|
||||||
|
SpendGoldRpc(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||||
|
/// Direct call — not Rpc-wrapped — because awards always originate
|
||||||
|
/// from server-authoritative game events.
|
||||||
|
/// </summary>
|
||||||
|
public void AwardGold(int amount)
|
||||||
|
{
|
||||||
|
if (!IsServer)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PlayerGoldManager] AwardGold called on a client. " +
|
||||||
|
"Only server code should call this directly.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount <= 0) return;
|
||||||
|
currentGold.Value += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server-side Rpc ---------------------------------------------
|
||||||
|
|
||||||
|
// InvokePermission = Owner: only the client that owns this NetworkObject
|
||||||
|
// can invoke the Rpc. NGO will reject calls from anyone else, so a
|
||||||
|
// malicious client can't spend another player's gold.
|
||||||
|
//
|
||||||
|
// This replaces the old "RequireOwnership = false" + the implicit
|
||||||
|
// shared-pool semantics. The deprecation warning is gone.
|
||||||
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||||
|
private void SpendGoldRpc(int amount, RpcParams rpcParams = default)
|
||||||
|
{
|
||||||
|
// Validation 1: positive amount.
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " +
|
||||||
|
$"from client {rpcParams.Receive.SenderClientId}: " +
|
||||||
|
$"amount must be positive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation 2: sufficient funds.
|
||||||
|
if (currentGold.Value < amount)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[PlayerGoldManager] Rejected spend of {amount} " +
|
||||||
|
$"from client {rpcParams.Receive.SenderClientId}: " +
|
||||||
|
$"insufficient funds (have {currentGold.Value}).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGold.Value -= amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6b9796562d7cc274f832657f21a61cce
|
||||||
Loading…
Add table
Add a link
Reference in a new issue