Everything related to chat functionality, and updating the projectile prefab to rotate properly
This commit is contained in:
parent
d92d00c83f
commit
66f84652dc
14 changed files with 1133 additions and 121 deletions
|
|
@ -132,7 +132,10 @@ namespace TD.Gameplay
|
|||
|
||||
// Escape: clear selection. Allowed during placement mode too — Escape never
|
||||
// means anything else here, and clearing selection during placement is fine.
|
||||
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)
|
||||
// Suppressed while chat (or any HUD text field) has focus, since Escape there
|
||||
// means "cancel typing" and should not also clear the unit selection.
|
||||
if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame
|
||||
&& !HUDController.IsTextInputActive)
|
||||
{
|
||||
SelectionState.Instance?.Clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,8 +227,10 @@ namespace TD.Gameplay
|
|||
{
|
||||
Vector2 dir = Vector2.zero;
|
||||
|
||||
// Keyboard: WASD + arrow keys
|
||||
var kb = Keyboard.current;
|
||||
// Keyboard: WASD + arrow keys. Suppressed entirely while the player
|
||||
// is typing — pressing 'a' or 'w' into chat should not pan the camera.
|
||||
// (Edge-pan below stays active since it's mouse-driven.)
|
||||
var kb = HUDController.IsTextInputActive ? null : Keyboard.current;
|
||||
if (kb != null)
|
||||
{
|
||||
if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) dir.x -= 1f;
|
||||
|
|
|
|||
159
Assets/_Project/Scripts/Gameplay/ChatService.cs
Normal file
159
Assets/_Project/Scripts/Gameplay/ChatService.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Assets/_Project/Scripts/Gameplay/ChatService.cs
|
||||
using Unity.Collections;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Networked chat singleton. Carries player-typed messages between peers and
|
||||
/// exposes a local-only entry point for system messages (life lost, income
|
||||
/// changes, etc.). UI consumers subscribe to <see cref="OnMessageReceived"/>
|
||||
/// to display the feed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Authority model.</b> Player messages go client → server (via
|
||||
/// <see cref="SubmitMessage"/> + <see cref="SubmitMessageServerRpc"/>) so the
|
||||
/// server gets a chance to validate/filter, then the server broadcasts to
|
||||
/// every peer via <see cref="BroadcastMessageClientRpc"/>. The host receives
|
||||
/// its own broadcast like any other client, so a single subscription path
|
||||
/// handles every message type uniformly.
|
||||
///
|
||||
/// <b>System messages.</b> <see cref="PostLocalSystem"/> is local-only and
|
||||
/// does NOT cross the network. Callers typically invoke it from inside an
|
||||
/// already-replicated event (e.g. <see cref="WaveManager.OnLifeLost"/>, which
|
||||
/// fires on every peer via its own ClientRpc) so each peer posts the system
|
||||
/// message itself. This avoids paying a second round-trip for events that
|
||||
/// are inherently broadcast already.
|
||||
///
|
||||
/// <b>Scene setup.</b> Drop a <c>ChatService</c> GameObject (with a
|
||||
/// <c>NetworkObject</c>) into the gameplay scene. NGO 2.x auto-discovers
|
||||
/// the prefab — no manual registration needed.
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class ChatService : NetworkBehaviour
|
||||
{
|
||||
// ----- Singleton --------------------------------------------------
|
||||
|
||||
public static ChatService Instance { get; private set; }
|
||||
|
||||
// ----- Message types ----------------------------------------------
|
||||
|
||||
public enum MessageKind
|
||||
{
|
||||
Player,
|
||||
System,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One chat feed entry. <see cref="SenderName"/> is empty for system messages.
|
||||
/// </summary>
|
||||
public readonly struct ChatEntry
|
||||
{
|
||||
public readonly MessageKind Kind;
|
||||
public readonly string SenderName;
|
||||
public readonly string Text;
|
||||
|
||||
public ChatEntry(MessageKind kind, string senderName, string text)
|
||||
{
|
||||
Kind = kind;
|
||||
SenderName = senderName;
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Local events -----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fires on every peer when a player message arrives (after the
|
||||
/// server's ClientRpc) OR when a local system message is posted via
|
||||
/// <see cref="PostLocalSystem"/>. HUD subscribes to render the feed.
|
||||
/// </summary>
|
||||
public static event System.Action<ChatEntry> OnMessageReceived;
|
||||
|
||||
// ----- NGO lifecycle ----------------------------------------------
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[ChatService] Duplicate instance detected. " +
|
||||
"Only one ChatService should exist per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ----- Player messages (network round-trip) -----------------------
|
||||
|
||||
/// <summary>
|
||||
/// Submits a chat message authored by the local player. Empty / whitespace
|
||||
/// strings are dropped. Text is trimmed and truncated to fit the wire
|
||||
/// payload (~120 chars).
|
||||
/// </summary>
|
||||
public void SubmitMessage(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
text = text.Trim();
|
||||
if (text.Length > 120) text = text.Substring(0, 120);
|
||||
|
||||
FixedString128Bytes payload = default;
|
||||
payload.Append(text);
|
||||
SubmitMessageRpc(payload);
|
||||
}
|
||||
|
||||
// NGO 2.x unified RPC attribute. SendTo.Server + InvokePermission.Everyone
|
||||
// is the modern replacement for [ServerRpc(RequireOwnership = false)] —
|
||||
// any client (not just the NetworkObject's owner) may invoke it.
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||
private void SubmitMessageRpc(FixedString128Bytes text, RpcParams rpcParams = default)
|
||||
{
|
||||
// Validation / filtering hook — drop spam, profanity, etc. For now we
|
||||
// just broadcast unchanged. The server origin tag means whatever
|
||||
// appears in clients' chat is what the server allowed through.
|
||||
BroadcastMessageRpc(rpcParams.Receive.SenderClientId, text);
|
||||
}
|
||||
|
||||
// SendTo.Everyone matches the old [ClientRpc] behavior under host mode —
|
||||
// the body runs on every peer including the host's local client, so the
|
||||
// host sees its own messages in the feed without a separate local call.
|
||||
[Rpc(SendTo.Everyone)]
|
||||
private void BroadcastMessageRpc(ulong senderClientId, FixedString128Bytes text)
|
||||
{
|
||||
string senderName = ResolveSenderName(senderClientId);
|
||||
OnMessageReceived?.Invoke(
|
||||
new ChatEntry(MessageKind.Player, senderName, text.ToString()));
|
||||
}
|
||||
|
||||
// ----- System messages (local only) -------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Posts a system message to the local chat feed only. Does NOT cross the
|
||||
/// network. Callers should invoke this from a code path that's already
|
||||
/// replicated on every peer (e.g. a ClientRpc handler or an event that's
|
||||
/// fired on every peer) so each peer sees the message exactly once.
|
||||
/// </summary>
|
||||
public static void PostLocalSystem(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
OnMessageReceived?.Invoke(
|
||||
new ChatEntry(MessageKind.System, "", text));
|
||||
}
|
||||
|
||||
// ----- Helpers ----------------------------------------------------
|
||||
|
||||
private static string ResolveSenderName(ulong clientId)
|
||||
{
|
||||
var pms = PlayerMatchState.GetForClient(clientId);
|
||||
if (pms != null && pms.Slot != PlayerSlot.None)
|
||||
return $"P{(int)pms.Slot}";
|
||||
return $"Client {clientId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/ChatService.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/ChatService.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 816890bb20c416b419e38d3d4b91ffca
|
||||
|
|
@ -61,6 +61,7 @@ namespace TD.Gameplay
|
|||
private PlayerSlot currentZone = PlayerSlot.None;
|
||||
private EnemyStatus status;
|
||||
private bool hasReachedGoal;
|
||||
private bool wasStuck; // dedupes the "no path" warning
|
||||
|
||||
// ----- Events ---------------------------------------------------------
|
||||
|
||||
|
|
@ -164,6 +165,12 @@ namespace TD.Gameplay
|
|||
if (toTarget.sqrMagnitude > 0.0001f)
|
||||
transform.rotation = Quaternion.LookRotation(toTarget);
|
||||
}
|
||||
|
||||
// Per-frame zone tracking. With path smoothing, waypoints can be
|
||||
// multiple tiles apart and a straight-line segment may cross zone
|
||||
// boundaries. Checking the current tile every frame ensures
|
||||
// OnZoneLeaked fires the moment the enemy enters a new zone.
|
||||
CheckZoneTransition(GridCoordinates.WorldToGrid(transform.position));
|
||||
}
|
||||
|
||||
// ----- Path management ------------------------------------------------
|
||||
|
|
@ -231,9 +238,25 @@ namespace TD.Gameplay
|
|||
|
||||
remainingPath = service.ComputePath(fromTile);
|
||||
|
||||
// Dedupe the "no path" log: only emit on the transition into stuck
|
||||
// state, not every frame a walkability change re-fires the recompute.
|
||||
// Stuck enemies are a legitimate edge case (tower placement disconnected
|
||||
// a pocket the enemy was in — placement BFS only checks spawner→exit
|
||||
// reachability, not every enemy's current tile). The enemy will just
|
||||
// stand still until a placement change re-opens a route.
|
||||
if (remainingPath.Count == 0)
|
||||
Debug.LogWarning($"[EnemyMovement] No path found from {fromTile}. " +
|
||||
"TowerPlacementManager should have prevented a full block.");
|
||||
{
|
||||
if (!wasStuck)
|
||||
{
|
||||
Debug.Log($"[EnemyMovement] No path from {fromTile} — enemy is " +
|
||||
"stuck in a disconnected pocket. Will retry on next walkability change.");
|
||||
wasStuck = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
wasStuck = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,17 @@ namespace TD.Gameplay
|
|||
/// nearest goal tile using A* on the runtime walkability grid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Algorithm:</b> A* with Manhattan-distance heuristic. Grid cost is uniform
|
||||
/// (1 per step, 4-connected, no diagonals), so A* is optimal and significantly
|
||||
/// faster than plain BFS on large grids thanks to the heuristic.
|
||||
/// <b>Algorithm:</b> A* with the octile-distance heuristic on an 8-connected grid.
|
||||
/// Cardinal steps cost 1.0, diagonal steps cost √2. Octile distance is the admissible
|
||||
/// heuristic for this cost model and yields optimal paths. Diagonal moves require
|
||||
/// both "shoulder" cardinals to be walkable (corner-cut prevention) — preserves
|
||||
/// maze-building, since a 1-tile diagonal gap between two walls won't admit enemies.
|
||||
///
|
||||
/// <b>Path smoothing:</b> After A* produces a tile-by-tile path, <see cref="SmoothPath"/>
|
||||
/// greedily collapses intermediate waypoints whenever a grid line-of-sight is clear
|
||||
/// between the current anchor and a later waypoint. This produces the any-angle
|
||||
/// straight-line movement seen in WC3/Wintermaul — enemies walk directly across open
|
||||
/// space rather than hugging 45° grid steps.
|
||||
///
|
||||
/// <b>Who calls this:</b>
|
||||
/// <list type="bullet">
|
||||
|
|
@ -55,8 +63,9 @@ namespace TD.Gameplay
|
|||
|
||||
// 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.
|
||||
private readonly Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>();
|
||||
private readonly Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>();
|
||||
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
|
||||
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
|
@ -114,9 +123,50 @@ namespace TD.Gameplay
|
|||
return new List<Vector2Int>();
|
||||
}
|
||||
|
||||
// If the requested start is non-walkable, the enemy was caught
|
||||
// standing inside a freshly-stamped tower footprint (or any tile
|
||||
// that just became blocked). Nudge to the nearest walkable tile so
|
||||
// the enemy can resume routing instead of repeatedly failing.
|
||||
// Search outward in Chebyshev rings — closest 8-neighbor wins.
|
||||
if (!loader.IsWalkable(startTile))
|
||||
{
|
||||
if (TryFindNearestWalkable(startTile, loader, maxRadius: 4, out var nudged))
|
||||
startTile = nudged;
|
||||
else
|
||||
return new List<Vector2Int>();
|
||||
}
|
||||
|
||||
return RunAStar(startTile, loader);
|
||||
}
|
||||
|
||||
// Expanding ring search (Chebyshev / 8-connected) for the nearest
|
||||
// walkable tile around <paramref name="origin"/>. Caps at maxRadius to
|
||||
// keep this O(maxRadius²) in the worst case. Used by ComputePath when
|
||||
// an enemy's start tile becomes non-walkable mid-flight.
|
||||
private static bool TryFindNearestWalkable(Vector2Int origin, LevelLoader loader,
|
||||
int maxRadius, out Vector2Int found)
|
||||
{
|
||||
for (int r = 1; r <= maxRadius; r++)
|
||||
{
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
{
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
// Only walk the OUTER ring at distance r (skip interior — already checked at smaller r).
|
||||
if (Mathf.Abs(dx) != r && Mathf.Abs(dy) != r) continue;
|
||||
var tile = new Vector2Int(origin.x + dx, origin.y + dy);
|
||||
if (loader.IsWalkable(tile))
|
||||
{
|
||||
found = tile;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----- A* implementation ------------------------------------------
|
||||
|
||||
private List<Vector2Int> RunAStar(Vector2Int start, LevelLoader loader)
|
||||
|
|
@ -125,7 +175,7 @@ namespace TD.Gameplay
|
|||
gScore.Clear();
|
||||
openSet.Clear();
|
||||
|
||||
gScore[start] = 0;
|
||||
gScore[start] = 0f;
|
||||
openSet.Enqueue(start, Heuristic(start));
|
||||
|
||||
while (openSet.Count > 0)
|
||||
|
|
@ -133,21 +183,36 @@ namespace TD.Gameplay
|
|||
Vector2Int current = openSet.Dequeue();
|
||||
|
||||
if (goalTiles.Contains(current))
|
||||
return ReconstructPath(start, current);
|
||||
{
|
||||
var tilePath = ReconstructPath(start, current);
|
||||
return SmoothPath(start, tilePath, loader);
|
||||
}
|
||||
|
||||
int currentG = gScore.TryGetValue(current, out int g) ? g : int.MaxValue;
|
||||
float currentG = gScore.TryGetValue(current, out float g) ? g : float.MaxValue;
|
||||
|
||||
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
|
||||
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||
{
|
||||
if (!loader.IsWalkable(neighbor)) continue;
|
||||
|
||||
int tentativeG = currentG + 1;
|
||||
if (gScore.TryGetValue(neighbor, out int existingG)
|
||||
// Corner-cut prevention: for a diagonal step, both cardinal
|
||||
// shoulder tiles must also be walkable. Otherwise enemies could
|
||||
// squeeze through 1-tile diagonal gaps between walls — that
|
||||
// would break the maze-building design intent.
|
||||
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||
{
|
||||
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||
out var shoulderA, out var shoulderB);
|
||||
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
||||
continue;
|
||||
}
|
||||
|
||||
float tentativeG = currentG + GridCoordinates.StepCost(current, neighbor);
|
||||
if (gScore.TryGetValue(neighbor, out float existingG)
|
||||
&& tentativeG >= existingG) continue;
|
||||
|
||||
cameFrom[neighbor] = current;
|
||||
gScore[neighbor] = tentativeG;
|
||||
int f = tentativeG + Heuristic(neighbor);
|
||||
float f = tentativeG + Heuristic(neighbor);
|
||||
|
||||
// Re-enqueue with updated priority. SimplePriorityQueue handles
|
||||
// duplicate entries by ignoring higher-cost duplicates on dequeue.
|
||||
|
|
@ -155,20 +220,23 @@ namespace TD.Gameplay
|
|||
}
|
||||
}
|
||||
|
||||
// No path found — TowerPlacementManager should have prevented this.
|
||||
Debug.LogWarning($"[PathfindingService] A* found no path from {start}. " +
|
||||
"Check that TowerPlacementManager BFS is validating correctly.");
|
||||
// No path found. This can legitimately happen during normal play
|
||||
// when an enemy is in a disconnected pocket created by a tower
|
||||
// placement (the placement BFS only verifies spawner→exit, not
|
||||
// every enemy's current tile). The EnemyMovement caller dedupes
|
||||
// its own warning so this doesn't spam the console on every
|
||||
// walkability change while an enemy is stuck.
|
||||
return new List<Vector2Int>();
|
||||
}
|
||||
|
||||
// Manhattan distance to the nearest goal tile. Admissible heuristic for
|
||||
// a 4-connected uniform-cost grid.
|
||||
private int Heuristic(Vector2Int tile)
|
||||
// Octile distance to the nearest goal tile. Admissible heuristic for an
|
||||
// 8-connected uniform-cost grid (cardinal 1, diagonal √2).
|
||||
private float Heuristic(Vector2Int tile)
|
||||
{
|
||||
int best = int.MaxValue;
|
||||
float best = float.MaxValue;
|
||||
foreach (var goal in goalTiles)
|
||||
{
|
||||
int d = GridCoordinates.ManhattanDistance(tile, goal);
|
||||
float d = GridCoordinates.OctileDistance(tile, goal);
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best;
|
||||
|
|
@ -190,6 +258,105 @@ namespace TD.Gameplay
|
|||
return path;
|
||||
}
|
||||
|
||||
// ----- Path smoothing ---------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Greedy line-of-sight path simplification. Walks the input tile path and
|
||||
/// collapses runs of tiles into single waypoints whenever a straight grid
|
||||
/// line-of-sight is clear. Produces any-angle paths from the otherwise
|
||||
/// 45°-stepped 8-connected A* output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Algorithm: maintain an "anchor" (initially the start tile). For each
|
||||
/// position in the path, find the FARTHEST subsequent waypoint that has clear
|
||||
/// LOS from the anchor. Add it to the smoothed result and make it the new
|
||||
/// anchor. Repeat. O(n²) in path length, acceptable for typical TD path
|
||||
/// lengths (< 100 tiles).
|
||||
///
|
||||
/// LOS check uses Bresenham line walk with the same corner-cut rules as A*
|
||||
/// — a smoothed segment never crosses through a non-walkable tile and never
|
||||
/// squeezes through a diagonal corner.
|
||||
/// </remarks>
|
||||
private List<Vector2Int> SmoothPath(Vector2Int start, List<Vector2Int> path,
|
||||
LevelLoader loader)
|
||||
{
|
||||
if (path.Count <= 1) return path;
|
||||
|
||||
var result = new List<Vector2Int>(path.Count);
|
||||
Vector2Int anchor = start;
|
||||
int i = 0;
|
||||
|
||||
while (i < path.Count)
|
||||
{
|
||||
// Find the farthest waypoint we can directly reach from the anchor.
|
||||
int farthest = i;
|
||||
for (int j = path.Count - 1; j > i; j--)
|
||||
{
|
||||
if (HasLineOfSight(anchor, path[j], loader))
|
||||
{
|
||||
farthest = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(path[farthest]);
|
||||
anchor = path[farthest];
|
||||
i = farthest + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid line-of-sight test from <paramref name="a"/> to <paramref name="b"/>.
|
||||
/// Returns true if a straight Bresenham line between the two tiles only
|
||||
/// crosses walkable tiles, with no diagonal corner-cuts. Used by
|
||||
/// <see cref="SmoothPath"/> to decide whether two waypoints can be collapsed.
|
||||
/// </summary>
|
||||
private static bool HasLineOfSight(Vector2Int a, Vector2Int b, LevelLoader loader)
|
||||
{
|
||||
int x0 = a.x, y0 = a.y;
|
||||
int x1 = b.x, y1 = b.y;
|
||||
int dx = Mathf.Abs(x1 - x0);
|
||||
int dy = Mathf.Abs(y1 - y0);
|
||||
int sx = (x0 < x1) ? 1 : -1;
|
||||
int sy = (y0 < y1) ? 1 : -1;
|
||||
int err = dx - dy;
|
||||
|
||||
int x = x0, y = y0;
|
||||
|
||||
while (x != x1 || y != y1)
|
||||
{
|
||||
int e2 = 2 * err;
|
||||
bool steppedX = false, steppedY = false;
|
||||
|
||||
if (e2 > -dy)
|
||||
{
|
||||
err -= dy;
|
||||
x += sx;
|
||||
steppedX = true;
|
||||
}
|
||||
if (e2 < dx)
|
||||
{
|
||||
err += dx;
|
||||
y += sy;
|
||||
steppedY = true;
|
||||
}
|
||||
|
||||
// Diagonal step: enforce corner-cut prevention against the two
|
||||
// shoulder tiles (same rule A* uses).
|
||||
if (steppedX && steppedY)
|
||||
{
|
||||
if (!loader.IsWalkable(new Vector2Int(x - sx, y))) return false;
|
||||
if (!loader.IsWalkable(new Vector2Int(x, y - sy))) return false;
|
||||
}
|
||||
|
||||
if (!loader.IsWalkable(new Vector2Int(x, y))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----- Helpers ----------------------------------------------------
|
||||
|
||||
private void BuildGoalTileSet()
|
||||
|
|
@ -226,14 +393,15 @@ namespace TD.Gameplay
|
|||
// -------------------------------------------------------------------------
|
||||
internal sealed class SimplePriorityQueue
|
||||
{
|
||||
private readonly List<(int priority, Vector2Int tile)> heap
|
||||
= new List<(int, Vector2Int)>();
|
||||
// Float priority to support diagonal cost (√2) in 8-connected A*.
|
||||
private readonly List<(float priority, Vector2Int tile)> heap
|
||||
= new List<(float, Vector2Int)>();
|
||||
|
||||
public int Count => heap.Count;
|
||||
|
||||
public void Clear() => heap.Clear();
|
||||
|
||||
public void Enqueue(Vector2Int tile, int priority)
|
||||
public void Enqueue(Vector2Int tile, float priority)
|
||||
{
|
||||
heap.Add((priority, tile));
|
||||
SiftUp(heap.Count - 1);
|
||||
|
|
|
|||
|
|
@ -494,10 +494,25 @@ namespace TD.Gameplay
|
|||
if (exitTiles.Contains(current))
|
||||
return true;
|
||||
|
||||
foreach (var neighbor in GridCoordinates.GetNeighbors(current))
|
||||
// 8-connected expansion to match enemy pathfinding. A 4-connected
|
||||
// BFS here would reject placements enemies could actually navigate
|
||||
// around via diagonals, OR accept placements that diagonally squeeze
|
||||
// through corners. Corner-cut prevention keeps the maze rule consistent
|
||||
// with PathfindingService: a diagonal step requires both shoulder
|
||||
// cardinal tiles to be walkable.
|
||||
foreach (var neighbor in GridCoordinates.GetNeighbors8(current))
|
||||
{
|
||||
if (bfsVisited.Contains(neighbor)) continue;
|
||||
if (!loader.IsWalkable(neighbor)) continue;
|
||||
|
||||
if (GridCoordinates.IsDiagonal(current, neighbor))
|
||||
{
|
||||
GridCoordinates.GetCornerShoulders(current, neighbor,
|
||||
out var shoulderA, out var shoulderB);
|
||||
if (!loader.IsWalkable(shoulderA) || !loader.IsWalkable(shoulderB))
|
||||
continue;
|
||||
}
|
||||
|
||||
bfsVisited.Add(neighbor);
|
||||
bfsQueue.Enqueue(neighbor);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,8 +387,20 @@ namespace TD.Gameplay
|
|||
private void ShowLifeLossClientRpc(Vector3 worldPos, int amount)
|
||||
{
|
||||
FloatingTextSpawner.Instance?.SpawnLifeLoss(worldPos, amount);
|
||||
OnLifeLost?.Invoke(amount);
|
||||
}
|
||||
|
||||
// ----- Local-only notification events -----------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer immediately after a life-loss popup spawns.
|
||||
/// HUD subscribes to flash a centered banner; gameplay code can also
|
||||
/// hook this for audio cues, screen-shake, etc. Argument is the number
|
||||
/// of lives lost (usually 1, but boss enemies with LivesCost > 1
|
||||
/// fire a single event carrying the larger value).
|
||||
/// </summary>
|
||||
public static event System.Action<int> OnLifeLost;
|
||||
|
||||
// ----- Helpers ----------------------------------------------------
|
||||
|
||||
private void UnsubscribeEnemy(EnemyHealth health)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue