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 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-31 00:32:33 -07:00
parent 4892d7253d
commit 16706a1ecf
154 changed files with 2608 additions and 287 deletions

View file

@ -4,26 +4,38 @@ The 2.0 code landed on `2.0_design_refactor` and compiles clean, but the new sys
**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.
Sections are in **dependency order** — every asset a step references has been created by an
earlier step. Scene wiring comes last, once there is something to assign.
---
## 2. Enemy-buff cards
## 0. Assets vs. scene objects
The single most useful distinction when hunting for a field:
- **`ScriptableObject` → a file in the Project window.** `WaveDefinition`, `WaveGroup`,
`PhaseDefinition`, `RunDefinition`, every ability, every card, every upgrade group.
- **`MonoBehaviour` → a component on a scene GameObject.** Only four:
`RunState`, `WaveVote`, `EnemyUpgradePool`, `EnemyAbilityPool`.
Scene components hold *references* to assets, never the other way round — `RunState` in the scene
points at the `RunDefinition` asset on disk.
Where the assets live, under `Assets/_Project/Definitions/`:
| Asset type | Folder |
|---|---|
| Abilities | `RunDefinitions/EnemyAbilities/` |
| Enemy-buff cards + upgrade groups | `RunDefinitions/Draft/EnemyDraft/` |
| Player draft options | `RunDefinitions/Draft/PlayerDraft/` |
| Wave groups, phases, run | `RunDefinitions/RunStructure/` |
| Individual waves | `RunDefinitions/Waves/` |
| Enemy types | `Enemies/` |
| Gold + enemy scaling configs | `RunDefinitions/` |
---
## 1. Enemy-buff cards
Two asset layers, because network identity and phase gating are separate concerns.
@ -34,28 +46,35 @@ Two asset layers, because network identity and phase gating are separate concern
`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.
**Groups** (`TD/Enemy Upgrades/Upgrade Group`) — the unit of phase gating:
Cut groups by *when a card should become available*, not by what it does, since a group is what
you drag between phases: `EUG_Phase1_Basics`, `EUG_Phase2_Nasty`, and so on. Availability
accumulates — phase 2 lists both Basics and Nasty. For MVP, one group holding every card is fine.
> A card must be in **both** the pool (§4, network identity) and a group (phase gating) to ever be
> offered. Pooled-but-ungrouped is silently never offered — that's how you park an authored card
> you aren't ready to use. Grouped-but-unpooled logs a warning and is skipped.
>
> Keep a card in only **one** group per phase. Two referenced groups holding the same card give it
> two independent shots at the draw, effectively doubling its rate.
---
## 3. Run structure
## 2. Run structure
Build bottom-up:
1. **`WaveGroup`** assets (`TD/Run/Wave Group`) — bags of weighted `WaveDefinition` entries.
Weights are **relative** 01 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`.
2. **`PhaseDefinition`** (`TD/Run/Phase Definition`) — `WaveGroups`, `BossGroups`, and
`EnemyUpgradeGroups` (the groups authored in §1).
3. **`RunDefinition`** (`TD/Run/Run Definition`) — `WavesPerCycle` (5), `CyclesPerPhase` (3),
and the `Phases` array. **MVP needs one phase.**
@ -74,22 +93,86 @@ check the type spread.
---
## 4. Gold config
## 3. Scene objects (both `9Player` and `Main`)
`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.
Everything these reference now exists. Two new GameObjects need a `NetworkObject`; one doesn't.
| GameObject | Component | NetworkObject? | Assign |
|---|---|---|---|
| `RunState` | `RunState` | **Yes** | The `RunDefinition` asset from §2. |
| `WaveVote` | `WaveVote` | **Yes** | Nothing — `Options Per Vote` defaults to 3. |
| `EnemyUpgradePool` | `EnemyUpgradePool` | No | Every **card** asset from §1 into `options`. |
Also update the pool that already exists:
- **`EnemyAbilityPool`** — add every **ability** asset from §1 to its `abilities` array. Its
`noAbilityWeight` field is **gone** (abilities are voted in now, not rolled per enemy).
`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.
>
> `EnemyUpgradePool.options` array order **is** each card's network id. Appending is safe;
> inserting or reordering desyncs a host from clients running a different build.
---
## 5. Player prefab
## 4. Gold config
`GoldConfig.Waves` is now **`GoldConfig.Encounters`**, indexed by global encounter number and
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.
Entries no longer reference a `WaveDefinition`. Payout is a property of *run position*, not of
which wave was drawn — encounter 3 pays encounter 3's rates whatever spawns in it. The inspector
labels each element `Encounter N` and projects per-encounter and cumulative earnings against
`PreviewEnemiesPerEncounter`, a preview-only figure that nothing at runtime reads.
> The existing asset's `Waves` data migrates automatically on first import
> (`[FormerlySerializedAs]`) — but it holds 10 entries and one phase needs 16, so the last six
> encounters (including the boss) pay nothing until they're authored.
---
## 5. Enemy scaling
Create an **`EnemyScalingConfig`** (`TD/Enemy Scaling Config`) and assign it to `WaveManager`
alongside the `GoldConfig`. Without it every enemy spawns at a flat 100 HP and difficulty never
progresses — the run is playable but meaningless.
Three numbers, no per-encounter table:
| Field | Default | Meaning |
|---|---|---|
| `BaseHpBudget` | 5000 | Total enemy health encounter 1 presents, **per player zone** |
| `GrowthPerEncounter` | 1.18 | +18% per encounter → budget doubles roughly every 4 |
| `BossHpMultiplier` | 6 | Extra multiplier on a boss encounter |
**Health is a budget, divided.** Per-enemy HP is the encounter's budget split across the wave's
enemy count, then skewed by the type's `HpMultiplier`. This is what lets waves be drawn at random:
a 150-strong swarm and an 8-strong elite pack in the same slot present the same total threat.
Enemy count becomes a *texture* knob, not a difficulty one.
> **`EnemyDefinition.MaxHp` no longer exists.** It's `HpMultiplier` now — a deviation from budget,
> not hit points. All ten definitions have been re-authored into a 0.71.35 band by archetype
> (golems tanky/slow, ents fragile/quick, drake fastest and flimsiest). **These are placeholder
> values — tune them.**
>
> `MoveSpeed` stays absolute and is deliberately never scaled by run progress. Escalating speed
> compounds with escalating health and invalidates tower balance mid-run; fast-vs-slow is a fixed
> character trait. Escalation is carried by health and the voted buffs.
---
## 6. 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
## 7. 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.
@ -99,7 +182,7 @@ or minions render full-size on remote peers.
---
## 7. Tower upgrade trees
## 8. Tower upgrade trees
1. Author upgraded towers as ordinary `TowerDefinition` assets and add them to
`TowerPlacementManager.towerDefinitions` (**not** to `startingDeck`).
@ -118,6 +201,20 @@ they now unlock an upgrade node. Re-check any that exist.
---
## Phase boundaries reset the board
When a boss dies and a new phase is drawn, **every built tower is destroyed with no refund**
players rebuild their maze from scratch each phase. Nothing to wire; it runs automatically off
`RunAdvance.NextPhase`.
- Queued and in-progress builds **are** refunded — those towers were never delivered, so eating
the gold would just punish having a build queued when the boss happened to die.
- Shelved build sites are cleared too, so no ghosts outlive the maze.
- A system message names the loss in chat, since an entire maze vanishing needs a stated cause.
- The final phase's boss ends the run instead, so no wipe happens there.
---
## Known gaps
- **Upgraded towers keep the parent's mesh.** Switching the definition changes stats immediately