Initial commit
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Phase 3.K.1 — Completion Report
|
||||
|
||||
**Date completed:** 2026-04-17
|
||||
**Source plan:** `2026-04-17-phase3-k1-test-harness.md`
|
||||
**Source spec:** `../specs/2026-04-17-phase3-k1-test-harness-design.md`
|
||||
**Execution mode:** subagent-driven-development (fresh subagent per task, two-stage review).
|
||||
|
||||
## Status
|
||||
|
||||
- **Code:** ✅ Shipped. All 11 tasks complete.
|
||||
- **Automated verification:** ✅ `./gradlew core:test` → 58/58 green. `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL.
|
||||
- **Manual smoke test:** ⏳ Pending user — see checklist below.
|
||||
- **Git:** project is not a git repository; no commits were made. All work lives in the working tree.
|
||||
|
||||
## What was delivered
|
||||
|
||||
### New files (10)
|
||||
|
||||
Production (1):
|
||||
- `core/src/main/java/ru/project/tower/GameContext.java` — composition-root holder for shared persistent-state services. `public final` fields for `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, and two `Preferences` refs.
|
||||
|
||||
Tests (9):
|
||||
- `core/src/test/java/ru/project/tower/HarnessSanityTest.java`
|
||||
- `core/src/test/java/ru/project/tower/FakePreferences.java` — in-memory `Preferences` impl, reused by every downstream test.
|
||||
- `core/src/test/java/ru/project/tower/FakePreferencesTest.java`
|
||||
- `core/src/test/java/ru/project/tower/PlayerStatsTest.java`
|
||||
- `core/src/test/java/ru/project/tower/PlayerAbilitiesTest.java`
|
||||
- `core/src/test/java/ru/project/tower/GameStateTest.java`
|
||||
- `core/src/test/java/ru/project/tower/TalentTreeTest.java`
|
||||
- `core/src/test/java/ru/project/tower/TalentDataTest.java`
|
||||
- `core/src/test/java/ru/project/tower/GameContextTest.java`
|
||||
|
||||
### Modified files (10)
|
||||
|
||||
Production classes (singletons → DI):
|
||||
- `PlayerStats.java` — public `PlayerStats(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `Gdx` import deleted, `preferences` field made `final`.
|
||||
- `PlayerAbilities.java` — public `PlayerAbilities(Preferences)` ctor, `getInstance()` deleted, `instance` field deleted, `PREFS_NAME` deleted, `preferences` field made `final`. `Gdx` import kept (used by `Gdx.app.error(...)`).
|
||||
- `GameState.java` — ctor made public, `getInstance()` deleted, `instance` field deleted.
|
||||
- `TalentTree.java` — public `TalentTree(PlayerStats)` ctor, `getInstance()` deleted, `instance` field deleted, two internal `PlayerStats.getInstance()` calls replaced with injected `stats` field, `talents`/`roots` made `final`.
|
||||
|
||||
Composition root + screens:
|
||||
- `Main.java` — rewritten; builds `GameContext` from two `Preferences`, passes to `MainScreen`.
|
||||
- `MainScreen.java` — 2-arg ctor, 3 downstream `new X(game)` → `new X(game, ctx)`.
|
||||
- `FirstScreen.java` — 2-arg ctor, 3 new cached fields (`ctx`, `gameState`, `talentTree`), 4 cached services assigned at ctor top (closed an NPE window), 13 `getInstance()` call-sites replaced, internal `new MainScreen(game)` → `new MainScreen(game, ctx)`, 2 redundant local shadows removed.
|
||||
- `AbilityShopScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`.
|
||||
- `AbilitySelectionScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(gameInstance, ctx)` + `new FirstScreen(gameInstance, ctx)`.
|
||||
- `TalentTreeScreen.java` — 2-arg ctor, 2 `getInstance()` replacements, internal `new MainScreen(game, ctx)`.
|
||||
|
||||
Build:
|
||||
- `core/build.gradle` — `testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'`, `testImplementation 'org.assertj:assertj-core:3.26.3'`, `test { useJUnitPlatform() }`.
|
||||
|
||||
### Test counts (by class)
|
||||
|
||||
| Test class | Tests |
|
||||
|---|---|
|
||||
| HarnessSanityTest | 1 |
|
||||
| FakePreferencesTest | 7 |
|
||||
| PlayerStatsTest | 14 |
|
||||
| PlayerAbilitiesTest | 8 |
|
||||
| GameStateTest | 6 |
|
||||
| TalentTreeTest | 12 |
|
||||
| TalentDataTest | 6 |
|
||||
| GameContextTest | 4 |
|
||||
| **Total** | **58** |
|
||||
|
||||
### Invariants verified at completion
|
||||
|
||||
- `grep "getInstance(" core/src/main/java/ru/project/tower -r` → 0 matches.
|
||||
- `grep "private static \w+ instance;" core/src/main/java/ru/project/tower -r` → 0 matches.
|
||||
- `./gradlew core:test` → 58 passed, 0 failed.
|
||||
- `./gradlew :core:build :lwjgl3:build` → BUILD SUCCESSFUL.
|
||||
|
||||
## Manual smoke test — ⏳ pending
|
||||
|
||||
Run `lwjgl3/src/main/java/ru/project/tower/lwjgl3/Lwjgl3Launcher` and verify:
|
||||
|
||||
- [ ] Main menu renders with persisted currency + max-wave.
|
||||
- [ ] Talent Tree: purchased levels display, new purchase deducts currency, back returns to menu.
|
||||
- [ ] Ability Shop: owned abilities display correctly, new purchase (e.g., Shield) persists after a restart.
|
||||
- [ ] Ability Selection → Start Game → wave 1 plays, firing kills enemies, death returns to menu, currency reflects loot.
|
||||
- [ ] Mid-run shop flow: save/restore works (upgrade survives shop round-trip).
|
||||
- [ ] Close and relaunch: all persisted state (currency, max wave, unlocked abilities, talent levels) survives.
|
||||
|
||||
If any check fails, report back — most likely a missed call-site or a `ctx` threading bug; fix scope is minutes, not hours.
|
||||
|
||||
## Carry-over items (non-blocking)
|
||||
|
||||
These were flagged by reviewers during K.1 execution but are either pre-existing (not regressions introduced by K.1) or are tiny cleanups better folded into the next spec. They do not block K.2.
|
||||
|
||||
### Pre-existing design issues surfaced by the new test harness
|
||||
|
||||
- **`PlayerStats.getRegenTalentLevel()` sums only `regen_1`.** Other aggregate getters sum two nodes. If a `regen_2` node is added later, this getter silently reports the wrong value. Add a `regen_2` node or a comment explaining the omission. (Flagged in Task 3 review.)
|
||||
- **`PlayerStats.incrementTalentNode(String)` accepts any string, including typos.** Phantom nodes are created in the in-memory `talentLevels` map but not persisted (saveData iterates `TALENT_NODE_IDS`). Consider a validation guard. (Flagged in Task 3 review.)
|
||||
- **`PlayerStats.resetData()` Javadoc says "для тестирования"** but the test suite doesn't call it — callers live elsewhere. Clarify the comment or remove the method. (Task 3 review.)
|
||||
- **`PlayerAbilities.resetData()` clears everything, including `freeze` and `explosion` which default to `true` on construction.** Intentional per the existing code, but the name implies "factory defaults" which it isn't. Rename or add a comment. (Task 4 review.)
|
||||
- **`GameState.resetGameState()` doesn't reset `spawnedBosses` or the gameplay stat fields** (money, squareHealth, bulletSpeed, etc.). If this method is the "start new run" boundary, that's a latent bug. (Task 5 review.)
|
||||
- **`FirstScreen.java` field declarations near lines 429–435 are non-final** while the `ctx` field is `final`. Consistency drift inside one file. (Final review.)
|
||||
- **`Main.ctx` is `public` non-final.** Safe given libGDX `Game.create()` semantics, but `public final` would document intent. (Final review.)
|
||||
|
||||
### Test-coverage gaps (not bugs)
|
||||
|
||||
- `TalentTreeTest.getStatBonus_*` exercises only the `damage_1` multiplier. A second multiplier (cooldown_1, health_1, etc.) would lock more of the formula. (Task 6 review.)
|
||||
- `TalentDataTest.enum_whenKeystone_thenEnumMatches` is a tautology (`KEYSTONE != DAMAGE`). Safe to delete. (Final review.)
|
||||
- `PlayerAbilitiesTest.purchaseAbility_whenUnknownType_thenNoChange` doesn't assert that the unknown key wasn't written to Preferences. (Task 4 review.)
|
||||
- `PlayerAbilitiesTest.construct_whenPreferencesPrePopulated_thenHonoursStored` seeds only `freeze` and `shield` — doesn't verify that absent `explosion` key falls through to the `true` default. (Task 4 review.)
|
||||
|
||||
### FakePreferences polish
|
||||
|
||||
- `FakePreferences.put(Map<String,?>)` bulk-put isn't guarded — values inserted through this path could be types no getter recognises (e.g., `Double`, `Short`). No test exercises this. (Task 2 review.)
|
||||
- `FakePreferences.getFloat`/`getLong` and the no-default single-arg getters have no tests. (Task 2 review.)
|
||||
|
||||
## What's next
|
||||
|
||||
K.1 is the foundation. The remaining specs in the K-series each handle one subsystem extraction out of `FirstScreen`'s god-class. Each follows the same brainstorm → spec → plan → subagent-driven-implementation cycle.
|
||||
|
||||
| Spec | Scope | Dependency |
|
||||
|---|---|---|
|
||||
| **K.2** | `BulletSystem` extraction — bullet tick, bullet-vs-circle collision, homing/pierce/aoe/chain resolution | K.1 |
|
||||
| **K.3** | `SpawnController` extraction — `spawnCircle()` cascade | K.1 |
|
||||
| **K.4** | `WaveController` extraction — `startNextWave` / `endWave` / scaling | K.1 |
|
||||
| **K.5** | `EnemyBehaviour` tests — `tick()` for `HealerCircleData`, `DasherCircleData`, `SniperCircleData` (and others) | K.1 + Phase 3 A1/A2/A3 |
|
||||
| **K.6** | `AbilitySystem` extraction — cooldowns, activations, effects | K.1 |
|
||||
| **K.7** | `UpgradeSystem` extraction — mid-run upgrade pick, money, apply-effect | K.1 |
|
||||
|
||||
Recommended next step: brainstorm **K.2** (`BulletSystem`) — highest-value extraction because bullet logic is central to the game, has the most flags (`piercesLeft`, `homing`, `aoeRadius`, `chainJumps`), and is the first place Phase 3's `homing` keystone + C2 chain work will land.
|
||||
|
||||
Phase 3 gameplay tasks (A–J from the parent Phase 3 plan) remain untouched and can proceed in parallel with K.2+ when desired.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
# Phase 3.K.1 — Test harness foundation (DI refactor + pure-logic tests)
|
||||
|
||||
**Status:** Design, awaiting review
|
||||
**Date:** 2026-04-17
|
||||
**Parent:** Phase 3, Area K (Test harness). Originally a single P4 task in `2026-04-17-phase3-content-meta-retention.md`; expanded into the K.x series after brainstorming.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Establish a test harness in the TowerAn project that serves as a **safety net for future refactoring**. Introduce unit tests for pure-logic singletons (`PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`, `TalentData`) so that subsequent extraction work in the `FirstScreen` god-class (planned as spec series K.2–K.7) can proceed without fear of silent regressions.
|
||||
|
||||
The spec also delivers the architectural prerequisite for testability: **removing the `getInstance()` singleton pattern** from the persistent-state classes, replacing it with constructor injection via a lightweight `GameContext` holder.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Any extraction of logic out of `FirstScreen`. Those are separate specs (K.2 `BulletSystem`, K.3 `SpawnController`, K.4 `WaveController`, K.5 `EnemyBehaviour`, K.6 `AbilitySystem`, K.7 `UpgradeSystem`). K.1 only prepares the ground.
|
||||
- Tests for `Achievements` and `DailySeed`. These classes do not exist yet (Phase 3 Tasks G1 / H1); their tests are authored alongside the classes, not in K.1.
|
||||
- Tests for enemy behaviour (`BaseCircleData` and subclasses). Deferred to K.5 because the new Phase 3 archetypes (Healer, Dasher, Sniper, Gnat) are not yet implemented.
|
||||
- Code coverage gating (JaCoCo). Introducing coverage thresholds without baseline history is premature.
|
||||
- CI integration (GitHub Actions or similar). Scope is local `./gradlew test`. CI wiring can be a tiny follow-up once K.1 is merged.
|
||||
- Headless libGDX (`HeadlessApplication`). Not needed because the DI refactor lets tests supply an in-memory `Preferences` directly.
|
||||
|
||||
## Background
|
||||
|
||||
TowerAn is a libGDX 1.13.1 game on Java 8, gdx-liftoff template, flat `ru.project.tower` package. There are currently **no tests of any kind** — no JUnit, no Spock, no manual harness. Verification has been purely visual: launch `Lwjgl3Launcher`, play, watch.
|
||||
|
||||
Five classes hold persistent or shared state through a static-singleton pattern:
|
||||
|
||||
- `PlayerStats` — currency, max wave, talent node levels. Backed by `Preferences` (`tower_attack_player_data`). `getInstance()` eagerly calls `Gdx.app.getPreferences(...)`.
|
||||
- `PlayerAbilities` — unlocked abilities, levels. Backed by `Preferences` (`tower_attack_player_abilities`). Same pattern.
|
||||
- `GameState` — in-memory run state (current wave, square health, money, run bonuses). No `Preferences`.
|
||||
- `TalentTree` — tree structure + level queries that call `PlayerStats.getInstance()` internally (lines 133, 138).
|
||||
- `TalentData` — plain data class used by `TalentTree`. No singleton; already testable but currently untested.
|
||||
|
||||
`getInstance()` usage is concentrated in five files (15 calls): `AbilitySelectionScreen` (2), `AbilityShopScreen` (2), `FirstScreen` (8), `TalentTreeScreen` (1), `TalentTree` (2).
|
||||
|
||||
Because singletons eagerly touch `Gdx.app` and `Preferences` on first `getInstance()`, any test that instantiates them fails outside a running libGDX context. The options are (a) reflection-nuking the `instance` field between tests, (b) `@VisibleForTesting resetForTests()` helpers, or (c) breaking the pattern. We chose (c) — it's a one-time architectural change that makes tests clean and unlocks downstream `FirstScreen` extraction work in K.2+.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
Three layers, implemented in this order (later layers depend on earlier ones):
|
||||
|
||||
1. **DI refactor.** Delete `private static instance` and `public static getInstance()` from `PlayerStats`, `PlayerAbilities`, `GameState`, `TalentTree`. Make constructors public and accept their dependencies explicitly.
|
||||
2. **Composition root.** Introduce `GameContext` as a holder for the refactored singletons. `Main.create()` constructs two `Preferences` instances, wraps them in a `GameContext`, and passes the `GameContext` to each screen via constructor injection.
|
||||
3. **Test harness.** Add JUnit 5 + AssertJ to `core/build.gradle`. Create an in-memory `FakePreferences` helper in `core/src/test/java`. Write unit tests for the five target classes.
|
||||
|
||||
Nothing in the rendering layer, the screen hierarchy, the enemy hierarchy, or the gameplay loop changes beyond mechanical `X.getInstance()` → `ctx.x` substitutions.
|
||||
|
||||
## Components
|
||||
|
||||
### `GameContext` (new, `core/src/main/java/ru/project/tower/GameContext.java`)
|
||||
|
||||
A tiny holder with `public final` fields, idiomatic Java for an immutable data carrier (Google Java Style §5.3 allows `public final` for this role).
|
||||
|
||||
```java
|
||||
public class GameContext {
|
||||
public final Preferences playerDataPrefs;
|
||||
public final Preferences abilitiesPrefs;
|
||||
public final PlayerStats playerStats;
|
||||
public final PlayerAbilities playerAbilities;
|
||||
public final GameState gameState;
|
||||
public final TalentTree talentTree;
|
||||
|
||||
public GameContext(Preferences playerDataPrefs, Preferences abilitiesPrefs) {
|
||||
this.playerDataPrefs = playerDataPrefs;
|
||||
this.abilitiesPrefs = abilitiesPrefs;
|
||||
this.playerStats = new PlayerStats(playerDataPrefs);
|
||||
this.playerAbilities = new PlayerAbilities(abilitiesPrefs);
|
||||
this.gameState = new GameState();
|
||||
this.talentTree = new TalentTree(this.playerStats);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Construction order matters: `TalentTree` depends on `PlayerStats`, so `playerStats` is created first. `Preferences` references are retained on the holder so future services (e.g., `Achievements`, `DailySeed` in Phase 3 G/H) can reuse them without re-plumbing `Main`.
|
||||
|
||||
### Singleton-to-DI conversions
|
||||
|
||||
For each target, the change is the same mechanical shape. Example for `PlayerStats`:
|
||||
|
||||
```java
|
||||
// Before
|
||||
private static PlayerStats instance;
|
||||
private Preferences preferences;
|
||||
|
||||
private PlayerStats() {
|
||||
preferences = Gdx.app.getPreferences(PREFS_NAME);
|
||||
loadData();
|
||||
}
|
||||
|
||||
public static PlayerStats getInstance() {
|
||||
if (instance == null) instance = new PlayerStats();
|
||||
return instance;
|
||||
}
|
||||
|
||||
// After
|
||||
private final Preferences preferences;
|
||||
|
||||
public PlayerStats(Preferences preferences) {
|
||||
this.preferences = preferences;
|
||||
loadData();
|
||||
}
|
||||
```
|
||||
|
||||
The private `PREFS_NAME = "tower_attack_player_data"` constant is deleted; the same literal moves to `Main` as `private static final String PLAYER_DATA_PREFS_NAME`. `PlayerAbilities` gets analogous treatment with `ABILITIES_PREFS_NAME`.
|
||||
|
||||
`GameState` has no `Preferences`, so its constructor is simply made public and argumentless. `TalentTree(PlayerStats stats)` stores the reference and replaces the two `PlayerStats.getInstance()` calls at lines 133 / 138 with `this.stats.getTalentNodeLevel(...)` and `this.stats.incrementTalentNode(...)`.
|
||||
|
||||
### `Main` as composition root
|
||||
|
||||
```java
|
||||
public class Main extends Game {
|
||||
private static final String PLAYER_DATA_PREFS_NAME = "tower_attack_player_data";
|
||||
private static final String ABILITIES_PREFS_NAME = "tower_attack_player_abilities";
|
||||
|
||||
public GameContext ctx;
|
||||
|
||||
@Override
|
||||
public void create() {
|
||||
ctx = new GameContext(
|
||||
Gdx.app.getPreferences(PLAYER_DATA_PREFS_NAME),
|
||||
Gdx.app.getPreferences(ABILITIES_PREFS_NAME)
|
||||
);
|
||||
setScreen(new MainScreen(this, ctx));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ctx` is a public field on `Main` so screens that already hold a `Main` reference can read it if needed, but the preferred path is receiving `GameContext` in the constructor.
|
||||
|
||||
### Screen constructor changes
|
||||
|
||||
Every screen class currently instantiated with `new Xxx(game)` becomes `new Xxx(game, ctx)`:
|
||||
|
||||
- `MainScreen(Main game, GameContext ctx)`
|
||||
- `FirstScreen(Main game, GameContext ctx)`
|
||||
- `AbilityShopScreen(Main game, GameContext ctx)`
|
||||
- `AbilitySelectionScreen(Main game, GameContext ctx)`
|
||||
- `TalentTreeScreen(Main game, GameContext ctx)`
|
||||
|
||||
Every `X.getInstance()` call becomes `ctx.x`. In `FirstScreen`, where there are 8 calls, cache into fields (`private final PlayerStats playerStats;` etc.) during the constructor to avoid `ctx.playerStats` repetition.
|
||||
|
||||
### `FakePreferences` (test helper, `core/src/test/java/ru/project/tower/FakePreferences.java`)
|
||||
|
||||
An in-memory implementation of `com.badlogic.gdx.Preferences` backed by `HashMap<String, Object>`. Approximately 30 trivial methods — every `putX(k, v)` stores in the map, every `getX(k)` / `getX(k, default)` reads. `flush()` is a no-op. `clear()` clears the map. `contains(k)` delegates to `map.containsKey`.
|
||||
|
||||
One shared helper serves every test class. Chosen over Mockito because Mockito for a single deterministic interface adds a dependency and weaker error messages for a trivial case.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Production:**
|
||||
```
|
||||
Main.create()
|
||||
└─ creates two Preferences via Gdx.app.getPreferences(...)
|
||||
└─ new GameContext(playerDataPrefs, abilitiesPrefs)
|
||||
└─ setScreen(new MainScreen(this, ctx))
|
||||
│
|
||||
└─ ctx propagates to every downstream screen constructor
|
||||
└─ screens access ctx.playerStats, ctx.talentTree, etc.
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
```
|
||||
@BeforeEach
|
||||
└─ new FakePreferences()
|
||||
└─ new PlayerStats(fakePrefs) // or the class under test, injected with fakes
|
||||
@Test
|
||||
└─ AAA: arrange state, invoke method, assert outcome
|
||||
(both on the object's API and on the fake Preferences where persistence matters)
|
||||
```
|
||||
|
||||
## Test conventions
|
||||
|
||||
- **Framework:** JUnit 5 (`org.junit.jupiter:junit-jupiter:5.10.2`).
|
||||
- **Assertions:** AssertJ (`org.assertj:assertj-core:3.26.3`). All assertions use `assertThat(...)` — no `assertEquals`. Rationale: fluent API, clearer messages, industry standard in modern Java.
|
||||
- **Test-method naming:** `methodName_whenCondition_thenExpected`. Sorts naturally by method in IDE, reads like a sentence.
|
||||
- `incrementTalentNode_whenNewNode_thenStoredInPreferences`
|
||||
- `getCurrency_whenNotYetSet_thenReturnsZero`
|
||||
- **Test-class naming:** `<ClassUnderTest>Test` (e.g., `PlayerStatsTest.java`).
|
||||
- **Body structure:** Arrange / Act / Assert separated by blank lines. No `// Arrange` comments.
|
||||
- **Isolation:** JUnit 5's default new-instance-per-test + `@BeforeEach` for setup. No static state between tests.
|
||||
- **One behaviour per test.** If a test has three `assertThat(...)` lines, they should all be aspects of the same behaviour (e.g., state returned _and_ persisted). Don't bundle unrelated scenarios.
|
||||
|
||||
## Test coverage targets
|
||||
|
||||
Approximately 30–35 test methods across 6 test classes, as sketched below. This is a floor, not a ceiling — prefer covering each public-API method with at least one happy-path test and one edge case.
|
||||
|
||||
### `PlayerStatsTest`
|
||||
- `getCurrency_whenNotYetSet_thenReturnsZero`
|
||||
- `addCurrency_whenCalled_thenIncrementsAndPersists`
|
||||
- `spendCurrency_whenInsufficient_thenReturnsFalseAndDoesNotDeduct`
|
||||
- `spendCurrency_whenSufficient_thenReturnsTrueAndDeducts`
|
||||
- `getMaxWaveReached_thenReadsFromPreferences`
|
||||
- `updateMaxWaveReached_whenNewHigher_thenUpdates`
|
||||
- `updateMaxWaveReached_whenNewLower_thenIgnored`
|
||||
- `getTalentNodeLevel_whenUnseen_thenZero`
|
||||
- `incrementTalentNode_whenCalled_thenLevelOneAndPersisted`
|
||||
- `incrementTalentNode_whenCalledTwice_thenLevelTwo`
|
||||
- Legacy aggregate getter(s): one test covering the wrapper still reports the right total.
|
||||
- A test that constructs `PlayerStats` from pre-populated `FakePreferences` and reads back expected state (persistence round-trip).
|
||||
|
||||
### `PlayerAbilitiesTest`
|
||||
- Every ability-unlock flag, get/set round-trip through `FakePreferences`.
|
||||
- Unlock toggles don't affect unrelated flags.
|
||||
- Default-state test (fresh `FakePreferences` gives the expected initial roster).
|
||||
|
||||
### `GameStateTest`
|
||||
- Initial values (wave, square health, money).
|
||||
- Wave increment.
|
||||
- Phase 2 `run*` bonus fields (damage, pierce, etc.) accumulate correctly.
|
||||
- Reset-for-new-run behaviour. The implementation plan must first confirm whether `GameState` has a reset API; if not, the plan adds a small one as part of this spec (this is a test-enabling concession, not feature work).
|
||||
|
||||
### `TalentTreeTest`
|
||||
- `initializeTree` creates expected roots and node IDs.
|
||||
- `getTalentLevel(id)` delegates to injected `PlayerStats`.
|
||||
- `incrementTalent(id)` delegates to injected `PlayerStats`.
|
||||
- Keystone node IDs (`keystone_pierce`, `keystone_crit`, `keystone_aoe`) are all registered.
|
||||
|
||||
### `TalentDataTest`
|
||||
- Construction: id, name, description, type, position, max level, base cost, cost multiplier all round-trip via getters.
|
||||
- Cost-at-level formula round-trip. The exact formula is read from the current `TalentData` source during implementation; the test locks in whatever that formula is, not a newly-invited one.
|
||||
|
||||
### `GameContextTest`
|
||||
- Construction with two `FakePreferences` yields non-null services.
|
||||
- `ctx.talentTree.getTalentLevel(someId)` reads from the same `PlayerStats` that `ctx.playerStats` exposes (i.e., wiring is correct).
|
||||
- Construction order: `TalentTree` sees the injected `PlayerStats`, not a new one.
|
||||
|
||||
## Build configuration
|
||||
|
||||
`core/build.gradle`:
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
api "com.badlogicgames.gdx:gdx-freetype:$gdxVersion"
|
||||
api "com.badlogicgames.gdx:gdx:$gdxVersion"
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||
testImplementation 'org.assertj:assertj-core:3.26.3'
|
||||
|
||||
if (enableGraalNative == 'true') {
|
||||
implementation "io.github.berstanio:gdx-svmhelper-annotations:$graalHelperVersion"
|
||||
}
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
```
|
||||
|
||||
Java 8 compatibility is preserved: JUnit 5.10 and AssertJ 3.26 both still ship Java-8-compatible bytecode.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
core/src/
|
||||
main/java/ru/project/tower/
|
||||
GameContext.java [NEW]
|
||||
PlayerStats.java [MODIFIED — constructor + remove getInstance]
|
||||
PlayerAbilities.java [MODIFIED — constructor + remove getInstance]
|
||||
GameState.java [MODIFIED — constructor public + remove getInstance]
|
||||
TalentTree.java [MODIFIED — constructor + remove getInstance]
|
||||
Main.java [MODIFIED — composition root]
|
||||
MainScreen.java [MODIFIED — constructor takes ctx]
|
||||
FirstScreen.java [MODIFIED — constructor takes ctx, replace 8 getInstance]
|
||||
AbilityShopScreen.java [MODIFIED — constructor takes ctx]
|
||||
AbilitySelectionScreen.java [MODIFIED — constructor takes ctx]
|
||||
TalentTreeScreen.java [MODIFIED — constructor takes ctx]
|
||||
test/java/ru/project/tower/ [NEW TREE]
|
||||
FakePreferences.java
|
||||
PlayerStatsTest.java
|
||||
PlayerAbilitiesTest.java
|
||||
GameStateTest.java
|
||||
TalentTreeTest.java
|
||||
TalentDataTest.java
|
||||
GameContextTest.java
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
There is one edge where the refactor can silently change behaviour: `getInstance()` lazily creates the singleton on first call. A class that was instantiated *before* `Gdx.app` was ready (unlikely in this codebase but worth naming) would now fail eagerly at `Main.create()` instead of at first use. Since `Main.create()` is the libGDX entry point and `Gdx.app` is guaranteed set by the backend before `create()` runs, this is safe.
|
||||
|
||||
All other error paths are unchanged — `Preferences.putX`/`getX` throw the same exceptions; `loadData()` runs the same logic.
|
||||
|
||||
## Verification
|
||||
|
||||
Because K.1 introduces the test harness itself, verification is both **automated** (the tests) and **manual** (the DI refactor must not break the running game):
|
||||
|
||||
- `./gradlew test` runs cleanly, all tests pass.
|
||||
- Launch `Lwjgl3Launcher`. Confirm:
|
||||
- Main menu renders; currency and max-wave values read from existing preferences.
|
||||
- Entering the talent tree shows purchased levels unchanged.
|
||||
- Starting a run, buying an upgrade, dying, and returning to menu preserves currency.
|
||||
- Ability shop displays owned abilities correctly.
|
||||
|
||||
Any of these failing indicates the `getInstance()` → `ctx.x` substitution dropped a wire.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- **Missed call-site.** 15 `getInstance()` calls plus possibly some we didn't count (constants, static initializers). Mitigation: after the refactor, `grep -r "getInstance()" core/src/main` must return zero hits. Automated in implementation plan.
|
||||
- **Screen-constructor signature changes break something external.** `lwjgl3/` and `android/` only touch `Main`, not individual screens. Mitigation: grep across all modules for `new MainScreen(`, `new FirstScreen(`, etc. after the refactor.
|
||||
- **Hidden `Preferences`-name constants.** We found two (`tower_attack_player_data`, `tower_attack_player_abilities`). If Phase 3 work introduces a third, the spec still works — just add another `Preferences` parameter to `GameContext`. Not a risk for K.1 itself.
|
||||
- **Test flakiness from order dependence.** Prevented by JUnit 5 per-test-instance default + fresh `FakePreferences` in `@BeforeEach`.
|
||||
|
||||
## Out of scope (reminder)
|
||||
|
||||
K.1 delivers the harness and proves the pattern on five pure-logic classes. Every subsequent K.x spec (K.2–K.7) builds on this to extract and test real gameplay subsystems. K.1 is merged when:
|
||||
|
||||
1. All 15+ `getInstance()` calls are gone.
|
||||
2. `GameContext` is the sole composition point for persistent services.
|
||||
3. `./gradlew test` runs and passes all 30+ tests authored in this spec.
|
||||
4. The game still boots and plays correctly on the desktop launcher.
|
||||
Reference in New Issue
Block a user