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 <noreply@anthropic.com>
This commit is contained in:
parent
7e5c3a8279
commit
4892d7253d
64 changed files with 4023 additions and 344 deletions
|
|
@ -0,0 +1,60 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/BlinkAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Every few seconds, the enemy teleports a short distance further along its path — skipping
|
||||
/// tiles, and the towers covering them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blinks follow the path rather than cutting toward the goal, so the maze still shapes the
|
||||
/// route; the buff shortens time-under-fire rather than bypassing walls. See
|
||||
/// <c>EnemyMovement.ServerBlinkForward</c>.
|
||||
///
|
||||
/// <para>The cooldown lives in this enemy's own timer slot rather than on the asset. A field
|
||||
/// here would be shared by every enemy carrying the buff, so the whole wave would blink in
|
||||
/// perfect unison — which looks like a bug even when the timing is right.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "BlinkAbility", menuName = "TD/Enemy Abilities/Blink")]
|
||||
public class BlinkAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.Blink;
|
||||
|
||||
[Header("Blink")]
|
||||
[Tooltip("Seconds between blinks.")]
|
||||
[Min(0.1f)]
|
||||
public float IntervalSeconds = 4f;
|
||||
|
||||
[Tooltip("How many path waypoints to skip per blink. Note that path smoothing can make " +
|
||||
"one waypoint span several tiles, so this is a coarser dial than it looks.")]
|
||||
[Min(1)]
|
||||
public int BlinkWaypoints = 2;
|
||||
|
||||
[Tooltip("Random spread (seconds) added to each enemy's first blink so a wave doesn't " +
|
||||
"blink in one synchronized block. Applied once, at spawn.")]
|
||||
[Min(0f)]
|
||||
public float StartJitterSeconds = 1.5f;
|
||||
|
||||
public override void ServerOnSpawn(EnemyAbility instance, int abilityIndex)
|
||||
{
|
||||
// Seed each enemy's cooldown with a different negative offset so the wave's first
|
||||
// blink is staggered. Done once, here, rather than on the first tick — a tick-time
|
||||
// check would have to distinguish "never started" from "just blinked", and both
|
||||
// states read as a zero timer.
|
||||
if (StartJitterSeconds > 0f)
|
||||
instance.TimerFor(abilityIndex) = -Random.Range(0f, StartJitterSeconds);
|
||||
}
|
||||
|
||||
public override void ServerTick(EnemyAbility instance, int abilityIndex, float dt)
|
||||
{
|
||||
ref float timer = ref instance.TimerFor(abilityIndex);
|
||||
timer += dt;
|
||||
if (timer < IntervalSeconds) return;
|
||||
|
||||
timer = 0f;
|
||||
instance.GetComponent<EnemyMovement>()?.ServerBlinkForward(BlinkWaypoints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4fbb09f55384ac44399ac4655b2b53ec
|
||||
|
|
@ -5,21 +5,28 @@ using TD.Core;
|
|||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Rolled
|
||||
/// at random for each spawned enemy by <see cref="EnemyAbilityPool.RollRandom"/> and applied
|
||||
/// via <see cref="EnemyAbility.InitializeServer"/>.
|
||||
/// Base class for one enemy ability (e.g. "split into smaller enemies on death"). Abilities
|
||||
/// are attached to a <b>wave slot</b> by the post-wave player vote, and every enemy that wave
|
||||
/// spawns from then on carries the whole accumulated set.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>One asset per kind.</b> <see cref="Kind"/> is fixed per subclass, same as
|
||||
/// <see cref="BuilderSpells.BuilderSpellDefinition"/>. <see cref="EnemyAbilityPool"/> uses
|
||||
/// it to build a fixed-size, enum-indexed lookup table.</para>
|
||||
/// <para><b>Assignment is deterministic, not random.</b> This system originally rolled an
|
||||
/// ability per individual enemy against a no-ability weight. Under the 2.0 design the players
|
||||
/// choose it, it applies to every enemy in the wave, and it persists for the rest of the phase
|
||||
/// — so the roll is gone and <c>WaveManager</c> reads the slot's set instead. What survived is
|
||||
/// the shape: one asset per kind, server-only hooks.</para>
|
||||
///
|
||||
/// <para><b>Server-only hooks.</b> All three hooks below only ever run on the server —
|
||||
/// <see cref="EnemyAbility"/> only calls them when <c>IsServer</c> is true. <see
|
||||
/// cref="ServerOnSpawn"/> and <see cref="ServerTick"/> are no-ops for abilities that don't
|
||||
/// need spawn-time setup or a per-frame timer (e.g. Split on Death uses neither); they exist
|
||||
/// so a future cooldown-driven ability (disable towers, teleport) doesn't need a base-class
|
||||
/// change.</para>
|
||||
/// <para><b>Abilities stack.</b> A wave can collect several across a phase's cycles, so an
|
||||
/// enemy holds a list and every hook below runs once per ability. Implementations must not
|
||||
/// assume they are the only ability on the enemy.</para>
|
||||
///
|
||||
/// <para><b>Server-only hooks.</b> All hooks run on the server only — <see cref="EnemyAbility"/>
|
||||
/// gates every call on <c>IsServer</c>. They default to no-ops so a new card only overrides the
|
||||
/// one or two it actually needs.</para>
|
||||
///
|
||||
/// <para><b>No per-enemy state on the asset.</b> A single asset is shared by every enemy
|
||||
/// carrying the ability, across every wave and match. Anything per-instance belongs on the
|
||||
/// <see cref="EnemyAbility"/> component or the enemy itself.</para>
|
||||
/// </remarks>
|
||||
public abstract class EnemyAbilityDefinition : ScriptableObject
|
||||
{
|
||||
|
|
@ -27,29 +34,74 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
public abstract EnemyAbilityKind Kind { get; }
|
||||
|
||||
[Header("Presentation")]
|
||||
[Tooltip("Name shown in debug logs and future enemy-info UI.")]
|
||||
[Tooltip("Name shown in debug logs and the enemy-info panel.")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Short description shown in future enemy-info UI.")]
|
||||
[Tooltip("Short description shown in the enemy-info panel.")]
|
||||
[TextArea(2, 4)]
|
||||
public string Description;
|
||||
|
||||
[Header("Selection")]
|
||||
[Tooltip("Relative weight of this ability being rolled, vs. the pool's other abilities " +
|
||||
"and its no-ability chance. Same semantics as DraftOption.Weight.")]
|
||||
[Min(0f)]
|
||||
public float Weight = 1f;
|
||||
// ----- Spawn-time stat modification --------------------------------
|
||||
|
||||
/// <summary>Server-only: called once, right after this ability is assigned (before the
|
||||
/// <summary>
|
||||
/// Server-only: alter what this enemy spawns as, before it is built. Runs for every enemy
|
||||
/// of the buffed wave. Default no-op.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the hook for cards that change what an enemy fundamentally <i>is</i> — flight,
|
||||
/// health, speed, size. It must run before the enemy exists, because spawn position
|
||||
/// (flyers are raised) and <c>EnemyHealth</c>/<c>EnemyMovement</c> initialization all read
|
||||
/// these values once and keep them.
|
||||
///
|
||||
/// <para>When several abilities stack, they see each other's edits in application order,
|
||||
/// so multiplicative modifiers compose naturally. Prefer multiplying over assigning for
|
||||
/// anything numeric, or the last card to run silently wins.</para>
|
||||
/// </remarks>
|
||||
public virtual void ServerModifySpawn(ref EnemySpawnContext context) { }
|
||||
|
||||
// ----- Lifetime hooks ----------------------------------------------
|
||||
|
||||
/// <summary>Server-only: called once, right after the ability set is assigned (before the
|
||||
/// enemy's NetworkObject is spawned to clients). Default no-op.</summary>
|
||||
public virtual void ServerOnSpawn(EnemyAbility instance) { }
|
||||
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/> — the
|
||||
/// same index <see cref="ServerTick"/> receives, so per-enemy timers can be seeded here.</param>
|
||||
public virtual void ServerOnSpawn(EnemyAbility instance, int abilityIndex) { }
|
||||
|
||||
/// <summary>Server-only: called every frame this ability is active on a live enemy.
|
||||
/// Default no-op.</summary>
|
||||
public virtual void ServerTick(EnemyAbility instance, float dt) { }
|
||||
/// <summary>
|
||||
/// Server-only: called every frame this ability is active on a live enemy. Default no-op.
|
||||
/// </summary>
|
||||
/// <param name="abilityIndex">This ability's slot on <paramref name="instance"/>. Pass it
|
||||
/// to <see cref="EnemyAbility.TimerFor"/> to reach a per-enemy float this ability owns.</param>
|
||||
/// <remarks>
|
||||
/// <b>The asset holds no per-enemy state.</b> One <see cref="EnemyAbilityDefinition"/>
|
||||
/// instance is shared by every enemy carrying the ability, so a cooldown stored in a field
|
||||
/// here would be shared by the entire wave — every enemy would blink in lockstep, or worse,
|
||||
/// race each other's writes. <see cref="EnemyAbility.TimerFor"/> gives each enemy its own
|
||||
/// slot by reference, which is enough for the timer-driven abilities and costs no
|
||||
/// allocation.
|
||||
/// </remarks>
|
||||
public virtual void ServerTick(EnemyAbility instance, int abilityIndex, float dt) { }
|
||||
|
||||
/// <summary>Server-only: called the instant the enemy's HP reaches zero, before the
|
||||
/// death animation/despawn sequence plays. Default no-op.</summary>
|
||||
public virtual void ServerOnDeath(EnemyAbility instance, EnemyHealth health) { }
|
||||
|
||||
// ----- Economy hooks -----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: adjust the gold a player earns for killing this enemy. Returns the reward
|
||||
/// to pass on; default is unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Chained across a stacked ability set, each ability receiving the previous one's result.
|
||||
/// Callers clamp the final value at zero, so returning a negative is safe but pointless.
|
||||
/// </remarks>
|
||||
public virtual int ServerModifyKillReward(EnemyAbility instance, int reward) => reward;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: called when this enemy reaches the defense point, after lives have been
|
||||
/// deducted. The hook for leak-punishing cards that cost more than lives. Default no-op.
|
||||
/// </summary>
|
||||
public virtual void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone) { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain MonoBehaviour: identical on every peer (same assets), so there is nothing to sync.
|
||||
/// Only the server calls <see cref="RollRandom"/> (at enemy spawn time, in
|
||||
/// <c>WaveManager.SpawnEnemy</c>).
|
||||
///
|
||||
/// <para><b>Lookup only — no rolling.</b> This pool used to draw a random ability per spawned
|
||||
/// enemy against a "no ability" weight. Under the 2.0 design abilities are chosen by the
|
||||
/// players' post-wave vote and apply to every enemy in the wave, so the draw moved to
|
||||
/// <c>WaveVote</c> (over cards, not abilities) and this is purely the kind→asset table
|
||||
/// <c>WaveManager</c> resolves against at spawn time.</para>
|
||||
/// </remarks>
|
||||
public class EnemyAbilityPool : MonoBehaviour
|
||||
{
|
||||
|
|
@ -25,11 +29,6 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
"its slot.")]
|
||||
[SerializeField] private EnemyAbilityDefinition[] abilities;
|
||||
|
||||
[Tooltip("Relative weight of an enemy rolling no ability at all, vs. the combined " +
|
||||
"weight of every entry in 'abilities'. Higher = abilities are rarer.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float noAbilityWeight = 100f;
|
||||
|
||||
// Fixed-size, enum-indexed lookup built once in Awake. Sized to the enum's entry count,
|
||||
// not authored count, so an out-of-range Kind is a compile-time impossibility rather
|
||||
// than a bounds check we'd otherwise need on every Get().
|
||||
|
|
@ -68,35 +67,5 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
int i = (int)kind;
|
||||
return (byKind != null && i >= 0 && i < byKind.Length) ? byKind[i] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: weighted random draw across every authored ability plus the
|
||||
/// no-ability bucket. Returns null when "no ability" is drawn, or when the pool has
|
||||
/// nothing authored.
|
||||
/// </summary>
|
||||
public EnemyAbilityDefinition RollRandom()
|
||||
{
|
||||
if (abilities == null || abilities.Length == 0) return null;
|
||||
|
||||
float total = noAbilityWeight;
|
||||
for (int i = 0; i < abilities.Length; i++)
|
||||
if (abilities[i] != null) total += abilities[i].Weight;
|
||||
|
||||
if (total <= 0f) return null;
|
||||
|
||||
float roll = UnityEngine.Random.Range(0f, total);
|
||||
if (roll < noAbilityWeight) return null;
|
||||
roll -= noAbilityWeight;
|
||||
|
||||
for (int i = 0; i < abilities.Length; i++)
|
||||
{
|
||||
var def = abilities[i];
|
||||
if (def == null) continue;
|
||||
if (roll < def.Weight) return def;
|
||||
roll -= def.Weight;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/EnemySpawnContext.cs
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Mutable per-spawn copy of an enemy's stats, handed to each of its abilities before the
|
||||
/// enemy is built so they can alter what it spawns as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Why a context rather than more parameters.</b> Wave buffs like "grounded enemies become
|
||||
/// flying" or "enemies spawn at half health" have to land <i>before</i>
|
||||
/// <c>EnemyHealth.InitializeServer</c> and before the spawn position is computed (flyers are
|
||||
/// raised by their flight height). Threading each new stat through <c>SpawnEnemy</c> as another
|
||||
/// argument was already unwieldy at two; this keeps the signature flat and gives abilities one
|
||||
/// obvious place to write.
|
||||
///
|
||||
/// <para>Seeded from the <see cref="EnemyDefinition"/>, never written back to it — the
|
||||
/// definition is a shared asset and a buff that mutated it would leak across waves, matches,
|
||||
/// and (in the editor) sessions.</para>
|
||||
/// </remarks>
|
||||
public struct EnemySpawnContext
|
||||
{
|
||||
public float MaxHp;
|
||||
public float MoveSpeed;
|
||||
public bool IsFlying;
|
||||
public float FlightHeight;
|
||||
public int LivesCost;
|
||||
|
||||
/// <summary>Uniform transform scale relative to the prefab's authored scale.</summary>
|
||||
public float VisualScale;
|
||||
|
||||
/// <summary>Seeds a context from an enemy definition's authored stats.</summary>
|
||||
public static EnemySpawnContext FromDefinition(EnemyDefinition def) => new EnemySpawnContext
|
||||
{
|
||||
MaxHp = def.MaxHp,
|
||||
MoveSpeed = def.MoveSpeed,
|
||||
IsFlying = def.IsFlying,
|
||||
FlightHeight = def.FlightHeight,
|
||||
LivesCost = def.LivesCost,
|
||||
VisualScale = 1f,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 2f4b0f77b422a474cbe82f3967e6195b
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/FlightAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Grounded enemies take to the air: they path over the baked terrain grid instead of through
|
||||
/// the maze, and towers flagged ground-only can no longer touch them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The single most punishing card in the set</b>, because it doesn't weaken the maze — it
|
||||
/// deletes it. Everything a player built to lengthen the route stops mattering for that wave,
|
||||
/// and only their anti-air coverage counts. Weight it accordingly in the pools.
|
||||
///
|
||||
/// <para>Purely a spawn-time change: <c>EnemyMovement</c> reads the flying flag once at
|
||||
/// initialization to pick which grid to path on and to opt out of re-path scheduling, and
|
||||
/// <c>TowerCombat</c> reads the replicated flag on <c>EnemyHealth</c> when filtering targets.
|
||||
/// Both are already wired for flight — this card only has to flip the bit before the enemy is
|
||||
/// built.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "FlightAbility", menuName = "TD/Enemy Abilities/Flight")]
|
||||
public class FlightAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.Flight;
|
||||
|
||||
[Header("Flight")]
|
||||
[Tooltip("Height above the ground these enemies hover at. Keep modest — tower targeting " +
|
||||
"uses 3D range, so altitude eats into the effective reach of anything that CAN " +
|
||||
"shoot them.")]
|
||||
[Min(0f)]
|
||||
public float FlightHeight = 3f;
|
||||
|
||||
[Tooltip("Speed multiplier applied when the enemy takes flight. Flying enemies travel a " +
|
||||
"much shorter route, so values below 1 are a reasonable counterweight.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float SpeedMultiplier = 1f;
|
||||
|
||||
public override void ServerModifySpawn(ref EnemySpawnContext context)
|
||||
{
|
||||
// Already-flying enemies keep their authored height rather than being overwritten by
|
||||
// this card's — the wave was designed around it, and stacking Flight onto a flier
|
||||
// shouldn't quietly relocate it.
|
||||
if (!context.IsFlying)
|
||||
{
|
||||
context.IsFlying = true;
|
||||
context.FlightHeight = FlightHeight;
|
||||
}
|
||||
|
||||
context.MoveSpeed *= SpeedMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: be77d353621f07e40bbdab2e9cc37f51
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/GoldTheftAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// An enemy that reaches the defense point doesn't just cost a life — it robs the player whose
|
||||
/// maze it escaped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Charged to the origin zone, not the whole team.</b> Lives are a shared pool, so a leak
|
||||
/// already punishes everyone equally; billing the gold to whoever let it through is the part
|
||||
/// that makes this card bite differently from "enemies cost 2 lives". It also keeps the blame
|
||||
/// legible — the player who leaked is the player who pays.
|
||||
///
|
||||
/// <para>Deducting more gold than a player has simply empties them; there is no debt. Going
|
||||
/// negative would silently disable building until they clawed back to zero, which reads as a
|
||||
/// broken HUD rather than a penalty.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "GoldTheftAbility", menuName = "TD/Enemy Abilities/Gold Theft")]
|
||||
public class GoldTheftAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.GoldTheft;
|
||||
|
||||
[Header("Theft")]
|
||||
[Tooltip("Flat gold taken from the leaking player, on top of the normal life cost.")]
|
||||
[Min(0)]
|
||||
public int GoldStolen = 15;
|
||||
|
||||
public override void ServerOnReachedGoal(EnemyAbility instance, PlayerSlot originZone)
|
||||
{
|
||||
if (GoldStolen <= 0 || originZone == PlayerSlot.None) return;
|
||||
|
||||
var pms = PlayerMatchState.GetForSlot(originZone);
|
||||
if (pms == null) return;
|
||||
|
||||
var gold = PlayerGoldManager.GetForClient(pms.OwnerClientId);
|
||||
if (gold == null) return;
|
||||
|
||||
// Clamp to what they actually hold — a leak shouldn't be able to put a player in debt.
|
||||
int taken = Mathf.Min(GoldStolen, gold.CurrentGold);
|
||||
if (taken <= 0) return;
|
||||
|
||||
gold.DeductGold(taken);
|
||||
|
||||
// Surface it in-world on every peer so the loss isn't just a number quietly ticking
|
||||
// down in the corner. Routed through WaveManager because the popup has to reach
|
||||
// clients and a ScriptableObject has no NetworkBehaviour to send from.
|
||||
WaveManager.Instance?.ServerBroadcastGoldLoss(instance.transform.position, taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3b770ce042b78f44d9a3df537fe25db0
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Assets/_Project/Scripts/Gameplay/EnemyAbilities/NoBountyAbilityDefinition.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
|
||||
namespace TD.Gameplay.EnemyAbilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Killing these enemies pays little or nothing. The wave gets no harder to survive — it gets
|
||||
/// harder to profit from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Attacks the economy, not the maze.</b> Every other card makes a wave more dangerous;
|
||||
/// this one makes clearing it worthless, which compounds instead across cycles — a wave that
|
||||
/// pays nothing on cycle 1 also pays nothing on cycles 2 and 3, so the players who voted it in
|
||||
/// are choosing to be poorer for the rest of the phase. That slow squeeze is the point, and
|
||||
/// it's why the card reads as mild and isn't.
|
||||
///
|
||||
/// <para>Wave-completion and no-leak bonuses are untouched — those are paid per player by
|
||||
/// <c>WaveManager</c>, not per kill, so a wave carrying this still rewards clearing it.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "NoBountyAbility", menuName = "TD/Enemy Abilities/No Bounty")]
|
||||
public class NoBountyAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.NoBounty;
|
||||
|
||||
[Header("Bounty")]
|
||||
[Tooltip("Fraction of the normal kill bounty these enemies still pay. 0 = nothing at all.")]
|
||||
[Range(0f, 1f)]
|
||||
public float BountyMultiplier = 0f;
|
||||
|
||||
public override int ServerModifyKillReward(EnemyAbility instance, int reward)
|
||||
=> Mathf.FloorToInt(reward * BountyMultiplier);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: fd5b12948b24e9c4c8840ef3470b437e
|
||||
|
|
@ -7,32 +7,60 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
/// <summary>
|
||||
/// On death, spawns <see cref="SplitCount"/> smaller copies of the dying enemy's own type at
|
||||
/// the corpse's position, continuing on toward the goal instead of restarting from the wave's
|
||||
/// spawner. There's no separate "minion" EnemyDefinition to author — the mini copies reuse
|
||||
/// whichever EnemyDefinition/prefab the dying enemy itself was spawned from, scaled down by
|
||||
/// <see cref="HpMultiplier"/>/<see cref="ScaleMultiplier"/>.
|
||||
/// spawner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No separate minion asset.</b> The copies reuse whichever <see cref="EnemyDefinition"/>
|
||||
/// and prefab the dying enemy was spawned from; every stat is expressed here as a percentage
|
||||
/// of what the parent actually spawned with, so one card covers any enemy it lands on.
|
||||
///
|
||||
/// <para><b>Percentages are of the PARENT, not the asset.</b> If other cards on the same wave
|
||||
/// have already modified the enemy, the minions scale off the modified values — a split of a
|
||||
/// buffed enemy produces buffed minions, which is what players will expect from watching it.</para>
|
||||
///
|
||||
/// <para><b>Minions inherit no abilities at all</b> — not even this one. That is what makes the
|
||||
/// recursion terminate: a minion of a splitting enemy is a plain enemy, so a wave carrying
|
||||
/// Split produces exactly one extra generation regardless of how many other cards it has
|
||||
/// collected. Enforced at the spawn site, not here.</para>
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(fileName = "SplitOnDeathAbility", menuName = "TD/Enemy Abilities/Split On Death")]
|
||||
public class SplitOnDeathAbilityDefinition : EnemyAbilityDefinition
|
||||
{
|
||||
public override EnemyAbilityKind Kind => EnemyAbilityKind.SplitOnDeath;
|
||||
|
||||
[Header("Split")]
|
||||
[Tooltip("How many mini copies spawn on death.")]
|
||||
[Tooltip("How many copies spawn when the parent dies.")]
|
||||
[Min(1)]
|
||||
public int SplitCount = 2;
|
||||
|
||||
[Tooltip("Each mini copy's MaxHp = the dying enemy's own MaxHp * this multiplier.")]
|
||||
[Range(0.01f, 1f)]
|
||||
public float HpMultiplier = 0.5f;
|
||||
[Header("Minion stats (percent of the parent's spawned values)")]
|
||||
[Tooltip("Minion max HP as a fraction of the parent's max HP.")]
|
||||
[Range(0.01f, 2f)]
|
||||
public float HpPercent = 0.5f;
|
||||
|
||||
[Tooltip("Uniform transform scale applied to each mini copy, relative to the normal " +
|
||||
"prefab scale. Requires the enemy prefab's NetworkTransform to sync scale, " +
|
||||
"or the mini will render full-size on remote peers.")]
|
||||
[Range(0.1f, 1f)]
|
||||
public float ScaleMultiplier = 0.6f;
|
||||
[Tooltip("Minion move speed as a fraction of the parent's speed. Above 1 makes the split " +
|
||||
"faster than what died — the classic 'smaller and quicker' read.")]
|
||||
[Range(0.01f, 3f)]
|
||||
public float SpeedPercent = 1.25f;
|
||||
|
||||
[Tooltip("Random scatter radius (world units) applied to each split spawn so they don't " +
|
||||
"spawn exactly on top of each other.")]
|
||||
[Tooltip("Minion transform scale as a fraction of the parent's scale. Requires the enemy " +
|
||||
"prefab's NetworkTransform to sync scale, or minions render full-size on remote peers.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float ScalePercent = 0.6f;
|
||||
|
||||
[Tooltip("Minion lives cost as a fraction of the parent's, rounded down but never below 1 " +
|
||||
"— a leak always has to hurt, or splits become a way to farm free ground.")]
|
||||
[Range(0f, 2f)]
|
||||
public float LivesCostPercent = 1f;
|
||||
|
||||
[Tooltip("Minion flight height as a fraction of the parent's. Only meaningful when the " +
|
||||
"parent is flying; ground splits ignore it.")]
|
||||
[Range(0.1f, 2f)]
|
||||
public float FlightHeightPercent = 1f;
|
||||
|
||||
[Header("Placement")]
|
||||
[Tooltip("Random scatter radius (world units) applied to each spawn so minions don't " +
|
||||
"stack exactly on top of each other.")]
|
||||
[Min(0f)]
|
||||
public float ScatterRadius = 0.5f;
|
||||
|
||||
|
|
@ -41,12 +69,24 @@ namespace TD.Gameplay.EnemyAbilities
|
|||
var def = health.Definition;
|
||||
if (def == null) return;
|
||||
|
||||
var movement = instance.GetComponent<EnemyMovement>();
|
||||
var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
|
||||
var movement = instance.GetComponent<EnemyMovement>();
|
||||
var atTile = GridCoordinates.WorldToGrid(instance.transform.position);
|
||||
var ownerSlot = movement != null ? movement.OriginZone : PlayerSlot.None;
|
||||
|
||||
// Derive the minion's stats from what the parent actually spawned as.
|
||||
var parent = instance.SpawnContext;
|
||||
var minion = new EnemySpawnContext
|
||||
{
|
||||
MaxHp = Mathf.Max(1f, parent.MaxHp * HpPercent),
|
||||
MoveSpeed = Mathf.Max(0.01f, parent.MoveSpeed * SpeedPercent),
|
||||
IsFlying = parent.IsFlying,
|
||||
FlightHeight = parent.FlightHeight * FlightHeightPercent,
|
||||
LivesCost = Mathf.Max(1, Mathf.FloorToInt(parent.LivesCost * LivesCostPercent)),
|
||||
VisualScale = parent.VisualScale * ScalePercent,
|
||||
};
|
||||
|
||||
WaveManager.Instance?.ServerSpawnSplitEnemies(
|
||||
def, SplitCount, atTile, ownerSlot, ScatterRadius, HpMultiplier, ScaleMultiplier);
|
||||
def, SplitCount, atTile, ownerSlot, ScatterRadius, minion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue