CollapseLogic/TASKS_World2.md

378 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.**