From 4892d7253dcc283279dc3fb2206a67c7197398fe Mon Sep 17 00:00:00 2001 From: Matt F Date: Thu, 30 Jul 2026 17:45:11 -0700 Subject: [PATCH 1/2] First pass at full refactor to 2.0 design Restructures the game around the cyclical run loop from Game Design Doc V2: 5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss. - New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the run as draggable weighted pools; RunState owns phase/cycle position, the drawn wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is gone -- it now only runs the encounter RunState points at. - New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public live-replicated ballots so the HUD can show who voted for what. - Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage ending early once every player has acted. - Enemy abilities inverted from per-instance random rolls to deterministic, stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink, No Bounty, Gold Theft, Double Up. - Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts an already-placed tower in place. - Boss encounters flag their enemies and drive a boss HP bar. - Player cap reduced to 3 via MatchRules.MaxPlayers. - GoldConfig is now keyed by global encounter number rather than wave index. Compiles clean; NOT yet verified in-engine. Editor wiring still required -- see Docs/2.0_Setup_Checklist.md. Co-Authored-By: Claude Opus 5 --- .../_Project/Scripts/Core/EnemyAbilityKind.cs | 17 +- Assets/_Project/Scripts/Core/Enums.cs | 30 +- Assets/_Project/Scripts/Core/MatchRules.cs | 26 + .../_Project/Scripts/Core/MatchRules.cs.meta | 2 + .../Scripts/Core/PoolWeightAttribute.cs | 35 ++ .../Scripts/Core/PoolWeightAttribute.cs.meta | 2 + .../_Project/Scripts/Dev/DevWaveControls.cs | 33 +- Assets/_Project/Scripts/Editor/Core.meta | 8 + .../Scripts/Editor/Core/PoolWeightDrawer.cs | 66 ++ .../Editor/Core/PoolWeightDrawer.cs.meta | 2 + .../Editor/Gameplay/RunDefinitionEditor.cs | 69 +++ .../Gameplay/RunDefinitionEditor.cs.meta | 2 + .../Scripts/Gameplay/Draft/DraftService.cs | 25 +- .../Scripts/Gameplay/Draft/PlayerDraft.cs | 58 +- .../Gameplay/Draft/TowerUpgradeDraftOption.cs | 66 +- .../EnemyAbilities/BlinkAbilityDefinition.cs | 60 ++ .../BlinkAbilityDefinition.cs.meta | 2 + .../EnemyAbilities/EnemyAbilityDefinition.cs | 100 +++- .../EnemyAbilities/EnemyAbilityPool.cs | 43 +- .../EnemyAbilities/EnemySpawnContext.cs | 42 ++ .../EnemyAbilities/EnemySpawnContext.cs.meta | 2 + .../EnemyAbilities/FlightAbilityDefinition.cs | 53 ++ .../FlightAbilityDefinition.cs.meta | 2 + .../GoldTheftAbilityDefinition.cs | 53 ++ .../GoldTheftAbilityDefinition.cs.meta | 2 + .../NoBountyAbilityDefinition.cs | 34 ++ .../NoBountyAbilityDefinition.cs.meta | 2 + .../SplitOnDeathAbilityDefinition.cs | 74 ++- .../_Project/Scripts/Gameplay/EnemyAbility.cs | 174 +++++- .../_Project/Scripts/Gameplay/EnemyHealth.cs | 32 +- .../Scripts/Gameplay/EnemyMovement.cs | 39 ++ .../Scripts/Gameplay/EnemyUpgrades.meta | 8 + .../AbilityEnemyUpgradeOption.cs | 62 ++ .../AbilityEnemyUpgradeOption.cs.meta | 2 + .../DoubleUpEnemyUpgradeOption.cs | 48 ++ .../DoubleUpEnemyUpgradeOption.cs.meta | 2 + .../EnemyUpgrades/EnemyUpgradeGroup.cs | 75 +++ .../EnemyUpgrades/EnemyUpgradeGroup.cs.meta | 2 + .../EnemyUpgrades/EnemyUpgradeOption.cs | 66 ++ .../EnemyUpgrades/EnemyUpgradeOption.cs.meta | 2 + .../EnemyUpgrades/EnemyUpgradePool.cs | 94 +++ .../EnemyUpgrades/EnemyUpgradePool.cs.meta | 2 + .../Gameplay/EnemyUpgrades/WaveVote.cs | 419 +++++++++++++ .../Gameplay/EnemyUpgrades/WaveVote.cs.meta | 2 + .../_Project/Scripts/Gameplay/GoldConfig.cs | 20 +- .../Scripts/Gameplay/PlayerMatchState.cs | 10 +- .../Scripts/Gameplay/PlayerTowerUpgrades.cs | 116 ++++ .../Gameplay/PlayerTowerUpgrades.cs.meta | 2 + .../Scripts/Gameplay/TowerInstance.cs | 153 ++++- .../Scripts/Gameplay/WaveDefinition.cs | 59 ++ .../_Project/Scripts/Gameplay/WaveManager.cs | 564 ++++++++++++++---- Assets/_Project/Scripts/Gameplay/Waves.meta | 8 + .../Scripts/Gameplay/Waves/PhaseDefinition.cs | 102 ++++ .../Gameplay/Waves/PhaseDefinition.cs.meta | 2 + .../Scripts/Gameplay/Waves/RunDefinition.cs | 209 +++++++ .../Gameplay/Waves/RunDefinition.cs.meta | 2 + .../Scripts/Gameplay/Waves/RunState.cs | 468 +++++++++++++++ .../Scripts/Gameplay/Waves/RunState.cs.meta | 2 + .../Scripts/Gameplay/Waves/WaveGroup.cs | 69 +++ .../Scripts/Gameplay/Waves/WaveGroup.cs.meta | 2 + .../Scripts/UI/FloatingTextSpawner.cs | 5 + Assets/_Project/Scripts/UI/HUDController.cs | 489 +++++++++++++-- Docs/2.0_Setup_Checklist.md | 129 ++++ Project_Context.md | 17 +- 64 files changed, 4023 insertions(+), 344 deletions(-) create mode 100644 Assets/_Project/Scripts/Core/MatchRules.cs create mode 100644 Assets/_Project/Scripts/Core/MatchRules.cs.meta create mode 100644 Assets/_Project/Scripts/Core/PoolWeightAttribute.cs create mode 100644 Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Core.meta create mode 100644 Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs create mode 100644 Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta create mode 100644 Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs create mode 100644 Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs create mode 100644 Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Waves.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/RunState.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta create mode 100644 Docs/2.0_Setup_Checklist.md diff --git a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs index 3bc7bd1..3356956 100644 --- a/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs +++ b/Assets/_Project/Scripts/Core/EnemyAbilityKind.cs @@ -8,9 +8,22 @@ namespace TD.Core /// public enum EnemyAbilityKind : byte { - /// No ability rolled. Never a real pool entry — only the sentinel value - /// reports before/absent a roll. + /// Sentinel for "no ability". Never a real pool entry. None = 0, + + /// Dies into several smaller, faster copies of itself. SplitOnDeath = 1, + + /// Grounded enemies take to the air, ignoring the maze entirely. + Flight = 2, + + /// Teleports a short distance further along its path on a timer. + Blink = 3, + + /// Grants no kill bounty. + NoBounty = 4, + + /// Steals gold from the player whose zone it escaped, on top of the life cost. + GoldTheft = 5, } } diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs index 95f979a..7d49400 100644 --- a/Assets/_Project/Scripts/Core/Enums.cs +++ b/Assets/_Project/Scripts/Core/Enums.cs @@ -68,8 +68,12 @@ namespace TD.Core /// /// /// None is a sentinel value used in OwnerGrid 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. + /// + /// The enum runs to 9, but only slots are ever + /// allocated. Lobbies capped at 3 for the 2.0 design; the surplus values are kept because + /// they are baked into existing LevelData owner grids, and renumbering the enum would + /// force a re-bake of every map to no runtime benefit. /// /// /// Global phase of a match, driven by MatchState. @@ -92,6 +96,28 @@ namespace TD.Core Defeat = 4, } + /// + /// Which step of the between-encounters sequence is currently running. Replicated by + /// WaveManager so every HUD can label the shared countdown correctly. + /// + /// + /// 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. + /// + public enum InterWaveStage : byte + { + /// No inter-wave step running — a wave is spawning or being fought. + None = 0, + /// Players are picking their personal draft reward. + Draft = 1, + /// Players are voting on the buff for the wave they just cleared. + Vote = 2, + /// Build countdown before the next wave spawns. + Build = 3, + } + /// /// Stable identifier per race. Values 1-16 reserve slots for the planned /// 16-race grid in the lobby; only races with a corresponding diff --git a/Assets/_Project/Scripts/Core/MatchRules.cs b/Assets/_Project/Scripts/Core/MatchRules.cs new file mode 100644 index 0000000..d698547 --- /dev/null +++ b/Assets/_Project/Scripts/Core/MatchRules.cs @@ -0,0 +1,26 @@ +// Assets/_Project/Scripts/Core/MatchRules.cs +namespace TD.Core +{ + /// + /// Match-wide constants that several unrelated systems have to agree on. + /// + public static class MatchRules + { + /// + /// Maximum players in one lobby. + /// + /// + /// Reduced from 9 to 3 for the 2.0 design 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. + /// + /// Why still runs to 9. Shrinking the enum would + /// invalidate the owner grids baked into existing LevelData 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. + /// + public const int MaxPlayers = 3; + } +} diff --git a/Assets/_Project/Scripts/Core/MatchRules.cs.meta b/Assets/_Project/Scripts/Core/MatchRules.cs.meta new file mode 100644 index 0000000..74bb050 --- /dev/null +++ b/Assets/_Project/Scripts/Core/MatchRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ac169573e8a2fd046a3ebac7d2e03c8c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs new file mode 100644 index 0000000..0bb29d0 --- /dev/null +++ b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs @@ -0,0 +1,35 @@ +// Assets/_Project/Scripts/Core/PoolWeightAttribute.cs +using UnityEngine; + +namespace TD.Core +{ + /// + /// Marks a float field as a 0–1 relative 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. + /// + /// + /// Relative, not absolute. 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. + /// + /// The warning is advisory. 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. + /// + public class PoolWeightAttribute : PropertyAttribute + { + /// + /// Noun used in the zero-weight warning ("this wave can never be drawn"). Keep it + /// singular and lowercase. + /// + public readonly string Noun; + + public PoolWeightAttribute(string noun = "entry") + { + Noun = string.IsNullOrWhiteSpace(noun) ? "entry" : noun; + } + } +} diff --git a/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta new file mode 100644 index 0000000..2ce9e75 --- /dev/null +++ b/Assets/_Project/Scripts/Core/PoolWeightAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e171856d7a8fe194f97091853672c19d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Dev/DevWaveControls.cs b/Assets/_Project/Scripts/Dev/DevWaveControls.cs index 2b941b1..9ffa05e 100644 --- a/Assets/_Project/Scripts/Dev/DevWaveControls.cs +++ b/Assets/_Project/Scripts/Dev/DevWaveControls.cs @@ -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(); + } } } diff --git a/Assets/_Project/Scripts/Editor/Core.meta b/Assets/_Project/Scripts/Editor/Core.meta new file mode 100644 index 0000000..7db3e99 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 75cc168bfd0b91b41a313a4389d31910 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs new file mode 100644 index 0000000..3321b16 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs @@ -0,0 +1,66 @@ +// Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs +using UnityEditor; +using UnityEngine; +using TD.Core; + +namespace TD.Editor.Core +{ + /// + /// Draws a field as a 0–1 slider, with an inline warning + /// rendered directly beneath it whenever the weight is zero. + /// + /// + /// 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. + /// + [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; + } +} diff --git a/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta new file mode 100644 index 0000000..3ec645e --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Core/PoolWeightDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ba57381fc7e6a3479e59d84e45720d9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs new file mode 100644 index 0000000..0c08333 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs @@ -0,0 +1,69 @@ +// Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs +using UnityEditor; +using UnityEngine; +using TD.Gameplay.Waves; + +namespace TD.Editor.Gameplay +{ + /// + /// Inspector for . Draws the normal fields, then a live + /// validation panel reporting whether the authored pools can actually produce a run. + /// + /// + /// 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. + /// + [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)."); + } + } + } +} diff --git a/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta new file mode 100644 index 0000000..2b46f6d --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Gameplay/RunDefinitionEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f5e8e0197efd154b92817bbb3028b35 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs b/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs index 3b9ea5f..f95a423 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/DraftService.cs @@ -82,7 +82,7 @@ namespace TD.Gameplay.Draft draft.ServerOffer(resultScratch); } - /// Server-only: auto-resolve any unpicked drafts (called at prep end). + /// Server-only: auto-resolve any unpicked drafts (called when the timer expires). public void ServerAutoResolveAll() { if (!IsServer) return; @@ -90,6 +90,29 @@ namespace TD.Gameplay.Draft PlayerDraft.GetForClient(pms.OwnerClientId)?.ServerAutoResolve(); } + /// + /// 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. + /// + /// + /// Returns false when nobody is connected, so an empty lobby can't trip the barrier and + /// race the sequence forward. + /// + 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 diff --git a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs index 41a7c04..1ac355b 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/PlayerDraft.cs @@ -18,7 +18,10 @@ namespace TD.Gameplay.Draft /// Authority. The server offers (), resolves /// ( / ), and applies the /// chosen option. The owning client only sends intent via - /// and . + /// . + /// + /// The paid reroll is disabled for the 2.0 MVP — see the commented-out block at + /// the bottom of this file for why it was kept rather than removed. /// /// Generation lives in ; this component is just /// the replicated state + the pick/buy entry points. @@ -143,25 +146,38 @@ namespace TD.Gameplay.Draft ServerResolve(optionId); } - /// - /// 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). - /// - [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. + // + // /// + // /// 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). + // /// + // [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); + // } } } diff --git a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs index 7746e3e..841d627 100644 --- a/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs +++ b/Assets/_Project/Scripts/Gameplay/Draft/TowerUpgradeDraftOption.cs @@ -5,52 +5,62 @@ using TD.Towers; namespace TD.Gameplay.Draft { /// - /// Draft choice — "upgrade a tower you already have". Replaces - /// with in the player's : every - /// tower of that type placed from now on is the upgraded version. Already-placed towers - /// are unaffected. + /// Draft choice — "unlock an upgrade". Adds to the player's + /// set, letting them spend gold converting a placed + /// into it. The base tower stays buildable. /// /// - /// Mirrors , but swaps rather than adds. Only offered - /// while the player currently has unlocked — once taken, - /// is removed from the deck, which automatically makes this option - /// (and any sibling upgrade branching off the same base) invalid for future drafts. + /// Unlock, not swap. 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 access, gold + /// grants the tower. So the deck is untouched here and already-placed towers are + /// upgraded one at a time, each paid for. + /// + /// Prerequisites fall out of the tree. 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 + /// is enough. /// [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); } /// Inherits the upgraded tower's icon unless this option assigns an override. diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs new file mode 100644 index 0000000..236c8b7 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs @@ -0,0 +1,60 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// Every few seconds, the enemy teleports a short distance further along its path — skipping + /// tiles, and the towers covering them. + /// + /// + /// 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 + /// EnemyMovement.ServerBlinkForward. + /// + /// 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. + /// + [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()?.ServerBlinkForward(BlinkWaypoints); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta new file mode 100644 index 0000000..b7af88b --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4fbb09f55384ac44399ac4655b2b53ec \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs index 349b0ee..d80b430 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityDefinition.cs @@ -5,21 +5,28 @@ using TD.Core; namespace TD.Gameplay.EnemyAbilities { /// - /// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled - /// at random for each spawned enemy by and applied - /// via . + /// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Abilities + /// are attached to a wave slot by the post-wave player vote, and every enemy that wave + /// spawns from then on carries the whole accumulated set. /// /// - /// One asset per kind. is fixed per subclass, same as - /// . uses - /// it to build a fixed-size, enum-indexed lookup table. + /// Assignment is deterministic, not random. 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 WaveManager reads the slot's set instead. What survived is + /// the shape: one asset per kind, server-only hooks. /// - /// Server-only hooks. All three hooks below only ever run on the server — - /// only calls them when IsServer is true. and 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. + /// Abilities stack. 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. + /// + /// Server-only hooks. All hooks run on the server only — + /// gates every call on IsServer. They default to no-ops so a new card only overrides the + /// one or two it actually needs. + /// + /// No per-enemy state on the asset. A single asset is shared by every enemy + /// carrying the ability, across every wave and match. Anything per-instance belongs on the + /// component or the enemy itself. /// 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 -------------------------------- - /// Server-only: called once, right after this ability is assigned (before the + /// + /// Server-only: alter what this enemy spawns as, before it is built. Runs for every enemy + /// of the buffed wave. Default no-op. + /// + /// + /// This is the hook for cards that change what an enemy fundamentally is — flight, + /// health, speed, size. It must run before the enemy exists, because spawn position + /// (flyers are raised) and EnemyHealth/EnemyMovement initialization all read + /// these values once and keep them. + /// + /// 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. + /// + public virtual void ServerModifySpawn(ref EnemySpawnContext context) { } + + // ----- Lifetime hooks ---------------------------------------------- + + /// Server-only: called once, right after the ability set is assigned (before the /// enemy's NetworkObject is spawned to clients). Default no-op. - public virtual void ServerOnSpawn(EnemyAbility instance) { } + /// This ability's slot on — the + /// same index receives, so per-enemy timers can be seeded here. + public virtual void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { } - /// Server-only: called every frame this ability is active on a live enemy. - /// Default no-op. - public virtual void ServerTick(EnemyAbility instance, float dt) { } + /// + /// Server-only: called every frame this ability is active on a live enemy. Default no-op. + /// + /// This ability's slot on . Pass it + /// to to reach a per-enemy float this ability owns. + /// + /// The asset holds no per-enemy state. One + /// 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. gives each enemy its own + /// slot by reference, which is enough for the timer-driven abilities and costs no + /// allocation. + /// + public virtual void ServerTick(EnemyAbility instance, int abilityIndex, float dt) { } /// Server-only: called the instant the enemy's HP reaches zero, before the /// death animation/despawn sequence plays. Default no-op. public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { } + + // ----- Economy hooks ----------------------------------------------- + + /// + /// Server-only: adjust the gold a player earns for killing this enemy. Returns the reward + /// to pass on; default is unchanged. + /// + /// + /// 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. + /// + public virtual int ServerModifyKillReward(EnemyAbility instance, int reward) => reward; + + /// + /// 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. + /// + public virtual void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone) { } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs index 30fe26c..09a0880 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemyAbilityPool.cs @@ -13,8 +13,12 @@ namespace TD.Gameplay.EnemyAbilities /// /// /// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync. - /// Only the server calls (at enemy spawn time, in - /// WaveManager.SpawnEnemy). + /// + /// Lookup only — no rolling. 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 + /// WaveVote (over cards, not abilities) and this is purely the kind→asset table + /// WaveManager resolves against at spawn time. /// 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; } - - /// - /// 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. - /// - 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; - } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs new file mode 100644 index 0000000..d643fcf --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs @@ -0,0 +1,42 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// 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. + /// + /// + /// Why a context rather than more parameters. Wave buffs like "grounded enemies become + /// flying" or "enemies spawn at half health" have to land before + /// EnemyHealth.InitializeServer and before the spawn position is computed (flyers are + /// raised by their flight height). Threading each new stat through SpawnEnemy as another + /// argument was already unwieldy at two; this keeps the signature flat and gives abilities one + /// obvious place to write. + /// + /// Seeded from the , 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. + /// + public struct EnemySpawnContext + { + public float MaxHp; + public float MoveSpeed; + public bool IsFlying; + public float FlightHeight; + public int LivesCost; + + /// Uniform transform scale relative to the prefab's authored scale. + public float VisualScale; + + /// Seeds a context from an enemy definition's authored stats. + 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, + }; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta new file mode 100644 index 0000000..d812b43 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2f4b0f77b422a474cbe82f3967e6195b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs new file mode 100644 index 0000000..322b2cf --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs @@ -0,0 +1,53 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// 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. + /// + /// + /// The single most punishing card in the set, 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. + /// + /// Purely a spawn-time change: EnemyMovement reads the flying flag once at + /// initialization to pick which grid to path on and to opt out of re-path scheduling, and + /// TowerCombat reads the replicated flag on EnemyHealth when filtering targets. + /// Both are already wired for flight — this card only has to flip the bit before the enemy is + /// built. + /// + [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; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta new file mode 100644 index 0000000..82e8217 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be77d353621f07e40bbdab2e9cc37f51 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs new file mode 100644 index 0000000..974b59f --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs @@ -0,0 +1,53 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// An enemy that reaches the defense point doesn't just cost a life — it robs the player whose + /// maze it escaped. + /// + /// + /// Charged to the origin zone, not the whole team. 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. + /// + /// 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. + /// + [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); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta new file mode 100644 index 0000000..aa23919 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b770ce042b78f44d9a3df537fe25db0 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs new file mode 100644 index 0000000..42d2bab --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs @@ -0,0 +1,34 @@ +// Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.EnemyAbilities +{ + /// + /// Killing these enemies pays little or nothing. The wave gets no harder to survive — it gets + /// harder to profit from. + /// + /// + /// Attacks the economy, not the maze. 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. + /// + /// Wave-completion and no-leak bonuses are untouched — those are paid per player by + /// WaveManager, not per kill, so a wave carrying this still rewards clearing it. + /// + [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); + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta new file mode 100644 index 0000000..a2f48ff --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fd5b12948b24e9c4c8840ef3470b437e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs index 48bf7c6..1d96391 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/SplitOnDeathAbilityDefinition.cs @@ -7,32 +7,60 @@ namespace TD.Gameplay.EnemyAbilities /// /// On death, spawns 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 - /// /. + /// spawner. /// + /// + /// No separate minion asset. The copies reuse whichever + /// 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. + /// + /// Percentages are of the PARENT, not the asset. 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. + /// + /// Minions inherit no abilities at all — 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. + /// [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(); - var atTile = GridCoordinates.WorldToGrid(instance.transform.position); + var movement = instance.GetComponent(); + 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); } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs index 93a577c..8913543 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbility.cs @@ -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 { /// - /// Per-enemy ability slot. Holds the rolled for this - /// instance (if any) and drives its server-only hooks. + /// Per-enemy ability set. Holds every its wave has + /// accumulated and drives their server-only hooks. /// /// /// Initialization: Call on the server immediately after /// Instantiate and before NetworkObject.Spawn(), following the same pattern as /// EnemyHealth.InitializeServer / EnemyMovement.InitializeServer. /// + /// A set, not a slot. 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. replicates the set so clients can show players what + /// they're facing. + /// /// Optional component: Not required by EnemyHealth or EnemyMovement — - /// WaveManager.SpawnEnemy 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. + /// WaveManager.SpawnEnemy only assigns abilities when this component is present on the + /// prefab, so enemy prefabs without it simply never carry wave buffs. /// /// On-death hook is not event-driven: WaveManager.HandleEnemyKilled calls - /// directly (via ) - /// rather than this component subscribing to EnemyHealth.OnDied itself — that keeps a - /// split-spawn's activeEnemyCount increment ordered before the triggering kill's - /// decrement, regardless of NetworkBehaviour subscription order. + /// directly rather than this component subscribing to + /// EnemyHealth.OnDied itself — that keeps a split-spawn's activeEnemyCount + /// increment ordered before the triggering kill's decrement, regardless of NetworkBehaviour + /// subscription order. /// [RequireComponent(typeof(NetworkObject))] public class EnemyAbility : NetworkBehaviour { - private readonly NetworkVariable kind = new NetworkVariable( - (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 kinds; // ----- Pre-spawn init (server-local) ---------------------------------- - private EnemyAbilityDefinition pendingDefinition; - private bool hasPendingInit; + private readonly List pendingDefinitions = new List(); + private bool hasPendingInit; // ----- Public state ----------------------------------------------------- - /// The ability rolled for this enemy, or null if none was rolled. - public EnemyAbilityDefinition Definition { get; private set; } + private readonly List definitions = new List(); - /// Replicated kind of the rolled ability. if - /// none was rolled. - public EnemyAbilityKind Kind => (EnemyAbilityKind)kind.Value; + /// The abilities this enemy carries. Server-side; empty on clients. + public IReadOnlyList Definitions => definitions; + + /// True if this enemy carries no abilities at all. + public bool IsPlain => definitions.Count == 0; + + /// + /// The stats this enemy actually spawned with, after every ability's spawn modification. + /// Server-side. + /// + /// + /// Kept so on-death abilities can derive from what the enemy really was rather than from + /// its . 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. + /// + public EnemySpawnContext SpawnContext { get; private set; } + + private void Awake() + { + kinds = new NetworkList(); + } + + /// Replicated check for a specific ability. Safe on any peer. + 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 ------------------------------------- /// /// Called by WaveManager on the server after Instantiate and before - /// NetworkObject.Spawn(). may be null, meaning no - /// ability was rolled for this enemy. + /// NetworkObject.Spawn(). may be null or empty, + /// meaning this enemy carries no wave buffs. /// - public void InitializeServer(EnemyAbilityDefinition definition) + /// + /// Copies rather than retains the caller's list — WaveManager reuses one scratch + /// buffer across every enemy in a wave, so holding the reference would alias every enemy + /// to the same (later mutated) contents. + /// + public void InitializeServer(IReadOnlyList 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(); - 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(); + + /// + /// A per-enemy float owned by the ability at , returned by + /// reference so it can be read and written in place. Typically a cooldown accumulator. + /// + 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 ---------------------------------------- + + /// Server-only: run every carried ability's on-death hook. + public void ServerInvokeOnDeath(EnemyHealth health) + { + if (!IsServer) return; + for (int i = 0; i < definitions.Count; i++) + definitions[i].ServerOnDeath(this, health); + } + + /// + /// 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. + /// + 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); + } + + /// Server-only: run every carried ability's reached-goal hook. + public void ServerInvokeReachedGoal(PlayerSlot originZone) + { + if (!IsServer) return; + for (int i = 0; i < definitions.Count; i++) + definitions[i].ServerOnReachedGoal(this, originZone); } } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs index 10256d2..ddd2e6a 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs @@ -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 isBoss = new NetworkVariable( + false, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server); + // ----- Public state --------------------------------------------------- public float CurrentHp => hp.Value; @@ -117,6 +126,19 @@ namespace TD.Gameplay /// public bool IsFlying => isFlying.Value; + /// True if this enemy was spawned by a phase's boss encounter. Replicated. + public bool IsBoss => isBoss.Value; + + /// + /// Every live boss, on every peer. Maintained by spawn/despawn so the HUD can render a + /// boss bar without polling the scene. + /// + /// + /// 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. + /// + public static readonly List ActiveBosses = new List(); + // ----- Events --------------------------------------------------------- /// @@ -136,12 +158,14 @@ namespace TD.Gameplay /// and before NetworkObject.Spawn(). Mirrors the /// TowerInstance.InitializeServer pattern. /// - 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. diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index 7eec0d1..147eaf9 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -282,6 +282,45 @@ namespace TD.Gameplay NetworkObject.Despawn(); } + // ----- Ability-driven movement ---------------------------------------- + + /// + /// Server-only: teleport this enemy forward along its existing path by up to + /// waypoints. Used by the Blink wave buff. + /// + /// + /// Follows the path rather than the straight line to the goal. 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. + /// + /// 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. + /// + /// Blinking onto the final waypoint resolves as a normal goal arrival, despawn and + /// life cost included. + /// + 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 diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta new file mode 100644 index 0000000..f5ba3d6 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 95ac4e8d1714a07489dad0c283970bf8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs new file mode 100644 index 0000000..3576d6b --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs @@ -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 +{ + /// + /// The workhorse enemy-buff card: grants an to a wave + /// slot. Every enemy spawned by that wave, in every later cycle of the phase, carries the + /// ability. + /// + /// + /// 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. is what that lookup resolves to. + /// + [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 s_idScratch + = new System.Collections.Generic.List(); + + 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; + } + + /// Inherits nothing automatically — carries no + /// icon, so the card's own is the only source. + public override Sprite ResolveIcon() => Icon; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta new file mode 100644 index 0000000..f619df4 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/AbilityEnemyUpgradeOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d075c66b381872a4781b3916296ccdc9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs new file mode 100644 index 0000000..882dd8f --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs @@ -0,0 +1,48 @@ +// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs +using UnityEngine; +using TD.Gameplay.Waves; + +namespace TD.Gameplay.EnemyUpgrades +{ + /// + /// Vote-meta card: the next time this wave is cleared, it takes several buffs automatically + /// and the players get no say in which. + /// + /// + /// Not an enemy ability. Nothing about the enemies changes when this is voted in — it + /// changes how the next vote on this slot resolves. That's why it derives from + /// directly instead of wrapping an + /// EnemyAbilityDefinition: the spawn path builds ability sets only from ability cards, + /// so this one is recorded on the slot and otherwise invisible to enemies. + /// + /// The trade is agency for information. 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 (ServerConsumeAutoApply), so a slot can only be + /// doubled once per card. + /// + /// 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. + /// + [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); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta new file mode 100644 index 0000000..ca93bb2 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/DoubleUpEnemyUpgradeOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f5e90fe6b8e439e4f99769a6923c2262 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs new file mode 100644 index 0000000..1e0f9f2 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs @@ -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 +{ + /// + /// One weighted candidate inside an . + /// + /// + /// Weight is relative and normalized at draw time — see . + /// 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. + /// + [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; + } + + /// + /// 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. + /// + /// + /// Buffs are gated by phase, 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. + /// + [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; + + /// Name used in validation and log messages. Falls back to the asset name. + public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName; + + /// + /// Appends every non-null, non-zero-weight entry across into + /// . Zero-weight entries are filtered here so "weight 0 means it + /// can never appear" lives in exactly one place. + /// + public static void CollectCandidates(EnemyUpgradeGroup[] groups, List 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); + } + } + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta new file mode 100644 index 0000000..3410908 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeGroup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4202a767d96e70f41b6a7e84eb08a832 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs new file mode 100644 index 0000000..ac43085 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs @@ -0,0 +1,66 @@ +// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs +using UnityEngine; +using TD.Gameplay.Waves; + +namespace TD.Gameplay.EnemyUpgrades +{ + /// + /// 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. + /// + /// + /// Mirrors DraftOption deliberately. 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 wave slot. + /// + /// Identity. Options live in and cross the network + /// as their pool index, the same stable-within-a-match id pattern used by the tower catalog + /// and draft pool. + /// + /// Recording is not this type's job. WaveVote records the winning option id + /// onto the slot via RunState.ServerAddUpgrade; is only + /// for cards that need a side effect beyond being recorded (the vote-meta cards). + /// Ability cards need nothing here — the spawn path reads the slot's recorded set. + /// + 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; + + /// + /// 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 + /// (which may be null). + /// + public virtual Sprite ResolveIcon() => Icon; + + /// + /// Server-only: may this option be OFFERED for right now? Default + /// true; override for cards with prerequisites or ones that would be a no-op. + /// + /// + /// Callers already skip options the slot has taken before, so implementations don't need + /// to re-check that. + /// + public virtual bool IsValidForSlot(RunState run, int slot) => true; + + /// + /// Server-only: side effect to run after this option has been recorded onto + /// . Default no-op — most cards do their work at enemy-spawn time + /// by virtue of being on the slot, not at vote-resolution time. + /// + public virtual void ServerOnApplied(RunState run, int slot) { } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta new file mode 100644 index 0000000..37a9369 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradeOption.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b1e9770be72a694a96169d9a9032528 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs new file mode 100644 index 0000000..f6c51e8 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs @@ -0,0 +1,94 @@ +// Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs +using System.Collections.Generic; +using UnityEngine; + +namespace TD.Gameplay.EnemyUpgrades +{ + /// + /// Scene singleton holding every that can appear anywhere in + /// this match. The array index is the option's EnemyUpgradeOptionId — the stable + /// identifier used to replicate vote offers, ballots, and the upgrades recorded on wave slots. + /// + /// + /// Why a flat pool and per-phase groups. The pool supplies network identity: one + /// array, one index per card, identical on every peer. PhaseDefinition.EnemyUpgradeGroups + /// 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. + /// + /// 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. + /// + 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; + } + + /// Number of options in the pool. + public int Count => options?.Length ?? 0; + + /// Returns the option at , or null if out of range. + public EnemyUpgradeOption Get(int id) + => (options != null && id >= 0 && id < options.Length) ? options[id] : null; + + /// Resolves an option asset back to its pool id, or -1 if it isn't in the pool. + 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; + } + + /// + /// 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. + /// + public void CollectAllowedIds(EnemyUpgradeGroup[] phaseGroups, + List entryScratch, + List intoIds, + List 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); + } + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta new file mode 100644 index 0000000..161d578 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/EnemyUpgradePool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4d98a01d9b9a7b438a6f91b0b7f498f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs new file mode 100644 index 0000000..4d8e872 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs @@ -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 +{ + /// + /// The shared, open-ballot vote held after each wave clears: players collectively pick + /// which permanent buff the wave they just beat will carry into the next cycle. + /// + /// + /// Ballots are public by design. 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. + /// + /// Votes stay changeable until the vote resolves, for the same reason. The + /// consequence is that the last player to vote ends the vote instantly (see + /// ), 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. + /// + /// Scene singleton, server-authoritative. Unlike PlayerDraft (one per + /// player) this is one shared object, so votes arrive as plain server RPCs and the server maps + /// the sender to a itself rather than trusting an owner claim. + /// + 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 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 ballots; + + /// Sentinel stored in for a player who hasn't voted. + public const int NoVote = -1; + + // Wave slot this vote will upgrade, or -1 when no vote is open. + private readonly NetworkVariable targetSlot = new NetworkVariable( + -1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); + + /// 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. + public event System.Action OnVoteChanged; + + // ----- Lifecycle ---------------------------------------------------- + + private void Awake() + { + offeredOptionIds = new NetworkList(); + ballots = new NetworkList(); + } + + 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 _) => OnVoteChanged?.Invoke(); + private void HandleTargetChanged(int _, int __) => OnVoteChanged?.Invoke(); + + // ----- Read API ------------------------------------------------------ + + /// True while a vote is open for players to cast or change a ballot. + public bool IsOpen => targetSlot.Value >= 0; + + /// Wave slot this vote will upgrade, or -1 when closed. + public int TargetSlot => targetSlot.Value; + + public int OptionCount => offeredOptionIds.Count; + + public int GetOptionId(int index) + => (index >= 0 && index < offeredOptionIds.Count) ? offeredOptionIds[index] : -1; + + /// The option voted for, or . + public int GetBallot(PlayerSlot slot) + { + int i = (int)slot; + return (i >= 0 && i < ballots.Count) ? ballots[i] : NoVote; + } + + /// + /// Appends every player slot that voted for . Drives the voter + /// badges the HUD paints onto each card. + /// + public void GetVotersFor(int optionId, List 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); + } + } + + /// + /// 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. + /// + 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 entryScratch = new List(); + private readonly List idScratch = new List(); + private readonly List weightScratch = new List(); + private readonly List tiedScratch = new List(); + + /// + /// Server-only: open a vote for , 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. + /// + 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; + } + + /// + /// 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. + /// + /// + /// 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. + /// + /// 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. + /// + 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; + } + + /// + /// Server-only: apply buffs to outright, + /// with no vote. Returns how many actually landed. Used by the vote-meta cards that trade + /// a future vote away for extra buffs. + /// + /// + /// Deliberately opens no ballot: the card's whole cost is that the players don't 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. + /// + 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; + } + + /// Server-only: close the vote without applying anything. + 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)]; + } + + /// + /// Fills / with the cards this phase + /// allows that can still meaningfully take. Returns false when + /// nothing qualifies. + /// + 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 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 ------------------------------------------------ + + /// + /// 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. + /// + [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; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta new file mode 100644 index 0000000..20d621a --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyUpgrades/WaveVote.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a18321dc2d138034fbdb5f705f19f893 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs index 965ee7e..460223a 100644 --- a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs +++ b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs @@ -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; /// - /// 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 + /// (RunState.GlobalEncounterNumber), or null if out of range. /// - public WaveGoldEntry GetWaveEntry(int waveNumber) + /// + /// Keyed by encounter, 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. + /// + 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]; } diff --git a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs index 212dbd7..d572bd7 100644 --- a/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs +++ b/Assets/_Project/Scripts/Gameplay/PlayerMatchState.cs @@ -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; } } diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs new file mode 100644 index 0000000..0b17d88 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs @@ -0,0 +1,116 @@ +// Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// Per-player set of unlocked tower upgrade nodes — the tower types this player is + /// allowed to convert an existing tower into. Lives on the Player prefab alongside + /// . + /// + /// + /// Distinct from the deck, deliberately. is what you + /// can build from scratch; this is what you can upgrade into. 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. + /// + /// The tree lives in the data. Edges are TowerDefinition.UpgradePaths; 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?). + /// + /// Append-only within a match, mirroring the deck. Reset happens by re-initializing at + /// match start, so Retry / return-to-lobby cycles start clean. + /// + public class PlayerTowerUpgrades : NetworkBehaviour + { + // ----- Static registry (mirrors PlayerTowerDeck / PlayerGoldManager) ----- + + private static readonly Dictionary s_byClientId + = new Dictionary(); + + 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 unlockedNodes; + + /// 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. + public event System.Action OnUpgradesChanged; + + private void Awake() + { + unlockedNodes = new NetworkList(); + } + + 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 _) => OnUpgradesChanged?.Invoke(); + + // ----- Read API --------------------------------------------------- + + public int Count => unlockedNodes.Count; + + public int GetTypeIdAt(int index) => unlockedNodes[index]; + + /// True if this player has unlocked the given upgrade node. + public bool Contains(int towerTypeId) + { + for (int i = 0; i < unlockedNodes.Count; i++) + if (unlockedNodes[i] == towerTypeId) return true; + return false; + } + + // ----- Server API ------------------------------------------------- + + /// Server-only: clear the unlocked set. Called at match start. + public void ServerInitialize() + { + if (!IsServer) return; + unlockedNodes.Clear(); + } + + /// + /// Server-only: unlock an upgrade node. Returns false if already unlocked (so a draft + /// option can report a wasted pick rather than silently succeeding). + /// + public bool ServerUnlock(int towerTypeId) + { + if (!IsServer) return false; + if (towerTypeId < 0 || Contains(towerTypeId)) return false; + + unlockedNodes.Add(towerTypeId); + return true; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta new file mode 100644 index 0000000..c0821f7 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/PlayerTowerUpgrades.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f60f6928562351f4891142ad59ab6352 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs index dfd73a8..6128ab4 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerInstance.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerInstance.cs @@ -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 ------------------------------------------------------ + + /// + /// 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. + /// + public event System.Action OnDefinitionChanged; + + /// + /// Gold cost to convert this tower into . The target's own + /// is the price — upgrade nodes are never placed + /// directly, so their cost field is free to mean "what this upgrade costs". + /// + public static int GetUpgradeCost(TowerDefinition target) => target != null ? target.GoldCost : 0; + + /// + /// True if is a legal upgrade of this tower's current type: a + /// direct child in the upgrade tree, with a matching footprint. + /// + /// + /// The footprint check is not cosmetic. 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. + /// + 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; + } + + /// + /// Collects the upgrades 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. + /// + 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)); + } + } + + /// + /// Client → server request to convert this tower into . + /// Accepted only from the owner, only for a node they've unlocked, only along a real tree + /// edge, and only if they can pay. + /// + /// + /// 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. + /// + [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(); + } + /// /// 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 diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs index 7398dd0..f23c5fa 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs @@ -41,5 +41,64 @@ namespace TD.Gameplay [Tooltip("Enemy groups that make up this wave. Processed in order.")] public WaveEntry[] Entries; + + /// + /// The enemy type this wave is "about" — the first assigned entry's type. Null if the + /// wave has no usable entries. + /// + /// + /// 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. still supports multiple + /// groups (useful for staggering counts of the same type), but mixing types within + /// one wave makes this ambiguous — see . + /// + 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; + } + } + + /// + /// True if every usable entry in this wave spawns the same enemy type. False means + /// 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. + /// + 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; + } + } + + /// Total enemies spawned per zone by this wave, across all entries. + 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; + } + } } } diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index ce61ce2..b393f9f 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -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 { /// - /// 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. Which encounter to + /// run comes from ; this class only runs it. /// /// - /// Wave lifecycle: + /// Encounter lifecycle: /// - /// When is entered, - /// advances - /// immediately (so the HUD shows the wave number during prep), then waits - /// before spawning. - /// Each spawns Count enemies per player zone, - /// one zone per frame-group, with SpawnInterval seconds between - /// individual enemies in the group. - /// After all entries are spawned, the wave is considered complete only when - /// every active enemy is either killed or has reached the goal. - /// All waves exhausted → . + /// When is entered, asks + /// to draw phase 1 and then runs its first wave. + /// Each encounter is: inter-wave step (draft, then the enemy-buff vote, then the + /// build countdown) → spawn → mop up. See . + /// Each spawns Count enemies per player zone. All + /// spawn held, then release in 10% chunks on ReleaseInterval. + /// The encounter is complete only when every spawned enemy is dead or has leaked. + /// then decides what comes next. + /// Final phase's boss dies → . /// Lives drop to 0 → . /// /// + /// Why the wave list moved out. 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. + /// owns it; this class asks it what to run. + /// /// Kill gold: When an enemy dies, names /// the tower's player. resolves the /// OwnerClientId, and awards - /// the gold. + /// the gold. GoldConfig is indexed by + /// — a run-long counter, not a per-cycle one, so + /// per-wave payouts keep climbing as the cycles repeat instead of resetting. /// /// Zone leak counts: is a NetworkList - /// indexed by (int)PlayerSlot (indices 0–8). It is incremented when an enemy + /// indexed by (int)PlayerSlot. 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 and is unused. /// /// Inspector setup: /// - /// Assign in order (Wave 1 at index 0). + /// Assign a RunDefinition to the scene's . /// Set to match your level design intent. /// /// @@ -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 = + new NetworkVariable( + 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 heldEnemies = new System.Collections.Generic.List(); @@ -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 ------------------------------------------- - /// - /// Total number of waves in this match. Same on every peer because - /// is a serialized prefab field, identical - /// on host and clients. Returns 0 if the array is unassigned. - /// - public int TotalWaves => waveDefinitions?.Length ?? 0; - /// /// 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 /// public float PrepCountdown => prepCountdown.Value; + /// + /// Which inter-wave step the current countdown belongs to, or + /// while a wave is spawning or being fought. Replicated; + /// safe on any peer. The HUD uses it to label the shared countdown. + /// + public InterWaveStage CurrentInterWaveStage => interWaveStage.Value; + + + /// + /// 1-based encounter number since the run began, or 0 before it starts. This is the key + /// is indexed by. Replicated via , so it's + /// safe on any peer. + /// + 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) + /// + /// Server-only: begin the run. Draws phase 1 and starts its first encounter. Idempotent — + /// re-entering won't restart a run in progress. + /// + 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); + } + + /// + /// Server-only: run whatever encounter is currently pointing at. + /// + /// Skip the draft/vote/build step and spawn immediately. + /// Used by the dev force-advance cheat. + 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)); + } + + /// + /// Server-only: the encounter is over — ask what's next and either + /// start it or end the match. + /// + 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 ----------------------------------------------- /// - /// 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. /// public void ForceAdvanceToNextWave() { - if (!IsServer) return; + if (!IsServer || !runStarted) return; + ClearFieldAndStopEncounter(); + AdvanceRun(); + } - // Stop the current wave's coroutine (cancels prep timer + remaining spawns). + /// + /// Dev cheat: skip whole encounters until the run reaches this phase's boss. Useful for + /// testing boss content without playing fifteen waves. Server-only. + /// + 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 --------------------------------------------- + + /// + /// 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. + /// + /// + /// 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. + /// + /// 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. + /// + 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; + } + + /// + /// Ticks down from to zero, + /// exiting early once returns true. + /// + /// + /// Network sync is throttled to ~10 Hz. NetworkVariable 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. + /// + private IEnumerator TickCountdown(float seconds, System.Func 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 currentWaveAbilities + = new System.Collections.Generic.List(); + + private readonly System.Collections.Generic.List upgradeIdScratch + = new System.Collections.Generic.List(); + + /// + /// Server-only: resolve the current wave slot's voted upgrades into the ability list every + /// enemy of this encounter will carry. + /// + /// + /// 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. + /// + 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); } + /// + /// Server-only: build and spawn one enemy. + /// + /// Wave buffs this enemy carries. Null/empty for a plain enemy. + /// Pre-computed stats to spawn with, bypassing both the + /// definition's values and ' spawn modifications. Used by + /// split-on-death, whose minions are derived from the parent rather than the asset. private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, + System.Collections.Generic.IReadOnlyList 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(); var movement = go.GetComponent(); @@ -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(); 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 /// activeEnemyCount/event-wiring bookkeeping as any other spawn. /// /// - /// 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 . + /// Minions inherit nothing. 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. + /// + /// Their stats come entirely from , 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. /// 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(); + 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(); - 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()?.ServerInvokeReachedGoal(movement.OriginZone); + UnsubscribeEnemy(movement.GetComponent()); 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); + } + + /// + /// Server-only: show a "-N gold" popup on every peer. Exposed because enemy abilities run + /// inside ScriptableObjects, which have no NetworkBehaviour of their own to send a + /// ClientRpc from. + /// + public void ServerBroadcastGoldLoss(Vector3 worldPos, int amount) + { + if (!IsServer || amount <= 0) return; + ShowGoldLossClientRpc(worldPos, amount); + } + // ----- Local-only notification events ----------------------------- /// @@ -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; diff --git a/Assets/_Project/Scripts/Gameplay/Waves.meta b/Assets/_Project/Scripts/Gameplay/Waves.meta new file mode 100644 index 0000000..67bd139 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f65ba25aee224c24dad3648d9e5e953e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs new file mode 100644 index 0000000..2700d59 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs @@ -0,0 +1,102 @@ +// Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs +using System.Collections.Generic; +using UnityEngine; + +namespace TD.Gameplay.Waves +{ + /// + /// 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. + /// + /// + /// Groups, not waves. Both pools are lists of 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. + /// + /// Draw contract. RunState draws WavesPerCycle waves from + /// with distinct enemy types, 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 WavesPerCycle distinct enemy types across its groups, + /// not just that many waves. exists so the draw can + /// fail loudly at match start rather than mid-run. + /// + [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; + + /// Name used in validation and log messages. Falls back to the asset name. + public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName; + + /// + /// Appends every non-null, non-zero-weight entry across 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. + /// + public static void CollectCandidates(WaveGroup[] groups, List 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); + } + } + } + + /// Collects this phase's cycle-wave candidates. + public void CollectWaveCandidates(List into) + => CollectCandidates(WaveGroups, into); + + /// Collects this phase's boss candidates. + public void CollectBossCandidates(List into) + => CollectCandidates(BossGroups, into); + + /// + /// 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. + /// + public int CountDistinctEnemyTypes() + { + var candidates = new List(); + CollectWaveCandidates(candidates); + + var seen = new HashSet(); + foreach (var entry in candidates) + { + var type = entry.Wave.PrimaryEnemyType; + if (type != null) seen.Add(type); + } + return seen.Count; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta new file mode 100644 index 0000000..c397855 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/PhaseDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f73fcf0261654d44a6d0939bdc9b88f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs new file mode 100644 index 0000000..6b0feb3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs @@ -0,0 +1,209 @@ +// Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace TD.Gameplay.Waves +{ + /// + /// 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 RunState in the + /// match scene; replaces WaveManager's old flat, hand-ordered wave array. + /// + /// + /// Stable wave ids. Waves are replicated by index into a flat list this asset builds + /// by walking its phases, groups, and entries in declaration order (see + /// ). Because every peer loads the identical asset, the same walk + /// produces the same ids everywhere — the same trick the tower catalog and + /// DraftPool already use, and the reason the server can say "wave slot 2 is wave #7" + /// in a single int. + /// + /// The index is frozen for the match. RunState 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. + /// + [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 waveIndex; + private Dictionary waveIds; + + /// Number of distinct waves reachable anywhere in this run. + public int WaveCount + { + get { EnsureIndex(); return waveIndex.Count; } + } + + /// Resolves a replicated wave id back to its asset, or null if out of range. + public WaveDefinition GetWave(int waveId) + { + EnsureIndex(); + return (waveId >= 0 && waveId < waveIndex.Count) ? waveIndex[waveId] : null; + } + + /// Resolves a wave asset to its replicated id. + public bool TryGetWaveId(WaveDefinition wave, out int waveId) + { + EnsureIndex(); + if (wave != null && waveIds.TryGetValue(wave, out waveId)) return true; + waveId = -1; + return false; + } + + /// + /// Rebuilds the flat wave index from the current asset contents. Called once by + /// RunState on every peer at match start so ids agree, and by the editor when + /// the asset changes. + /// + public void RebuildIndex() + { + waveIndex ??= new List(); + waveIds ??= new Dictionary(); + 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 -------------------------------------------------- + + /// + /// Checks that this run can actually be played: at least one phase, every phase able to + /// fill slots with distinct enemy types, and every phase able + /// to produce a boss. Returns true when the run is playable; + /// carries the problems either way (it may hold warnings even + /// on success). + /// + /// + /// 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. + /// + 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(); + + 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 + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta new file mode 100644 index 0000000..6c2d0d3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/RunDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b73bb9876b7fdb4480b13ff6bb8bd67 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs new file mode 100644 index 0000000..5f0cd92 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs @@ -0,0 +1,468 @@ +// Assets/_Project/Scripts/Gameplay/Waves/RunState.cs +using System.Collections.Generic; +using Unity.Netcode; +using UnityEngine; + +namespace TD.Gameplay.Waves +{ + /// + /// Outcome of advancing the run by one encounter. Returned by + /// so WaveManager can react without + /// re-deriving the progression maths. + /// + public enum RunAdvance : byte + { + /// Moved to the next wave inside the current cycle. + NextWave = 0, + /// Moved to the first wave of a new cycle — the same waves come round again, + /// now carrying whatever upgrades players voted onto them. + NextCycle = 1, + /// Every cycle in the phase is done; the boss is up next. + BossReady = 2, + /// The boss died and a new phase was drawn. Upgrades were wiped. + NextPhase = 3, + /// The final phase's boss died — the run is won. + RunComplete = 4, + } + + /// + /// 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. + /// + /// + /// Split of duties with WaveManager. This type is the run's state — + /// counters, draws, upgrade sets — and knows nothing about spawning. WaveManager is the + /// driver: it runs an encounter, and when the field is clear it calls + /// and acts on the returned . Keeping them + /// apart is what stops WaveManager (already a large class) from also owning pool draws and + /// upgrade bookkeeping. + /// + /// Linear encounter index. Rather than replicate cycle and wave separately and + /// keep them in sync, one counter runs + /// 0 .. CyclesPerPhase*WavesPerCycle inclusive, with the final value meaning "boss". + /// Cycle and wave slot are derived from it, so they cannot disagree. + /// + /// Upgrades are keyed by wave slot, not enemy type. 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. + /// + 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 phaseIndex = new NetworkVariable( + 0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); + + // 0 .. (CyclesPerPhase * WavesPerCycle), where the top value means "boss". + private readonly NetworkVariable encounterInPhase = new NetworkVariable( + 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 drawnWaveIds; + + private readonly NetworkVariable bossWaveId = new NetworkVariable( + -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 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 autoApplyCounts; + + /// + /// Fired on every peer whenever run progression or the drawn/upgraded wave set changes. + /// The HUD subscribes to relabel without polling. + /// + public event System.Action OnRunChanged; + + // ----- Lifecycle --------------------------------------------------- + + private void Awake() + { + drawnWaveIds = new NetworkList(); + waveUpgrades = new NetworkList(); + autoApplyCounts = new NetworkList(); + } + + 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 _) => OnRunChanged?.Invoke(); + + // ----- Structure accessors ----------------------------------------- + + /// The run asset driving this match, or null if unassigned (designer error). + 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; + + /// Encounters in one phase, counting its boss. + public int EncountersPerPhase => CyclesPerPhase * WavesPerCycle + 1; + + // ----- Progression accessors --------------------------------------- + + /// Zero-based phase index. + public int PhaseIndex => phaseIndex.Value; + + /// + /// 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. + /// + 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; + } + } + + /// 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). + public int CycleIndex => Mathf.Min(encounterInPhase.Value / WavesPerCycle, CyclesPerPhase - 1); + + /// Zero-based wave slot within the current cycle. Meaningless while + /// is true. + public int WaveSlot => encounterInPhase.Value % WavesPerCycle; + + /// True when the next/current encounter is this phase's boss. + public bool IsBossStage => encounterInPhase.Value >= CyclesPerPhase * WavesPerCycle; + + /// + /// 1-based count of encounters since the run began, including bosses. This is the key + /// GoldConfig is indexed by, so its per-wave entries scale monotonically across the + /// whole run rather than resetting every cycle. + /// + public int GlobalEncounterNumber + => phaseIndex.Value * EncountersPerPhase + encounterInPhase.Value + 1; + + /// Human-readable progress line for the HUD, e.g. "Phase 1 · Cycle 2 · Wave 3/5". + public string ProgressLabel + => IsBossStage + ? $"Phase {phaseIndex.Value + 1} · BOSS" + : $"Phase {phaseIndex.Value + 1} · Cycle {CycleIndex + 1} · " + + $"Wave {WaveSlot + 1}/{WavesPerCycle}"; + + // ----- Wave accessors ---------------------------------------------- + + /// The wave asset drawn for , or null if not drawn yet. + public WaveDefinition GetSlotWave(int slot) + { + if (runDefinition == null) return null; + if (slot < 0 || slot >= drawnWaveIds.Count) return null; + return runDefinition.GetWave(drawnWaveIds[slot]); + } + + /// This phase's boss wave, or null if not drawn yet. + public WaveDefinition BossWave + => runDefinition != null ? runDefinition.GetWave(bossWaveId.Value) : null; + + /// The wave the run is currently pointing at — boss or cycle wave. + public WaveDefinition CurrentWave => IsBossStage ? BossWave : GetSlotWave(WaveSlot); + + /// + /// 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. + /// + public int CurrentUpgradeSlot => IsBossStage ? WavesPerCycle : WaveSlot; + + // ----- Upgrade accessors ------------------------------------------- + + /// + /// Collects the upgrade option ids voted onto , in the order they + /// were applied. Safe on any peer. + /// + public void GetUpgradesForSlot(int slot, List into) + { + into.Clear(); + for (int i = 0; i < waveUpgrades.Count; i++) + { + int packed = waveUpgrades[i]; + if ((packed >> SlotShift) == slot) into.Add(packed & OptionMask); + } + } + + /// How many upgrades are stacked on . + public int CountUpgradesForSlot(int slot) + { + int n = 0; + for (int i = 0; i < waveUpgrades.Count; i++) + if ((waveUpgrades[i] >> SlotShift) == slot) n++; + return n; + } + + /// True if already carries . + /// Used to keep the vote from offering a buff a wave already has. + 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; + } + + /// Server-only: stack an upgrade onto a wave slot. Ignores exact duplicates. + 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) -------------------------------- + + /// + /// How many buffs will take automatically instead of holding its + /// next vote. Zero means a normal vote. + /// + public int GetAutoApplyCount(int slot) + => (slot >= 0 && slot < autoApplyCounts.Count) ? autoApplyCounts[slot] : 0; + + /// + /// Server-only: arm to auto-take 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. + /// + 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); + } + + /// Server-only: disarm after its auto-apply has fired. + 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 ------------------------------------------ + + /// + /// 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. + /// + 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); + } + + /// + /// Server-only: advance one encounter, drawing a new phase (and wiping upgrades) when the + /// boss falls. See for what the caller should do with each result. + /// + 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 candidateScratch = new List(); + + /// + /// Server-only: draw this phase's cycle waves and boss. Cycle waves are drawn by relative + /// weight without replacement and with distinct enemy types; the boss is a plain + /// weighted draw. + /// + 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 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 + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta new file mode 100644 index 0000000..4905373 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a293cf6dca91dd2478c9f4e799cf9e0d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs new file mode 100644 index 0000000..d870d94 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs @@ -0,0 +1,69 @@ +// Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs +using System; +using UnityEngine; +using TD.Core; + +namespace TD.Gameplay.Waves +{ + /// + /// One weighted candidate inside a : a wave that may be drawn, + /// and how likely it is to be drawn relative to the group's other entries. + /// + /// + /// Weight is RELATIVE, not an absolute probability. 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. + /// + /// A class rather than a struct specifically so 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 + /// . + /// + [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; + } + + /// + /// 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. + /// + /// + /// Why weights live on the entry, not on . Weight is a + /// property of "how common is this wave in this pool", not of the wave itself. Keeping + /// it here means the same 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. + /// + /// Boss groups use the same type. A boss wave is just a + /// whose entries spawn a single powerful enemy, so boss pools + /// are ordinary s referenced from + /// . + /// + [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; + + /// Name used in validation and log messages. Falls back to the asset name. + public string Label => string.IsNullOrWhiteSpace(DisplayName) ? name : DisplayName; + + /// Number of entries, including any that are null or zero-weight. + public int Count => Waves?.Length ?? 0; + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta new file mode 100644 index 0000000..817269e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5625bd25f378bda429666820a4940cee \ No newline at end of file diff --git a/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs b/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs index 392b1a2..49b4e66 100644 --- a/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs +++ b/Assets/_Project/Scripts/UI/FloatingTextSpawner.cs @@ -74,6 +74,11 @@ namespace TD.UI public void SpawnGoldReward(Vector3 worldPos, int amount) => SpawnInternal(worldPos, $"+{amount}", goldColor); + /// 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. + public void SpawnGoldLoss(Vector3 worldPos, int amount) + => SpawnInternal(worldPos, $"-{amount}", goldColor); + /// Spawns a life-loss popup (e.g. "-1") at the given world position. public void SpawnLifeLoss(Vector3 worldPos, int amount) => SpawnInternal(worldPos, $"-{amount}", livesColor); diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 488cea9..29e2b0f 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -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 voteVoterScratch + = new System.Collections.Generic.List(); + + // 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(); + 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.) diff --git a/Docs/2.0_Setup_Checklist.md b/Docs/2.0_Setup_Checklist.md new file mode 100644 index 0000000..8ddc037 --- /dev/null +++ b/Docs/2.0_Setup_Checklist.md @@ -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. diff --git a/Project_Context.md b/Project_Context.md index fd56cfd..540f001 100644 --- a/Project_Context.md +++ b/Project_Context.md @@ -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) -- 2.49.1 From 16706a1ecfa12b28f93d057f1aef8535032e035f Mon Sep 17 00:00:00 2001 From: Matt F Date: Fri, 31 Jul 2026 00:32:33 -0700 Subject: [PATCH 2/2] Scale difficulty by run position; wipe towers at phase end Follow-up pass on the 2.0 refactor, closing the gap between "waves are drawn at random" and "difficulty still lives in the enemy assets". - Enemy health is no longer authored per enemy type. New EnemyScalingConfig scales a per-zone HP *budget* by encounter number; per-enemy health is that budget divided by the wave's enemy count. Scaling the total rather than the per-enemy value keeps enemy count a texture knob (few tanks vs many swarmers) instead of a second, uncontrolled difficulty axis. EnemyDefinition.MaxHp becomes HpMultiplier, a deviation around 1.0. Speed stays archetype-only and unscaled -- escalating it compounds with health and invalidates tower balance mid-run. - GoldConfig entries no longer reference a WaveDefinition. Payout is a property of run position, not of which wave got drawn: WaveGoldEntry -> EncounterGoldEntry, Waves -> Encounters, keyed by global encounter number. Its inspector labels elements "Encounter N" and projects cumulative earnings. - The wave's voted buffs now show as icon badges in the top bar, read from the same RunState slot the spawn path uses so the display can't drift from what the enemies actually carry. Hover names the buff; unillustrated cards fall back to a lettered badge rather than vanishing. - Clearing a phase's boss destroys every built tower, unrefunded. Queued build jobs still refund -- those towers were never delivered. Runs with the field empty, so the walkability churn doesn't hit the re-path scheduler. - Fixed an RPC codegen break: [ClientRpc] requires a ClientRpc suffix, unlike the newer [Rpc(SendTo...)] style this file doesn't use. - Setup checklist reordered into dependency order; it previously asked for a RunDefinition two sections before creating one. Also carries the editor-side asset reorganisation into Definitions/RunDefinitions and the sprite move into Enemy/Player draft icon folders. Co-Authored-By: Claude Opus 5 --- .../_Project/Art/Sprites/EnemyDraftIcons.meta | 8 + .../Art/Sprites/EnemyDraftIcons/BlinkIcon.png | 3 + .../EnemyDraftIcons/BlinkIcon.png.meta | 117 ++++++++++ .../Sprites/EnemyDraftIcons/DoubleUpIcon.png | 3 + .../EnemyDraftIcons/DoubleUpIcon.png.meta | 117 ++++++++++ .../Sprites/EnemyDraftIcons/FlightIcon.png | 3 + .../EnemyDraftIcons/FlightIcon.png.meta | 117 ++++++++++ .../Sprites/EnemyDraftIcons/NoBountyIcon.png | 3 + .../EnemyDraftIcons/NoBountyIcon.png.meta | 117 ++++++++++ .../Art/Sprites/EnemyDraftIcons/SplitIcon.png | 3 + .../EnemyDraftIcons/SplitIcon.png.meta | 117 ++++++++++ .../Art/Sprites/EnemyDraftIcons/ThiefIcon.png | 3 + .../EnemyDraftIcons/ThiefIcon.png.meta | 117 ++++++++++ .../Art/Sprites/PlayerDraftIcons.meta | 8 + .../{ => PlayerDraftIcons}/Spells.meta | 0 .../Spells/FireballSpell.png | 0 .../Spells/FireballSpell.png.meta | 0 .../Spells/SlowSpell.png | 0 .../Spells/SlowSpell.png.meta | 0 .../{ => PlayerDraftIcons}/Towers.meta | 0 .../PlayerDraftIcons/Towers/ArrowTower.meta | 8 + .../Towers/ArrowTower/ArrowTowerUpgrade_1.png | 3 + .../ArrowTower/ArrowTowerUpgrade_1.png.meta | 117 ++++++++++ .../Towers/ArrowTower}/arrow.png | 0 .../Towers/ArrowTower}/arrow.png.meta | 0 .../{ => PlayerDraftIcons}/Towers/siege.png | 0 .../Towers/siege.png.meta | 0 Assets/_Project/Art/Sprites/RaceIcons.meta | 8 + .../Sprites/{ => RaceIcons}/BloodAngels.jpg | 0 .../{ => RaceIcons}/BloodAngels.jpg.meta | 0 .../Sprites/{ => RaceIcons}/Ultramarines.jpg | 0 .../{ => RaceIcons}/Ultramarines.jpg.meta | 0 ...01_Enemy_CrystalGolemBlue_Definition.asset | 4 +- .../02_Enemy_CyclopsGrey_Definition.asset | 2 +- .../03_Enemy_EntGreen_Definition.asset | 4 +- ..._Enemy_CrystalGolemYellow_Definition.asset | 4 +- .../05_Enemy_CyclopsGreen_Definition.asset | 4 +- .../06_Enemy_EntBlack_Definition.asset | 4 +- .../07_Enemy_CrystalGolemRed_Definition.asset | 4 +- .../08_Enemy_CyclopsRed_Definition.asset | 4 +- .../09_Enemy_EntOrange_Definition.asset | 4 +- .../10_Enemy_UndeadDrakeBone_Definition.asset | 6 +- .../_Project/Definitions/RunDefinitions.meta | 8 + .../{ => RunDefinitions}/Draft.meta | 0 .../RunDefinitions/Draft/EnemyDraft.meta | 8 + .../Draft/EnemyDraft/EnemyUpgrade_Blink.asset | 18 ++ .../EnemyDraft/EnemyUpgrade_Blink.asset.meta | 8 + .../EnemyDraft/EnemyUpgrade_DoubleUp.asset | 19 ++ .../EnemyUpgrade_DoubleUp.asset.meta | 8 + .../EnemyDraft/EnemyUpgrade_Flight.asset | 18 ++ .../EnemyDraft/EnemyUpgrade_Flight.asset.meta | 8 + .../EnemyDraft/EnemyUpgrade_NoBounty.asset | 18 ++ .../EnemyUpgrade_NoBounty.asset.meta | 8 + .../Draft/EnemyDraft/EnemyUpgrade_Split.asset | 19 ++ .../EnemyDraft/EnemyUpgrade_Split.asset.meta | 8 + .../Draft/EnemyDraft/EnemyUpgrade_Theft.asset | 18 ++ .../EnemyDraft/EnemyUpgrade_Theft.asset.meta | 8 + .../RunDefinitions/Draft/PlayerDraft.meta | 8 + .../Draft_BuilderEffect_GoldPerKill.asset | 0 ...Draft_BuilderEffect_GoldPerKill.asset.meta | 0 .../Draft_BuilderSpellOption_Fireball.asset | 0 ...aft_BuilderSpellOption_Fireball.asset.meta | 0 .../Draft_BuilderSpellOption_SlowArea.asset | 0 ...aft_BuilderSpellOption_SlowArea.asset.meta | 0 ...t_BuilderSpellOption_SlowAreaUpgrade.asset | 0 ...lderSpellOption_SlowAreaUpgrade.asset.meta | 0 .../Draft_NewTower_SiegeCannon.asset | 0 .../Draft_NewTower_SiegeCannon.asset.meta | 0 .../Draft_NewTower_TeslaCoil.asset | 0 .../Draft_NewTower_TeslaCoil.asset.meta | 0 .../PlayerDraft}/Draft_NewTower_Wall.asset | 0 .../Draft_NewTower_Wall.asset.meta | 0 .../Draft_TowerUpgrade_Arrow_1.asset | 21 ++ .../Draft_TowerUpgrade_Arrow_1.asset.meta | 8 + .../{ => RunDefinitions}/EnemyAbilities.meta | 0 .../EnemyAbilities/BlinkAbility.asset | 19 ++ .../EnemyAbilities/BlinkAbility.asset.meta | 8 + .../EnemyAbilities/FlightAbility.asset | 18 ++ .../EnemyAbilities/FlightAbility.asset.meta | 8 + .../EnemyAbilities/GoldTheftAbility.asset | 17 ++ .../GoldTheftAbility.asset.meta | 8 + .../EnemyAbilities/NoBountyAbility.asset | 17 ++ .../EnemyAbilities/NoBountyAbility.asset.meta | 8 + .../EnemyAbilities/SplitOnDeathAbility.asset | 0 .../SplitOnDeathAbility.asset.meta | 0 .../RunDefinitions/EnemyScalingConfig.asset | 17 ++ .../EnemyScalingConfig.asset.meta | 8 + .../{ => RunDefinitions}/GoldConfig.asset | 0 .../GoldConfig.asset.meta | 0 .../RunDefinitions/RunStructure.meta | 8 + .../EnemyUpgradeGroup_Phase1_Basics.asset | 28 +++ ...EnemyUpgradeGroup_Phase1_Basics.asset.meta | 8 + .../RunDefinitions/RunStructure/Phase1.asset | 21 ++ .../RunStructure/Phase1.asset.meta | 8 + .../RunStructure/Test_Run_Definition.asset | 18 ++ .../Test_Run_Definition.asset.meta | 8 + .../WaveGroup_Phase1_Bosses.asset | 18 ++ .../WaveGroup_Phase1_Bosses.asset.meta | 8 + .../WaveGroup_Phase1_Normal.asset | 34 +++ .../WaveGroup_Phase1_Normal.asset.meta | 8 + .../{ => RunDefinitions}/Waves.meta | 0 .../Waves/Wave10Definition.asset | 4 +- .../Waves/Wave10Definition.asset.meta | 0 .../Waves/Wave1Definition.asset | 0 .../Waves/Wave1Definition.asset.meta | 0 .../Waves/Wave2Definition.asset | 0 .../Waves/Wave2Definition.asset.meta | 0 .../Waves/Wave3Definition.asset | 0 .../Waves/Wave3Definition.asset.meta | 0 .../Waves/Wave4Definition.asset | 0 .../Waves/Wave4Definition.asset.meta | 0 .../Waves/Wave5Definition.asset | 0 .../Waves/Wave5Definition.asset.meta | 0 .../Waves/Wave6Definition.asset | 0 .../Waves/Wave6Definition.asset.meta | 0 .../Waves/Wave7Definition.asset | 0 .../Waves/Wave7Definition.asset.meta | 0 .../Waves/Wave8Definition.asset | 0 .../Waves/Wave8Definition.asset.meta | 0 .../Waves/Wave9Definition.asset | 0 .../Waves/Wave9Definition.asset.meta | 0 .../Definitions/Towers/BasicArrowTower.asset | 8 +- .../Towers/BasicArrowTower_Upgrade_1.asset | 41 ++++ .../BasicArrowTower_Upgrade_1.asset.meta | 8 + .../Enemies/Enemy_CrystalGolem_Red.prefab | 16 +- .../Enemies/Enemy_CrystalGolem_Yellow.prefab | 14 ++ .../Enemies/Enemy_Cyclops_Green.prefab | 18 +- .../Prefabs/Enemies/Enemy_Cyclops_Grey.prefab | 14 ++ .../Prefabs/Enemies/Enemy_Cyclops_Red.prefab | 14 ++ .../Prefabs/Enemies/Enemy_Ent_Black.prefab | 14 ++ .../Prefabs/Enemies/Enemy_Ent_Green.prefab | 14 ++ .../Prefabs/Enemies/Enemy_Ent_Orange.prefab | 14 ++ .../Enemies/Enemy_UndeadDrake_Bone.prefab | 14 ++ Assets/_Project/Prefabs/Player/Player.prefab | 44 +++- Assets/_Project/Scenes/Levels/9Player.unity | 221 +++++++++++++++++- .../Gameplay/EncounterGoldEntryDrawer.cs | 122 ++++++++++ .../Gameplay/EncounterGoldEntryDrawer.cs.meta | 2 + .../Editor/Gameplay/WaveGoldEntryDrawer.cs | 112 --------- .../Gameplay/WaveGoldEntryDrawer.cs.meta | 2 - .../EnemyAbilities/EnemySpawnContext.cs | 30 ++- .../Scripts/Gameplay/EnemyDefinition.cs | 19 +- .../_Project/Scripts/Gameplay/EnemyHealth.cs | 6 +- .../Scripts/Gameplay/EnemyScalingConfig.cs | 85 +++++++ .../Gameplay/EnemyScalingConfig.cs.meta | 2 + .../_Project/Scripts/Gameplay/GoldConfig.cs | 149 ++++++------ .../Scripts/Gameplay/TowerPlacementManager.cs | 70 ++++++ .../Scripts/Gameplay/WaveDefinition.cs | 6 + .../_Project/Scripts/Gameplay/WaveManager.cs | 100 +++++++- .../Scripts/Gameplay/Waves/RunState.cs | 2 +- .../Scripts/Gameplay/Waves/WaveGroup.cs | 2 +- Assets/_Project/Scripts/UI/HUDController.cs | 117 +++++++++- Assets/_Project/UI/HUD.uss | 28 +++ Assets/_Project/UI/HUD.uxml | 4 + Docs/2.0_Setup_Checklist.md | 161 ++++++++++--- 154 files changed, 2608 insertions(+), 287 deletions(-) create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png create mode 100644 Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png.meta create mode 100644 Assets/_Project/Art/Sprites/PlayerDraftIcons.meta rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Spells.meta (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Spells/FireballSpell.png (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Spells/FireballSpell.png.meta (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Spells/SlowSpell.png (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Spells/SlowSpell.png.meta (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Towers.meta (100%) create mode 100644 Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower.meta create mode 100644 Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png create mode 100644 Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png.meta rename Assets/_Project/Art/Sprites/{Towers => PlayerDraftIcons/Towers/ArrowTower}/arrow.png (100%) rename Assets/_Project/Art/Sprites/{Towers => PlayerDraftIcons/Towers/ArrowTower}/arrow.png.meta (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Towers/siege.png (100%) rename Assets/_Project/Art/Sprites/{ => PlayerDraftIcons}/Towers/siege.png.meta (100%) create mode 100644 Assets/_Project/Art/Sprites/RaceIcons.meta rename Assets/_Project/Art/Sprites/{ => RaceIcons}/BloodAngels.jpg (100%) rename Assets/_Project/Art/Sprites/{ => RaceIcons}/BloodAngels.jpg.meta (100%) rename Assets/_Project/Art/Sprites/{ => RaceIcons}/Ultramarines.jpg (100%) rename Assets/_Project/Art/Sprites/{ => RaceIcons}/Ultramarines.jpg.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions.meta rename Assets/_Project/Definitions/{ => RunDefinitions}/Draft.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft.meta rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderEffect_GoldPerKill.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderEffect_GoldPerKill.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_Fireball.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_Fireball.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_SlowArea.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_SlowArea.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_SlowAreaUpgrade.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_SiegeCannon.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_SiegeCannon.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_TeslaCoil.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_TeslaCoil.asset.meta (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_Wall.asset (100%) rename Assets/_Project/Definitions/{Draft => RunDefinitions/Draft/PlayerDraft}/Draft_NewTower_Wall.asset.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset.meta rename Assets/_Project/Definitions/{ => RunDefinitions}/EnemyAbilities.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset.meta rename Assets/_Project/Definitions/{ => RunDefinitions}/EnemyAbilities/SplitOnDeathAbility.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/EnemyAbilities/SplitOnDeathAbility.asset.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset.meta rename Assets/_Project/Definitions/{ => RunDefinitions}/GoldConfig.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/GoldConfig.asset.meta (100%) create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset.meta create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset create mode 100644 Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset.meta rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave10Definition.asset (93%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave10Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave1Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave1Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave2Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave2Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave3Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave3Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave4Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave4Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave5Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave5Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave6Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave6Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave7Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave7Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave8Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave8Definition.asset.meta (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave9Definition.asset (100%) rename Assets/_Project/Definitions/{ => RunDefinitions}/Waves/Wave9Definition.asset.meta (100%) create mode 100644 Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset create mode 100644 Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset.meta create mode 100644 Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs create mode 100644 Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs.meta delete mode 100644 Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs delete mode 100644 Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs create mode 100644 Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs.meta diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons.meta new file mode 100644 index 0000000..4257bdb --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 42ccb4a3b6aaaab47b73147c8be319bb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png new file mode 100644 index 0000000..f2a24b8 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b119eb6ab2d6c01573bce480df9fdb6676449398f4afde46673c3c2c2710fcc +size 13324 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png.meta new file mode 100644 index 0000000..8523ae6 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/BlinkIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: df5903159b157404d8e92942029f08f7 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png new file mode 100644 index 0000000..6503be4 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5569ff4e8d0780e2d79dfb9575f34856d59cb99ac751dd4f8caf4c2219eb7976 +size 8730 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png.meta new file mode 100644 index 0000000..eef1834 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/DoubleUpIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: e81c6ebff6b98ac44b884d3006611ae6 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png new file mode 100644 index 0000000..7ef1d14 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4779891d6890994436e47c586b11768d6a8eead32c4e740f4c99713598783fea +size 15796 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png.meta new file mode 100644 index 0000000..eb15613 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/FlightIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 39ac5850677a092479550234de13e868 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png new file mode 100644 index 0000000..ffb3b3e --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e2d28ff550b9a9e83fe317c6c692115d3f5ef27f20f9aa9220e7f5aeb91768f +size 15522 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png.meta new file mode 100644 index 0000000..42c8511 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/NoBountyIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: ea9802d37489c9442ba2ffd4a41c6cb2 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png new file mode 100644 index 0000000..e57b4db --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f0919f710e6673a55f44b1dbd55a16e19105fa5fe7d643330a4baea97eff095 +size 19587 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png.meta new file mode 100644 index 0000000..57b7944 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/SplitIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 43490fdb6e2477643a275956ce5f4596 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png b/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png new file mode 100644 index 0000000..92cd276 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90a7787cfc6120d1e6816d59a523093250f0f21200d3ad29fc6c99974837a7ef +size 18309 diff --git a/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png.meta b/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png.meta new file mode 100644 index 0000000..6198141 --- /dev/null +++ b/Assets/_Project/Art/Sprites/EnemyDraftIcons/ThiefIcon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 315be3feae908714a911f596b8533317 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/PlayerDraftIcons.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons.meta new file mode 100644 index 0000000..16c1caf --- /dev/null +++ b/Assets/_Project/Art/Sprites/PlayerDraftIcons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 319f59716d010174aa75cc757855ffd3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/Spells.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Spells.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells.meta diff --git a/Assets/_Project/Art/Sprites/Spells/FireballSpell.png b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/FireballSpell.png similarity index 100% rename from Assets/_Project/Art/Sprites/Spells/FireballSpell.png rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/FireballSpell.png diff --git a/Assets/_Project/Art/Sprites/Spells/FireballSpell.png.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/FireballSpell.png.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Spells/FireballSpell.png.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/FireballSpell.png.meta diff --git a/Assets/_Project/Art/Sprites/Spells/SlowSpell.png b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/SlowSpell.png similarity index 100% rename from Assets/_Project/Art/Sprites/Spells/SlowSpell.png rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/SlowSpell.png diff --git a/Assets/_Project/Art/Sprites/Spells/SlowSpell.png.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/SlowSpell.png.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Spells/SlowSpell.png.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Spells/SlowSpell.png.meta diff --git a/Assets/_Project/Art/Sprites/Towers.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Towers.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers.meta diff --git a/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower.meta new file mode 100644 index 0000000..3e3ff27 --- /dev/null +++ b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 06352dd9c76072941bbe7ad909c5f2f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png new file mode 100644 index 0000000..d7e8a96 --- /dev/null +++ b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e1b3dd4114b2639901392675da17a1c33146aa112157663fa13cf1dbb718cf4 +size 7076 diff --git a/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png.meta new file mode 100644 index 0000000..bbe72c7 --- /dev/null +++ b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/ArrowTowerUpgrade_1.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a140f62a10b8dc04e84625f8008e0c24 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 0 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/Towers/arrow.png b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/arrow.png similarity index 100% rename from Assets/_Project/Art/Sprites/Towers/arrow.png rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/arrow.png diff --git a/Assets/_Project/Art/Sprites/Towers/arrow.png.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/arrow.png.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Towers/arrow.png.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/ArrowTower/arrow.png.meta diff --git a/Assets/_Project/Art/Sprites/Towers/siege.png b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/siege.png similarity index 100% rename from Assets/_Project/Art/Sprites/Towers/siege.png rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/siege.png diff --git a/Assets/_Project/Art/Sprites/Towers/siege.png.meta b/Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/siege.png.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Towers/siege.png.meta rename to Assets/_Project/Art/Sprites/PlayerDraftIcons/Towers/siege.png.meta diff --git a/Assets/_Project/Art/Sprites/RaceIcons.meta b/Assets/_Project/Art/Sprites/RaceIcons.meta new file mode 100644 index 0000000..361ea99 --- /dev/null +++ b/Assets/_Project/Art/Sprites/RaceIcons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 45b4fc03b527ce444a7c37579965d990 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Sprites/BloodAngels.jpg b/Assets/_Project/Art/Sprites/RaceIcons/BloodAngels.jpg similarity index 100% rename from Assets/_Project/Art/Sprites/BloodAngels.jpg rename to Assets/_Project/Art/Sprites/RaceIcons/BloodAngels.jpg diff --git a/Assets/_Project/Art/Sprites/BloodAngels.jpg.meta b/Assets/_Project/Art/Sprites/RaceIcons/BloodAngels.jpg.meta similarity index 100% rename from Assets/_Project/Art/Sprites/BloodAngels.jpg.meta rename to Assets/_Project/Art/Sprites/RaceIcons/BloodAngels.jpg.meta diff --git a/Assets/_Project/Art/Sprites/Ultramarines.jpg b/Assets/_Project/Art/Sprites/RaceIcons/Ultramarines.jpg similarity index 100% rename from Assets/_Project/Art/Sprites/Ultramarines.jpg rename to Assets/_Project/Art/Sprites/RaceIcons/Ultramarines.jpg diff --git a/Assets/_Project/Art/Sprites/Ultramarines.jpg.meta b/Assets/_Project/Art/Sprites/RaceIcons/Ultramarines.jpg.meta similarity index 100% rename from Assets/_Project/Art/Sprites/Ultramarines.jpg.meta rename to Assets/_Project/Art/Sprites/RaceIcons/Ultramarines.jpg.meta diff --git a/Assets/_Project/Definitions/Enemies/01_Enemy_CrystalGolemBlue_Definition.asset b/Assets/_Project/Definitions/Enemies/01_Enemy_CrystalGolemBlue_Definition.asset index 5a7e5c4..39b2af6 100644 --- a/Assets/_Project/Definitions/Enemies/01_Enemy_CrystalGolemBlue_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/01_Enemy_CrystalGolemBlue_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 01_Enemy_CrystalGolemBlue_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Crystal Elemental (Young) - MaxHp: 100 - MoveSpeed: 3 + HpMultiplier: 1.15 + MoveSpeed: 2.6 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: 5839dc36140d67d4db94c4370a98ea2c, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/02_Enemy_CyclopsGrey_Definition.asset b/Assets/_Project/Definitions/Enemies/02_Enemy_CyclopsGrey_Definition.asset index b616fca..2bad6f2 100644 --- a/Assets/_Project/Definitions/Enemies/02_Enemy_CyclopsGrey_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/02_Enemy_CyclopsGrey_Definition.asset @@ -13,7 +13,7 @@ MonoBehaviour: m_Name: 02_Enemy_CyclopsGrey_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Cyclops (Young) - MaxHp: 200 + HpMultiplier: 1.05 MoveSpeed: 3 IsFlying: 0 LivesCost: 1 diff --git a/Assets/_Project/Definitions/Enemies/03_Enemy_EntGreen_Definition.asset b/Assets/_Project/Definitions/Enemies/03_Enemy_EntGreen_Definition.asset index 48c0a11..6ca1cb8 100644 --- a/Assets/_Project/Definitions/Enemies/03_Enemy_EntGreen_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/03_Enemy_EntGreen_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 03_Enemy_EntGreen_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Ent (Juvenile) - MaxHp: 400 - MoveSpeed: 3 + HpMultiplier: 0.85 + MoveSpeed: 3.4 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: dc462b86bdb8136488da56eb84d88ab5, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/04_Enemy_CrystalGolemYellow_Definition.asset b/Assets/_Project/Definitions/Enemies/04_Enemy_CrystalGolemYellow_Definition.asset index 5129b58..ed14151 100644 --- a/Assets/_Project/Definitions/Enemies/04_Enemy_CrystalGolemYellow_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/04_Enemy_CrystalGolemYellow_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 04_Enemy_CrystalGolemYellow_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Crystal Elemental (Adolescent) - MaxHp: 500 - MoveSpeed: 3 + HpMultiplier: 1.25 + MoveSpeed: 2.4 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: d4ca85138dd41244c9b0ab29c945f064, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/05_Enemy_CyclopsGreen_Definition.asset b/Assets/_Project/Definitions/Enemies/05_Enemy_CyclopsGreen_Definition.asset index d516ea5..29dbef6 100644 --- a/Assets/_Project/Definitions/Enemies/05_Enemy_CyclopsGreen_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/05_Enemy_CyclopsGreen_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 05_Enemy_CyclopsGreen_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Cyclops (Adult) - MaxHp: 600 - MoveSpeed: 3 + HpMultiplier: 1.1 + MoveSpeed: 2.9 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: 525e51e0dfac75744b39e4609b9245f9, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/06_Enemy_EntBlack_Definition.asset b/Assets/_Project/Definitions/Enemies/06_Enemy_EntBlack_Definition.asset index dc74753..cd153bb 100644 --- a/Assets/_Project/Definitions/Enemies/06_Enemy_EntBlack_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/06_Enemy_EntBlack_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 06_Enemy_EntBlack_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Ent (Mature) - MaxHp: 700 - MoveSpeed: 3 + HpMultiplier: 0.9 + MoveSpeed: 3.3 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: c3cae952216a68b458b9a934d89b8be8, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/07_Enemy_CrystalGolemRed_Definition.asset b/Assets/_Project/Definitions/Enemies/07_Enemy_CrystalGolemRed_Definition.asset index 9b8a5b3..c0b1005 100644 --- a/Assets/_Project/Definitions/Enemies/07_Enemy_CrystalGolemRed_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/07_Enemy_CrystalGolemRed_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 07_Enemy_CrystalGolemRed_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Crystal Elemental (Mature) - MaxHp: 800 - MoveSpeed: 3 + HpMultiplier: 1.35 + MoveSpeed: 2.2 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: 2f13fef371bd3464694526ff99edef03, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/08_Enemy_CyclopsRed_Definition.asset b/Assets/_Project/Definitions/Enemies/08_Enemy_CyclopsRed_Definition.asset index 7ca2962..a71ed1a 100644 --- a/Assets/_Project/Definitions/Enemies/08_Enemy_CyclopsRed_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/08_Enemy_CyclopsRed_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 08_Enemy_CyclopsRed_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Cyclops (Enraged) - MaxHp: 900 - MoveSpeed: 3 + HpMultiplier: 1.2 + MoveSpeed: 2.8 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: 729e11f4ecd916d4fbeaf26dbbae8b7d, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/09_Enemy_EntOrange_Definition.asset b/Assets/_Project/Definitions/Enemies/09_Enemy_EntOrange_Definition.asset index e9d57fa..e2d0b3b 100644 --- a/Assets/_Project/Definitions/Enemies/09_Enemy_EntOrange_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/09_Enemy_EntOrange_Definition.asset @@ -13,8 +13,8 @@ MonoBehaviour: m_Name: 09_Enemy_EntOrange_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition DisplayName: Ent (Ancient) - MaxHp: 1000 - MoveSpeed: 3 + HpMultiplier: 0.95 + MoveSpeed: 3.2 IsFlying: 0 LivesCost: 1 EnemyPrefab: {fileID: 1455822126534880203, guid: d6eed12cf96ab194ab8d35359e3bc8ee, type: 3} diff --git a/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset b/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset index 31d1eec..af46c2c 100644 --- a/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset +++ b/Assets/_Project/Definitions/Enemies/10_Enemy_UndeadDrakeBone_Definition.asset @@ -12,9 +12,9 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: c0d2521d49d21fe4380434f5951944d1, type: 3} m_Name: 10_Enemy_UndeadDrakeBone_Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyDefinition - DisplayName: Ent (Ancient) - MaxHp: 800 - MoveSpeed: 4 + DisplayName: Drake + HpMultiplier: 0.7 + MoveSpeed: 4.5 IsFlying: 1 FlightHeight: 3 LivesCost: 1 diff --git a/Assets/_Project/Definitions/RunDefinitions.meta b/Assets/_Project/Definitions/RunDefinitions.meta new file mode 100644 index 0000000..d07862f --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d1d72155425add944a007932c121c951 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Draft.meta b/Assets/_Project/Definitions/RunDefinitions/Draft.meta similarity index 100% rename from Assets/_Project/Definitions/Draft.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft.meta diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft.meta new file mode 100644 index 0000000..359e56c --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 465572754aa5c7e48b1c00843f1b5d7c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset new file mode 100644 index 0000000..98bd078 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d075c66b381872a4781b3916296ccdc9, type: 3} + m_Name: EnemyUpgrade_Blink + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.AbilityEnemyUpgradeOption + DisplayName: Blink + Description: Enemies occasionally teleport a short distance ahead. + Icon: {fileID: 21300000, guid: df5903159b157404d8e92942029f08f7, type: 3} + Ability: {fileID: 11400000, guid: 030f10c9a264e544ea3e0f4fdb4171bd, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset.meta new file mode 100644 index 0000000..6570ea6 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Blink.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eef28ca7b8844504e9d75ff903a3bb61 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset new file mode 100644 index 0000000..daa0979 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f5e90fe6b8e439e4f99769a6923c2262, type: 3} + m_Name: EnemyUpgrade_DoubleUp + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.DoubleUpEnemyUpgradeOption + DisplayName: Double Dip + Description: Enemies from this wave gain no upgrades, but enemies from the next + wave have two upgrades selected automatically + Icon: {fileID: 21300000, guid: e81c6ebff6b98ac44b884d3006611ae6, type: 3} + BuffsToAutoApply: 2 diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset.meta new file mode 100644 index 0000000..db74d57 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_DoubleUp.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 46e2a6a59c0a3034f9fa44681d9ba422 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset new file mode 100644 index 0000000..5379694 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d075c66b381872a4781b3916296ccdc9, type: 3} + m_Name: EnemyUpgrade_Flight + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.AbilityEnemyUpgradeOption + DisplayName: Flight + Description: Enemies gain the power of flight + Icon: {fileID: 21300000, guid: 39ac5850677a092479550234de13e868, type: 3} + Ability: {fileID: 11400000, guid: cb2ec6bb70f57d0429b99b7cd4c7db73, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset.meta new file mode 100644 index 0000000..c49dfc4 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Flight.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 80fb851b702ff304183cb6e89c4aa245 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset new file mode 100644 index 0000000..58af079 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d075c66b381872a4781b3916296ccdc9, type: 3} + m_Name: EnemyUpgrade_NoBounty + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.AbilityEnemyUpgradeOption + DisplayName: Peniless + Description: Enemies no longer drop gold upon death + Icon: {fileID: 21300000, guid: ea9802d37489c9442ba2ffd4a41c6cb2, type: 3} + Ability: {fileID: 11400000, guid: c64c7121b0456bb4a873acec9e0abf91, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset.meta new file mode 100644 index 0000000..bea9c35 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_NoBounty.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a67aa3644d2ad4847a47040f53fea26f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset new file mode 100644 index 0000000..2351102 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d075c66b381872a4781b3916296ccdc9, type: 3} + m_Name: EnemyUpgrade_Split + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.AbilityEnemyUpgradeOption + DisplayName: Split on Death + Description: Enemies killed will spawn two more smaller, faster, weaker versions + of themselves. (This only triggers a single time per base enemy) + Icon: {fileID: 21300000, guid: 43490fdb6e2477643a275956ce5f4596, type: 3} + Ability: {fileID: 11400000, guid: 0305468bc8f912d429a8b20bf23f947c, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset.meta new file mode 100644 index 0000000..ca2e9c2 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Split.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f6bfb033a15b6d0419587b80a8286bd1 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset new file mode 100644 index 0000000..e0f0515 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d075c66b381872a4781b3916296ccdc9, type: 3} + m_Name: EnemyUpgrade_Theft + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.AbilityEnemyUpgradeOption + DisplayName: Thief + Description: Enemies that leak steal 15 gold on their way out + Icon: {fileID: 21300000, guid: 315be3feae908714a911f596b8533317, type: 3} + Ability: {fileID: 11400000, guid: a18691a20d75f17479a83b3e171759e7, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset.meta new file mode 100644 index 0000000..f4847ca --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/EnemyDraft/EnemyUpgrade_Theft.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 37d761966a349ba4cb4141f913224d5c +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft.meta new file mode 100644 index 0000000..18e2f50 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4a6f4d6ffa10ef44da3ac0dd7c7734db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderEffect_GoldPerKill.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderEffect_GoldPerKill.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderEffect_GoldPerKill.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderEffect_GoldPerKill.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderEffect_GoldPerKill.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderEffect_GoldPerKill.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderEffect_GoldPerKill.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderEffect_GoldPerKill.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_Fireball.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_Fireball.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_Fireball.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_Fireball.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_Fireball.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowArea.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowArea.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowArea.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowArea.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowArea.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_BuilderSpellOption_SlowAreaUpgrade.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_SiegeCannon.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_SiegeCannon.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_SiegeCannon.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_SiegeCannon.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_SiegeCannon.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_SiegeCannon.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_SiegeCannon.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_SiegeCannon.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_TeslaCoil.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_TeslaCoil.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_TeslaCoil.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_TeslaCoil.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_TeslaCoil.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_TeslaCoil.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_TeslaCoil.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_TeslaCoil.asset.meta diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_Wall.asset similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_Wall.asset diff --git a/Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_Wall.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Draft/Draft_NewTower_Wall.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_NewTower_Wall.asset.meta diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset new file mode 100644 index 0000000..016e045 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset @@ -0,0 +1,21 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: aeec22f152a57ef5798eae035c091543, type: 3} + m_Name: Draft_TowerUpgrade_Arrow_1 + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.TowerUpgradeDraftOption + DisplayName: Arrow Tower Upgrade 1 + Description: Unlock the ability to upgrade Arrow Towers with more damage and a + faster attack speed + Icon: {fileID: 21300000, guid: a140f62a10b8dc04e84625f8008e0c24, type: 3} + Weight: 1 + BaseTower: {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + UpgradedTower: {fileID: 11400000, guid: 86d4c3ab00d4c654eb4c255280ee5701, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset.meta new file mode 100644 index 0000000..1009def --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/Draft/PlayerDraft/Draft_TowerUpgrade_Arrow_1.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0b9f4905752751f44b9959b84b620266 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/EnemyAbilities.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities.meta similarity index 100% rename from Assets/_Project/Definitions/EnemyAbilities.meta rename to Assets/_Project/Definitions/RunDefinitions/EnemyAbilities.meta diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset new file mode 100644 index 0000000..6cc7cf7 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4fbb09f55384ac44399ac4655b2b53ec, type: 3} + m_Name: BlinkAbility + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.BlinkAbilityDefinition + DisplayName: Blink + Description: Enemies occasionally teleport a short distance ahead. + IntervalSeconds: 4 + BlinkWaypoints: 2 + StartJitterSeconds: 1.5 diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset.meta new file mode 100644 index 0000000..78c2c0b --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/BlinkAbility.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 030f10c9a264e544ea3e0f4fdb4171bd +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset new file mode 100644 index 0000000..725338e --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: be77d353621f07e40bbdab2e9cc37f51, type: 3} + m_Name: FlightAbility + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.FlightAbilityDefinition + DisplayName: Flight + Description: Enemies gain flying + FlightHeight: 3 + SpeedMultiplier: 1 diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset.meta new file mode 100644 index 0000000..0b5d052 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/FlightAbility.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cb2ec6bb70f57d0429b99b7cd4c7db73 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset new file mode 100644 index 0000000..fc16c66 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset @@ -0,0 +1,17 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3b770ce042b78f44d9a3df537fe25db0, type: 3} + m_Name: GoldTheftAbility + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.GoldTheftAbilityDefinition + DisplayName: Thief + Description: Enemies that leak out of a player's maze steal 15 gold as they go. + GoldStolen: 15 diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset.meta new file mode 100644 index 0000000..11247d1 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/GoldTheftAbility.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a18691a20d75f17479a83b3e171759e7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset new file mode 100644 index 0000000..0231479 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset @@ -0,0 +1,17 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fd5b12948b24e9c4c8840ef3470b437e, type: 3} + m_Name: NoBountyAbility + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.NoBountyAbilityDefinition + DisplayName: Penniless + Description: Enemies no longer drop gold on death + BountyMultiplier: 0 diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset.meta new file mode 100644 index 0000000..8aede82 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/NoBountyAbility.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c64c7121b0456bb4a873acec9e0abf91 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/SplitOnDeathAbility.asset similarity index 100% rename from Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset rename to Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/SplitOnDeathAbility.asset diff --git a/Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/SplitOnDeathAbility.asset.meta similarity index 100% rename from Assets/_Project/Definitions/EnemyAbilities/SplitOnDeathAbility.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/EnemyAbilities/SplitOnDeathAbility.asset.meta diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset b/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset new file mode 100644 index 0000000..509a039 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset @@ -0,0 +1,17 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: de25a6333a11d074280b5ade557aa8b9, type: 3} + m_Name: EnemyScalingConfig + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyScalingConfig + BaseHpBudget: 5000 + GrowthPerEncounter: 1.18 + BossHpMultiplier: 1.2 diff --git a/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset.meta b/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset.meta new file mode 100644 index 0000000..377b8a5 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/EnemyScalingConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ae9fde0b564e20440ac2e20861fa1ca6 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/GoldConfig.asset b/Assets/_Project/Definitions/RunDefinitions/GoldConfig.asset similarity index 100% rename from Assets/_Project/Definitions/GoldConfig.asset rename to Assets/_Project/Definitions/RunDefinitions/GoldConfig.asset diff --git a/Assets/_Project/Definitions/GoldConfig.asset.meta b/Assets/_Project/Definitions/RunDefinitions/GoldConfig.asset.meta similarity index 100% rename from Assets/_Project/Definitions/GoldConfig.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/GoldConfig.asset.meta diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure.meta new file mode 100644 index 0000000..5a3abf7 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 11ba3723e03690b47a4f2518bfc68a62 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset b/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset new file mode 100644 index 0000000..8691b9a --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset @@ -0,0 +1,28 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4202a767d96e70f41b6a7e84eb08a832, type: 3} + m_Name: EnemyUpgradeGroup_Phase1_Basics + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.EnemyUpgradeGroup + DisplayName: Upgrade Group Phase 1 + Options: + - Option: {fileID: 11400000, guid: eef28ca7b8844504e9d75ff903a3bb61, type: 2} + Weight: 1 + - Option: {fileID: 11400000, guid: 46e2a6a59c0a3034f9fa44681d9ba422, type: 2} + Weight: 1 + - Option: {fileID: 11400000, guid: 80fb851b702ff304183cb6e89c4aa245, type: 2} + Weight: 1 + - Option: {fileID: 11400000, guid: a67aa3644d2ad4847a47040f53fea26f, type: 2} + Weight: 1 + - Option: {fileID: 11400000, guid: f6bfb033a15b6d0419587b80a8286bd1, type: 2} + Weight: 1 + - Option: {fileID: 11400000, guid: 37d761966a349ba4cb4141f913224d5c, type: 2} + Weight: 1 diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset.meta new file mode 100644 index 0000000..04f87c2 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/EnemyUpgradeGroup_Phase1_Basics.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3dfcbf5323c0a184c84f6b9e80e37257 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset new file mode 100644 index 0000000..34c811a --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset @@ -0,0 +1,21 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 8f73fcf0261654d44a6d0939bdc9b88f, type: 3} + m_Name: Phase1 + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Waves.PhaseDefinition + DisplayName: Phase 1 + WaveGroups: + - {fileID: 11400000, guid: 80eaa5ec98fd5df4e950131a8984fd31, type: 2} + BossGroups: + - {fileID: 11400000, guid: 2981ffffb36dfc440aa4517c17cfbbf4, type: 2} + EnemyUpgradeGroups: + - {fileID: 11400000, guid: 3dfcbf5323c0a184c84f6b9e80e37257, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset.meta new file mode 100644 index 0000000..7fea787 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Phase1.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1d4f04f995c4c3b44be6e5f9d0f6e3f8 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset new file mode 100644 index 0000000..c77b032 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2b73bb9876b7fdb4480b13ff6bb8bd67, type: 3} + m_Name: Test_Run_Definition + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Waves.RunDefinition + WavesPerCycle: 5 + CyclesPerPhase: 3 + Phases: + - {fileID: 11400000, guid: 1d4f04f995c4c3b44be6e5f9d0f6e3f8, type: 2} diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset.meta new file mode 100644 index 0000000..bcf6074 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/Test_Run_Definition.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bb81a966192c344d94cce2628c1832b +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset new file mode 100644 index 0000000..2e739af --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset @@ -0,0 +1,18 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5625bd25f378bda429666820a4940cee, type: 3} + m_Name: WaveGroup_Phase1_Bosses + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Waves.WaveGroup + DisplayName: Phase 1 Bosses + Waves: + - Wave: {fileID: 11400000, guid: 4db677d2940202340841471f90a5b73a, type: 2} + Weight: 1 diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset.meta new file mode 100644 index 0000000..174e980 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Bosses.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2981ffffb36dfc440aa4517c17cfbbf4 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset new file mode 100644 index 0000000..265c492 --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset @@ -0,0 +1,34 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5625bd25f378bda429666820a4940cee, type: 3} + m_Name: WaveGroup_Phase1_Normal + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Waves.WaveGroup + DisplayName: + Waves: + - Wave: {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2} + Weight: 1 + - Wave: {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2} + Weight: 1 + - Wave: {fileID: 11400000, guid: 39921b44a1a0a56478200028940c5202, type: 2} + Weight: 1 + - Wave: {fileID: 11400000, guid: 50f498bc5bfc46e44b064cc96403e2cb, type: 2} + Weight: 1 + - Wave: {fileID: 11400000, guid: 9ee6dee12f0660844b4d46880f01c02f, type: 2} + Weight: 0.5 + - Wave: {fileID: 11400000, guid: 41231de63e8f25d448f19f3816e0c22f, type: 2} + Weight: 0.5 + - Wave: {fileID: 11400000, guid: 6290df178cbd3144aa92a0f09833a7be, type: 2} + Weight: 0.5 + - Wave: {fileID: 11400000, guid: 86736fd52c18fa84e8ced40f30b514fa, type: 2} + Weight: 0.5 + - Wave: {fileID: 11400000, guid: 8fdf53cfc405a5f41a00f376198b8d84, type: 2} + Weight: 0.5 diff --git a/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset.meta b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset.meta new file mode 100644 index 0000000..c2de13d --- /dev/null +++ b/Assets/_Project/Definitions/RunDefinitions/RunStructure/WaveGroup_Phase1_Normal.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 80eaa5ec98fd5df4e950131a8984fd31 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Definitions/Waves.meta b/Assets/_Project/Definitions/RunDefinitions/Waves.meta similarity index 100% rename from Assets/_Project/Definitions/Waves.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves.meta diff --git a/Assets/_Project/Definitions/Waves/Wave10Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave10Definition.asset similarity index 93% rename from Assets/_Project/Definitions/Waves/Wave10Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave10Definition.asset index eb49ceb..1bc8617 100644 --- a/Assets/_Project/Definitions/Waves/Wave10Definition.asset +++ b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave10Definition.asset @@ -13,7 +13,7 @@ MonoBehaviour: m_Name: Wave10Definition m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.WaveDefinition PrepTime: 10 + ReleaseInterval: 2 Entries: - EnemyType: {fileID: 11400000, guid: 1d9809612c00ed049848a955613a4619, type: 2} - Count: 50 - SpawnInterval: 0.5 + Count: 5 diff --git a/Assets/_Project/Definitions/Waves/Wave10Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave10Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave10Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave10Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave1Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave1Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave1Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave1Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave1Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave1Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave1Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave1Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave2Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave2Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave2Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave2Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave2Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave2Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave2Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave2Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave3Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave3Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave3Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave3Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave3Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave3Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave3Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave3Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave4Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave4Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave4Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave4Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave4Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave4Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave4Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave4Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave5Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave5Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave5Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave5Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave5Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave5Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave5Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave5Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave6Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave6Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave6Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave6Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave6Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave6Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave6Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave6Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave7Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave7Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave7Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave7Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave7Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave7Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave7Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave7Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave8Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave8Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave8Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave8Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave8Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave8Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave8Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave8Definition.asset.meta diff --git a/Assets/_Project/Definitions/Waves/Wave9Definition.asset b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave9Definition.asset similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave9Definition.asset rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave9Definition.asset diff --git a/Assets/_Project/Definitions/Waves/Wave9Definition.asset.meta b/Assets/_Project/Definitions/RunDefinitions/Waves/Wave9Definition.asset.meta similarity index 100% rename from Assets/_Project/Definitions/Waves/Wave9Definition.asset.meta rename to Assets/_Project/Definitions/RunDefinitions/Waves/Wave9Definition.asset.meta diff --git a/Assets/_Project/Definitions/Towers/BasicArrowTower.asset b/Assets/_Project/Definitions/Towers/BasicArrowTower.asset index 3a1c5c4..d39d8d9 100644 --- a/Assets/_Project/Definitions/Towers/BasicArrowTower.asset +++ b/Assets/_Project/Definitions/Towers/BasicArrowTower.asset @@ -17,8 +17,12 @@ MonoBehaviour: backbone of any maze. FootprintSize: {x: 2, y: 2} GoldCost: 25 + SellRefundPercent: 0.75 + FullRefundIfUnupgraded: 0 BuildTime: 0.5 TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} + ConstructionPhases: {fileID: 0} + Icon: {fileID: 21300000, guid: afe216737d1b46e783889299bda1c252, type: 3} DamageType: 0 TargetPriority: 0 TargetType: 0 @@ -34,5 +38,5 @@ MonoBehaviour: EffectDuration: 0 ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3} ProjectileSpeed: 15 - UpgradePaths: [] - Icon: {fileID: 21300000, guid: afe216737d1b46e783889299bda1c252, type: 3} + UpgradePaths: + - {fileID: 11400000, guid: 86d4c3ab00d4c654eb4c255280ee5701, type: 2} diff --git a/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset b/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset new file mode 100644 index 0000000..226afff --- /dev/null +++ b/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset @@ -0,0 +1,41 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7b353a757b6e6774d97e6fb8ba138fcc, type: 3} + m_Name: BasicArrowTower_Upgrade_1 + m_EditorClassIdentifier: Assembly-CSharp::TD.Towers.TowerDefinition + DisplayName: Basic Arrow Tower + Description: Single-target tower that fires arrows at ground and air enemies. The + backbone of any maze. + FootprintSize: {x: 2, y: 2} + GoldCost: 50 + SellRefundPercent: 0.75 + FullRefundIfUnupgraded: 0 + BuildTime: 1.5 + TowerPrefab: {fileID: 6482414459531823157, guid: 1511641f145758b469e64376d2a0d434, type: 3} + ConstructionPhases: {fileID: 0} + Icon: {fileID: 21300000, guid: a140f62a10b8dc04e84625f8008e0c24, type: 3} + DamageType: 0 + TargetPriority: 0 + TargetType: 0 + GroundedOnly: 0 + Damage: 15 + Range: 20 + AttacksPerSecond: 6 + SplashRadius: 0 + ChainCount: 0 + ChainRange: 0 + SlowFactor: 0 + DotDamagePerSecond: 0 + EffectDuration: 0 + ProjectilePrefab: {fileID: 2664719039363295382, guid: dc2e4a4108e03874a8b2dab88dcc8fba, type: 3} + ProjectileSpeed: 15 + UpgradePaths: [] diff --git a/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset.meta b/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset.meta new file mode 100644 index 0000000..facefe3 --- /dev/null +++ b/Assets/_Project/Definitions/Towers/BasicArrowTower_Upgrade_1.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 86d4c3ab00d4c654eb4c255280ee5701 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab index f183f6e..d912bec 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Red.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: 7515565462997402737} m_Layer: 10 m_Name: Enemy_CrystalGolem_Red m_TagString: Untagged @@ -52,7 +53,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 3715663997 + GlobalObjectIdHash: 3037368120 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &7515565462997402737 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab index 3144e35..bd7ac68 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_CrystalGolem_Yellow.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: -5265869660159140516} m_Layer: 10 m_Name: Enemy_CrystalGolem_Yellow m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &-5265869660159140516 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab index dfb934a..d721ee2 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Green.prefab @@ -17,6 +17,7 @@ GameObject: - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} - component: {fileID: -4695266156693656620} + - component: {fileID: -7800194977638654489} m_Layer: 10 m_Name: Enemy_Cyclops_Green m_TagString: Untagged @@ -215,9 +216,20 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 9df6fea9a86daafae998f8eecc44ee7d, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Combat.EnemyDeathSound - deathClips: - - {fileID: 8300000, guid: e007e19b827137ce395021d825927dcc, type: 3} - - {fileID: 8300000, guid: 6cea30689732db2f0a8989fe8f1a07a9, type: 3} + deathSounds: [] +--- !u!114 &-7800194977638654489 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab index 055ef54..89ac836 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Grey.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: -5218620239817511880} m_Layer: 10 m_Name: Enemy_Cyclops_Grey m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &-5218620239817511880 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab index 5a0e904..f5ace60 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Cyclops_Red.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: -7009934292190590155} m_Layer: 10 m_Name: Enemy_Cyclops_Red m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &-7009934292190590155 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab index 0e12a01..60b1291 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Black.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: 3080180597758813642} m_Layer: 10 m_Name: Enemy_Ent_Black m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &3080180597758813642 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab index eeaca4c..2ab5755 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Green.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: -2371779262468771447} m_Layer: 10 m_Name: Enemy_Ent_Green m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &-2371779262468771447 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab index 9b366bb..bca0e4a 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_Ent_Orange.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: 2883007611589499530} m_Layer: 10 m_Name: Enemy_Ent_Orange m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &2883007611589499530 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab b/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab index 8acc7db..ef51e96 100644 --- a/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab +++ b/Assets/_Project/Prefabs/Enemies/Enemy_UndeadDrake_Bone.prefab @@ -16,6 +16,7 @@ GameObject: - component: {fileID: 3283904430289710888} - component: {fileID: 3756613737869398948} - component: {fileID: 6036713598566100742} + - component: {fileID: 5647470509798016640} m_Layer: 10 m_Name: Enemy_UndeadDrake_Bone m_TagString: Untagged @@ -202,6 +203,19 @@ CapsuleCollider: m_Height: 2 m_Direction: 1 m_Center: {x: 0, y: 1, z: 0} +--- !u!114 &5647470509798016640 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1455822126534880203} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9d1a26a32e8969cd29bce6010126a5f6, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbility + ShowTopMostFoldoutHeaderGroup: 1 --- !u!1 &5361867751622119598 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab index ce17790..240ec88 100644 --- a/Assets/_Project/Prefabs/Player/Player.prefab +++ b/Assets/_Project/Prefabs/Player/Player.prefab @@ -18,6 +18,9 @@ GameObject: - component: {fileID: 5683786710272852339} - component: {fileID: 6856236869205671849} - component: {fileID: -4438158474568445526} + - component: {fileID: 2871545571532934715} + - component: {fileID: 1298503032983562452} + - component: {fileID: 8755797571419628899} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -52,7 +55,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Name: m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject - GlobalObjectIdHash: 1552073510 + GlobalObjectIdHash: 121878297 InScenePlacedSourceGlobalObjectIdHash: 0 DeferredDespawnTick: 0 Ownership: 1 @@ -171,3 +174,42 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DebugCommandRelay ShowTopMostFoldoutHeaderGroup: 1 +--- !u!114 &2871545571532934715 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3493329038866903420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f60f6928562351f4891142ad59ab6352, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerTowerUpgrades + ShowTopMostFoldoutHeaderGroup: 1 +--- !u!114 &1298503032983562452 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3493329038866903420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b7e4c91a08f5d2461a3f8e6c25b09d74, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerTowerDeck + ShowTopMostFoldoutHeaderGroup: 1 +--- !u!114 &8755797571419628899 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3493329038866903420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2b8f4d1e6c3a497051d8e2f7a9c1b063, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Draft.PlayerDraft + ShowTopMostFoldoutHeaderGroup: 1 diff --git a/Assets/_Project/Scenes/Levels/9Player.unity b/Assets/_Project/Scenes/Levels/9Player.unity index dd7bef0..a08f1d5 100644 --- a/Assets/_Project/Scenes/Levels/9Player.unity +++ b/Assets/_Project/Scenes/Levels/9Player.unity @@ -2307,7 +2307,10 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyAbilities.EnemyAbilityPool abilities: - {fileID: 11400000, guid: 0305468bc8f912d429a8b20bf23f947c, type: 2} - noAbilityWeight: 100 + - {fileID: 11400000, guid: 030f10c9a264e544ea3e0f4fdb4171bd, type: 2} + - {fileID: 11400000, guid: cb2ec6bb70f57d0429b99b7cd4c7db73, type: 2} + - {fileID: 11400000, guid: a18691a20d75f17479a83b3e171759e7, type: 2} + - {fileID: 11400000, guid: c64c7121b0456bb4a873acec9e0abf91, type: 2} --- !u!4 &181002641 Transform: m_ObjectHideFlags: 0 @@ -13016,6 +13019,57 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 24, y: 1, z: 30} m_Center: {x: 0, y: 0, z: 0} +--- !u!1 &875833597 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 875833599} + - component: {fileID: 875833598} + m_Layer: 0 + m_Name: EnemyUpgradePool + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &875833598 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 875833597} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c4d98a01d9b9a7b438a6f91b0b7f498f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.EnemyUpgradePool + options: + - {fileID: 11400000, guid: eef28ca7b8844504e9d75ff903a3bb61, type: 2} + - {fileID: 11400000, guid: 46e2a6a59c0a3034f9fa44681d9ba422, type: 2} + - {fileID: 11400000, guid: 80fb851b702ff304183cb6e89c4aa245, type: 2} + - {fileID: 11400000, guid: a67aa3644d2ad4847a47040f53fea26f, type: 2} + - {fileID: 11400000, guid: 37d761966a349ba4cb4141f913224d5c, type: 2} + - {fileID: 11400000, guid: f6bfb033a15b6d0419587b80a8286bd1, type: 2} +--- !u!4 &875833599 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 875833597} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 38.30481, y: 0.5, z: 155.93135} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &902199259 GameObject: m_ObjectHideFlags: 0 @@ -13942,6 +13996,78 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 17, y: 1, z: 13} m_Center: {x: 0, y: 0, z: 0} +--- !u!1 &961493951 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 961493954} + - component: {fileID: 961493952} + - component: {fileID: 961493953} + m_Layer: 0 + m_Name: RunState + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &961493952 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961493951} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject + GlobalObjectIdHash: 3660389945 + InScenePlacedSourceGlobalObjectIdHash: 0 + DeferredDespawnTick: 0 + Ownership: 1 + AlwaysReplicateAsRoot: 0 + SynchronizeTransform: 1 + ActiveSceneSynchronization: 0 + SceneMigrationSynchronization: 0 + SpawnWithObservers: 1 + DontDestroyWithOwner: 0 + AutoObjectParentSync: 1 + SyncOwnerTransformWhenParented: 1 + AllowOwnerToParent: 0 +--- !u!114 &961493953 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961493951} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a293cf6dca91dd2478c9f4e799cf9e0d, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.Waves.RunState + ShowTopMostFoldoutHeaderGroup: 1 + runDefinition: {fileID: 11400000, guid: 1bb81a966192c344d94cce2628c1832b, type: 2} +--- !u!4 &961493954 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961493951} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 38.30481, y: 0.5, z: 155.93135} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &993870443 GameObject: m_ObjectHideFlags: 0 @@ -14522,6 +14648,7 @@ MonoBehaviour: placementManager: {fileID: 1507514108} cameraController: {fileID: 1239994223} rejectionMessageDuration: 2.5 + menuButtonIcon: {fileID: 0} chatMaxHeight: 280 chatMaxMessages: 2147483647 chatSystemColor: {r: 1, g: 0.7, b: 0.2, a: 1} @@ -18307,19 +18434,11 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.WaveManager ShowTopMostFoldoutHeaderGroup: 1 - waveDefinitions: - - {fileID: 11400000, guid: 65f66289ea1233b4897f46cd997d9c7a, type: 2} - - {fileID: 11400000, guid: 190e39db44aa0794aa808fd60976f7c4, type: 2} - - {fileID: 11400000, guid: 39921b44a1a0a56478200028940c5202, type: 2} - - {fileID: 11400000, guid: 50f498bc5bfc46e44b064cc96403e2cb, type: 2} - - {fileID: 11400000, guid: 9ee6dee12f0660844b4d46880f01c02f, type: 2} - - {fileID: 11400000, guid: 41231de63e8f25d448f19f3816e0c22f, type: 2} - - {fileID: 11400000, guid: 6290df178cbd3144aa92a0f09833a7be, type: 2} - - {fileID: 11400000, guid: 86736fd52c18fa84e8ced40f30b514fa, type: 2} - - {fileID: 11400000, guid: 8fdf53cfc405a5f41a00f376198b8d84, type: 2} - - {fileID: 11400000, guid: 4db677d2940202340841471f90a5b73a, type: 2} startingLives: 40 + draftTimeSeconds: 30 + voteTimeSeconds: 25 goldConfig: {fileID: 11400000, guid: d5e01c919f14a1e4888ad494a159241b, type: 2} + enemyScalingConfig: {fileID: 11400000, guid: ae9fde0b564e20440ac2e20861fa1ca6, type: 2} --- !u!43 &1393870843 Mesh: m_ObjectHideFlags: 0 @@ -20189,6 +20308,7 @@ MonoBehaviour: - {fileID: 0} - {fileID: 11400000, guid: d5b2e8fa3c7e9b5f4a1d0c2e7f6b3a91, type: 2} - {fileID: 11400000, guid: 0f693e29ca953e1439e10cb8f12e4b30, type: 2} + - {fileID: 11400000, guid: 86d4c3ab00d4c654eb4c255280ee5701, type: 2} - {fileID: 11400000, guid: 04219895bbea4fb9a94fd62fef35bf2a, type: 2} - {fileID: 11400000, guid: e3f1a9c2b5d84e76a0c3f1e8d27b4905, type: 2} startingDeck: @@ -22897,6 +23017,7 @@ MonoBehaviour: m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DevWaveControls hotkey: 94 grantTowerHotkey: 101 + skipToBossHotkey: 100 --- !u!4 &1731269687 Transform: m_ObjectHideFlags: 0 @@ -27956,6 +28077,78 @@ Mesh: - serializedVersion: 1 m_IndexStart: 0 m_IndexCount: 0 +--- !u!1 &2021000666 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2021000669} + - component: {fileID: 2021000667} + - component: {fileID: 2021000668} + m_Layer: 0 + m_Name: WaveVote + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &2021000667 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2021000666} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject + GlobalObjectIdHash: 3520766009 + InScenePlacedSourceGlobalObjectIdHash: 0 + DeferredDespawnTick: 0 + Ownership: 1 + AlwaysReplicateAsRoot: 0 + SynchronizeTransform: 1 + ActiveSceneSynchronization: 0 + SceneMigrationSynchronization: 0 + SpawnWithObservers: 1 + DontDestroyWithOwner: 0 + AutoObjectParentSync: 1 + SyncOwnerTransformWhenParented: 1 + AllowOwnerToParent: 0 +--- !u!114 &2021000668 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2021000666} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a18321dc2d138034fbdb5f705f19f893, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.EnemyUpgrades.WaveVote + ShowTopMostFoldoutHeaderGroup: 1 + optionsPerVote: 3 +--- !u!4 &2021000669 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2021000666} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 38.30481, y: 0.5, z: 155.93135} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!43 &2024389666 Mesh: m_ObjectHideFlags: 0 @@ -28831,6 +29024,7 @@ MonoBehaviour: options: - {fileID: 11400000, guid: 6b1d3f8a2c5e4097b8a1d0c3e6f9b240, type: 2} - {fileID: 11400000, guid: f4a2b8d1c6e93f05a7b2d4c8e1f6a930, type: 2} + - {fileID: 11400000, guid: 0b9f4905752751f44b9959b84b620266, type: 2} - {fileID: 11400000, guid: 65ff25c1c8a89f7df8e88f71968c1c98, type: 2} - {fileID: 11400000, guid: 5f390de69ba43b2a6a0d89cf09580320, type: 2} - {fileID: 11400000, guid: 29b35841f7e5db454903798cb5d83434, type: 2} @@ -29237,3 +29431,6 @@ SceneRoots: - {fileID: 759470735} - {fileID: 954434161} - {fileID: 181002641} + - {fileID: 961493954} + - {fileID: 2021000669} + - {fileID: 875833599} diff --git a/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs b/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs new file mode 100644 index 0000000..737b34a --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs @@ -0,0 +1,122 @@ +// Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs +using UnityEditor; +using UnityEngine; +using TD.Gameplay; + +namespace TD.Editor.Gameplay +{ + /// + /// Custom property drawer for . Labels each element by its + /// run position ("Encounter 3") rather than "Element 2", and renders a projected-earnings + /// line beneath the fields so the economy can be tuned without arithmetic. + /// + /// + /// Projections are estimates by necessity. Kill income depends on how many enemies the + /// encounter spawns, and the wave filling each slot is drawn at runtime from a phase pool — + /// so the drawer multiplies against + /// instead. The cumulative figure is the number worth watching: it answers "how much could a + /// player have banked by this point in the run", which is what tower prices get balanced + /// against. + /// + [CustomPropertyDrawer(typeof(EncounterGoldEntry))] + public class EncounterGoldEntryDrawer : PropertyDrawer + { + private const float PreviewLineExtraHeight = 4f; + + public override float GetPropertyHeight(SerializedProperty property, GUIContent label) + { + if (!property.isExpanded) return EditorGUIUtility.singleLineHeight; + + float h = EditorGUIUtility.singleLineHeight; // foldout + h += SpacedLineHeight() * 3; // the three gold fields + h += SpacedLineHeight() * 2 + PreviewLineExtraHeight; // projection + cumulative + return h; + } + + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + int encounterNumber = EncounterNumberFrom(property); + + // Re-label the foldout by run position. Element indices are 0-based and read as + // off-by-one against everything else in the run (RunState, the HUD, the checklist), + // which made miscounting the boss slot easy. + var header = new GUIContent(encounterNumber > 0 + ? $"Encounter {encounterNumber}" + : label.text); + + Rect headerRect = new Rect(position.x, position.y, position.width, + EditorGUIUtility.singleLineHeight); + property.isExpanded = EditorGUI.Foldout(headerRect, property.isExpanded, header, + toggleOnLabelClick: true); + if (!property.isExpanded) return; + + float y = position.y + SpacedLineHeight(); + const float indent = 14f; + Rect Row() + { + Rect r = new Rect(position.x + indent, y, position.width - indent, + EditorGUIUtility.singleLineHeight); + y += SpacedLineHeight(); + return r; + } + + var perEnemyProp = property.FindPropertyRelative("GoldPerEnemy"); + var completionProp = property.FindPropertyRelative("CompletionBonus"); + var noLeaksProp = property.FindPropertyRelative("NoLeaksBonus"); + + EditorGUI.PropertyField(Row(), perEnemyProp); + EditorGUI.PropertyField(Row(), completionProp); + EditorGUI.PropertyField(Row(), noLeaksProp); + + // Read values off the SerializedProperties rather than the backing object: edits + // aren't applied to the instance until ApplyModifiedProperties runs at end of frame, + // so the object would show stale numbers while the designer is still typing. + int perEnemy = perEnemyProp.intValue; + int completion = completionProp.intValue; + int noLeaks = noLeaksProp.intValue; + + var config = property.serializedObject.targetObject as GoldConfig; + int assumed = config != null ? config.PreviewEnemiesPerEncounter : 0; + + int projected = perEnemy * assumed + completion + noLeaks; + + y += PreviewLineExtraHeight; + var style = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }; + + using (new EditorGUI.DisabledScope(true)) + { + EditorGUI.LabelField( + Row(), + $"Projected: {projected} g ({perEnemy} × {assumed} enemies " + + $"+ {completion} clear + {noLeaks} no-leak)", + style); + + // Cumulative only makes sense once we know where this entry sits in the run. + if (config != null && encounterNumber > 0) + { + EditorGUI.LabelField( + Row(), + $"Cumulative by here: {config.ProjectedCumulativeThrough(encounterNumber)} g " + + $"(incl. {config.StartingGold} starting)", + style); + } + } + } + + // Recovers the 1-based run position from a path like "Encounters.Array.data[2]". + // Returns 0 when the entry isn't being drawn as an array element. + private static int EncounterNumberFrom(SerializedProperty property) + { + string path = property.propertyPath; + int open = path.LastIndexOf('['); + int close = path.LastIndexOf(']'); + if (open < 0 || close < open) return 0; + + string inner = path.Substring(open + 1, close - open - 1); + return int.TryParse(inner, out int index) ? index + 1 : 0; + } + + private static float SpacedLineHeight() + => EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + } +} diff --git a/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs.meta b/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs.meta new file mode 100644 index 0000000..74b7ba7 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Gameplay/EncounterGoldEntryDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 62f688cf14ba23b42bd0edd63a11ba62 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs b/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs deleted file mode 100644 index dff5ca8..0000000 --- a/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs -using UnityEditor; -using UnityEngine; -using TD.Gameplay; - -namespace TD.Editor.Gameplay -{ - /// - /// Custom property drawer for . Renders the standard fields - /// followed by a read-only "Preview Total" line computed from the entry's values, so - /// designers can see at a glance how much a single player could earn from a wave. - /// - /// - /// The preview includes the kill-reward portion only when the entry's Wave - /// reference is assigned (it's needed to count enemies in that wave). When unset, the - /// preview shows just CompletionBonus + NoLeaksBonus and labels itself so the - /// designer knows to drop a WaveDefinition in for a complete number. - /// - [CustomPropertyDrawer(typeof(WaveGoldEntry))] - public class WaveGoldEntryDrawer : PropertyDrawer - { - private const float PreviewLineExtraHeight = 4f; - - public override float GetPropertyHeight(SerializedProperty property, GUIContent label) - { - // Sum the children + one extra line for the preview label. We render children - // ourselves rather than using EditorGUI.PropertyField default since we want a - // foldout with our custom preview tacked onto the end. - if (!property.isExpanded) return EditorGUIUtility.singleLineHeight; - - float h = EditorGUIUtility.singleLineHeight; // foldout - h += SpacedLineHeight() * 4; // Wave + GoldPerEnemy + Completion + NoLeaks - h += SpacedLineHeight() + PreviewLineExtraHeight; // preview label - return h; - } - - public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) - { - // Foldout header. - Rect headerRect = new Rect(position.x, position.y, position.width, - EditorGUIUtility.singleLineHeight); - property.isExpanded = EditorGUI.Foldout(headerRect, property.isExpanded, label, - toggleOnLabelClick: true); - if (!property.isExpanded) return; - - float y = position.y + SpacedLineHeight(); - float indent = 14f; - Rect Row() - { - Rect r = new Rect(position.x + indent, y, position.width - indent, - EditorGUIUtility.singleLineHeight); - y += SpacedLineHeight(); - return r; - } - - // Standard property fields. - var waveProp = property.FindPropertyRelative("Wave"); - var perEnemyProp = property.FindPropertyRelative("GoldPerEnemy"); - var completionProp = property.FindPropertyRelative("CompletionBonus"); - var noLeaksProp = property.FindPropertyRelative("NoLeaksBonus"); - - EditorGUI.PropertyField(Row(), waveProp); - EditorGUI.PropertyField(Row(), perEnemyProp); - EditorGUI.PropertyField(Row(), completionProp); - EditorGUI.PropertyField(Row(), noLeaksProp); - - // Compute the preview total. We instantiate the same logic as the runtime - // property — read the serialized values, count enemies in the optional Wave, - // sum it all up. Done inline (rather than calling WaveGoldEntry.PreviewTotalGold - // directly) because SerializedProperty edits aren't applied to the target object - // until ApplyModifiedProperties runs at the end of the frame, so reading the - // backing class instance here would show stale data while the user is typing. - int perEnemy = perEnemyProp.intValue; - int completion = completionProp.intValue; - int noLeaks = noLeaksProp.intValue; - var waveAsset = waveProp.objectReferenceValue as WaveDefinition; - - int enemyCount = 0; - if (waveAsset != null && waveAsset.Entries != null) - { - foreach (var e in waveAsset.Entries) - { - if (e.EnemyType != null && e.Count > 0) - enemyCount += e.Count; - } - } - - int total = perEnemy * enemyCount + completion + noLeaks; - - // Preview line. Slightly dimmed style, italic, non-editable. Includes a - // breakdown so the designer can see where the number came from. - y += PreviewLineExtraHeight; - Rect previewRect = new Rect(position.x + indent, y, position.width - indent, - EditorGUIUtility.singleLineHeight); - - string breakdown = waveAsset != null - ? $"Preview Total: {total} g ({perEnemy} × {enemyCount} enemies " + - $"+ {completion} completion + {noLeaks} no-leak)" - : $"Preview Total: {completion + noLeaks} g (bonuses only — assign Wave " + - $"to include kill rewards)"; - - var prevStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }; - using (new EditorGUI.DisabledScope(true)) - { - EditorGUI.LabelField(previewRect, breakdown, prevStyle); - } - } - - private static float SpacedLineHeight() - => EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; - } -} diff --git a/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs.meta b/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs.meta deleted file mode 100644 index b9c6f5d..0000000 --- a/Assets/_Project/Scripts/Editor/Gameplay/WaveGoldEntryDrawer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: cc17000144b098340a210921594babca \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs index d643fcf..67fda7b 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs @@ -28,15 +28,25 @@ namespace TD.Gameplay.EnemyAbilities /// Uniform transform scale relative to the prefab's authored scale. public float VisualScale; - /// Seeds a context from an enemy definition's authored stats. - 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, - }; + /// + /// Seeds a context from an enemy definition's authored stats, with health supplied by the + /// caller. + /// + /// The type being spawned. Supplies everything except health. + /// Health this enemy spawns with, already resolved from the + /// encounter's budget and the type's — see + /// . Passed in rather than read off the + /// definition because it depends on run position and the wave's enemy count, neither of + /// which the asset knows about. + public static EnemySpawnContext FromDefinition(EnemyDefinition def, float resolvedMaxHp) + => new EnemySpawnContext + { + MaxHp = resolvedMaxHp, + MoveSpeed = def.MoveSpeed, + IsFlying = def.IsFlying, + FlightHeight = def.FlightHeight, + LivesCost = def.LivesCost, + VisualScale = 1f, + }; } } diff --git a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs index 15f45f8..bda4e45 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyDefinition.cs @@ -12,6 +12,13 @@ namespace TD.Gameplay /// in project assets, only the asset reference (or its fields) crosses runtime code. /// Replace with a real mesh/animator when art is ready — /// no code changes required. + /// + /// An enemy type is flavour, not a difficulty tier. There is deliberately no + /// absolute health field: health comes from based on how far + /// into the run the encounter sits, and this asset only says how the type deviates from that + /// (). Because waves are drawn at random from a phase pool, any + /// difficulty baked into the roster would be difficulty handed out by the luck of the draw. + /// Every type should be a viable occupant of any slot. /// [CreateAssetMenu(fileName = "EnemyDefinition", menuName = "TD/Enemy Definition", order = 3)] public class EnemyDefinition : ScriptableObject @@ -21,10 +28,16 @@ namespace TD.Gameplay public string DisplayName; [Header("Stats")] - [Tooltip("Maximum hit points for this enemy type.")] - public float MaxHp = 100f; + [Tooltip("How tough this enemy is RELATIVE to its encounter's budget — not an absolute " + + "hit-point value. 1.0 = exactly its share of the budget; 1.25 = a quarter " + + "tougher than the slot calls for. Keep it near 1.0 and let the wave's enemy " + + "count express tankiness (fewer enemies each get a bigger share).")] + [Min(0.01f)] + public float HpMultiplier = 1f; - [Tooltip("Movement speed in world units per second along the A* path.")] + [Tooltip("Movement speed in world units per second along the A* path. ABSOLUTE and " + + "unscaled — speed is a fixed character trait, not a progression axis. See " + + "EnemyScalingConfig for why.")] public float MoveSpeed = 3f; [Tooltip("When true this enemy flies: it paths on the baked terrain grid, ignoring " + diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs index ddd2e6a..28f6ab9 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs @@ -73,9 +73,9 @@ namespace TD.Gameplay // ----- Server-local runtime state ------------------------------------- - // Kill gold is no longer carried per-enemy — it comes from - // GoldConfig.Waves[currentWaveIndex].GoldPerEnemy at the moment the kill - // is registered. See WaveManager.HandleEnemyKilled. + // Kill gold is no longer carried per-enemy — it comes from the GoldConfig entry for + // the current encounter (GoldConfig.GetEncounterEntry) at the moment the kill is + // registered. See WaveManager.HandleEnemyKilled. /// Lives deducted from the shared pool when this enemy reaches the goal. public int LivesCost { get; private set; } = 1; diff --git a/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs b/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs new file mode 100644 index 0000000..8a833ee --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs @@ -0,0 +1,85 @@ +// Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs +using UnityEngine; + +namespace TD.Gameplay +{ + /// + /// How enemy health scales across a run. Single source of truth for difficulty progression, + /// the health counterpart to . + /// + /// + /// The problem this solves. Waves are drawn at random from a phase pool, so difficulty + /// can no longer live in the assets — an enemy authored at + /// 1000 HP is brutal at encounter 1 and trivial at encounter 40, and the draw decides which + /// you get. Progression has to be a property of how far the players have come, exactly + /// as gold payouts are. + /// + /// Budget, not per-enemy HP. This config scales the total health an + /// encounter presents; per-enemy health is that budget divided by the wave's enemy count. If + /// the curve set per-enemy health instead, enemy count would silently become a second + /// difficulty axis and a 150-strong swarm would hit three times harder than a 50-strong wave + /// in the same slot. Dividing a fixed budget turns count into a texture knob: eight + /// giants and a hundred rats threaten equally, but demand different mazes. + /// + /// Budget is per zone. Every player zone spawns the full wave, so this is the + /// health one player faces, not the lobby total. It does not change with player count. + /// + /// Speed is deliberately absent. Speed stays on the + /// , unscaled. Escalating speed alongside health compounds into + /// a difficulty cliff and quietly invalidates tower balance as a run goes — projectile lead, + /// slow-effect value and time-under-fire all shift. Fast-versus-slow reads best as a fixed + /// character trait; health and the voted wave buffs carry escalation. + /// + [CreateAssetMenu(fileName = "EnemyScalingConfig", menuName = "TD/Enemy Scaling Config", order = 3)] + public class EnemyScalingConfig : ScriptableObject + { + [Header("Health curve")] + [Tooltip("Total enemy health the run's FIRST encounter presents, per player zone. " + + "Per-enemy health is this divided by the wave's enemy count.")] + [Min(1f)] + public float BaseHpBudget = 5000f; + + [Tooltip("Multiplier applied per encounter. 1.18 ≈ +18% health each encounter, so the " + + "budget roughly doubles every four. Encounters are counted continuously across " + + "the whole run, so cycle 2 is strictly harder than cycle 1.")] + [Min(1f)] + public float GrowthPerEncounter = 1.18f; + + [Header("Boss")] + [Tooltip("Extra multiplier on a boss encounter's budget, on top of the curve. Bosses " + + "spawn few enemies, so nearly all of this lands on the single boss body.")] + [Min(1f)] + public float BossHpMultiplier = 6f; + + /// + /// Total health budget for the given 1-based encounter number + /// (RunState.GlobalEncounterNumber), per player zone. + /// + public float GetEncounterHpBudget(int encounterNumber, bool isBoss) + { + int steps = Mathf.Max(0, encounterNumber - 1); + float budget = BaseHpBudget * Mathf.Pow(GrowthPerEncounter, steps); + if (isBoss) budget *= BossHpMultiplier; + return budget; + } + + /// + /// Health for one enemy in the given encounter: the encounter's budget shared across + /// bodies, then skewed by the enemy type's + /// . + /// + /// + /// The archetype multiplier is a deliberate deviation from budget, not a + /// redistribution of it — a wave of 1.25× enemies really is 25% tougher than its slot + /// calls for. Keep multipliers near 1.0 and let the wave's enemy count express tankiness; + /// that is the knob the budget is designed to divide. + /// + public float ResolvePerEnemyHp(int encounterNumber, bool isBoss, int waveEnemyCount, + float archetypeMultiplier) + { + int bodies = Mathf.Max(1, waveEnemyCount); + float share = GetEncounterHpBudget(encounterNumber, isBoss) / bodies; + return Mathf.Max(1f, share * Mathf.Max(0.01f, archetypeMultiplier)); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs.meta b/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs.meta new file mode 100644 index 0000000..a428b6f --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: de25a6333a11d074280b5ade557aa8b9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs index 460223a..2707d91 100644 --- a/Assets/_Project/Scripts/Gameplay/GoldConfig.cs +++ b/Assets/_Project/Scripts/Gameplay/GoldConfig.cs @@ -1,91 +1,68 @@ // Assets/_Project/Scripts/Gameplay/GoldConfig.cs using System; using UnityEngine; +using UnityEngine.Serialization; namespace TD.Gameplay { /// - /// Per-wave gold rules nested under . Each entry maps to one - /// wave by array index in . + /// Gold rules for one encounter slot, addressed by position in the run rather than by + /// which wave happens to occupy it. /// /// - /// Pairing with WaveDefinition. The runtime pairing of gold entry to wave is by - /// array index — entry 0 applies to wave 1, etc. The reference here - /// is OPTIONAL and exists purely so the inspector can compute and display the - /// read-only value (which needs to know the wave's - /// total enemy count to multiply against ). Leaving it - /// unset just hides the kill-reward portion of the preview; runtime is unaffected. + /// Position, not identity. Under the 2.0 run structure the waves filling each slot are + /// drawn at random from a phase pool, so "the gold for wave 3" means the run's third + /// encounter, whatever spawns in it. Payouts are a property of how far the players have + /// progressed — not of which enemy they happened to draw. /// [Serializable] - public class WaveGoldEntry + public class EncounterGoldEntry { - [Tooltip("Optional reference to the WaveDefinition this entry pairs with. " + - "Used ONLY for the inspector preview total — runtime pairing is by " + - "array index in GoldConfig.Waves. Drag the matching WaveDefinition " + - "here to see the live total computed under this entry.")] - public WaveDefinition Wave; - - [Tooltip("Gold awarded to the killing player per enemy slain during this wave. " + - "Applies uniformly to every enemy type in the wave.")] + [Tooltip("Gold awarded to the killing player per enemy slain during this encounter. " + + "Applies uniformly to every enemy type.")] public int GoldPerEnemy = 10; - [Tooltip("Flat bonus gold awarded to every active player when this wave is fully " + + [Tooltip("Flat bonus gold awarded to every active player when this encounter is fully " + "cleared (all enemies dead or reached the goal).")] public int CompletionBonus = 50; - [Tooltip("Extra bonus gold awarded to a player who cleared this wave without any " + + [Tooltip("Extra bonus gold awarded to a player who cleared this encounter without any " + "enemy from their own zone escaping their leak volume. Stacks on top of " + - "CompletionBonus for the no-leak achievement.")] + "CompletionBonus.")] public int NoLeaksBonus = 50; /// - /// Read-only preview of the maximum gold a single player could earn in this wave: - /// GoldPerEnemy × totalEnemyCount + CompletionBonus + NoLeaksBonus. + /// Guaranteed gold for a player who clears this encounter: the two bonuses, with no + /// kill income counted. /// /// - /// Returns just CompletionBonus + NoLeaksBonus if is null - /// (no enemy count to multiply against). Pure computation — safe at edit time and - /// at runtime. The custom inspector under renders this as - /// a non-editable label under the entry's fields. + /// Kill income can't be known here — the wave occupying this slot is drawn at runtime, so + /// its enemy count varies per run. + /// supplies an estimate for the inspector's projected totals. /// - public int PreviewTotalGold - { - get - { - int total = CompletionBonus + NoLeaksBonus; - if (Wave != null && Wave.Entries != null) - { - int enemies = 0; - foreach (var e in Wave.Entries) - { - if (e.EnemyType != null && e.Count > 0) - enemies += e.Count; - } - total += GoldPerEnemy * enemies; - } - return total; - } - } + public int GuaranteedBonusGold => CompletionBonus + NoLeaksBonus; + + /// + /// Projected best-case earnings for one player this encounter, given an assumed enemy + /// count. Editor tuning aid only — nothing at runtime reads it. + /// + public int ProjectedTotal(int assumedEnemyCount) + => GoldPerEnemy * Mathf.Max(0, assumedEnemyCount) + GuaranteedBonusGold; } /// /// Single source of truth for every gold-related tunable in the game. /// /// - /// Inspector layout. StartingGold is at the top and applies to every player. - /// The Waves array follows, each element collapsible in the inspector with the - /// per-wave rules and a read-only preview total computed from the entry's data. + /// Wiring. Assign one GoldConfig asset to WaveManager.goldConfig in the Match + /// scene. WaveManager seeds per-player starting gold from , reads + /// for kill rewards, and awards the completion + /// and no-leak bonuses as each encounter clears. /// - /// Wiring. Assign one GoldConfig asset to WaveManager.goldConfig in - /// the Match scene. WaveManager initializes per-player starting gold from - /// at match start, uses - /// to compute kill rewards, and awards / - /// when each wave clears. - /// - /// No per-enemy-type bounties. Every enemy killed in wave N grants the same - /// regardless of . - /// If per-type variation becomes a design need, extend WaveGoldEntry with a per-type - /// table; the current model intentionally favors simplicity. + /// No per-enemy-type bounties. Every enemy killed in a given encounter grants the + /// same reward regardless of . With enemy types now drawn at + /// random per phase, per-type bounties would make income depend on the luck of the draw + /// rather than on progression. /// [CreateAssetMenu(fileName = "GoldConfig", menuName = "TD/Gold Config", order = 2)] public class GoldConfig : ScriptableObject @@ -93,28 +70,58 @@ namespace TD.Gameplay [Tooltip("Gold each player starts the match with. Same for every player.")] public int StartingGold = 100; - [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; + [Tooltip("Gold rules per encounter, in run order. Element 0 = the run's first encounter. " + + "Encounters are counted continuously — cycles do NOT restart the count — so one " + + "phase of 5 waves × 3 cycles + a boss needs 16 entries. Missing entries pay " + + "zero for that encounter.")] + [FormerlySerializedAs("Waves")] + public EncounterGoldEntry[] Encounters; + + [Header("Editor preview")] + [Tooltip("Assumed enemies per encounter, used ONLY to project totals in this inspector. " + + "Nothing at runtime reads it — the real count comes from whichever wave the run " + + "draws into each slot.")] + [Min(0)] + public int PreviewEnemiesPerEncounter = 40; /// - /// Returns the gold entry for the given 1-based encounter number + /// Returns the gold rules for the given 1-based encounter number /// (RunState.GlobalEncounterNumber), or null if out of range. /// /// - /// Keyed by encounter, 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. + /// Keyed by run position, not by wave slot within a cycle. 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 grew steadily stronger from the buffs voted onto them. A + /// run-long counter lets payouts climb with the difficulty. /// - public WaveGoldEntry GetWaveEntry(int encounterNumber) + public EncounterGoldEntry GetEncounterEntry(int encounterNumber) { - if (Waves == null) return null; + if (Encounters == null) return null; int index = encounterNumber - 1; - if (index < 0 || index >= Waves.Length) return null; - return Waves[index]; + if (index < 0 || index >= Encounters.Length) return null; + return Encounters[index]; + } + + /// Number of authored encounter entries. + public int EncounterCount => Encounters?.Length ?? 0; + + /// + /// Editor tuning aid: cumulative projected earnings for one player from the run's first + /// encounter through inclusive, assuming + /// kills each time and no leaks. + /// + public int ProjectedCumulativeThrough(int encounterNumber) + { + if (Encounters == null) return StartingGold; + + int total = StartingGold; + int last = Mathf.Min(encounterNumber, Encounters.Length); + for (int i = 0; i < last; i++) + { + if (Encounters[i] == null) continue; + total += Encounters[i].ProjectedTotal(PreviewEnemiesPerEncounter); + } + return total; } } } diff --git a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs index 9943dc5..3706dfb 100644 --- a/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs +++ b/Assets/_Project/Scripts/Gameplay/TowerPlacementManager.cs @@ -388,6 +388,76 @@ namespace TD.Gameplay spawner.Play(worldPos); } + // ----- Phase teardown --------------------------------------------- + + /// + /// Server-only: destroys every built tower on the map, refunding nothing. Called when a + /// phase ends so the next phase starts from bare ground. + /// + /// How many towers were destroyed. + /// + /// Deliberately not the sell path. Selling refunds gold, plays the coin VFX and is + /// owner-gated; none of that applies here. This is confiscation, not a transaction — the + /// maze players spent a whole phase building is the price of the boss going down. + /// + /// Cleanup rides on despawn. + /// already un-stamps the footprint, deregisters from the minimap and clears any local + /// selection pointing at it, so this method only has to despawn. Shelved + /// s self-clean the same way; queued and in-progress jobs are + /// cancelled through their own Builder, which restores their footprint tiles. + /// + /// Queued jobs DO refund. Those towers were never delivered, so eating the + /// gold would punish players for having a build queued when the boss happened to die. The + /// no-refund rule applies to towers that got built. + /// + /// Timing matters for performance. Every despawn fires a walkability change, + /// and a full maze is a lot of them. This runs with the field empty — the boss is dead and + /// the next wave hasn't spawned — so no enemy is registered with the re-path scheduler and + /// the recompute storm that would cause mid-wave never happens. + /// + public int ServerDestroyAllTowers() + { + if (!IsServer) return 0; + + // Cancel build queues first. This despawns their non-shelved visuals and frees the + // footprint tiles they had reserved, so the sweep below only has to deal with what's + // left standing. + foreach (var pms in PlayerMatchState.AllPlayers) + { + var builder = Builder.GetForClient(pms.OwnerClientId); + builder?.ServerCancelAllJobs(); + } + + var spawnManager = NetworkManager.Singleton?.SpawnManager; + if (spawnManager == null) return 0; + + // Snapshot before iterating — despawning mutates the live spawned-objects collection. + var snapshot = new System.Collections.Generic.List( + spawnManager.SpawnedObjectsList); + + int destroyed = 0; + foreach (var no in snapshot) + { + if (no == null || !no.IsSpawned) continue; + + if (no.GetComponent() != null) + { + no.Despawn(destroy: true); + destroyed++; + continue; + } + + // Shelved build sites outlive their Builder's queue, so cancelling jobs above + // doesn't reach them. Left alone they'd sit on the map as ghosts of towers that + // no longer exist, still holding their footprint tiles occupied. + var visual = no.GetComponent(); + if (visual != null && visual.IsShelved) + no.Despawn(destroy: true); + } + + return destroyed; + } + // ----- Server-side commit hooks called by Builder ------------------ /// diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs index f23c5fa..5eeb6e5 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs @@ -87,6 +87,12 @@ namespace TD.Gameplay } /// Total enemies spawned per zone by this wave, across all entries. + /// + /// Also the divisor for the encounter's health budget — see + /// . That makes count a texture + /// knob rather than a difficulty one: a wave of 8 spawns eight tanks, a wave of 150 spawns + /// a fragile swarm, and both present the same total health for their slot in the run. + /// public int TotalEnemyCount { get diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index b393f9f..a40ed28 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -83,6 +83,11 @@ namespace TD.Gameplay "no kill/wave rewards (designer-error indicator, not a supported runtime mode).")] [SerializeField] private GoldConfig goldConfig; + [Tooltip("How enemy health scales across the run. Required for a real match; if unset, " + + "every enemy spawns at a flat fallback health and difficulty never progresses " + + "(designer-error indicator, not a supported runtime mode).")] + [SerializeField] private EnemyScalingConfig enemyScalingConfig; + // ----- Networked state -------------------------------------------- // Per-slot total leak counters across the whole match. Index = (int)PlayerSlot; @@ -372,9 +377,18 @@ namespace TD.Gameplay break; case RunAdvance.NextPhase: + { + // A phase boundary resets the board as well as the enemies: the maze players + // spent the phase building is torn down, unrefunded, so the new phase starts + // from bare ground. Runs here rather than inside RunState.ServerAdvance + // because it touches player structures, which the run has no business owning. + int destroyed = TowerPlacementManager.Instance?.ServerDestroyAllTowers() ?? 0; + NotifyTowersWipedClientRpc(destroyed); + Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " + - $"Enemy upgrades wiped."); + $"Enemy upgrades wiped; {destroyed} tower(s) destroyed."); break; + } case RunAdvance.NextCycle: Debug.Log($"[WaveManager] Cycle complete — same waves return upgraded " + @@ -492,6 +506,7 @@ namespace TD.Gameplay // 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(); + ResolveCurrentEncounterHealth(def); // Spawn all enemies at once in a held (untargetable, immobile) state. if (def.Entries != null) @@ -668,6 +683,58 @@ namespace TD.Gameplay $"{currentWaveAbilities.Count} wave buff(s)."); } + // ----- Encounter health resolution --------------------------------- + + // Health one enemy of this encounter gets BEFORE its type's HpMultiplier is applied: + // the encounter's budget divided across the wave's bodies. Resolved once per encounter + // for the same reason the ability list is — it's fixed for the whole wave, and the + // divisor would otherwise be recomputed for every spawn. + private float currentEncounterHpShare = FallbackEnemyHp; + + // Used only when no EnemyScalingConfig is assigned. Enemies stay killable so the match + // is still playable, but difficulty never progresses — see the field's tooltip. + private const float FallbackEnemyHp = 100f; + + /// + /// Server-only: work out the per-enemy health share for the encounter about to spawn. + /// + /// + /// Splits the encounter's total budget across the wave's enemy count, so a swarm and an + /// elite wave in the same slot present the same threat. The type's + /// is applied later, per enemy, so a wave + /// mixing types skews each one independently off the shared share. + /// + private void ResolveCurrentEncounterHealth(WaveDefinition def) + { + if (enemyScalingConfig == null) + { + currentEncounterHpShare = FallbackEnemyHp; + Debug.LogWarning("[WaveManager] No EnemyScalingConfig assigned — enemies spawn at " + + $"a flat {FallbackEnemyHp} HP and difficulty will not progress."); + return; + } + + var run = RunState.Instance; + int encounter = CurrentEncounterNumber; + bool isBoss = run?.IsBossStage ?? false; + + int bodies = def != null ? def.TotalEnemyCount : 0; + if (bodies <= 0) + { + // An empty wave can't be divided into. Nothing will spawn from it either, so this + // only guards against a divide-by-zero on a misauthored asset. + currentEncounterHpShare = FallbackEnemyHp; + return; + } + + currentEncounterHpShare = + enemyScalingConfig.ResolvePerEnemyHp(encounter, isBoss, bodies, archetypeMultiplier: 1f); + + Debug.Log($"[WaveManager] Encounter {encounter}{(isBoss ? " (BOSS)" : "")}: " + + $"{enemyScalingConfig.GetEncounterHpBudget(encounter, isBoss):0} HP budget " + + $"across {bodies} enemies = {currentEncounterHpShare:0} each before type modifiers."); + } + // ----- Spawn helpers ---------------------------------------------- private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false) @@ -742,7 +809,8 @@ namespace TD.Gameplay // 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); + var context = contextOverride + ?? EnemySpawnContext.FromDefinition(def, currentEncounterHpShare * def.HpMultiplier); if (contextOverride == null && abilities != null) { for (int i = 0; i < abilities.Count; i++) @@ -857,11 +925,11 @@ namespace TD.Gameplay private void HandleEnemyKilled(EnemyHealth health) { - // Kill reward comes from GoldConfig for the current wave — same value for - // every enemy in the wave regardless of EnemyDefinition type. Missing config - // or out-of-range wave → 0 reward (gold flow disabled, designer-error mode). + // Kill reward comes from GoldConfig for the current encounter — same value for + // every enemy in it regardless of EnemyDefinition type. Missing config or an + // unauthored encounter → 0 reward (gold flow disabled, designer-error mode). int killReward = 0; - var goldEntry = goldConfig?.GetWaveEntry(CurrentEncounterNumber); + var goldEntry = goldConfig?.GetEncounterEntry(CurrentEncounterNumber); if (goldEntry != null) killReward = goldEntry.GoldPerEnemy; // Wave buffs get to alter the bounty before anything else sees it — this is what @@ -983,8 +1051,24 @@ namespace TD.Gameplay ShowGoldLossClientRpc(worldPos, amount); } + // Announces the end-of-phase tower wipe on every peer. Sent rather than derived locally + // because the despawns arrive as individual NetworkObject removals with nothing tying them + // together — without this a client just watches its maze evaporate for no stated reason. + [ClientRpc] + private void NotifyTowersWipedClientRpc(int destroyedCount) + { + OnTowersWiped?.Invoke(destroyedCount); + } + // ----- Local-only notification events ----------------------------- + /// + /// Fired on every peer when a phase ends and every built tower is destroyed. Argument is + /// how many towers went down. HUD subscribes to explain what just happened; audio and + /// camera-shake hooks can use it too. + /// + public static event System.Action OnTowersWiped; + /// /// Fired on every peer immediately after a life-loss popup spawns. /// HUD subscribes to flash a centered banner; gameplay code can also @@ -1045,10 +1129,10 @@ namespace TD.Gameplay // Server-only. Iterates active players, awards CompletionBonus to each, plus // NoLeaksBonus to those whose per-wave leak counter is zero. Floating-text popups // are spawned at each player's builder position so the reward is visible in-world. - // Skipped silently if no goldConfig or no entry for this wave. + // Skipped silently if no goldConfig or no entry for this encounter. private void AwardWaveCompletionBonuses() { - var entry = goldConfig?.GetWaveEntry(CurrentEncounterNumber); + var entry = goldConfig?.GetEncounterEntry(CurrentEncounterNumber); if (entry == null) return; int completionBonus = entry.CompletionBonus; diff --git a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs index 5f0cd92..b31b585 100644 --- a/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs +++ b/Assets/_Project/Scripts/Gameplay/Waves/RunState.cs @@ -188,7 +188,7 @@ namespace TD.Gameplay.Waves /// /// 1-based count of encounters since the run began, including bosses. This is the key - /// GoldConfig is indexed by, so its per-wave entries scale monotonically across the + /// GoldConfig.Encounters is indexed by, so payouts scale monotonically across the /// whole run rather than resetting every cycle. /// public int GlobalEncounterNumber diff --git a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs index d870d94..9d1c129 100644 --- a/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs +++ b/Assets/_Project/Scripts/Gameplay/Waves/WaveGroup.cs @@ -19,7 +19,7 @@ namespace TD.Gameplay.Waves /// A class rather than a struct specifically so 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 - /// . + /// . /// [Serializable] public class WavePoolEntry diff --git a/Assets/_Project/Scripts/UI/HUDController.cs b/Assets/_Project/Scripts/UI/HUDController.cs index 29e2b0f..7b51380 100644 --- a/Assets/_Project/Scripts/UI/HUDController.cs +++ b/Assets/_Project/Scripts/UI/HUDController.cs @@ -77,6 +77,19 @@ namespace TD.UI private Label nextWaveLabel; // prep countdown ("next: 0:12") private Label leakedLabel; // local player's origin-leak count ("leaked: 3") private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") + + // Top-bar row of buff badges for the wave currently spawning. Rebuilt only when the + // underlying set changes — see waveBuffSignature. + private VisualElement waveBuffRow; + + // Cheap change-detector for the buff row. RefreshTopBar runs every frame, and rebuilding + // a handful of VisualElements at 60 Hz would churn the UI system for no reason. Encodes + // the slot plus the option ids on it, so it changes when the wave advances OR when a vote + // lands on the slot the players are about to face again. + private int waveBuffSignature = int.MinValue; + + private readonly System.Collections.Generic.List waveBuffScratch + = new System.Collections.Generic.List(); private VisualElement playerListContainer; // right-panel scoreboard rows private Label portraitName; private Label levelLabel; @@ -977,6 +990,7 @@ namespace TD.UI nextWaveLabel = Require