CollapseLogic/TASKS_World2.md

13 KiB
Raw Permalink Blame 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):

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:

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:

// Different color → block stops (MVP: no merge)

Replace that branch with:

// 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:

// 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:

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:

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:

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:

func playMerge() {
    playSound("merge")
}
  1. 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:

{
  "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.

@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)

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.