378 lines
13 KiB
Markdown
378 lines
13 KiB
Markdown
# 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.**
|