605 lines
28 KiB
Markdown
605 lines
28 KiB
Markdown
# TowerAn — Phase 3: Content, Meta & Retention
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
>
|
||
> **Note on TDD:** This project has **no test infrastructure** (libGDX game, procedural rendering, no existing JUnit/Spock tests). The "write failing test first" ritual is not applicable. Verification is **visual / manual**: run the desktop launcher (`lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher`), reproduce the scenario, confirm the change. Each task lists explicit visual checks in place of test assertions. Introducing a test harness is itself a Phase 3 candidate (Task 0) but is not a prerequisite.
|
||
|
||
**Goal:** Close the content and retention gaps identified by the three game-design agents — new enemy archetypes, boss fight depth, environment variety, audio, onboarding, achievements, daily seeds, save/resume, and full-tree build diversity.
|
||
|
||
**Architecture:** All enemies extend `BaseCircleData` in the flat `ru.project.tower` package. New systems (achievements, daily seed, settings) piggyback on existing `Preferences`-based persistence alongside `PlayerStats`/`PlayerAbilities`. Onboarding is a `FirstScreen` overlay (same pattern as the mid-run upgrade pick added in Phase 2). Biomes are data-driven palette swaps applied each wave.
|
||
|
||
**Tech Stack:** Java 8, libGDX 1.13.1, `ShapeRenderer`/`SpriteBatch`, `Preferences` for persistence, `GlyphLayout` for tooltips.
|
||
|
||
**Prerequisites:** Phases 1 and 2 merged. Run the game at least once to confirm Phase 2 overlays render correctly (the Phase 2 session discovered that `renderUI()`/`renderGameObjects()` are not wired — render pipeline is direct-to-`render()`; new renderers must be called explicitly from `render()`).
|
||
|
||
---
|
||
|
||
## Task ordering
|
||
|
||
Tasks are grouped by area. Within an area they go easiest → hardest. Between areas:
|
||
|
||
1. **Enemy archetypes** (pure data classes, mechanical payoff, low risk)
|
||
2. **Boss depth** (enrage phase, reuses existing frameworks)
|
||
3. **Projectile variants** (ties into existing `BulletData` flags from Phase 1)
|
||
4. **Audio pack** (tiny code, requires asset decisions)
|
||
5. **Biomes** (procedural palette swap)
|
||
6. **Onboarding / tooltips** (touches many screens)
|
||
7. **Achievements & unlocks** (new persistent store)
|
||
8. **Daily seed & run modifiers** (roguelite hook)
|
||
9. **Save/resume mid-run** (serialization work)
|
||
10. **Ability mastery** (XP per ability, optional late-tier)
|
||
|
||
Each Task group ends with a commit. Skip any group that the user explicitly de-prioritizes.
|
||
|
||
---
|
||
|
||
# Area A — Enemy archetypes
|
||
|
||
Two complaints from the variety agent: **only 5 enemy archetypes** (Basic/Strong/Fast/Spawner/Splitter), **all travel in a straight line toward center** (no behaviour differences beyond HP/speed/colour). Add 4 behaviourally distinct enemies. Each is a single file + a tiny spawn-chance hook in `FirstScreen.spawnCircle`.
|
||
|
||
## Task A1: `HealerCircleData` — AoE healer
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/HealerCircleData.java`
|
||
- Modify: `core/src/main/java/ru/project/tower/FirstScreen.java` (add spawn branch + constants)
|
||
|
||
- [ ] **Step 1: Create `HealerCircleData`**
|
||
|
||
```java
|
||
package ru.project.tower;
|
||
|
||
import com.badlogic.gdx.math.Circle;
|
||
import com.badlogic.gdx.math.Vector2;
|
||
import com.badlogic.gdx.utils.TimeUtils;
|
||
|
||
public class HealerCircleData extends BaseCircleData {
|
||
private static final long HEAL_INTERVAL_MS = 1500L;
|
||
private static final float HEAL_RADIUS = 120f;
|
||
private static final int HEAL_AMOUNT = 3;
|
||
private long lastHealTime = TimeUtils.millis();
|
||
|
||
public HealerCircleData(Circle circle, Vector2 velocity, int health, int damage) {
|
||
super(circle, velocity, health, damage);
|
||
}
|
||
|
||
public boolean shouldPulse() {
|
||
if (TimeUtils.millis() - lastHealTime > HEAL_INTERVAL_MS) {
|
||
lastHealTime = TimeUtils.millis();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public static float getHealRadius() { return HEAL_RADIUS; }
|
||
public static int getHealAmount() { return HEAL_AMOUNT; }
|
||
|
||
@Override
|
||
public float[] getColor() {
|
||
return new float[]{0.2f, 1.0f, 0.5f, 1f};
|
||
}
|
||
|
||
@Override
|
||
protected int getBaseReward() { return 4; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add spawn constants in `FirstScreen.java`** near the other enemy constants (search for `SPLITTER_CIRCLE_CHANCE`):
|
||
|
||
```java
|
||
private static final float HEALER_CIRCLE_CHANCE = 0.08f;
|
||
private static final int HEALER_CIRCLE_HEALTH = 6;
|
||
private static final int HEALER_START_WAVE = 8;
|
||
```
|
||
|
||
- [ ] **Step 3: Add spawn branch in `spawnCircle()`** — after the `isSplitterCircle` branch, before `isStrongCircle`:
|
||
|
||
```java
|
||
boolean isHealerCircle = !isSpawnerCircle && !isFastCircle && !isSplitterCircle
|
||
&& currentWave >= HEALER_START_WAVE
|
||
&& random < (currentFastCircleChance + currentSplitterCircleChance + HEALER_CIRCLE_CHANCE);
|
||
```
|
||
|
||
Then add a construction branch mirroring the other archetypes and set `spawned = new HealerCircleData(...)`.
|
||
|
||
- [ ] **Step 4: Pulse loop in the per-frame circle iterator** (same block that spawns from `SpawnerCircleData`):
|
||
|
||
```java
|
||
if (circleData instanceof HealerCircleData) {
|
||
HealerCircleData healer = (HealerCircleData) circleData;
|
||
if (healer.shouldPulse()) {
|
||
for (BaseCircleData other : circles) {
|
||
if (other == healer) continue;
|
||
if (Vector2.dst(healer.getX(), healer.getY(), other.getX(), other.getY())
|
||
<= HealerCircleData.getHealRadius()) {
|
||
other.health += HealerCircleData.getHealAmount();
|
||
}
|
||
}
|
||
createExplosion(healer.getX(), healer.getY(),
|
||
new Color(0.2f, 1f, 0.5f, 1f));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Visual check** — play until wave 8, spot a green enemy, watch nearby enemies gain HP (visible because `StrongCircleData.getColor` scales alpha with health — strong enemies near a healer should stay bright blue).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add core/src/main/java/ru/project/tower/HealerCircleData.java \
|
||
core/src/main/java/ru/project/tower/FirstScreen.java
|
||
git commit -m "feat: add HealerCircleData AoE healer enemy"
|
||
```
|
||
|
||
---
|
||
|
||
## Task A2: `DasherCircleData` — telegraphed charge
|
||
|
||
Uses `JuggernautData`'s charge pattern at a smaller scale for a rank-and-file enemy.
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/DasherCircleData.java`
|
||
- Modify: `FirstScreen.java` (spawn branch, per-frame state tick, render tint for the "windup" phase)
|
||
|
||
- [ ] **Step 1: Create class** — holds a `long dashReadyAt` and `boolean dashing`. At `update()` time: if idle and in range of player, enter windup for 800ms (velocity halved, visually pulses yellow), then lock a direction, multiply velocity ×3 for 400ms, return to normal.
|
||
|
||
```java
|
||
package ru.project.tower;
|
||
|
||
import com.badlogic.gdx.math.Circle;
|
||
import com.badlogic.gdx.math.Vector2;
|
||
import com.badlogic.gdx.utils.TimeUtils;
|
||
|
||
public class DasherCircleData extends BaseCircleData {
|
||
private static final long WINDUP_MS = 800L;
|
||
private static final long DASH_MS = 400L;
|
||
private static final float DASH_SPEED_MULT = 3f;
|
||
private enum State { IDLE, WINDUP, DASHING }
|
||
private State state = State.IDLE;
|
||
private long stateEnter = 0;
|
||
private Vector2 baseVelocity;
|
||
|
||
public DasherCircleData(Circle circle, Vector2 velocity, int health, int damage) {
|
||
super(circle, velocity, health, damage);
|
||
this.baseVelocity = new Vector2(velocity);
|
||
}
|
||
|
||
public boolean isWindup() { return state == State.WINDUP; }
|
||
public boolean isDashing() { return state == State.DASHING; }
|
||
|
||
public void tick(float squareX, float squareY) {
|
||
long now = TimeUtils.millis();
|
||
switch (state) {
|
||
case IDLE:
|
||
if (Vector2.dst(getX(), getY(), squareX, squareY) < 280f) {
|
||
state = State.WINDUP;
|
||
stateEnter = now;
|
||
velocity.scl(0.3f);
|
||
}
|
||
break;
|
||
case WINDUP:
|
||
if (now - stateEnter > WINDUP_MS) {
|
||
state = State.DASHING;
|
||
stateEnter = now;
|
||
float dx = squareX - getX(), dy = squareY - getY();
|
||
float len = (float)Math.sqrt(dx*dx + dy*dy);
|
||
if (len > 0f) {
|
||
velocity.set(dx/len, dy/len).scl(baseVelocity.len() * DASH_SPEED_MULT);
|
||
}
|
||
}
|
||
break;
|
||
case DASHING:
|
||
if (now - stateEnter > DASH_MS) {
|
||
state = State.IDLE;
|
||
velocity.set(baseVelocity);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public float[] getColor() {
|
||
switch (state) {
|
||
case WINDUP: return new float[]{1f, 0.9f, 0.3f, 1f};
|
||
case DASHING: return new float[]{1f, 0.4f, 0.1f, 1f};
|
||
default: return new float[]{0.9f, 0.6f, 0.2f, 1f};
|
||
}
|
||
}
|
||
|
||
@Override
|
||
protected int getBaseReward() { return 3; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Spawn branch** in `spawnCircle` with `currentWave >= 6` and chance 0.10.
|
||
|
||
- [ ] **Step 3: Call `dasher.tick(squareCenterX, squareCenterY)`** inside the per-frame circles loop (same spot that handles spawner spawning).
|
||
|
||
- [ ] **Step 4: Visual check** — from wave 6 a dasher should pulse yellow for ~0.8s then streak toward the square at 3× speed. Good stress test for Dash/Shield abilities.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
## Task A3: `SniperCircleData` — ranged shooter
|
||
|
||
Stays at range, fires `BulletData(isEnemyBullet=true)` projectiles.
|
||
|
||
**Files:** as above.
|
||
|
||
- [ ] **Step 1: Class holds `long lastShotAt` and overrides movement** — moves toward player until distance ~= 400, then stops (velocity set to 0) and fires every 2s.
|
||
|
||
- [ ] **Step 2: Spawning & firing** mirror `ShadowWeaverData.shouldShoot()` usage on lines 5935–5940 of current FirstScreen.
|
||
|
||
- [ ] **Step 3: Balance** — damage = 2× basic damage, HP = 0.5× basic.
|
||
|
||
- [ ] **Step 4: Visual check** — sniper stops in a ring ~400px from square, red tracer shots visible. Shield/Dash should block them.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
## Task A4: `GnatCircleData` — 1-HP swarm
|
||
|
||
Fastest type, 1 HP, spawned in packs. Overrides `BaseCircleData`'s single-spawn assumption by inserting a small "swarm" around a spawn roll.
|
||
|
||
**Files:** as above.
|
||
|
||
- [ ] **Step 1: Class is trivial** — health always 1, speed 1.8× basic, radius 0.6× basic.
|
||
|
||
- [ ] **Step 2: Spawn as a pack** — when rolled, call `spawnCircle`-like helper 5 times with small offsets around the roll point. Add `isGnatSwarm` branch before the Basic branch.
|
||
|
||
- [ ] **Step 3: Visual check** — from wave 10+, occasional pack of tiny pink dots darts in. AoE abilities should shred them.
|
||
|
||
- [ ] **Step 4: Commit.**
|
||
|
||
---
|
||
|
||
# Area B — Boss depth
|
||
|
||
## Task B1: Enrage phase for all 6 bosses
|
||
|
||
Add a shared `BaseBossData` abstract class **or** a `BossEnrage` mixin-helper invoked from each boss. Pragmatic path: helper invoked from each boss's `update()`.
|
||
|
||
**Files:**
|
||
- Modify: `JuggernautData`, `SwarmQueenData`, `GlacialWardenData`, `IllusionMasterData`, `ShadowWeaverData`, `VolatileReactorData`
|
||
|
||
- [ ] **Step 1: Add per-boss `maxHealth` field** — captured at construction time. Boss HP is already scaled in `spawnRandomBoss`; snapshot right after construction.
|
||
|
||
- [ ] **Step 2: Add `boolean isEnraged()` per boss** — `health / maxHealth < 0.33f`. Cache the flag so enrage triggers once.
|
||
|
||
- [ ] **Step 3: Multiply attack cadence by 1.5× when enraged** — find each `shouldShoot`/`shouldCharge`/`shouldCreateFireZone` cadence constant and divide by `isEnraged() ? 1.5f : 1f`.
|
||
|
||
- [ ] **Step 4: Visual/audio telegraph** — one-time screen shake + red flash + `"ENRAGE!"` floating text when transitioning. Trigger from `FirstScreen.updateBosses` by checking `boss.isEnraged() && !boss.enrageTriggered`.
|
||
|
||
- [ ] **Step 5: Visual check** — boss at low HP flashes red, floating text appears, projectile cadence noticeably faster.
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
## Task B2: De-templatize the "projectile barrage + summon" pattern
|
||
|
||
Agent noted 4 of 6 bosses use the same `burst + minions` pattern. Low-effort differentiator:
|
||
|
||
- [ ] **Step 1: Give `ShadowWeaverData` a beam attack** (continuous line that deals 1 dmg/tick instead of its volley).
|
||
- [ ] **Step 2: Give `GlacialWardenData` a wall-spawn** (creates temporary immovable obstacles that bullets collide with).
|
||
- [ ] **Step 3: Commit.**
|
||
|
||
_(Lower priority — skip if schedule tight.)_
|
||
|
||
---
|
||
|
||
# Area C — Projectile variants exposed to the player
|
||
|
||
Phase 1 added `piercesLeft / homing / aoeRadius` to `BulletData` and Phase 2 wired pierce + AoE to keystones. `homing` is defined but unimplemented.
|
||
|
||
## Task C1: Implement homing
|
||
|
||
**Files:** `FirstScreen.java`, `BulletData.java`
|
||
|
||
- [ ] **Step 1:** In the bullet-update loop (where `bulletData.move(delta)` lives), if `bullet.homing && !bullet.isEnemyBullet()`: find nearest enemy within 400px, compute desired velocity direction, lerp `bullet.velocity` toward it by `0.06f` per frame (caps turn rate so homing feels responsive but not snap-on).
|
||
|
||
- [ ] **Step 2:** Add a new **Keystone talent** `keystone_homing` (tier 3) that sets `bullet.homing = true` on all bullets when level >= 1. Add registration in `TalentTree.initializeTree`, add `"keystone_homing"` to `PlayerStats.TALENT_NODE_IDS`.
|
||
|
||
- [ ] **Step 3: Visual check** — with the keystone bought, bullets curve toward enemies.
|
||
|
||
- [ ] **Step 4: Commit.**
|
||
|
||
## Task C2: Chain lightning on hit
|
||
|
||
New per-bullet flag `chainJumps` (int). On collision, if `chainJumps > 0`, find nearest enemy within 200px, spawn a short-lived line render + deal damage, decrement.
|
||
|
||
- [ ] **Step 1:** Add field to `BulletData`.
|
||
- [ ] **Step 2:** Add collision-site chain logic in `FirstScreen` bullet-vs-circle resolution block (near the existing `bulletData.aoeRadius > 0f` check).
|
||
- [ ] **Step 3:** Render the chain as a thin additive-blend line from hit-point to hit-target, alpha fading over 150ms.
|
||
- [ ] **Step 4:** New keystone `keystone_chain`. Each level = +1 jump.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
# Area D — Audio pack
|
||
|
||
The project ships with **2 sounds** (`pop.wav`, `shot.wav`) and 1 PNG. Need a base pack. **Asset dependency** — cannot be completed in a code-only session. The plan's task is to wire up the loader; the user supplies the files.
|
||
|
||
## Task D1: Sound asset loader
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/SoundAssets.java`
|
||
- Modify: `FirstScreen.java`
|
||
|
||
- [ ] **Step 1: Create `SoundAssets` singleton** that lazy-loads and caches `Sound` handles:
|
||
|
||
```java
|
||
public class SoundAssets {
|
||
private static SoundAssets instance;
|
||
private final Map<String, Sound> cache = new HashMap<>();
|
||
|
||
public static SoundAssets get() {
|
||
if (instance == null) instance = new SoundAssets();
|
||
return instance;
|
||
}
|
||
|
||
public Sound load(String filename) {
|
||
Sound s = cache.get(filename);
|
||
if (s != null) return s;
|
||
FileHandle f = Gdx.files.internal(filename);
|
||
if (!f.exists() || f.length() < 200) return null;
|
||
try { s = Gdx.audio.newSound(f); cache.put(filename, s); return s; }
|
||
catch (Exception e) { return null; }
|
||
}
|
||
|
||
public void play(String filename, float volume, float pitch) {
|
||
Sound s = load(filename);
|
||
if (s != null) s.play(volume, pitch, 0f);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Replace direct `Gdx.audio.newSound` in `FirstScreen.show`** with `SoundAssets.get().load(...)`.
|
||
|
||
- [ ] **Step 3: Add explicit call sites** for the 10 events the variety agent listed. For each event, add `SoundAssets.get().play("<name>.wav", vol, pitch)`:
|
||
- `player_hit.wav` — in the 5 damage-to-player sites added in Phase 1
|
||
- `boss_spawn.wav` — in `spawnRandomBoss`
|
||
- `ability_freeze.wav` / `_explosion.wav` / `_shield.wav` / `_impulse.wav` / `_dash.wav` / `_pull.wav` / `_turret.wav` — start of each `activateX` method
|
||
- `wave_start.wav` — first line of `startNextWave`
|
||
- `wave_clear.wav` — in `endWave` after bonus calculation
|
||
- `game_over.wav` — first line of `gameOver`
|
||
- `ui_click.wav` — in `handleUpgradeButtons` / `handleUpgradePickTouch` after a successful button hit
|
||
|
||
- [ ] **Step 4:** Missing files degrade silently (loader returns null → play is no-op). No crash if user hasn't supplied assets yet.
|
||
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
## Task D2: Music loop slots
|
||
|
||
- [ ] **Step 1:** Add `Music menuTrack, combatTrack, bossTrack` fields; load via `Gdx.audio.newMusic(Gdx.files.internal("music/...ogg"))` guarded by `exists()`.
|
||
- [ ] **Step 2:** Start combat track in `show()`; switch to boss track when a boss is active (`isAnyBossOnScreen`); restore combat track when boss dies.
|
||
- [ ] **Step 3: Commit.**
|
||
|
||
---
|
||
|
||
# Area E — Biomes / palette variety
|
||
|
||
One `BACKGROUND_COLOR`, one particle palette. Agent: rotate every ~5 waves.
|
||
|
||
## Task E1: `BiomePalette` data class + per-wave selection
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/BiomePalette.java`
|
||
- Modify: `FirstScreen.java`
|
||
|
||
- [ ] **Step 1: Create class**
|
||
|
||
```java
|
||
public class BiomePalette {
|
||
public final Color background;
|
||
public final Color particle1;
|
||
public final Color particle2;
|
||
public final String label;
|
||
public BiomePalette(Color bg, Color p1, Color p2, String label) {
|
||
this.background = bg; this.particle1 = p1; this.particle2 = p2; this.label = label;
|
||
}
|
||
public static final BiomePalette[] ALL = {
|
||
new BiomePalette(new Color(0.05f,0.05f,0.10f,1f), new Color(0.2f,0.6f,1f,1f), new Color(1f,0.2f,0.6f,1f), "Neon City"),
|
||
new BiomePalette(new Color(0.02f,0.08f,0.05f,1f), new Color(0.2f,1f,0.5f,1f), new Color(1f,1f,0.3f,1f), "Bio Jungle"),
|
||
new BiomePalette(new Color(0.08f,0.02f,0.02f,1f), new Color(1f,0.3f,0.1f,1f), new Color(1f,0.8f,0.2f,1f), "Magma Forge"),
|
||
new BiomePalette(new Color(0.03f,0.03f,0.12f,1f), new Color(0.5f,0.2f,1f,1f), new Color(0.2f,0.9f,1f,1f), "Void Shift"),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Select in `startNextWave`** — `currentBiome = BiomePalette.ALL[(currentWave / 5) % BiomePalette.ALL.length]`. Replace the static `BACKGROUND_COLOR` references in `render()` with `currentBiome.background`.
|
||
|
||
- [ ] **Step 3: Background particle colors** — in the background particle spawn logic, pick randomly between `currentBiome.particle1` and `particle2`.
|
||
|
||
- [ ] **Step 4: Biome transition floating text** — when biome changes, spawn a 3-second floating text with `currentBiome.label`.
|
||
|
||
- [ ] **Step 5: Visual check** — at waves 5, 10, 15, 20 the background and particle colors rotate.
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
## Task E2: Random wave modifiers
|
||
|
||
Once per run, at wave-start, roll a 25% chance of a modifier from a small list: "double gravity" (pulls enemies toward center faster), "fog" (shooting range -30%), "rush" (+25% enemy speed, +25% reward). Display as floating text at wave start. Stores as fields on `FirstScreen`; cleared in `endWave`.
|
||
|
||
- [ ] **Step 1:** Add `enum WaveModifier { NONE, GRAVITY, FOG, RUSH }` and `WaveModifier currentWaveModifier`.
|
||
- [ ] **Step 2:** Roll in `startNextWave`.
|
||
- [ ] **Step 3:** Apply effects: GRAVITY → multiply `currentEnemySpeedMultiplier` by 1.3 and pull velocity toward center each tick; FOG → shrink `SHOOTING_RANGE` effective value by 0.7; RUSH → multiply speed × 1.25, reward × 1.25.
|
||
- [ ] **Step 4:** Floating text at wave start with the label.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
# Area F — Onboarding
|
||
|
||
## Task F1: Tutorial overlay for first run
|
||
|
||
**Files:**
|
||
- Modify: `FirstScreen.java`, `PlayerStats.java` (add `tutorialSeen` preference)
|
||
|
||
- [ ] **Step 1:** Add boolean `tutorialSeen` to `PlayerStats` persisted in Preferences, default false.
|
||
- [ ] **Step 2:** In `FirstScreen.show`, if `!playerStats.tutorialSeen`, set `showTutorialStep = 0` and skip auto-starting wave 1.
|
||
- [ ] **Step 3:** Add `renderTutorialOverlay()` method — dims the screen and shows one tip at a time with an arrow pointing at the relevant UI region.
|
||
- Step 0: "Автоматическая турель в центре стреляет по ближайшему врагу"
|
||
- Step 1: "Кнопки способностей снизу — жмите когда заряжены"
|
||
- Step 2: "Каждые 3 волны выбирайте улучшение"
|
||
- Step 3: "Магазин и дерево талантов в главном меню"
|
||
- [ ] **Step 4:** Tap advances. Last step → `tutorialSeen = true; saveData();` and wave 1 starts.
|
||
- [ ] **Step 5: Call `renderTutorialOverlay()` at the end of `render()`** (alongside `renderUpgradePickOverlay`).
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
## Task F2: Tooltips on upgrade/talent/ability buttons
|
||
|
||
- [ ] **Step 1:** Add `tooltipText` field on each button and track hover state (already tracked in `renderNeonButton` via `isHovered`).
|
||
- [ ] **Step 2:** When hovered, render a small panel above the button with text. One pass, one method — `drawTooltip(Rectangle btn, String text)`.
|
||
- [ ] **Step 3:** Wire per button — damage/speed/cooldown upgrades get stat deltas, ability buttons get radius/damage/duration values.
|
||
- [ ] **Step 4: Commit.**
|
||
|
||
---
|
||
|
||
# Area G — Achievements & unlocks
|
||
|
||
## Task G1: Achievements store
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/Achievements.java`
|
||
- Modify: `FirstScreen.java` (hook increment calls)
|
||
|
||
- [ ] **Step 1: Define enum** of 15 achievements:
|
||
- `FIRST_BLOOD` (1 kill), `KILL_100`, `KILL_1000`
|
||
- `WAVE_10`, `WAVE_25`, `WAVE_50`
|
||
- `BOSS_ALL_6` (defeat each boss once)
|
||
- `NO_HIT_WAVE` (complete a wave with no damage taken)
|
||
- `FREEZE_100_FROZEN`, `SHATTER_50`
|
||
- `ELITE_KILL_100`
|
||
- `TALENT_TREE_COMPLETE`, `FULL_ABILITY_ROSTER`
|
||
- `DAILY_SEED_FIRST`, `RUN_WITH_ALL_3_KEYSTONES`
|
||
|
||
- [ ] **Step 2: `Achievements` singleton with `Preferences`-backed unlocked set and counters**. `increment(key)`, `unlock(id)`, `isUnlocked(id)`. Each unlock awards a currency bounty (e.g., 25 for easy, 100 for hard).
|
||
|
||
- [ ] **Step 3: Hook call sites** — kill counter in the bullet-kill block, wave counter in `endWave`, boss-defeated in the circle.health<=0 block when `isBossCircle`, etc.
|
||
|
||
- [ ] **Step 4: Unlock toast** — on first unlock of an achievement, floating text "ДОСТИЖЕНИЕ: <name> (+N монет)" + shake.
|
||
|
||
- [ ] **Step 5:** Add an "Achievements" button on `MainScreen` → new `AchievementsScreen` with a scrollable list (use same stage/skin style as `AbilityShopScreen`).
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
## Task G2: Cosmetic unlocks
|
||
|
||
Simple: square tint unlocks (5 colours unlocked by achievement thresholds). Stored in `PlayerStats.selectedTint`. Applied in `renderNeonSquare`.
|
||
|
||
- [ ] **Step 1:** Add `String selectedTint` / list of unlocked tints to `PlayerStats`.
|
||
- [ ] **Step 2:** Cosmetics screen launched from `MainScreen`.
|
||
- [ ] **Step 3: Commit.**
|
||
|
||
---
|
||
|
||
# Area H — Daily seed & run modifiers
|
||
|
||
## Task H1: Daily seed
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/DailySeed.java`
|
||
- Modify: `MainScreen.java`, `FirstScreen.java`
|
||
|
||
- [ ] **Step 1:** `DailySeed.todaySeed()` returns `LocalDate.now().toString().hashCode()`.
|
||
- [ ] **Step 2:** New "Daily Challenge" button on main menu that starts a run with `MathUtils.random.setSeed(seed)` injected into `FirstScreen` before first `spawnCircle`.
|
||
- [ ] **Step 3:** Record best-wave-today in Preferences keyed by date. Display on main menu "Daily: best W14".
|
||
- [ ] **Step 4:** At wave end in daily mode, also save to a rolling 7-day log for a "last 7 days" leaderboard view.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
## Task H2: Pre-run modifiers
|
||
|
||
Before starting a run, offer an optional modifier card: "+30% reward if all 3 abilities selected", "Bosses +50% HP / reward ×2", etc. Applied as `GameState.runModifier` flag, checked in relevant methods.
|
||
|
||
- [ ] Deferred — pair with Area E2 work.
|
||
|
||
---
|
||
|
||
# Area I — Save mid-run
|
||
|
||
Agent noted `GameState` is in-memory only. Serialize run to JSON on pause/quit so players can resume.
|
||
|
||
**Files:**
|
||
- Modify: `GameState.java`, `FirstScreen.java` (serialize on pause, deserialize on show)
|
||
|
||
- [ ] **Step 1:** Add `toJson()`/`fromJson(String)` on `GameState` using libGDX `Json`. Include: `currentWave`, `squareHealth`, `maxSquareHealth`, `money`, `runDamageBonus`, `runPierceBonus`, etc. (the Phase 2 `run*` fields).
|
||
- [ ] **Step 2:** On pause (`shouldPause` branch), write JSON string into `Preferences`.
|
||
- [ ] **Step 3:** On `FirstScreen.show`, if a saved run exists and user chose "Resume" from main menu, deserialize and jump to `startNextWave`-equivalent state.
|
||
- [ ] **Step 4:** Main menu gets a "Resume" button that appears only when save exists.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
# Area J — Ability mastery
|
||
|
||
Roguelite depth — each ability gains XP as it's used; XP unlocks passive modifiers.
|
||
|
||
**Files:**
|
||
- Create: `core/src/main/java/ru/project/tower/AbilityMastery.java`
|
||
- Modify: `FirstScreen.java` (increment on each cast), `AbilitySelectionScreen.java` (show levels)
|
||
|
||
- [ ] **Step 1:** `AbilityMastery` singleton tracks `int masteryXp[ability]` persisted in Preferences. Level = `floor(sqrt(xp/10))`.
|
||
- [ ] **Step 2:** Increment 10 XP per cast; 50 XP per "meaningful kill" (freeze of 5+, explosion of 10+ etc.)
|
||
- [ ] **Step 3:** Unlocks per level:
|
||
- Freeze L2: +30% radius; L4: +25% duration; L6: enemies under Freeze take +25% bullet damage.
|
||
- Explosion L2: +30% radius; L4: applies brief slow; L6: double damage vs frozen (stacks with Phase 1 synergy).
|
||
- Shield L2: +1s duration; L4: reflects projectiles; L6: explosion on expire.
|
||
- Impulse L2: +30% force; L4: +50% damage to pushed enemies; L6: also applies freeze 500ms.
|
||
- Dash L2: +150ms i-frames; L4: leaves fire trail; L6: double-dash (2 charges).
|
||
- Pull L2: +30% radius; L4: pulled enemies take +20% damage for 2s; L6: pulls bosses briefly.
|
||
- Turret L2: +3s lifetime; L4: +25% fire rate; L6: turret shots pierce +1.
|
||
- [ ] **Step 4: Visualise** — in `AbilitySelectionScreen`, show level number on each selected ability card.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
# Area K — Test harness (optional, enabler for future work)
|
||
|
||
Not required for any Phase 3 task but strongly suggested before Phase 4.
|
||
|
||
- [ ] **Step 1:** Add JUnit 5 to `core/build.gradle`: `testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0'`.
|
||
- [ ] **Step 2:** Headless libGDX test scaffolding via `Headless Application` — enough for `PlayerStats`, `Achievements`, `TalentTree`, `DailySeed` logic tests.
|
||
- [ ] **Step 3:** One sample test per class to prove the pattern.
|
||
- [ ] **Step 4:** Document in README the `gradle test` command.
|
||
- [ ] **Step 5: Commit.**
|
||
|
||
---
|
||
|
||
## Priority summary
|
||
|
||
| Priority | Area | Rationale |
|
||
| --- | --- | --- |
|
||
| P1 | A1 Healer, A3 Sniper, B1 Enrage, E1 Biomes | Cheap, high-impact variety; no new assets |
|
||
| P1 | F1 Tutorial, F2 Tooltips | Fixes onboarding (agent's #3 critical gap) |
|
||
| P1 | G1 Achievements (core only) | Fixes retention goals (agent's #2 critical gap) |
|
||
| P2 | A2 Dasher, A4 Gnat, C1 Homing, C2 Chain | More variety + keystone payoffs |
|
||
| P2 | H1 Daily seed | Flywheel for returning players |
|
||
| P2 | I Save mid-run | Mobile QoL |
|
||
| P3 | D1 Audio pack | Requires assets |
|
||
| P3 | B2 Boss de-templating | Large rework, lower leverage |
|
||
| P3 | J Ability mastery | Late-tier depth |
|
||
| P3 | E2 Wave modifiers, H2 Pre-run modifiers | Nice-to-have |
|
||
| P4 | K Test harness | Enabler for Phase 4 |
|
||
|
||
Implement top-to-bottom. P1 block (A1+A3+B1+E1+F1+F2+G1) is the minimum that addresses all three agent reports.
|
||
|
||
---
|
||
|
||
## Self-review notes
|
||
|
||
- **Spec coverage:** Every top-level gap from the three agent reports maps to at least one task. Elite modifier and 3 new abilities (Dash/Pull/Turret) from Phase 2 aren't re-planned here; homing is the only `BulletData` flag that was dormant.
|
||
- **Placeholder scan:** Task B2 is the only explicitly-deferred item; flagged P3. All other code steps show the code.
|
||
- **Type consistency:** `HealerCircleData.getHealRadius()` used from `FirstScreen` matches class definition. `BiomePalette.ALL` array is the public API used in `startNextWave`. `Achievements.increment(String key)` signature referenced in Task G1.step3 matches Task G1.step2.
|
||
- **Out-of-scope:** Multiplayer, shader-based bloom, and new fonts are not in this plan. Boss entry cinematics, cosmetic particle trails, and lore text are intentionally skipped — re-open after data shows engagement.
|
||
|
||
---
|
||
|
||
## Execution handoff
|
||
|
||
This plan is saved as `docs/superpowers/plans/2026-04-17-phase3-content-meta-retention.md`. When you're ready to execute:
|
||
|
||
1. **Subagent-Driven (recommended):** Fresh subagent per task group (A, B, ... J), review between groups. Use `superpowers:subagent-driven-development`.
|
||
2. **Inline Execution:** Step through in-session with `superpowers:executing-plans`, checkpoint after each area.
|
||
|
||
Tell Claude which approach and which priority tier (P1 only? P1+P2? full sweep?) to run.
|