CollapseLogic/TASKS_World2.md

379 lines
13 KiB
Markdown
Raw Normal View History

# TASKS_World2.md — Collapse Logic: World 2 Implementation
Feed this file to Claude Code. Work through tasks **in order**. Do not skip ahead.
Complete each task fully before starting the next. Run tests after every task that touches the engine.
---
## Task 1 — Extend `BlockColor` with new colors
**File:** `Models/Block.swift`
Add three primary and three secondary colors to the `BlockColor` enum (yellow already may be stubbed — check first):
```swift
enum BlockColor: String, Codable, CaseIterable {
// Primary (can merge)
case red
case blue
case yellow
// Secondary (result of merges)
case purple
case orange
case green
var isPrimary: Bool {
switch self {
case .red, .blue, .yellow: return true
case .purple, .orange, .green: return false
}
}
var displayHex: String {
switch self {
case .red: return "#E63946"
case .blue: return "#457B9D"
case .yellow: return "#F4D35E"
case .purple: return "#7B2D8B"
case .orange: return "#E76F51"
case .green: return "#2A9D8F"
}
}
}
```
**Verify:** No existing code breaks. `BlockColor` is `Codable` so JSON loading of `"yellow"`, `"purple"`, etc. will now work automatically.
---
## Task 2 — Add `mergeColor` to `GameEngine`
**File:** `Engine/GameEngine.swift`
Add this static helper. Place it near the top of `GameEngine` as a private static func:
```swift
private static func mergeColor(_ a: BlockColor, _ b: BlockColor) -> BlockColor? {
guard a.isPrimary && b.isPrimary && a != b else { return nil }
let pair = Set([a, b])
if pair == Set([BlockColor.red, .blue]) { return .purple }
if pair == Set([BlockColor.red, .yellow]) { return .orange }
if pair == Set([BlockColor.blue, .yellow]) { return .green }
return nil
}
```
**No other changes in this task.**
---
## Task 3 — Update `processMove` for merge collisions
**File:** `Engine/GameEngine.swift`
Find step 5 of the movement algorithm — the collision check after the block slides to position T. Currently it reads:
```swift
// Different color → block stops (MVP: no merge)
```
Replace that branch with:
```swift
// Different color — check for merge
if let merged = GameEngine.mergeColor(movingBlock.color, stationaryBlock.color) {
// Remove both blocks
var newBlocks = state.blocks
// Remove in reverse index order to avoid index shifting
let indicesToRemove = [movingIndex, stationaryIndex].sorted(by: >)
for idx in indicesToRemove { newBlocks.remove(at: idx) }
// Place merged block at stationary position
let mergedBlock = Block(position: stationaryBlock.position, color: merged)
newBlocks.append(mergedBlock)
// Emit merged event
events.append(.merged(
blockIndex1: movingIndex,
blockIndex2: stationaryIndex,
resultColor: merged,
at: stationaryBlock.position
))
newState = GameState(
grid: state.grid,
blocks: newBlocks,
walls: state.walls,
moveCount: state.moveCount + 1,
history: state.historySnapshot(),
objective: state.objective,
par: state.par
)
} else {
// No merge possible — block stops at T
// (existing stop logic unchanged)
}
```
> Note: `historySnapshot()` should already exist from World 1's undo implementation. If it's named differently in your codebase, use the correct name.
**After this task: run ALL existing unit tests.** Zero regressions allowed before continuing.
---
## Task 4 — Unit tests for merge logic
**File:** `CollapseLogicTests/GameEngineTests.swift` (add to existing test file)
Add a `// MARK: - World 2 Merge Tests` section with these tests:
```swift
// MARK: - World 2 Merge Tests
func testRedBlueProducesPurple() {
// red at (0,0), blue at (2,0), push red right
// Expected: purple block at (2,0), both originals removed
}
func testRedYellowProducesOrange() { }
func testBlueYellowProducesGreen() { }
func testMergedBlockAtStationaryPosition() {
// Verify the new block is at the stationary block's cell, not the moving block's cell
}
func testSecondaryPlusPrimaryStops() {
// purple moving into red → block stops, no merge, no destroy
}
func testDifferentSecondariesStop() {
// purple moving into green → block stops
}
func testSameSecondaryDestroysBoth() {
// purple moving into purple → both destroyed (same rule as primaries)
}
func testMoveCountIncrementsOnMerge() { }
func testUndoAfterMergeRestoresState() {
// After a merge, undo should restore both original blocks and remove the merged block
}
func testWinConditionAfterMerge() {
// Board with one red + one blue. Merge them. If objective is clear_all,
// that's NOT a win (merged block remains). Verify win is NOT triggered.
// Then push merged purple into a pre-placed purple → destroy both → win.
}
```
Implement each test fully. All must pass before proceeding.
---
## Task 5 — Update `BlockNode` color rendering
**File:** `Scene/BlockNode.swift`
Add color cases for `yellow`, `purple`, `orange`, `green` in the color-to-UIColor/SKColor switch. Use:
```swift
case .yellow: return SKColor(hex: "#F4D35E")
case .purple: return SKColor(hex: "#7B2D8B")
case .orange: return SKColor(hex: "#E76F51")
case .green: return SKColor(hex: "#2A9D8F")
```
Additionally, add a **white inner ring** for secondary-color blocks. After setting the block's fill color, add:
```swift
if !block.color.isPrimary {
let ring = SKShapeNode(rectOf: CGSize(width: size.width - 8, height: size.height - 8),
cornerRadius: cornerRadius - 2)
ring.fillColor = .clear
ring.strokeColor = SKColor.white.withAlphaComponent(0.4)
ring.lineWidth = 2
ring.zPosition = 1
addChild(ring)
}
```
**Verify visually** in the simulator: place a World 1 level with a pre-placed `purple` block (edit a level JSON temporarily) and confirm it renders with the inner ring. Revert the JSON after verifying.
---
## Task 6 — Merge animation in `GameScene`
**File:** `Scene/GameScene.swift`
Find the method that processes `GameEvent` items for animation (likely a `playEvents` or `animateEvents` function). Add a case for `.merged`:
```swift
case .merged(_, _, let resultColor, let position):
// 1. Both source blocks have already slid/are at position — their nodes need to burst out
// 2. Scale both up 1.2× (0.1s), then scale + fade to 0 (0.1s), remove nodes
// 3. Create a new BlockNode for resultColor at `position`, scale 0 → 1.15 → 1.0 (0.15s spring)
// 4. Play merge.wav
let burstScale = SKAction.scale(to: 1.2, duration: 0.1)
let burstFade = SKAction.group([
SKAction.scale(to: 0.0, duration: 0.1),
SKAction.fadeOut(withDuration: 0.1)
])
let burstRemove = SKAction.removeFromParent()
let burst = SKAction.sequence([burstScale, burstFade, burstRemove])
// Run burst on both source nodes (look them up by their current grid positions)
// Then after burst completes, spawn merged node with spring pop:
let spawnDelay = SKAction.wait(forDuration: 0.2)
let spawnSpring = SKAction.sequence([
SKAction.scale(to: 0.0, duration: 0),
SKAction.scale(to: 1.15, duration: 0.12),
SKAction.scale(to: 1.0, duration: 0.05)
])
// Create new BlockNode, add to scene at correct sprite position, run spawnSpring
```
Sequence these so the burst finishes before the new block appears. Use `SKAction.sequence` with a wait equal to the burst duration (0.2s).
Total animation time: ~0.35s. Do not block player input during this time — use the existing input-lock pattern from the slide animation.
---
## Task 7 — Add `merge.wav`
**File:** `Resources/SFX/merge.wav` and `Audio/SFXManager.swift`
1. Add a `merge.wav` placeholder to `Resources/SFX/`. If you don't have an audio file yet, create an empty file as a placeholder — the app will not crash on a missing SKAction sound, it will just play nothing. Add a `// TODO: replace with real audio` comment.
2. In `SFXManager.swift`, add:
```swift
func playMerge() {
playSound("merge")
}
```
3. Call `SFXManager.shared.playMerge()` from the `.merged` animation case in Task 6.
---
## Task 8 — World 2 level files
**Directory:** `Levels/world2/`
Create this directory and add `level_01.json` through `level_12.json`. All 12 files are provided in `Levels/world2/` alongside this task file. Copy them into the Xcode project the same way World 1 levels were added — as a **folder reference** (blue folder), not individual file entries.
**Xcode setup reminder:**
- Right-click `Levels/` group → Add Files → select the `world2/` folder → choose "Create folder references" (blue folder)
- Confirm `world2/` appears as a blue folder in the Levels group
- Do NOT add individual JSON files — only the folder
---
## Task 9 — Update `metadata.json`
**File:** `Levels/metadata.json`
Add the World 2 entry to the `worlds` array:
```json
{
"id": 2,
"name": "Fusion",
"description": "Discover what happens when two colors collide.",
"new_mechanic": "merging",
"unlock_stars": 15,
"levels": [
{ "id": "w2_01", "title": "First Contact", "file": "world2/level_01.json", "is_challenge": false },
{ "id": "w2_02", "title": "Bound Together", "file": "world2/level_02.json", "is_challenge": false },
{ "id": "w2_03", "title": "The Third Color", "file": "world2/level_03.json", "is_challenge": false },
{ "id": "w2_04", "title": "Sun Stone", "file": "world2/level_04.json", "is_challenge": false },
{ "id": "w2_05", "title": "Color Theory", "file": "world2/level_05.json", "is_challenge": false },
{ "id": "w2_06", "title": "Triad", "file": "world2/level_06.json", "is_challenge": false },
{ "id": "w2_07", "title": "Inheritance", "file": "world2/level_07.json", "is_challenge": false },
{ "id": "w2_08", "title": "Reflection", "file": "world2/level_08.json", "is_challenge": false },
{ "id": "w2_09", "title": "Cascade", "file": "world2/level_09.json", "is_challenge": false },
{ "id": "w2_10", "title": "Chain Reaction", "file": "world2/level_10.json", "is_challenge": false },
{ "id": "w2_11", "title": "The Long Way", "file": "world2/level_11.json", "is_challenge": false },
{ "id": "w2_12", "title": "Prism", "file": "world2/level_12.json", "is_challenge": true, "stars_required": 15 }
]
}
```
Also update the `LevelLoader` / wherever `metadata.json` is parsed to handle the new `unlock_stars` and `new_mechanic` fields. These are optional — existing World 1 entry has neither and must still load correctly.
---
## Task 10 — World selector UI
**File:** `Views/LevelSelectView.swift`
Add a world selector at the top of the level select screen.
```swift
@State private var selectedWorld: Int = 1
// World 2 unlock condition
private var world2Unlocked: Bool {
progressStore.totalStars() >= 15
}
```
Render two pill-shaped buttons: "World 1" and "World 2". When World 2 is locked:
- Pill is grayed out (opacity 0.4)
- Show a lock symbol (SF Symbol: `lock.fill`) and the text "15 ★"
- Tapping the locked pill does nothing (or shows a brief shake animation)
When World 2 is unlocked, tapping it switches `selectedWorld` to 2 and the level grid below shows World 2 levels.
Keep the implementation simple — the world metadata is already loaded, just filter `levels` by `selectedWorld`.
---
## Task 11 — Level loading tests (World 2)
**File:** `CollapseLogicTests/LevelLoaderTests.swift` (add to existing)
```swift
func testAllWorld2LevelsLoad() {
for i in 1...12 {
let id = String(format: "w2_%02d", i)
let level = LevelLoader.load(id: id)
XCTAssertNotNil(level, "Failed to load \(id)")
}
}
func testPreplacedSecondaryColorsLoad() {
// Load w2_07 (first level with pre-placed secondary blocks)
// Verify the purple block in that level loads with color == .purple
}
func testWorld2MetadataLoads() {
let metadata = LevelLoader.loadMetadata()
XCTAssertEqual(metadata.worlds.count, 2)
let world2 = metadata.worlds.first(where: { $0.id == 2 })
XCTAssertNotNil(world2)
XCTAssertEqual(world2?.levels.count, 12)
}
```
All must pass before marking World 2 complete.
---
## Task 12 — Full playthrough and regression check
Manual steps before closing World 2:
1. Run all unit tests. Zero failures.
2. Build and launch on iPhone 15 Pro simulator.
3. Play World 1 levels 1, 5, and 10 — verify no regressions.
4. Earn 15 stars. Verify World 2 unlocks in the level select.
5. Play all 12 World 2 levels from start to finish.
6. For each World 2 level: verify the level is solvable at par.
7. Trigger at least one of each merge type (red+blue, red+yellow, blue+yellow) and confirm the animation plays correctly.
8. Undo a merge — verify the board restores correctly.
9. Build and run on iPhone SE (3rd gen) simulator — verify layout is not broken.
10. Confirm no crashes on any level.
**Definition of Done — World 2 is complete when all 12 tasks are done and all manual steps above pass.**