Updating HUD, Gold Config, and finishing off Play flow for 9player map.
This commit is contained in:
parent
a7be12fa9b
commit
3dcc0e7edd
28 changed files with 2272 additions and 9601 deletions
|
|
@ -16,6 +16,5 @@ MonoBehaviour:
|
||||||
MaxHp: 100
|
MaxHp: 100
|
||||||
MoveSpeed: 3
|
MoveSpeed: 3
|
||||||
IsFlying: 0
|
IsFlying: 0
|
||||||
GoldReward: 10
|
|
||||||
LivesCost: 1
|
LivesCost: 1
|
||||||
EnemyPrefab: {fileID: 1455822126534880203, guid: 0854f339a1958d343a6cb16cd3f907ff, type: 3}
|
EnemyPrefab: {fileID: 1455822126534880203, guid: 0854f339a1958d343a6cb16cd3f907ff, type: 3}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@ MonoBehaviour:
|
||||||
m_Name: 02_RedSkeletonDefinition
|
m_Name: 02_RedSkeletonDefinition
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition
|
||||||
DisplayName: Reddy Boi
|
DisplayName: Reddy Boi
|
||||||
MaxHp: 500
|
MaxHp: 300
|
||||||
MoveSpeed: 3
|
MoveSpeed: 3
|
||||||
IsFlying: 0
|
IsFlying: 0
|
||||||
GoldReward: 15
|
|
||||||
LivesCost: 1
|
LivesCost: 1
|
||||||
EnemyPrefab: {fileID: 1455822126534880203, guid: 51ab567e92e18f34eb7848588e3c4479, type: 3}
|
EnemyPrefab: {fileID: 1455822126534880203, guid: 51ab567e92e18f34eb7848588e3c4479, type: 3}
|
||||||
|
|
|
||||||
24
Assets/_Project/Data/GoldConfig.asset
Normal file
24
Assets/_Project/Data/GoldConfig.asset
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
%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: dede7fe606207ab4a8b7624d0a710d9b, type: 3}
|
||||||
|
m_Name: GoldConfig
|
||||||
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.GoldConfig
|
||||||
|
StartingGold: 100
|
||||||
|
Waves:
|
||||||
|
- Wave: {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2}
|
||||||
|
GoldPerEnemy: 5
|
||||||
|
CompletionBonus: 20
|
||||||
|
NoLeaksBonus: 50
|
||||||
|
- Wave: {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2}
|
||||||
|
GoldPerEnemy: 7
|
||||||
|
CompletionBonus: 20
|
||||||
|
NoLeaksBonus: 50
|
||||||
8
Assets/_Project/Data/GoldConfig.asset.meta
Normal file
8
Assets/_Project/Data/GoldConfig.asset.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d5e01c919f14a1e4888ad494a159241b
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -32,5 +32,5 @@ MonoBehaviour:
|
||||||
DotDamagePerSecond: 0
|
DotDamagePerSecond: 0
|
||||||
EffectDuration: 0
|
EffectDuration: 0
|
||||||
ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3}
|
ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3}
|
||||||
ProjectileSpeed: 10
|
ProjectileSpeed: 15
|
||||||
UpgradePaths: []
|
UpgradePaths: []
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,5 @@ MonoBehaviour:
|
||||||
PrepTime: 10
|
PrepTime: 10
|
||||||
Entries:
|
Entries:
|
||||||
- EnemyType: {fileID: 11400000, guid: 4e85a539eac1ed64cbd972db4914ca3d, type: 2}
|
- EnemyType: {fileID: 11400000, guid: 4e85a539eac1ed64cbd972db4914ca3d, type: 2}
|
||||||
Count: 10
|
Count: 50
|
||||||
SpawnInterval: 0.5
|
SpawnInterval: 0.5
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0e47a49b9bdc21d0b40f3f39d9f16c74113d2d90de7ffc2ece86b2ea8b916d19
|
oid sha256:c784661082720f660c85baa52d926021079a93f59e3726198b934b58f7063b46
|
||||||
size 26196
|
size 27517
|
||||||
|
|
|
||||||
8
Assets/_Project/Scripts/Editor/Gameplay.meta
Normal file
8
Assets/_Project/Scripts/Editor/Gameplay.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9c677da561a359f4aa230d4913644f4e
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
112
Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs
Normal file
112
Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Gameplay;
|
||||||
|
|
||||||
|
namespace TD.Editor.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Custom property drawer for <see cref="WaveGoldEntry"/>. Renders the standard fields
|
||||||
|
/// followed by a read-only "Preview Total" line computed from the entry's values, so
|
||||||
|
/// designers can see at a glance how much a single player could earn from a wave.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The preview includes the kill-reward portion only when the entry's <c>Wave</c>
|
||||||
|
/// reference is assigned (it's needed to count enemies in that wave). When unset, the
|
||||||
|
/// preview shows just <c>CompletionBonus + NoLeaksBonus</c> and labels itself so the
|
||||||
|
/// designer knows to drop a WaveDefinition in for a complete number.
|
||||||
|
/// </remarks>
|
||||||
|
[CustomPropertyDrawer(typeof(WaveGoldEntry))]
|
||||||
|
public class WaveGoldEntryDrawer : PropertyDrawer
|
||||||
|
{
|
||||||
|
private const float PreviewLineExtraHeight = 4f;
|
||||||
|
|
||||||
|
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||||
|
{
|
||||||
|
// Sum the children + one extra line for the preview label. We render children
|
||||||
|
// ourselves rather than using EditorGUI.PropertyField default since we want a
|
||||||
|
// foldout with our custom preview tacked onto the end.
|
||||||
|
if (!property.isExpanded) return EditorGUIUtility.singleLineHeight;
|
||||||
|
|
||||||
|
float h = EditorGUIUtility.singleLineHeight; // foldout
|
||||||
|
h += SpacedLineHeight() * 4; // Wave + GoldPerEnemy + Completion + NoLeaks
|
||||||
|
h += SpacedLineHeight() + PreviewLineExtraHeight; // preview label
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||||
|
{
|
||||||
|
// Foldout header.
|
||||||
|
Rect headerRect = new Rect(position.x, position.y, position.width,
|
||||||
|
EditorGUIUtility.singleLineHeight);
|
||||||
|
property.isExpanded = EditorGUI.Foldout(headerRect, property.isExpanded, label,
|
||||||
|
toggleOnLabelClick: true);
|
||||||
|
if (!property.isExpanded) return;
|
||||||
|
|
||||||
|
float y = position.y + SpacedLineHeight();
|
||||||
|
float indent = 14f;
|
||||||
|
Rect Row()
|
||||||
|
{
|
||||||
|
Rect r = new Rect(position.x + indent, y, position.width - indent,
|
||||||
|
EditorGUIUtility.singleLineHeight);
|
||||||
|
y += SpacedLineHeight();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard property fields.
|
||||||
|
var waveProp = property.FindPropertyRelative("Wave");
|
||||||
|
var perEnemyProp = property.FindPropertyRelative("GoldPerEnemy");
|
||||||
|
var completionProp = property.FindPropertyRelative("CompletionBonus");
|
||||||
|
var noLeaksProp = property.FindPropertyRelative("NoLeaksBonus");
|
||||||
|
|
||||||
|
EditorGUI.PropertyField(Row(), waveProp);
|
||||||
|
EditorGUI.PropertyField(Row(), perEnemyProp);
|
||||||
|
EditorGUI.PropertyField(Row(), completionProp);
|
||||||
|
EditorGUI.PropertyField(Row(), noLeaksProp);
|
||||||
|
|
||||||
|
// Compute the preview total. We instantiate the same logic as the runtime
|
||||||
|
// property — read the serialized values, count enemies in the optional Wave,
|
||||||
|
// sum it all up. Done inline (rather than calling WaveGoldEntry.PreviewTotalGold
|
||||||
|
// directly) because SerializedProperty edits aren't applied to the target object
|
||||||
|
// until ApplyModifiedProperties runs at the end of the frame, so reading the
|
||||||
|
// backing class instance here would show stale data while the user is typing.
|
||||||
|
int perEnemy = perEnemyProp.intValue;
|
||||||
|
int completion = completionProp.intValue;
|
||||||
|
int noLeaks = noLeaksProp.intValue;
|
||||||
|
var waveAsset = waveProp.objectReferenceValue as WaveDefinition;
|
||||||
|
|
||||||
|
int enemyCount = 0;
|
||||||
|
if (waveAsset != null && waveAsset.Entries != null)
|
||||||
|
{
|
||||||
|
foreach (var e in waveAsset.Entries)
|
||||||
|
{
|
||||||
|
if (e.EnemyType != null && e.Count > 0)
|
||||||
|
enemyCount += e.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = perEnemy * enemyCount + completion + noLeaks;
|
||||||
|
|
||||||
|
// Preview line. Slightly dimmed style, italic, non-editable. Includes a
|
||||||
|
// breakdown so the designer can see where the number came from.
|
||||||
|
y += PreviewLineExtraHeight;
|
||||||
|
Rect previewRect = new Rect(position.x + indent, y, position.width - indent,
|
||||||
|
EditorGUIUtility.singleLineHeight);
|
||||||
|
|
||||||
|
string breakdown = waveAsset != null
|
||||||
|
? $"Preview Total: {total} g ({perEnemy} × {enemyCount} enemies " +
|
||||||
|
$"+ {completion} completion + {noLeaks} no-leak)"
|
||||||
|
: $"Preview Total: {completion + noLeaks} g (bonuses only — assign Wave " +
|
||||||
|
$"to include kill rewards)";
|
||||||
|
|
||||||
|
var prevStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic };
|
||||||
|
using (new EditorGUI.DisabledScope(true))
|
||||||
|
{
|
||||||
|
EditorGUI.LabelField(previewRect, breakdown, prevStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float SpacedLineHeight()
|
||||||
|
=> EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cc17000144b098340a210921594babca
|
||||||
|
|
@ -33,13 +33,13 @@ namespace TD.Gameplay
|
||||||
"blocked by tower colliders (handled in EnemyMovement).")]
|
"blocked by tower colliders (handled in EnemyMovement).")]
|
||||||
public bool IsFlying;
|
public bool IsFlying;
|
||||||
|
|
||||||
[Header("Rewards / Costs")]
|
[Header("Costs")]
|
||||||
[Tooltip("Gold awarded to the player whose tower lands the killing blow.")]
|
|
||||||
public int GoldReward = 10;
|
|
||||||
|
|
||||||
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
|
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
|
||||||
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
|
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
|
||||||
public int LivesCost = 1;
|
public int LivesCost = 1;
|
||||||
|
// GoldReward removed in favor of per-wave GoldPerEnemy in GoldConfig — every
|
||||||
|
// enemy in a given wave grants the same kill reward regardless of type. If
|
||||||
|
// per-type variation is needed later, add an optional override here.
|
||||||
|
|
||||||
[Header("Visuals")]
|
[Header("Visuals")]
|
||||||
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +
|
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,15 @@ namespace TD.Gameplay
|
||||||
// ----- Pre-spawn init (server-local) ----------------------------------
|
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||||
|
|
||||||
private float pendingMaxHp = 100f;
|
private float pendingMaxHp = 100f;
|
||||||
private int pendingGoldReward;
|
|
||||||
private int pendingLivesCost = 1;
|
private int pendingLivesCost = 1;
|
||||||
private bool pendingIsFlying;
|
private bool pendingIsFlying;
|
||||||
private bool hasPendingInit;
|
private bool hasPendingInit;
|
||||||
|
|
||||||
// ----- Server-local runtime state -------------------------------------
|
// ----- Server-local runtime state -------------------------------------
|
||||||
|
|
||||||
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
|
// Kill gold is no longer carried per-enemy — it comes from
|
||||||
public int GoldReward { get; private set; }
|
// GoldConfig.Waves[currentWaveIndex].GoldPerEnemy at the moment the kill
|
||||||
|
// is registered. See WaveManager.HandleEnemyKilled.
|
||||||
|
|
||||||
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
|
/// <summary>Lives deducted from the shared pool when this enemy reaches the goal.</summary>
|
||||||
public int LivesCost { get; private set; } = 1;
|
public int LivesCost { get; private set; } = 1;
|
||||||
|
|
@ -126,18 +126,16 @@ namespace TD.Gameplay
|
||||||
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
||||||
/// <c>TowerInstance.InitializeServer</c> pattern.
|
/// <c>TowerInstance.InitializeServer</c> pattern.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
|
public void InitializeServer(float maxHp, int livesCost, bool flying)
|
||||||
{
|
{
|
||||||
pendingMaxHp = maxHp;
|
pendingMaxHp = maxHp;
|
||||||
pendingGoldReward = goldReward;
|
|
||||||
pendingLivesCost = livesCost;
|
pendingLivesCost = livesCost;
|
||||||
pendingIsFlying = flying;
|
pendingIsFlying = flying;
|
||||||
hasPendingInit = true;
|
hasPendingInit = true;
|
||||||
|
|
||||||
// Cache locally on the server immediately — clients resolve via NV.
|
// Cache locally on the server immediately — clients resolve via NV.
|
||||||
MaxHp = maxHp;
|
MaxHp = maxHp;
|
||||||
GoldReward = goldReward;
|
LivesCost = livesCost;
|
||||||
LivesCost = livesCost;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- NGO lifecycle --------------------------------------------------
|
// ----- NGO lifecycle --------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
private float pendingMoveSpeed;
|
private float pendingMoveSpeed;
|
||||||
private Vector2Int pendingSpawnerTile;
|
private Vector2Int pendingSpawnerTile;
|
||||||
|
private PlayerSlot pendingOwnerSlot;
|
||||||
private bool hasPendingInit;
|
private bool hasPendingInit;
|
||||||
|
|
||||||
// ----- Server-local runtime state -------------------------------------
|
// ----- Server-local runtime state -------------------------------------
|
||||||
|
|
@ -59,6 +60,16 @@ namespace TD.Gameplay
|
||||||
private float moveSpeed;
|
private float moveSpeed;
|
||||||
private List<Vector2Int> remainingPath = new List<Vector2Int>();
|
private List<Vector2Int> remainingPath = new List<Vector2Int>();
|
||||||
private PlayerSlot currentZone = PlayerSlot.None;
|
private PlayerSlot currentZone = PlayerSlot.None;
|
||||||
|
// Zone the enemy was spawned in — i.e., which player "owns" this enemy as part
|
||||||
|
// of their wave. Used so OnZoneLeaked fires only when the enemy escapes that
|
||||||
|
// origin zone (not when it transits through any subsequent zone on its way to
|
||||||
|
// the goal). Without this, every zone crossing was counted as a leak; only
|
||||||
|
// the originating player should be credited a leak per the design.
|
||||||
|
private PlayerSlot originZone = PlayerSlot.None;
|
||||||
|
// Latches once the enemy has crossed its origin zone's leak volume, so we
|
||||||
|
// never double-count a leak if the enemy re-enters its origin (rare but
|
||||||
|
// possible if pathing is dynamic).
|
||||||
|
private bool hasLeakedOriginZone;
|
||||||
private EnemyStatus status;
|
private EnemyStatus status;
|
||||||
private bool hasReachedGoal;
|
private bool hasReachedGoal;
|
||||||
private bool wasStuck; // dedupes the "no path" warning
|
private bool wasStuck; // dedupes the "no path" warning
|
||||||
|
|
@ -66,10 +77,11 @@ namespace TD.Gameplay
|
||||||
// ----- Events ---------------------------------------------------------
|
// ----- Events ---------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired on the server when this enemy crosses from one player zone into another
|
/// Fired on the server EXACTLY ONCE per enemy, when it crosses out of its
|
||||||
/// (or from a neutral zone into a player zone). The argument is the zone being
|
/// origin zone (the zone it spawned in) into another zone. The argument is the
|
||||||
/// LEFT — the zone that should be debited a life.
|
/// origin zone — the player who "owns" this enemy and should be credited a
|
||||||
/// <c>WaveManager</c> subscribes to deduct from the correct player's life pool.
|
/// leak. Transit through subsequent zones does NOT fire this event.
|
||||||
|
/// <c>WaveManager</c> subscribes to increment the per-player leak counter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event System.Action<PlayerSlot> OnZoneLeaked;
|
public event System.Action<PlayerSlot> OnZoneLeaked;
|
||||||
|
|
||||||
|
|
@ -87,12 +99,18 @@ namespace TD.Gameplay
|
||||||
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and
|
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and
|
||||||
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
|
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
|
||||||
/// <see cref="EnemyDefinition.MoveSpeed"/>; <paramref name="spawnerTile"/> is
|
/// <see cref="EnemyDefinition.MoveSpeed"/>; <paramref name="spawnerTile"/> is
|
||||||
/// the tile the enemy spawns on (used as the A* start node).
|
/// the tile the enemy spawns on (used as the A* start node);
|
||||||
|
/// <paramref name="ownerSlot"/> identifies which player "owns" this enemy
|
||||||
|
/// for leak-attribution (the slot whose <c>PlayerZoneData</c> contained the
|
||||||
|
/// spawner). It is NOT derived from the spawner tile's owner because spawner
|
||||||
|
/// tiles sit inside <c>SpawnerVolume</c>, not <c>PlayerZoneVolume</c>, so
|
||||||
|
/// their owner-grid entry is <see cref="PlayerSlot.None"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void InitializeServer(float speed, Vector2Int spawnerTile)
|
public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot)
|
||||||
{
|
{
|
||||||
pendingMoveSpeed = speed;
|
pendingMoveSpeed = speed;
|
||||||
pendingSpawnerTile = spawnerTile;
|
pendingSpawnerTile = spawnerTile;
|
||||||
|
pendingOwnerSlot = ownerSlot;
|
||||||
hasPendingInit = true;
|
hasPendingInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +131,21 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
moveSpeed = pendingMoveSpeed;
|
moveSpeed = pendingMoveSpeed;
|
||||||
|
|
||||||
// Resolve starting zone from the spawner tile.
|
// Resolve starting zone from the spawner tile. This is what the enemy
|
||||||
|
// observes as "the zone I am currently in." For SpawnerVolume tiles that
|
||||||
|
// sit outside any PlayerZoneVolume (the common case) this is None — the
|
||||||
|
// enemy will transition to its owner's zone on the first waypoint inside
|
||||||
|
// that PlayerZoneVolume.
|
||||||
var loader = LevelLoader.Instance;
|
var loader = LevelLoader.Instance;
|
||||||
if (loader != null)
|
if (loader != null)
|
||||||
currentZone = loader.GetOwner(pendingSpawnerTile);
|
currentZone = loader.GetOwner(pendingSpawnerTile);
|
||||||
|
|
||||||
|
// Origin zone is the player who "owns" this enemy for leak attribution.
|
||||||
|
// WaveManager passes it in (zone.Owner) because the spawner tile's owner-
|
||||||
|
// grid entry is unreliable (typically None — see InitializeServer remarks).
|
||||||
|
originZone = pendingOwnerSlot;
|
||||||
|
hasLeakedOriginZone = false;
|
||||||
|
|
||||||
// Compute the initial path from the spawn tile to the nearest goal.
|
// Compute the initial path from the spawn tile to the nearest goal.
|
||||||
ComputeAndStorePath(pendingSpawnerTile);
|
ComputeAndStorePath(pendingSpawnerTile);
|
||||||
|
|
||||||
|
|
@ -194,9 +222,18 @@ namespace TD.Gameplay
|
||||||
PlayerSlot newZone = loader.GetOwner(tile);
|
PlayerSlot newZone = loader.GetOwner(tile);
|
||||||
if (newZone == currentZone) return;
|
if (newZone == currentZone) return;
|
||||||
|
|
||||||
// The enemy is leaving currentZone — debit that player's life pool.
|
// The enemy is crossing a zone boundary. Only fire OnZoneLeaked if it's
|
||||||
if (currentZone != PlayerSlot.None)
|
// the FIRST time this enemy escapes its ORIGIN zone — that's the
|
||||||
OnZoneLeaked?.Invoke(currentZone);
|
// "I failed to stop it in my own maze" event the leak counter tracks.
|
||||||
|
// Subsequent transit through other zones is just routing toward the goal
|
||||||
|
// and doesn't credit any player a leak.
|
||||||
|
if (!hasLeakedOriginZone
|
||||||
|
&& currentZone == originZone
|
||||||
|
&& currentZone != PlayerSlot.None)
|
||||||
|
{
|
||||||
|
hasLeakedOriginZone = true;
|
||||||
|
OnZoneLeaked?.Invoke(originZone);
|
||||||
|
}
|
||||||
|
|
||||||
currentZone = newZone;
|
currentZone = newZone;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
Assets/_Project/Scripts/Gameplay/GoldConfig.cs
Normal file
112
Assets/_Project/Scripts/Gameplay/GoldConfig.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Assets/_Project/Scripts/Gameplay/GoldConfig.cs
|
||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.Gameplay
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-wave gold rules nested under <see cref="GoldConfig"/>. Each entry maps to one
|
||||||
|
/// wave by array index in <see cref="GoldConfig.Waves"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Pairing with WaveDefinition.</b> The runtime pairing of gold entry to wave is by
|
||||||
|
/// array index — entry 0 applies to wave 1, etc. The <see cref="Wave"/> reference here
|
||||||
|
/// is OPTIONAL and exists purely so the inspector can compute and display the
|
||||||
|
/// <see cref="PreviewTotalGold"/> read-only value (which needs to know the wave's
|
||||||
|
/// total enemy count to multiply against <see cref="GoldPerEnemy"/>). Leaving it
|
||||||
|
/// unset just hides the kill-reward portion of the preview; runtime is unaffected.
|
||||||
|
/// </remarks>
|
||||||
|
[Serializable]
|
||||||
|
public class WaveGoldEntry
|
||||||
|
{
|
||||||
|
[Tooltip("Optional reference to the WaveDefinition this entry pairs with. " +
|
||||||
|
"Used ONLY for the inspector preview total — runtime pairing is by " +
|
||||||
|
"array index in GoldConfig.Waves. Drag the matching WaveDefinition " +
|
||||||
|
"here to see the live total computed under this entry.")]
|
||||||
|
public WaveDefinition Wave;
|
||||||
|
|
||||||
|
[Tooltip("Gold awarded to the killing player per enemy slain during this wave. " +
|
||||||
|
"Applies uniformly to every enemy type in the wave.")]
|
||||||
|
public int GoldPerEnemy = 10;
|
||||||
|
|
||||||
|
[Tooltip("Flat bonus gold awarded to every active player when this wave is fully " +
|
||||||
|
"cleared (all enemies dead or reached the goal).")]
|
||||||
|
public int CompletionBonus = 50;
|
||||||
|
|
||||||
|
[Tooltip("Extra bonus gold awarded to a player who cleared this wave without any " +
|
||||||
|
"enemy from their own zone escaping their leak volume. Stacks on top of " +
|
||||||
|
"CompletionBonus for the no-leak achievement.")]
|
||||||
|
public int NoLeaksBonus = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only preview of the maximum gold a single player could earn in this wave:
|
||||||
|
/// <c>GoldPerEnemy × totalEnemyCount + CompletionBonus + NoLeaksBonus</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns just <c>CompletionBonus + NoLeaksBonus</c> if <see cref="Wave"/> is null
|
||||||
|
/// (no enemy count to multiply against). Pure computation — safe at edit time and
|
||||||
|
/// at runtime. The custom inspector under <see cref="GoldConfig"/> renders this as
|
||||||
|
/// a non-editable label under the entry's fields.
|
||||||
|
/// </remarks>
|
||||||
|
public int PreviewTotalGold
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
int total = CompletionBonus + NoLeaksBonus;
|
||||||
|
if (Wave != null && Wave.Entries != null)
|
||||||
|
{
|
||||||
|
int enemies = 0;
|
||||||
|
foreach (var e in Wave.Entries)
|
||||||
|
{
|
||||||
|
if (e.EnemyType != null && e.Count > 0)
|
||||||
|
enemies += e.Count;
|
||||||
|
}
|
||||||
|
total += GoldPerEnemy * enemies;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for every gold-related tunable in the game.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Inspector layout.</b> StartingGold is at the top and applies to every player.
|
||||||
|
/// The Waves array follows, each element collapsible in the inspector with the
|
||||||
|
/// per-wave rules and a read-only preview total computed from the entry's data.
|
||||||
|
///
|
||||||
|
/// <b>Wiring.</b> Assign one GoldConfig asset to <c>WaveManager.goldConfig</c> in
|
||||||
|
/// the Match scene. WaveManager initializes per-player starting gold from
|
||||||
|
/// <see cref="StartingGold"/> at match start, uses <see cref="WaveGoldEntry.GoldPerEnemy"/>
|
||||||
|
/// to compute kill rewards, and awards <see cref="WaveGoldEntry.CompletionBonus"/> /
|
||||||
|
/// <see cref="WaveGoldEntry.NoLeaksBonus"/> when each wave clears.
|
||||||
|
///
|
||||||
|
/// <b>No per-enemy-type bounties.</b> Every enemy killed in wave N grants the same
|
||||||
|
/// <see cref="WaveGoldEntry.GoldPerEnemy"/> regardless of <see cref="EnemyDefinition"/>.
|
||||||
|
/// If per-type variation becomes a design need, extend WaveGoldEntry with a per-type
|
||||||
|
/// table; the current model intentionally favors simplicity.
|
||||||
|
/// </remarks>
|
||||||
|
[CreateAssetMenu(fileName = "GoldConfig", menuName = "TD/Gold Config", order = 2)]
|
||||||
|
public class GoldConfig : ScriptableObject
|
||||||
|
{
|
||||||
|
[Tooltip("Gold each player starts the match with. Same for every player.")]
|
||||||
|
public int StartingGold = 100;
|
||||||
|
|
||||||
|
[Tooltip("Per-wave gold rules. Element 0 = Wave 1. Match the order and length of " +
|
||||||
|
"WaveManager.waveDefinitions; extra entries are ignored, missing entries " +
|
||||||
|
"fall back to zero gold for that wave.")]
|
||||||
|
public WaveGoldEntry[] Waves;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the gold entry for the given 1-based wave number, or null if out of range.
|
||||||
|
/// </summary>
|
||||||
|
public WaveGoldEntry GetWaveEntry(int waveNumber)
|
||||||
|
{
|
||||||
|
if (Waves == null) return null;
|
||||||
|
int index = waveNumber - 1;
|
||||||
|
if (index < 0 || index >= Waves.Length) return null;
|
||||||
|
return Waves[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/Gameplay/GoldConfig.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/GoldConfig.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dede7fe606207ab4a8b7624d0a710d9b
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
|
// Assets/_Project/Scripts/Gameplay/LevelLoader.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
using TD.Levels;
|
using TD.Levels;
|
||||||
|
|
@ -379,6 +380,12 @@ namespace TD.Gameplay
|
||||||
/// <c>false</c>) and when a tower is sold/destroyed (pass <c>true</c>).
|
/// <c>false</c>) and when a tower is sold/destroyed (pass <c>true</c>).
|
||||||
/// No-ops silently for out-of-bounds tiles.
|
/// No-ops silently for out-of-bounds tiles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Fires <see cref="OnWalkabilityChanged"/> once if the tile actually changed.
|
||||||
|
/// For multi-tile stamps (a tower footprint), prefer
|
||||||
|
/// <see cref="SetWalkableBatch"/> to fire the event ONCE for the whole batch
|
||||||
|
/// instead of per-tile — every event triggers all enemy re-paths.
|
||||||
|
/// </remarks>
|
||||||
public void SetWalkable(Vector2Int tile, bool walkable)
|
public void SetWalkable(Vector2Int tile, bool walkable)
|
||||||
{
|
{
|
||||||
if (!TryFlatIndex(tile, out int idx)) return;
|
if (!TryFlatIndex(tile, out int idx)) return;
|
||||||
|
|
@ -387,6 +394,28 @@ namespace TD.Gameplay
|
||||||
OnWalkabilityChanged?.Invoke();
|
OnWalkabilityChanged?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batched variant of <see cref="SetWalkable"/>: updates every tile in
|
||||||
|
/// <paramref name="tiles"/> to <paramref name="walkable"/> and fires
|
||||||
|
/// <see cref="OnWalkabilityChanged"/> AT MOST ONCE for the entire batch
|
||||||
|
/// (only if at least one tile actually changed). Use this for tower footprint
|
||||||
|
/// stamps so a 2×2 placement triggers a single enemy re-path instead of four.
|
||||||
|
/// Out-of-bounds tiles in the list are skipped silently.
|
||||||
|
/// </summary>
|
||||||
|
public void SetWalkableBatch(IList<Vector2Int> tiles, bool walkable)
|
||||||
|
{
|
||||||
|
if (tiles == null) return;
|
||||||
|
bool anyChanged = false;
|
||||||
|
for (int i = 0; i < tiles.Count; i++)
|
||||||
|
{
|
||||||
|
if (!TryFlatIndex(tiles[i], out int idx)) continue;
|
||||||
|
if (runtimeWalkability[idx] == walkable) continue;
|
||||||
|
runtimeWalkability[idx] = walkable;
|
||||||
|
anyChanged = true;
|
||||||
|
}
|
||||||
|
if (anyChanged) OnWalkabilityChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the runtime occupancy of <paramref name="tile"/>. Called alongside
|
/// Sets the runtime occupancy of <paramref name="tile"/>. Called alongside
|
||||||
/// <see cref="SetWalkable"/> — always update both grids together so they
|
/// <see cref="SetWalkable"/> — always update both grids together so they
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,22 @@ namespace TD.Gameplay
|
||||||
return playerCount <= map.PlayerCount;
|
return playerCount <= map.PlayerCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if any registered map's <see cref="LevelData.SceneName"/> matches the given
|
||||||
|
/// scene name. Used by components on the persistent Player Prefab
|
||||||
|
/// (e.g. <c>PlayerBuilderSpawner</c>) to recognize "we just entered a match scene"
|
||||||
|
/// without hardcoding individual scene names. Empty/null inputs return false.
|
||||||
|
/// </summary>
|
||||||
|
public bool ContainsScene(string sceneName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sceneName)) return false;
|
||||||
|
for (int i = 0; i < validatedMaps.Count; i++)
|
||||||
|
{
|
||||||
|
if (validatedMaps[i].SceneName == sceneName) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Lifecycle --------------------------------------------------
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
// Validated subset of the inspector array; built once in Awake and never mutated.
|
// Validated subset of the inspector array; built once in Awake and never mutated.
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,17 @@ namespace TD.Gameplay
|
||||||
// Built once on Start from LevelData.Goals[].TileArea.
|
// Built once on Start from LevelData.Goals[].TileArea.
|
||||||
private HashSet<Vector2Int> goalTiles;
|
private HashSet<Vector2Int> goalTiles;
|
||||||
|
|
||||||
|
// Precomputed octile-distance-to-nearest-goal, indexed by [y * gridWidth + x] in
|
||||||
|
// grid-local space (subtract GridOriginTile to convert world-tile → array index).
|
||||||
|
// Computed ONCE on Start. Without this, the A* heuristic scanned every goal tile
|
||||||
|
// (~48 tiles for the 9-player map) on every node visit — making the heuristic
|
||||||
|
// O(node visits * goal count) per A* run. With this table, it's O(1) per node.
|
||||||
|
// Tiles outside the grid use Heuristic's fallback path (per-goal scan); they are
|
||||||
|
// rare so the table isn't extended to cover them.
|
||||||
|
private float[] heuristicTable;
|
||||||
|
private Vector2Int heuristicOrigin;
|
||||||
|
private Vector2Int heuristicSize;
|
||||||
|
|
||||||
// A* scratch collections — allocated once and cleared per run to avoid GC.
|
// A* scratch collections — allocated once and cleared per run to avoid GC.
|
||||||
// PathfindingService is a singleton, so single-instance scratch is safe.
|
// PathfindingService is a singleton, so single-instance scratch is safe.
|
||||||
// gScore is float to support diagonal cost √2; the priority queue matches.
|
// gScore is float to support diagonal cost √2; the priority queue matches.
|
||||||
|
|
@ -85,6 +96,7 @@ namespace TD.Gameplay
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
BuildGoalTileSet();
|
BuildGoalTileSet();
|
||||||
|
BuildHeuristicTable();
|
||||||
|
|
||||||
var loader = LevelLoader.Instance;
|
var loader = LevelLoader.Instance;
|
||||||
if (loader != null)
|
if (loader != null)
|
||||||
|
|
@ -230,9 +242,24 @@ namespace TD.Gameplay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Octile distance to the nearest goal tile. Admissible heuristic for an
|
// Octile distance to the nearest goal tile. Admissible heuristic for an
|
||||||
// 8-connected uniform-cost grid (cardinal 1, diagonal √2).
|
// 8-connected uniform-cost grid (cardinal 1, diagonal √2). Hot path: served
|
||||||
|
// from heuristicTable (O(1)) when the tile is in the grid bounds, otherwise
|
||||||
|
// falls back to scanning every goal (O(goalCount)).
|
||||||
private float Heuristic(Vector2Int tile)
|
private float Heuristic(Vector2Int tile)
|
||||||
{
|
{
|
||||||
|
if (heuristicTable != null)
|
||||||
|
{
|
||||||
|
int x = tile.x - heuristicOrigin.x;
|
||||||
|
int y = tile.y - heuristicOrigin.y;
|
||||||
|
if (x >= 0 && x < heuristicSize.x && y >= 0 && y < heuristicSize.y)
|
||||||
|
{
|
||||||
|
return heuristicTable[y * heuristicSize.x + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-grid fallback. A* shouldn't usually visit these (it expands only
|
||||||
|
// from walkable tiles, which are in-bounds), but we keep correctness for
|
||||||
|
// any callers that hand us a stray tile.
|
||||||
float best = float.MaxValue;
|
float best = float.MaxValue;
|
||||||
foreach (var goal in goalTiles)
|
foreach (var goal in goalTiles)
|
||||||
{
|
{
|
||||||
|
|
@ -380,6 +407,53 @@ namespace TD.Gameplay
|
||||||
Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles.");
|
Debug.Log($"[PathfindingService] Goal tile set built: {goalTiles.Count} tiles.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precomputes the octile-distance-to-nearest-goal for every tile in the grid.
|
||||||
|
// Runs once on Start (cheap: ~3000 tiles × ~50 goals = 150K octile evaluations on
|
||||||
|
// the 9-player map, well under a millisecond). The output is a flat float[] keyed
|
||||||
|
// by grid-local index, hot for the A* heuristic.
|
||||||
|
private void BuildHeuristicTable()
|
||||||
|
{
|
||||||
|
var loader = LevelLoader.Instance;
|
||||||
|
if (loader == null || loader.LevelData == null)
|
||||||
|
{
|
||||||
|
heuristicTable = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelData = loader.LevelData;
|
||||||
|
heuristicOrigin = levelData.GridOriginTile;
|
||||||
|
heuristicSize = levelData.GridSize;
|
||||||
|
|
||||||
|
int total = heuristicSize.x * heuristicSize.y;
|
||||||
|
if (total <= 0 || goalTiles == null || goalTiles.Count == 0)
|
||||||
|
{
|
||||||
|
heuristicTable = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
heuristicTable = new float[total];
|
||||||
|
for (int y = 0; y < heuristicSize.y; y++)
|
||||||
|
{
|
||||||
|
for (int x = 0; x < heuristicSize.x; x++)
|
||||||
|
{
|
||||||
|
Vector2Int worldTile = new Vector2Int(
|
||||||
|
heuristicOrigin.x + x,
|
||||||
|
heuristicOrigin.y + y);
|
||||||
|
|
||||||
|
float best = float.MaxValue;
|
||||||
|
foreach (var goal in goalTiles)
|
||||||
|
{
|
||||||
|
float d = GridCoordinates.OctileDistance(worldTile, goal);
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
heuristicTable[y * heuristicSize.x + x] = best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log($"[PathfindingService] Heuristic table built: {total} tiles, " +
|
||||||
|
$"origin={heuristicOrigin}, size={heuristicSize}.");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleWalkabilityChanged()
|
private void HandleWalkabilityChanged()
|
||||||
{
|
{
|
||||||
OnPathsInvalidated?.Invoke();
|
OnPathsInvalidated?.Invoke();
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,22 @@ namespace TD.Gameplay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge case: the player connected while a match is already in progress.
|
// Edge case: the player connected while a match is already in progress.
|
||||||
// The Match scene is already loaded, so OnLoadEventCompleted won't fire
|
// The match scene is already loaded, so OnLoadEventCompleted won't fire
|
||||||
// again until the next transition. Spawn now.
|
// again until the next transition. Spawn now.
|
||||||
if (SceneManager.GetActiveScene().name == SceneNames.Match)
|
if (IsMatchScene(SceneManager.GetActiveScene().name))
|
||||||
TrySpawnBuilder();
|
TrySpawnBuilder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NGO fires this on the server once a scene load is acknowledged complete
|
// NGO fires this on the server once a scene load is acknowledged complete
|
||||||
// by every connected client (or timed out). We only act when the Match
|
// by every connected client (or timed out). We only act when a registered
|
||||||
// scene loads; Lobby / MainMenu loads are no-ops here.
|
// match scene loads; Lobby / MainMenu loads are no-ops here.
|
||||||
private void HandleSceneLoadCompleted(string sceneName,
|
private void HandleSceneLoadCompleted(string sceneName,
|
||||||
LoadSceneMode loadSceneMode,
|
LoadSceneMode loadSceneMode,
|
||||||
List<ulong> clientsCompleted,
|
List<ulong> clientsCompleted,
|
||||||
List<ulong> clientsTimedOut)
|
List<ulong> clientsTimedOut)
|
||||||
{
|
{
|
||||||
if (!IsServer) return;
|
if (!IsServer) return;
|
||||||
if (sceneName != SceneNames.Match) return;
|
if (!IsMatchScene(sceneName)) return;
|
||||||
TrySpawnBuilder();
|
TrySpawnBuilder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,9 +115,9 @@ namespace TD.Gameplay
|
||||||
var pms = GetComponent<PlayerMatchState>();
|
var pms = GetComponent<PlayerMatchState>();
|
||||||
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
|
if (pms != null) pms.SlotReady -= OnOwnerSlotReady;
|
||||||
|
|
||||||
// Only spawn if we're in the Match scene. SlotReady can fire in MainMenu
|
// Only spawn if we're in a match scene. SlotReady can fire in MainMenu
|
||||||
// (during initial connection) — we don't want a builder there.
|
// (during initial connection) — we don't want a builder there.
|
||||||
if (SceneManager.GetActiveScene().name == SceneNames.Match)
|
if (IsMatchScene(SceneManager.GetActiveScene().name))
|
||||||
SpawnBuilderForOwner(slot);
|
SpawnBuilderForOwner(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,6 +185,21 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// ----- Helpers ----------------------------------------------------
|
// ----- Helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
// True if the named scene is a registered match map. Source of truth is
|
||||||
|
// MapRegistry — any scene whose LevelData is in the registry counts. Falls back
|
||||||
|
// to comparing against the legacy hardcoded SceneNames.Match when the registry
|
||||||
|
// is missing (editor standalone-scene testing, etc.), so the original 2-player
|
||||||
|
// workflow keeps working.
|
||||||
|
private static bool IsMatchScene(string sceneName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sceneName)) return false;
|
||||||
|
var registry = MapRegistry.Instance;
|
||||||
|
if (registry != null && registry.ContainsScene(sceneName)) return true;
|
||||||
|
// Fallback for editor testing without MainMenu (no MapRegistry persistent
|
||||||
|
// instance). Keeps the old behavior intact.
|
||||||
|
return sceneName == SceneNames.Match;
|
||||||
|
}
|
||||||
|
|
||||||
// Picks the builder prefab to spawn for this player. Race-specific takes
|
// Picks the builder prefab to spawn for this player. Race-specific takes
|
||||||
// priority when (a) RaceRegistry is in the scene, (b) the player picked
|
// priority when (a) RaceRegistry is in the scene, (b) the player picked
|
||||||
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.
|
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,10 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// --- Tunables ----------------------------------------------------
|
// --- Tunables ----------------------------------------------------
|
||||||
|
|
||||||
[Tooltip("How much gold this player starts with when the match begins.")]
|
[Tooltip("Fallback starting gold used only when no GoldConfig is reachable at " +
|
||||||
|
"match start (e.g. editor testing in the Match scene without the lobby " +
|
||||||
|
"flow). Normally WaveManager overwrites this with GoldConfig.StartingGold " +
|
||||||
|
"during InitAfterSpawn.")]
|
||||||
[SerializeField] private int startingGold = 100;
|
[SerializeField] private int startingGold = 100;
|
||||||
|
|
||||||
// --- Networked state ---------------------------------------------
|
// --- Networked state ---------------------------------------------
|
||||||
|
|
@ -77,7 +80,19 @@ namespace TD.Gameplay
|
||||||
writePerm: NetworkVariableWritePermission.Server
|
writePerm: NetworkVariableWritePermission.Server
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Gold this player has accumulated since the start of the current wave. Reset to
|
||||||
|
// 0 at the start of each new wave by WaveManager. Includes kill rewards,
|
||||||
|
// completion bonus, and no-leak bonus — anything that flows through AwardGold.
|
||||||
|
// Spending does NOT decrement it; it's a "gold earned", not "gold held", counter.
|
||||||
|
// The HUD's top-bar "+N g/wave" reads this for the LOCAL player.
|
||||||
|
private readonly NetworkVariable<int> goldEarnedThisWave = new NetworkVariable<int>(
|
||||||
|
value: 0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server
|
||||||
|
);
|
||||||
|
|
||||||
public int CurrentGold => currentGold.Value;
|
public int CurrentGold => currentGold.Value;
|
||||||
|
public int GoldEarnedThisWave => goldEarnedThisWave.Value;
|
||||||
|
|
||||||
// --- Lifecycle ---------------------------------------------------
|
// --- Lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -128,7 +143,9 @@ namespace TD.Gameplay
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||||
/// Direct call — not Rpc-wrapped — because awards always originate
|
/// Direct call — not Rpc-wrapped — because awards always originate
|
||||||
/// from server-authoritative game events.
|
/// from server-authoritative game events. Also increments
|
||||||
|
/// <see cref="GoldEarnedThisWave"/> so the HUD's per-wave counter reflects
|
||||||
|
/// it; spending does not decrement that counter (it tracks earnings, not balance).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void AwardGold(int amount)
|
public void AwardGold(int amount)
|
||||||
{
|
{
|
||||||
|
|
@ -141,6 +158,39 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
if (amount <= 0) return;
|
if (amount <= 0) return;
|
||||||
currentGold.Value += amount;
|
currentGold.Value += amount;
|
||||||
|
goldEarnedThisWave.Value += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side: overwrites the player's current gold to the exact specified
|
||||||
|
/// amount. Used by <c>WaveManager</c> at match start to apply
|
||||||
|
/// <see cref="GoldConfig.StartingGold"/>, replacing the inspector-default value
|
||||||
|
/// set during initial spawn. Does NOT touch <see cref="GoldEarnedThisWave"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void ServerSetGold(int amount)
|
||||||
|
{
|
||||||
|
if (!IsServer)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PlayerGoldManager] ServerSetGold called on a client. " +
|
||||||
|
"Only server code should call this directly.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentGold.Value = Mathf.Max(0, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side: resets <see cref="GoldEarnedThisWave"/> back to 0. Called by
|
||||||
|
/// <c>WaveManager</c> at the start of each new wave so the HUD's top-bar
|
||||||
|
/// per-wave counter starts fresh.
|
||||||
|
/// </summary>
|
||||||
|
public void ServerResetWaveEarnings()
|
||||||
|
{
|
||||||
|
if (!IsServer)
|
||||||
|
{
|
||||||
|
Debug.LogError("[PlayerGoldManager] ServerResetWaveEarnings called on a client.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goldEarnedThisWave.Value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ namespace TD.Gameplay
|
||||||
private readonly Queue<Vector2Int> bfsQueue = new Queue<Vector2Int>();
|
private readonly Queue<Vector2Int> bfsQueue = new Queue<Vector2Int>();
|
||||||
private readonly HashSet<Vector2Int> bfsVisited = new HashSet<Vector2Int>();
|
private readonly HashSet<Vector2Int> bfsVisited = new HashSet<Vector2Int>();
|
||||||
|
|
||||||
|
// Scratch set for "tiles that should be treated as blocked for this BFS run only"
|
||||||
|
// — populated by queue-time path-validity checks with the candidate tower's footprint
|
||||||
|
// tiles. Avoids the stamp-then-restore pattern (which fired walkability-change events
|
||||||
|
// on tiles whose net state didn't change, causing a cascade of enemy re-paths).
|
||||||
|
private readonly HashSet<Vector2Int> virtualBlockedScratch = new HashSet<Vector2Int>();
|
||||||
|
|
||||||
// ----- Lifecycle --------------------------------------------------
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
public override void OnNetworkSpawn()
|
public override void OnNetworkSpawn()
|
||||||
|
|
@ -278,23 +284,26 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Check 6: Path validity (queue-time)
|
// Check 6: Path validity (queue-time)
|
||||||
// Temporarily stamp the footprint non-walkable, run BFS per spawner
|
// Virtually treat the footprint as non-walkable and run BFS per spawner
|
||||||
// in the placing player's zone, then un-stamp if any spawner loses
|
// in the placing player's zone. We do NOT modify the grid here — the
|
||||||
// its exit route. Importantly we do NOT stamp other queued (but not
|
// BFS just consults a "virtually blocked" tile set in addition to
|
||||||
// yet constructing) jobs as non-walkable — queued ghosts represent
|
// IsWalkable. Importantly we do NOT block other queued (but not yet
|
||||||
// intent only and don't block enemies. The check is "could THIS
|
// constructing) jobs — queued ghosts represent intent only and don't
|
||||||
// tower be built right now if it were instantly complete?" — a
|
// block enemies. The check is "could THIS tower be built right now if
|
||||||
// coarse test that catches obvious blockers at queue-time. The
|
// it were instantly complete?" — a coarse test that catches obvious
|
||||||
// construction-start re-check (in Builder.DriveHead_Queued) catches
|
// blockers at queue-time. The construction-start re-check (in
|
||||||
// cases where the maze changed since queue-time.
|
// Builder.DriveHead_Queued) catches cases where the maze changed since
|
||||||
|
// queue-time.
|
||||||
|
//
|
||||||
|
// Why virtual instead of stamp-and-restore: every real walkability
|
||||||
|
// flip fires OnWalkabilityChanged which triggers all enemies to A*.
|
||||||
|
// Stamp-and-restore (no net change) would fire those events twice for
|
||||||
|
// no reason. The virtual approach has zero side-effects.
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
StampWalkable(loader, footprint, walkable: false);
|
virtualBlockedScratch.Clear();
|
||||||
|
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
|
||||||
|
|
||||||
bool pathValid = CheckPathValidity(loader, placingSlot);
|
bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);
|
||||||
|
|
||||||
// Restore walkability — the queue stage leaves tiles walkable.
|
|
||||||
// Occupancy is stamped below as part of the commit.
|
|
||||||
StampWalkable(loader, footprint, walkable: true);
|
|
||||||
|
|
||||||
if (!pathValid)
|
if (!pathValid)
|
||||||
{
|
{
|
||||||
|
|
@ -348,18 +357,22 @@ namespace TD.Gameplay
|
||||||
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
|
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
|
||||||
footprint.Add(tile);
|
footprint.Add(tile);
|
||||||
|
|
||||||
StampWalkable(loader, footprint, walkable: false);
|
// Virtual check first — no grid mutation, no walkability events fire while
|
||||||
|
// we're just asking "would this break the maze?". Only if the check passes
|
||||||
|
// do we stamp the footprint for real, which fires exactly one batched event.
|
||||||
|
virtualBlockedScratch.Clear();
|
||||||
|
foreach (var tile in footprint) virtualBlockedScratch.Add(tile);
|
||||||
|
|
||||||
bool ok = CheckPathValidity(loader, placingSlot);
|
if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
|
||||||
|
|
||||||
if (!ok)
|
|
||||||
{
|
{
|
||||||
// Roll back — the maze would break. Caller refunds and drops the job.
|
// Maze would break. Caller refunds and drops the job. Grid untouched,
|
||||||
StampWalkable(loader, footprint, walkable: true);
|
// no events fired.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footprint is now occupied (still) and non-walkable. Construction proceeds.
|
// Commit: stamp the footprint non-walkable. Single batched event fires
|
||||||
|
// OnWalkabilityChanged once for the whole footprint, regardless of size.
|
||||||
|
StampWalkable(loader, footprint, walkable: false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,7 +420,8 @@ namespace TD.Gameplay
|
||||||
/// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
|
/// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
|
||||||
/// collections (cleared between BFS runs) to avoid GC allocation per call.
|
/// collections (cleared between BFS runs) to avoid GC allocation per call.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot)
|
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot,
|
||||||
|
HashSet<Vector2Int> virtualBlocked = null)
|
||||||
{
|
{
|
||||||
var levelData = loader.LevelData;
|
var levelData = loader.LevelData;
|
||||||
|
|
||||||
|
|
@ -432,10 +446,12 @@ namespace TD.Gameplay
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BFS per spawner: each spawner's tile area is the BFS seed set.
|
// BFS per spawner: each spawner's tile area is the BFS seed set. The optional
|
||||||
|
// virtualBlocked set lets queue-time checks treat the candidate footprint as
|
||||||
|
// non-walkable WITHOUT modifying the grid (avoiding spurious walkability events).
|
||||||
foreach (var spawner in zoneData.Spawners)
|
foreach (var spawner in zoneData.Spawners)
|
||||||
{
|
{
|
||||||
if (!SpawnerCanReachExit(loader, spawner, exitTiles))
|
if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,11 +490,20 @@ namespace TD.Gameplay
|
||||||
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
|
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
|
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
|
||||||
HashSet<Vector2Int> exitTiles)
|
HashSet<Vector2Int> exitTiles,
|
||||||
|
HashSet<Vector2Int> virtualBlocked = null)
|
||||||
{
|
{
|
||||||
bfsQueue.Clear();
|
bfsQueue.Clear();
|
||||||
bfsVisited.Clear();
|
bfsVisited.Clear();
|
||||||
|
|
||||||
|
// Local walkability check that honors the virtual-blocked override. Hot-path
|
||||||
|
// helper so we don't duplicate the conditional inside every neighbor test.
|
||||||
|
bool IsTileOpen(Vector2Int t)
|
||||||
|
{
|
||||||
|
if (virtualBlocked != null && virtualBlocked.Contains(t)) return false;
|
||||||
|
return loader.IsWalkable(t);
|
||||||
|
}
|
||||||
|
|
||||||
// Seed the BFS with the spawner's full tile area (not just its center tile),
|
// Seed the BFS with the spawner's full tile area (not just its center tile),
|
||||||
// matching bake-time P5-4 exactly.
|
// matching bake-time P5-4 exactly.
|
||||||
foreach (var tile in spawner.TileArea)
|
foreach (var tile in spawner.TileArea)
|
||||||
|
|
@ -503,13 +528,13 @@ namespace TD.Gameplay
|
||||||
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||||
{
|
{
|
||||||
if (bfsVisited.Contains(neighbor)) continue;
|
if (bfsVisited.Contains(neighbor)) continue;
|
||||||
if (!loader.IsWalkable(neighbor)) continue;
|
if (!IsTileOpen(neighbor)) continue;
|
||||||
|
|
||||||
if (GridCoordinates.IsDiagonal(current, neighbor))
|
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||||
{
|
{
|
||||||
GridCoordinates.GetCornerShoulders(current, neighbor,
|
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||||
out var shoulderA, out var shoulderB);
|
out var shoulderA, out var shoulderB);
|
||||||
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
if (!IsTileOpen(shoulderA) || !IsTileOpen(shoulderB))
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,8 +556,11 @@ namespace TD.Gameplay
|
||||||
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
|
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
|
||||||
bool walkable)
|
bool walkable)
|
||||||
{
|
{
|
||||||
foreach (var tile in footprint)
|
// Batched: fires OnWalkabilityChanged at most once for the whole footprint,
|
||||||
loader.SetWalkable(tile, walkable);
|
// instead of once per tile. Without this, a 2×2 placement fires 4 enemy
|
||||||
|
// re-paths instead of 1; a 3×3 fires 9. The cascade was the dominant
|
||||||
|
// contributor to placement stutter on larger maps.
|
||||||
|
loader.SetWalkableBatch(footprint, walkable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,40 @@ namespace TD.Gameplay
|
||||||
[Tooltip("Shared lives pool at the start of a match.")]
|
[Tooltip("Shared lives pool at the start of a match.")]
|
||||||
[SerializeField] private int startingLives = 20;
|
[SerializeField] private int startingLives = 20;
|
||||||
|
|
||||||
|
[Tooltip("Single source of truth for every gold tunable: starting gold, per-wave " +
|
||||||
|
"kill rewards, completion bonus, no-leak bonus. Required for a real match; " +
|
||||||
|
"if unset the game falls back to per-player startingGold defaults and grants " +
|
||||||
|
"no kill/wave rewards (designer-error indicator, not a supported runtime mode).")]
|
||||||
|
[SerializeField] private GoldConfig goldConfig;
|
||||||
|
|
||||||
// ----- Networked state --------------------------------------------
|
// ----- Networked state --------------------------------------------
|
||||||
|
|
||||||
// Per-slot zone-leak counters. Index = (int)PlayerSlot; size = 10 (0-9).
|
// Per-slot total leak counters across the whole match. Index = (int)PlayerSlot;
|
||||||
// Index 0 (PlayerSlot.None) is allocated but never written.
|
// size = 10 (0-9). Index 0 (PlayerSlot.None) is allocated but never written.
|
||||||
// Replicated so the HUD can show per-player leak scores on all peers.
|
// Replicated so the HUD scoreboard can show total leaks per player.
|
||||||
|
//
|
||||||
|
// Semantics: zoneLeakCounts[P] is incremented exactly once per enemy that
|
||||||
|
// ORIGINATED in player P's spawn AND escaped P's zone (crossed P's leak
|
||||||
|
// volume). Transit through other zones (e.g. a P1 enemy passing through
|
||||||
|
// P4 on its way to the goal) does NOT increment any counter — this is the
|
||||||
|
// "enemies I failed to stop in my own maze" metric, not a transit count.
|
||||||
private readonly NetworkList<int> zoneLeakCounts = new NetworkList<int>();
|
private readonly NetworkList<int> zoneLeakCounts = new NetworkList<int>();
|
||||||
|
|
||||||
|
// Per-slot leak counter for the CURRENT wave only. Same shape as zoneLeakCounts;
|
||||||
|
// server resets every entry to 0 at the start of each wave. Used to determine
|
||||||
|
// who earns the NoLeaksBonus on wave completion. Not currently surfaced in UI
|
||||||
|
// separately from zoneLeakCounts — could be exposed if a "this wave: 0 leaks"
|
||||||
|
// indicator becomes desirable.
|
||||||
|
private readonly NetworkList<int> waveLeakCounts = new NetworkList<int>();
|
||||||
|
|
||||||
|
// Networked prep-phase countdown. Counts down from WaveDefinition.PrepTime to
|
||||||
|
// zero during prep; 0 while the wave is active or being mopped up. Read by the
|
||||||
|
// HUD (next-wave-label) to render "next: 0:12". Server is the only writer.
|
||||||
|
private readonly NetworkVariable<float> prepCountdown = new NetworkVariable<float>(
|
||||||
|
value: 0f,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
// ----- Server-local runtime state ---------------------------------
|
// ----- Server-local runtime state ---------------------------------
|
||||||
|
|
||||||
private int remainingLives;
|
private int remainingLives;
|
||||||
|
|
@ -87,9 +114,12 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
if (!IsServer) return;
|
if (!IsServer) return;
|
||||||
|
|
||||||
// Populate the NetworkList with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
|
// Populate the NetworkLists with 10 zeros (indices 0-9 for PlayerSlot.None..Player9).
|
||||||
for (int i = 0; i < 10; i++)
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
zoneLeakCounts.Add(0);
|
zoneLeakCounts.Add(0);
|
||||||
|
waveLeakCounts.Add(0);
|
||||||
|
}
|
||||||
|
|
||||||
remainingLives = startingLives;
|
remainingLives = startingLives;
|
||||||
|
|
||||||
|
|
@ -115,6 +145,21 @@ namespace TD.Gameplay
|
||||||
ms.SetLives(remainingLives);
|
ms.SetLives(remainingLives);
|
||||||
ms.OnPhaseChanged += HandlePhaseChanged;
|
ms.OnPhaseChanged += HandlePhaseChanged;
|
||||||
|
|
||||||
|
// Apply StartingGold from the config to every connected player, overwriting
|
||||||
|
// whatever fallback the PlayerGoldManager set during its own OnNetworkSpawn.
|
||||||
|
// PlayerGoldManager spawns once per client connection (in MainMenu/Lobby),
|
||||||
|
// before the Match scene exists — so this is where the config-driven init
|
||||||
|
// actually lands. Skipped silently if no goldConfig is assigned; the per-
|
||||||
|
// player fallback startingGold stays.
|
||||||
|
if (goldConfig != null)
|
||||||
|
{
|
||||||
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||||
|
{
|
||||||
|
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||||
|
if (gm != null) gm.ServerSetGold(goldConfig.StartingGold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ms.Phase == MatchPhase.Playing)
|
if (ms.Phase == MatchPhase.Playing)
|
||||||
StartNextWave();
|
StartNextWave();
|
||||||
}
|
}
|
||||||
|
|
@ -137,8 +182,8 @@ namespace TD.Gameplay
|
||||||
public int TotalWaves => waveDefinitions?.Length ?? 0;
|
public int TotalWaves => waveDefinitions?.Length ?? 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of times enemies have leaked out of the given player's zone.
|
/// Number of times enemies have leaked out of the given player's zone over the
|
||||||
/// Replicated — safe to call on any peer.
|
/// entire match. Replicated — safe to call on any peer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int GetZoneLeakCount(PlayerSlot slot)
|
public int GetZoneLeakCount(PlayerSlot slot)
|
||||||
{
|
{
|
||||||
|
|
@ -146,6 +191,20 @@ namespace TD.Gameplay
|
||||||
return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0;
|
return (idx >= 0 && idx < zoneLeakCounts.Count) ? zoneLeakCounts[idx] : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="GoldConfig"/> assigned to this wave manager, or null if unset.
|
||||||
|
/// Exposed so other systems (HUD, future income panels) can read its values.
|
||||||
|
/// </summary>
|
||||||
|
public GoldConfig GoldConfig => goldConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seconds remaining in the prep phase before the next wave starts spawning.
|
||||||
|
/// Zero outside of the prep phase. Replicated; safe to call on any peer.
|
||||||
|
/// HUD reads this each frame to render the countdown label.
|
||||||
|
/// </summary>
|
||||||
|
public float PrepCountdown => prepCountdown.Value;
|
||||||
|
|
||||||
|
|
||||||
// ----- Phase handling ---------------------------------------------
|
// ----- Phase handling ---------------------------------------------
|
||||||
|
|
||||||
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
|
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
|
||||||
|
|
@ -172,6 +231,18 @@ namespace TD.Gameplay
|
||||||
// shows the upcoming wave number during the countdown.
|
// shows the upcoming wave number during the countdown.
|
||||||
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
|
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
|
||||||
|
|
||||||
|
// Reset per-wave bookkeeping for the wave that's about to begin:
|
||||||
|
// - waveLeakCounts: per-slot leaks this wave, used for the no-leak bonus.
|
||||||
|
// - PlayerGoldManager.goldEarnedThisWave: HUD top-bar resets so the
|
||||||
|
// "+N g/wave" counter starts at 0.
|
||||||
|
for (int i = 0; i < waveLeakCounts.Count; i++)
|
||||||
|
waveLeakCounts[i] = 0;
|
||||||
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||||
|
{
|
||||||
|
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||||
|
if (gm != null) gm.ServerResetWaveEarnings();
|
||||||
|
}
|
||||||
|
|
||||||
activeEnemyCount = 0;
|
activeEnemyCount = 0;
|
||||||
spawningComplete = false;
|
spawningComplete = false;
|
||||||
|
|
||||||
|
|
@ -229,9 +300,34 @@ namespace TD.Gameplay
|
||||||
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
|
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
|
||||||
{
|
{
|
||||||
// Prep phase — players build while the countdown ticks. Skipped when
|
// Prep phase — players build while the countdown ticks. Skipped when
|
||||||
// a dev cheat forces the wave to start immediately.
|
// a dev cheat forces the wave to start immediately. We tick the
|
||||||
|
// replicated prepCountdown each frame so every HUD can render the
|
||||||
|
// remaining time consistently. Server is the only writer; clients
|
||||||
|
// observe the value via NetworkVariable replication.
|
||||||
if (!skipPrep)
|
if (!skipPrep)
|
||||||
yield return new WaitForSeconds(def.PrepTime);
|
{
|
||||||
|
prepCountdown.Value = def.PrepTime;
|
||||||
|
float remaining = def.PrepTime;
|
||||||
|
// Throttle network sync to ~10 Hz. NetworkVariable replicates on every
|
||||||
|
// mutation; at 60 fps we'd send ~600 deltas per 10s prep purely to
|
||||||
|
// animate text that only changes once per second on the HUD. 0.1s
|
||||||
|
// gives a smooth-enough fall while keeping bandwidth minimal.
|
||||||
|
const float NetworkSyncInterval = 0.1f;
|
||||||
|
float nextSync = def.PrepTime - NetworkSyncInterval;
|
||||||
|
while (remaining > 0f)
|
||||||
|
{
|
||||||
|
yield return null;
|
||||||
|
remaining = Mathf.Max(0f, remaining - Time.deltaTime);
|
||||||
|
if (remaining <= nextSync || remaining <= 0f)
|
||||||
|
{
|
||||||
|
prepCountdown.Value = remaining;
|
||||||
|
nextSync = remaining - NetworkSyncInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure the countdown reads zero entering the spawn phase, regardless of
|
||||||
|
// whether prep was skipped or just expired.
|
||||||
|
prepCountdown.Value = 0f;
|
||||||
|
|
||||||
// Spawn phase.
|
// Spawn phase.
|
||||||
if (def.Entries != null)
|
if (def.Entries != null)
|
||||||
|
|
@ -276,11 +372,15 @@ namespace TD.Gameplay
|
||||||
if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue;
|
if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue;
|
||||||
|
|
||||||
// Use the first spawner in the zone. Future: round-robin through Spawners.
|
// Use the first spawner in the zone. Future: round-robin through Spawners.
|
||||||
SpawnEnemy(def, zone.Spawners[0].TilePosition);
|
// Pass zone.Owner explicitly so EnemyMovement knows which player owns
|
||||||
|
// this enemy for leak attribution — can't be derived from the spawner
|
||||||
|
// tile's owner-grid entry because SpawnerVolume sits outside
|
||||||
|
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
|
||||||
|
SpawnEnemy(def, zone.Spawners[0].TilePosition, zone.Owner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile)
|
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot)
|
||||||
{
|
{
|
||||||
if (def.EnemyPrefab == null)
|
if (def.EnemyPrefab == null)
|
||||||
{
|
{
|
||||||
|
|
@ -304,8 +404,8 @@ namespace TD.Gameplay
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
health.InitializeServer(def.MaxHp, def.GoldReward, def.LivesCost, def.IsFlying);
|
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying);
|
||||||
movement.InitializeServer(def.MoveSpeed, spawnerTile);
|
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
|
||||||
|
|
||||||
health.OnDied += HandleEnemyKilled;
|
health.OnDied += HandleEnemyKilled;
|
||||||
movement.OnZoneLeaked += HandleZoneLeak;
|
movement.OnZoneLeaked += HandleZoneLeak;
|
||||||
|
|
@ -320,22 +420,29 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
private void HandleEnemyKilled(EnemyHealth health)
|
private void HandleEnemyKilled(EnemyHealth health)
|
||||||
{
|
{
|
||||||
|
// Kill reward comes from GoldConfig for the current wave — same value for
|
||||||
|
// every enemy in the wave regardless of EnemyDefinition type. Missing config
|
||||||
|
// or out-of-range wave → 0 reward (gold flow disabled, designer-error mode).
|
||||||
|
int killReward = 0;
|
||||||
|
var goldEntry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
|
||||||
|
if (goldEntry != null) killReward = goldEntry.GoldPerEnemy;
|
||||||
|
|
||||||
// Award kill gold to the tower owner that landed the killing blow.
|
// Award kill gold to the tower owner that landed the killing blow.
|
||||||
PlayerSlot killerSlot = health.LastHitOwner;
|
PlayerSlot killerSlot = health.LastHitOwner;
|
||||||
if (killerSlot != PlayerSlot.None)
|
if (killerSlot != PlayerSlot.None && killReward > 0)
|
||||||
{
|
{
|
||||||
var pms = PlayerMatchState.GetForSlot(killerSlot);
|
var pms = PlayerMatchState.GetForSlot(killerSlot);
|
||||||
if (pms != null)
|
if (pms != null)
|
||||||
PlayerGoldManager.GetForClient(pms.OwnerClientId)
|
PlayerGoldManager.GetForClient(pms.OwnerClientId)
|
||||||
?.AwardGold(health.GoldReward);
|
?.AwardGold(killReward);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show a "+N" gold popup above the corpse on every peer. Capture the
|
// Show a "+N" gold popup above the corpse on every peer. Capture the
|
||||||
// position here on the server — by the time the RPC fires on clients
|
// position here on the server — by the time the RPC fires on clients
|
||||||
// the death sequence will be moving the corpse, but the spawn point
|
// the death sequence will be moving the corpse, but the spawn point
|
||||||
// is good enough and we want the popup to anchor where the kill happened.
|
// is good enough and we want the popup to anchor where the kill happened.
|
||||||
if (health.GoldReward > 0)
|
if (killReward > 0)
|
||||||
ShowGoldRewardClientRpc(health.transform.position, health.GoldReward);
|
ShowGoldRewardClientRpc(health.transform.position, killReward);
|
||||||
|
|
||||||
UnsubscribeEnemy(health);
|
UnsubscribeEnemy(health);
|
||||||
DecrementAndCheckComplete();
|
DecrementAndCheckComplete();
|
||||||
|
|
@ -343,10 +450,15 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
private void HandleZoneLeak(PlayerSlot leavingZone)
|
private void HandleZoneLeak(PlayerSlot leavingZone)
|
||||||
{
|
{
|
||||||
// Increment the per-slot leak counter for the zone the enemy is leaving.
|
// EnemyMovement fires this exactly once per enemy, when it escapes its
|
||||||
|
// origin zone. We increment both the match-total and the per-wave counter
|
||||||
|
// for the originating player. Per-wave count drives the no-leak bonus
|
||||||
|
// eligibility check at wave completion.
|
||||||
int idx = (int)leavingZone;
|
int idx = (int)leavingZone;
|
||||||
if (idx >= 0 && idx < zoneLeakCounts.Count)
|
if (idx >= 0 && idx < zoneLeakCounts.Count)
|
||||||
zoneLeakCounts[idx]++;
|
zoneLeakCounts[idx]++;
|
||||||
|
if (idx >= 0 && idx < waveLeakCounts.Count)
|
||||||
|
waveLeakCounts[idx]++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
|
private void HandleEnemyReachedGoal(EnemyMovement movement, int livesCost)
|
||||||
|
|
@ -432,8 +544,55 @@ namespace TD.Gameplay
|
||||||
if (ms == null || ms.Phase == MatchPhase.Defeat || ms.Phase == MatchPhase.Victory)
|
if (ms == null || ms.Phase == MatchPhase.Defeat || ms.Phase == MatchPhase.Victory)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Award per-wave bonuses BEFORE advancing the wave (so waveLeakCounts still
|
||||||
|
// reflects this wave's leaks, and goldEarnedThisWave still accumulates this
|
||||||
|
// wave's bonus on top of kill gold). Completion bonus is unconditional;
|
||||||
|
// no-leak bonus only if the player's waveLeakCounts entry is exactly 0.
|
||||||
|
AwardWaveCompletionBonuses();
|
||||||
|
|
||||||
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
|
Debug.Log($"[WaveManager] Wave {currentWaveIndex + 1} complete. Starting next wave.");
|
||||||
StartNextWave();
|
StartNextWave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-only. Iterates active players, awards CompletionBonus to each, plus
|
||||||
|
// NoLeaksBonus to those whose per-wave leak counter is zero. Floating-text popups
|
||||||
|
// are spawned at each player's builder position so the reward is visible in-world.
|
||||||
|
// Skipped silently if no goldConfig or no entry for this wave.
|
||||||
|
private void AwardWaveCompletionBonuses()
|
||||||
|
{
|
||||||
|
var entry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
|
||||||
|
if (entry == null) return;
|
||||||
|
|
||||||
|
int completionBonus = entry.CompletionBonus;
|
||||||
|
int noLeaksBonus = entry.NoLeaksBonus;
|
||||||
|
if (completionBonus <= 0 && noLeaksBonus <= 0) return;
|
||||||
|
|
||||||
|
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||||
|
{
|
||||||
|
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||||
|
if (gm == null) continue;
|
||||||
|
|
||||||
|
int award = 0;
|
||||||
|
if (completionBonus > 0) award += completionBonus;
|
||||||
|
|
||||||
|
int slotIdx = (int)pms.Slot;
|
||||||
|
bool zeroLeaks = slotIdx >= 0 && slotIdx < waveLeakCounts.Count
|
||||||
|
&& waveLeakCounts[slotIdx] == 0;
|
||||||
|
if (zeroLeaks && noLeaksBonus > 0) award += noLeaksBonus;
|
||||||
|
|
||||||
|
if (award > 0)
|
||||||
|
{
|
||||||
|
gm.AwardGold(award);
|
||||||
|
|
||||||
|
// Surface the bonus in-world so players see it land. Position the
|
||||||
|
// popup at the player's builder if we can find one; otherwise the
|
||||||
|
// origin (popups still spawn, just centered on world origin).
|
||||||
|
Vector3 popupPos = Vector3.zero;
|
||||||
|
var builder = Builder.GetForClient(pms.OwnerClientId);
|
||||||
|
if (builder != null) popupPos = builder.CurrentPosition;
|
||||||
|
ShowGoldRewardClientRpc(popupPos, award);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ namespace TD.UI
|
||||||
private Label goldLabel;
|
private Label goldLabel;
|
||||||
private Label waveLabel;
|
private Label waveLabel;
|
||||||
private Label livesLabel;
|
private Label livesLabel;
|
||||||
|
private Label nextWaveLabel; // prep countdown ("next: 0:12")
|
||||||
|
private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
|
||||||
|
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
|
||||||
|
private VisualElement playerListContainer; // right-panel scoreboard rows
|
||||||
private Label portraitName;
|
private Label portraitName;
|
||||||
private Label levelLabel;
|
private Label levelLabel;
|
||||||
private VisualElement statLines;
|
private VisualElement statLines;
|
||||||
|
|
@ -236,6 +240,10 @@ namespace TD.UI
|
||||||
goldLabel = Require<Label>(root, "gold-label");
|
goldLabel = Require<Label>(root, "gold-label");
|
||||||
waveLabel = Require<Label>(root, "wave-label");
|
waveLabel = Require<Label>(root, "wave-label");
|
||||||
livesLabel = Require<Label>(root, "lives-label");
|
livesLabel = Require<Label>(root, "lives-label");
|
||||||
|
nextWaveLabel = Require<Label>(root, "next-wave-label");
|
||||||
|
leakedLabel = Require<Label>(root, "leaked-label");
|
||||||
|
incomeLabel = Require<Label>(root, "income-label");
|
||||||
|
playerListContainer = Require<VisualElement>(root, "player-list");
|
||||||
portraitName = Require<Label>(root, "portrait-name");
|
portraitName = Require<Label>(root, "portrait-name");
|
||||||
levelLabel = Require<Label>(root, "level-label");
|
levelLabel = Require<Label>(root, "level-label");
|
||||||
statLines = Require<VisualElement>(root, "stat-lines");
|
statLines = Require<VisualElement>(root, "stat-lines");
|
||||||
|
|
@ -438,17 +446,195 @@ namespace TD.UI
|
||||||
private void RefreshMatchStateDisplays()
|
private void RefreshMatchStateDisplays()
|
||||||
{
|
{
|
||||||
var ms = MatchState.Instance;
|
var ms = MatchState.Instance;
|
||||||
|
var wm = WaveManager.Instance;
|
||||||
|
|
||||||
if (livesLabel != null)
|
if (livesLabel != null)
|
||||||
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
|
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
|
||||||
|
|
||||||
if (waveLabel != null)
|
if (waveLabel != null)
|
||||||
{
|
{
|
||||||
int total = WaveManager.Instance?.TotalWaves ?? 0;
|
int total = wm?.TotalWaves ?? 0;
|
||||||
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
|
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
|
||||||
? $"Wave {ms.CurrentWave} / {total}"
|
? $"Wave {ms.CurrentWave} / {total}"
|
||||||
: "Wave --";
|
: "Wave --";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Next-wave countdown. Shows during prep ("next: 0:12") and clears the
|
||||||
|
// moment the wave actually starts spawning. WaveManager.PrepCountdown
|
||||||
|
// is networked so this reads the same value on every peer.
|
||||||
|
if (nextWaveLabel != null)
|
||||||
|
{
|
||||||
|
float t = wm != null ? wm.PrepCountdown : 0f;
|
||||||
|
if (t > 0f)
|
||||||
|
{
|
||||||
|
// Ceiling so the user sees a full "0:01" tick before "0:00".
|
||||||
|
int seconds = Mathf.CeilToInt(t);
|
||||||
|
int mm = seconds / 60;
|
||||||
|
int ss = seconds % 60;
|
||||||
|
nextWaveLabel.text = $"next: {mm}:{ss:00}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
nextWaveLabel.text = "next: --:--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-bar "earned this wave" counter. Reads the LOCAL player's
|
||||||
|
// PlayerGoldManager.GoldEarnedThisWave — which the server resets to 0 at
|
||||||
|
// wave start and increments via AwardGold on every kill / completion /
|
||||||
|
// no-leak bonus. Spending doesn't decrement it.
|
||||||
|
if (incomeLabel != null)
|
||||||
|
{
|
||||||
|
var localGold = PlayerGoldManager.Local;
|
||||||
|
int earned = localGold != null ? localGold.GoldEarnedThisWave : 0;
|
||||||
|
incomeLabel.text = $"+{earned} g/wave";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local player's origin-leak count: how many enemies that spawned in MY
|
||||||
|
// zone escaped my maze. Resolves the local PlayerMatchState's slot then
|
||||||
|
// reads the per-slot counter from WaveManager (replicated NetworkList).
|
||||||
|
if (leakedLabel != null)
|
||||||
|
{
|
||||||
|
var local = PlayerMatchState.Local;
|
||||||
|
int leaks = 0;
|
||||||
|
if (wm != null && local != null && local.Slot != PlayerSlot.None)
|
||||||
|
leaks = wm.GetZoneLeakCount(local.Slot);
|
||||||
|
leakedLabel.text = $"leaked: {leaks}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-panel scoreboard rebuild — see RefreshScoreboard.
|
||||||
|
RefreshScoreboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Scoreboard --------------------------------------------------
|
||||||
|
|
||||||
|
// Snapshot of last-rebuilt scoreboard state so we only rebuild when something
|
||||||
|
// changes. Without this we'd destroy and recreate every row every frame —
|
||||||
|
// wasteful and (in line with LobbyController's player-list pattern) would
|
||||||
|
// also break any per-row pointer interaction we might add later.
|
||||||
|
private string lastScoreboardSignature = string.Empty;
|
||||||
|
|
||||||
|
private void RefreshScoreboard()
|
||||||
|
{
|
||||||
|
if (playerListContainer == null) return;
|
||||||
|
|
||||||
|
// Sort by slot for stable ordering. Counter-intuitively the underlying
|
||||||
|
// collection isn't slot-ordered (it's keyed by NGO clientId).
|
||||||
|
var players = new System.Collections.Generic.List<PlayerMatchState>();
|
||||||
|
foreach (var pms in PlayerMatchState.AllPlayers) players.Add(pms);
|
||||||
|
players.Sort((a, b) => ((int)a.Slot).CompareTo((int)b.Slot));
|
||||||
|
|
||||||
|
// Build a signature of everything the rendered rows depend on. Skip the
|
||||||
|
// rebuild when nothing has changed.
|
||||||
|
string sig = ComputeScoreboardSignature(players);
|
||||||
|
if (sig == lastScoreboardSignature) return;
|
||||||
|
lastScoreboardSignature = sig;
|
||||||
|
|
||||||
|
playerListContainer.Clear();
|
||||||
|
if (players.Count == 0)
|
||||||
|
{
|
||||||
|
var emptyLabel = new Label("(no players)");
|
||||||
|
emptyLabel.style.color = new Color(0.6f, 0.6f, 0.6f);
|
||||||
|
emptyLabel.style.unityFontStyleAndWeight = FontStyle.Italic;
|
||||||
|
playerListContainer.Add(emptyLabel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wm = WaveManager.Instance;
|
||||||
|
foreach (var pms in players)
|
||||||
|
{
|
||||||
|
playerListContainer.Add(BuildScoreboardRow(pms, wm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement BuildScoreboardRow(PlayerMatchState pms, WaveManager wm)
|
||||||
|
{
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
|
row.style.alignItems = Align.Center;
|
||||||
|
row.style.marginBottom = 2;
|
||||||
|
row.style.paddingLeft = 4;
|
||||||
|
row.style.paddingRight = 4;
|
||||||
|
|
||||||
|
// Layout model: three columns with explicit widths/flex so all three are
|
||||||
|
// guaranteed visible inside the right panel. Name has flexGrow:1 so it
|
||||||
|
// takes leftover space; gold and leaks have fixed widths and right-align
|
||||||
|
// their text so the numbers line up vertically across rows. The previous
|
||||||
|
// layout used flexGrow on name with justify-content space-between, which
|
||||||
|
// pushed the leaks column off the panel's right edge on narrow widths.
|
||||||
|
string name = string.IsNullOrEmpty(pms.DisplayName)
|
||||||
|
? $"P{(int)pms.Slot}"
|
||||||
|
: pms.DisplayName;
|
||||||
|
var nameLabel = new Label(name);
|
||||||
|
// Tint with the canonical player-slot color — same palette used by
|
||||||
|
// builders, minimap icons, and zone outlines for consistent identity.
|
||||||
|
// Colors were tuned for gizmos on Unity's gray scene view; they're still
|
||||||
|
// legible on the dark panel for all slots except P9 (dark gray), which is
|
||||||
|
// intentionally subdued relative to the others.
|
||||||
|
nameLabel.style.color = PlayerColors.Get(pms.Slot);
|
||||||
|
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
nameLabel.style.fontSize = 10;
|
||||||
|
nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
|
||||||
|
nameLabel.style.flexGrow = 1; // takes all leftover width
|
||||||
|
nameLabel.style.flexShrink = 1;
|
||||||
|
nameLabel.style.overflow = Overflow.Hidden;
|
||||||
|
nameLabel.style.textOverflow = TextOverflow.Ellipsis;
|
||||||
|
row.Add(nameLabel);
|
||||||
|
|
||||||
|
// Gold column. Read from PlayerGoldManager by clientId. May be null briefly
|
||||||
|
// during spawn races; render "--" in that case rather than crashing.
|
||||||
|
// Width 46px: right-aligned numbers; fits 4-digit gold totals at 10px font.
|
||||||
|
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||||
|
string goldText = gm != null ? $"{gm.CurrentGold}" : "--";
|
||||||
|
var goldLabel = new Label(goldText);
|
||||||
|
goldLabel.style.color = new Color(1f, 0.85f, 0.35f); // gold-y
|
||||||
|
goldLabel.style.fontSize = 10;
|
||||||
|
goldLabel.style.width = 46;
|
||||||
|
goldLabel.style.flexShrink = 0;
|
||||||
|
goldLabel.style.unityTextAlign = TextAnchor.MiddleRight;
|
||||||
|
row.Add(goldLabel);
|
||||||
|
|
||||||
|
// Leaks column. Same NetworkList the local player's "leaked: N" top-bar
|
||||||
|
// label reads — keeps the two views in sync. Always rendered (including 0)
|
||||||
|
// so designers can see at a glance whether a player has clean runs so far.
|
||||||
|
// Width 30px: right-aligned; 2-digit leak counts fit comfortably at 10px font.
|
||||||
|
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
|
||||||
|
? wm.GetZoneLeakCount(pms.Slot)
|
||||||
|
: 0;
|
||||||
|
var leaksLabel = new Label($"{leaks}");
|
||||||
|
leaksLabel.style.color = leaks == 0
|
||||||
|
? new Color(0.55f, 0.85f, 0.55f)
|
||||||
|
: new Color(0.95f, 0.6f, 0.4f);
|
||||||
|
leaksLabel.style.fontSize = 10;
|
||||||
|
leaksLabel.style.width = 30;
|
||||||
|
leaksLabel.style.flexShrink = 0;
|
||||||
|
leaksLabel.style.unityTextAlign = TextAnchor.MiddleRight;
|
||||||
|
row.Add(leaksLabel);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Components: ordered slot sequence + their name / gold / leaks. Any change
|
||||||
|
// triggers a rebuild. Slot count itself rarely changes mid-match (joins are
|
||||||
|
// gated by the lobby), but the values do.
|
||||||
|
private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
|
||||||
|
new System.Text.StringBuilder(64);
|
||||||
|
private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
|
||||||
|
{
|
||||||
|
s_scoreboardSigBuf.Clear();
|
||||||
|
var wm = WaveManager.Instance;
|
||||||
|
foreach (var pms in players)
|
||||||
|
{
|
||||||
|
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||||
|
int gold = gm != null ? gm.CurrentGold : 0;
|
||||||
|
int leaks = (wm != null && pms.Slot != PlayerSlot.None)
|
||||||
|
? wm.GetZoneLeakCount(pms.Slot) : 0;
|
||||||
|
s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
|
||||||
|
.Append(pms.DisplayName ?? string.Empty).Append(':')
|
||||||
|
.Append(gold).Append(':')
|
||||||
|
.Append(leaks).Append(';');
|
||||||
|
}
|
||||||
|
return s_scoreboardSigBuf.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Command grid -----------------------------------------------
|
// ----- Command grid -----------------------------------------------
|
||||||
|
|
@ -807,7 +993,13 @@ namespace TD.UI
|
||||||
if (def != null)
|
if (def != null)
|
||||||
{
|
{
|
||||||
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
|
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
|
||||||
AddStatLine($"Bounty: {def.GoldReward} g");
|
// Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
|
||||||
|
// per-enemy-type. Read the current wave's value so the tooltip is accurate.
|
||||||
|
var wm = WaveManager.Instance;
|
||||||
|
int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
|
||||||
|
var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
|
||||||
|
if (goldEntry != null)
|
||||||
|
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
|
||||||
// (Weaknesses/resistances will go here once the resistance system lands.)
|
// (Weaknesses/resistances will go here once the resistance system lands.)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,29 @@ namespace TD.UI
|
||||||
pms.SetRaceSelection(RaceId.Race1);
|
pms.SetRaceSelection(RaceId.Race1);
|
||||||
pms.SetReady(true);
|
pms.SetReady(true);
|
||||||
|
|
||||||
// Skip Lobby — drop straight into the Match scene.
|
// Skip Lobby — drop straight into the default map's scene. Pulls from
|
||||||
NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
|
// MapRegistry.Default (the first map authored in the registry — by convention
|
||||||
|
// the 9-player map). Falls back to the hardcoded SceneNames.Match if the
|
||||||
|
// registry isn't present (e.g. testing in editor without going through MainMenu,
|
||||||
|
// though that's the very scene this controller lives in, so it should always
|
||||||
|
// resolve in practice).
|
||||||
|
string sceneToLoad;
|
||||||
|
var registry = MapRegistry.Instance;
|
||||||
|
var defaultMap = registry != null ? registry.Default : null;
|
||||||
|
if (defaultMap != null && !string.IsNullOrEmpty(defaultMap.SceneName))
|
||||||
|
{
|
||||||
|
sceneToLoad = defaultMap.SceneName;
|
||||||
|
Debug.Log($"[MainMenu] Quick Start loading default map '{defaultMap.MapName}' " +
|
||||||
|
$"(scene='{sceneToLoad}').");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sceneToLoad = SceneNames.Match;
|
||||||
|
Debug.LogWarning($"[MainMenu] Quick Start: MapRegistry default unavailable, " +
|
||||||
|
$"falling back to hardcoded scene '{sceneToLoad}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkBootstrap.LoadSceneAsHost(sceneToLoad);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
/* ---- RIGHT SCOREBOARD PANEL --------------------------------- */
|
/* ---- RIGHT SCOREBOARD PANEL --------------------------------- */
|
||||||
|
|
||||||
.right-panel {
|
.right-panel {
|
||||||
width: 130px;
|
width: 172px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -20,22 +20,9 @@
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
<ui:VisualElement name="right-panel" class="right-panel">
|
<ui:VisualElement name="right-panel" class="right-panel">
|
||||||
<ui:Label text="scoreboard" class="panel-header"/>
|
<ui:Label text="scoreboard" class="panel-header"/>
|
||||||
<ui:Label text="name · lives · leaked" class="section-label"/>
|
<ui:Label text="name · gold · leaked" class="section-label"/>
|
||||||
<ui:VisualElement name="player-list" class="player-list"/>
|
<ui:VisualElement name="player-list" class="player-list"/>
|
||||||
<ui:Label text="income / wave" class="section-label" style="margin-top: 6px;"/>
|
<ui:Label text="race" class="section-label" style="margin-top: 8px;"/>
|
||||||
<ui:VisualElement class="income-row">
|
|
||||||
<ui:Label text="base" class="income-key"/>
|
|
||||||
<ui:Label name="income-base-label" text="+? g" class="income-val"/>
|
|
||||||
</ui:VisualElement>
|
|
||||||
<ui:VisualElement class="income-row">
|
|
||||||
<ui:Label text="bonus" class="income-key"/>
|
|
||||||
<ui:Label name="income-bonus-label" text="+? g" class="income-val"/>
|
|
||||||
</ui:VisualElement>
|
|
||||||
<ui:VisualElement class="income-row">
|
|
||||||
<ui:Label text="total" class="income-key"/>
|
|
||||||
<ui:Label name="income-total-label" text="+? g" class="income-val"/>
|
|
||||||
</ui:VisualElement>
|
|
||||||
<ui:Label text="race" class="section-label" style="margin-top: 4px;"/>
|
|
||||||
<ui:VisualElement class="income-row">
|
<ui:VisualElement class="income-row">
|
||||||
<ui:Label text="builder" class="income-key"/>
|
<ui:Label text="builder" class="income-key"/>
|
||||||
<ui:Label name="race-label" text="?" class="income-val"/>
|
<ui:Label name="race-label" text="?" class="income-val"/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue