2.0_design_refactor #13
64 changed files with 4023 additions and 344 deletions
|
|
@ -8,9 +8,22 @@ namespace TD.Core
|
|||
/// </summary>
|
||||
public enum EnemyAbilityKind : byte
|
||||
{
|
||||
/// <summary>No ability rolled. Never a real pool entry — only the sentinel value
|
||||
/// <see cref="TD.Gameplay.EnemyAbility"/> reports before/absent a roll.</summary>
|
||||
/// <summary>Sentinel for "no ability". Never a real pool entry.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Dies into several smaller, faster copies of itself.</summary>
|
||||
SplitOnDeath = 1,
|
||||
|
||||
/// <summary>Grounded enemies take to the air, ignoring the maze entirely.</summary>
|
||||
Flight = 2,
|
||||
|
||||
/// <summary>Teleports a short distance further along its path on a timer.</summary>
|
||||
Blink = 3,
|
||||
|
||||
/// <summary>Grants no kill bounty.</summary>
|
||||
NoBounty = 4,
|
||||
|
||||
/// <summary>Steals gold from the player whose zone it escaped, on top of the life cost.</summary>
|
||||
GoldTheft = 5,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ namespace TD.Core
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>None</c> is a sentinel value used in <c>OwnerGrid</c> to mark tiles not owned by any player zone.
|
||||
/// Player1..Player9 cover the maximum supported player count. Maps using fewer players use a
|
||||
/// contiguous prefix (e.g., a 3-player map uses Player1, Player2, Player3 only).
|
||||
/// Maps use a contiguous prefix of the player values.
|
||||
///
|
||||
/// <para><b>The enum runs to 9, but only <see cref="MatchRules.MaxPlayers"/> slots are ever
|
||||
/// allocated.</b> Lobbies capped at 3 for the 2.0 design; the surplus values are kept because
|
||||
/// they are baked into existing <c>LevelData</c> owner grids, and renumbering the enum would
|
||||
/// force a re-bake of every map to no runtime benefit.</para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Global phase of a match, driven by <c>MatchState</c>.
|
||||
|
|
@ -92,6 +96,28 @@ namespace TD.Core
|
|||
Defeat = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which step of the between-encounters sequence is currently running. Replicated by
|
||||
/// <c>WaveManager</c> so every HUD can label the shared countdown correctly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These run strictly in order and never overlap: players take their personal draft, then vote
|
||||
/// on the buff for the wave they just cleared, then build. The build timer deliberately does
|
||||
/// NOT run concurrently with the choices — "after all players have chosen" only means
|
||||
/// something if it isn't racing a clock players also want to spend on their maze.
|
||||
/// </remarks>
|
||||
public enum InterWaveStage : byte
|
||||
{
|
||||
/// <summary>No inter-wave step running — a wave is spawning or being fought.</summary>
|
||||
None = 0,
|
||||
/// <summary>Players are picking their personal draft reward.</summary>
|
||||
Draft = 1,
|
||||
/// <summary>Players are voting on the buff for the wave they just cleared.</summary>
|
||||
Vote = 2,
|
||||
/// <summary>Build countdown before the next wave spawns.</summary>
|
||||
Build = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable identifier per race. Values 1-16 reserve slots for the planned
|
||||
/// 16-race grid in the lobby; only races with a corresponding
|
||||
|
|
|
|||
26
Assets/_Project/Scripts/Core/MatchRules.cs
Normal file
26
Assets/_Project/Scripts/Core/MatchRules.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Assets/_Project/Scripts/Core/MatchRules.cs
|
||||
namespace TD.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Match-wide constants that several unrelated systems have to agree on.
|
||||
/// </summary>
|
||||
public static class MatchRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum players in one lobby.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Reduced from 9 to 3 for the 2.0 design</b> as a deliberate scope cut: three players
|
||||
/// is enough for the shared-lives and shared-vote mechanics to matter, and it shrinks map
|
||||
/// authoring, balance surface, and network load all at once.
|
||||
///
|
||||
/// <para><b>Why <see cref="PlayerSlot"/> still runs to 9.</b> Shrinking the enum would
|
||||
/// invalidate the owner grids baked into existing <c>LevelData</c> assets and force a
|
||||
/// re-bake of every map, for no runtime benefit — the extra values are simply never
|
||||
/// allocated. Slot allocation caps here instead, which is the only place that decides who
|
||||
/// gets a slot at all. Per-slot arrays stay sized to the enum, so they hold a few unused
|
||||
/// entries; that is intentional and costs nothing.</para>
|
||||
/// </remarks>
|
||||
public const int MaxPlayers = 3;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Core/MatchRules.cs.meta
Normal file
2
Assets/_Project/Scripts/Core/MatchRules.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: ac169573e8a2fd046a3ebac7d2e03c8c
|
||||
35
Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
Normal file
35
Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Assets/_Project/Scripts/Core/PoolWeightAttribute.cs
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks a float field as a 0–1 <b>relative</b> draw weight inside an authored pool. The
|
||||
/// inspector renders it as a slider and, when the value is zero, shows a warning directly
|
||||
/// beneath explaining that the entry can never be drawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Relative, not absolute.</b> Weights are normalized against the summed weight of the
|
||||
/// candidate set at draw time, so entries at equal weight are equally likely and an entry at
|
||||
/// 0.5 is half as likely as one at 1.0. This is why every weight field defaults to 1 —
|
||||
/// adding an entry to a pool then never silently re-weights the entries already in it.
|
||||
///
|
||||
/// <para><b>The warning is advisory.</b> Muting an entry while tuning is a legitimate
|
||||
/// workflow, so the drawer never clamps the value or blocks the edit. It exists because a
|
||||
/// zero-weight entry is otherwise indistinguishable, in play, from one that simply hasn't come
|
||||
/// up yet — the failure is silent, so the authoring surface has to be loud.</para>
|
||||
/// </remarks>
|
||||
public class PoolWeightAttribute : PropertyAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Noun used in the zero-weight warning ("this <c>wave</c> can never be drawn"). Keep it
|
||||
/// singular and lowercase.
|
||||
/// </summary>
|
||||
public readonly string Noun;
|
||||
|
||||
public PoolWeightAttribute(string noun = "entry")
|
||||
{
|
||||
Noun = string.IsNullOrWhiteSpace(noun) ? "entry" : noun;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta
Normal file
2
Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e171856d7a8fe194f97091853672c19d
|
||||
|
|
@ -33,6 +33,10 @@ namespace TD.Dev
|
|||
"verified now. Set to Key.None to use the OnGUI button only.")]
|
||||
[SerializeField] private Key grantTowerHotkey = Key.F8;
|
||||
|
||||
[Tooltip("Keyboard shortcut to skip every remaining encounter in the current phase and " +
|
||||
"go straight to its boss. Set to Key.None to use the OnGUI button only.")]
|
||||
[SerializeField] private Key skipToBossHotkey = Key.F7;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
var kb = Keyboard.current;
|
||||
|
|
@ -43,6 +47,9 @@ namespace TD.Dev
|
|||
|
||||
if (grantTowerHotkey != Key.None && kb[grantTowerHotkey].wasPressedThisFrame)
|
||||
TryGrantNextTower();
|
||||
|
||||
if (skipToBossHotkey != Key.None && kb[skipToBossHotkey].wasPressedThisFrame)
|
||||
TrySkipToBoss();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
|
|
@ -59,11 +66,13 @@ namespace TD.Dev
|
|||
|
||||
// Anchored below the top HUD bar so it doesn't overlap gold/wave/lives.
|
||||
const float topOffset = 90f;
|
||||
GUI.Box(new Rect(10, topOffset, 180, 95), "Dev: Wave Controls");
|
||||
GUI.Box(new Rect(10, topOffset, 180, 128), "Dev: Wave Controls");
|
||||
if (GUI.Button(new Rect(20, topOffset + 25, 160, 28), "Force Next Wave"))
|
||||
TryForceNextWave();
|
||||
if (GUI.Button(new Rect(20, topOffset + 58, 160, 28), "Grant Next Tower"))
|
||||
TryGrantNextTower();
|
||||
if (GUI.Button(new Rect(20, topOffset + 91, 160, 28), "Skip To Boss"))
|
||||
TrySkipToBoss();
|
||||
}
|
||||
|
||||
// Dev stand-in for the draft system: grants the local player the first catalog
|
||||
|
|
@ -118,5 +127,27 @@ namespace TD.Dev
|
|||
Debug.Log("[DevWaveControls] Forcing next wave.");
|
||||
wm.ForceAdvanceToNextWave();
|
||||
}
|
||||
|
||||
// Skips straight to the phase boss so boss content can be tested without playing
|
||||
// every cycle first. Server-only, like the other cheats.
|
||||
private void TrySkipToBoss()
|
||||
{
|
||||
if (NetworkManager.Singleton == null || !NetworkManager.Singleton.IsServer)
|
||||
{
|
||||
Debug.LogWarning("[DevWaveControls] Skip-to-boss requested off the server. Ignored.");
|
||||
return;
|
||||
}
|
||||
|
||||
var wm = WaveManager.Instance;
|
||||
if (wm == null)
|
||||
{
|
||||
Debug.LogWarning("[DevWaveControls] WaveManager.Instance is null. " +
|
||||
"Is the scene running with a WaveManager network-spawned?");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log("[DevWaveControls] Skipping to boss.");
|
||||
wm.ForceAdvanceToBoss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
Assets/_Project/Scripts/Editor/Core.meta
Normal file
8
Assets/_Project/Scripts/Editor/Core.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 75cc168bfd0b91b41a313a4389d31910
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
66
Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
Normal file
66
Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Editor.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws a <see cref="PoolWeightAttribute"/> field as a 0–1 slider, with an inline warning
|
||||
/// rendered directly beneath it whenever the weight is zero.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately does not clamp, revert, or disable the field — a designer muting an entry
|
||||
/// while tuning is normal. The drawer's only job is to make the consequence visible at the
|
||||
/// point of editing, since a zero-weight entry fails silently at runtime.
|
||||
/// </remarks>
|
||||
[CustomPropertyDrawer(typeof(PoolWeightAttribute))]
|
||||
public class PoolWeightDrawer : PropertyDrawer
|
||||
{
|
||||
// Two lines of text alongside the icon column need more than one line of box height.
|
||||
private const float HelpBoxLines = 2.4f;
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||
{
|
||||
float h = EditorGUIUtility.singleLineHeight;
|
||||
|
||||
if (IsZero(property))
|
||||
h += EditorGUIUtility.standardVerticalSpacing + HelpBoxHeight();
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
if (property.propertyType != SerializedPropertyType.Float)
|
||||
{
|
||||
EditorGUI.LabelField(position, label.text, "[PoolWeight] only supports float fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sliderRect = new Rect(position.x, position.y, position.width,
|
||||
EditorGUIUtility.singleLineHeight);
|
||||
EditorGUI.Slider(sliderRect, property, 0f, 1f, label);
|
||||
|
||||
if (!IsZero(property)) return;
|
||||
|
||||
var attr = (PoolWeightAttribute)attribute;
|
||||
var boxRect = new Rect(
|
||||
position.x,
|
||||
position.y + EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing,
|
||||
position.width,
|
||||
HelpBoxHeight());
|
||||
|
||||
EditorGUI.HelpBox(
|
||||
boxRect,
|
||||
$"Weight is 0 — this {attr.Noun} can never be drawn and will not appear in game. " +
|
||||
$"Raise it above 0 to put it back in the pool.",
|
||||
MessageType.Warning);
|
||||
}
|
||||
|
||||
private static bool IsZero(SerializedProperty property)
|
||||
=> property.propertyType == SerializedPropertyType.Float && property.floatValue <= 0f;
|
||||
|
||||
private static float HelpBoxHeight() => EditorGUIUtility.singleLineHeight * HelpBoxLines;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8ba57381fc7e6a3479e59d84e45720d9
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Editor.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Inspector for <see cref="RunDefinition"/>. Draws the normal fields, then a live
|
||||
/// validation panel reporting whether the authored pools can actually produce a run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The check that matters here is the distinct-enemy-type one: because enemy upgrades are
|
||||
/// keyed to a wave slot, a phase must be able to fill its cycle with waves that use different
|
||||
/// enemies. A pool can look healthy (plenty of entries) and still fail that. Reporting it in
|
||||
/// the inspector turns a startup error into something a designer sees while authoring.
|
||||
/// </remarks>
|
||||
[CustomEditor(typeof(RunDefinition))]
|
||||
public class RunDefinitionEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
var run = (RunDefinition)target;
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Validation", EditorStyles.boldLabel);
|
||||
|
||||
bool ok = run.Validate(out string report);
|
||||
EditorGUILayout.HelpBox(report, ok ? MessageType.Info : MessageType.Error);
|
||||
|
||||
// Per-phase capacity readout — the fastest way to see how much headroom a pool has
|
||||
// before the distinct-type requirement starts biting.
|
||||
if (run.Phases != null)
|
||||
{
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Phase capacity", EditorStyles.boldLabel);
|
||||
|
||||
for (int i = 0; i < run.Phases.Length; i++)
|
||||
{
|
||||
var phase = run.Phases[i];
|
||||
if (phase == null)
|
||||
{
|
||||
EditorGUILayout.LabelField($"Phase {i + 1}", "— empty —");
|
||||
continue;
|
||||
}
|
||||
|
||||
int distinct = phase.CountDistinctEnemyTypes();
|
||||
string value = $"{distinct} distinct enemy type(s) / {run.WavesPerCycle} needed";
|
||||
var style = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
normal = { textColor = distinct >= run.WavesPerCycle
|
||||
? EditorStyles.label.normal.textColor
|
||||
: new Color(0.9f, 0.4f, 0.3f) }
|
||||
};
|
||||
EditorGUILayout.LabelField(phase.Label, value, style);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space();
|
||||
if (GUILayout.Button("Rebuild Wave Index"))
|
||||
{
|
||||
run.RebuildIndex();
|
||||
Debug.Log($"[RunDefinition] Rebuilt wave index: {run.WaveCount} distinct wave(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5f5e8e0197efd154b92817bbb3028b35
|
||||
|
|
@ -82,7 +82,7 @@ namespace TD.Gameplay.Draft
|
|||
draft.ServerOffer(resultScratch);
|
||||
}
|
||||
|
||||
/// <summary>Server-only: auto-resolve any unpicked drafts (called at prep end).</summary>
|
||||
/// <summary>Server-only: auto-resolve any unpicked drafts (called when the timer expires).</summary>
|
||||
public void ServerAutoResolveAll()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
|
@ -90,6 +90,29 @@ namespace TD.Gameplay.Draft
|
|||
PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True once every connected player has resolved their draft. The inter-wave step polls
|
||||
/// this to end the draft timer early instead of making everyone wait out the clock.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns false when nobody is connected, so an empty lobby can't trip the barrier and
|
||||
/// race the sequence forward.
|
||||
/// </remarks>
|
||||
public static bool AllPlayersPicked
|
||||
{
|
||||
get
|
||||
{
|
||||
bool any = false;
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
{
|
||||
any = true;
|
||||
var draft = PlayerDraft.GetForClient(pms.OwnerClientId);
|
||||
if (draft != null && draft.HasActiveDraft) return false;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Generation -------------------------------------------------
|
||||
|
||||
// Weighted random draw WITHOUT replacement: picks `count` distinct option ids from
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ namespace TD.Gameplay.Draft
|
|||
/// <para><b>Authority.</b> The server offers (<see cref="ServerOffer"/>), resolves
|
||||
/// (<see cref="ServerResolve"/> / <see cref="ServerAutoResolve"/>), and applies the
|
||||
/// chosen option. The owning client only sends intent via
|
||||
/// <see cref="RequestPickRpc"/> and <see cref="RequestBuyRerollRpc"/>.</para>
|
||||
/// <see cref="RequestPickRpc"/>.</para>
|
||||
///
|
||||
/// <para><b>The paid reroll is disabled</b> for the 2.0 MVP — see the commented-out block at
|
||||
/// the bottom of this file for why it was kept rather than removed.</para>
|
||||
///
|
||||
/// <para><b>Generation</b> lives in <see cref="DraftService"/>; this component is just
|
||||
/// the replicated state + the pick/buy entry points.</para>
|
||||
|
|
@ -143,25 +146,38 @@ namespace TD.Gameplay.Draft
|
|||
ServerResolve(optionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owning client: buy a fresh roll of options for gold. Rejected if the player can't
|
||||
/// afford it or already has an unresolved draft (resolve the current one first so a
|
||||
/// free pick is never silently overwritten).
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
public void RequestBuyRerollRpc()
|
||||
{
|
||||
if (HasActiveDraft) return; // resolve the pending draft before buying another
|
||||
|
||||
var service = DraftService.Instance;
|
||||
if (service == null) return;
|
||||
|
||||
var gold = PlayerGoldManager.GetForClient(OwnerClientId);
|
||||
int cost = service.RerollCost;
|
||||
if (gold == null || gold.CurrentGold < cost) return;
|
||||
|
||||
gold.DeductGold(cost);
|
||||
service.ServerOfferTo(OwnerClientId);
|
||||
}
|
||||
// ----- Extra-draft shop (DISABLED for the 2.0 MVP) --------------------
|
||||
//
|
||||
// Kept rather than deleted: the shop is expected back, but as a SHARED gold sink — the
|
||||
// whole lobby pools money toward one collective extra draft for everyone — rather than
|
||||
// the per-player reroll implemented here. When it returns, this method is the wrong shape
|
||||
// (per-player deduction, per-player offer) but the surrounding plumbing is right, so it
|
||||
// stays as the reference for what to rebuild against.
|
||||
//
|
||||
// Re-enabling this alone would also break the inter-wave barrier: DraftService.
|
||||
// AllPlayersPicked ends the draft stage the moment everyone has resolved, so a player
|
||||
// buying a roll after that point would be offered cards the sequence has already moved
|
||||
// past.
|
||||
//
|
||||
// /// <summary>
|
||||
// /// Owning client: buy a fresh roll of options for gold. Rejected if the player can't
|
||||
// /// afford it or already has an unresolved draft (resolve the current one first so a
|
||||
// /// free pick is never silently overwritten).
|
||||
// /// </summary>
|
||||
// [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
// public void RequestBuyRerollRpc()
|
||||
// {
|
||||
// if (HasActiveDraft) return; // resolve the pending draft before buying another
|
||||
//
|
||||
// var service = DraftService.Instance;
|
||||
// if (service == null) return;
|
||||
//
|
||||
// var gold = PlayerGoldManager.GetForClient(OwnerClientId);
|
||||
// int cost = service.RerollCost;
|
||||
// if (gold == null || gold.CurrentGold < cost) return;
|
||||
//
|
||||
// gold.DeductGold(cost);
|
||||
// service.ServerOfferTo(OwnerClientId);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,52 +5,62 @@ using TD.Towers;
|
|||
namespace TD.Gameplay.Draft
|
||||
{
|
||||
/// <summary>
|
||||
/// Draft choice — "upgrade a tower you already have". Replaces <see cref="BaseTower"/>
|
||||
/// with <see cref="UpgradedTower"/> in the player's <see cref="PlayerTowerDeck"/>: every
|
||||
/// tower of that type placed from now on is the upgraded version. Already-placed towers
|
||||
/// are unaffected.
|
||||
/// Draft choice — "unlock an upgrade". Adds <see cref="UpgradedTower"/> to the player's
|
||||
/// <see cref="PlayerTowerUpgrades"/> set, letting them spend gold converting a placed
|
||||
/// <see cref="BaseTower"/> into it. The base tower stays buildable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="NewTowerDraftOption"/>, but swaps rather than adds. Only offered
|
||||
/// while the player currently has <see cref="BaseTower"/> unlocked — once taken,
|
||||
/// <see cref="BaseTower"/> is removed from the deck, which automatically makes this option
|
||||
/// (and any sibling upgrade branching off the same base) invalid for future drafts.
|
||||
/// <para><b>Unlock, not swap.</b> This option used to replace the base tower in the player's
|
||||
/// deck, which made the draft pick itself the whole upgrade — free, instant, and retroactive to
|
||||
/// every tower built afterward. The 2.0 model splits it: the draft grants <i>access</i>, gold
|
||||
/// grants the <i>tower</i>. So the deck is untouched here and already-placed towers are
|
||||
/// upgraded one at a time, each paid for.</para>
|
||||
///
|
||||
/// <para><b>Prerequisites fall out of the tree.</b> An upgrade is offerable only once its
|
||||
/// parent is reachable — either the parent is a base tower in the player's deck, or it's an
|
||||
/// upgrade node they've already unlocked. That single rule is what gates deep nodes behind
|
||||
/// shallow ones without any explicit prerequisite list: authoring the tree in
|
||||
/// <see cref="TowerDefinition.UpgradePaths"/> is enough.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "TowerUpgradeOption", menuName = "TD/Draft/Tower Upgrade Option")]
|
||||
public class TowerUpgradeDraftOption : DraftOption
|
||||
{
|
||||
[Header("Payload")]
|
||||
[Tooltip("The tower this option upgrades from. Must be present in the player's deck " +
|
||||
"for this option to be offered.")]
|
||||
[Tooltip("The tower this upgrade branches from — the tree node that must already be " +
|
||||
"reachable (in the player's deck, or itself an unlocked upgrade) for this " +
|
||||
"option to be offered.")]
|
||||
public TowerDefinition BaseTower;
|
||||
|
||||
[Tooltip("The tower this option upgrades to. Must also be present in the " +
|
||||
"TowerPlacementManager catalog (that's where its TowerTypeId comes from).")]
|
||||
[Tooltip("The tower this option unlocks as an upgrade target. Must be in the " +
|
||||
"TowerPlacementManager catalog (that's where its TowerTypeId comes from) and " +
|
||||
"should be listed in BaseTower's UpgradePaths so the conversion is legal.")]
|
||||
public TowerDefinition UpgradedTower;
|
||||
|
||||
public override bool IsValidFor(ulong clientId)
|
||||
{
|
||||
var deck = PlayerTowerDeck.GetForClient(clientId);
|
||||
var pm = TowerPlacementManager.Instance;
|
||||
if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false;
|
||||
var deck = PlayerTowerDeck.GetForClient(clientId);
|
||||
var upgrades = PlayerTowerUpgrades.GetForClient(clientId);
|
||||
var pm = TowerPlacementManager.Instance;
|
||||
|
||||
if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) return false;
|
||||
if (deck == null || upgrades == null || pm == null) return false;
|
||||
if (BaseTower == null || UpgradedTower == null) return false;
|
||||
|
||||
return deck.Contains(baseTypeId);
|
||||
if (!pm.TryGetTypeId(BaseTower, out int baseTypeId)) return false;
|
||||
if (!pm.TryGetTypeId(UpgradedTower, out int upgradedTypeId)) return false;
|
||||
|
||||
// Already unlocked — offering it again would waste the pick.
|
||||
if (upgrades.Contains(upgradedTypeId)) return false;
|
||||
|
||||
// The parent must be reachable: a base tower they can build, or an upgrade they've
|
||||
// already taken. This is the whole prerequisite mechanism.
|
||||
return deck.Contains(baseTypeId) || upgrades.Contains(baseTypeId);
|
||||
}
|
||||
|
||||
public override bool ServerApply(ulong clientId)
|
||||
{
|
||||
var deck = PlayerTowerDeck.GetForClient(clientId);
|
||||
var pm = TowerPlacementManager.Instance;
|
||||
if (deck == null || pm == null || BaseTower == null || UpgradedTower == null) return false;
|
||||
|
||||
if (!pm.TryGetTypeId(BaseTower, out int baseTypeId))
|
||||
{
|
||||
Debug.LogError($"[TowerUpgradeDraftOption] '{BaseTower.name}' is not in the tower " +
|
||||
$"catalog; cannot apply. Add it to TowerPlacementManager.towerDefinitions.");
|
||||
return false;
|
||||
}
|
||||
var upgrades = PlayerTowerUpgrades.GetForClient(clientId);
|
||||
var pm = TowerPlacementManager.Instance;
|
||||
if (upgrades == null || pm == null || UpgradedTower == null) return false;
|
||||
|
||||
if (!pm.TryGetTypeId(UpgradedTower, out int upgradedTypeId))
|
||||
{
|
||||
|
|
@ -60,7 +70,7 @@ namespace TD.Gameplay.Draft
|
|||
return false;
|
||||
}
|
||||
|
||||
return deck.ServerUpgradeTower(baseTypeId, upgradedTypeId);
|
||||
return upgrades.ServerUnlock(upgradedTypeId);
|
||||
}
|
||||
|
||||
/// <summary>Inherits the upgraded tower's icon unless this option assigns an override.</summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Every few seconds, the enemy teleports a short distance further along its path — skipping
|
||||
/// tiles, and the towers covering them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blinks follow the path rather than cutting toward the goal, so the maze still shapes the
|
||||
/// route; the buff shortens time-under-fire rather than bypassing walls. See
|
||||
/// <c>EnemyMovement.ServerBlinkForward</c>.
|
||||
///
|
||||
/// <para>The cooldown lives in this enemy's own timer slot rather than on the asset. A field
|
||||
/// here would be shared by every enemy carrying the buff, so the whole wave would blink in
|
||||
/// perfect unison — which looks like a bug even when the timing is right.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "BlinkAbility", menuName = "TD/Enemy Abilities/Blink")]
|
||||
public class BlinkAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.Blink;
|
||||
|
||||
[Header("Blink")]
|
||||
[Tooltip("Seconds between blinks.")]
|
||||
[Min(0.1f)]
|
||||
public float IntervalSeconds = 4f;
|
||||
|
||||
[Tooltip("How many path waypoints to skip per blink. Note that path smoothing can make " +
|
||||
"one waypoint span several tiles, so this is a coarser dial than it looks.")]
|
||||
[Min(1)]
|
||||
public int BlinkWaypoints = 2;
|
||||
|
||||
[Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " +
|
||||
"blink in one synchronized block. Applied once, at spawn.")]
|
||||
[Min(0f)]
|
||||
public float StartJitterSeconds = 1.5f;
|
||||
|
||||
public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex)
|
||||
{
|
||||
// Seed each enemy's cooldown with a different negative offset so the wave's first
|
||||
// blink is staggered. Done once, here, rather than on the first tick — a tick-time
|
||||
// check would have to distinguish "never started" from "just blinked", and both
|
||||
// states read as a zero timer.
|
||||
if (StartJitterSeconds > 0f)
|
||||
instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds);
|
||||
}
|
||||
|
||||
public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt)
|
||||
{
|
||||
ref float timer = ref instance.TimerFor(abilityIndex);
|
||||
timer += dt;
|
||||
if (timer < IntervalSeconds) return;
|
||||
|
||||
timer = 0f;
|
||||
instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkWaypoints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4fbb09f55384ac44399ac4655b2b53ec
|
||||
|
|
@ -5,21 +5,28 @@ using TD.Core;
|
|||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled
|
||||
/// at random for each spawned enemy by <see cref="EnemyAbilityPool.RollRandom"/> and applied
|
||||
/// via <see cref="EnemyAbility.InitializeServer"/>.
|
||||
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Abilities
|
||||
/// are attached to a <b>wave slot</b> by the post-wave player vote, and every enemy that wave
|
||||
/// spawns from then on carries the whole accumulated set.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>One asset per kind.</b> <see cref="Kind"/> is fixed per subclass, same as
|
||||
/// <see cref="BuilderSpells.BuilderSpellDefinition"/>. <see cref="EnemyAbilityPool"/> uses
|
||||
/// it to build a fixed-size, enum-indexed lookup table.</para>
|
||||
/// <para><b>Assignment is deterministic, not random.</b> This system originally rolled an
|
||||
/// ability per individual enemy against a no-ability weight. Under the 2.0 design the players
|
||||
/// choose it, it applies to every enemy in the wave, and it persists for the rest of the phase
|
||||
/// — so the roll is gone and <c>WaveManager</c> reads the slot's set instead. What survived is
|
||||
/// the shape: one asset per kind, server-only hooks.</para>
|
||||
///
|
||||
/// <para><b>Server-only hooks.</b> All three hooks below only ever run on the server —
|
||||
/// <see cref="EnemyAbility"/> only calls them when <c>IsServer</c> is true. <see
|
||||
/// cref="ServerOnSpawn"/> and <see cref="ServerTick"/> are no-ops for abilities that don't
|
||||
/// need spawn-time setup or a per-frame timer (e.g. Split on Death uses neither); they exist
|
||||
/// so a future cooldown-driven ability (disable towers, teleport) doesn't need a base-class
|
||||
/// change.</para>
|
||||
/// <para><b>Abilities stack.</b> A wave can collect several across a phase's cycles, so an
|
||||
/// enemy holds a list and every hook below runs once per ability. Implementations must not
|
||||
/// assume they are the only ability on the enemy.</para>
|
||||
///
|
||||
/// <para><b>Server-only hooks.</b> All hooks run on the server only — <see cref="EnemyAbility"/>
|
||||
/// gates every call on <c>IsServer</c>. They default to no-ops so a new card only overrides the
|
||||
/// one or two it actually needs.</para>
|
||||
///
|
||||
/// <para><b>No per-enemy state on the asset.</b> A single asset is shared by every enemy
|
||||
/// carrying the ability, across every wave and match. Anything per-instance belongs on the
|
||||
/// <see cref="EnemyAbility"/> component or the enemy itself.</para>
|
||||
/// </remarks>
|
||||
public abstract class EnemyAbilityDefinition : ScriptableObject
|
||||
{
|
||||
|
|
@ -27,29 +34,74 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
public abstract EnemyAbilityKind Kind { get; }
|
||||
|
||||
[Header("Presentation")]
|
||||
[Tooltip("Name shown in debug logs and future enemy-info UI.")]
|
||||
[Tooltip("Name shown in debug logs and the enemy-info panel.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Short description shown in future enemy-info UI.")]
|
||||
[Tooltip("Short description shown in the enemy-info panel.")]
|
||||
[TextArea(2, 4)]
|
||||
public string Description;
|
||||
|
||||
[Header("Selection")]
|
||||
[Tooltip("Relative weight of this ability being rolled, vs. the pool's other abilities " +
|
||||
"and its no-ability chance. Same semantics as DraftOption.Weight.")]
|
||||
[Min(0f)]
|
||||
public float Weight = 1f;
|
||||
// ----- Spawn-time stat modification --------------------------------
|
||||
|
||||
/// <summary>Server-only: called once, right after this ability is assigned (before the
|
||||
/// <summary>
|
||||
/// Server-only: alter what this enemy spawns as, before it is built. Runs for every enemy
|
||||
/// of the buffed wave. Default no-op.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the hook for cards that change what an enemy fundamentally <i>is</i> — flight,
|
||||
/// health, speed, size. It must run before the enemy exists, because spawn position
|
||||
/// (flyers are raised) and <c>EnemyHealth</c>/<c>EnemyMovement</c> initialization all read
|
||||
/// these values once and keep them.
|
||||
///
|
||||
/// <para>When several abilities stack, they see each other's edits in application order,
|
||||
/// so multiplicative modifiers compose naturally. Prefer multiplying over assigning for
|
||||
/// anything numeric, or the last card to run silently wins.</para>
|
||||
/// </remarks>
|
||||
public virtual void ServerModifySpawn(ref EnemySpawnContext context) { }
|
||||
|
||||
// ----- Lifetime hooks ----------------------------------------------
|
||||
|
||||
/// <summary>Server-only: called once, right after the ability set is assigned (before the
|
||||
/// enemy's NetworkObject is spawned to clients). Default no-op.</summary>
|
||||
public virtual void ServerOnSpawn(EnemyAbility instance) { }
|
||||
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/> — the
|
||||
/// same index <see cref="ServerTick"/> receives, so per-enemy timers can be seeded here.</param>
|
||||
public virtual void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { }
|
||||
|
||||
/// <summary>Server-only: called every frame this ability is active on a live enemy.
|
||||
/// Default no-op.</summary>
|
||||
public virtual void ServerTick(EnemyAbility instance, float dt) { }
|
||||
/// <summary>
|
||||
/// Server-only: called every frame this ability is active on a live enemy. Default no-op.
|
||||
/// </summary>
|
||||
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/>. Pass it
|
||||
/// to <see cref="EnemyAbility.TimerFor"/> to reach a per-enemy float this ability owns.</param>
|
||||
/// <remarks>
|
||||
/// <b>The asset holds no per-enemy state.</b> One <see cref="EnemyAbilityDefinition"/>
|
||||
/// instance is shared by every enemy carrying the ability, so a cooldown stored in a field
|
||||
/// here would be shared by the entire wave — every enemy would blink in lockstep, or worse,
|
||||
/// race each other's writes. <see cref="EnemyAbility.TimerFor"/> gives each enemy its own
|
||||
/// slot by reference, which is enough for the timer-driven abilities and costs no
|
||||
/// allocation.
|
||||
/// </remarks>
|
||||
public virtual void ServerTick(EnemyAbility instance, int abilityIndex, float dt) { }
|
||||
|
||||
/// <summary>Server-only: called the instant the enemy's HP reaches zero, before the
|
||||
/// death animation/despawn sequence plays. Default no-op.</summary>
|
||||
public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { }
|
||||
|
||||
// ----- Economy hooks -----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: adjust the gold a player earns for killing this enemy. Returns the reward
|
||||
/// to pass on; default is unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Chained across a stacked ability set, each ability receiving the previous one's result.
|
||||
/// Callers clamp the final value at zero, so returning a negative is safe but pointless.
|
||||
/// </remarks>
|
||||
public virtual int ServerModifyKillReward(EnemyAbility instance, int reward) => reward;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: called when this enemy reaches the defense point, after lives have been
|
||||
/// deducted. The hook for leak-punishing cards that cost more than lives. Default no-op.
|
||||
/// </summary>
|
||||
public virtual void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone) { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
|
||||
/// Only the server calls <see cref="RollRandom"/> (at enemy spawn time, in
|
||||
/// <c>WaveManager.SpawnEnemy</c>).
|
||||
///
|
||||
/// <para><b>Lookup only — no rolling.</b> This pool used to draw a random ability per spawned
|
||||
/// enemy against a "no ability" weight. Under the 2.0 design abilities are chosen by the
|
||||
/// players' post-wave vote and apply to every enemy in the wave, so the draw moved to
|
||||
/// <c>WaveVote</c> (over cards, not abilities) and this is purely the kind→asset table
|
||||
/// <c>WaveManager</c> resolves against at spawn time.</para>
|
||||
/// </remarks>
|
||||
public class EnemyAbilityPool : MonoBehaviour
|
||||
{
|
||||
|
|
@ -25,11 +29,6 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
"its slot.")]
|
||||
[SerializeField] private EnemyAbilityDefinition[] abilities;
|
||||
|
||||
[Tooltip("Relative weight of an enemy rolling no ability at all, vs. the combined " +
|
||||
"weight of every entry in 'abilities'. Higher = abilities are rarer.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float noAbilityWeight = 100f;
|
||||
|
||||
// Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count,
|
||||
// not authored count, so an out-of-range Kind is a compile-time impossibility rather
|
||||
// than a bounds check we'd otherwise need on every Get().
|
||||
|
|
@ -68,35 +67,5 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
int i = (int)kind;
|
||||
return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: weighted random draw across every authored ability plus the
|
||||
/// no-ability bucket. Returns null when "no ability" is drawn, or when the pool has
|
||||
/// nothing authored.
|
||||
/// </summary>
|
||||
public EnemyAbilityDefinition RollRandom()
|
||||
{
|
||||
if (abilities == null || abilities.Length == 0) return null;
|
||||
|
||||
float total = noAbilityWeight;
|
||||
for (int i = 0; i < abilities.Length; i++)
|
||||
if (abilities[i] != null) total += abilities[i].Weight;
|
||||
|
||||
if (total <= 0f) return null;
|
||||
|
||||
float roll = UnityEngine.Random.Range(0f, total);
|
||||
if (roll < noAbilityWeight) return null;
|
||||
roll -= noAbilityWeight;
|
||||
|
||||
for (int i = 0; i < abilities.Length; i++)
|
||||
{
|
||||
var def = abilities[i];
|
||||
if (def == null) continue;
|
||||
if (roll < def.Weight) return def;
|
||||
roll -= def.Weight;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Mutable per-spawn copy of an enemy's stats, handed to each of its abilities before the
|
||||
/// enemy is built so they can alter what it spawns as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why a context rather than more parameters.</b> Wave buffs like "grounded enemies become
|
||||
/// flying" or "enemies spawn at half health" have to land <i>before</i>
|
||||
/// <c>EnemyHealth.InitializeServer</c> and before the spawn position is computed (flyers are
|
||||
/// raised by their flight height). Threading each new stat through <c>SpawnEnemy</c> as another
|
||||
/// argument was already unwieldy at two; this keeps the signature flat and gives abilities one
|
||||
/// obvious place to write.
|
||||
///
|
||||
/// <para>Seeded from the <see cref="EnemyDefinition"/>, never written back to it — the
|
||||
/// definition is a shared asset and a buff that mutated it would leak across waves, matches,
|
||||
/// and (in the editor) sessions.</para>
|
||||
/// </remarks>
|
||||
public struct EnemySpawnContext
|
||||
{
|
||||
public float MaxHp;
|
||||
public float MoveSpeed;
|
||||
public bool IsFlying;
|
||||
public float FlightHeight;
|
||||
public int LivesCost;
|
||||
|
||||
/// <summary>Uniform transform scale relative to the prefab's authored scale.</summary>
|
||||
public float VisualScale;
|
||||
|
||||
/// <summary>Seeds a context from an enemy definition's authored stats.</summary>
|
||||
public static EnemySpawnContext FromDefinition(EnemyDefinition def) => new EnemySpawnContext
|
||||
{
|
||||
MaxHp = def.MaxHp,
|
||||
MoveSpeed = def.MoveSpeed,
|
||||
IsFlying = def.IsFlying,
|
||||
FlightHeight = def.FlightHeight,
|
||||
LivesCost = def.LivesCost,
|
||||
VisualScale = 1f,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2f4b0f77b422a474cbe82f3967e6195b
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Grounded enemies take to the air: they path over the baked terrain grid instead of through
|
||||
/// the maze, and towers flagged ground-only can no longer touch them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The single most punishing card in the set</b>, because it doesn't weaken the maze — it
|
||||
/// deletes it. Everything a player built to lengthen the route stops mattering for that wave,
|
||||
/// and only their anti-air coverage counts. Weight it accordingly in the pools.
|
||||
///
|
||||
/// <para>Purely a spawn-time change: <c>EnemyMovement</c> reads the flying flag once at
|
||||
/// initialization to pick which grid to path on and to opt out of re-path scheduling, and
|
||||
/// <c>TowerCombat</c> reads the replicated flag on <c>EnemyHealth</c> when filtering targets.
|
||||
/// Both are already wired for flight — this card only has to flip the bit before the enemy is
|
||||
/// built.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "FlightAbility", menuName = "TD/Enemy Abilities/Flight")]
|
||||
public class FlightAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.Flight;
|
||||
|
||||
[Header("Flight")]
|
||||
[Tooltip("Height above the ground these enemies hover at. Keep modest — tower targeting " +
|
||||
"uses 3D range, so altitude eats into the effective reach of anything that CAN " +
|
||||
"shoot them.")]
|
||||
[Min(0f)]
|
||||
public float FlightHeight = 3f;
|
||||
|
||||
[Tooltip("Speed multiplier applied when the enemy takes flight. Flying enemies travel a " +
|
||||
"much shorter route, so values below 1 are a reasonable counterweight.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float SpeedMultiplier = 1f;
|
||||
|
||||
public override void ServerModifySpawn(ref EnemySpawnContext context)
|
||||
{
|
||||
// Already-flying enemies keep their authored height rather than being overwritten by
|
||||
// this card's — the wave was designed around it, and stacking Flight onto a flier
|
||||
// shouldn't quietly relocate it.
|
||||
if (!context.IsFlying)
|
||||
{
|
||||
context.IsFlying = true;
|
||||
context.FlightHeight = FlightHeight;
|
||||
}
|
||||
|
||||
context.MoveSpeed *= SpeedMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: be77d353621f07e40bbdab2e9cc37f51
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// An enemy that reaches the defense point doesn't just cost a life — it robs the player whose
|
||||
/// maze it escaped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Charged to the origin zone, not the whole team.</b> Lives are a shared pool, so a leak
|
||||
/// already punishes everyone equally; billing the gold to whoever let it through is the part
|
||||
/// that makes this card bite differently from "enemies cost 2 lives". It also keeps the blame
|
||||
/// legible — the player who leaked is the player who pays.
|
||||
///
|
||||
/// <para>Deducting more gold than a player has simply empties them; there is no debt. Going
|
||||
/// negative would silently disable building until they clawed back to zero, which reads as a
|
||||
/// broken HUD rather than a penalty.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "GoldTheftAbility", menuName = "TD/Enemy Abilities/Gold Theft")]
|
||||
public class GoldTheftAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.GoldTheft;
|
||||
|
||||
[Header("Theft")]
|
||||
[Tooltip("Flat gold taken from the leaking player, on top of the normal life cost.")]
|
||||
[Min(0)]
|
||||
public int GoldStolen = 15;
|
||||
|
||||
public override void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone)
|
||||
{
|
||||
if (GoldStolen <= 0 || originZone == PlayerSlot.None) return;
|
||||
|
||||
var pms = PlayerMatchState.GetForSlot(originZone);
|
||||
if (pms == null) return;
|
||||
|
||||
var gold = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||
if (gold == null) return;
|
||||
|
||||
// Clamp to what they actually hold — a leak shouldn't be able to put a player in debt.
|
||||
int taken = Mathf.Min(GoldStolen, gold.CurrentGold);
|
||||
if (taken <= 0) return;
|
||||
|
||||
gold.DeductGold(taken);
|
||||
|
||||
// Surface it in-world on every peer so the loss isn't just a number quietly ticking
|
||||
// down in the corner. Routed through WaveManager because the popup has to reach
|
||||
// clients and a ScriptableObject has no NetworkBehaviour to send from.
|
||||
WaveManager.Instance?.ServerBroadcastGoldLoss(instance.transform.position, taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3b770ce042b78f44d9a3df537fe25db0
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Killing these enemies pays little or nothing. The wave gets no harder to survive — it gets
|
||||
/// harder to profit from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Attacks the economy, not the maze.</b> Every other card makes a wave more dangerous;
|
||||
/// this one makes clearing it worthless, which compounds instead across cycles — a wave that
|
||||
/// pays nothing on cycle 1 also pays nothing on cycles 2 and 3, so the players who voted it in
|
||||
/// are choosing to be poorer for the rest of the phase. That slow squeeze is the point, and
|
||||
/// it's why the card reads as mild and isn't.
|
||||
///
|
||||
/// <para>Wave-completion and no-leak bonuses are untouched — those are paid per player by
|
||||
/// <c>WaveManager</c>, not per kill, so a wave carrying this still rewards clearing it.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "NoBountyAbility", menuName = "TD/Enemy Abilities/No Bounty")]
|
||||
public class NoBountyAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.NoBounty;
|
||||
|
||||
[Header("Bounty")]
|
||||
[Tooltip("Fraction of the normal kill bounty these enemies still pay. 0 = nothing at all.")]
|
||||
[Range(0f, 1f)]
|
||||
public float BountyMultiplier = 0f;
|
||||
|
||||
public override int ServerModifyKillReward(EnemyAbility instance, int reward)
|
||||
=> Mathf.FloorToInt(reward * BountyMultiplier);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fd5b12948b24e9c4c8840ef3470b437e
|
||||
|
|
@ -7,32 +7,60 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
/// <summary>
|
||||
/// On death, spawns <see cref="SplitCount"/> smaller copies of the dying enemy's own type at
|
||||
/// the corpse's position, continuing on toward the goal instead of restarting from the wave's
|
||||
/// spawner. There's no separate "minion" EnemyDefinition to author — the mini copies reuse
|
||||
/// whichever EnemyDefinition/prefab the dying enemy itself was spawned from, scaled down by
|
||||
/// <see cref="HpMultiplier"/>/<see cref="ScaleMultiplier"/>.
|
||||
/// spawner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No separate minion asset.</b> The copies reuse whichever <see cref="EnemyDefinition"/>
|
||||
/// and prefab the dying enemy was spawned from; every stat is expressed here as a percentage
|
||||
/// of what the parent actually spawned with, so one card covers any enemy it lands on.
|
||||
///
|
||||
/// <para><b>Percentages are of the PARENT, not the asset.</b> If other cards on the same wave
|
||||
/// have already modified the enemy, the minions scale off the modified values — a split of a
|
||||
/// buffed enemy produces buffed minions, which is what players will expect from watching it.</para>
|
||||
///
|
||||
/// <para><b>Minions inherit no abilities at all</b> — not even this one. That is what makes the
|
||||
/// recursion terminate: a minion of a splitting enemy is a plain enemy, so a wave carrying
|
||||
/// Split produces exactly one extra generation regardless of how many other cards it has
|
||||
/// collected. Enforced at the spawn site, not here.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "SplitOnDeathAbility", menuName = "TD/Enemy Abilities/Split On Death")]
|
||||
public class SplitOnDeathAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.SplitOnDeath;
|
||||
|
||||
[Header("Split")]
|
||||
[Tooltip("How many mini copies spawn on death.")]
|
||||
[Tooltip("How many copies spawn when the parent dies.")]
|
||||
[Min(1)]
|
||||
public int SplitCount = 2;
|
||||
|
||||
[Tooltip("Each mini copy's MaxHp = the dying enemy's own MaxHp * this multiplier.")]
|
||||
[Range(0.01f, 1f)]
|
||||
public float HpMultiplier = 0.5f;
|
||||
[Header("Minion stats (percent of the parent's spawned values)")]
|
||||
[Tooltip("Minion max HP as a fraction of the parent's max HP.")]
|
||||
[Range(0.01f, 2f)]
|
||||
public float HpPercent = 0.5f;
|
||||
|
||||
[Tooltip("Uniform transform scale applied to each mini copy, relative to the normal " +
|
||||
"prefab scale. Requires the enemy prefab's NetworkTransform to sync scale, " +
|
||||
"or the mini will render full-size on remote peers.")]
|
||||
[Range(0.1f, 1f)]
|
||||
public float ScaleMultiplier = 0.6f;
|
||||
[Tooltip("Minion move speed as a fraction of the parent's speed. Above 1 makes the split " +
|
||||
"faster than what died — the classic 'smaller and quicker' read.")]
|
||||
[Range(0.01f, 3f)]
|
||||
public float SpeedPercent = 1.25f;
|
||||
|
||||
[Tooltip("Random scatter radius (world units) applied to each split spawn so they don't " +
|
||||
"spawn exactly on top of each other.")]
|
||||
[Tooltip("Minion transform scale as a fraction of the parent's scale. Requires the enemy " +
|
||||
"prefab's NetworkTransform to sync scale, or minions render full-size on remote peers.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float ScalePercent = 0.6f;
|
||||
|
||||
[Tooltip("Minion lives cost as a fraction of the parent's, rounded down but never below 1 " +
|
||||
"— a leak always has to hurt, or splits become a way to farm free ground.")]
|
||||
[Range(0f, 2f)]
|
||||
public float LivesCostPercent = 1f;
|
||||
|
||||
[Tooltip("Minion flight height as a fraction of the parent's. Only meaningful when the " +
|
||||
"parent is flying; ground splits ignore it.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float FlightHeightPercent = 1f;
|
||||
|
||||
[Header("Placement")]
|
||||
[Tooltip("Random scatter radius (world units) applied to each spawn so minions don't " +
|
||||
"stack exactly on top of each other.")]
|
||||
[Min(0f)]
|
||||
public float ScatterRadius = 0.5f;
|
||||
|
||||
|
|
@ -41,12 +69,24 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
var def = health.Definition;
|
||||
if (def == null) return;
|
||||
|
||||
var movement = instance.GetComponent<EnemyMovement>();
|
||||
var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
|
||||
var movement = instance.GetComponent<EnemyMovement>();
|
||||
var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
|
||||
var ownerSlot = movement != null ? movement.OriginZone : PlayerSlot.None;
|
||||
|
||||
// Derive the minion's stats from what the parent actually spawned as.
|
||||
var parent = instance.SpawnContext;
|
||||
var minion = new EnemySpawnContext
|
||||
{
|
||||
MaxHp = Mathf.Max(1f, parent.MaxHp * HpPercent),
|
||||
MoveSpeed = Mathf.Max(0.01f, parent.MoveSpeed * SpeedPercent),
|
||||
IsFlying = parent.IsFlying,
|
||||
FlightHeight = parent.FlightHeight * FlightHeightPercent,
|
||||
LivesCost = Mathf.Max(1, Mathf.FloorToInt(parent.LivesCost * LivesCostPercent)),
|
||||
VisualScale = parent.VisualScale * ScalePercent,
|
||||
};
|
||||
|
||||
WaveManager.Instance?.ServerSpawnSplitEnemies(
|
||||
def, SplitCount, atTile, ownerSlot, ScatterRadius, HpMultiplier, ScaleMultiplier);
|
||||
def, SplitCount, atTile, ownerSlot, ScatterRadius, minion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbility.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
|
@ -7,57 +8,101 @@ using TD.Gameplay.EnemyAbilities;
|
|||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-enemy ability slot. Holds the <see cref="EnemyAbilityDefinition"/> rolled for this
|
||||
/// instance (if any) and drives its server-only hooks.
|
||||
/// Per-enemy ability set. Holds every <see cref="EnemyAbilityDefinition"/> its wave has
|
||||
/// accumulated and drives their server-only hooks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Initialization:</b> Call <see cref="InitializeServer"/> on the server immediately after
|
||||
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>, following the same pattern as
|
||||
/// <c>EnemyHealth.InitializeServer</c> / <c>EnemyMovement.InitializeServer</c>.
|
||||
///
|
||||
/// <b>A set, not a slot.</b> This used to hold one ability rolled at random per enemy. Wave
|
||||
/// buffs stack across a phase's cycles, so it now holds a list and fans every hook out over
|
||||
/// all of them. <see cref="kinds"/> replicates the set so clients can show players what
|
||||
/// they're facing.
|
||||
///
|
||||
/// <b>Optional component:</b> Not required by <c>EnemyHealth</c> or <c>EnemyMovement</c> —
|
||||
/// <c>WaveManager.SpawnEnemy</c> only rolls and assigns an ability when this component is
|
||||
/// present on the prefab, so existing enemy prefabs are unaffected until it's added to them.
|
||||
/// <c>WaveManager.SpawnEnemy</c> only assigns abilities when this component is present on the
|
||||
/// prefab, so enemy prefabs without it simply never carry wave buffs.
|
||||
///
|
||||
/// <b>On-death hook is not event-driven:</b> <c>WaveManager.HandleEnemyKilled</c> calls
|
||||
/// <see cref="EnemyAbilityDefinition.ServerOnDeath"/> directly (via <see cref="Definition"/>)
|
||||
/// rather than this component subscribing to <c>EnemyHealth.OnDied</c> itself — that keeps a
|
||||
/// split-spawn's <c>activeEnemyCount</c> increment ordered before the triggering kill's
|
||||
/// decrement, regardless of NetworkBehaviour subscription order.
|
||||
/// <see cref="ServerInvokeOnDeath"/> directly rather than this component subscribing to
|
||||
/// <c>EnemyHealth.OnDied</c> itself — that keeps a split-spawn's <c>activeEnemyCount</c>
|
||||
/// increment ordered before the triggering kill's decrement, regardless of NetworkBehaviour
|
||||
/// subscription order.
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class EnemyAbility : NetworkBehaviour
|
||||
{
|
||||
private readonly NetworkVariable<byte> kind = new NetworkVariable<byte>(
|
||||
(byte)EnemyAbilityKind.None,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server);
|
||||
// Replicated ability kinds carried by this enemy. Empty means no wave buffs. Exists so
|
||||
// clients can render "this wave splits and flies" in the enemy-info panel without
|
||||
// re-deriving it from run state.
|
||||
private NetworkList<byte> kinds;
|
||||
|
||||
// ----- Pre-spawn init (server-local) ----------------------------------
|
||||
|
||||
private EnemyAbilityDefinition pendingDefinition;
|
||||
private bool hasPendingInit;
|
||||
private readonly List<EnemyAbilityDefinition> pendingDefinitions = new List<EnemyAbilityDefinition>();
|
||||
private bool hasPendingInit;
|
||||
|
||||
// ----- Public state -----------------------------------------------------
|
||||
|
||||
/// <summary>The ability rolled for this enemy, or null if none was rolled.</summary>
|
||||
public EnemyAbilityDefinition Definition { get; private set; }
|
||||
private readonly List<EnemyAbilityDefinition> definitions = new List<EnemyAbilityDefinition>();
|
||||
|
||||
/// <summary>Replicated kind of the rolled ability. <see cref="EnemyAbilityKind.None"/> if
|
||||
/// none was rolled.</summary>
|
||||
public EnemyAbilityKind Kind => (EnemyAbilityKind)kind.Value;
|
||||
/// <summary>The abilities this enemy carries. Server-side; empty on clients.</summary>
|
||||
public IReadOnlyList<EnemyAbilityDefinition> Definitions => definitions;
|
||||
|
||||
/// <summary>True if this enemy carries no abilities at all.</summary>
|
||||
public bool IsPlain => definitions.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// The stats this enemy actually spawned with, after every ability's spawn modification.
|
||||
/// Server-side.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept so on-death abilities can derive from what the enemy really was rather than from
|
||||
/// its <see cref="EnemyDefinition"/>. Split-on-death needs this: its minions are a
|
||||
/// percentage of "the enemy that died", which is not the authored asset once other cards
|
||||
/// on the same wave have modified it.
|
||||
/// </remarks>
|
||||
public EnemySpawnContext SpawnContext { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
kinds = new NetworkList<byte>();
|
||||
}
|
||||
|
||||
/// <summary>Replicated check for a specific ability. Safe on any peer.</summary>
|
||||
public bool HasKind(EnemyAbilityKind kind)
|
||||
{
|
||||
byte target = (byte)kind;
|
||||
for (int i = 0; i < kinds.Count; i++)
|
||||
if (kinds[i] == target) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----- Server-only pre-spawn init -------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Called by <c>WaveManager</c> on the server after <c>Instantiate</c> and before
|
||||
/// <c>NetworkObject.Spawn()</c>. <paramref name="definition"/> may be null, meaning no
|
||||
/// ability was rolled for this enemy.
|
||||
/// <c>NetworkObject.Spawn()</c>. <paramref name="abilities"/> may be null or empty,
|
||||
/// meaning this enemy carries no wave buffs.
|
||||
/// </summary>
|
||||
public void InitializeServer(EnemyAbilityDefinition definition)
|
||||
/// <remarks>
|
||||
/// Copies rather than retains the caller's list — <c>WaveManager</c> reuses one scratch
|
||||
/// buffer across every enemy in a wave, so holding the reference would alias every enemy
|
||||
/// to the same (later mutated) contents.
|
||||
/// </remarks>
|
||||
public void InitializeServer(IReadOnlyList<EnemyAbilityDefinition> abilities,
|
||||
EnemySpawnContext spawnContext)
|
||||
{
|
||||
pendingDefinition = definition;
|
||||
hasPendingInit = true;
|
||||
pendingDefinitions.Clear();
|
||||
if (abilities != null)
|
||||
{
|
||||
for (int i = 0; i < abilities.Count; i++)
|
||||
if (abilities[i] != null) pendingDefinitions.Add(abilities[i]);
|
||||
}
|
||||
SpawnContext = spawnContext;
|
||||
hasPendingInit = true;
|
||||
}
|
||||
|
||||
// ----- NGO lifecycle --------------------------------------------------
|
||||
|
|
@ -66,22 +111,89 @@ namespace TD.Gameplay
|
|||
{
|
||||
if (!IsServer || !hasPendingInit) return;
|
||||
|
||||
Definition = pendingDefinition;
|
||||
kind.Value = (byte)(Definition != null ? Definition.Kind : EnemyAbilityKind.None);
|
||||
definitions.Clear();
|
||||
definitions.AddRange(pendingDefinitions);
|
||||
hasPendingInit = false;
|
||||
|
||||
if (Definition != null)
|
||||
Debug.Log($"[EnemyAbility] {name} rolled {Definition.Kind}.");
|
||||
timers = definitions.Count > 0
|
||||
? new float[definitions.Count]
|
||||
: System.Array.Empty<float>();
|
||||
|
||||
Definition?.ServerOnSpawn(this);
|
||||
kinds.Clear();
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
kinds.Add((byte)definitions[i].Kind);
|
||||
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
definitions[i].ServerOnSpawn(this, i);
|
||||
}
|
||||
|
||||
// ----- Server tick ------------------------------------------------
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer || Definition == null) return;
|
||||
Definition.ServerTick(this, Time.deltaTime);
|
||||
if (!IsServer || definitions.Count == 0) return;
|
||||
|
||||
float dt = Time.deltaTime;
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
definitions[i].ServerTick(this, i, dt);
|
||||
}
|
||||
|
||||
// ----- Per-enemy ability scratch ----------------------------------
|
||||
|
||||
// One float per carried ability, owned by that ability. Exists because the definitions
|
||||
// are shared ScriptableObjects: a cooldown stored on the asset would be shared by every
|
||||
// enemy in the wave, making them all fire in lockstep. Allocated once at init, sized to
|
||||
// the ability count — never resized, since the set is fixed at spawn.
|
||||
private float[] timers = System.Array.Empty<float>();
|
||||
|
||||
/// <summary>
|
||||
/// A per-enemy float owned by the ability at <paramref name="abilityIndex"/>, returned by
|
||||
/// reference so it can be read and written in place. Typically a cooldown accumulator.
|
||||
/// </summary>
|
||||
public ref float TimerFor(int abilityIndex)
|
||||
{
|
||||
if (abilityIndex < 0 || abilityIndex >= timers.Length)
|
||||
{
|
||||
// Out-of-range means a caller passed an index that doesn't match its slot. Hand
|
||||
// back a scratch cell rather than throwing mid-tick; the ability just won't
|
||||
// accumulate, which is visible in play but not fatal.
|
||||
Debug.LogError($"[EnemyAbility] TimerFor({abilityIndex}) is out of range on " +
|
||||
$"{name}. The ability will not keep time.");
|
||||
return ref timerFallback;
|
||||
}
|
||||
return ref timers[abilityIndex];
|
||||
}
|
||||
|
||||
private float timerFallback;
|
||||
|
||||
// ----- Server hook fan-out ----------------------------------------
|
||||
|
||||
/// <summary>Server-only: run every carried ability's on-death hook.</summary>
|
||||
public void ServerInvokeOnDeath(EnemyHealth health)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
definitions[i].ServerOnDeath(this, health);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: chain every carried ability's kill-reward modifier. Result is clamped at
|
||||
/// zero so a stack of reward-suppressing cards can't hand out negative gold.
|
||||
/// </summary>
|
||||
public int ServerModifyKillReward(int reward)
|
||||
{
|
||||
if (!IsServer) return reward;
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
reward = definitions[i].ServerModifyKillReward(this, reward);
|
||||
return Mathf.Max(0, reward);
|
||||
}
|
||||
|
||||
/// <summary>Server-only: run every carried ability's reached-goal hook.</summary>
|
||||
public void ServerInvokeReachedGoal(PlayerSlot originZone)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
for (int i = 0; i < definitions.Count; i++)
|
||||
definitions[i].ServerOnReachedGoal(this, originZone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyHealth.cs
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
|
@ -67,6 +68,7 @@ namespace TD.Gameplay
|
|||
private int pendingLivesCost = 1;
|
||||
private bool pendingIsFlying;
|
||||
private bool pendingIsHeld;
|
||||
private bool pendingIsBoss;
|
||||
private bool hasPendingInit;
|
||||
|
||||
// ----- Server-local runtime state -------------------------------------
|
||||
|
|
@ -103,6 +105,13 @@ namespace TD.Gameplay
|
|||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server);
|
||||
|
||||
// Set for enemies spawned by a phase's boss encounter. Replicated because the HUD's boss
|
||||
// bar is client-side and has to recognise a boss without asking the server.
|
||||
private readonly NetworkVariable<bool> isBoss = new NetworkVariable<bool>(
|
||||
false,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server);
|
||||
|
||||
// ----- Public state ---------------------------------------------------
|
||||
|
||||
public float CurrentHp => hp.Value;
|
||||
|
|
@ -117,6 +126,19 @@ namespace TD.Gameplay
|
|||
/// </summary>
|
||||
public bool IsFlying => isFlying.Value;
|
||||
|
||||
/// <summary>True if this enemy was spawned by a phase's boss encounter. Replicated.</summary>
|
||||
public bool IsBoss => isBoss.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Every live boss, on every peer. Maintained by spawn/despawn so the HUD can render a
|
||||
/// boss bar without polling the scene.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A list rather than a single reference because the boss encounter spawns one boss per
|
||||
/// player zone — there are as many simultaneous bosses as there are players.
|
||||
/// </remarks>
|
||||
public static readonly List<EnemyHealth> ActiveBosses = new List<EnemyHealth>();
|
||||
|
||||
// ----- Events ---------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -136,12 +158,14 @@ namespace TD.Gameplay
|
|||
/// and before <c>NetworkObject.Spawn()</c>. Mirrors the
|
||||
/// <c>TowerInstance.InitializeServer</c> pattern.
|
||||
/// </summary>
|
||||
public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false)
|
||||
public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false,
|
||||
bool boss = false)
|
||||
{
|
||||
pendingMaxHp = maxHp;
|
||||
pendingLivesCost = livesCost;
|
||||
pendingIsFlying = flying;
|
||||
pendingIsHeld = held;
|
||||
pendingIsBoss = boss;
|
||||
hasPendingInit = true;
|
||||
|
||||
// Cache locally on the server immediately — clients resolve via NV.
|
||||
|
|
@ -158,6 +182,7 @@ namespace TD.Gameplay
|
|||
hp.Value = pendingMaxHp;
|
||||
isFlying.Value = pendingIsFlying;
|
||||
isHeld.Value = pendingIsHeld;
|
||||
isBoss.Value = pendingIsBoss;
|
||||
hasPendingInit = false;
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +190,9 @@ namespace TD.Gameplay
|
|||
if (!IsServer)
|
||||
MaxHp = hp.Value;
|
||||
|
||||
if (isBoss.Value && !ActiveBosses.Contains(this))
|
||||
ActiveBosses.Add(this);
|
||||
|
||||
// Apply initial held state on all peers and watch for future changes.
|
||||
// isHeld.OnValueChanged doesn't fire for the initial replication, so we
|
||||
// apply it explicitly here as well.
|
||||
|
|
@ -174,6 +202,8 @@ namespace TD.Gameplay
|
|||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
ActiveBosses.Remove(this);
|
||||
|
||||
// If this enemy was the locally-selected ISelectable, clear the
|
||||
// selection so the HUD doesn't keep displaying a stale corpse.
|
||||
// SelectionState is a local UI singleton, safe to query on any peer.
|
||||
|
|
|
|||
|
|
@ -282,6 +282,45 @@ namespace TD.Gameplay
|
|||
NetworkObject.Despawn();
|
||||
}
|
||||
|
||||
// ----- Ability-driven movement ----------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: teleport this enemy forward along its existing path by up to
|
||||
/// <paramref name="tiles"/> waypoints. Used by the Blink wave buff.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Follows the path rather than the straight line to the goal.</b> Jumping toward the
|
||||
/// goal as the crow flies would let a grounded enemy blink through walls, which is the
|
||||
/// flying buff's job, not this one. Skipping waypoints keeps the enemy inside the maze —
|
||||
/// it cuts corners, it doesn't ignore them.
|
||||
///
|
||||
/// <para>Zone tracking still runs on the destination tile, so an enemy that blinks out of
|
||||
/// its origin zone still credits its owner a leak. Skipping that would make the buff a way
|
||||
/// to launder leaks past the no-leak bonus.</para>
|
||||
///
|
||||
/// <para>Blinking onto the final waypoint resolves as a normal goal arrival, despawn and
|
||||
/// life cost included.</para>
|
||||
/// </remarks>
|
||||
public void ServerBlinkForward(int tiles)
|
||||
{
|
||||
if (!IsServer || tiles <= 0) return;
|
||||
if (hasReachedGoal || remainingPath.Count == 0) return;
|
||||
if (health != null && health.IsHeld) return;
|
||||
|
||||
int skip = Mathf.Min(tiles, remainingPath.Count);
|
||||
Vector2Int destination = remainingPath[skip - 1];
|
||||
remainingPath.RemoveRange(0, skip);
|
||||
|
||||
// Preserve Y so flyers stay at altitude and grounded pivots don't sink.
|
||||
Vector3 world = GridCoordinates.GridToWorld(destination);
|
||||
transform.position = new Vector3(world.x, transform.position.y, world.z);
|
||||
|
||||
CheckZoneTransition(destination);
|
||||
|
||||
if (remainingPath.Count == 0)
|
||||
HandleGoalReached();
|
||||
}
|
||||
|
||||
// ----- Path invalidation ----------------------------------------------
|
||||
|
||||
// Called by PathfindingService's budgeted scheduler after a walkability change
|
||||
|
|
|
|||
8
Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta
Normal file
8
Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 95ac4e8d1714a07489dad0c283970bf8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.EnemyAbilities;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// The workhorse enemy-buff card: grants an <see cref="EnemyAbilityDefinition"/> to a wave
|
||||
/// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the
|
||||
/// ability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is nothing to do at vote-resolution time — recording the option id on the slot IS the
|
||||
/// effect, because the spawn path builds each enemy's ability set from the slot's recorded
|
||||
/// options. <see cref="Ability"/> is what that lookup resolves to.
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgrade_Ability",
|
||||
menuName = "TD/Enemy Upgrades/Ability Card", order = 20)]
|
||||
public class AbilityEnemyUpgradeOption : EnemyUpgradeOption
|
||||
{
|
||||
[Header("Payload")]
|
||||
[Tooltip("The ability every enemy in this wave gains. Must also be present in the scene's " +
|
||||
"EnemyAbilityPool so it can be resolved at spawn time.")]
|
||||
public EnemyAbilityDefinition Ability;
|
||||
|
||||
public override bool IsValidForSlot(RunState run, int slot)
|
||||
{
|
||||
if (Ability == null) return false;
|
||||
|
||||
// Don't offer an ability the slot already has by another card's hand. Exact-id repeats
|
||||
// are filtered by the caller, but two different cards can grant the same ability, and
|
||||
// stacking one twice is a no-op the players would rightly feel cheated by.
|
||||
return !SlotAlreadyHasAbility(run, slot);
|
||||
}
|
||||
|
||||
// Shared scratch: validation runs on the server during vote generation, which is
|
||||
// single-threaded, so one reused list beats allocating per candidate per slot.
|
||||
private static readonly System.Collections.Generic.List<int> s_idScratch
|
||||
= new System.Collections.Generic.List<int>();
|
||||
|
||||
private bool SlotAlreadyHasAbility(RunState run, int slot)
|
||||
{
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (run == null || pool == null) return false;
|
||||
|
||||
var ids = s_idScratch;
|
||||
run.GetUpgradesForSlot(slot, ids);
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
if (pool.Get(id) is AbilityEnemyUpgradeOption other && other.Ability == Ability)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Inherits nothing automatically — <see cref="EnemyAbilityDefinition"/> carries no
|
||||
/// icon, so the card's own <see cref="EnemyUpgradeOption.Icon"/> is the only source.</summary>
|
||||
public override Sprite ResolveIcon() => Icon;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d075c66b381872a4781b3916296ccdc9
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically
|
||||
/// and the players get no say in which.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not an enemy ability.</b> Nothing about the enemies changes when this is voted in — it
|
||||
/// changes how the <i>next vote</i> on this slot resolves. That's why it derives from
|
||||
/// <see cref="EnemyUpgradeOption"/> directly instead of wrapping an
|
||||
/// <c>EnemyAbilityDefinition</c>: the spawn path builds ability sets only from ability cards,
|
||||
/// so this one is recorded on the slot and otherwise invisible to enemies.
|
||||
///
|
||||
/// <para><b>The trade is agency for information.</b> Voting this in is choosing to make one
|
||||
/// future wave worse in exchange for it not being the worst option — the auto-draw is weighted
|
||||
/// and slot-filtered exactly like a real vote, so it's a gamble on the pool, not a punishment.
|
||||
/// It also disarms itself after firing (<c>ServerConsumeAutoApply</c>), so a slot can only be
|
||||
/// doubled once per card.</para>
|
||||
///
|
||||
/// <para>Repeats are prevented by the normal offer filter: the slot records this card like any
|
||||
/// other, so it can't be offered to the same slot twice in a phase.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgrade_DoubleUp",
|
||||
menuName = "TD/Enemy Upgrades/Double Up Card", order = 22)]
|
||||
public class DoubleUpEnemyUpgradeOption : EnemyUpgradeOption
|
||||
{
|
||||
[Header("Payload")]
|
||||
[Tooltip("How many buffs the wave takes automatically in place of its next vote.")]
|
||||
[Min(2)]
|
||||
public int BuffsToAutoApply = 2;
|
||||
|
||||
public override bool IsValidForSlot(RunState run, int slot)
|
||||
{
|
||||
// Pointless to offer while the slot is already armed — the player would be trading a
|
||||
// vote away for nothing.
|
||||
return run != null && run.GetAutoApplyCount(slot) <= 0;
|
||||
}
|
||||
|
||||
public override void ServerOnApplied(RunState run, int slot)
|
||||
{
|
||||
run?.ServerSetAutoApply(slot, BuffsToAutoApply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f5e90fe6b8e439e4f99769a6923c2262
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// One weighted candidate inside an <see cref="EnemyUpgradeGroup"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Weight is relative and normalized at draw time — see <see cref="PoolWeightAttribute"/>.
|
||||
/// Kept on the entry rather than on the option asset for the same reason wave weights are:
|
||||
/// a card can be a staple of one phase's pool and a rarity in another's.
|
||||
/// </remarks>
|
||||
[Serializable]
|
||||
public class EnemyUpgradePoolEntry
|
||||
{
|
||||
[Tooltip("The enemy-buff card that may be offered from this group.")]
|
||||
public EnemyUpgradeOption Option;
|
||||
|
||||
[Tooltip("Relative draw weight within the pool this group contributes to. Entries at " +
|
||||
"equal weight are equally likely; 0.5 is half as likely as 1.0.")]
|
||||
[PoolWeight("buff")]
|
||||
public float Weight = 1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named, reusable bag of enemy-buff cards. Groups are the unit designers move between
|
||||
/// phases: dragging a group into Phase 2's list makes every card in it votable from phase 2
|
||||
/// onward, without touching the cards themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Buffs are gated by <b>phase</b>, not by cycle — every card a phase allows is votable from
|
||||
/// its very first wave. Gating by cycle was considered and rejected: the whole point of the
|
||||
/// cyclical structure is that players choose how hard to make their own next lap, and holding
|
||||
/// the interesting cards back until cycle 3 would flatten that decision into a formality.
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "EnemyUpgradeGroup",
|
||||
menuName = "TD/Enemy Upgrades/Upgrade Group", order = 21)]
|
||||
public class EnemyUpgradeGroup : ScriptableObject
|
||||
{
|
||||
[Tooltip("Designer-facing name, shown in validation messages. Falls back to the asset name.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Cards in this group, each with a relative draw weight.")]
|
||||
public EnemyUpgradePoolEntry[] Options;
|
||||
|
||||
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
|
||||
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
|
||||
|
||||
/// <summary>
|
||||
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
|
||||
/// <paramref name="into"/>. Zero-weight entries are filtered here so "weight 0 means it
|
||||
/// can never appear" lives in exactly one place.
|
||||
/// </summary>
|
||||
public static void CollectCandidates(EnemyUpgradeGroup[] groups, List<EnemyUpgradePoolEntry> into)
|
||||
{
|
||||
into.Clear();
|
||||
if (groups == null) return;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group?.Options == null) continue;
|
||||
foreach (var entry in group.Options)
|
||||
{
|
||||
if (entry?.Option == null) continue;
|
||||
if (entry.Weight <= 0f) continue;
|
||||
into.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4202a767d96e70f41b6a7e84eb08a832
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs
|
||||
using UnityEngine;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for one card in the post-wave enemy-buff vote. Players collectively choose one
|
||||
/// of these to permanently attach to the wave they just cleared; it takes effect the next time
|
||||
/// that wave comes round in the cycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Mirrors <c>DraftOption</c> deliberately.</b> Same shape — abstract SO, one asset
|
||||
/// per card, concrete subclasses carry the payload — so the two authoring surfaces read the
|
||||
/// same way. The difference is who it targets: a draft option applies to a player, an enemy
|
||||
/// upgrade applies to a <i>wave slot</i>.</para>
|
||||
///
|
||||
/// <para><b>Identity.</b> Options live in <see cref="EnemyUpgradePool"/> and cross the network
|
||||
/// as their pool index, the same stable-within-a-match id pattern used by the tower catalog
|
||||
/// and draft pool.</para>
|
||||
///
|
||||
/// <para><b>Recording is not this type's job.</b> <c>WaveVote</c> records the winning option id
|
||||
/// onto the slot via <c>RunState.ServerAddUpgrade</c>; <see cref="ServerOnApplied"/> is only
|
||||
/// for cards that need a side effect <i>beyond</i> being recorded (the vote-meta cards).
|
||||
/// Ability cards need nothing here — the spawn path reads the slot's recorded set.</para>
|
||||
/// </remarks>
|
||||
public abstract class EnemyUpgradeOption : ScriptableObject
|
||||
{
|
||||
[Header("Presentation")]
|
||||
[Tooltip("Name shown on the vote card.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Short description shown on the vote card. Say what it does to the enemies, in " +
|
||||
"the players' terms — this is the only thing they get to judge the vote on.")]
|
||||
[TextArea(2, 4)]
|
||||
public string Description;
|
||||
|
||||
[Tooltip("OPTIONAL override for the vote card icon. Leave empty to inherit the icon of " +
|
||||
"whatever this option grants.")]
|
||||
public Sprite Icon;
|
||||
|
||||
/// <summary>
|
||||
/// Icon actually shown on the vote card. Subclasses override to fall back to the icon of
|
||||
/// the thing they grant; the base has nothing to inherit from, so it returns
|
||||
/// <see cref="Icon"/> (which may be null).
|
||||
/// </summary>
|
||||
public virtual Sprite ResolveIcon() => Icon;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: may this option be OFFERED for <paramref name="slot"/> right now? Default
|
||||
/// true; override for cards with prerequisites or ones that would be a no-op.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Callers already skip options the slot has taken before, so implementations don't need
|
||||
/// to re-check that.
|
||||
/// </remarks>
|
||||
public virtual bool IsValidForSlot(RunState run, int slot) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: side effect to run after this option has been recorded onto
|
||||
/// <paramref name="slot"/>. Default no-op — most cards do their work at enemy-spawn time
|
||||
/// by virtue of being on the slot, not at vote-resolution time.
|
||||
/// </summary>
|
||||
public virtual void ServerOnApplied(RunState run, int slot) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6b1e9770be72a694a96169d9a9032528
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene singleton holding every <see cref="EnemyUpgradeOption"/> that can appear anywhere in
|
||||
/// this match. The array index is the option's <c>EnemyUpgradeOptionId</c> — the stable
|
||||
/// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why a flat pool <i>and</i> per-phase groups.</b> The pool supplies network identity: one
|
||||
/// array, one index per card, identical on every peer. <c>PhaseDefinition.EnemyUpgradeGroups</c>
|
||||
/// supplies gating: which of those cards a given phase may offer. Folding gating into the pool
|
||||
/// would make ids phase-relative and therefore unstable across a phase change, which is
|
||||
/// exactly when replicated slot upgrades are being cleared and rewritten.
|
||||
///
|
||||
/// <para>Plain MonoBehaviour — identical assets on every peer, so there is nothing to sync.
|
||||
/// The server reads it to generate and resolve votes; clients read it to render vote cards
|
||||
/// from replicated ids.</para>
|
||||
/// </remarks>
|
||||
public class EnemyUpgradePool : MonoBehaviour
|
||||
{
|
||||
public static EnemyUpgradePool Instance { get; private set; }
|
||||
|
||||
[Tooltip("Every EnemyUpgradeOption asset that can appear this match. Array index is the " +
|
||||
"id used over the network — reordering mid-development is fine, but not between " +
|
||||
"a host and its clients.")]
|
||||
[SerializeField] private EnemyUpgradeOption[] options;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[EnemyUpgradePool] Multiple instances detected. Only one per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
/// <summary>Number of options in the pool.</summary>
|
||||
public int Count => options?.Length ?? 0;
|
||||
|
||||
/// <summary>Returns the option at <paramref name="id"/>, or null if out of range.</summary>
|
||||
public EnemyUpgradeOption Get(int id)
|
||||
=> (options != null && id >= 0 && id < options.Length) ? options[id] : null;
|
||||
|
||||
/// <summary>Resolves an option asset back to its pool id, or -1 if it isn't in the pool.</summary>
|
||||
public int GetId(EnemyUpgradeOption option)
|
||||
{
|
||||
if (options == null || option == null) return -1;
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
if (options[i] == option) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the pool ids of every card the given phase groups allow. Cards missing from
|
||||
/// the pool are reported once and skipped — an authored group referencing an unpooled
|
||||
/// card is a wiring mistake that would otherwise silently shrink the vote.
|
||||
/// </summary>
|
||||
public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups,
|
||||
List<EnemyUpgradePoolEntry> entryScratch,
|
||||
List<int> intoIds,
|
||||
List<float> intoWeights)
|
||||
{
|
||||
intoIds.Clear();
|
||||
intoWeights.Clear();
|
||||
|
||||
EnemyUpgradeGroup.CollectCandidates(phaseGroups, entryScratch);
|
||||
|
||||
foreach (var entry in entryScratch)
|
||||
{
|
||||
int id = GetId(entry.Option);
|
||||
if (id < 0)
|
||||
{
|
||||
Debug.LogWarning($"[EnemyUpgradePool] '{entry.Option.name}' is referenced by a " +
|
||||
$"phase's upgrade groups but is not in the pool — it can " +
|
||||
$"never be offered. Add it to the pool's options array.");
|
||||
continue;
|
||||
}
|
||||
|
||||
intoIds.Add(id);
|
||||
intoWeights.Add(entry.Weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c4d98a01d9b9a7b438a6f91b0b7f498f
|
||||
419
Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
Normal file
419
Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Gameplay.Waves;
|
||||
|
||||
namespace TD.Gameplay.EnemyUpgrades
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared, <b>open-ballot</b> vote held after each wave clears: players collectively pick
|
||||
/// which permanent buff the wave they just beat will carry into the next cycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Ballots are public by design.</b> <see cref="ballots"/> replicates to everyone the
|
||||
/// instant a vote is cast, and the HUD paints each voter's slot badge onto the card they chose.
|
||||
/// That visibility is the point of the mechanic — seeing two teammates converge on "Flying" is
|
||||
/// what lets the third decide whether to join them or split the vote. A private tally would
|
||||
/// reduce this to a dice roll with extra steps.</para>
|
||||
///
|
||||
/// <para><b>Votes stay changeable</b> until the vote resolves, for the same reason. The
|
||||
/// consequence is that the last player to vote ends the vote instantly (see
|
||||
/// <see cref="AllPlayersVoted"/>), locking their choice in with no chance for anyone to
|
||||
/// respond. If that reads badly in playtest, the fix is a short grace period after the final
|
||||
/// ballot rather than closing the ballots.</para>
|
||||
///
|
||||
/// <para><b>Scene singleton, server-authoritative.</b> Unlike <c>PlayerDraft</c> (one per
|
||||
/// player) this is one shared object, so votes arrive as plain server RPCs and the server maps
|
||||
/// the sender to a <see cref="PlayerSlot"/> itself rather than trusting an owner claim.</para>
|
||||
/// </remarks>
|
||||
public class WaveVote : NetworkBehaviour
|
||||
{
|
||||
// ----- Singleton ---------------------------------------------------
|
||||
|
||||
public static WaveVote Instance { get; private set; }
|
||||
|
||||
// ----- Inspector ---------------------------------------------------
|
||||
|
||||
[Tooltip("How many buff cards the vote offers. Standard is 3.")]
|
||||
[SerializeField] private int optionsPerVote = 3;
|
||||
|
||||
// ----- Networked state ---------------------------------------------
|
||||
|
||||
// The EnemyUpgradeOptionIds on the table. Empty when no vote is open.
|
||||
private NetworkList<int> offeredOptionIds;
|
||||
|
||||
// Ballots indexed by (int)PlayerSlot, sized 10 (slots 0-9; index 0 = PlayerSlot.None and
|
||||
// is never written). Value is the option id voted for, or NoVote. Public read permission
|
||||
// is deliberate — see the class remarks.
|
||||
private NetworkList<int> ballots;
|
||||
|
||||
/// <summary>Sentinel stored in <see cref="ballots"/> for a player who hasn't voted.</summary>
|
||||
public const int NoVote = -1;
|
||||
|
||||
// Wave slot this vote will upgrade, or -1 when no vote is open.
|
||||
private readonly NetworkVariable<int> targetSlot = new NetworkVariable<int>(
|
||||
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
/// <summary>Fired on every peer when the offered set, any ballot, or the open/closed
|
||||
/// state changes. The HUD subscribes to rebuild the vote panel and its voter badges.</summary>
|
||||
public event System.Action OnVoteChanged;
|
||||
|
||||
// ----- Lifecycle ----------------------------------------------------
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
offeredOptionIds = new NetworkList<int>();
|
||||
ballots = new NetworkList<int>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[WaveVote] Duplicate WaveVote detected. Only one may exist per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
// One entry per PlayerSlot value (None + Player1..Player9).
|
||||
for (int i = 0; i < 10; i++) ballots.Add(NoVote);
|
||||
}
|
||||
|
||||
offeredOptionIds.OnListChanged += HandleListChanged;
|
||||
ballots.OnListChanged += HandleListChanged;
|
||||
targetSlot.OnValueChanged += HandleTargetChanged;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
offeredOptionIds.OnListChanged -= HandleListChanged;
|
||||
ballots.OnListChanged -= HandleListChanged;
|
||||
targetSlot.OnValueChanged -= HandleTargetChanged;
|
||||
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void HandleListChanged(NetworkListEvent<int> _) => OnVoteChanged?.Invoke();
|
||||
private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke();
|
||||
|
||||
// ----- Read API ------------------------------------------------------
|
||||
|
||||
/// <summary>True while a vote is open for players to cast or change a ballot.</summary>
|
||||
public bool IsOpen => targetSlot.Value >= 0;
|
||||
|
||||
/// <summary>Wave slot this vote will upgrade, or -1 when closed.</summary>
|
||||
public int TargetSlot => targetSlot.Value;
|
||||
|
||||
public int OptionCount => offeredOptionIds.Count;
|
||||
|
||||
public int GetOptionId(int index)
|
||||
=> (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1;
|
||||
|
||||
/// <summary>The option <paramref name="slot"/> voted for, or <see cref="NoVote"/>.</summary>
|
||||
public int GetBallot(PlayerSlot slot)
|
||||
{
|
||||
int i = (int)slot;
|
||||
return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends every player slot that voted for <paramref name="optionId"/>. Drives the voter
|
||||
/// badges the HUD paints onto each card.
|
||||
/// </summary>
|
||||
public void GetVotersFor(int optionId, List<PlayerSlot> into)
|
||||
{
|
||||
into.Clear();
|
||||
if (optionId < 0) return;
|
||||
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
{
|
||||
if (pms.Slot == PlayerSlot.None) continue;
|
||||
if (GetBallot(pms.Slot) == optionId) into.Add(pms.Slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when every connected player with a real slot has cast a ballot. The inter-wave
|
||||
/// step uses this to end the vote early instead of burning the whole timer.
|
||||
/// </summary>
|
||||
public bool AllPlayersVoted
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsOpen) return false;
|
||||
|
||||
bool any = false;
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
{
|
||||
if (pms.Slot == PlayerSlot.None) continue;
|
||||
any = true;
|
||||
if (GetBallot(pms.Slot) == NoVote) return false;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Server: open / resolve ----------------------------------------
|
||||
|
||||
// Scratch reused across vote generation; server is single-threaded.
|
||||
private readonly List<EnemyUpgradePoolEntry> entryScratch = new List<EnemyUpgradePoolEntry>();
|
||||
private readonly List<int> idScratch = new List<int>();
|
||||
private readonly List<float> weightScratch = new List<float>();
|
||||
private readonly List<int> tiedScratch = new List<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: open a vote for <paramref name="waveSlot"/>, drawing cards from the
|
||||
/// current phase's allowed pool. Returns false (and opens nothing) if there is nothing
|
||||
/// legal left to offer — a wave that has already collected every card its phase allows
|
||||
/// simply gets no vote rather than an empty panel.
|
||||
/// </summary>
|
||||
public bool ServerOpenVote(int waveSlot)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
|
||||
var run = RunState.Instance;
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (run == null || pool == null)
|
||||
{
|
||||
Debug.LogWarning("[WaveVote] No RunState or EnemyUpgradePool in scene — " +
|
||||
"skipping the enemy-buff vote.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CollectCandidatesFor(waveSlot, run, pool))
|
||||
{
|
||||
Debug.Log($"[WaveVote] No enemy-buff cards left to offer for wave slot " +
|
||||
$"{waveSlot}; skipping the vote.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Weighted draw without replacement.
|
||||
offeredOptionIds.Clear();
|
||||
int draws = Mathf.Min(optionsPerVote, idScratch.Count);
|
||||
for (int n = 0; n < draws; n++)
|
||||
{
|
||||
int pick = DrawWeightedIndex(weightScratch);
|
||||
if (pick < 0) break;
|
||||
|
||||
offeredOptionIds.Add(idScratch[pick]);
|
||||
idScratch.RemoveAt(pick);
|
||||
weightScratch.RemoveAt(pick);
|
||||
}
|
||||
|
||||
ServerClearBallots();
|
||||
targetSlot.Value = waveSlot;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: tally the ballots, apply the winner to the target slot, and close the
|
||||
/// vote. Returns the winning option id, or -1 if no vote was open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ties resolve randomly among the tied options. At three players and three cards a 1/1/1
|
||||
/// split is common, and with open ballots the players can see it coming and break it
|
||||
/// themselves — which is the intended pressure, not a flaw in the tie-break.
|
||||
///
|
||||
/// <para>If nobody voted at all (everyone idle), one of the offered cards is chosen at
|
||||
/// random. Skipping the buff entirely would quietly make idling the optimal play.</para>
|
||||
/// </remarks>
|
||||
public int ServerResolve()
|
||||
{
|
||||
if (!IsServer || !IsOpen) return -1;
|
||||
|
||||
int slot = targetSlot.Value;
|
||||
int winner = TallyWinner();
|
||||
|
||||
if (winner >= 0)
|
||||
{
|
||||
var run = RunState.Instance;
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
|
||||
run?.ServerAddUpgrade(slot, winner);
|
||||
pool?.Get(winner)?.ServerOnApplied(run, slot);
|
||||
|
||||
Debug.Log($"[WaveVote] Wave slot {slot} gains " +
|
||||
$"'{pool?.Get(winner)?.DisplayName ?? winner.ToString()}'.");
|
||||
}
|
||||
|
||||
ServerClose();
|
||||
return winner;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: apply <paramref name="count"/> buffs to <paramref name="slot"/> outright,
|
||||
/// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade
|
||||
/// a future vote away for extra buffs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately opens no ballot: the card's whole cost is that the players <i>don't</i> get
|
||||
/// to choose this time. Drawing from the same weighted, slot-filtered candidate set the
|
||||
/// vote would have used keeps the outcome fair rather than worst-case.
|
||||
/// </remarks>
|
||||
public int ServerAutoApply(int slot, int count)
|
||||
{
|
||||
if (!IsServer || count <= 0) return 0;
|
||||
|
||||
var run = RunState.Instance;
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (run == null || pool == null) return 0;
|
||||
|
||||
var phase = run.CurrentPhase;
|
||||
if (phase == null) return 0;
|
||||
|
||||
int applied = 0;
|
||||
for (int n = 0; n < count; n++)
|
||||
{
|
||||
// Re-collect each time: applying one buff can invalidate another (a card the slot
|
||||
// now has, or one whose prerequisite just changed).
|
||||
if (!CollectCandidatesFor(slot, run, pool)) break;
|
||||
|
||||
int pick = DrawWeightedIndex(weightScratch);
|
||||
if (pick < 0) break;
|
||||
|
||||
int id = idScratch[pick];
|
||||
run.ServerAddUpgrade(slot, id);
|
||||
pool.Get(id)?.ServerOnApplied(run, slot);
|
||||
applied++;
|
||||
|
||||
Debug.Log($"[WaveVote] Auto-applied '{pool.Get(id)?.DisplayName ?? id.ToString()}' " +
|
||||
$"to wave slot {slot}.");
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
/// <summary>Server-only: close the vote without applying anything.</summary>
|
||||
public void ServerClose()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
targetSlot.Value = -1;
|
||||
offeredOptionIds.Clear();
|
||||
ServerClearBallots();
|
||||
}
|
||||
|
||||
private void ServerClearBallots()
|
||||
{
|
||||
for (int i = 0; i < ballots.Count; i++) ballots[i] = NoVote;
|
||||
}
|
||||
|
||||
// Highest vote count wins; ties broken at random. No votes cast → random offered card.
|
||||
private int TallyWinner()
|
||||
{
|
||||
if (offeredOptionIds.Count == 0) return -1;
|
||||
|
||||
int best = 0;
|
||||
tiedScratch.Clear();
|
||||
|
||||
for (int i = 0; i < offeredOptionIds.Count; i++)
|
||||
{
|
||||
int id = offeredOptionIds[i];
|
||||
int count = 0;
|
||||
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
{
|
||||
if (pms.Slot == PlayerSlot.None) continue;
|
||||
if (GetBallot(pms.Slot) == id) count++;
|
||||
}
|
||||
|
||||
if (count > best)
|
||||
{
|
||||
best = count;
|
||||
tiedScratch.Clear();
|
||||
tiedScratch.Add(id);
|
||||
}
|
||||
else if (count == best && best > 0)
|
||||
{
|
||||
tiedScratch.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (tiedScratch.Count == 0)
|
||||
{
|
||||
// Nobody voted — pick one of the offered cards rather than letting the wave
|
||||
// escape unbuffed, which would make going idle the strongest play.
|
||||
return offeredOptionIds[Random.Range(0, offeredOptionIds.Count)];
|
||||
}
|
||||
|
||||
return tiedScratch[Random.Range(0, tiedScratch.Count)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills <see cref="idScratch"/>/<see cref="weightScratch"/> with the cards this phase
|
||||
/// allows that <paramref name="slot"/> can still meaningfully take. Returns false when
|
||||
/// nothing qualifies.
|
||||
/// </summary>
|
||||
private bool CollectCandidatesFor(int slot, RunState run, EnemyUpgradePool pool)
|
||||
{
|
||||
var phase = run.CurrentPhase;
|
||||
if (phase == null) return false;
|
||||
|
||||
pool.CollectAllowedIds(phase.EnemyUpgradeGroups, entryScratch, idScratch, weightScratch);
|
||||
|
||||
// Filter to what this slot can actually take: not already applied, and the card itself
|
||||
// agrees it's a meaningful offer.
|
||||
for (int i = idScratch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
int id = idScratch[i];
|
||||
var option = pool.Get(id);
|
||||
|
||||
bool valid = option != null
|
||||
&& !run.SlotHasUpgrade(slot, id)
|
||||
&& option.IsValidForSlot(run, slot);
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
idScratch.RemoveAt(i);
|
||||
weightScratch.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
return idScratch.Count > 0;
|
||||
}
|
||||
|
||||
private static int DrawWeightedIndex(List<float> weights)
|
||||
{
|
||||
if (weights == null || weights.Count == 0) return -1;
|
||||
|
||||
float total = 0f;
|
||||
for (int i = 0; i < weights.Count; i++) total += Mathf.Max(0f, weights[i]);
|
||||
if (total <= 0f) return -1;
|
||||
|
||||
float roll = Random.value * total;
|
||||
for (int i = 0; i < weights.Count; i++)
|
||||
{
|
||||
roll -= Mathf.Max(0f, weights[i]);
|
||||
if (roll <= 0f) return i;
|
||||
}
|
||||
return weights.Count - 1; // float drift fallback
|
||||
}
|
||||
|
||||
// ----- Client → server ------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Cast or change this player's vote. Any client may send; the server resolves the sender
|
||||
/// to a slot itself and ignores anything it can't attribute or that isn't on the table.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RequestVoteRpc(int optionId, RpcParams rpcParams = default)
|
||||
{
|
||||
if (!IsOpen) return;
|
||||
|
||||
var senderSlot = PlayerMatchState.SlotForClient(rpcParams.Receive.SenderClientId);
|
||||
if (senderSlot == PlayerSlot.None) return;
|
||||
|
||||
// Only options actually on the table are accepted — a client can't vote a card in.
|
||||
bool offered = false;
|
||||
for (int i = 0; i < offeredOptionIds.Count; i++)
|
||||
if (offeredOptionIds[i] == optionId) { offered = true; break; }
|
||||
if (!offered) return;
|
||||
|
||||
int index = (int)senderSlot;
|
||||
if (index >= 0 && index < ballots.Count) ballots[index] = optionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a18321dc2d138034fbdb5f705f19f893
|
||||
|
|
@ -93,18 +93,26 @@ namespace TD.Gameplay
|
|||
[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.")]
|
||||
[Tooltip("Per-encounter gold rules. Element 0 = the run's first encounter. Encounters " +
|
||||
"are counted continuously across the whole run — cycles do NOT restart the " +
|
||||
"count — so a phase of 5 waves × 3 cycles + a boss needs 16 entries. Missing " +
|
||||
"entries fall back to zero gold for that encounter.")]
|
||||
public WaveGoldEntry[] Waves;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the gold entry for the given 1-based wave number, or null if out of range.
|
||||
/// Returns the gold entry for the given 1-based encounter number
|
||||
/// (<c>RunState.GlobalEncounterNumber</c>), or null if out of range.
|
||||
/// </summary>
|
||||
public WaveGoldEntry GetWaveEntry(int waveNumber)
|
||||
/// <remarks>
|
||||
/// Keyed by <b>encounter</b>, not by wave slot. Under the cyclical run structure the same
|
||||
/// five waves come round three times per phase, so a slot-keyed table would pay the same
|
||||
/// on cycle 3 as on cycle 1 while the enemies got steadily stronger. A run-long counter
|
||||
/// lets payouts climb monotonically with difficulty.
|
||||
/// </remarks>
|
||||
public WaveGoldEntry GetWaveEntry(int encounterNumber)
|
||||
{
|
||||
if (Waves == null) return null;
|
||||
int index = waveNumber - 1;
|
||||
int index = encounterNumber - 1;
|
||||
if (index < 0 || index >= Waves.Length) return null;
|
||||
return Waves[index];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,17 +210,21 @@ namespace TD.Gameplay
|
|||
|
||||
// --- Slot allocation (server-only) -------------------------------
|
||||
|
||||
// Caps at MatchRules.MaxPlayers rather than at the PlayerSlot enum's range — the enum
|
||||
// still runs to 9 so baked LevelData owner grids stay valid, but only the first
|
||||
// MaxPlayers slots are ever handed out. See MatchRules for why.
|
||||
private static PlayerSlot AllocateNextSlot()
|
||||
{
|
||||
for (int i = 1; i <= 9; i++)
|
||||
for (int i = 1; i <= MatchRules.MaxPlayers; i++)
|
||||
{
|
||||
var candidate = (PlayerSlot)i;
|
||||
if (!s_assignedSlots.Contains(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
Debug.LogError("[PlayerMatchState] No free PlayerSlot (already 9 players). " +
|
||||
"Returning None — this client will be unable to play.");
|
||||
Debug.LogError($"[PlayerMatchState] No free PlayerSlot (lobby is full at " +
|
||||
$"{MatchRules.MaxPlayers} players). Returning None — this client " +
|
||||
$"will be unable to play.");
|
||||
return PlayerSlot.None;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
Normal file
116
Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-player set of unlocked tower <b>upgrade nodes</b> — the tower types this player is
|
||||
/// allowed to convert an existing tower into. Lives on the Player prefab alongside
|
||||
/// <see cref="PlayerTowerDeck"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Distinct from the deck, deliberately.</b> <see cref="PlayerTowerDeck"/> is what you
|
||||
/// can <i>build from scratch</i>; this is what you can <i>upgrade into</i>. They have to be
|
||||
/// separate sets because the 2.0 model is two-step: a draft pick unlocks a node, and gold spent
|
||||
/// on an already-placed tower converts it. Unlocking an upgrade must not make it directly
|
||||
/// buildable, or the gold step — the entire cost of the upgrade — is bypassed.</para>
|
||||
///
|
||||
/// <para><b>The tree lives in the data.</b> Edges are <c>TowerDefinition.UpgradePaths</c>; this
|
||||
/// component only records which nodes a player has earned the right to use. Whether a
|
||||
/// particular tower can take a particular upgrade is a question for the tree (is it a child of
|
||||
/// what's placed?) crossed with this set (has the player unlocked it?).</para>
|
||||
///
|
||||
/// <para>Append-only within a match, mirroring the deck. Reset happens by re-initializing at
|
||||
/// match start, so Retry / return-to-lobby cycles start clean.</para>
|
||||
/// </remarks>
|
||||
public class PlayerTowerUpgrades : NetworkBehaviour
|
||||
{
|
||||
// ----- Static registry (mirrors PlayerTowerDeck / PlayerGoldManager) -----
|
||||
|
||||
private static readonly Dictionary<ulong, PlayerTowerUpgrades> s_byClientId
|
||||
= new Dictionary<ulong, PlayerTowerUpgrades>();
|
||||
|
||||
public static PlayerTowerUpgrades GetForClient(ulong clientId)
|
||||
{
|
||||
s_byClientId.TryGetValue(clientId, out var upgrades);
|
||||
return upgrades;
|
||||
}
|
||||
|
||||
public static PlayerTowerUpgrades Local
|
||||
{
|
||||
get
|
||||
{
|
||||
var nm = NetworkManager.Singleton;
|
||||
if (nm == null || !nm.IsClient) return null;
|
||||
return GetForClient(nm.LocalClientId);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Networked state --------------------------------------------
|
||||
|
||||
// TowerTypeIds (catalog indices) this player may upgrade into.
|
||||
private NetworkList<int> unlockedNodes;
|
||||
|
||||
/// <summary>Fired on every peer when the unlocked set changes. The HUD subscribes so a
|
||||
/// selected tower's upgrade buttons appear the moment a draft grants them.</summary>
|
||||
public event System.Action OnUpgradesChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
unlockedNodes = new NetworkList<int>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
s_byClientId[OwnerClientId] = this;
|
||||
unlockedNodes.OnListChanged += HandleListChanged;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
unlockedNodes.OnListChanged -= HandleListChanged;
|
||||
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
|
||||
s_byClientId.Remove(OwnerClientId);
|
||||
}
|
||||
|
||||
private void HandleListChanged(NetworkListEvent<int> _) => OnUpgradesChanged?.Invoke();
|
||||
|
||||
// ----- Read API ---------------------------------------------------
|
||||
|
||||
public int Count => unlockedNodes.Count;
|
||||
|
||||
public int GetTypeIdAt(int index) => unlockedNodes[index];
|
||||
|
||||
/// <summary>True if this player has unlocked the given upgrade node.</summary>
|
||||
public bool Contains(int towerTypeId)
|
||||
{
|
||||
for (int i = 0; i < unlockedNodes.Count; i++)
|
||||
if (unlockedNodes[i] == towerTypeId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----- Server API -------------------------------------------------
|
||||
|
||||
/// <summary>Server-only: clear the unlocked set. Called at match start.</summary>
|
||||
public void ServerInitialize()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
unlockedNodes.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: unlock an upgrade node. Returns false if already unlocked (so a draft
|
||||
/// option can report a wasted pick rather than silently succeeding).
|
||||
/// </summary>
|
||||
public bool ServerUnlock(int towerTypeId)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
if (towerTypeId < 0 || Contains(towerTypeId)) return false;
|
||||
|
||||
unlockedNodes.Add(towerTypeId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f60f6928562351f4891142ad59ab6352
|
||||
|
|
@ -303,6 +303,10 @@ namespace TD.Gameplay
|
|||
ApplyTint();
|
||||
paintColor.OnValueChanged += HandlePaintColorChanged;
|
||||
|
||||
// An upgrade swaps the replicated TypeId out from under every peer; re-resolve so
|
||||
// clients pick up the new stats and tint instead of holding the pre-upgrade asset.
|
||||
definitionTypeId.OnValueChanged += HandleDefinitionTypeChanged;
|
||||
|
||||
// Register for minimap rendering.
|
||||
MinimapEntityRegistry.Register(this);
|
||||
|
||||
|
|
@ -332,7 +336,8 @@ namespace TD.Gameplay
|
|||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
paintColor.OnValueChanged -= HandlePaintColorChanged;
|
||||
paintColor.OnValueChanged -= HandlePaintColorChanged;
|
||||
definitionTypeId.OnValueChanged -= HandleDefinitionTypeChanged;
|
||||
|
||||
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
|
||||
// so the tiles become walkable and buildable again.
|
||||
|
|
@ -412,6 +417,152 @@ namespace TD.Gameplay
|
|||
upgradeCount.Value += 1;
|
||||
}
|
||||
|
||||
// ----- Upgrading ------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer when this tower's definition changes (i.e. it was upgraded).
|
||||
/// The HUD subscribes to relabel a selected tower's action grid.
|
||||
/// </summary>
|
||||
public event System.Action OnDefinitionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gold cost to convert this tower into <paramref name="target"/>. The target's own
|
||||
/// <see cref="TowerDefinition.GoldCost"/> is the price — upgrade nodes are never placed
|
||||
/// directly, so their cost field is free to mean "what this upgrade costs".
|
||||
/// </summary>
|
||||
public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0;
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="target"/> is a legal upgrade of this tower's current type: a
|
||||
/// direct child in the upgrade tree, with a matching footprint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The footprint check is not cosmetic.</b> A tower's occupied/unwalkable tiles are
|
||||
/// stamped into the grid at its current size; converting to a differently-sized node would
|
||||
/// leave the stamp describing a shape the tower no longer has, corrupting both pathfinding
|
||||
/// and future placement checks. Growing a tower's footprint needs a stamp-swap (and a
|
||||
/// re-validation that the new tiles are even free), which the tree doesn't currently need —
|
||||
/// so it's rejected loudly rather than half-supported.
|
||||
/// </remarks>
|
||||
public bool CanUpgradeTo(TowerDefinition target)
|
||||
{
|
||||
if (target == null || resolvedDefinition == null) return false;
|
||||
if (resolvedDefinition.UpgradePaths == null) return false;
|
||||
|
||||
bool isChild = false;
|
||||
foreach (var path in resolvedDefinition.UpgradePaths)
|
||||
{
|
||||
if (path == target) { isChild = true; break; }
|
||||
}
|
||||
if (!isChild) return false;
|
||||
|
||||
return target.FootprintSize == resolvedDefinition.FootprintSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the upgrades <paramref name="clientId"/> can currently apply to this tower:
|
||||
/// direct children of its type that the player has unlocked. Does not filter on gold —
|
||||
/// the HUD shows unaffordable upgrades disabled rather than hiding them, so players can
|
||||
/// see what they're saving for.
|
||||
/// </summary>
|
||||
public void CollectAvailableUpgrades(ulong clientId, List<(TowerDefinition Def, int TypeId)> into)
|
||||
{
|
||||
into.Clear();
|
||||
|
||||
var unlocked = PlayerTowerUpgrades.GetForClient(clientId);
|
||||
var pm = TowerPlacementManager.Instance;
|
||||
if (unlocked == null || pm == null || resolvedDefinition?.UpgradePaths == null) return;
|
||||
|
||||
foreach (var path in resolvedDefinition.UpgradePaths)
|
||||
{
|
||||
if (path == null) continue;
|
||||
if (!pm.TryGetTypeId(path, out int typeId)) continue;
|
||||
if (!unlocked.Contains(typeId)) continue;
|
||||
if (!CanUpgradeTo(path)) continue;
|
||||
|
||||
into.Add((path, typeId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client → server request to convert this tower into <paramref name="targetTypeId"/>.
|
||||
/// Accepted only from the owner, only for a node they've unlocked, only along a real tree
|
||||
/// edge, and only if they can pay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every check is repeated here even though the HUD already filters — the HUD is a
|
||||
/// convenience, this is the authority. Same posture as placement, paint, and sell.
|
||||
/// </remarks>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RequestUpgradeServerRpc(int targetTypeId, RpcParams rpcParams = default)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (serverSold) return; // mid-sell; nothing to upgrade
|
||||
|
||||
ulong senderClientId = rpcParams.Receive.SenderClientId;
|
||||
PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId);
|
||||
if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} " +
|
||||
$"({senderSlot}) does not own tower owned by {ownerSlot.Value}.");
|
||||
return;
|
||||
}
|
||||
|
||||
var target = TowerPlacementManager.GetDefinition(targetTypeId);
|
||||
if (target == null)
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Upgrade rejected: TypeId {targetTypeId} is not in the catalog.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CanUpgradeTo(target))
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Upgrade rejected: '{target.name}' is not a " +
|
||||
$"same-footprint child of '{resolvedDefinition?.name}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var unlocked = PlayerTowerUpgrades.GetForClient(senderClientId);
|
||||
if (unlocked == null || !unlocked.Contains(targetTypeId))
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} has not " +
|
||||
$"unlocked '{target.name}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
int cost = GetUpgradeCost(target);
|
||||
var gold = PlayerGoldManager.GetForClient(senderClientId);
|
||||
if (gold == null || gold.CurrentGold < cost)
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Upgrade rejected: client {senderClientId} cannot " +
|
||||
$"afford '{target.name}' ({cost}g).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cost > 0) gold.DeductGold(cost);
|
||||
|
||||
// Record the spend before switching type, so the refund reflects everything sunk in
|
||||
// and the tower loses any full-refund-while-unupgraded status.
|
||||
ServerAddUpgradeInvestment(cost);
|
||||
|
||||
// Switching the replicated TypeId is the upgrade: TowerCombat re-reads Definition
|
||||
// every tick, so the new stats take effect on the next shot with nothing to notify.
|
||||
definitionTypeId.Value = targetTypeId;
|
||||
resolvedDefinition = target;
|
||||
|
||||
OnDefinitionChanged?.Invoke();
|
||||
}
|
||||
|
||||
// Re-resolve and re-tint on clients when the type changes under them (an upgrade landed).
|
||||
private void HandleDefinitionTypeChanged(int previous, int current)
|
||||
{
|
||||
if (previous == current) return;
|
||||
|
||||
resolvedDefinition = TowerPlacementManager.GetDefinition(current);
|
||||
ApplyTint();
|
||||
OnDefinitionChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client → server request to sell this tower. Accepted only from the tower's owner
|
||||
/// (same ownership rule as placement and paint). The server refunds gold, broadcasts
|
||||
|
|
|
|||
|
|
@ -41,5 +41,64 @@ namespace TD.Gameplay
|
|||
|
||||
[Tooltip("Enemy groups that make up this wave. Processed in order.")]
|
||||
public WaveEntry[] Entries;
|
||||
|
||||
/// <summary>
|
||||
/// The enemy type this wave is "about" — the first assigned entry's type. Null if the
|
||||
/// wave has no usable entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The 2.0 design gives each wave a single enemy type, and enemy upgrades are keyed to a
|
||||
/// wave slot, so this is the identity the phase draw uses to keep slots distinct and the
|
||||
/// HUD uses to label an upcoming wave. <see cref="Entries"/> still supports multiple
|
||||
/// groups (useful for staggering counts of the same type), but mixing <i>types</i> within
|
||||
/// one wave makes this ambiguous — see <see cref="HasSingleEnemyType"/>.
|
||||
/// </remarks>
|
||||
public EnemyDefinition PrimaryEnemyType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Entries == null) return null;
|
||||
foreach (var e in Entries)
|
||||
{
|
||||
if (e.EnemyType != null && e.Count > 0) return e.EnemyType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if every usable entry in this wave spawns the same enemy type. False means
|
||||
/// <see cref="PrimaryEnemyType"/> is only telling part of the story — the run validator
|
||||
/// warns on these rather than rejecting them, since a mixed wave still plays fine, it
|
||||
/// just labels and upgrades oddly.
|
||||
/// </summary>
|
||||
public bool HasSingleEnemyType
|
||||
{
|
||||
get
|
||||
{
|
||||
var first = PrimaryEnemyType;
|
||||
if (first == null) return false;
|
||||
foreach (var e in Entries)
|
||||
{
|
||||
if (e.EnemyType != null && e.Count > 0 && e.EnemyType != first) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total enemies spawned per zone by this wave, across all entries.</summary>
|
||||
public int TotalEnemyCount
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Entries == null) return 0;
|
||||
int total = 0;
|
||||
foreach (var e in Entries)
|
||||
{
|
||||
if (e.EnemyType != null && e.Count > 0) total += e.Count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,44 +6,54 @@ using TD.Core;
|
|||
using TD.Gameplay.BuilderEffects;
|
||||
using TD.Gameplay.Draft;
|
||||
using TD.Gameplay.EnemyAbilities;
|
||||
using TD.Gameplay.EnemyUpgrades;
|
||||
using TD.Gameplay.Waves;
|
||||
using TD.Levels;
|
||||
using TD.UI;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative wave controller. Spawns enemies across all player zones,
|
||||
/// tracks wave completion, awards kill gold, and manages the shared lives pool.
|
||||
/// Server-authoritative encounter driver. Spawns enemies across all player zones, tracks
|
||||
/// completion, awards kill gold, and manages the shared lives pool. <b>Which</b> encounter to
|
||||
/// run comes from <see cref="RunState"/>; this class only runs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Wave lifecycle:</b>
|
||||
/// <b>Encounter lifecycle:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>When <see cref="MatchPhase.Playing"/> is entered,
|
||||
/// <see cref="StartNextWave"/> advances <see cref="MatchState.CurrentWave"/>
|
||||
/// immediately (so the HUD shows the wave number during prep), then waits
|
||||
/// <see cref="WaveDefinition.PrepTime"/> before spawning.</item>
|
||||
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone,
|
||||
/// one zone per frame-group, with <c>SpawnInterval</c> seconds between
|
||||
/// individual enemies in the group.</item>
|
||||
/// <item>After all entries are spawned, the wave is considered complete only when
|
||||
/// every active enemy is either killed or has reached the goal.</item>
|
||||
/// <item>All waves exhausted → <see cref="MatchPhase.Victory"/>.</item>
|
||||
/// <item>When <see cref="MatchPhase.Playing"/> is entered, <see cref="StartRun"/> asks
|
||||
/// <see cref="RunState.ServerBeginRun"/> to draw phase 1 and then runs its first wave.</item>
|
||||
/// <item>Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the
|
||||
/// build countdown) → spawn → mop up. See <see cref="RunInterWave"/>.</item>
|
||||
/// <item>Each <see cref="WaveEntry"/> spawns <c>Count</c> enemies per player zone. All
|
||||
/// spawn held, then release in 10% chunks on <c>ReleaseInterval</c>.</item>
|
||||
/// <item>The encounter is complete only when every spawned enemy is dead or has leaked.
|
||||
/// <see cref="RunState.ServerAdvance"/> then decides what comes next.</item>
|
||||
/// <item>Final phase's boss dies → <see cref="MatchPhase.Victory"/>.</item>
|
||||
/// <item>Lives drop to 0 → <see cref="MatchPhase.Defeat"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Why the wave list moved out.</b> Waves used to be a hand-ordered array walked
|
||||
/// start-to-finish, so "which wave is next" was just an index++. Under the 2.0 cyclical
|
||||
/// design the same five waves repeat across three cycles while accumulating player-voted
|
||||
/// upgrades, and each phase draws a fresh five — progression that no longer fits in an index.
|
||||
/// <see cref="RunState"/> owns it; this class asks it what to run.
|
||||
///
|
||||
/// <b>Kill gold:</b> When an enemy dies, <see cref="EnemyHealth.LastHitOwner"/> names
|
||||
/// the tower's player. <see cref="PlayerMatchState.GetForSlot"/> resolves the
|
||||
/// <c>OwnerClientId</c>, and <see cref="PlayerGoldManager.GetForClient"/> awards
|
||||
/// the gold.
|
||||
/// the gold. <c>GoldConfig</c> is indexed by
|
||||
/// <see cref="RunState.GlobalEncounterNumber"/> — a run-long counter, not a per-cycle one, so
|
||||
/// per-wave payouts keep climbing as the cycles repeat instead of resetting.
|
||||
///
|
||||
/// <b>Zone leak counts:</b> <see cref="zoneLeakCounts"/> is a <c>NetworkList</c>
|
||||
/// indexed by <c>(int)PlayerSlot</c> (indices 0–8). It is incremented when an enemy
|
||||
/// indexed by <c>(int)PlayerSlot</c>. It is incremented when an enemy
|
||||
/// crosses from one player zone into another, giving the HUD a per-player leak score.
|
||||
/// Index 0 corresponds to <see cref="PlayerSlot.None"/> and is unused.
|
||||
///
|
||||
/// <b>Inspector setup:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>Assign <see cref="waveDefinitions"/> in order (Wave 1 at index 0).</item>
|
||||
/// <item>Assign a <c>RunDefinition</c> to the scene's <see cref="RunState"/>.</item>
|
||||
/// <item>Set <see cref="startingLives"/> to match your level design intent.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
|
|
@ -55,12 +65,18 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
[Tooltip("Wave definitions in order. Index 0 = Wave 1.")]
|
||||
[SerializeField] private WaveDefinition[] waveDefinitions;
|
||||
|
||||
[Tooltip("Shared lives pool at the start of a match.")]
|
||||
[SerializeField] private int startingLives = 20;
|
||||
|
||||
[Tooltip("Seconds players get to make their personal draft pick. Ends early once every " +
|
||||
"player has picked. Be generous — this is reading time, not reaction time.")]
|
||||
[SerializeField] private float draftTimeSeconds = 30f;
|
||||
|
||||
[Tooltip("Seconds players get to vote on the enemy buff. Ends early once every player " +
|
||||
"has voted. Generous enough that players can watch each other's votes land and " +
|
||||
"change their own in response.")]
|
||||
[SerializeField] private float voteTimeSeconds = 25f;
|
||||
|
||||
[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 " +
|
||||
|
|
@ -95,14 +111,27 @@ namespace TD.Gameplay
|
|||
readPerm: NetworkVariableReadPermission.Everyone,
|
||||
writePerm: NetworkVariableWritePermission.Server);
|
||||
|
||||
// Which inter-wave stage the countdown above belongs to. One countdown serves all three
|
||||
// stages (they never overlap), so the HUD needs this to label it correctly.
|
||||
private readonly NetworkVariable<InterWaveStage> interWaveStage =
|
||||
new NetworkVariable<InterWaveStage>(
|
||||
value: InterWaveStage.None,
|
||||
readPerm: NetworkVariableReadPermission.Everyone,
|
||||
writePerm: NetworkVariableWritePermission.Server);
|
||||
|
||||
// ----- Server-local runtime state ---------------------------------
|
||||
|
||||
private int remainingLives;
|
||||
private int activeEnemyCount;
|
||||
private bool spawningComplete;
|
||||
private int currentWaveIndex = -1; // -1 = not yet started
|
||||
private bool runStarted;
|
||||
private Coroutine activeWaveCoroutine;
|
||||
|
||||
// Wave slot the NEXT inter-wave vote will buff — captured when an encounter clears, before
|
||||
// the run advances past it. -1 means "no vote due": the run's first encounter (nothing has
|
||||
// been beaten yet) or straight after a boss (the phase's slots are being wiped).
|
||||
private int pendingVoteSlot = -1;
|
||||
|
||||
private readonly System.Collections.Generic.List<EnemyHealth> heldEnemies
|
||||
= new System.Collections.Generic.List<EnemyHealth>();
|
||||
|
||||
|
|
@ -178,6 +207,11 @@ namespace TD.Gameplay
|
|||
{
|
||||
var deck = PlayerTowerDeck.GetForClient(pms.OwnerClientId);
|
||||
if (deck != null) deck.ServerInitialize(startingTypeIds);
|
||||
|
||||
// Upgrade nodes start empty — every one is earned through the draft. Cleared
|
||||
// rather than left alone so Retry / return-to-lobby doesn't carry a previous
|
||||
// run's unlocks into a new one.
|
||||
PlayerTowerUpgrades.GetForClient(pms.OwnerClientId)?.ServerInitialize();
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
@ -187,7 +221,7 @@ namespace TD.Gameplay
|
|||
}
|
||||
|
||||
if (ms.Phase == MatchPhase.Playing)
|
||||
StartNextWave();
|
||||
StartRun();
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
|
|
@ -200,13 +234,6 @@ namespace TD.Gameplay
|
|||
|
||||
// ----- Public accessors -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Total number of waves in this match. Same on every peer because
|
||||
/// <see cref="waveDefinitions"/> is a serialized prefab field, identical
|
||||
/// on host and clients. Returns 0 if the array is unassigned.
|
||||
/// </summary>
|
||||
public int TotalWaves => waveDefinitions?.Length ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of times enemies have leaked out of the given player's zone over the
|
||||
/// entire match. Replicated — safe to call on any peer.
|
||||
|
|
@ -230,34 +257,82 @@ namespace TD.Gameplay
|
|||
/// </summary>
|
||||
public float PrepCountdown => prepCountdown.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Which inter-wave step the current countdown belongs to, or
|
||||
/// <see cref="InterWaveStage.None"/> while a wave is spawning or being fought. Replicated;
|
||||
/// safe on any peer. The HUD uses it to label the shared countdown.
|
||||
/// </summary>
|
||||
public InterWaveStage CurrentInterWaveStage => interWaveStage.Value;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 1-based encounter number since the run began, or 0 before it starts. This is the key
|
||||
/// <see cref="GoldConfig"/> is indexed by. Replicated via <see cref="RunState"/>, so it's
|
||||
/// safe on any peer.
|
||||
/// </summary>
|
||||
public int CurrentEncounterNumber
|
||||
=> RunState.Instance != null && runStarted ? RunState.Instance.GlobalEncounterNumber : 0;
|
||||
|
||||
// ----- Phase handling ---------------------------------------------
|
||||
|
||||
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (next == MatchPhase.Playing && currentWaveIndex < 0)
|
||||
StartNextWave();
|
||||
if (next == MatchPhase.Playing) StartRun();
|
||||
}
|
||||
|
||||
// ----- Wave coroutine ---------------------------------------------
|
||||
// ----- Run control ------------------------------------------------
|
||||
|
||||
private void StartNextWave(bool skipPrep = false)
|
||||
/// <summary>
|
||||
/// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent —
|
||||
/// re-entering <see cref="MatchPhase.Playing"/> won't restart a run in progress.
|
||||
/// </summary>
|
||||
private void StartRun()
|
||||
{
|
||||
currentWaveIndex++;
|
||||
if (!IsServer || runStarted) return;
|
||||
|
||||
if (waveDefinitions == null || currentWaveIndex >= waveDefinitions.Length)
|
||||
var run = RunState.Instance;
|
||||
if (run == null)
|
||||
{
|
||||
Debug.Log("[WaveManager] All waves complete. Victory.");
|
||||
MatchState.Instance?.SetPhase(MatchPhase.Victory);
|
||||
Debug.LogError("[WaveManager] No RunState in the scene — no waves can run. " +
|
||||
"Add a RunState object and assign it a RunDefinition.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance the replicated wave counter at the START of prep so the HUD
|
||||
// shows the upcoming wave number during the countdown.
|
||||
MatchState.Instance?.SetCurrentWave(currentWaveIndex + 1); // 1-based
|
||||
if (!run.ServerBeginRun())
|
||||
{
|
||||
// ServerBeginRun already logged the specific reason (missing asset, unplayable
|
||||
// pools). Bail rather than spawn an empty encounter that can never complete.
|
||||
Debug.LogError("[WaveManager] Run could not be started; see the RunState error above.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset per-wave bookkeeping for the wave that's about to begin:
|
||||
runStarted = true;
|
||||
StartEncounter(skipInterWave: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: run whatever encounter <see cref="RunState"/> is currently pointing at.
|
||||
/// </summary>
|
||||
/// <param name="skipInterWave">Skip the draft/vote/build step and spawn immediately.
|
||||
/// Used by the dev force-advance cheat.</param>
|
||||
private void StartEncounter(bool skipInterWave)
|
||||
{
|
||||
var run = RunState.Instance;
|
||||
var def = run?.CurrentWave;
|
||||
|
||||
if (def == null)
|
||||
{
|
||||
Debug.LogError($"[WaveManager] No wave drawn for {run?.ProgressLabel ?? "?"} — " +
|
||||
$"the run cannot continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish the run-long encounter number so the HUD and the gold lookup agree on
|
||||
// which encounter's payout table applies.
|
||||
MatchState.Instance?.SetCurrentWave(run.GlobalEncounterNumber);
|
||||
|
||||
// Reset per-wave bookkeeping for the encounter 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.
|
||||
|
|
@ -272,23 +347,91 @@ namespace TD.Gameplay
|
|||
activeEnemyCount = 0;
|
||||
spawningComplete = false;
|
||||
|
||||
activeWaveCoroutine = StartCoroutine(
|
||||
RunWave(waveDefinitions[currentWaveIndex], skipPrep));
|
||||
activeWaveCoroutine = StartCoroutine(RunEncounter(def, skipInterWave));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: the encounter is over — ask <see cref="RunState"/> what's next and either
|
||||
/// start it or end the match.
|
||||
/// </summary>
|
||||
private void AdvanceRun()
|
||||
{
|
||||
var run = RunState.Instance;
|
||||
if (run == null) return;
|
||||
|
||||
switch (run.ServerAdvance())
|
||||
{
|
||||
case RunAdvance.RunComplete:
|
||||
Debug.Log("[WaveManager] Final boss defeated. Victory.");
|
||||
MatchState.Instance?.SetPhase(MatchPhase.Victory);
|
||||
return;
|
||||
|
||||
case RunAdvance.BossReady:
|
||||
Debug.Log($"[WaveManager] All cycles cleared — boss incoming " +
|
||||
$"({run.ProgressLabel}).");
|
||||
break;
|
||||
|
||||
case RunAdvance.NextPhase:
|
||||
Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " +
|
||||
$"Enemy upgrades wiped.");
|
||||
break;
|
||||
|
||||
case RunAdvance.NextCycle:
|
||||
Debug.Log($"[WaveManager] Cycle complete — same waves return upgraded " +
|
||||
$"({run.ProgressLabel}).");
|
||||
break;
|
||||
}
|
||||
|
||||
StartEncounter(skipInterWave: false);
|
||||
}
|
||||
|
||||
// ----- Dev / cheats -----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Dev cheat: skip the rest of the current wave (despawn any remaining
|
||||
/// enemies, kill the prep timer) and start the next wave immediately.
|
||||
/// Server-only — silently no-ops on clients. Safe to call during prep,
|
||||
/// mid-spawn, or while enemies are alive.
|
||||
/// Dev cheat: skip the rest of the current encounter (despawn any remaining enemies, kill
|
||||
/// the timers) and start the next one immediately. Server-only — silently no-ops on
|
||||
/// clients. Safe to call during the inter-wave step, mid-spawn, or while enemies are alive.
|
||||
/// </summary>
|
||||
public void ForceAdvanceToNextWave()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (!IsServer || !runStarted) return;
|
||||
ClearFieldAndStopEncounter();
|
||||
AdvanceRun();
|
||||
}
|
||||
|
||||
// Stop the current wave's coroutine (cancels prep timer + remaining spawns).
|
||||
/// <summary>
|
||||
/// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for
|
||||
/// testing boss content without playing fifteen waves. Server-only.
|
||||
/// </summary>
|
||||
public void ForceAdvanceToBoss()
|
||||
{
|
||||
if (!IsServer || !runStarted) return;
|
||||
|
||||
var run = RunState.Instance;
|
||||
if (run == null || run.IsBossStage) return;
|
||||
|
||||
ClearFieldAndStopEncounter();
|
||||
|
||||
// Advance until the boss is up (or the run ends, which shouldn't happen from a
|
||||
// non-boss encounter but is guarded anyway).
|
||||
int guard = run.EncountersPerPhase + 1;
|
||||
while (!run.IsBossStage && guard-- > 0)
|
||||
{
|
||||
if (run.ServerAdvance() == RunAdvance.RunComplete)
|
||||
{
|
||||
MatchState.Instance?.SetPhase(MatchPhase.Victory);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
StartEncounter(skipInterWave: false);
|
||||
}
|
||||
|
||||
// Shared teardown for the dev cheats: stop the running encounter, wipe the field, and
|
||||
// resolve anything the players had open so nothing is left dangling mid-skip.
|
||||
private void ClearFieldAndStopEncounter()
|
||||
{
|
||||
// Stop the current encounter's coroutine (cancels timers + remaining spawns).
|
||||
if (activeWaveCoroutine != null)
|
||||
{
|
||||
StopCoroutine(activeWaveCoroutine);
|
||||
|
|
@ -316,59 +459,40 @@ namespace TD.Gameplay
|
|||
}
|
||||
}
|
||||
|
||||
// Reset bookkeeping and start the next wave's RunWave coroutine,
|
||||
// skipping the prep timer so spawning starts immediately.
|
||||
activeEnemyCount = 0;
|
||||
spawningComplete = false;
|
||||
heldEnemies.Clear();
|
||||
|
||||
// Force-advancing interrupts the prep phase, so resolve any drafts the players
|
||||
// had open before skipping ahead.
|
||||
// Skipping ahead interrupts the inter-wave step, so resolve anything the players had
|
||||
// open before moving on. The draft auto-resolves (a free reward shouldn't vanish
|
||||
// because a dev pressed a key); the vote is closed WITHOUT resolving, since applying a
|
||||
// buff nobody finished voting on would silently rewrite the run.
|
||||
DraftService.Instance?.ServerAutoResolveAll();
|
||||
WaveVote.Instance?.ServerClose();
|
||||
|
||||
StartNextWave(skipPrep: true);
|
||||
interWaveStage.Value = InterWaveStage.None;
|
||||
prepCountdown.Value = 0f;
|
||||
}
|
||||
|
||||
private IEnumerator RunWave(WaveDefinition def, bool skipPrep = false)
|
||||
// ----- Encounter coroutine ----------------------------------------
|
||||
|
||||
private IEnumerator RunEncounter(WaveDefinition def, bool skipInterWave = false)
|
||||
{
|
||||
// Prep phase — players build while the countdown ticks. Skipped when
|
||||
// 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)
|
||||
{
|
||||
// Open a draft for every player at the start of the build phase. They pick
|
||||
// during prep; any unpicked draft is auto-resolved when the timer expires.
|
||||
DraftService.Instance?.ServerOfferToAll();
|
||||
// Draft, enemy-buff vote, then the build countdown. Skipped entirely when a dev
|
||||
// cheat forces the encounter to start immediately.
|
||||
if (!skipInterWave)
|
||||
yield return RunInterWave(def);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Prep timer expired — auto-resolve any draft the player didn't pick so
|
||||
// the free reward isn't wasted.
|
||||
DraftService.Instance?.ServerAutoResolveAll();
|
||||
}
|
||||
// Ensure the countdown reads zero entering the spawn phase, regardless of
|
||||
// whether prep was skipped or just expired.
|
||||
// whether the build step was skipped or just expired.
|
||||
prepCountdown.Value = 0f;
|
||||
|
||||
// Resolve this wave slot's voted buffs once, here — the set is fixed for the whole
|
||||
// encounter, so doing it per enemy would repeat the same lookup hundreds of times.
|
||||
// Must run AFTER the inter-wave step, since the vote that just closed may have added
|
||||
// to a slot this very encounter re-runs on a later cycle.
|
||||
ResolveCurrentWaveAbilities();
|
||||
|
||||
// Spawn all enemies at once in a held (untargetable, immobile) state.
|
||||
if (def.Entries != null)
|
||||
{
|
||||
|
|
@ -405,6 +529,145 @@ namespace TD.Gameplay
|
|||
CheckWaveComplete();
|
||||
}
|
||||
|
||||
// ----- Inter-wave step ---------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The step between encounters: players take their personal draft, then vote on the buff
|
||||
/// the wave they just cleared will carry into the next cycle, then build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three stages run strictly in sequence and never overlap. The build countdown used
|
||||
/// to double as the draft window, which made "wait until everyone has chosen" impossible
|
||||
/// to express — players who wanted to build were paying for teammates who wanted to read
|
||||
/// their cards. Separating them costs a few seconds per wave and makes both steps mean
|
||||
/// what they say.
|
||||
///
|
||||
/// <para>Each stage ends early the moment every player has acted, so the generous timers
|
||||
/// are a ceiling for deliberation, not a floor everyone sits through.</para>
|
||||
/// </remarks>
|
||||
private IEnumerator RunInterWave(WaveDefinition def)
|
||||
{
|
||||
// ----- 1. Personal draft -----
|
||||
// Offered to everyone at once. Anyone who hasn't picked when the timer expires has
|
||||
// their draft auto-resolved, so an idle player never wastes a free reward.
|
||||
interWaveStage.Value = InterWaveStage.Draft;
|
||||
DraftService.Instance?.ServerOfferToAll();
|
||||
yield return TickCountdown(draftTimeSeconds, () => DraftService.AllPlayersPicked);
|
||||
DraftService.Instance?.ServerAutoResolveAll();
|
||||
|
||||
// ----- 2. Enemy-buff vote -----
|
||||
// Votes on the wave that was just cleared, which is the slot captured before the run
|
||||
// advanced. Skipped on the run's first encounter (nothing has been defeated yet) and
|
||||
// after a boss (that phase's slots are being wiped anyway).
|
||||
if (pendingVoteSlot >= 0)
|
||||
{
|
||||
var vote = WaveVote.Instance;
|
||||
var run = RunState.Instance;
|
||||
|
||||
// A vote-meta card may have armed this slot to skip its next vote and take
|
||||
// several buffs outright. That's the card's whole effect, so it pre-empts the
|
||||
// normal vote rather than running alongside it.
|
||||
int autoCount = run != null ? run.GetAutoApplyCount(pendingVoteSlot) : 0;
|
||||
|
||||
if (autoCount > 0 && vote != null)
|
||||
{
|
||||
int applied = vote.ServerAutoApply(pendingVoteSlot, autoCount);
|
||||
run.ServerConsumeAutoApply(pendingVoteSlot);
|
||||
Debug.Log($"[WaveManager] Wave slot {pendingVoteSlot} auto-took {applied} " +
|
||||
$"buff(s); no vote held.");
|
||||
}
|
||||
else if (vote != null && vote.ServerOpenVote(pendingVoteSlot))
|
||||
{
|
||||
interWaveStage.Value = InterWaveStage.Vote;
|
||||
yield return TickCountdown(voteTimeSeconds, () => vote.AllPlayersVoted);
|
||||
vote.ServerResolve();
|
||||
}
|
||||
|
||||
pendingVoteSlot = -1;
|
||||
}
|
||||
|
||||
// ----- 3. Build -----
|
||||
interWaveStage.Value = InterWaveStage.Build;
|
||||
yield return TickCountdown(def.PrepTime);
|
||||
|
||||
interWaveStage.Value = InterWaveStage.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks <see cref="prepCountdown"/> down from <paramref name="seconds"/> to zero,
|
||||
/// exiting early once <paramref name="finishedEarly"/> returns true.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Network sync is throttled to ~10 Hz. <c>NetworkVariable</c> replicates on every
|
||||
/// mutation, so ticking at frame rate would push ~600 deltas across a 10-second countdown
|
||||
/// purely to animate text that changes once a second. A tenth of a second still falls
|
||||
/// smoothly while keeping the traffic negligible.
|
||||
/// </remarks>
|
||||
private IEnumerator TickCountdown(float seconds, System.Func<bool> finishedEarly = null)
|
||||
{
|
||||
const float NetworkSyncInterval = 0.1f;
|
||||
|
||||
prepCountdown.Value = seconds;
|
||||
float remaining = seconds;
|
||||
float nextSync = seconds - NetworkSyncInterval;
|
||||
|
||||
while (remaining > 0f)
|
||||
{
|
||||
if (finishedEarly != null && finishedEarly()) break;
|
||||
|
||||
yield return null;
|
||||
remaining = Mathf.Max(0f, remaining - Time.deltaTime);
|
||||
|
||||
if (remaining <= nextSync || remaining <= 0f)
|
||||
{
|
||||
prepCountdown.Value = remaining;
|
||||
nextSync = remaining - NetworkSyncInterval;
|
||||
}
|
||||
}
|
||||
|
||||
prepCountdown.Value = 0f;
|
||||
}
|
||||
|
||||
// ----- Wave ability resolution -------------------------------------
|
||||
|
||||
// Abilities carried by every enemy of the encounter currently spawning. Rebuilt once per
|
||||
// encounter (not per enemy) since a wave's buff set is fixed for its whole duration.
|
||||
private readonly System.Collections.Generic.List<EnemyAbilityDefinition> currentWaveAbilities
|
||||
= new System.Collections.Generic.List<EnemyAbilityDefinition>();
|
||||
|
||||
private readonly System.Collections.Generic.List<int> upgradeIdScratch
|
||||
= new System.Collections.Generic.List<int>();
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: resolve the current wave slot's voted upgrades into the ability list every
|
||||
/// enemy of this encounter will carry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called once at the top of an encounter's spawn phase. Cards that resolve to nothing
|
||||
/// (missing pool entry, non-ability card) are skipped silently here — the vote already
|
||||
/// validated them, and a warning per enemy would flood the log.
|
||||
/// </remarks>
|
||||
private void ResolveCurrentWaveAbilities()
|
||||
{
|
||||
currentWaveAbilities.Clear();
|
||||
|
||||
var run = RunState.Instance;
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
if (run == null || pool == null) return;
|
||||
|
||||
run.GetUpgradesForSlot(run.CurrentUpgradeSlot, upgradeIdScratch);
|
||||
|
||||
foreach (int id in upgradeIdScratch)
|
||||
{
|
||||
if (pool.Get(id) is AbilityEnemyUpgradeOption card && card.Ability != null)
|
||||
currentWaveAbilities.Add(card.Ability);
|
||||
}
|
||||
|
||||
if (currentWaveAbilities.Count > 0)
|
||||
Debug.Log($"[WaveManager] {run.ProgressLabel} enemies carry " +
|
||||
$"{currentWaveAbilities.Count} wave buff(s).");
|
||||
}
|
||||
|
||||
// ----- Spawn helpers ----------------------------------------------
|
||||
|
||||
private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false)
|
||||
|
|
@ -430,7 +693,9 @@ namespace TD.Gameplay
|
|||
// PlayerZoneVolume (so OwnerGrid[spawnerTile] = None).
|
||||
var spawner = zone.Spawners[0];
|
||||
(float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea);
|
||||
SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held, spawner.Facing);
|
||||
SpawnEnemy(def, spawner.TilePosition, zone.Owner, currentWaveAbilities,
|
||||
xHalfExtent: xHalf, zHalfExtent: zHalf, held: held,
|
||||
facing: spawner.Facing);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -455,10 +720,18 @@ namespace TD.Gameplay
|
|||
return (xHalf, zHalf);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: build and spawn one enemy.
|
||||
/// </summary>
|
||||
/// <param name="abilities">Wave buffs this enemy carries. Null/empty for a plain enemy.</param>
|
||||
/// <param name="contextOverride">Pre-computed stats to spawn with, bypassing both the
|
||||
/// definition's values and <paramref name="abilities"/>' spawn modifications. Used by
|
||||
/// split-on-death, whose minions are derived from the parent rather than the asset.</param>
|
||||
private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot,
|
||||
System.Collections.Generic.IReadOnlyList<EnemyAbilityDefinition> abilities,
|
||||
EnemySpawnContext? contextOverride = null,
|
||||
float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false,
|
||||
Direction facing = Direction.South, bool canRollAbility = true,
|
||||
float hpMultiplier = 1f, float visualScale = 1f)
|
||||
Direction facing = Direction.South)
|
||||
{
|
||||
if (def.EnemyPrefab == null)
|
||||
{
|
||||
|
|
@ -466,6 +739,16 @@ namespace TD.Gameplay
|
|||
return;
|
||||
}
|
||||
|
||||
// Resolve the stats this enemy will actually spawn with. Abilities get first say —
|
||||
// flight, health and size all have to be settled before the position is computed and
|
||||
// before EnemyHealth/EnemyMovement read them, since each is captured once.
|
||||
var context = contextOverride ?? EnemySpawnContext.FromDefinition(def);
|
||||
if (contextOverride == null && abilities != null)
|
||||
{
|
||||
for (int i = 0; i < abilities.Count; i++)
|
||||
abilities[i]?.ServerModifySpawn(ref context);
|
||||
}
|
||||
|
||||
Vector3 spawnPos = GridCoordinates.GridToWorld(spawnerTile);
|
||||
if (xHalfExtent > 0f)
|
||||
spawnPos.x += Random.Range(-xHalfExtent, xHalfExtent);
|
||||
|
|
@ -475,8 +758,8 @@ namespace TD.Gameplay
|
|||
// Flying enemies spawn elevated so they visually soar over towers. The
|
||||
// movement code preserves Y per-frame, so this height persists for the
|
||||
// enemy's whole flight. NetworkTransform replicates the raised position.
|
||||
if (def.IsFlying)
|
||||
spawnPos.y += def.FlightHeight;
|
||||
if (context.IsFlying)
|
||||
spawnPos.y += context.FlightHeight;
|
||||
|
||||
float yaw = facing switch
|
||||
{
|
||||
|
|
@ -492,11 +775,11 @@ namespace TD.Gameplay
|
|||
spawnPos,
|
||||
Quaternion.Euler(0f, yaw, 0f));
|
||||
|
||||
// Scales the whole prefab hierarchy uniformly (used for split-on-death minions).
|
||||
// Scales the whole prefab hierarchy uniformly (split minions, size-changing buffs).
|
||||
// Relies on the enemy prefab's NetworkTransform syncing scale to clients — verify
|
||||
// that's enabled if a scaled spawn doesn't look smaller on remote peers.
|
||||
if (visualScale != 1f)
|
||||
go.transform.localScale *= visualScale;
|
||||
if (!Mathf.Approximately(context.VisualScale, 1f))
|
||||
go.transform.localScale *= context.VisualScale;
|
||||
|
||||
var health = go.GetComponent<EnemyHealth>();
|
||||
var movement = go.GetComponent<EnemyMovement>();
|
||||
|
|
@ -509,16 +792,24 @@ namespace TD.Gameplay
|
|||
return;
|
||||
}
|
||||
|
||||
health.InitializeServer(def.MaxHp * hpMultiplier, def.LivesCost, def.IsFlying, held);
|
||||
movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot, def.IsFlying);
|
||||
// Boss-ness comes from the run's position (this is the phase's boss encounter), not
|
||||
// from the wave asset — the same WaveDefinition could legally sit in both a cycle pool
|
||||
// and a boss pool, and it's only a boss when drawn as one. Split minions inherit it
|
||||
// via the context override path, which never sets the flag.
|
||||
bool isBoss = contextOverride == null && (RunState.Instance?.IsBossStage ?? false);
|
||||
|
||||
// Optional — only prefabs with an EnemyAbility component roll an ability. See
|
||||
health.InitializeServer(context.MaxHp, context.LivesCost, context.IsFlying, held, isBoss);
|
||||
movement.InitializeServer(context.MoveSpeed, spawnerTile, ownerSlot, context.IsFlying);
|
||||
|
||||
// Optional — only prefabs with an EnemyAbility component can carry wave buffs. See
|
||||
// EnemyAbility's remarks for why on-death abilities aren't event-subscription driven.
|
||||
// canRollAbility is false for split-spawned minions (see ServerSpawnSplitEnemies) so
|
||||
// they can never chain into further splits or pick up any other ability.
|
||||
var ability = go.GetComponent<EnemyAbility>();
|
||||
if (ability != null)
|
||||
ability.InitializeServer(canRollAbility ? EnemyAbilityPool.Instance?.RollRandom() : null);
|
||||
ability.InitializeServer(abilities, context);
|
||||
else if (abilities != null && abilities.Count > 0)
|
||||
Debug.LogWarning($"[WaveManager] '{def.EnemyPrefab.name}' has no EnemyAbility " +
|
||||
$"component, so this wave's {abilities.Count} buff(s) will not " +
|
||||
$"apply to it. Add the component to the prefab.");
|
||||
|
||||
if (held) heldEnemies.Add(health);
|
||||
|
||||
|
|
@ -540,18 +831,26 @@ namespace TD.Gameplay
|
|||
/// <c>activeEnemyCount</c>/event-wiring bookkeeping as any other spawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split-spawned minions never roll an ability of their own — they're always plain,
|
||||
/// so a split can't chain into further splits (or any other ability) regardless of
|
||||
/// which EnemyDefinition/prefab is used for <paramref name="def"/>.
|
||||
/// <b>Minions inherit nothing.</b> They are spawned with an empty ability list, so a split
|
||||
/// can never chain into further splits or pick up any other buff the parent's wave carries.
|
||||
/// That is the whole termination argument for the recursion — one extra generation, always,
|
||||
/// however many cards the wave has collected.
|
||||
///
|
||||
/// <para>Their stats come entirely from <paramref name="context"/>, which the calling
|
||||
/// ability derives from the parent's own spawned values. Passing it as an override also
|
||||
/// bypasses spawn modification, which is correct: those modifiers already applied to the
|
||||
/// parent and are baked into what's being scaled here.</para>
|
||||
/// </remarks>
|
||||
public void ServerSpawnSplitEnemies(EnemyDefinition def, int count, Vector2Int atTile,
|
||||
PlayerSlot ownerSlot, float scatterRadius,
|
||||
float hpMultiplier = 1f, float visualScale = 1f)
|
||||
EnemySpawnContext context)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
for (int i = 0; i < count; i++)
|
||||
SpawnEnemy(def, atTile, ownerSlot, scatterRadius, scatterRadius, canRollAbility: false,
|
||||
hpMultiplier: hpMultiplier, visualScale: visualScale);
|
||||
SpawnEnemy(def, atTile, ownerSlot,
|
||||
abilities: null,
|
||||
contextOverride: context,
|
||||
xHalfExtent: scatterRadius, zHalfExtent: scatterRadius);
|
||||
}
|
||||
|
||||
// ----- Enemy event handlers (server-only) -------------------------
|
||||
|
|
@ -562,9 +861,16 @@ namespace TD.Gameplay
|
|||
// 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);
|
||||
var goldEntry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
|
||||
if (goldEntry != null) killReward = goldEntry.GoldPerEnemy;
|
||||
|
||||
// Wave buffs get to alter the bounty before anything else sees it — this is what
|
||||
// reward-suppressing cards hook. Applied before the builder's gold-per-kill bonus so
|
||||
// a card that zeroes the bounty doesn't also cancel a player's own upgrade.
|
||||
var enemyAbility = health.GetComponent<EnemyAbility>();
|
||||
if (enemyAbility != null)
|
||||
killReward = enemyAbility.ServerModifyKillReward(killReward);
|
||||
|
||||
// Award kill gold to the tower owner that landed the killing blow. The builder's
|
||||
// "gold per kill" effect (if granted) is queried live here rather than cached on
|
||||
// the killing tower — see BuilderUpgradeManager's "query, don't snapshot" note.
|
||||
|
|
@ -591,12 +897,11 @@ namespace TD.Gameplay
|
|||
if (totalReward > 0)
|
||||
ShowGoldRewardClientRpc(health.transform.position, totalReward);
|
||||
|
||||
// Resolve any on-death ability BEFORE unsubscribing/decrementing. A split-spawn's
|
||||
// Resolve on-death abilities BEFORE unsubscribing/decrementing. A split-spawn's
|
||||
// activeEnemyCount++ (inside ServerSpawnSplitEnemies -> SpawnEnemy) must land before
|
||||
// this kill's activeEnemyCount-- below, or a split on a wave's last enemy could let
|
||||
// CheckWaveComplete see activeEnemyCount hit 0 and advance the wave prematurely.
|
||||
var ability = health.GetComponent<EnemyAbility>();
|
||||
ability?.Definition?.ServerOnDeath(ability, health);
|
||||
enemyAbility?.ServerInvokeOnDeath(health);
|
||||
|
||||
UnsubscribeEnemy(health);
|
||||
DecrementAndCheckComplete();
|
||||
|
|
@ -624,6 +929,11 @@ namespace TD.Gameplay
|
|||
if (livesCost > 0)
|
||||
ShowLifeLossClientRpc(leakPos, livesCost);
|
||||
|
||||
// Wave buffs that punish leaks beyond the life cost hook here, while the enemy is
|
||||
// still alive enough to be queried. Runs before the defeat check so a leak that ends
|
||||
// the match still applies its full penalty.
|
||||
movement.GetComponent<EnemyAbility>()?.ServerInvokeReachedGoal(movement.OriginZone);
|
||||
|
||||
UnsubscribeEnemy(movement.GetComponent<EnemyHealth>());
|
||||
|
||||
remainingLives = Mathf.Max(0, remainingLives - livesCost);
|
||||
|
|
@ -656,6 +966,23 @@ namespace TD.Gameplay
|
|||
OnLifeLost?.Invoke(amount);
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
private void ShowGoldLossClientRpc(Vector3 worldPos, int amount)
|
||||
{
|
||||
FloatingTextSpawner.Instance?.SpawnGoldLoss(worldPos, amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run
|
||||
/// inside ScriptableObjects, which have no <c>NetworkBehaviour</c> of their own to send a
|
||||
/// ClientRpc from.
|
||||
/// </summary>
|
||||
public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount)
|
||||
{
|
||||
if (!IsServer || amount <= 0) return;
|
||||
ShowGoldLossClientRpc(worldPos, amount);
|
||||
}
|
||||
|
||||
// ----- Local-only notification events -----------------------------
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -704,8 +1031,15 @@ namespace TD.Gameplay
|
|||
// 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();
|
||||
// Capture which slot the upcoming vote will buff BEFORE advancing past it — the vote
|
||||
// is on "the wave you just beat", but by the time the inter-wave step runs the run has
|
||||
// already moved on. A boss clear is excluded: its phase's slots are about to be wiped,
|
||||
// so voting a buff onto them would be voting into a bin.
|
||||
var run = RunState.Instance;
|
||||
pendingVoteSlot = (run != null && !run.IsBossStage) ? run.CurrentUpgradeSlot : -1;
|
||||
|
||||
Debug.Log($"[WaveManager] {run?.ProgressLabel ?? "Encounter"} complete.");
|
||||
AdvanceRun();
|
||||
}
|
||||
|
||||
// Server-only. Iterates active players, awards CompletionBonus to each, plus
|
||||
|
|
@ -714,7 +1048,7 @@ namespace TD.Gameplay
|
|||
// Skipped silently if no goldConfig or no entry for this wave.
|
||||
private void AwardWaveCompletionBonuses()
|
||||
{
|
||||
var entry = goldConfig?.GetWaveEntry(currentWaveIndex + 1);
|
||||
var entry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
|
||||
if (entry == null) return;
|
||||
|
||||
int completionBonus = entry.CompletionBonus;
|
||||
|
|
|
|||
8
Assets/_Project/Scripts/Gameplay/Waves.meta
Normal file
8
Assets/_Project/Scripts/Gameplay/Waves.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f65ba25aee224c24dad3648d9e5e953e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
102
Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
Normal file
102
Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.Waves
|
||||
{
|
||||
/// <summary>
|
||||
/// One phase of a run: the pool its cycle waves are drawn from, and the pool its boss is
|
||||
/// drawn from. A phase runs the same drawn wave set for every one of its cycles, then ends
|
||||
/// in a boss.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Groups, not waves.</b> Both pools are lists of <see cref="WaveGroup"/> assets rather
|
||||
/// than flat wave lists, so re-balancing a run means dragging a group between phases instead
|
||||
/// of re-authoring individual entries. Listing the same group in two phases is legal — the
|
||||
/// draws are independent.
|
||||
///
|
||||
/// <para><b>Draw contract.</b> <c>RunState</c> draws <c>WavesPerCycle</c> waves from
|
||||
/// <see cref="WaveGroups"/> with <b>distinct enemy types</b>, because enemy upgrades are keyed
|
||||
/// to a wave slot and two slots sharing an enemy type would make "which wave did this buff
|
||||
/// apply to" ambiguous. That makes the real authoring requirement stricter than the entry
|
||||
/// count: a phase needs at least <c>WavesPerCycle</c> distinct enemy types across its groups,
|
||||
/// not just that many waves. <see cref="CountDistinctEnemyTypes"/> exists so the draw can
|
||||
/// fail loudly at match start rather than mid-run.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "PhaseDefinition", menuName = "TD/Run/Phase Definition", order = 11)]
|
||||
public class PhaseDefinition : ScriptableObject
|
||||
{
|
||||
[Tooltip("Designer-facing name for this phase, shown in validation messages. " +
|
||||
"Falls back to the asset name when empty.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Groups this phase's cycle waves are drawn from. All groups are pooled " +
|
||||
"together for the draw; group membership is an authoring convenience, not a " +
|
||||
"draw boundary.")]
|
||||
public WaveGroup[] WaveGroups;
|
||||
|
||||
[Tooltip("Groups this phase's boss is drawn from. A boss is an ordinary WaveDefinition " +
|
||||
"that happens to spawn a single powerful enemy.")]
|
||||
public WaveGroup[] BossGroups;
|
||||
|
||||
[Tooltip("Enemy-buff cards votable during this phase. Gating is per PHASE, not per cycle " +
|
||||
"— every card listed here can be voted on from this phase's very first wave. " +
|
||||
"Cards must also appear in the scene's EnemyUpgradePool to be offerable.")]
|
||||
public EnemyUpgrades.EnemyUpgradeGroup[] EnemyUpgradeGroups;
|
||||
|
||||
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
|
||||
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
|
||||
|
||||
/// <summary>
|
||||
/// Appends every non-null, non-zero-weight entry across <paramref name="groups"/> into
|
||||
/// <paramref name="into"/>. Zero-weight entries are filtered here (not at draw time) so
|
||||
/// "weight 0 means it can never appear" is enforced in exactly one place.
|
||||
/// </summary>
|
||||
public static void CollectCandidates(WaveGroup[] groups, List<WavePoolEntry> into)
|
||||
{
|
||||
into.Clear();
|
||||
if (groups == null) return;
|
||||
|
||||
for (int g = 0; g < groups.Length; g++)
|
||||
{
|
||||
var group = groups[g];
|
||||
if (group?.Waves == null) continue;
|
||||
|
||||
for (int i = 0; i < group.Waves.Length; i++)
|
||||
{
|
||||
var entry = group.Waves[i];
|
||||
if (entry?.Wave == null) continue;
|
||||
if (entry.Weight <= 0f) continue;
|
||||
into.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Collects this phase's cycle-wave candidates.</summary>
|
||||
public void CollectWaveCandidates(List<WavePoolEntry> into)
|
||||
=> CollectCandidates(WaveGroups, into);
|
||||
|
||||
/// <summary>Collects this phase's boss candidates.</summary>
|
||||
public void CollectBossCandidates(List<WavePoolEntry> into)
|
||||
=> CollectCandidates(BossGroups, into);
|
||||
|
||||
/// <summary>
|
||||
/// How many distinct enemy types are reachable across this phase's wave groups. The
|
||||
/// upper bound on how many wave slots this phase can fill, since the draw requires
|
||||
/// distinct types.
|
||||
/// </summary>
|
||||
public int CountDistinctEnemyTypes()
|
||||
{
|
||||
var candidates = new List<WavePoolEntry>();
|
||||
CollectWaveCandidates(candidates);
|
||||
|
||||
var seen = new HashSet<EnemyDefinition>();
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
var type = entry.Wave.PrimaryEnemyType;
|
||||
if (type != null) seen.Add(type);
|
||||
}
|
||||
return seen.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8f73fcf0261654d44a6d0939bdc9b88f
|
||||
209
Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
Normal file
209
Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.Waves
|
||||
{
|
||||
/// <summary>
|
||||
/// The whole run: how many waves make a cycle, how many cycles make a phase, and the
|
||||
/// per-phase pools everything is drawn from. One asset assigned to <c>RunState</c> in the
|
||||
/// match scene; replaces <c>WaveManager</c>'s old flat, hand-ordered wave array.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Stable wave ids.</b> Waves are replicated by index into a flat list this asset builds
|
||||
/// by walking its phases, groups, and entries in declaration order (see
|
||||
/// <see cref="RebuildIndex"/>). Because every peer loads the identical asset, the same walk
|
||||
/// produces the same ids everywhere — the same trick the tower catalog and
|
||||
/// <c>DraftPool</c> already use, and the reason the server can say "wave slot 2 is wave #7"
|
||||
/// in a single int.
|
||||
///
|
||||
/// <para><b>The index is frozen for the match.</b> <c>RunState</c> builds it once on spawn
|
||||
/// and never rebuilds, so editing this asset mid-play-session cannot renumber ids out from
|
||||
/// under in-flight replication. Edit-time changes invalidate the cache normally.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "RunDefinition", menuName = "TD/Run/Run Definition", order = 12)]
|
||||
public class RunDefinition : ScriptableObject
|
||||
{
|
||||
[Header("Structure")]
|
||||
[Tooltip("Waves in one cycle. The phase draws this many distinct-enemy-type waves and " +
|
||||
"replays them every cycle.")]
|
||||
[Min(1)]
|
||||
public int WavesPerCycle = 5;
|
||||
|
||||
[Tooltip("Cycles before the phase's boss appears. The same drawn waves repeat each " +
|
||||
"cycle, carrying whatever upgrades players voted onto them.")]
|
||||
[Min(1)]
|
||||
public int CyclesPerPhase = 3;
|
||||
|
||||
[Tooltip("Phases in order. Beating the last phase's boss wins the run. The MVP ships " +
|
||||
"one phase; the full design is three.")]
|
||||
public PhaseDefinition[] Phases;
|
||||
|
||||
// ----- Flat wave index (network identity) --------------------------
|
||||
|
||||
// Built lazily and cached. Null means "not built yet". Rebuilt only on explicit request
|
||||
// or on edit-time validation — never spontaneously during play, since ids in flight
|
||||
// would be invalidated.
|
||||
private List<WaveDefinition> waveIndex;
|
||||
private Dictionary<WaveDefinition, int> waveIds;
|
||||
|
||||
/// <summary>Number of distinct waves reachable anywhere in this run.</summary>
|
||||
public int WaveCount
|
||||
{
|
||||
get { EnsureIndex(); return waveIndex.Count; }
|
||||
}
|
||||
|
||||
/// <summary>Resolves a replicated wave id back to its asset, or null if out of range.</summary>
|
||||
public WaveDefinition GetWave(int waveId)
|
||||
{
|
||||
EnsureIndex();
|
||||
return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null;
|
||||
}
|
||||
|
||||
/// <summary>Resolves a wave asset to its replicated id.</summary>
|
||||
public bool TryGetWaveId(WaveDefinition wave, out int waveId)
|
||||
{
|
||||
EnsureIndex();
|
||||
if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true;
|
||||
waveId = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the flat wave index from the current asset contents. Called once by
|
||||
/// <c>RunState</c> on every peer at match start so ids agree, and by the editor when
|
||||
/// the asset changes.
|
||||
/// </summary>
|
||||
public void RebuildIndex()
|
||||
{
|
||||
waveIndex ??= new List<WaveDefinition>();
|
||||
waveIds ??= new Dictionary<WaveDefinition, int>();
|
||||
waveIndex.Clear();
|
||||
waveIds.Clear();
|
||||
|
||||
if (Phases == null) return;
|
||||
|
||||
// Declaration-order walk. Every peer runs this over the same asset, so the
|
||||
// resulting ids match without any negotiation. Zero-weight entries are indexed
|
||||
// too: they can't be drawn, but indexing them keeps ids stable if a designer
|
||||
// re-weights between sessions.
|
||||
foreach (var phase in Phases)
|
||||
{
|
||||
if (phase == null) continue;
|
||||
IndexGroups(phase.WaveGroups);
|
||||
IndexGroups(phase.BossGroups);
|
||||
}
|
||||
}
|
||||
|
||||
private void IndexGroups(WaveGroup[] groups)
|
||||
{
|
||||
if (groups == null) return;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group?.Waves == null) continue;
|
||||
foreach (var entry in group.Waves)
|
||||
{
|
||||
if (entry?.Wave == null) continue;
|
||||
if (waveIds.ContainsKey(entry.Wave)) continue; // first occurrence wins
|
||||
waveIds[entry.Wave] = waveIndex.Count;
|
||||
waveIndex.Add(entry.Wave);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureIndex()
|
||||
{
|
||||
if (waveIndex == null || waveIds == null) RebuildIndex();
|
||||
}
|
||||
|
||||
// ----- Validation --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Checks that this run can actually be played: at least one phase, every phase able to
|
||||
/// fill <see cref="WavesPerCycle"/> slots with distinct enemy types, and every phase able
|
||||
/// to produce a boss. Returns true when the run is playable;
|
||||
/// <paramref name="report"/> carries the problems either way (it may hold warnings even
|
||||
/// on success).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The distinct-enemy-type requirement is the one that bites in practice: a phase can
|
||||
/// hold twenty waves and still be unplayable if they all point at the same three enemies.
|
||||
/// Checking it up front turns that into a startup error instead of a draw that silently
|
||||
/// runs short a wave.
|
||||
/// </remarks>
|
||||
public bool Validate(out string report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
bool ok = true;
|
||||
|
||||
if (Phases == null || Phases.Length == 0)
|
||||
{
|
||||
sb.AppendLine("• No phases assigned — the run has nothing to play.");
|
||||
report = sb.ToString();
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidates = new List<WavePoolEntry>();
|
||||
|
||||
for (int i = 0; i < Phases.Length; i++)
|
||||
{
|
||||
var phase = Phases[i];
|
||||
string label = phase != null ? phase.Label : $"Phase {i + 1}";
|
||||
|
||||
if (phase == null)
|
||||
{
|
||||
sb.AppendLine($"• {label}: slot is empty.");
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
int distinctTypes = phase.CountDistinctEnemyTypes();
|
||||
if (distinctTypes < WavesPerCycle)
|
||||
{
|
||||
sb.AppendLine($"• {label}: needs {WavesPerCycle} distinct enemy types to fill " +
|
||||
$"a cycle but only {distinctTypes} are reachable. Add waves " +
|
||||
$"using other enemies, or lower Waves Per Cycle.");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
phase.CollectBossCandidates(candidates);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
sb.AppendLine($"• {label}: no boss candidates (every boss group is empty, " +
|
||||
$"unassigned, or entirely zero-weight).");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
// Warnings — these don't block a run, they just play oddly.
|
||||
phase.CollectWaveCandidates(candidates);
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (entry.Wave.PrimaryEnemyType == null)
|
||||
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' has no usable " +
|
||||
$"enemy entries and can never be drawn meaningfully.");
|
||||
else if (!entry.Wave.HasSingleEnemyType)
|
||||
sb.AppendLine($"• (warning) {label}: '{entry.Wave.name}' mixes enemy " +
|
||||
$"types. Upgrades and HUD labels will use " +
|
||||
$"'{entry.Wave.PrimaryEnemyType.name}' only.");
|
||||
}
|
||||
}
|
||||
|
||||
report = sb.Length > 0 ? sb.ToString() : "Run structure OK.";
|
||||
return ok;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate()
|
||||
{
|
||||
// Only invalidate outside play mode. Clearing the cache mid-match would renumber
|
||||
// wave ids while replicated slot references are still pointing at the old numbering.
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
waveIndex = null;
|
||||
waveIds = null;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2b73bb9876b7fdb4480b13ff6bb8bd67
|
||||
468
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
Normal file
468
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs
|
||||
using System.Collections.Generic;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.Gameplay.Waves
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of advancing the run by one encounter. Returned by
|
||||
/// <see cref="RunState.ServerAdvance"/> so <c>WaveManager</c> can react without
|
||||
/// re-deriving the progression maths.
|
||||
/// </summary>
|
||||
public enum RunAdvance : byte
|
||||
{
|
||||
/// <summary>Moved to the next wave inside the current cycle.</summary>
|
||||
NextWave = 0,
|
||||
/// <summary>Moved to the first wave of a new cycle — the same waves come round again,
|
||||
/// now carrying whatever upgrades players voted onto them.</summary>
|
||||
NextCycle = 1,
|
||||
/// <summary>Every cycle in the phase is done; the boss is up next.</summary>
|
||||
BossReady = 2,
|
||||
/// <summary>The boss died and a new phase was drawn. Upgrades were wiped.</summary>
|
||||
NextPhase = 3,
|
||||
/// <summary>The final phase's boss died — the run is won.</summary>
|
||||
RunComplete = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative owner of run progression: which phase and cycle we're in, which waves
|
||||
/// this phase drew, and which upgrades the players have voted onto each wave slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Split of duties with <c>WaveManager</c>.</b> This type is the run's <i>state</i> —
|
||||
/// counters, draws, upgrade sets — and knows nothing about spawning. <c>WaveManager</c> is the
|
||||
/// <i>driver</i>: it runs an encounter, and when the field is clear it calls
|
||||
/// <see cref="ServerAdvance"/> and acts on the returned <see cref="RunAdvance"/>. Keeping them
|
||||
/// apart is what stops WaveManager (already a large class) from also owning pool draws and
|
||||
/// upgrade bookkeeping.
|
||||
///
|
||||
/// <para><b>Linear encounter index.</b> Rather than replicate cycle and wave separately and
|
||||
/// keep them in sync, one <see cref="encounterInPhase"/> counter runs
|
||||
/// <c>0 .. CyclesPerPhase*WavesPerCycle</c> inclusive, with the final value meaning "boss".
|
||||
/// Cycle and wave slot are derived from it, so they cannot disagree.</para>
|
||||
///
|
||||
/// <para><b>Upgrades are keyed by wave slot, not enemy type.</b> The phase draw guarantees the
|
||||
/// slots hold distinct enemy types, which makes slot and type interchangeable as a key — and
|
||||
/// slot is the one that survives a phase change cleanly, since the next phase reassigns every
|
||||
/// slot to a fresh enemy anyway.</para>
|
||||
/// </remarks>
|
||||
public class RunState : NetworkBehaviour
|
||||
{
|
||||
// ----- Singleton --------------------------------------------------
|
||||
|
||||
public static RunState Instance { get; private set; }
|
||||
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
[Tooltip("The run this match plays: phase pools, waves per cycle, cycles per phase. " +
|
||||
"Required — without it no waves can be drawn.")]
|
||||
[SerializeField] private RunDefinition runDefinition;
|
||||
|
||||
// ----- Networked state --------------------------------------------
|
||||
|
||||
private readonly NetworkVariable<int> phaseIndex = new NetworkVariable<int>(
|
||||
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss".
|
||||
private readonly NetworkVariable<int> encounterInPhase = new NetworkVariable<int>(
|
||||
0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// Wave ids (indices into RunDefinition's flat index) drawn for this phase's slots.
|
||||
// Exactly WavesPerCycle entries once a phase has been drawn.
|
||||
private NetworkList<int> drawnWaveIds;
|
||||
|
||||
private readonly NetworkVariable<int> bossWaveId = new NetworkVariable<int>(
|
||||
-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// Upgrades voted onto wave slots, packed as (slot << SlotShift) | optionId. A flat
|
||||
// append-only list rather than a per-slot structure because NGO has no nested-collection
|
||||
// support and the counts here are tiny (a handful per slot, per phase).
|
||||
private NetworkList<int> waveUpgrades;
|
||||
|
||||
private const int SlotShift = 16;
|
||||
private const int OptionMask = (1 << SlotShift) - 1;
|
||||
|
||||
// Per-slot count of buffs to apply automatically, skipping the next vote for that slot.
|
||||
// Indexed by upgrade slot (0..WavesPerCycle, the last being the boss). Zero means a normal
|
||||
// vote. Written by the vote-meta cards, consumed by the inter-wave step.
|
||||
private NetworkList<int> autoApplyCounts;
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer whenever run progression or the drawn/upgraded wave set changes.
|
||||
/// The HUD subscribes to relabel without polling.
|
||||
/// </summary>
|
||||
public event System.Action OnRunChanged;
|
||||
|
||||
// ----- Lifecycle ---------------------------------------------------
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
drawnWaveIds = new NetworkList<int>();
|
||||
waveUpgrades = new NetworkList<int>();
|
||||
autoApplyCounts = new NetworkList<int>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[RunState] Duplicate RunState detected. Only one may exist per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
// Freeze the wave index on EVERY peer (not just the server) before any id crosses
|
||||
// the wire. Same asset, same declaration-order walk, same numbering.
|
||||
runDefinition?.RebuildIndex();
|
||||
|
||||
// One auto-apply counter per upgrade slot, plus one for the boss slot.
|
||||
if (IsServer)
|
||||
{
|
||||
for (int i = 0; i <= WavesPerCycle; i++) autoApplyCounts.Add(0);
|
||||
}
|
||||
|
||||
phaseIndex.OnValueChanged += HandleIntChanged;
|
||||
encounterInPhase.OnValueChanged += HandleIntChanged;
|
||||
bossWaveId.OnValueChanged += HandleIntChanged;
|
||||
drawnWaveIds.OnListChanged += HandleListChanged;
|
||||
waveUpgrades.OnListChanged += HandleListChanged;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
phaseIndex.OnValueChanged -= HandleIntChanged;
|
||||
encounterInPhase.OnValueChanged -= HandleIntChanged;
|
||||
bossWaveId.OnValueChanged -= HandleIntChanged;
|
||||
drawnWaveIds.OnListChanged -= HandleListChanged;
|
||||
waveUpgrades.OnListChanged -= HandleListChanged;
|
||||
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void HandleIntChanged(int _, int __) => OnRunChanged?.Invoke();
|
||||
private void HandleListChanged(NetworkListEvent<int> _) => OnRunChanged?.Invoke();
|
||||
|
||||
// ----- Structure accessors -----------------------------------------
|
||||
|
||||
/// <summary>The run asset driving this match, or null if unassigned (designer error).</summary>
|
||||
public RunDefinition Definition => runDefinition;
|
||||
|
||||
public int WavesPerCycle => runDefinition != null ? Mathf.Max(1, runDefinition.WavesPerCycle) : 5;
|
||||
public int CyclesPerPhase => runDefinition != null ? Mathf.Max(1, runDefinition.CyclesPerPhase) : 3;
|
||||
public int PhaseCount => runDefinition?.Phases?.Length ?? 0;
|
||||
|
||||
/// <summary>Encounters in one phase, counting its boss.</summary>
|
||||
public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1;
|
||||
|
||||
// ----- Progression accessors ---------------------------------------
|
||||
|
||||
/// <summary>Zero-based phase index.</summary>
|
||||
public int PhaseIndex => phaseIndex.Value;
|
||||
|
||||
/// <summary>
|
||||
/// The phase currently being played, or null if the run isn't set up. Read by the vote
|
||||
/// generator to find which enemy-buff cards this phase allows.
|
||||
/// </summary>
|
||||
public PhaseDefinition CurrentPhase
|
||||
{
|
||||
get
|
||||
{
|
||||
var phases = runDefinition?.Phases;
|
||||
if (phases == null) return null;
|
||||
int i = phaseIndex.Value;
|
||||
return (i >= 0 && i < phases.Length) ? phases[i] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zero-based cycle index within the current phase. Reads as the last cycle
|
||||
/// while the boss is up (the boss belongs to the phase, not to a fourth cycle).</summary>
|
||||
public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1);
|
||||
|
||||
/// <summary>Zero-based wave slot within the current cycle. Meaningless while
|
||||
/// <see cref="IsBossStage"/> is true.</summary>
|
||||
public int WaveSlot => encounterInPhase.Value % WavesPerCycle;
|
||||
|
||||
/// <summary>True when the next/current encounter is this phase's boss.</summary>
|
||||
public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle;
|
||||
|
||||
/// <summary>
|
||||
/// 1-based count of encounters since the run began, including bosses. This is the key
|
||||
/// <c>GoldConfig</c> is indexed by, so its per-wave entries scale monotonically across the
|
||||
/// whole run rather than resetting every cycle.
|
||||
/// </summary>
|
||||
public int GlobalEncounterNumber
|
||||
=> phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1;
|
||||
|
||||
/// <summary>Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5".</summary>
|
||||
public string ProgressLabel
|
||||
=> IsBossStage
|
||||
? $"Phase {phaseIndex.Value + 1} · BOSS"
|
||||
: $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " +
|
||||
$"Wave {WaveSlot + 1}/{WavesPerCycle}";
|
||||
|
||||
// ----- Wave accessors ----------------------------------------------
|
||||
|
||||
/// <summary>The wave asset drawn for <paramref name="slot"/>, or null if not drawn yet.</summary>
|
||||
public WaveDefinition GetSlotWave(int slot)
|
||||
{
|
||||
if (runDefinition == null) return null;
|
||||
if (slot < 0 || slot >= drawnWaveIds.Count) return null;
|
||||
return runDefinition.GetWave(drawnWaveIds[slot]);
|
||||
}
|
||||
|
||||
/// <summary>This phase's boss wave, or null if not drawn yet.</summary>
|
||||
public WaveDefinition BossWave
|
||||
=> runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null;
|
||||
|
||||
/// <summary>The wave the run is currently pointing at — boss or cycle wave.</summary>
|
||||
public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot);
|
||||
|
||||
/// <summary>
|
||||
/// The slot key upgrades attach to for the current encounter. The boss occupies the slot
|
||||
/// one past the last cycle wave, so boss upgrades (if ever voted) don't collide with it.
|
||||
/// </summary>
|
||||
public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot;
|
||||
|
||||
// ----- Upgrade accessors -------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Collects the upgrade option ids voted onto <paramref name="slot"/>, in the order they
|
||||
/// were applied. Safe on any peer.
|
||||
/// </summary>
|
||||
public void GetUpgradesForSlot(int slot, List<int> into)
|
||||
{
|
||||
into.Clear();
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
{
|
||||
int packed = waveUpgrades[i];
|
||||
if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many upgrades are stacked on <paramref name="slot"/>.</summary>
|
||||
public int CountUpgradesForSlot(int slot)
|
||||
{
|
||||
int n = 0;
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
if ((waveUpgrades[i] >> SlotShift) == slot) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="slot"/> already carries <paramref name="optionId"/>.
|
||||
/// Used to keep the vote from offering a buff a wave already has.</summary>
|
||||
public bool SlotHasUpgrade(int slot, int optionId)
|
||||
{
|
||||
int packed = (slot << SlotShift) | (optionId & OptionMask);
|
||||
for (int i = 0; i < waveUpgrades.Count; i++)
|
||||
if (waveUpgrades[i] == packed) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates.</summary>
|
||||
public void ServerAddUpgrade(int slot, int optionId)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || optionId < 0) return;
|
||||
if (SlotHasUpgrade(slot, optionId)) return;
|
||||
|
||||
waveUpgrades.Add((slot << SlotShift) | (optionId & OptionMask));
|
||||
}
|
||||
|
||||
// ----- Auto-apply (vote-meta cards) --------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// How many buffs <paramref name="slot"/> will take automatically instead of holding its
|
||||
/// next vote. Zero means a normal vote.
|
||||
/// </summary>
|
||||
public int GetAutoApplyCount(int slot)
|
||||
=> (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: arm <paramref name="slot"/> to auto-take <paramref name="count"/> buffs in
|
||||
/// place of its next vote. Takes the larger value if already armed, so two vote-meta cards
|
||||
/// on one slot don't cancel each other down.
|
||||
/// </summary>
|
||||
public void ServerSetAutoApply(int slot, int count)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || slot >= autoApplyCounts.Count || count <= 0) return;
|
||||
autoApplyCounts[slot] = Mathf.Max(autoApplyCounts[slot], count);
|
||||
}
|
||||
|
||||
/// <summary>Server-only: disarm <paramref name="slot"/> after its auto-apply has fired.</summary>
|
||||
public void ServerConsumeAutoApply(int slot)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (slot < 0 || slot >= autoApplyCounts.Count) return;
|
||||
autoApplyCounts[slot] = 0;
|
||||
}
|
||||
|
||||
private void ServerClearAutoApply()
|
||||
{
|
||||
for (int i = 0; i < autoApplyCounts.Count; i++) autoApplyCounts[i] = 0;
|
||||
}
|
||||
|
||||
// ----- Server: run control ------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: start the run at phase 0 and draw its waves. Returns false if the run
|
||||
/// definition is missing or unplayable — the caller should treat that as fatal rather
|
||||
/// than limping along with an empty schedule.
|
||||
/// </summary>
|
||||
public bool ServerBeginRun()
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
|
||||
if (runDefinition == null)
|
||||
{
|
||||
Debug.LogError("[RunState] No RunDefinition assigned — cannot start a run.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!runDefinition.Validate(out string report))
|
||||
{
|
||||
Debug.LogError($"[RunState] RunDefinition '{runDefinition.name}' is not playable:\n{report}");
|
||||
return false;
|
||||
}
|
||||
|
||||
phaseIndex.Value = 0;
|
||||
encounterInPhase.Value = 0;
|
||||
waveUpgrades.Clear();
|
||||
ServerClearAutoApply();
|
||||
|
||||
return ServerDrawPhase(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the
|
||||
/// boss falls. See <see cref="RunAdvance"/> for what the caller should do with each result.
|
||||
/// </summary>
|
||||
public RunAdvance ServerAdvance()
|
||||
{
|
||||
if (!IsServer) return RunAdvance.NextWave;
|
||||
|
||||
// Boss just died: the phase is over. Either draw the next one or win the run.
|
||||
if (IsBossStage)
|
||||
{
|
||||
int next = phaseIndex.Value + 1;
|
||||
if (next >= PhaseCount) return RunAdvance.RunComplete;
|
||||
|
||||
phaseIndex.Value = next;
|
||||
encounterInPhase.Value = 0;
|
||||
|
||||
// A new phase means five brand-new waves, so every upgrade voted onto the old
|
||||
// slots is meaningless — wiped by design, not by omission. Armed auto-applies go
|
||||
// with them: they were promises made about waves that no longer exist.
|
||||
waveUpgrades.Clear();
|
||||
ServerClearAutoApply();
|
||||
ServerDrawPhase(next);
|
||||
return RunAdvance.NextPhase;
|
||||
}
|
||||
|
||||
int previousCycle = CycleIndex;
|
||||
encounterInPhase.Value++;
|
||||
|
||||
if (IsBossStage) return RunAdvance.BossReady;
|
||||
if (CycleIndex != previousCycle) return RunAdvance.NextCycle;
|
||||
return RunAdvance.NextWave;
|
||||
}
|
||||
|
||||
// ----- Server: phase draw -------------------------------------------
|
||||
|
||||
// Scratch list reused across draws; server is single-threaded so sharing is safe.
|
||||
private readonly List<WavePoolEntry> candidateScratch = new List<WavePoolEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative
|
||||
/// weight <b>without replacement and with distinct enemy types</b>; the boss is a plain
|
||||
/// weighted draw.
|
||||
/// </summary>
|
||||
private bool ServerDrawPhase(int index)
|
||||
{
|
||||
if (!IsServer) return false;
|
||||
|
||||
var phases = runDefinition?.Phases;
|
||||
if (phases == null || index < 0 || index >= phases.Length || phases[index] == null)
|
||||
{
|
||||
Debug.LogError($"[RunState] Phase {index + 1} is missing — cannot draw waves.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var phase = phases[index];
|
||||
int needed = WavesPerCycle;
|
||||
|
||||
phase.CollectWaveCandidates(candidateScratch);
|
||||
drawnWaveIds.Clear();
|
||||
|
||||
for (int drawn = 0; drawn < needed; drawn++)
|
||||
{
|
||||
var pick = DrawWeighted(candidateScratch);
|
||||
if (pick == null)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[RunState] {phase.Label}: ran out of candidates after {drawn} of " +
|
||||
$"{needed} wave(s). The phase needs {needed} waves with DISTINCT enemy " +
|
||||
$"types — check the RunDefinition inspector's phase-capacity readout.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!runDefinition.TryGetWaveId(pick.Wave, out int waveId))
|
||||
{
|
||||
Debug.LogError($"[RunState] Wave '{pick.Wave.name}' is not in the run's wave " +
|
||||
$"index. This should be impossible — the index is built from " +
|
||||
$"the same pools. Skipping.");
|
||||
candidateScratch.Remove(pick);
|
||||
drawn--;
|
||||
continue;
|
||||
}
|
||||
|
||||
drawnWaveIds.Add(waveId);
|
||||
|
||||
// Retire every remaining candidate sharing this enemy type, so distinctness is
|
||||
// enforced by shrinking the pool rather than by reject-and-retry.
|
||||
var type = pick.Wave.PrimaryEnemyType;
|
||||
candidateScratch.RemoveAll(
|
||||
e => e.Wave == pick.Wave || (type != null && e.Wave.PrimaryEnemyType == type));
|
||||
}
|
||||
|
||||
// Boss draw — independent pool, no distinctness constraint.
|
||||
phase.CollectBossCandidates(candidateScratch);
|
||||
var boss = DrawWeighted(candidateScratch);
|
||||
if (boss == null || !runDefinition.TryGetWaveId(boss.Wave, out int bossId))
|
||||
{
|
||||
Debug.LogError($"[RunState] {phase.Label}: no boss could be drawn.");
|
||||
bossWaveId.Value = -1;
|
||||
return false;
|
||||
}
|
||||
bossWaveId.Value = bossId;
|
||||
|
||||
Debug.Log($"[RunState] Drew {phase.Label}: {needed} wave(s) + boss " +
|
||||
$"'{boss.Wave.name}'.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Relative-weight draw. Weights are normalized against the candidate set's total, so a
|
||||
// pool of three 1.0 entries is uniform and a 0.5 entry is half as likely as a 1.0 one.
|
||||
// Zero-weight entries never reach here — PhaseDefinition filters them during collection.
|
||||
private static WavePoolEntry DrawWeighted(List<WavePoolEntry> candidates)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return null;
|
||||
|
||||
float total = 0f;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
total += Mathf.Max(0f, candidates[i].Weight);
|
||||
|
||||
if (total <= 0f) return null;
|
||||
|
||||
float roll = Random.value * total;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
roll -= Mathf.Max(0f, candidates[i].Weight);
|
||||
if (roll <= 0f) return candidates[i];
|
||||
}
|
||||
|
||||
return candidates[candidates.Count - 1]; // float drift fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a293cf6dca91dd2478c9f4e799cf9e0d
|
||||
69
Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
Normal file
69
Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.Waves
|
||||
{
|
||||
/// <summary>
|
||||
/// One weighted candidate inside a <see cref="WaveGroup"/>: a wave that may be drawn,
|
||||
/// and how likely it is to be drawn relative to the group's other entries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Weight is RELATIVE, not an absolute probability.</b> The draw normalizes every
|
||||
/// candidate's weight against the summed weight of the whole candidate set, so three
|
||||
/// entries at 1.0 each are equally likely (33% apiece) and an entry at 0.5 is half as
|
||||
/// likely to be drawn as one at 1.0. This is what makes the default sane: new entries
|
||||
/// start at 1.0, so adding a wave to a group never silently re-weights the others.
|
||||
///
|
||||
/// <para>A class rather than a struct specifically so <see cref="Weight"/> can carry a
|
||||
/// field initializer — Unity runs it when the inspector creates a fresh array element,
|
||||
/// which is what gives new entries their equal-by-default weighting. Mirrors
|
||||
/// <see cref="WaveGoldEntry"/>.</para>
|
||||
/// </remarks>
|
||||
[Serializable]
|
||||
public class WavePoolEntry
|
||||
{
|
||||
[Tooltip("The wave that may be drawn from this group.")]
|
||||
public WaveDefinition Wave;
|
||||
|
||||
[Tooltip("Relative draw weight within this group's pool. Entries at equal weight are " +
|
||||
"equally likely; an entry at 0.5 is half as likely as one at 1.0. NOT an " +
|
||||
"absolute percentage.")]
|
||||
[PoolWeight("wave")]
|
||||
public float Weight = 1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named, reusable bag of candidate waves. Groups are the unit designers move between
|
||||
/// phases: dragging a group asset from Phase 1's pool to Phase 2's moves every wave in it
|
||||
/// (and their weights) in one action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why weights live on the entry, not on <see cref="WaveDefinition"/>.</b> Weight is a
|
||||
/// property of "how common is this wave <i>in this pool</i>", not of the wave itself. Keeping
|
||||
/// it here means the same <see cref="WaveDefinition"/> asset can be a staple of one group and
|
||||
/// a rarity in another, and a group carries its tuning with it when moved between phases.
|
||||
///
|
||||
/// <para><b>Boss groups use the same type.</b> A boss wave is just a
|
||||
/// <see cref="WaveDefinition"/> whose entries spawn a single powerful enemy, so boss pools
|
||||
/// are ordinary <see cref="WaveGroup"/>s referenced from
|
||||
/// <see cref="PhaseDefinition.BossGroups"/>.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "WaveGroup", menuName = "TD/Run/Wave Group", order = 10)]
|
||||
public class WaveGroup : ScriptableObject
|
||||
{
|
||||
[Tooltip("Designer-facing name for this group, shown in run-structure validation " +
|
||||
"messages. Falls back to the asset name when empty.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Candidate waves in this group, each with a relative draw weight.")]
|
||||
public WavePoolEntry[] Waves;
|
||||
|
||||
/// <summary>Name used in validation and log messages. Falls back to the asset name.</summary>
|
||||
public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName;
|
||||
|
||||
/// <summary>Number of entries, including any that are null or zero-weight.</summary>
|
||||
public int Count => Waves?.Length ?? 0;
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5625bd25f378bda429666820a4940cee
|
||||
|
|
@ -74,6 +74,11 @@ namespace TD.UI
|
|||
public void SpawnGoldReward(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"+{amount}", goldColor);
|
||||
|
||||
/// <summary>Spawns a gold-loss popup (e.g. "-15") at the given world position. Same colour
|
||||
/// as a reward so it reads as gold; the sign carries the meaning.</summary>
|
||||
public void SpawnGoldLoss(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"-{amount}", goldColor);
|
||||
|
||||
/// <summary>Spawns a life-loss popup (e.g. "-1") at the given world position.</summary>
|
||||
public void SpawnLifeLoss(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"-{amount}", livesColor);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ using TD.Core;
|
|||
using TD.Gameplay;
|
||||
using TD.Gameplay.BuilderSpells;
|
||||
using TD.Gameplay.Draft;
|
||||
using TD.Gameplay.EnemyUpgrades;
|
||||
using TD.Gameplay.Waves;
|
||||
using TD.Towers;
|
||||
using TD.UI.Minimap;
|
||||
|
||||
|
|
@ -112,10 +114,34 @@ namespace TD.UI
|
|||
// shown when no draft is pending so a paid roll can be bought any time.
|
||||
private VisualElement draftPanel;
|
||||
private VisualElement draftCardRow;
|
||||
private Button draftBuyButton;
|
||||
private bool draftSubscribed;
|
||||
private PlayerDraft subscribedDraft;
|
||||
|
||||
// Enemy-buff vote overlay. Shares the draft overlay's position and card styling, but the
|
||||
// cards are shared rather than per-player and carry live voter badges.
|
||||
private VisualElement votePanel;
|
||||
private VisualElement voteCardRow;
|
||||
private Label voteTitle;
|
||||
private bool voteSubscribed;
|
||||
private WaveVote subscribedVote;
|
||||
|
||||
// Boss health bar. Shown only while a boss is alive; polled per frame like lives/gold
|
||||
// rather than event-driven, since HP changes continuously anyway.
|
||||
private VisualElement bossBarPanel;
|
||||
private VisualElement bossHealthFill;
|
||||
private Label bossHealthText;
|
||||
private Label bossNameLabel;
|
||||
|
||||
// Reused when painting voter badges so a rebuild-per-ballot doesn't allocate a list per
|
||||
// card. The HUD is single-threaded, so one shared buffer is safe.
|
||||
private readonly System.Collections.Generic.List<PlayerSlot> voteVoterScratch
|
||||
= new System.Collections.Generic.List<PlayerSlot>();
|
||||
|
||||
// Reused when building a selected tower's upgrade buttons.
|
||||
private readonly System.Collections.Generic.List<(TowerDefinition Def, int TypeId)>
|
||||
upgradeOptionScratch
|
||||
= new System.Collections.Generic.List<(TowerDefinition, int)>();
|
||||
|
||||
// Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies
|
||||
// its reserved width, so the centered command bar never shifts) while the local player
|
||||
// has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown
|
||||
|
|
@ -201,6 +227,8 @@ namespace TD.UI
|
|||
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
|
||||
private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
|
||||
private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
|
||||
private bool upgradesSubscribed; // true once we've hooked the local PlayerTowerUpgrades
|
||||
private PlayerTowerUpgrades subscribedUpgrades;
|
||||
private MinimapView minimapView;
|
||||
private GameMenuView gameMenu; // gear button + modal menu overlay
|
||||
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
|
||||
|
|
@ -330,6 +358,19 @@ namespace TD.UI
|
|||
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
|
||||
}
|
||||
|
||||
// Same idea for unlocked upgrade nodes: a draft pick can grant an upgrade while the
|
||||
// player already has the target tower selected, and the new branch should appear in the
|
||||
// action grid without needing a reselect.
|
||||
private void TrySubscribeUpgrades()
|
||||
{
|
||||
var upgrades = PlayerTowerUpgrades.Local;
|
||||
if (upgrades == null) return;
|
||||
upgrades.OnUpgradesChanged += HandleDeckChanged;
|
||||
subscribedUpgrades = upgrades;
|
||||
upgradesSubscribed = true;
|
||||
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
|
||||
}
|
||||
|
||||
// Hook the local player's draft so the overlay rebuilds when options are offered,
|
||||
// picked, or rerolled. Retried each Update until the local PlayerDraft exists.
|
||||
private void TrySubscribeDraft()
|
||||
|
|
@ -366,9 +407,9 @@ namespace TD.UI
|
|||
|
||||
// ----- Draft overlay ----------------------------------------------
|
||||
|
||||
// Non-modal draft UI: a card row floats at top-center while a draft is active, and
|
||||
// a "Buy Roll" button shows when no draft is pending. The host panel ignores picks
|
||||
// so empty space clicks through to the world — the player keeps building during prep.
|
||||
// Non-modal draft UI: a card row floats at top-center while the local player has an
|
||||
// unresolved offer. The host panel ignores picks so empty space clicks through to the
|
||||
// world — the player can still pan and inspect their maze while deciding.
|
||||
private void BuildDraftOverlay(VisualElement root)
|
||||
{
|
||||
draftPanel = new VisualElement();
|
||||
|
|
@ -390,12 +431,8 @@ namespace TD.UI
|
|||
draftCardRow.pickingMode = PickingMode.Ignore;
|
||||
box.Add(draftCardRow);
|
||||
|
||||
draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc())
|
||||
{
|
||||
text = "Buy Roll"
|
||||
};
|
||||
draftBuyButton.style.marginTop = 6;
|
||||
box.Add(draftBuyButton);
|
||||
// The "Buy Roll" button lived here. Removed with the extra-draft shop for the 2.0 MVP
|
||||
// — see PlayerDraft's commented-out RequestBuyRerollRpc for why the backend was kept.
|
||||
|
||||
draftPanel.Add(box);
|
||||
root.Add(draftPanel);
|
||||
|
|
@ -474,35 +511,318 @@ namespace TD.UI
|
|||
return card;
|
||||
}
|
||||
|
||||
// Per-frame: toggles the card row vs. the buy button and keeps the buy button's
|
||||
// label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame
|
||||
// refresh); only allocates a string while the buy button is shown.
|
||||
// Shows the draft row only while the local player has an unresolved offer. With the
|
||||
// extra-draft shop gone there is nothing else in this panel, so it hides entirely once
|
||||
// the player has picked — which also gives them a clear "I'm done, waiting on others"
|
||||
// signal during the barrier.
|
||||
private void UpdateDraftVisibility()
|
||||
{
|
||||
if (draftPanel == null) return;
|
||||
|
||||
var draft = PlayerDraft.Local;
|
||||
var service = DraftService.Instance;
|
||||
var gold = PlayerGoldManager.Local;
|
||||
|
||||
bool hasDraft = draft != null && draft.HasActiveDraft;
|
||||
bool canShowBuy = draft != null && service != null && !hasDraft;
|
||||
var draft = PlayerDraft.Local;
|
||||
bool hasDraft = draft != null && draft.HasActiveDraft;
|
||||
|
||||
if (draftCardRow != null)
|
||||
draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
|
||||
if (draftBuyButton != null)
|
||||
draftPanel.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
// ----- Enemy-buff vote overlay --------------------------------------
|
||||
|
||||
// Shared vote UI. Same anchor and card language as the draft row (they never show at the
|
||||
// same time — the inter-wave stages are strictly sequential), but every peer sees the same
|
||||
// three cards, and each card carries badges for the players currently voting for it.
|
||||
private void BuildVoteOverlay(VisualElement root)
|
||||
{
|
||||
votePanel = new VisualElement();
|
||||
votePanel.style.position = Position.Absolute;
|
||||
votePanel.style.left = 0;
|
||||
votePanel.style.right = 0;
|
||||
votePanel.style.top = 70; // below the top bar, same as the draft row
|
||||
votePanel.style.alignItems = Align.Center;
|
||||
votePanel.pickingMode = PickingMode.Ignore;
|
||||
votePanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.flexDirection = FlexDirection.Column;
|
||||
box.style.alignItems = Align.Center;
|
||||
box.pickingMode = PickingMode.Ignore;
|
||||
|
||||
voteTitle = new Label("Choose how this wave comes back");
|
||||
voteTitle.style.fontSize = 15;
|
||||
voteTitle.style.color = new Color(0.95f, 0.72f, 0.55f);
|
||||
voteTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
voteTitle.style.marginBottom = 6;
|
||||
box.Add(voteTitle);
|
||||
|
||||
voteCardRow = new VisualElement();
|
||||
voteCardRow.style.flexDirection = FlexDirection.Row;
|
||||
voteCardRow.pickingMode = PickingMode.Ignore;
|
||||
box.Add(voteCardRow);
|
||||
|
||||
votePanel.Add(box);
|
||||
root.Add(votePanel);
|
||||
|
||||
UpdateVoteVisibility();
|
||||
}
|
||||
|
||||
// Hook the shared vote so the panel rebuilds whenever a ballot lands. Retried each Update
|
||||
// until the scene's WaveVote has network-spawned.
|
||||
private void TrySubscribeVote()
|
||||
{
|
||||
var vote = WaveVote.Instance;
|
||||
if (vote == null) return;
|
||||
vote.OnVoteChanged += HandleVoteChanged;
|
||||
subscribedVote = vote;
|
||||
voteSubscribed = true;
|
||||
RebuildVoteCards();
|
||||
}
|
||||
|
||||
private void HandleVoteChanged()
|
||||
{
|
||||
RebuildVoteCards();
|
||||
}
|
||||
|
||||
// Rebuilt on every ballot change, not just when the offered set changes — the voter
|
||||
// badges are the point of the panel, so they have to track votes as they land.
|
||||
private void RebuildVoteCards()
|
||||
{
|
||||
if (voteCardRow == null) return;
|
||||
voteCardRow.Clear();
|
||||
|
||||
var vote = WaveVote.Instance;
|
||||
var pool = EnemyUpgradePool.Instance;
|
||||
|
||||
if (vote != null && pool != null && vote.IsOpen)
|
||||
{
|
||||
draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (canShowBuy)
|
||||
for (int i = 0; i < vote.OptionCount; i++)
|
||||
{
|
||||
int cost = service.RerollCost;
|
||||
draftBuyButton.text = $"Buy Roll ({cost}g)";
|
||||
draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost);
|
||||
int id = vote.GetOptionId(i);
|
||||
var option = pool.Get(id);
|
||||
if (option == null) continue;
|
||||
voteCardRow.Add(CreateVoteCard(option, id, vote));
|
||||
}
|
||||
}
|
||||
|
||||
draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
UpdateVoteVisibility();
|
||||
}
|
||||
|
||||
private VisualElement CreateVoteCard(EnemyUpgradeOption option, int optionId, WaveVote vote)
|
||||
{
|
||||
var localSlot = PlayerMatchState.Local != null
|
||||
? PlayerMatchState.Local.Slot
|
||||
: PlayerSlot.None;
|
||||
bool isLocalChoice = localSlot != PlayerSlot.None
|
||||
&& vote.GetBallot(localSlot) == optionId;
|
||||
|
||||
var card = new VisualElement();
|
||||
card.style.width = 160;
|
||||
card.style.marginLeft = card.style.marginRight = 4;
|
||||
card.style.paddingTop = card.style.paddingBottom = 10;
|
||||
card.style.paddingLeft = card.style.paddingRight = 10;
|
||||
card.style.backgroundColor = new Color(0.13f, 0.09f, 0.09f, 0.96f);
|
||||
card.style.borderTopWidth = card.style.borderBottomWidth =
|
||||
card.style.borderLeftWidth = card.style.borderRightWidth = 2;
|
||||
|
||||
// The local player's current choice gets a bright border so they can tell at a glance
|
||||
// which one is theirs among everyone else's badges.
|
||||
var border = isLocalChoice
|
||||
? new Color(0.95f, 0.72f, 0.35f)
|
||||
: new Color(0.45f, 0.30f, 0.30f);
|
||||
card.style.borderTopColor = card.style.borderBottomColor =
|
||||
card.style.borderLeftColor = card.style.borderRightColor = border;
|
||||
card.style.alignItems = Align.Center;
|
||||
|
||||
var title = new Label(option.DisplayName);
|
||||
title.style.fontSize = 14;
|
||||
title.style.color = Color.white;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.whiteSpace = WhiteSpace.Normal;
|
||||
title.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
title.style.marginBottom = 6;
|
||||
card.Add(title);
|
||||
|
||||
var iconSprite = option.ResolveIcon();
|
||||
if (iconSprite != null)
|
||||
{
|
||||
var img = new Image { sprite = iconSprite };
|
||||
img.style.width = 48;
|
||||
img.style.height = 48;
|
||||
img.style.marginBottom = 6;
|
||||
card.Add(img);
|
||||
}
|
||||
|
||||
var desc = new Label(option.Description ?? "");
|
||||
desc.style.fontSize = 11;
|
||||
desc.style.color = new Color(0.82f, 0.82f, 0.82f);
|
||||
desc.style.whiteSpace = WhiteSpace.Normal;
|
||||
desc.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
desc.style.marginBottom = 8;
|
||||
card.Add(desc);
|
||||
|
||||
// Live voter badges — one per player currently voting for this card, in their own
|
||||
// player color. This row is why the panel rebuilds on every ballot change.
|
||||
var badgeRow = new VisualElement();
|
||||
badgeRow.style.flexDirection = FlexDirection.Row;
|
||||
badgeRow.style.justifyContent = Justify.Center;
|
||||
badgeRow.style.minHeight = 24;
|
||||
badgeRow.style.marginBottom = 6;
|
||||
|
||||
voteVoterScratch.Clear();
|
||||
vote.GetVotersFor(optionId, voteVoterScratch);
|
||||
foreach (var voter in voteVoterScratch)
|
||||
badgeRow.Add(CreateVoterBadge(voter));
|
||||
|
||||
card.Add(badgeRow);
|
||||
|
||||
var pick = new Button(() => WaveVote.Instance?.RequestVoteRpc(optionId))
|
||||
{
|
||||
text = isLocalChoice ? "Voted" : "Vote"
|
||||
};
|
||||
card.Add(pick);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Small filled circle carrying the player's slot number, in their canonical player color.
|
||||
// Same visual vocabulary as the race-selection overlay's picker badges, so players read it
|
||||
// as "who" without a legend.
|
||||
private VisualElement CreateVoterBadge(PlayerSlot slot)
|
||||
{
|
||||
var badge = new Label(((int)slot).ToString());
|
||||
badge.style.width = 20;
|
||||
badge.style.height = 20;
|
||||
badge.style.marginLeft = badge.style.marginRight = 2;
|
||||
badge.style.backgroundColor = PlayerColors.Get(slot);
|
||||
badge.style.color = Color.black;
|
||||
badge.style.fontSize = 11;
|
||||
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
badge.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
badge.style.borderTopLeftRadius = badge.style.borderTopRightRadius =
|
||||
badge.style.borderBottomLeftRadius = badge.style.borderBottomRightRadius = 10;
|
||||
return badge;
|
||||
}
|
||||
|
||||
private void UpdateVoteVisibility()
|
||||
{
|
||||
if (votePanel == null) return;
|
||||
|
||||
var vote = WaveVote.Instance;
|
||||
bool open = vote != null && vote.IsOpen && vote.OptionCount > 0;
|
||||
votePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
// ----- Boss bar -----------------------------------------------------
|
||||
|
||||
// Prominent health bar for the phase boss. Hidden whenever no boss is alive, so it costs
|
||||
// nothing outside a boss encounter.
|
||||
private void BuildBossBar(VisualElement root)
|
||||
{
|
||||
bossBarPanel = new VisualElement();
|
||||
bossBarPanel.style.position = Position.Absolute;
|
||||
bossBarPanel.style.left = 0;
|
||||
bossBarPanel.style.right = 0;
|
||||
bossBarPanel.style.top = 70;
|
||||
bossBarPanel.style.alignItems = Align.Center;
|
||||
bossBarPanel.pickingMode = PickingMode.Ignore;
|
||||
bossBarPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.width = 440;
|
||||
box.style.alignItems = Align.Center;
|
||||
box.pickingMode = PickingMode.Ignore;
|
||||
|
||||
bossNameLabel = new Label("BOSS");
|
||||
bossNameLabel.style.fontSize = 18;
|
||||
bossNameLabel.style.color = new Color(0.95f, 0.45f, 0.4f);
|
||||
bossNameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
bossNameLabel.style.marginBottom = 3;
|
||||
box.Add(bossNameLabel);
|
||||
|
||||
var track = new VisualElement();
|
||||
track.style.width = 440;
|
||||
track.style.height = 18;
|
||||
track.style.backgroundColor = new Color(0.08f, 0.06f, 0.06f, 0.95f);
|
||||
track.style.borderTopWidth = track.style.borderBottomWidth =
|
||||
track.style.borderLeftWidth = track.style.borderRightWidth = 2;
|
||||
var trackBorder = new Color(0.5f, 0.25f, 0.22f);
|
||||
track.style.borderTopColor = track.style.borderBottomColor =
|
||||
track.style.borderLeftColor = track.style.borderRightColor = trackBorder;
|
||||
track.pickingMode = PickingMode.Ignore;
|
||||
|
||||
bossHealthFill = new VisualElement();
|
||||
bossHealthFill.style.height = Length.Percent(100);
|
||||
bossHealthFill.style.width = Length.Percent(100);
|
||||
bossHealthFill.style.backgroundColor = new Color(0.75f, 0.18f, 0.16f);
|
||||
bossHealthFill.pickingMode = PickingMode.Ignore;
|
||||
track.Add(bossHealthFill);
|
||||
|
||||
bossHealthText = new Label("");
|
||||
bossHealthText.style.position = Position.Absolute;
|
||||
bossHealthText.style.left = 0;
|
||||
bossHealthText.style.right = 0;
|
||||
bossHealthText.style.top = 0;
|
||||
bossHealthText.style.bottom = 0;
|
||||
bossHealthText.style.fontSize = 12;
|
||||
bossHealthText.style.color = Color.white;
|
||||
bossHealthText.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
bossHealthText.pickingMode = PickingMode.Ignore;
|
||||
track.Add(bossHealthText);
|
||||
|
||||
box.Add(track);
|
||||
bossBarPanel.Add(box);
|
||||
root.Add(bossBarPanel);
|
||||
}
|
||||
|
||||
// Polled from the per-frame refresh, like lives and gold. Picks the boss in the local
|
||||
// player's own zone when there is one — the boss encounter spawns one per zone, and the
|
||||
// one actually walking through your maze is the one you need to watch.
|
||||
private void RefreshBossBar()
|
||||
{
|
||||
if (bossBarPanel == null) return;
|
||||
|
||||
var boss = ResolveDisplayedBoss();
|
||||
if (boss == null)
|
||||
{
|
||||
bossBarPanel.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
|
||||
bossBarPanel.style.display = DisplayStyle.Flex;
|
||||
|
||||
float max = Mathf.Max(1f, boss.MaxHp);
|
||||
float pct = Mathf.Clamp01(boss.CurrentHp / max);
|
||||
bossHealthFill.style.width = Length.Percent(pct * 100f);
|
||||
bossHealthText.text = $"{Mathf.CeilToInt(boss.CurrentHp)} / {Mathf.CeilToInt(max)}";
|
||||
bossNameLabel.text = boss.DisplayName;
|
||||
}
|
||||
|
||||
private EnemyHealth ResolveDisplayedBoss()
|
||||
{
|
||||
var bosses = EnemyHealth.ActiveBosses;
|
||||
if (bosses.Count == 0) return null;
|
||||
|
||||
var localSlot = PlayerMatchState.Local != null
|
||||
? PlayerMatchState.Local.Slot
|
||||
: PlayerSlot.None;
|
||||
|
||||
EnemyHealth fallback = null;
|
||||
for (int i = 0; i < bosses.Count; i++)
|
||||
{
|
||||
var boss = bosses[i];
|
||||
if (boss == null || boss.IsDead) continue;
|
||||
|
||||
fallback ??= boss;
|
||||
|
||||
if (localSlot != PlayerSlot.None)
|
||||
{
|
||||
var movement = boss.GetComponent<EnemyMovement>();
|
||||
if (movement != null && movement.OriginZone == localSlot) return boss;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ----- Spell hotbar -------------------------------------------------
|
||||
|
|
@ -715,9 +1035,17 @@ namespace TD.UI
|
|||
BuildMatchEndOverlay(root);
|
||||
|
||||
// Build the draft overlay (roguelike between-waves draft). Hidden until the
|
||||
// local player has an active draft (or to show the paid "Buy Roll" button).
|
||||
// local player has an active draft.
|
||||
BuildDraftOverlay(root);
|
||||
|
||||
// Build the shared enemy-buff vote overlay. Hidden until a vote is open; never shows
|
||||
// at the same time as the draft row, since the inter-wave stages run in sequence.
|
||||
BuildVoteOverlay(root);
|
||||
|
||||
// Boss health bar. Shares the top-centre anchor with the draft/vote rows, which is
|
||||
// safe because a boss is only ever alive once the inter-wave stages have finished.
|
||||
BuildBossBar(root);
|
||||
|
||||
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
|
||||
// Player typing toggled with Enter; system messages (e.g. life lost) post via
|
||||
// ChatService.PostLocalSystem on every peer.
|
||||
|
|
@ -777,6 +1105,13 @@ namespace TD.UI
|
|||
deckSubscribed = false;
|
||||
subscribedDeck = null;
|
||||
|
||||
if (upgradesSubscribed && subscribedUpgrades != null)
|
||||
{
|
||||
subscribedUpgrades.OnUpgradesChanged -= HandleDeckChanged;
|
||||
}
|
||||
upgradesSubscribed = false;
|
||||
subscribedUpgrades = null;
|
||||
|
||||
if (draftSubscribed && subscribedDraft != null)
|
||||
{
|
||||
subscribedDraft.OnDraftChanged -= HandleDraftChanged;
|
||||
|
|
@ -784,6 +1119,13 @@ namespace TD.UI
|
|||
draftSubscribed = false;
|
||||
subscribedDraft = null;
|
||||
|
||||
if (voteSubscribed && subscribedVote != null)
|
||||
{
|
||||
subscribedVote.OnVoteChanged -= HandleVoteChanged;
|
||||
}
|
||||
voteSubscribed = false;
|
||||
subscribedVote = null;
|
||||
|
||||
if (spellLoadoutSubscribed && subscribedSpellLoadout != null)
|
||||
{
|
||||
subscribedSpellLoadout.OnLoadoutChanged -= HandleSpellLoadoutChanged;
|
||||
|
|
@ -813,9 +1155,15 @@ namespace TD.UI
|
|||
if (!deckSubscribed)
|
||||
TrySubscribeDeck();
|
||||
|
||||
if (!upgradesSubscribed)
|
||||
TrySubscribeUpgrades();
|
||||
|
||||
if (!draftSubscribed)
|
||||
TrySubscribeDraft();
|
||||
|
||||
if (!voteSubscribed)
|
||||
TrySubscribeVote();
|
||||
|
||||
if (!spellLoadoutSubscribed)
|
||||
TrySubscribeSpellLoadout();
|
||||
|
||||
|
|
@ -992,27 +1340,40 @@ namespace TD.UI
|
|||
if (livesLabel != null)
|
||||
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
|
||||
|
||||
RefreshBossBar();
|
||||
|
||||
// Run progress reads "Phase 1 · Cycle 2 · Wave 3/5" (or "· BOSS"). RunState builds
|
||||
// the string since it owns the phase/cycle/slot maths; the HUD just displays it.
|
||||
if (waveLabel != null)
|
||||
{
|
||||
int total = wm?.TotalWaves ?? 0;
|
||||
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
|
||||
? $"Wave {ms.CurrentWave} / {total}"
|
||||
var run = RunState.Instance;
|
||||
waveLabel.text = run != null && ms != null && ms.CurrentWave > 0
|
||||
? run.ProgressLabel
|
||||
: "Wave --";
|
||||
}
|
||||
|
||||
// Next-wave countdown. Shows during prep ("next: 0:12") and clears the
|
||||
// moment the wave actually starts spawning. WaveManager.PrepCountdown
|
||||
// Inter-wave countdown. One timer serves all three stages (they never overlap), so
|
||||
// the label is prefixed with which stage is running — "draft: 0:12", "vote: 0:08",
|
||||
// "build: 0:15". Clears the moment the wave 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)
|
||||
{
|
||||
string stage = wm.CurrentInterWaveStage switch
|
||||
{
|
||||
InterWaveStage.Draft => "draft",
|
||||
InterWaveStage.Vote => "vote",
|
||||
InterWaveStage.Build => "build",
|
||||
_ => "next",
|
||||
};
|
||||
|
||||
// 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}";
|
||||
nextWaveLabel.text = $"{stage}: {mm}:{ss:00}";
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -1276,8 +1637,27 @@ namespace TD.UI
|
|||
}
|
||||
else if (selection is TowerInstance tower)
|
||||
{
|
||||
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
|
||||
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
|
||||
// WC3 layout convention: primary actions top-left (Q onward), sell bottom-right.
|
||||
// One button per unlocked upgrade branch rather than a single generic "Upgrade" —
|
||||
// a tree node can have several children, and a lone button couldn't express the
|
||||
// choice between them without an extra submenu layer.
|
||||
upgradeOptionScratch.Clear();
|
||||
var localClientId = NetworkManager.Singleton != null
|
||||
? NetworkManager.Singleton.LocalClientId
|
||||
: 0UL;
|
||||
bool ownsTower = PlayerMatchState.Local != null
|
||||
&& tower.Owner == PlayerMatchState.Local.Slot;
|
||||
|
||||
if (ownsTower)
|
||||
tower.CollectAvailableUpgrades(localClientId, upgradeOptionScratch);
|
||||
|
||||
int upgradeSlots = Mathf.Min(upgradeOptionScratch.Count, GRID_MAX - 1);
|
||||
for (int i = 0; i < upgradeSlots; i++)
|
||||
{
|
||||
var (def, typeId) = upgradeOptionScratch[i];
|
||||
cells[i] = CreateUpgradeButton(tower, def, typeId, HotkeyLayout[i]);
|
||||
}
|
||||
|
||||
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
|
||||
}
|
||||
else if (selection is BuildSiteVisual bsv)
|
||||
|
|
@ -1439,19 +1819,35 @@ namespace TD.UI
|
|||
return btn;
|
||||
}
|
||||
|
||||
// Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
|
||||
// upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
|
||||
// hotkey handler also skips them (it gates on enabledSelf).
|
||||
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
|
||||
// One button per unlocked upgrade branch on the selected tower. A tree node can have
|
||||
// several children, so a single generic "Upgrade" button couldn't express the choice
|
||||
// between them without an extra submenu layer — the grid already has the slots.
|
||||
private VisualElement CreateUpgradeButton(TowerInstance tower, TowerDefinition target,
|
||||
int targetTypeId, Key hotkey)
|
||||
{
|
||||
int cost = TowerInstance.GetUpgradeCost(target);
|
||||
var gold = PlayerGoldManager.Local;
|
||||
bool affordable = gold != null && gold.CurrentGold >= cost;
|
||||
|
||||
var btn = CreateActionButton(
|
||||
costText: "", // tier cost unknown until upgrade system lands
|
||||
costText: cost > 0 ? $"{cost}g" : "",
|
||||
hotkey: hotkey,
|
||||
onClick: () =>
|
||||
{
|
||||
/* TODO: upgrade flow */
|
||||
});
|
||||
btn.SetEnabled(false);
|
||||
onClick: () => tower.RequestUpgradeServerRpc(targetTypeId));
|
||||
|
||||
if (target.Icon != null)
|
||||
{
|
||||
var img = new Image { sprite = target.Icon };
|
||||
img.AddToClassList("cmd-icon");
|
||||
img.pickingMode = PickingMode.Ignore;
|
||||
btn.Insert(0, img);
|
||||
}
|
||||
|
||||
btn.tooltip = $"Upgrade to {target.DisplayName}";
|
||||
|
||||
// Shown but disabled when unaffordable, rather than hidden — a player saving toward
|
||||
// an upgrade should be able to see the price. Disabling also makes the hotkey handler
|
||||
// skip it, since that gates on enabledSelf.
|
||||
btn.SetEnabled(affordable);
|
||||
return btn;
|
||||
}
|
||||
|
||||
|
|
@ -1659,8 +2055,7 @@ namespace TD.UI
|
|||
// 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);
|
||||
var goldEntry = wm?.GoldConfig?.GetWaveEntry(wm.CurrentEncounterNumber);
|
||||
if (goldEntry != null)
|
||||
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
|
||||
// (Weaknesses/resistances will go here once the resistance system lands.)
|
||||
|
|
|
|||
129
Docs/2.0_Setup_Checklist.md
Normal file
129
Docs/2.0_Setup_Checklist.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# 2.0 Refactor — Editor Setup Checklist
|
||||
|
||||
The 2.0 code landed on `2.0_design_refactor` and compiles clean, but the new systems are
|
||||
**data- and scene-driven**. Nothing runs until the assets below exist and the scene objects are
|
||||
wired. Work top to bottom — each section depends on the ones above it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scene objects (both `9Player` and `Main`)
|
||||
|
||||
Three new scene objects are required. Two are plain MonoBehaviours; one needs a `NetworkObject`.
|
||||
|
||||
| GameObject | Component | NetworkObject? | Notes |
|
||||
|---|---|---|---|
|
||||
| `RunState` | `RunState` | **Yes** | Assign the `RunDefinition` asset from §3. |
|
||||
| `WaveVote` | `WaveVote` | **Yes** | `Options Per Vote` defaults to 3. |
|
||||
| `EnemyUpgradePool` | `EnemyUpgradePool` | No | Fill `options` with every card asset from §2. |
|
||||
|
||||
`WaveManager` **no longer has a `waveDefinitions` array** — that field is gone. Its two new
|
||||
serialized fields are `Draft Time Seconds` (30) and `Vote Time Seconds` (25).
|
||||
|
||||
> Both new NetworkBehaviours are scene objects, so they must be present *before* the host starts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Enemy-buff cards
|
||||
|
||||
Two asset layers, because network identity and phase gating are separate concerns.
|
||||
|
||||
**Abilities** (`TD/Enemy Abilities/…`) — what a buffed enemy *does*:
|
||||
|
||||
- Split On Death — already exists; **re-check its fields**, they changed. It now has
|
||||
`SplitCount`, `HpPercent`, `SpeedPercent`, `ScalePercent`, `LivesCostPercent`,
|
||||
`FlightHeightPercent`, `ScatterRadius`.
|
||||
- Flight, Blink, No Bounty, Gold Theft — new, need assets creating.
|
||||
|
||||
Add every ability asset to the scene's existing `EnemyAbilityPool`.
|
||||
Its `noAbilityWeight` field is **gone** (abilities are voted in now, not rolled).
|
||||
|
||||
**Cards** (`TD/Enemy Upgrades/…`) — what players vote on:
|
||||
|
||||
- One `Ability Card` per ability above, each pointing at its `EnemyAbilityDefinition`.
|
||||
- One `Double Up Card` (vote-meta; grants no ability).
|
||||
|
||||
Then: add every card to `EnemyUpgradePool.options`, and group them into one or more
|
||||
`EnemyUpgradeGroup` assets for phase gating.
|
||||
|
||||
---
|
||||
|
||||
## 3. Run structure
|
||||
|
||||
Build bottom-up:
|
||||
|
||||
1. **`WaveGroup`** assets (`TD/Run/Wave Group`) — bags of weighted `WaveDefinition` entries.
|
||||
Weights are **relative** 0–1 and default to 1 (equal odds). A zero weight warns inline and
|
||||
means the wave can never be drawn.
|
||||
2. **`PhaseDefinition`** (`TD/Run/Phase Definition`) — `WaveGroups`, `BossGroups`,
|
||||
`EnemyUpgradeGroups`.
|
||||
3. **`RunDefinition`** (`TD/Run/Run Definition`) — `WavesPerCycle` (5), `CyclesPerPhase` (3),
|
||||
and the `Phases` array. **MVP needs one phase.**
|
||||
|
||||
The `RunDefinition` inspector shows a live validation panel and a per-phase capacity readout.
|
||||
Get it green before pressing play.
|
||||
|
||||
### The constraint that will bite
|
||||
|
||||
A phase must supply **`WavesPerCycle` waves with DISTINCT enemy types**. Enemy buffs are keyed to
|
||||
a wave slot, so two slots sharing an enemy type would make "which wave did this buff apply to"
|
||||
ambiguous. Ten waves in a pool is *not* enough if they only use three enemies. The inspector
|
||||
reports distinct-type count per phase; the draw fails loudly at match start otherwise.
|
||||
|
||||
Ten wave definitions and ten enemy definitions already exist, so the content floor is met — but
|
||||
check the type spread.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gold config
|
||||
|
||||
`GoldConfig.Waves` is now indexed by **global encounter number**, counted continuously across the
|
||||
whole run — cycles do **not** reset it. One phase of 5 waves × 3 cycles + a boss needs
|
||||
**16 entries**. Missing entries pay zero for that encounter.
|
||||
|
||||
---
|
||||
|
||||
## 5. Player prefab
|
||||
|
||||
Add **`PlayerTowerUpgrades`** to `Player.prefab`, alongside `PlayerTowerDeck` / `PlayerDraft`.
|
||||
Without it, tower-upgrade draft options can't apply and the Upgrade buttons never appear.
|
||||
|
||||
---
|
||||
|
||||
## 6. Enemy prefabs
|
||||
|
||||
Any enemy that should be able to carry wave buffs needs an **`EnemyAbility`** component on its
|
||||
root. `WaveManager` logs a warning naming the prefab if a buffed wave spawns an enemy without one.
|
||||
|
||||
`SplitOnDeath` and any size-changing buff need the prefab's **`NetworkTransform` to sync scale**,
|
||||
or minions render full-size on remote peers.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tower upgrade trees
|
||||
|
||||
1. Author upgraded towers as ordinary `TowerDefinition` assets and add them to
|
||||
`TowerPlacementManager.towerDefinitions` (**not** to `startingDeck`).
|
||||
2. Wire the tree by filling each parent's `UpgradePaths` array with its children.
|
||||
3. Each upgrade node's **`GoldCost` is its upgrade price** — upgrade nodes are never placed
|
||||
directly, so the field is free to mean that.
|
||||
4. **Footprint must match the parent.** A differently-sized upgrade is rejected: the tower's
|
||||
occupied/unwalkable tiles are stamped at its current size, and converting would leave the grid
|
||||
describing a shape the tower no longer has.
|
||||
5. Create a `TD/Draft/Tower Upgrade Option` per node (`BaseTower` → `UpgradedTower`) and add it to
|
||||
`DraftPool`. Prerequisites need no explicit list — a node is offered only once its parent is
|
||||
reachable (in the deck, or itself unlocked).
|
||||
|
||||
Existing `TowerUpgradeDraftOption` assets **changed meaning**: they used to swap the deck entry,
|
||||
they now unlock an upgrade node. Re-check any that exist.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Upgraded towers keep the parent's mesh.** Switching the definition changes stats immediately
|
||||
(`TowerCombat` re-reads it every tick) but not the model — visual swap is an art task.
|
||||
- **No 3-player map yet.** `MatchRules.MaxPlayers` is 3 and slot allocation caps there, but
|
||||
`9Player` is still the only authored level.
|
||||
- **Tower vulnerability is not built** — Savage, Mind Control, AOE EMP, Fart Miasma and Dummy all
|
||||
need towers to be damageable/disableable, which doesn't exist. Deferred by agreement.
|
||||
- **Relics** untouched, as agreed.
|
||||
|
|
@ -10,10 +10,11 @@ Last substantial update: 2026-07-14.
|
|||
|
||||
## Game overview
|
||||
|
||||
A **co-op tower-defense / roguelike hybrid** for up to 9 players.
|
||||
A **co-op tower-defense / roguelike hybrid** for up to **3 players** (reduced from 9 in the 2.0 design as a scope cut — see `TD.Core.MatchRules.MaxPlayers`; the `PlayerSlot` enum still runs to 9 so baked `LevelData` owner grids stay valid).
|
||||
|
||||
- **Maze defense (Wintermaul-style):** players build towers to force enemies along a longer path through their own zone. Lives are a **shared pool**; gold is **per-player**.
|
||||
- **Roguelike layer (now core to the design):** every player starts with the **same three towers** and builds out a personal "deck"/"build" through a **3-option draft** presented at match start and after every wave. Choices span new towers, systemic upgrades, builder abilities, enemy debuffs, and relic quests. See [`Project_Roadmap.md`](Project_Roadmap.md) for the full design.
|
||||
- **Cyclical run structure (2.0):** 5 waves = a **cycle**, 3 cycles = a **phase**, each phase ends in a **boss**, 3 phases wins the run. A phase draws 5 waves with **distinct enemy types** and replays them every cycle. Beating the last phase's boss is Victory.
|
||||
- **Roguelike layer (core):** every player starts with the same three towers and grows a personal deck through a **3-option draft**. After each wave the sequence is: personal draft → shared **enemy-buff vote** (open ballots, visible per-player) → build countdown. The vote permanently buffs the wave just cleared, so it comes back harder next cycle; buffs are wiped when a new phase draws fresh waves. See [`Project_Roadmap.md`](Project_Roadmap.md) for the full design.
|
||||
- **Target platform:** Steam (Windows / Linux / Steam Deck).
|
||||
- **Visual direction (aspirational):** "painted tabletop miniature" look with Spider-Verse-style stepped (on-2s) enemy animation. Current visuals are **placeholder** (primitive meshes / cones, sourced creature models).
|
||||
|
||||
|
|
@ -32,11 +33,13 @@ Unity **6.4 (6000.4.4f1)**, URP, IL2CPP, .NET Standard 2.1, Linear color space,
|
|||
## Architecture & conventions
|
||||
|
||||
- **Server-authoritative gameplay; local-only UI/visual state.** Only gameplay-meaningful state is networked. Selection, placement ghosts, animation, and the paint cursor are client-local.
|
||||
- **Data-driven via ScriptableObjects:** `TowerDefinition`, `EnemyDefinition`, `WaveDefinition`, `RaceDefinition`, `GoldConfig`, `BuffDefinition`/`BuffCategory`, `DraftOption` (+ `NewTowerDraftOption`). Designers tune stats in assets, not code.
|
||||
- **Per-player state pattern:** `NetworkBehaviour`s on the **Player prefab** with a static `GetForClient(clientId)` / `Local` registry. Current set: `PlayerGoldManager`, `PlayerMatchState`, `PlayerBuffManager`, `PlayerTowerDeck`, `PlayerDraft`.
|
||||
- **Networked identifiers are catalog indices.** `TowerTypeId` indexes `TowerPlacementManager.towerDefinitions[]`; `DraftOptionId` indexes `DraftPool`. Stable **within a match**, not across sessions — cross-match persistence will need stable IDs (asset GUID or a serialized StableId).
|
||||
- **Namespaces:** `TD.Core` (enums, palettes, grid math), `TD.Gameplay` (builder, placement, match/wave/pathfinding state, economy, enemies, deck), `TD.Gameplay.Draft` (draft system), `TD.Combat` (TowerCombat/Projectile), `TD.Levels` (in-engine authoring + bake), `TD.UI`, `TD.Net`.
|
||||
- **Scenes:** `MainMenu` → `Lobby` → a Match level (`9Player`, `Main`).
|
||||
- **Data-driven via ScriptableObjects:** `TowerDefinition`, `EnemyDefinition`, `WaveDefinition`, `RaceDefinition`, `GoldConfig`, `DraftOption` (+ subclasses), `EnemyAbilityDefinition` (+ subclasses), `EnemyUpgradeOption` (+ subclasses), and the run structure — `WaveGroup` → `PhaseDefinition` → `RunDefinition`, plus `EnemyUpgradeGroup`. Designers tune stats in assets, not code.
|
||||
- **Pools are authored as draggable groups.** Wave and enemy-buff pools are lists of *group* assets, so rebalancing a phase means dragging a group between phases rather than re-authoring entries. Draw weights are **relative** 0–1 values on each entry (`[PoolWeight]`), normalized at draw time; a zero weight means "never drawn" and the inspector warns inline.
|
||||
- **Per-player state pattern:** `NetworkBehaviour`s on the **Player prefab** with a static `GetForClient(clientId)` / `Local` registry. Current set: `PlayerGoldManager`, `PlayerMatchState`, `PlayerTowerDeck`, `PlayerTowerUpgrades`, `PlayerDraft`, `PlayerSpellLoadout`, `BuilderUpgradeManager`.
|
||||
- **Networked identifiers are catalog indices.** `TowerTypeId` indexes `TowerPlacementManager.towerDefinitions[]`; `DraftOptionId` indexes `DraftPool`; `EnemyUpgradeOptionId` indexes `EnemyUpgradePool`; wave ids index a flat list `RunDefinition` builds by walking its phases/groups/entries in declaration order (identical on every peer). Stable **within a match**, not across sessions.
|
||||
- **Run progression lives in `RunState`, not `WaveManager`.** `RunState` owns phase/cycle position, the drawn wave slots, and the per-slot enemy-buff sets; `WaveManager` only runs the encounter it is pointed at and calls `ServerAdvance()` when the field clears.
|
||||
- **Namespaces:** `TD.Core` (enums, palettes, grid math, match rules), `TD.Gameplay` (builder, placement, match/pathfinding state, economy, enemies, deck), `TD.Gameplay.Waves` (run structure + progression), `TD.Gameplay.Draft` (per-player draft), `TD.Gameplay.EnemyAbilities` (what buffed enemies *do*), `TD.Gameplay.EnemyUpgrades` (the vote + its cards), `TD.Combat` (TowerCombat/Projectile), `TD.Levels` (in-engine authoring + bake), `TD.UI`, `TD.Net`.
|
||||
- **Scenes:** `MainMenu` → `Lobby` → a Match level (`9Player`, `Main`). `9Player` is **legacy** at 3-player scope — a new 3-player map is pending.
|
||||
|
||||
### Engineering principles (carried across sessions)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue