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