Updating HUD, Gold Config, and finishing off Play flow for 9player map.

This commit is contained in:
Matt F 2026-05-22 12:18:23 -07:00
parent a7be12fa9b
commit 3dcc0e7edd
28 changed files with 2272 additions and 9601 deletions

View file

@ -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}

View file

@ -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}

View 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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d5e01c919f14a1e4888ad494a159241b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9c677da561a359f4aa230d4913644f4e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc17000144b098340a210921594babca

View file

@ -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, " +

View file

@ -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 --------------------------------------------------

View file

@ -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;
} }

View 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];
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dede7fe606207ab4a8b7624d0a710d9b

View file

@ -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

View file

@ -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.

View file

@ -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();

View file

@ -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.

View file

@ -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>

View file

@ -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>

View file

@ -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);
}
}
}
} }
} }

View file

@ -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.)
} }
} }

View file

@ -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);
} }
} }
} }

View file

@ -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;

View file

@ -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"/>