272 lines
9.7 KiB
Markdown
272 lines
9.7 KiB
Markdown
# CLAUDE.md — Collapse Logic iOS
|
||
|
||
## Project Overview
|
||
|
||
Collapse Logic is a grid-based puzzle game for iPhone where the player pushes colored blocks to trigger same-color destruction and different-color merging.
|
||
|
||
**Studio:** Vulcara Games
|
||
**Platform:** iOS 17.0+ (iPhone primary)
|
||
**Language:** Swift 5.9+
|
||
**Frameworks:** SpriteKit (gameplay), SwiftUI (menus/HUD)
|
||
**Architecture:** MVVM with pure-struct game state
|
||
|
||
---
|
||
|
||
## Current Build Status
|
||
|
||
- **World 1 — COMPLETE.** All 10 levels playable. Engine, SpriteKit scene, SwiftUI shell, undo, star rating, and persistence are all working.
|
||
- **World 2 — IN PROGRESS.** Adding color merging. See `TASKS_World2.md` for the step-by-step build plan.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
```
|
||
CollapseLogic/
|
||
├── App/
|
||
│ └── CollapseLogicApp.swift
|
||
├── Models/
|
||
│ ├── GameState.swift
|
||
│ ├── Block.swift # BlockColor enum lives here — add yellow + secondaries
|
||
│ ├── GridPosition.swift
|
||
│ ├── Direction.swift
|
||
│ ├── MoveResult.swift # .merged event already stubbed — wire it up
|
||
│ └── LevelDefinition.swift
|
||
├── Engine/
|
||
│ ├── GameEngine.swift # Core change: different-color collision now merges
|
||
│ └── LevelLoader.swift
|
||
├── Views/
|
||
│ ├── MainMenuView.swift
|
||
│ ├── LevelSelectView.swift # Needs world selector (World 1 / World 2)
|
||
│ └── GameContainerView.swift
|
||
├── Scene/
|
||
│ ├── GameScene.swift
|
||
│ ├── BlockNode.swift # Add color rendering for yellow + 3 secondaries
|
||
│ └── GridRenderer.swift
|
||
├── Audio/
|
||
│ └── SFXManager.swift # Add merge.wav
|
||
├── Persistence/
|
||
│ └── ProgressStore.swift
|
||
├── Levels/
|
||
│ ├── metadata.json # Add World 2 entry
|
||
│ └── world1/
|
||
│ └── level_01.json … level_10.json
|
||
│ └── world2/
|
||
│ └── level_01.json … level_12.json
|
||
└── Resources/
|
||
└── SFX/
|
||
```
|
||
|
||
---
|
||
|
||
## Block Colors — Full Set
|
||
|
||
### Primary colors (can be pushed into each other to merge)
|
||
|
||
| Color | Hex | Name |
|
||
|-------|-----|------|
|
||
| `red` | #E63946 | Ruby |
|
||
| `blue` | #457B9D | Sapphire |
|
||
| `yellow` | #F4D35E | Topaz |
|
||
|
||
### Secondary colors (result of merges — can destroy each other but cannot merge further)
|
||
|
||
| Color | Created From | Hex | Name |
|
||
|-------|-------------|-----|------|
|
||
| `purple` | red + blue | #7B2D8B | Amethyst |
|
||
| `orange` | red + yellow | #E76F51 | Ember |
|
||
| `green` | blue + yellow | #2A9D8F | Jade |
|
||
|
||
Secondary blocks **can** be pre-placed in level JSON. They **can** destroy each other on same-color collision. They **cannot** merge — two different secondaries just stop (no interaction).
|
||
|
||
---
|
||
|
||
## Merge Rules (World 2 Engine Logic)
|
||
|
||
When a moving block collides with a stationary block of a **different** color:
|
||
|
||
```
|
||
if both are PRIMARY colors → merge into the secondary color, placed at the stationary block's position
|
||
if moving is PRIMARY, stationary is SECONDARY → block stops (no interaction)
|
||
if moving is SECONDARY, stationary is PRIMARY → block stops (no interaction)
|
||
if both are SECONDARY colors (different) → block stops (no interaction)
|
||
if same color (primary or secondary) → both destroyed (existing World 1 rule, unchanged)
|
||
```
|
||
|
||
### Merge color table
|
||
|
||
```swift
|
||
static func mergeColor(_ a: BlockColor, _ b: BlockColor) -> BlockColor? {
|
||
let pair = Set([a, b])
|
||
if pair == [.red, .blue] { return .purple }
|
||
if pair == [.red, .yellow] { return .orange }
|
||
if pair == [.blue, .yellow] { return .green }
|
||
return nil // no merge possible
|
||
}
|
||
```
|
||
|
||
When a merge occurs:
|
||
1. Both source blocks are removed from state.
|
||
2. A new block of the merged color is placed at the **stationary block's position**.
|
||
3. A `.merged` event is emitted (for animation).
|
||
4. Move count increments (merge is a valid move).
|
||
5. Win condition is checked after the merge.
|
||
|
||
---
|
||
|
||
## GameEngine Changes (World 2)
|
||
|
||
The only change to `GameEngine.processMove` is step 5 of the movement algorithm:
|
||
|
||
**Before (World 1):**
|
||
```
|
||
5. Check cell adjacent to T in direction D:
|
||
- Same color → destroy both
|
||
- Different color → block stops
|
||
```
|
||
|
||
**After (World 2):**
|
||
```
|
||
5. Check cell adjacent to T in direction D:
|
||
- Same color (any) → destroy both [unchanged]
|
||
- Both are primary, different colors → merge; new secondary block at stationary position
|
||
- Any other different-color combination → block stops [unchanged]
|
||
```
|
||
|
||
No other engine logic changes. The pure-struct GameState design means this is fully unit-testable in isolation before touching SpriteKit.
|
||
|
||
---
|
||
|
||
## MoveResult Events
|
||
|
||
```swift
|
||
enum GameEvent {
|
||
case slid(blockIndex: Int, from: GridPosition, to: GridPosition)
|
||
case destroyed(blockIndex1: Int, blockIndex2: Int, at: GridPosition)
|
||
case merged(blockIndex1: Int, blockIndex2: Int, resultColor: BlockColor, at: GridPosition)
|
||
}
|
||
```
|
||
|
||
The `.merged` event was already stubbed in World 1. Wire it up now.
|
||
|
||
---
|
||
|
||
## Animation — Merge
|
||
|
||
In `GameScene`, handle `.merged` events:
|
||
|
||
1. Slide the moving block to position T (same 0.15s ease-out as existing slide).
|
||
2. Run a **merge burst**: scale both blocks up to 1.2× over 0.1s, then scale down and fade out over 0.1s simultaneously.
|
||
3. Spawn a **new block node** of the merged color at the stationary position, starting at scale 0.0, and animate it scaling up to 1.0 over 0.15s with a slight overshoot (spring feel — scale to 1.15 then settle to 1.0).
|
||
4. Total merge animation duration: ~0.35s.
|
||
|
||
Keep it snappy. The new merged block "popping" into existence is the payoff moment.
|
||
|
||
---
|
||
|
||
## New Color Rendering (BlockNode)
|
||
|
||
Add cases for `yellow`, `purple`, `orange`, `green` to the color switch in `BlockNode`. Use the hex values from the color table above.
|
||
|
||
Visual treatment:
|
||
- Primary blocks (red, blue, yellow): same rounded rect style as World 1.
|
||
- Secondary blocks (purple, orange, green): add a subtle **white inner ring** (2px, 40% opacity) to visually distinguish them as merged/special. This gives players a quick read that these are second-tier blocks.
|
||
|
||
---
|
||
|
||
## World Select UI
|
||
|
||
`LevelSelectView` currently shows World 1 levels only. For World 2, add a simple world selector at the top — two tappable pills: "World 1" and "World 2". World 2 unlocks when the player has earned at least **15 stars** in World 1 (out of 30 possible).
|
||
|
||
Lock state: if World 2 is locked, show the pill grayed out with a lock icon and "15 ★ to unlock" label.
|
||
|
||
Do not over-engineer this — a simple `@State var selectedWorld: Int` in `LevelSelectView` is enough.
|
||
|
||
---
|
||
|
||
## Sound Effects
|
||
|
||
Add one new sound:
|
||
|
||
5. `merge.wav` — A two-tone "blip-bloom" sound (0.25s). Higher pitch than destroy. Suggests transformation rather than destruction.
|
||
|
||
Use `SKAction.playSoundFileNamed()` same as the existing four sounds.
|
||
|
||
---
|
||
|
||
## Level Design — World 2 (12 levels)
|
||
|
||
World 2 introduces merging progressively across 12 levels. All level JSON files are in `Levels/world2/`. Grid sizes 5×5 to 6×6.
|
||
|
||
**Teaching sequence:**
|
||
- Levels 1–3: Introduce one merge pair (red+blue=purple). No pre-placed secondaries.
|
||
- Levels 4–6: Add yellow. Teach red+yellow=orange and blue+yellow=green.
|
||
- Levels 7–9: Pre-placed secondary blocks appear. Player must match them.
|
||
- Levels 10–11: Multi-step chains — merge, then use the result to destroy another secondary.
|
||
- Level 12: Boss level — all three primaries, all three secondaries, careful sequencing required.
|
||
|
||
See `Levels/world2/level_*.json` for the hand-crafted level files.
|
||
|
||
---
|
||
|
||
## Testing Priorities (World 2 additions)
|
||
|
||
**Engine unit tests — add these:**
|
||
- `red` + `blue` collision → produces `purple` block at stationary position
|
||
- `red` + `yellow` collision → produces `orange`
|
||
- `blue` + `yellow` collision → produces `green`
|
||
- Secondary + primary collision → block stops (no merge)
|
||
- Secondary + different secondary collision → block stops (no merge)
|
||
- Same secondary collision → both destroyed (e.g. purple + purple)
|
||
- Merged block is placed at correct position (stationary block's cell)
|
||
- Move count increments on merge
|
||
- Undo correctly restores state before a merge
|
||
- Win condition detected after a merge clears the last blocks
|
||
|
||
**Level loading tests:**
|
||
- All 12 World 2 JSON files parse without errors
|
||
- Pre-placed secondary color blocks load correctly
|
||
|
||
**Manual playtesting:**
|
||
- Each World 2 level is solvable at par
|
||
- Merge animation feels satisfying, not jarring
|
||
- World 2 unlock (15 stars) triggers correctly
|
||
|
||
---
|
||
|
||
## Style & Conventions
|
||
|
||
- Swift naming conventions (camelCase properties, PascalCase types)
|
||
- Prefer value types (structs, enums) over classes except for SpriteKit nodes
|
||
- No force unwraps except in tests
|
||
- Keep files under 200 lines. Split if needed.
|
||
- Comment non-obvious game logic
|
||
- `// MARK: -` sections in longer files
|
||
|
||
---
|
||
|
||
## Build & Run
|
||
|
||
- Xcode 15+
|
||
- iOS 17.0 deployment target
|
||
- No external dependencies
|
||
- Primary simulator: iPhone 15 Pro
|
||
- Small-screen test: iPhone SE (3rd gen)
|
||
|
||
---
|
||
|
||
## Definition of Done — World 2
|
||
|
||
- [ ] `mergeColor(_:_:)` function implemented and all merge combinations correct
|
||
- [ ] `GameEngine.processMove` handles primary+primary different-color collision as merge
|
||
- [ ] `.merged` GameEvent emitted with correct blockIndex values and resultColor
|
||
- [ ] All existing World 1 unit tests still pass (no regressions)
|
||
- [ ] New merge unit tests pass (see Testing Priorities above)
|
||
- [ ] `yellow`, `purple`, `orange`, `green` render correctly in `BlockNode`
|
||
- [ ] Secondary blocks have white inner ring visual treatment
|
||
- [ ] Merge animation plays: slide → burst → new block pop-in (~0.35s total)
|
||
- [ ] `merge.wav` plays on merge
|
||
- [ ] All 12 World 2 level JSONs load and are playable
|
||
- [ ] World selector UI in `LevelSelectView` (two pills, lock at <15 stars)
|
||
- [ ] `metadata.json` updated with World 2 entry
|
||
- [ ] World 2 levels are fun and teach the mechanic clearly
|