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
|
|
@ -33,13 +33,13 @@ namespace TD.Gameplay
|
|||
"blocked by tower colliders (handled in EnemyMovement).")]
|
||||
public bool IsFlying;
|
||||
|
||||
[Header("Rewards / Costs")]
|
||||
[Tooltip("Gold awarded to the player whose tower lands the killing blow.")]
|
||||
public int GoldReward = 10;
|
||||
|
||||
[Header("Costs")]
|
||||
[Tooltip("Number of lives deducted from the shared pool when this enemy " +
|
||||
"reaches the Goal. Boss enemies might cost 2 or more lives.")]
|
||||
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")]
|
||||
[Tooltip("Prefab spawned in the world for this enemy. Must have NetworkObject, " +
|
||||
|
|
|
|||
|
|
@ -64,15 +64,15 @@ namespace TD.Gameplay
|
|||
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||
|
||||
private float pendingMaxHp = 100f;
|
||||
private int pendingGoldReward;
|
||||
private int pendingLivesCost = 1;
|
||||
private bool pendingIsFlying;
|
||||
private bool hasPendingInit;
|
||||
|
||||
// ----- Server-local runtime state -------------------------------------
|
||||
|
||||
/// <summary>Gold awarded to the killing player when this enemy dies.</summary>
|
||||
public int GoldReward { get; private set; }
|
||||
// Kill gold is no longer carried per-enemy — it comes from
|
||||
// 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>
|
||||
public int LivesCost { get; private set; } = 1;
|
||||
|
|
@ -126,18 +126,16 @@ namespace TD.Gameplay
|
|||
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
||||
/// <c>TowerInstance.InitializeServer</c> pattern.
|
||||
/// </summary>
|
||||
public void InitializeServer(float maxHp, int goldReward, int livesCost, bool flying)
|
||||
public void InitializeServer(float maxHp, int livesCost, bool flying)
|
||||
{
|
||||
pendingMaxHp = maxHp;
|
||||
pendingGoldReward = goldReward;
|
||||
pendingLivesCost = livesCost;
|
||||
pendingIsFlying = flying;
|
||||
hasPendingInit = true;
|
||||
|
||||
// Cache locally on the server immediately — clients resolve via NV.
|
||||
MaxHp = maxHp;
|
||||
GoldReward = goldReward;
|
||||
LivesCost = livesCost;
|
||||
MaxHp = maxHp;
|
||||
LivesCost = livesCost;
|
||||
}
|
||||
|
||||
// ----- NGO lifecycle --------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ namespace TD.Gameplay
|
|||
|
||||
private float pendingMoveSpeed;
|
||||
private Vector2Int pendingSpawnerTile;
|
||||
private PlayerSlot pendingOwnerSlot;
|
||||
private bool hasPendingInit;
|
||||
|
||||
// ----- Server-local runtime state -------------------------------------
|
||||
|
|
@ -59,6 +60,16 @@ namespace TD.Gameplay
|
|||
private float moveSpeed;
|
||||
private List<Vector2Int> remainingPath = new List<Vector2Int>();
|
||||
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 bool hasReachedGoal;
|
||||
private bool wasStuck; // dedupes the "no path" warning
|
||||
|
|
@ -66,10 +77,11 @@ namespace TD.Gameplay
|
|||
// ----- Events ---------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fired on the server when this enemy crosses from one player zone into another
|
||||
/// (or from a neutral zone into a player zone). The argument is the zone being
|
||||
/// LEFT — the zone that should be debited a life.
|
||||
/// <c>WaveManager</c> subscribes to deduct from the correct player's life pool.
|
||||
/// Fired on the server EXACTLY ONCE per enemy, when it crosses out of its
|
||||
/// origin zone (the zone it spawned in) into another zone. The argument is the
|
||||
/// origin zone — the player who "owns" this enemy and should be credited a
|
||||
/// leak. Transit through subsequent zones does NOT fire this event.
|
||||
/// <c>WaveManager</c> subscribes to increment the per-player leak counter.
|
||||
/// </summary>
|
||||
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
|
||||
/// before <c>NetworkObject.Spawn()</c>. <paramref name="speed"/> comes from
|
||||
/// <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>
|
||||
public void InitializeServer(float speed, Vector2Int spawnerTile)
|
||||
public void InitializeServer(float speed, Vector2Int spawnerTile, PlayerSlot ownerSlot)
|
||||
{
|
||||
pendingMoveSpeed = speed;
|
||||
pendingSpawnerTile = spawnerTile;
|
||||
pendingOwnerSlot = ownerSlot;
|
||||
hasPendingInit = true;
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +131,21 @@ namespace TD.Gameplay
|
|||
|
||||
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;
|
||||
if (loader != null)
|
||||
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.
|
||||
ComputeAndStorePath(pendingSpawnerTile);
|
||||
|
||||
|
|
@ -194,9 +222,18 @@ namespace TD.Gameplay
|
|||
PlayerSlot newZone = loader.GetOwner(tile);
|
||||
if (newZone == currentZone) return;
|
||||
|
||||
// The enemy is leaving currentZone — debit that player's life pool.
|
||||
if (currentZone != PlayerSlot.None)
|
||||
OnZoneLeaked?.Invoke(currentZone);
|
||||
// The enemy is crossing a zone boundary. Only fire OnZoneLeaked if it's
|
||||
// the FIRST time this enemy escapes its ORIGIN zone — that's the
|
||||
// "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;
|
||||
}
|
||||
|
|
|
|||
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
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Levels;
|
||||
|
|
@ -379,6 +380,12 @@ namespace TD.Gameplay
|
|||
/// <c>false</c>) and when a tower is sold/destroyed (pass <c>true</c>).
|
||||
/// No-ops silently for out-of-bounds tiles.
|
||||
/// </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)
|
||||
{
|
||||
if (!TryFlatIndex(tile, out int idx)) return;
|
||||
|
|
@ -387,6 +394,28 @@ namespace TD.Gameplay
|
|||
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>
|
||||
/// Sets the runtime occupancy of <paramref name="tile"/>. Called alongside
|
||||
/// <see cref="SetWalkable"/> — always update both grids together so they
|
||||
|
|
|
|||
|
|
@ -109,6 +109,22 @@ namespace TD.Gameplay
|
|||
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 --------------------------------------------------
|
||||
|
||||
// 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.
|
||||
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.
|
||||
// PathfindingService is a singleton, so single-instance scratch is safe.
|
||||
// gScore is float to support diagonal cost √2; the priority queue matches.
|
||||
|
|
@ -85,6 +96,7 @@ namespace TD.Gameplay
|
|||
private void Start()
|
||||
{
|
||||
BuildGoalTileSet();
|
||||
BuildHeuristicTable();
|
||||
|
||||
var loader = LevelLoader.Instance;
|
||||
if (loader != null)
|
||||
|
|
@ -230,9 +242,24 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
foreach (var goal in goalTiles)
|
||||
{
|
||||
|
|
@ -380,6 +407,53 @@ namespace TD.Gameplay
|
|||
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()
|
||||
{
|
||||
OnPathsInvalidated?.Invoke();
|
||||
|
|
|
|||
|
|
@ -71,22 +71,22 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
// 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.
|
||||
if (SceneManager.GetActiveScene().name == SceneNames.Match)
|
||||
if (IsMatchScene(SceneManager.GetActiveScene().name))
|
||||
TrySpawnBuilder();
|
||||
}
|
||||
|
||||
// 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
|
||||
// scene loads; Lobby / MainMenu loads are no-ops here.
|
||||
// by every connected client (or timed out). We only act when a registered
|
||||
// match scene loads; Lobby / MainMenu loads are no-ops here.
|
||||
private void HandleSceneLoadCompleted(string sceneName,
|
||||
LoadSceneMode loadSceneMode,
|
||||
List<ulong> clientsCompleted,
|
||||
List<ulong> clientsTimedOut)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (sceneName != SceneNames.Match) return;
|
||||
if (!IsMatchScene(sceneName)) return;
|
||||
TrySpawnBuilder();
|
||||
}
|
||||
|
||||
|
|
@ -115,9 +115,9 @@ namespace TD.Gameplay
|
|||
var pms = GetComponent<PlayerMatchState>();
|
||||
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.
|
||||
if (SceneManager.GetActiveScene().name == SceneNames.Match)
|
||||
if (IsMatchScene(SceneManager.GetActiveScene().name))
|
||||
SpawnBuilderForOwner(slot);
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +185,21 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- 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
|
||||
// priority when (a) RaceRegistry is in the scene, (b) the player picked
|
||||
// a race, and (c) that race's RaceDefinition has a BuilderPrefab assigned.
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ namespace TD.Gameplay
|
|||
|
||||
// --- 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;
|
||||
|
||||
// --- Networked state ---------------------------------------------
|
||||
|
|
@ -77,7 +80,19 @@ namespace TD.Gameplay
|
|||
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 GoldEarnedThisWave => goldEarnedThisWave.Value;
|
||||
|
||||
// --- Lifecycle ---------------------------------------------------
|
||||
|
||||
|
|
@ -128,7 +143,9 @@ namespace TD.Gameplay
|
|||
/// <summary>
|
||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||
/// Direct call — not Rpc-wrapped — because awards always originate
|
||||
/// from server-authoritative game events.
|
||||
/// 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>
|
||||
public void AwardGold(int amount)
|
||||
{
|
||||
|
|
@ -141,6 +158,39 @@ namespace TD.Gameplay
|
|||
|
||||
if (amount <= 0) return;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ namespace TD.Gameplay
|
|||
private readonly Queue<Vector2Int> bfsQueue = new Queue<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 --------------------------------------------------
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
|
|
@ -278,23 +284,26 @@ namespace TD.Gameplay
|
|||
|
||||
// ------------------------------------------------------------------
|
||||
// Check 6: Path validity (queue-time)
|
||||
// Temporarily stamp the footprint non-walkable, run BFS per spawner
|
||||
// in the placing player's zone, then un-stamp if any spawner loses
|
||||
// its exit route. Importantly we do NOT stamp other queued (but not
|
||||
// yet constructing) jobs as non-walkable — queued ghosts represent
|
||||
// intent only and don't block enemies. The check is "could THIS
|
||||
// tower be built right now if it were instantly complete?" — a
|
||||
// coarse test that catches obvious blockers at queue-time. The
|
||||
// construction-start re-check (in Builder.DriveHead_Queued) catches
|
||||
// cases where the maze changed since queue-time.
|
||||
// Virtually treat the footprint as non-walkable and run BFS per spawner
|
||||
// in the placing player's zone. We do NOT modify the grid here — the
|
||||
// BFS just consults a "virtually blocked" tile set in addition to
|
||||
// IsWalkable. Importantly we do NOT block other queued (but not yet
|
||||
// constructing) jobs — queued ghosts represent intent only and don't
|
||||
// block enemies. The check is "could THIS tower be built right now if
|
||||
// it were instantly complete?" — a coarse test that catches obvious
|
||||
// blockers at queue-time. The construction-start re-check (in
|
||||
// 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);
|
||||
|
||||
// Restore walkability — the queue stage leaves tiles walkable.
|
||||
// Occupancy is stamped below as part of the commit.
|
||||
StampWalkable(loader, footprint, walkable: true);
|
||||
bool pathValid = CheckPathValidity(loader, placingSlot, virtualBlockedScratch);
|
||||
|
||||
if (!pathValid)
|
||||
{
|
||||
|
|
@ -348,18 +357,22 @@ namespace TD.Gameplay
|
|||
foreach (var tile in GridCoordinates.GetFootprintTiles(anchor, footprintSize))
|
||||
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 (!ok)
|
||||
if (!CheckPathValidity(loader, placingSlot, virtualBlockedScratch))
|
||||
{
|
||||
// Roll back — the maze would break. Caller refunds and drops the job.
|
||||
StampWalkable(loader, footprint, walkable: true);
|
||||
// Maze would break. Caller refunds and drops the job. Grid untouched,
|
||||
// no events fired.
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +420,8 @@ namespace TD.Gameplay
|
|||
/// grid. Reuses <see cref="bfsQueue"/> and <see cref="bfsVisited"/> scratch
|
||||
/// collections (cleared between BFS runs) to avoid GC allocation per call.
|
||||
/// </remarks>
|
||||
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot)
|
||||
private bool CheckPathValidity(LevelLoader loader, PlayerSlot slot,
|
||||
HashSet<Vector2Int> virtualBlocked = null)
|
||||
{
|
||||
var levelData = loader.LevelData;
|
||||
|
||||
|
|
@ -432,10 +446,12 @@ namespace TD.Gameplay
|
|||
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)
|
||||
{
|
||||
if (!SpawnerCanReachExit(loader, spawner, exitTiles))
|
||||
if (!SpawnerCanReachExit(loader, spawner, exitTiles, virtualBlocked))
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -474,11 +490,20 @@ namespace TD.Gameplay
|
|||
/// is reachable via walkable tiles. Uses the shared scratch queue and visited set.
|
||||
/// </summary>
|
||||
private bool SpawnerCanReachExit(LevelLoader loader, SpawnerData spawner,
|
||||
HashSet<Vector2Int> exitTiles)
|
||||
HashSet<Vector2Int> exitTiles,
|
||||
HashSet<Vector2Int> virtualBlocked = null)
|
||||
{
|
||||
bfsQueue.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),
|
||||
// matching bake-time P5-4 exactly.
|
||||
foreach (var tile in spawner.TileArea)
|
||||
|
|
@ -503,13 +528,13 @@ namespace TD.Gameplay
|
|||
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||
{
|
||||
if (bfsVisited.Contains(neighbor)) continue;
|
||||
if (!loader.IsWalkable(neighbor)) continue;
|
||||
if (!IsTileOpen(neighbor)) continue;
|
||||
|
||||
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||
{
|
||||
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||
out var shoulderA, out var shoulderB);
|
||||
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
||||
if (!IsTileOpen(shoulderA) || !IsTileOpen(shoulderB))
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -531,8 +556,11 @@ namespace TD.Gameplay
|
|||
private static void StampWalkable(LevelLoader loader, List<Vector2Int> footprint,
|
||||
bool walkable)
|
||||
{
|
||||
foreach (var tile in footprint)
|
||||
loader.SetWalkable(tile, walkable);
|
||||
// Batched: fires OnWalkabilityChanged at most once for the whole footprint,
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -58,13 +58,40 @@ namespace TD.Gameplay
|
|||
[Tooltip("Shared lives pool at the start of a match.")]
|
||||
[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 --------------------------------------------
|
||||
|
||||
// Per-slot zone-leak counters. Index = (int)PlayerSlot; 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.
|
||||
// Per-slot total leak counters across the whole match. Index = (int)PlayerSlot;
|
||||
// size = 10 (0-9). Index 0 (PlayerSlot.None) is allocated but never written.
|
||||
// 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>();
|
||||
|
||||
// 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 ---------------------------------
|
||||
|
||||
private int remainingLives;
|
||||
|
|
@ -87,9 +114,12 @@ namespace TD.Gameplay
|
|||
|
||||
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++)
|
||||
{
|
||||
zoneLeakCounts.Add(0);
|
||||
waveLeakCounts.Add(0);
|
||||
}
|
||||
|
||||
remainingLives = startingLives;
|
||||
|
||||
|
|
@ -115,6 +145,21 @@ namespace TD.Gameplay
|
|||
ms.SetLives(remainingLives);
|
||||
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)
|
||||
StartNextWave();
|
||||
}
|
||||
|
|
@ -137,8 +182,8 @@ namespace TD.Gameplay
|
|||
public int TotalWaves => waveDefinitions?.Length ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of times enemies have leaked out of the given player's zone.
|
||||
/// Replicated — safe to call on any peer.
|
||||
/// Number of times enemies have leaked out of the given player's zone over the
|
||||
/// entire match. Replicated — safe to call on any peer.
|
||||
/// </summary>
|
||||
public int GetZoneLeakCount(PlayerSlot slot)
|
||||
{
|
||||
|
|
@ -146,6 +191,20 @@ namespace TD.Gameplay
|
|||
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 ---------------------------------------------
|
||||
|
||||
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
|
||||
|
|
@ -172,6 +231,18 @@ namespace TD.Gameplay
|
|||
// shows the upcoming wave number during the countdown.
|
||||
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;
|
||||
spawningComplete = false;
|
||||
|
||||
|
|
@ -229,9 +300,34 @@ namespace TD.Gameplay
|
|||
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
|
||||
{
|
||||
// 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)
|
||||
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.
|
||||
if (def.Entries != null)
|
||||
|
|
@ -276,11 +372,15 @@ namespace TD.Gameplay
|
|||
if (PlayerMatchState.GetForSlot(zone.Owner) == null) continue;
|
||||
|
||||
// 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)
|
||||
{
|
||||
|
|
@ -304,8 +404,8 @@ namespace TD.Gameplay
|
|||
return;
|
||||
}
|
||||
|
||||
health.InitializeServer(def.MaxHp, def.GoldReward, def.LivesCost, def.IsFlying);
|
||||
movement.InitializeServer(def.MoveSpeed, spawnerTile);
|
||||
health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying);
|
||||
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot);
|
||||
|
||||
health.OnDied += HandleEnemyKilled;
|
||||
movement.OnZoneLeaked += HandleZoneLeak;
|
||||
|
|
@ -320,22 +420,29 @@ namespace TD.Gameplay
|
|||
|
||||
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.
|
||||
PlayerSlot killerSlot = health.LastHitOwner;
|
||||
if (killerSlot != PlayerSlot.None)
|
||||
if (killerSlot != PlayerSlot.None && killReward > 0)
|
||||
{
|
||||
var pms = PlayerMatchState.GetForSlot(killerSlot);
|
||||
if (pms != null)
|
||||
PlayerGoldManager.GetForClient(pms.OwnerClientId)
|
||||
?.AwardGold(health.GoldReward);
|
||||
?.AwardGold(killReward);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
if (health.GoldReward > 0)
|
||||
ShowGoldRewardClientRpc(health.transform.position, health.GoldReward);
|
||||
if (killReward > 0)
|
||||
ShowGoldRewardClientRpc(health.transform.position, killReward);
|
||||
|
||||
UnsubscribeEnemy(health);
|
||||
DecrementAndCheckComplete();
|
||||
|
|
@ -343,10 +450,15 @@ namespace TD.Gameplay
|
|||
|
||||
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;
|
||||
if (idx >= 0 && idx < zoneLeakCounts.Count)
|
||||
zoneLeakCounts[idx]++;
|
||||
if (idx >= 0 && idx < waveLeakCounts.Count)
|
||||
waveLeakCounts[idx]++;
|
||||
}
|
||||
|
||||
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)
|
||||
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.");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue