Initial import: World 1 + World 2 (Fusion) with merge gating
This commit is contained in:
commit
04e638e50d
61 changed files with 4648 additions and 0 deletions
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||||
|
*.xcworkspace/xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcuserdatad/
|
||||||
|
*.moved-aside
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
Packages/
|
||||||
|
Package.resolved
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# CocoaPods / Carthage / fastlane (not used today, future-proofing)
|
||||||
|
Pods/
|
||||||
|
Carthage/Build/
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Editor / misc
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
*.zip
|
||||||
272
CLAUDE.md
Normal file
272
CLAUDE.md
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# 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
|
||||||
481
CollapseLogic/CollapseLogic.xcodeproj/project.pbxproj
Normal file
481
CollapseLogic/CollapseLogic.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,481 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
583D3E752F9FE03400867DC9 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 584AD0482F6A5E0700B97C0B /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 584AD04F2F6A5E0700B97C0B;
|
||||||
|
remoteInfo = CollapseLogic;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
583D3E712F9FE03400867DC9 /* CollapseLogicTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CollapseLogicTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
584AD0502F6A5E0700B97C0B /* CollapseLogic.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CollapseLogic.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
584AD0C12F6A72C300B97C0B /* Exceptions for "CollapseLogic" folder in "CollapseLogic" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
CollapseLogicApp.swift,
|
||||||
|
ContentView.swift,
|
||||||
|
);
|
||||||
|
target = 584AD04F2F6A5E0700B97C0B /* CollapseLogic */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
583D3E722F9FE03400867DC9 /* CollapseLogicTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CollapseLogicTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
584AD0522F6A5E0700B97C0B /* CollapseLogic */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
584AD0C12F6A72C300B97C0B /* Exceptions for "CollapseLogic" folder in "CollapseLogic" target */,
|
||||||
|
);
|
||||||
|
path = CollapseLogic;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
583D3E6E2F9FE03400867DC9 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
584AD04D2F6A5E0700B97C0B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
584AD0472F6A5E0700B97C0B = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
584AD0522F6A5E0700B97C0B /* CollapseLogic */,
|
||||||
|
583D3E722F9FE03400867DC9 /* CollapseLogicTests */,
|
||||||
|
584AD0512F6A5E0700B97C0B /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
584AD0512F6A5E0700B97C0B /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
584AD0502F6A5E0700B97C0B /* CollapseLogic.app */,
|
||||||
|
583D3E712F9FE03400867DC9 /* CollapseLogicTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
583D3E702F9FE03400867DC9 /* CollapseLogicTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 583D3E792F9FE03400867DC9 /* Build configuration list for PBXNativeTarget "CollapseLogicTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
583D3E6D2F9FE03400867DC9 /* Sources */,
|
||||||
|
583D3E6E2F9FE03400867DC9 /* Frameworks */,
|
||||||
|
583D3E6F2F9FE03400867DC9 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
583D3E762F9FE03400867DC9 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
583D3E722F9FE03400867DC9 /* CollapseLogicTests */,
|
||||||
|
);
|
||||||
|
name = CollapseLogicTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CollapseLogicTests;
|
||||||
|
productReference = 583D3E712F9FE03400867DC9 /* CollapseLogicTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
584AD04F2F6A5E0700B97C0B /* CollapseLogic */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 584AD05B2F6A5E0800B97C0B /* Build configuration list for PBXNativeTarget "CollapseLogic" */;
|
||||||
|
buildPhases = (
|
||||||
|
584AD04C2F6A5E0700B97C0B /* Sources */,
|
||||||
|
584AD04D2F6A5E0700B97C0B /* Frameworks */,
|
||||||
|
584AD04E2F6A5E0700B97C0B /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
584AD0522F6A5E0700B97C0B /* CollapseLogic */,
|
||||||
|
);
|
||||||
|
name = CollapseLogic;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CollapseLogic;
|
||||||
|
productReference = 584AD0502F6A5E0700B97C0B /* CollapseLogic.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
584AD0482F6A5E0700B97C0B /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2640;
|
||||||
|
LastUpgradeCheck = 2640;
|
||||||
|
TargetAttributes = {
|
||||||
|
583D3E702F9FE03400867DC9 = {
|
||||||
|
CreatedOnToolsVersion = 26.4.1;
|
||||||
|
TestTargetID = 584AD04F2F6A5E0700B97C0B;
|
||||||
|
};
|
||||||
|
584AD04F2F6A5E0700B97C0B = {
|
||||||
|
CreatedOnToolsVersion = 26.3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 584AD04B2F6A5E0700B97C0B /* Build configuration list for PBXProject "CollapseLogic" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 584AD0472F6A5E0700B97C0B;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 584AD0512F6A5E0700B97C0B /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
584AD04F2F6A5E0700B97C0B /* CollapseLogic */,
|
||||||
|
583D3E702F9FE03400867DC9 /* CollapseLogicTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
583D3E6F2F9FE03400867DC9 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
584AD04E2F6A5E0700B97C0B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
583D3E6D2F9FE03400867DC9 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
584AD04C2F6A5E0700B97C0B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
583D3E762F9FE03400867DC9 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 584AD04F2F6A5E0700B97C0B /* CollapseLogic */;
|
||||||
|
targetProxy = 583D3E752F9FE03400867DC9 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
583D3E772F9FE03400867DC9 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.vulcara.CollapseLogicTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CollapseLogic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CollapseLogic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
583D3E782F9FE03400867DC9 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.vulcara.CollapseLogicTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CollapseLogic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CollapseLogic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
584AD0592F6A5E0800B97C0B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
584AD05A2F6A5E0800B97C0B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
584AD05C2F6A5E0800B97C0B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.vulcara.CollapseLogic;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
584AD05D2F6A5E0800B97C0B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = C9CW6BFJQ7;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.vulcara.CollapseLogic;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
583D3E792F9FE03400867DC9 /* Build configuration list for PBXNativeTarget "CollapseLogicTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
583D3E772F9FE03400867DC9 /* Debug */,
|
||||||
|
583D3E782F9FE03400867DC9 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
584AD04B2F6A5E0700B97C0B /* Build configuration list for PBXProject "CollapseLogic" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
584AD0592F6A5E0800B97C0B /* Debug */,
|
||||||
|
584AD05A2F6A5E0800B97C0B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
584AD05B2F6A5E0800B97C0B /* Build configuration list for PBXNativeTarget "CollapseLogic" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
584AD05C2F6A5E0800B97C0B /* Debug */,
|
||||||
|
584AD05D2F6A5E0800B97C0B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 584AD0482F6A5E0700B97C0B /* Project object */;
|
||||||
|
}
|
||||||
7
CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>CollapseLogic.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
11
CollapseLogic/CollapseLogic/App/CollapseLogicApp.swift
Normal file
11
CollapseLogic/CollapseLogic/App/CollapseLogicApp.swift
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CollapseLogicApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MainMenuView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1 MiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "CollapseLogic_icon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
54
CollapseLogic/CollapseLogic/Audio/SFXManager.swift
Normal file
54
CollapseLogic/CollapseLogic/Audio/SFXManager.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
enum SFXEvent {
|
||||||
|
case select, slide, destroy, complete, merge
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SFXManager {
|
||||||
|
static let shared = SFXManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private var isMuted = false
|
||||||
|
|
||||||
|
func play(_ event: SFXEvent) {
|
||||||
|
guard !isMuted else { return }
|
||||||
|
let filename: String
|
||||||
|
switch event {
|
||||||
|
case .select: filename = "select.mp3"
|
||||||
|
case .slide: filename = "slide.mp3"
|
||||||
|
case .destroy: filename = "destroy.mp3"
|
||||||
|
case .complete: filename = "complete.mp3"
|
||||||
|
case .merge: filename = "merge.wav" // TODO: replace with real audio
|
||||||
|
}
|
||||||
|
// SKAction.playSoundFileNamed silently ignores missing files — safe for placeholder TODOs
|
||||||
|
let action = SKAction.playSoundFileNamed(filename, waitForCompletion: false)
|
||||||
|
// We need a scene to run the action; post to a shared coordinator node
|
||||||
|
SFXRunner.shared.run(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playMerge() {
|
||||||
|
play(.merge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMuted(_ muted: Bool) {
|
||||||
|
isMuted = muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long-lived SKNode that can run audio actions without being in a scene tree.
|
||||||
|
// Attach it to the active scene on first use.
|
||||||
|
final class SFXRunner {
|
||||||
|
static let shared = SFXRunner()
|
||||||
|
private(set) var node = SKNode()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func attach(to scene: SKScene) {
|
||||||
|
if node.parent == nil {
|
||||||
|
scene.addChild(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ action: SKAction) {
|
||||||
|
node.run(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
CollapseLogic/CollapseLogic/CollapseLogicApp.swift
Normal file
17
CollapseLogic/CollapseLogic/CollapseLogicApp.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
//
|
||||||
|
// CollapseLogicApp.swift
|
||||||
|
// CollapseLogic
|
||||||
|
//
|
||||||
|
// Created by Frank Fuentes on 3/17/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CollapseLogicApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
CollapseLogic/CollapseLogic/ContentView.swift
Normal file
24
CollapseLogic/CollapseLogic/ContentView.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// CollapseLogic
|
||||||
|
//
|
||||||
|
// Created by Frank Fuentes on 3/17/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.imageScale(.large)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
Text("Hello, world!")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
105
CollapseLogic/CollapseLogic/Engine/GameEngine.swift
Normal file
105
CollapseLogic/CollapseLogic/Engine/GameEngine.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
struct GameEngine {
|
||||||
|
|
||||||
|
// MARK: - Primary entry point
|
||||||
|
|
||||||
|
static func processMove(state: GameState, blockIndex: Int, direction: Direction) -> MoveResult {
|
||||||
|
guard blockIndex >= 0 && blockIndex < state.blocks.count else {
|
||||||
|
return MoveResult(newState: state, events: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let movingBlock = state.blocks[blockIndex]
|
||||||
|
let origin = movingBlock.position
|
||||||
|
|
||||||
|
// --- Step 1: Compute landing position ---
|
||||||
|
let landingPos = slide(from: origin, direction: direction, state: state)
|
||||||
|
|
||||||
|
// --- Step 2: If block didn't move, return unchanged ---
|
||||||
|
if landingPos == origin {
|
||||||
|
return MoveResult(newState: state, events: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Check cell adjacent to landing in direction ---
|
||||||
|
let adjacentPos = landingPos.offset(by: direction)
|
||||||
|
var events: [GameEvent] = []
|
||||||
|
events.append(.slid(blockIndex: blockIndex, from: origin, to: landingPos))
|
||||||
|
|
||||||
|
var newBlocks = state.blocks
|
||||||
|
|
||||||
|
// Move the block to its landing position
|
||||||
|
newBlocks[blockIndex] = Block(id: movingBlock.id, position: landingPos, color: movingBlock.color)
|
||||||
|
|
||||||
|
// --- Step 4: Check for same-color collision at adjacent cell ---
|
||||||
|
if state.grid.contains(adjacentPos),
|
||||||
|
let adjacentIdx = state.blocks.indices.first(where: {
|
||||||
|
$0 != blockIndex && state.blocks[$0].position == adjacentPos
|
||||||
|
}) {
|
||||||
|
let adjacentBlock = state.blocks[adjacentIdx]
|
||||||
|
if adjacentBlock.color == movingBlock.color {
|
||||||
|
// Same color → destroy both
|
||||||
|
events.append(.destroyed(blockIndex1: blockIndex, blockIndex2: adjacentIdx, at: adjacentPos))
|
||||||
|
// Remove by index (higher index first to keep indices stable)
|
||||||
|
let removeIndices = [blockIndex, adjacentIdx].sorted(by: >)
|
||||||
|
for idx in removeIndices {
|
||||||
|
newBlocks.remove(at: idx)
|
||||||
|
}
|
||||||
|
} else if state.allowsMerging,
|
||||||
|
let merged = GameEngine.mergeColor(movingBlock.color, adjacentBlock.color) {
|
||||||
|
// Different primary colors → merge into a secondary color
|
||||||
|
events.append(.merged(
|
||||||
|
blockIndex1: blockIndex,
|
||||||
|
blockIndex2: adjacentIdx,
|
||||||
|
resultColor: merged,
|
||||||
|
at: adjacentBlock.position
|
||||||
|
))
|
||||||
|
// Remove both in reverse index order to avoid index shifting
|
||||||
|
let removeIndices = [blockIndex, adjacentIdx].sorted(by: >)
|
||||||
|
for idx in removeIndices {
|
||||||
|
newBlocks.remove(at: idx)
|
||||||
|
}
|
||||||
|
// Place merged block at the stationary block's position
|
||||||
|
let mergedBlock = Block(id: adjacentBlock.id, position: adjacentBlock.position, color: merged)
|
||||||
|
newBlocks.append(mergedBlock)
|
||||||
|
}
|
||||||
|
// Any other different-color combination: block already stopped at landingPos
|
||||||
|
}
|
||||||
|
|
||||||
|
let newState = state.withBlocks(newBlocks, incrementMove: true)
|
||||||
|
return MoveResult(newState: newState, events: events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Undo
|
||||||
|
|
||||||
|
static func undo(state: GameState) -> GameState {
|
||||||
|
return state.undoState() ?? state
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge color lookup
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sliding algorithm
|
||||||
|
|
||||||
|
/// Returns the furthest empty cell reachable from `from` in `direction`.
|
||||||
|
/// Returns `from` if the very first step is blocked.
|
||||||
|
private static func slide(from: GridPosition, direction: Direction, state: GameState) -> GridPosition {
|
||||||
|
var current = from
|
||||||
|
while true {
|
||||||
|
let next = current.offset(by: direction)
|
||||||
|
// Stop if next is out of bounds
|
||||||
|
guard state.grid.contains(next) else { break }
|
||||||
|
// Stop if next is a wall
|
||||||
|
guard !state.walls.contains(next) else { break }
|
||||||
|
// Stop if next is occupied by another block
|
||||||
|
guard state.blocks.first(where: { $0.position == next }) == nil else { break }
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
172
CollapseLogic/CollapseLogic/Engine/LevelLoader.swift
Normal file
172
CollapseLogic/CollapseLogic/Engine/LevelLoader.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum LevelLoadError: Error, LocalizedError {
|
||||||
|
case fileNotFound(String)
|
||||||
|
case decodingFailed(String, Error)
|
||||||
|
case validationFailed(String, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .fileNotFound(let name): return "Level file not found: \(name)"
|
||||||
|
case .decodingFailed(let name, let e): return "Failed to decode \(name): \(e)"
|
||||||
|
case .validationFailed(let id, let reason): return "Level \(id) invalid: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LevelLoader {
|
||||||
|
|
||||||
|
// MARK: - Load single level
|
||||||
|
|
||||||
|
static func load(levelId: String, world: Int) throws -> GameState {
|
||||||
|
// World 2 files are prefixed w2_ to avoid name collision with world 1
|
||||||
|
let filename = world >= 2 ? "w2_\(levelId)" : levelId
|
||||||
|
guard let url = Bundle.main.url(forResource: filename, withExtension: "json") else {
|
||||||
|
throw LevelLoadError.fileNotFound("\(filename).json")
|
||||||
|
}
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let def: LevelDefinition
|
||||||
|
do {
|
||||||
|
def = try JSONDecoder().decode(LevelDefinition.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw LevelLoadError.decodingFailed(filename, error)
|
||||||
|
}
|
||||||
|
try validate(def)
|
||||||
|
return makeGameState(from: def)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load from definition (used in tests with in-memory JSON)
|
||||||
|
|
||||||
|
static func makeGameState(from def: LevelDefinition) -> GameState {
|
||||||
|
let gridSize = GridSize(width: def.grid.width, height: def.grid.height)
|
||||||
|
|
||||||
|
let blocks = def.blocks.enumerated().map { idx, b in
|
||||||
|
Block(
|
||||||
|
id: idx,
|
||||||
|
position: GridPosition(x: b.x, y: b.y),
|
||||||
|
color: BlockColor(rawValue: b.color) ?? .red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let walls: Set<GridPosition> = Set((def.walls ?? []).map {
|
||||||
|
GridPosition(x: $0.x, y: $0.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
let objective = parseObjective(def.objective)
|
||||||
|
|
||||||
|
return GameState(
|
||||||
|
grid: gridSize,
|
||||||
|
blocks: blocks,
|
||||||
|
walls: walls,
|
||||||
|
moveCount: 0,
|
||||||
|
par: def.par,
|
||||||
|
objective: objective,
|
||||||
|
allowsMerging: def.world >= 2,
|
||||||
|
history: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
static func loadMetadata() -> MetadataFile? {
|
||||||
|
guard let url = Bundle.main.url(forResource: "metadata", withExtension: "json") else { return nil }
|
||||||
|
return try? JSONDecoder().decode(MetadataFile.self, from: Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
static func validate(_ def: LevelDefinition) throws {
|
||||||
|
let id = def.id
|
||||||
|
guard def.grid.width >= 3, def.grid.width <= 10,
|
||||||
|
def.grid.height >= 3, def.grid.height <= 10 else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Grid dimensions out of range 3–10")
|
||||||
|
}
|
||||||
|
guard def.par > 0 else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Par must be positive")
|
||||||
|
}
|
||||||
|
guard def.blocks.count >= 2 else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "At least 2 blocks required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var occupied = Set<String>()
|
||||||
|
func cell(_ x: Int, _ y: Int) -> String { "\(x),\(y)" }
|
||||||
|
|
||||||
|
for b in def.blocks {
|
||||||
|
guard b.x >= 0, b.x < def.grid.width, b.y >= 0, b.y < def.grid.height else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Block (\(b.x),\(b.y)) out of bounds")
|
||||||
|
}
|
||||||
|
let key = cell(b.x, b.y)
|
||||||
|
guard !occupied.contains(key) else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Duplicate position (\(b.x),\(b.y))")
|
||||||
|
}
|
||||||
|
occupied.insert(key)
|
||||||
|
}
|
||||||
|
for w in def.walls ?? [] {
|
||||||
|
guard w.x >= 0, w.x < def.grid.width, w.y >= 0, w.y < def.grid.height else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Wall (\(w.x),\(w.y)) out of bounds")
|
||||||
|
}
|
||||||
|
let key = cell(w.x, w.y)
|
||||||
|
guard !occupied.contains(key) else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Wall overlaps object at (\(w.x),\(w.y))")
|
||||||
|
}
|
||||||
|
occupied.insert(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let validObjectives = ["clear_all", "clear_color", "reduce_to", "clear_targets"]
|
||||||
|
guard validObjectives.contains(def.objective.type) else {
|
||||||
|
throw LevelLoadError.validationFailed(id, "Unknown objective type: \(def.objective.type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private static func parseObjective(_ obj: ObjectiveDef) -> ObjectiveType {
|
||||||
|
switch obj.type {
|
||||||
|
case "clear_all":
|
||||||
|
return .clearAll
|
||||||
|
case "clear_color":
|
||||||
|
let color = BlockColor(rawValue: obj.color ?? "red") ?? .red
|
||||||
|
return .clearColor(color)
|
||||||
|
case "reduce_to":
|
||||||
|
return .reduceTo(obj.count ?? 1)
|
||||||
|
default:
|
||||||
|
return .clearAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata Codable types
|
||||||
|
|
||||||
|
struct MetadataFile: Codable {
|
||||||
|
let version: String
|
||||||
|
let worlds: [WorldMeta]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorldMeta: Codable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let newMechanic: String?
|
||||||
|
let unlockStars: Int?
|
||||||
|
let levels: [LevelMeta]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, levels
|
||||||
|
case newMechanic = "new_mechanic"
|
||||||
|
case unlockStars = "unlock_stars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LevelMeta: Codable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let file: String
|
||||||
|
let isChallenge: Bool
|
||||||
|
let starsRequired: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, title, file
|
||||||
|
case isChallenge = "is_challenge"
|
||||||
|
case starsRequired = "stars_required"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
CollapseLogic/CollapseLogic/Levels/metadata.json
Normal file
44
CollapseLogic/CollapseLogic/Levels/metadata.json
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"worlds": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Primary",
|
||||||
|
"description": "Learn the basics of pushing and destroying.",
|
||||||
|
"new_mechanic": null,
|
||||||
|
"levels": [
|
||||||
|
{ "id": "w1_01", "title": "First Push", "file": "world1/level_01.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_02", "title": "Blue Pair", "file": "world1/level_02.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_03", "title": "Two Pairs", "file": "world1/level_03.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_04", "title": "Wall Detour", "file": "world1/level_04.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_05", "title": "The Corridor", "file": "world1/level_05.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_06", "title": "Six Pack", "file": "world1/level_06.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_07", "title": "Deadlock Trap", "file": "world1/level_07.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_08", "title": "Detour", "file": "world1/level_08.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_09", "title": "Four Pairs", "file": "world1/level_09.json", "is_challenge": false },
|
||||||
|
{ "id": "w1_10", "title": "The Gauntlet", "file": "world1/level_10.json", "is_challenge": false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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/w2_level_01.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_02", "title": "Bound Together", "file": "world2/w2_level_02.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_03", "title": "The Third Color", "file": "world2/w2_level_03.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_04", "title": "Sun Stone", "file": "world2/w2_level_04.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_05", "title": "Color Theory", "file": "world2/w2_level_05.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_06", "title": "Triad", "file": "world2/w2_level_06.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_07", "title": "Inheritance", "file": "world2/w2_level_07.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_08", "title": "Reflection", "file": "world2/w2_level_08.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_09", "title": "Cascade", "file": "world2/w2_level_09.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_10", "title": "Chain Reaction", "file": "world2/w2_level_10.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_11", "title": "The Long Way", "file": "world2/w2_level_11.json", "is_challenge": false },
|
||||||
|
{ "id": "w2_12", "title": "Prism", "file": "world2/w2_level_12.json", "is_challenge": true, "stars_required": 15 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
CollapseLogic/CollapseLogic/Levels/world1/level_01.json
Normal file
16
CollapseLogic/CollapseLogic/Levels/world1/level_01.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"id": "w1_01",
|
||||||
|
"world": 1,
|
||||||
|
"level": 1,
|
||||||
|
"title": "First Push",
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 1, "color": "red" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 1,
|
||||||
|
"hints": ["Push the left block to the right."]
|
||||||
|
}
|
||||||
16
CollapseLogic/CollapseLogic/Levels/world1/level_02.json
Normal file
16
CollapseLogic/CollapseLogic/Levels/world1/level_02.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"id": "w1_02",
|
||||||
|
"world": 1,
|
||||||
|
"level": 2,
|
||||||
|
"title": "Blue Pair",
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 3, "y": 3, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 2,
|
||||||
|
"hints": ["Align them on the same row, then push."]
|
||||||
|
}
|
||||||
18
CollapseLogic/CollapseLogic/Levels/world1/level_03.json
Normal file
18
CollapseLogic/CollapseLogic/Levels/world1/level_03.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"id": "w1_03",
|
||||||
|
"world": 1,
|
||||||
|
"level": 3,
|
||||||
|
"title": "Two Pairs",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 4, "y": 1, "color": "red" },
|
||||||
|
{ "x": 1, "y": 3, "color": "blue" },
|
||||||
|
{ "x": 3, "y": 3, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 3,
|
||||||
|
"hints": ["Clear the reds first, then align the blues."]
|
||||||
|
}
|
||||||
23
CollapseLogic/CollapseLogic/Levels/world1/level_04.json
Normal file
23
CollapseLogic/CollapseLogic/Levels/world1/level_04.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"id": "w1_04",
|
||||||
|
"world": 1,
|
||||||
|
"level": 4,
|
||||||
|
"title": "Wall Detour",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "red" },
|
||||||
|
{ "x": 1, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 3, "y": 4, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"The wall blocks a direct red push.",
|
||||||
|
"Move one red down first to clear row 0."
|
||||||
|
]
|
||||||
|
}
|
||||||
26
CollapseLogic/CollapseLogic/Levels/world1/level_05.json
Normal file
26
CollapseLogic/CollapseLogic/Levels/world1/level_05.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"id": "w1_05",
|
||||||
|
"world": 1,
|
||||||
|
"level": 5,
|
||||||
|
"title": "The Corridor",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 4, "y": 2, "color": "red" },
|
||||||
|
{ "x": 2, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 4, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 1, "y": 0 },
|
||||||
|
{ "x": 3, "y": 0 },
|
||||||
|
{ "x": 1, "y": 4 },
|
||||||
|
{ "x": 3, "y": 4 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"The center column is open — use it.",
|
||||||
|
"Reds can meet on row 2."
|
||||||
|
]
|
||||||
|
}
|
||||||
23
CollapseLogic/CollapseLogic/Levels/world1/level_06.json
Normal file
23
CollapseLogic/CollapseLogic/Levels/world1/level_06.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"id": "w1_06",
|
||||||
|
"world": 1,
|
||||||
|
"level": 6,
|
||||||
|
"title": "Six Pack",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 1, "y": 2, "color": "red" },
|
||||||
|
{ "x": 3, "y": 2, "color": "red" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 5,
|
||||||
|
"hints": [
|
||||||
|
"Clear the middle reds first to open up space.",
|
||||||
|
"Then handle the corners."
|
||||||
|
]
|
||||||
|
}
|
||||||
24
CollapseLogic/CollapseLogic/Levels/world1/level_07.json
Normal file
24
CollapseLogic/CollapseLogic/Levels/world1/level_07.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"id": "w1_07",
|
||||||
|
"world": 1,
|
||||||
|
"level": 7,
|
||||||
|
"title": "Deadlock Trap",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 2, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 2, "color": "red" },
|
||||||
|
{ "x": 2, "y": 4, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 1, "y": 2 },
|
||||||
|
{ "x": 3, "y": 2 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"Pushing red right immediately blocks the path.",
|
||||||
|
"Move blue first to make room."
|
||||||
|
]
|
||||||
|
}
|
||||||
30
CollapseLogic/CollapseLogic/Levels/world1/level_08.json
Normal file
30
CollapseLogic/CollapseLogic/Levels/world1/level_08.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"id": "w1_08",
|
||||||
|
"world": 1,
|
||||||
|
"level": 8,
|
||||||
|
"title": "Detour",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "red" },
|
||||||
|
{ "x": 1, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 5, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 },
|
||||||
|
{ "x": 2, "y": 1 },
|
||||||
|
{ "x": 3, "y": 0 },
|
||||||
|
{ "x": 3, "y": 1 },
|
||||||
|
{ "x": 2, "y": 4 },
|
||||||
|
{ "x": 2, "y": 5 },
|
||||||
|
{ "x": 3, "y": 4 },
|
||||||
|
{ "x": 3, "y": 5 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 6,
|
||||||
|
"hints": [
|
||||||
|
"Rows 2 and 3 are the only clear crossing rows.",
|
||||||
|
"Route each block through the middle."
|
||||||
|
]
|
||||||
|
}
|
||||||
30
CollapseLogic/CollapseLogic/Levels/world1/level_09.json
Normal file
30
CollapseLogic/CollapseLogic/Levels/world1/level_09.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"id": "w1_09",
|
||||||
|
"world": 1,
|
||||||
|
"level": 9,
|
||||||
|
"title": "Four Pairs",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 5, "y": 2, "color": "red" },
|
||||||
|
{ "x": 0, "y": 3, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 3, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 1 },
|
||||||
|
{ "x": 3, "y": 1 },
|
||||||
|
{ "x": 2, "y": 4 },
|
||||||
|
{ "x": 3, "y": 4 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 7,
|
||||||
|
"hints": [
|
||||||
|
"Rows 2 and 3 can be cleared without routing.",
|
||||||
|
"Rows 0 and 5 need the walls accounted for."
|
||||||
|
]
|
||||||
|
}
|
||||||
36
CollapseLogic/CollapseLogic/Levels/world1/level_10.json
Normal file
36
CollapseLogic/CollapseLogic/Levels/world1/level_10.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"id": "w1_10",
|
||||||
|
"world": 1,
|
||||||
|
"level": 10,
|
||||||
|
"title": "The Gauntlet",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 4, "color": "red" },
|
||||||
|
{ "x": 1, "y": 3, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 5, "y": 3, "color": "red" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 },
|
||||||
|
{ "x": 3, "y": 0 },
|
||||||
|
{ "x": 0, "y": 3 },
|
||||||
|
{ "x": 5, "y": 2 },
|
||||||
|
{ "x": 2, "y": 5 },
|
||||||
|
{ "x": 3, "y": 5 },
|
||||||
|
{ "x": 1, "y": 1 },
|
||||||
|
{ "x": 4, "y": 4 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 8,
|
||||||
|
"hints": [
|
||||||
|
"Sequence matters — one wrong move jams the board.",
|
||||||
|
"Clear interior blocks before the corner pairs."
|
||||||
|
]
|
||||||
|
}
|
||||||
20
CollapseLogic/CollapseLogic/Levels/world2/w2_level_01.json
Normal file
20
CollapseLogic/CollapseLogic/Levels/world2/w2_level_01.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"id": "w2_01",
|
||||||
|
"world": 2,
|
||||||
|
"level": 1,
|
||||||
|
"title": "First Contact",
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 1, "color": "blue" },
|
||||||
|
{ "x": 1, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 2,
|
||||||
|
"hints": [
|
||||||
|
"Push the red block into the blue block. Something new will appear.",
|
||||||
|
"Now push the new purple block into the one waiting below."
|
||||||
|
]
|
||||||
|
}
|
||||||
21
CollapseLogic/CollapseLogic/Levels/world2/w2_level_02.json
Normal file
21
CollapseLogic/CollapseLogic/Levels/world2/w2_level_02.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"id": "w2_02",
|
||||||
|
"world": 2,
|
||||||
|
"level": 2,
|
||||||
|
"title": "Bound Together",
|
||||||
|
"grid": { "width": 5, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 3, "color": "red" },
|
||||||
|
{ "x": 4, "y": 3, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"You need to make two purple blocks and then destroy both.",
|
||||||
|
"Each red+blue collision creates one purple."
|
||||||
|
]
|
||||||
|
}
|
||||||
22
CollapseLogic/CollapseLogic/Levels/world2/w2_level_03.json
Normal file
22
CollapseLogic/CollapseLogic/Levels/world2/w2_level_03.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"id": "w2_03",
|
||||||
|
"world": 2,
|
||||||
|
"level": 3,
|
||||||
|
"title": "The Third Color",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 4, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 2 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 3,
|
||||||
|
"hints": [
|
||||||
|
"The wall stops the red block in the middle of the row.",
|
||||||
|
"Move blue down to row 2 first, then push red into it."
|
||||||
|
]
|
||||||
|
}
|
||||||
20
CollapseLogic/CollapseLogic/Levels/world2/w2_level_04.json
Normal file
20
CollapseLogic/CollapseLogic/Levels/world2/w2_level_04.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"id": "w2_04",
|
||||||
|
"world": 2,
|
||||||
|
"level": 4,
|
||||||
|
"title": "Sun Stone",
|
||||||
|
"grid": { "width": 5, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 4, "y": 1, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 3, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 2,
|
||||||
|
"hints": [
|
||||||
|
"Red and yellow make orange.",
|
||||||
|
"Push the new orange into the one waiting at the bottom."
|
||||||
|
]
|
||||||
|
}
|
||||||
22
CollapseLogic/CollapseLogic/Levels/world2/w2_level_05.json
Normal file
22
CollapseLogic/CollapseLogic/Levels/world2/w2_level_05.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"id": "w2_05",
|
||||||
|
"world": 2,
|
||||||
|
"level": 5,
|
||||||
|
"title": "Color Theory",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 4, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 0, "color": "purple" },
|
||||||
|
{ "x": 2, "y": 4, "color": "yellow" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 3,
|
||||||
|
"hints": [
|
||||||
|
"You need to make a purple block to match the one already on the board.",
|
||||||
|
"Merge red and blue first, then align the two purples.",
|
||||||
|
"Yellow is still on the board — you'll need to deal with it separately."
|
||||||
|
]
|
||||||
|
}
|
||||||
24
CollapseLogic/CollapseLogic/Levels/world2/w2_level_06.json
Normal file
24
CollapseLogic/CollapseLogic/Levels/world2/w2_level_06.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"id": "w2_06",
|
||||||
|
"world": 2,
|
||||||
|
"level": 6,
|
||||||
|
"title": "Triad",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 4, "color": "yellow" },
|
||||||
|
{ "x": 4, "y": 2, "color": "purple" },
|
||||||
|
{ "x": 2, "y": 4, "color": "orange" },
|
||||||
|
{ "x": 4, "y": 4, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 6,
|
||||||
|
"hints": [
|
||||||
|
"Three primaries, three secondaries waiting to match them.",
|
||||||
|
"Red+blue=purple, red+yellow=orange, blue+yellow=green.",
|
||||||
|
"Think about which merge to do first — order matters."
|
||||||
|
]
|
||||||
|
}
|
||||||
24
CollapseLogic/CollapseLogic/Levels/world2/w2_level_07.json
Normal file
24
CollapseLogic/CollapseLogic/Levels/world2/w2_level_07.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"id": "w2_07",
|
||||||
|
"world": 2,
|
||||||
|
"level": 7,
|
||||||
|
"title": "Inheritance",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 1, "color": "purple" },
|
||||||
|
{ "x": 4, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 },
|
||||||
|
{ "x": 2, "y": 4 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"You need to create two purple blocks to match the ones on the right.",
|
||||||
|
"The walls prevent a direct push — you'll need to reposition first."
|
||||||
|
]
|
||||||
|
}
|
||||||
26
CollapseLogic/CollapseLogic/Levels/world2/w2_level_08.json
Normal file
26
CollapseLogic/CollapseLogic/Levels/world2/w2_level_08.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"id": "w2_08",
|
||||||
|
"world": 2,
|
||||||
|
"level": 8,
|
||||||
|
"title": "Reflection",
|
||||||
|
"grid": { "width": 6, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 0, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 4, "color": "yellow" },
|
||||||
|
{ "x": 3, "y": 2, "color": "orange" },
|
||||||
|
{ "x": 1, "y": 2, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 1 },
|
||||||
|
{ "x": 2, "y": 3 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 6,
|
||||||
|
"hints": [
|
||||||
|
"Orange comes from red+yellow. Green comes from blue+yellow.",
|
||||||
|
"The walls create lanes — use them to control where your blocks end up."
|
||||||
|
]
|
||||||
|
}
|
||||||
25
CollapseLogic/CollapseLogic/Levels/world2/w2_level_09.json
Normal file
25
CollapseLogic/CollapseLogic/Levels/world2/w2_level_09.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"id": "w2_09",
|
||||||
|
"world": 2,
|
||||||
|
"level": 9,
|
||||||
|
"title": "Cascade",
|
||||||
|
"grid": { "width": 6, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 5, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 3, "y": 4, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 3, "y": 0 },
|
||||||
|
{ "x": 3, "y": 1 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 5,
|
||||||
|
"hints": [
|
||||||
|
"One merge alone won't clear the board — you need to chain two merges.",
|
||||||
|
"Think about what color you need at the end, and work backwards."
|
||||||
|
]
|
||||||
|
}
|
||||||
27
CollapseLogic/CollapseLogic/Levels/world2/w2_level_10.json
Normal file
27
CollapseLogic/CollapseLogic/Levels/world2/w2_level_10.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"id": "w2_10",
|
||||||
|
"world": 2,
|
||||||
|
"level": 10,
|
||||||
|
"title": "Chain Reaction",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 5, "color": "red" },
|
||||||
|
{ "x": 5, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 2, "color": "yellow" },
|
||||||
|
{ "x": 3, "y": 4, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 1, "y": 3 },
|
||||||
|
{ "x": 4, "y": 2 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 7,
|
||||||
|
"hints": [
|
||||||
|
"You need to create two purple blocks to destroy each other.",
|
||||||
|
"The yellow block and the orange block also need to be cleared.",
|
||||||
|
"Work the corners first."
|
||||||
|
]
|
||||||
|
}
|
||||||
29
CollapseLogic/CollapseLogic/Levels/world2/w2_level_11.json
Normal file
29
CollapseLogic/CollapseLogic/Levels/world2/w2_level_11.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"id": "w2_11",
|
||||||
|
"world": 2,
|
||||||
|
"level": 11,
|
||||||
|
"title": "The Long Way",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 0, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "red" },
|
||||||
|
{ "x": 2, "y": 3, "color": "orange" },
|
||||||
|
{ "x": 4, "y": 2, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 1, "y": 2 },
|
||||||
|
{ "x": 3, "y": 0 },
|
||||||
|
{ "x": 3, "y": 1 },
|
||||||
|
{ "x": 1, "y": 4 },
|
||||||
|
{ "x": 3, "y": 5 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 8,
|
||||||
|
"hints": [
|
||||||
|
"The walls create a maze. There's only one efficient path through.",
|
||||||
|
"You'll need to use every block — nothing is wasted in this layout."
|
||||||
|
]
|
||||||
|
}
|
||||||
33
CollapseLogic/CollapseLogic/Levels/world2/w2_level_12.json
Normal file
33
CollapseLogic/CollapseLogic/Levels/world2/w2_level_12.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"id": "w2_12",
|
||||||
|
"world": 2,
|
||||||
|
"level": 12,
|
||||||
|
"title": "Prism",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 5, "color": "yellow" },
|
||||||
|
{ "x": 5, "y": 2, "color": "red" },
|
||||||
|
{ "x": 0, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 1, "color": "purple" },
|
||||||
|
{ "x": 3, "y": 4, "color": "orange" },
|
||||||
|
{ "x": 1, "y": 4, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 },
|
||||||
|
{ "x": 4, "y": 1 },
|
||||||
|
{ "x": 1, "y": 3 },
|
||||||
|
{ "x": 4, "y": 3 },
|
||||||
|
{ "x": 2, "y": 5 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 9,
|
||||||
|
"hints": [
|
||||||
|
"All six colors are here. Every block has a partner.",
|
||||||
|
"The three pre-placed secondaries tell you which primary pairs to make.",
|
||||||
|
"Map out the full solution before your first move."
|
||||||
|
]
|
||||||
|
}
|
||||||
35
CollapseLogic/CollapseLogic/Models/Block.swift
Normal file
35
CollapseLogic/CollapseLogic/Models/Block.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
enum BlockColor: String, Codable, Hashable, 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Block: Hashable, Equatable {
|
||||||
|
let id: Int // stable index within original level; survives undo
|
||||||
|
let position: GridPosition
|
||||||
|
let color: BlockColor
|
||||||
|
}
|
||||||
3
CollapseLogic/CollapseLogic/Models/Direction.swift
Normal file
3
CollapseLogic/CollapseLogic/Models/Direction.swift
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
enum Direction: CaseIterable {
|
||||||
|
case up, down, left, right
|
||||||
|
}
|
||||||
100
CollapseLogic/CollapseLogic/Models/GameState.swift
Normal file
100
CollapseLogic/CollapseLogic/Models/GameState.swift
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
// MARK: - Supporting types
|
||||||
|
|
||||||
|
struct GridSize: Equatable {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
|
||||||
|
func contains(_ pos: GridPosition) -> Bool {
|
||||||
|
pos.x >= 0 && pos.x < width && pos.y >= 0 && pos.y < height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ObjectiveType: Equatable {
|
||||||
|
case clearAll
|
||||||
|
case clearColor(BlockColor)
|
||||||
|
case reduceTo(Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GameState
|
||||||
|
|
||||||
|
/// Immutable. Every move produces a new instance.
|
||||||
|
struct GameState: Equatable {
|
||||||
|
let grid: GridSize
|
||||||
|
let blocks: [Block]
|
||||||
|
let walls: Set<GridPosition>
|
||||||
|
let moveCount: Int
|
||||||
|
let par: Int
|
||||||
|
let objective: ObjectiveType
|
||||||
|
/// World 2+ enables primary+primary merging into secondary colors. World 1 leaves
|
||||||
|
/// different-color collisions as a hard stop, matching the original mechanic.
|
||||||
|
let allowsMerging: Bool
|
||||||
|
/// Previous states for undo. Stored WITHOUT their own history to avoid memory explosion.
|
||||||
|
let history: [GameState]
|
||||||
|
|
||||||
|
// MARK: Derived helpers
|
||||||
|
|
||||||
|
var isWon: Bool {
|
||||||
|
switch objective {
|
||||||
|
case .clearAll:
|
||||||
|
return blocks.isEmpty
|
||||||
|
case .clearColor(let color):
|
||||||
|
return !blocks.contains(where: { $0.color == color })
|
||||||
|
case .reduceTo(let count):
|
||||||
|
return blocks.count <= count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stars(for moveCount: Int) -> Int {
|
||||||
|
if moveCount <= par { return 3 }
|
||||||
|
if moveCount <= par + 2 { return 2 }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentStars: Int { stars(for: moveCount) }
|
||||||
|
|
||||||
|
func block(at pos: GridPosition) -> Block? {
|
||||||
|
blocks.first(where: { $0.position == pos })
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOccupied(_ pos: GridPosition) -> Bool {
|
||||||
|
walls.contains(pos) || blocks.contains(where: { $0.position == pos })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: State factory used by GameEngine
|
||||||
|
|
||||||
|
func withBlocks(_ newBlocks: [Block], incrementMove: Bool) -> GameState {
|
||||||
|
// Strip history from self before storing as a history entry
|
||||||
|
let stripped = GameState(
|
||||||
|
grid: grid, blocks: blocks, walls: walls,
|
||||||
|
moveCount: moveCount, par: par, objective: objective,
|
||||||
|
allowsMerging: allowsMerging, history: []
|
||||||
|
)
|
||||||
|
return GameState(
|
||||||
|
grid: grid,
|
||||||
|
blocks: newBlocks,
|
||||||
|
walls: walls,
|
||||||
|
moveCount: incrementMove ? moveCount + 1 : moveCount,
|
||||||
|
par: par,
|
||||||
|
objective: objective,
|
||||||
|
allowsMerging: allowsMerging,
|
||||||
|
history: history + [stripped]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Undo
|
||||||
|
|
||||||
|
var canUndo: Bool { !history.isEmpty }
|
||||||
|
|
||||||
|
func undoState() -> GameState? {
|
||||||
|
guard var prev = history.last else { return nil }
|
||||||
|
// Restore the history slice that was current before this move
|
||||||
|
let newHistory = Array(history.dropLast())
|
||||||
|
prev = GameState(
|
||||||
|
grid: prev.grid, blocks: prev.blocks, walls: prev.walls,
|
||||||
|
moveCount: prev.moveCount, par: prev.par,
|
||||||
|
objective: prev.objective, allowsMerging: prev.allowsMerging,
|
||||||
|
history: newHistory
|
||||||
|
)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
}
|
||||||
13
CollapseLogic/CollapseLogic/Models/GridPosition.swift
Normal file
13
CollapseLogic/CollapseLogic/Models/GridPosition.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
struct GridPosition: Hashable, Equatable, Codable {
|
||||||
|
let x: Int
|
||||||
|
let y: Int
|
||||||
|
|
||||||
|
func offset(by direction: Direction) -> GridPosition {
|
||||||
|
switch direction {
|
||||||
|
case .up: return GridPosition(x: x, y: y - 1)
|
||||||
|
case .down: return GridPosition(x: x, y: y + 1)
|
||||||
|
case .left: return GridPosition(x: x - 1, y: y)
|
||||||
|
case .right: return GridPosition(x: x + 1, y: y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
CollapseLogic/CollapseLogic/Models/LevelDefinition.swift
Normal file
51
CollapseLogic/CollapseLogic/Models/LevelDefinition.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Codable structs mirroring the JSON level format
|
||||||
|
|
||||||
|
struct GridSizeDef: Codable {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BlockDef: Codable {
|
||||||
|
let x: Int
|
||||||
|
let y: Int
|
||||||
|
let color: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PositionDef: Codable {
|
||||||
|
let x: Int
|
||||||
|
let y: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ObjectiveDef: Codable {
|
||||||
|
let type: String
|
||||||
|
let color: String?
|
||||||
|
let count: Int?
|
||||||
|
let targets: [PositionDef]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LevelDefinition: Codable {
|
||||||
|
let id: String
|
||||||
|
let world: Int
|
||||||
|
let level: Int
|
||||||
|
let title: String?
|
||||||
|
let grid: GridSizeDef
|
||||||
|
let blocks: [BlockDef]
|
||||||
|
let walls: [PositionDef]?
|
||||||
|
let specialTiles: [SpecialTileDef]?
|
||||||
|
let objective: ObjectiveDef
|
||||||
|
let par: Int
|
||||||
|
let hints: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, world, level, title, grid, blocks, walls
|
||||||
|
case specialTiles = "special_tiles"
|
||||||
|
case objective, par, hints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SpecialTileDef: Codable {
|
||||||
|
let x: Int
|
||||||
|
let y: Int
|
||||||
|
let type: String
|
||||||
|
// Additional fields ignored at MVP
|
||||||
|
}
|
||||||
12
CollapseLogic/CollapseLogic/Models/MoveResult.swift
Normal file
12
CollapseLogic/CollapseLogic/Models/MoveResult.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MoveResult {
|
||||||
|
let newState: GameState
|
||||||
|
let events: [GameEvent]
|
||||||
|
/// True when the move actually changed state (block moved)
|
||||||
|
var isValid: Bool { !events.isEmpty }
|
||||||
|
}
|
||||||
47
CollapseLogic/CollapseLogic/Persistence/ProgressStore.swift
Normal file
47
CollapseLogic/CollapseLogic/Persistence/ProgressStore.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
final class ProgressStore: ObservableObject {
|
||||||
|
static let shared = ProgressStore()
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Bumped whenever stored progress changes. SwiftUI views that observe
|
||||||
|
/// this store re-render automatically; the actual reads still go through
|
||||||
|
/// the existing methods.
|
||||||
|
@Published private(set) var version: Int = 0
|
||||||
|
|
||||||
|
// MARK: - Keys
|
||||||
|
|
||||||
|
private func starsKey(for levelId: String) -> String { "stars_\(levelId)" }
|
||||||
|
private func completedKey(for levelId: String) -> String { "completed_\(levelId)" }
|
||||||
|
|
||||||
|
// MARK: - API
|
||||||
|
|
||||||
|
func starsFor(levelId: String) -> Int {
|
||||||
|
defaults.integer(forKey: starsKey(for: levelId)) // returns 0 if missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStars(_ stars: Int, for levelId: String) {
|
||||||
|
let clamped = max(0, min(3, stars))
|
||||||
|
// Only upgrade, never downgrade
|
||||||
|
if clamped > starsFor(levelId: levelId) {
|
||||||
|
defaults.set(clamped, forKey: starsKey(for: levelId))
|
||||||
|
defaults.set(true, forKey: completedKey(for: levelId))
|
||||||
|
version &+= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompleted(levelId: String) -> Bool {
|
||||||
|
defaults.bool(forKey: completedKey(for: levelId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalStars() -> Int {
|
||||||
|
// Sum all stored star keys
|
||||||
|
defaults.dictionaryRepresentation()
|
||||||
|
.filter { $0.key.hasPrefix("stars_") }
|
||||||
|
.values
|
||||||
|
.compactMap { $0 as? Int }
|
||||||
|
.reduce(0, +)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CollapseLogic/CollapseLogic/Resources/SFX/complete.mp3
Normal file
BIN
CollapseLogic/CollapseLogic/Resources/SFX/complete.mp3
Normal file
Binary file not shown.
BIN
CollapseLogic/CollapseLogic/Resources/SFX/destroy.mp3
Normal file
BIN
CollapseLogic/CollapseLogic/Resources/SFX/destroy.mp3
Normal file
Binary file not shown.
0
CollapseLogic/CollapseLogic/Resources/SFX/merge.wav
Normal file
0
CollapseLogic/CollapseLogic/Resources/SFX/merge.wav
Normal file
BIN
CollapseLogic/CollapseLogic/Resources/SFX/select.mp3
Normal file
BIN
CollapseLogic/CollapseLogic/Resources/SFX/select.mp3
Normal file
Binary file not shown.
BIN
CollapseLogic/CollapseLogic/Resources/SFX/slide.mp3
Normal file
BIN
CollapseLogic/CollapseLogic/Resources/SFX/slide.mp3
Normal file
Binary file not shown.
134
CollapseLogic/CollapseLogic/Scene/BlockNode.swift
Normal file
134
CollapseLogic/CollapseLogic/Scene/BlockNode.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
// MARK: - Colors
|
||||||
|
|
||||||
|
extension BlockColor {
|
||||||
|
var uiColor: UIColor {
|
||||||
|
switch self {
|
||||||
|
case .red: return UIColor(hex: "#E63946")
|
||||||
|
case .blue: return UIColor(hex: "#457B9D")
|
||||||
|
case .yellow: return UIColor(hex: "#F4D35E")
|
||||||
|
case .purple: return UIColor(hex: "#7B2D8B")
|
||||||
|
case .orange: return UIColor(hex: "#E76F51")
|
||||||
|
case .green: return UIColor(hex: "#2A9D8F")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
convenience init(hex: String) {
|
||||||
|
let h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var val: UInt64 = 0
|
||||||
|
Scanner(string: h).scanHexInt64(&val)
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((val >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((val >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(val & 0xFF) / 255,
|
||||||
|
alpha: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BlockNode
|
||||||
|
|
||||||
|
final class BlockNode: SKShapeNode {
|
||||||
|
|
||||||
|
let block: Block
|
||||||
|
private var selectionRing: SKShapeNode?
|
||||||
|
|
||||||
|
init(block: Block, cellSize: CGFloat) {
|
||||||
|
self.block = block
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
let size = cellSize * 0.82
|
||||||
|
let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
|
||||||
|
path = UIBezierPath(roundedRect: rect, cornerRadius: size * 0.18).cgPath
|
||||||
|
|
||||||
|
fillColor = block.color.uiColor
|
||||||
|
strokeColor = block.color.uiColor.darker(by: 0.25)
|
||||||
|
lineWidth = 1.5
|
||||||
|
zPosition = 10
|
||||||
|
name = "block_\(block.id)"
|
||||||
|
|
||||||
|
// Secondary blocks get a white inner ring to distinguish them from primaries
|
||||||
|
if !block.color.isPrimary {
|
||||||
|
let inset = size - 8
|
||||||
|
let innerRect = CGRect(x: -inset / 2, y: -inset / 2, width: inset, height: inset)
|
||||||
|
let ring = SKShapeNode(path: UIBezierPath(roundedRect: innerRect, cornerRadius: size * 0.18 - 2).cgPath)
|
||||||
|
ring.fillColor = .clear
|
||||||
|
ring.strokeColor = SKColor.white.withAlphaComponent(0.4)
|
||||||
|
ring.lineWidth = 2
|
||||||
|
ring.zPosition = 1
|
||||||
|
addChild(ring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: Selection
|
||||||
|
|
||||||
|
func setSelected(_ selected: Bool, cellSize: CGFloat) {
|
||||||
|
selectionRing?.removeFromParent()
|
||||||
|
selectionRing = nil
|
||||||
|
guard selected else { return }
|
||||||
|
|
||||||
|
let size = cellSize * 0.86
|
||||||
|
let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
|
||||||
|
let ring = SKShapeNode(path: UIBezierPath(roundedRect: rect, cornerRadius: size * 0.18).cgPath)
|
||||||
|
ring.fillColor = .clear
|
||||||
|
ring.strokeColor = .white
|
||||||
|
ring.lineWidth = 2
|
||||||
|
ring.zPosition = 11
|
||||||
|
ring.name = "selectionRing"
|
||||||
|
|
||||||
|
let pulse = SKAction.sequence([
|
||||||
|
SKAction.fadeAlpha(to: 0.4, duration: 0.5),
|
||||||
|
SKAction.fadeAlpha(to: 1.0, duration: 0.5)
|
||||||
|
])
|
||||||
|
ring.run(SKAction.repeatForever(pulse))
|
||||||
|
addChild(ring)
|
||||||
|
self.selectionRing = ring
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Destruction animation
|
||||||
|
|
||||||
|
func animateDestruction(completion: @escaping () -> Void) {
|
||||||
|
let shrink = SKAction.scale(to: 0.1, duration: 0.18)
|
||||||
|
let fade = SKAction.fadeOut(withDuration: 0.18)
|
||||||
|
let group = SKAction.group([shrink, fade])
|
||||||
|
run(group) { completion() }
|
||||||
|
|
||||||
|
// Particle burst — simple coloured circles
|
||||||
|
for _ in 0..<6 {
|
||||||
|
let dot = SKShapeNode(circleOfRadius: 4)
|
||||||
|
dot.fillColor = fillColor
|
||||||
|
dot.strokeColor = .clear
|
||||||
|
dot.zPosition = 12
|
||||||
|
parent?.addChild(dot)
|
||||||
|
dot.position = position
|
||||||
|
|
||||||
|
let angle = CGFloat.random(in: 0 ..< .pi * 2)
|
||||||
|
let dist = CGFloat.random(in: 20 ... 45)
|
||||||
|
let dx = cos(angle) * dist
|
||||||
|
let dy = sin(angle) * dist
|
||||||
|
dot.run(SKAction.sequence([
|
||||||
|
SKAction.group([
|
||||||
|
SKAction.moveBy(x: dx, y: dy, duration: 0.22),
|
||||||
|
SKAction.fadeOut(withDuration: 0.22)
|
||||||
|
]),
|
||||||
|
SKAction.removeFromParent()
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SKColor helper
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
func darker(by factor: CGFloat) -> UIColor {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return UIColor(red: max(0, r - factor), green: max(0, g - factor),
|
||||||
|
blue: max(0, b - factor), alpha: a)
|
||||||
|
}
|
||||||
|
}
|
||||||
308
CollapseLogic/CollapseLogic/Scene/GameScene.swift
Normal file
308
CollapseLogic/CollapseLogic/Scene/GameScene.swift
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
// MARK: - Delegate
|
||||||
|
|
||||||
|
protocol GameSceneDelegate: AnyObject {
|
||||||
|
func gameSceneDidWin(moveCount: Int, stars: Int)
|
||||||
|
func gameSceneDidUpdateMoveCount(_ count: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GameScene
|
||||||
|
|
||||||
|
final class GameScene: SKScene {
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
var gameState: GameState {
|
||||||
|
didSet { syncBlockNodes() }
|
||||||
|
}
|
||||||
|
private var renderer: GridRenderer!
|
||||||
|
private var blockNodes: [Int: BlockNode] = [:] // keyed by Block.id
|
||||||
|
private var selectedBlockIndex: Int? = nil
|
||||||
|
private var isAnimating = false
|
||||||
|
weak var sceneDelegate: GameSceneDelegate?
|
||||||
|
|
||||||
|
// MARK: Init
|
||||||
|
|
||||||
|
init(state: GameState, size: CGSize) {
|
||||||
|
self.gameState = state
|
||||||
|
super.init(size: size)
|
||||||
|
scaleMode = .resizeFill
|
||||||
|
backgroundColor = UIColor(hex: "#1A1A2E")
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - Scene lifecycle
|
||||||
|
|
||||||
|
override func didMove(to view: SKView) {
|
||||||
|
renderer = GridRenderer(gridSize: gameState.grid, sceneSize: size)
|
||||||
|
addChild(renderer.buildBackground())
|
||||||
|
addChild(renderer.buildWalls(gameState.walls))
|
||||||
|
rebuildAllBlockNodes()
|
||||||
|
setupGestures(in: view)
|
||||||
|
SFXRunner.shared.attach(to: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input
|
||||||
|
|
||||||
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard !isAnimating, let touch = touches.first else { return }
|
||||||
|
let loc = touch.location(in: self)
|
||||||
|
|
||||||
|
// Check if a block was tapped
|
||||||
|
if let blockNode = nodes(at: loc).compactMap({ $0 as? BlockNode }).first {
|
||||||
|
let idx = gameState.blocks.firstIndex(where: { $0.id == blockNode.block.id })
|
||||||
|
selectBlock(at: idx)
|
||||||
|
SFXManager.shared.play(.select)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
// Handled by swipe detection below
|
||||||
|
}
|
||||||
|
|
||||||
|
private var swipeStart: CGPoint?
|
||||||
|
|
||||||
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let touch = touches.first else { return }
|
||||||
|
if swipeStart == nil { swipeStart = touch.previousLocation(in: self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use a single recognizer-style approach: track start on touchesBegan
|
||||||
|
// and compute direction on touchesEnded for the selected block.
|
||||||
|
|
||||||
|
// MARK: - Swipe detection via UISwipeGestureRecognizer (added in didMove)
|
||||||
|
|
||||||
|
func setupGestures(in view: SKView) {
|
||||||
|
for dir in [UISwipeGestureRecognizer.Direction.up,
|
||||||
|
.down, .left, .right] {
|
||||||
|
let rec = UISwipeGestureRecognizer(target: self,
|
||||||
|
action: #selector(handleSwipe(_:)))
|
||||||
|
rec.direction = dir
|
||||||
|
view.addGestureRecognizer(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleSwipe(_ rec: UISwipeGestureRecognizer) {
|
||||||
|
guard !isAnimating, let idx = selectedBlockIndex else { return }
|
||||||
|
let direction: Direction
|
||||||
|
switch rec.direction {
|
||||||
|
case .up: direction = .up
|
||||||
|
case .down: direction = .down
|
||||||
|
case .left: direction = .left
|
||||||
|
case .right: direction = .right
|
||||||
|
default: return
|
||||||
|
}
|
||||||
|
applyMove(blockIndex: idx, direction: direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Move application
|
||||||
|
|
||||||
|
func applyMove(blockIndex: Int, direction: Direction) {
|
||||||
|
guard !isAnimating else { return }
|
||||||
|
let result = GameEngine.processMove(state: gameState, blockIndex: blockIndex,
|
||||||
|
direction: direction)
|
||||||
|
guard result.isValid else { return }
|
||||||
|
|
||||||
|
isAnimating = true
|
||||||
|
selectedBlockIndex = nil
|
||||||
|
deselectAll()
|
||||||
|
|
||||||
|
animateEvents(result.events, newState: result.newState) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.gameState = result.newState
|
||||||
|
self.isAnimating = false
|
||||||
|
self.sceneDelegate?.gameSceneDidUpdateMoveCount(result.newState.moveCount)
|
||||||
|
if result.newState.isWon {
|
||||||
|
SFXManager.shared.play(.complete)
|
||||||
|
self.sceneDelegate?.gameSceneDidWin(
|
||||||
|
moveCount: result.newState.moveCount,
|
||||||
|
stars: result.newState.currentStars
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUndo() {
|
||||||
|
guard !isAnimating else { return }
|
||||||
|
let prev = GameEngine.undo(state: gameState)
|
||||||
|
guard prev.moveCount != gameState.moveCount || prev.blocks.count != gameState.blocks.count else { return }
|
||||||
|
gameState = prev
|
||||||
|
sceneDelegate?.gameSceneDidUpdateMoveCount(prev.moveCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRestart(initialState: GameState) {
|
||||||
|
guard !isAnimating else { return }
|
||||||
|
gameState = initialState
|
||||||
|
sceneDelegate?.gameSceneDidUpdateMoveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation
|
||||||
|
|
||||||
|
private func animateEvents(_ events: [GameEvent], newState: GameState,
|
||||||
|
completion: @escaping () -> Void) {
|
||||||
|
var remaining = events.count
|
||||||
|
guard remaining > 0 else { completion(); return }
|
||||||
|
|
||||||
|
let done = {
|
||||||
|
remaining -= 1
|
||||||
|
if remaining == 0 { completion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
switch event {
|
||||||
|
case .slid(let blockIndex, _, let to):
|
||||||
|
guard let block = gameState.blocks.indices.contains(blockIndex)
|
||||||
|
? gameState.blocks[blockIndex] : nil,
|
||||||
|
let node = blockNodes[block.id] else { done(); continue }
|
||||||
|
|
||||||
|
let dest = renderer.scenePosition(for: to)
|
||||||
|
SFXManager.shared.play(.slide)
|
||||||
|
node.run(SKAction.move(to: dest, duration: 0.15).ease(.easeOut)) { done() }
|
||||||
|
|
||||||
|
case .destroyed(let idx1, let idx2, _):
|
||||||
|
// Both nodes are still at their pre-move positions; animate the slid one
|
||||||
|
// (already handled in .slid), then destroy both
|
||||||
|
let ids = [idx1, idx2].compactMap { i -> Int? in
|
||||||
|
guard gameState.blocks.indices.contains(i) else { return nil }
|
||||||
|
return gameState.blocks[i].id
|
||||||
|
}
|
||||||
|
SFXManager.shared.play(.destroy)
|
||||||
|
var destroyRemaining = ids.count
|
||||||
|
for id in ids {
|
||||||
|
guard let node = blockNodes[id] else {
|
||||||
|
destroyRemaining -= 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.animateDestruction {
|
||||||
|
node.removeFromParent()
|
||||||
|
destroyRemaining -= 1
|
||||||
|
if destroyRemaining == 0 { done() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.isEmpty { done() }
|
||||||
|
|
||||||
|
case .merged(let idx1, let idx2, let resultColor, let position):
|
||||||
|
// Look up both source block nodes (indices reference the OLD state)
|
||||||
|
let ids = [idx1, idx2].compactMap { i -> Int? in
|
||||||
|
guard gameState.blocks.indices.contains(i) else { return nil }
|
||||||
|
return gameState.blocks[i].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine assigns the merged block the stationary block's id.
|
||||||
|
// Look it up in the new state so the sprite, the model block, and
|
||||||
|
// the blockNodes dict all agree on the same id.
|
||||||
|
let mergedId = newState.blocks.first(where: { $0.position == position })?.id ?? -1
|
||||||
|
|
||||||
|
// Burst: scale up 1.2×, then shrink + fade out, then remove
|
||||||
|
let burstUp = SKAction.scale(to: 1.2, duration: 0.1)
|
||||||
|
let burstOut = SKAction.group([
|
||||||
|
SKAction.scale(to: 0.0, duration: 0.1),
|
||||||
|
SKAction.fadeOut(withDuration: 0.1)
|
||||||
|
])
|
||||||
|
let burstSeq = SKAction.sequence([burstUp, burstOut, SKAction.removeFromParent()])
|
||||||
|
|
||||||
|
var burstCount = ids.count
|
||||||
|
let spawnMergedBlock = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
// Source nodes were removed from the scene tree by burstSeq;
|
||||||
|
// drop their dict entries so syncBlockNodes/selection stay consistent.
|
||||||
|
for id in ids { self.blockNodes.removeValue(forKey: id) }
|
||||||
|
|
||||||
|
let mergedBlock = Block(id: mergedId, position: position, color: resultColor)
|
||||||
|
let node = BlockNode(block: mergedBlock, cellSize: self.renderer.cellSize)
|
||||||
|
node.position = self.renderer.scenePosition(for: position)
|
||||||
|
node.setScale(0.0)
|
||||||
|
self.addChild(node)
|
||||||
|
self.blockNodes[mergedId] = node
|
||||||
|
// Spring pop: 0 → 1.15 → 1.0
|
||||||
|
let spring = SKAction.sequence([
|
||||||
|
SKAction.scale(to: 1.15, duration: 0.12),
|
||||||
|
SKAction.scale(to: 1.0, duration: 0.05)
|
||||||
|
])
|
||||||
|
node.run(spring) { done() }
|
||||||
|
}
|
||||||
|
|
||||||
|
SFXManager.shared.play(.merge)
|
||||||
|
|
||||||
|
if ids.isEmpty {
|
||||||
|
spawnMergedBlock()
|
||||||
|
} else {
|
||||||
|
for id in ids {
|
||||||
|
guard let node = blockNodes[id] else {
|
||||||
|
burstCount -= 1
|
||||||
|
if burstCount == 0 { spawnMergedBlock() }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.run(burstSeq) {
|
||||||
|
burstCount -= 1
|
||||||
|
if burstCount == 0 { spawnMergedBlock() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Node management
|
||||||
|
|
||||||
|
private func rebuildAllBlockNodes() {
|
||||||
|
blockNodes.values.forEach { $0.removeFromParent() }
|
||||||
|
blockNodes = [:]
|
||||||
|
for (idx, block) in gameState.blocks.enumerated() {
|
||||||
|
addBlockNode(block: block, at: block.position)
|
||||||
|
_ = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addBlockNode(block: Block, at pos: GridPosition) {
|
||||||
|
let node = BlockNode(block: block, cellSize: renderer.cellSize)
|
||||||
|
node.position = renderer.scenePosition(for: pos)
|
||||||
|
addChild(node)
|
||||||
|
blockNodes[block.id] = node
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called after state changes NOT driven by animation (undo/restart)
|
||||||
|
private func syncBlockNodes() {
|
||||||
|
// Remove nodes for blocks no longer in state
|
||||||
|
let liveIds = Set(gameState.blocks.map { $0.id })
|
||||||
|
for (id, node) in blockNodes where !liveIds.contains(id) {
|
||||||
|
node.removeFromParent()
|
||||||
|
blockNodes.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
// Update existing or add new
|
||||||
|
for block in gameState.blocks {
|
||||||
|
if let node = blockNodes[block.id] {
|
||||||
|
node.position = renderer.scenePosition(for: block.position)
|
||||||
|
} else {
|
||||||
|
addBlockNode(block: block, at: block.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection
|
||||||
|
|
||||||
|
private func selectBlock(at index: Int?) {
|
||||||
|
deselectAll()
|
||||||
|
selectedBlockIndex = index
|
||||||
|
guard let idx = index,
|
||||||
|
gameState.blocks.indices.contains(idx) else { return }
|
||||||
|
let block = gameState.blocks[idx]
|
||||||
|
blockNodes[block.id]?.setSelected(true, cellSize: renderer.cellSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deselectAll() {
|
||||||
|
for node in blockNodes.values {
|
||||||
|
node.setSelected(false, cellSize: renderer.cellSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SKAction ease helper
|
||||||
|
|
||||||
|
private extension SKAction {
|
||||||
|
func ease(_ timing: SKActionTimingMode) -> SKAction {
|
||||||
|
timingMode = timing
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
104
CollapseLogic/CollapseLogic/Scene/GridRenderer.swift
Normal file
104
CollapseLogic/CollapseLogic/Scene/GridRenderer.swift
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import SpriteKit
|
||||||
|
|
||||||
|
final class GridRenderer {
|
||||||
|
|
||||||
|
private let gridSize: GridSize
|
||||||
|
let cellSize: CGFloat
|
||||||
|
let gridOrigin: CGPoint // bottom-left corner in scene coordinates
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init(gridSize: GridSize, sceneSize: CGSize) {
|
||||||
|
self.gridSize = gridSize
|
||||||
|
|
||||||
|
// Fill ~85% of screen width
|
||||||
|
let targetWidth = sceneSize.width * 0.85
|
||||||
|
cellSize = floor(targetWidth / CGFloat(gridSize.width))
|
||||||
|
|
||||||
|
let totalW = cellSize * CGFloat(gridSize.width)
|
||||||
|
let totalH = cellSize * CGFloat(gridSize.height)
|
||||||
|
gridOrigin = CGPoint(
|
||||||
|
x: (sceneSize.width - totalW) / 2,
|
||||||
|
y: (sceneSize.height - totalH) / 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Build nodes
|
||||||
|
|
||||||
|
func buildBackground() -> SKNode {
|
||||||
|
let root = SKNode()
|
||||||
|
root.zPosition = 0
|
||||||
|
|
||||||
|
// Grid background
|
||||||
|
let totalW = cellSize * CGFloat(gridSize.width)
|
||||||
|
let totalH = cellSize * CGFloat(gridSize.height)
|
||||||
|
let bg = SKShapeNode(rect: CGRect(origin: .zero, size: CGSize(width: totalW, height: totalH)), cornerRadius: 8)
|
||||||
|
bg.fillColor = UIColor(hex: "#1A1A2E")
|
||||||
|
bg.strokeColor = .clear
|
||||||
|
bg.position = .zero
|
||||||
|
bg.zPosition = 0
|
||||||
|
root.addChild(bg)
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
let lineColor = UIColor(hex: "#2A2A3E")
|
||||||
|
for col in 0...gridSize.width {
|
||||||
|
let x = CGFloat(col) * cellSize
|
||||||
|
let line = SKShapeNode()
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: CGPoint(x: x, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: x, y: totalH))
|
||||||
|
line.path = path
|
||||||
|
line.strokeColor = lineColor
|
||||||
|
line.lineWidth = 1
|
||||||
|
line.zPosition = 1
|
||||||
|
root.addChild(line)
|
||||||
|
}
|
||||||
|
for row in 0...gridSize.height {
|
||||||
|
let y = CGFloat(row) * cellSize
|
||||||
|
let line = SKShapeNode()
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.move(to: CGPoint(x: 0, y: y))
|
||||||
|
path.addLine(to: CGPoint(x: totalW, y: y))
|
||||||
|
line.path = path
|
||||||
|
line.strokeColor = lineColor
|
||||||
|
line.lineWidth = 1
|
||||||
|
line.zPosition = 1
|
||||||
|
root.addChild(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
root.position = gridOrigin
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWalls(_ walls: Set<GridPosition>) -> SKNode {
|
||||||
|
let root = SKNode()
|
||||||
|
root.zPosition = 2
|
||||||
|
root.position = gridOrigin
|
||||||
|
|
||||||
|
for wall in walls {
|
||||||
|
let size = cellSize * 0.9
|
||||||
|
let rect = CGRect(x: -size / 2, y: -size / 2, width: size, height: size)
|
||||||
|
let node = SKShapeNode(path: UIBezierPath(roundedRect: rect, cornerRadius: 4).cgPath)
|
||||||
|
node.fillColor = UIColor(hex: "#2A2A3E")
|
||||||
|
node.strokeColor = UIColor(hex: "#3A3A4E")
|
||||||
|
node.lineWidth = 1
|
||||||
|
node.position = scenePosition(for: wall)
|
||||||
|
// position is relative to gridOrigin, so subtract it
|
||||||
|
node.position = CGPoint(
|
||||||
|
x: node.position.x - gridOrigin.x,
|
||||||
|
y: node.position.y - gridOrigin.y
|
||||||
|
)
|
||||||
|
root.addChild(node)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinate helpers
|
||||||
|
|
||||||
|
/// Convert grid position → scene coordinates (with Y-flip)
|
||||||
|
func scenePosition(for pos: GridPosition) -> CGPoint {
|
||||||
|
let spriteY = CGFloat(gridSize.height - 1 - pos.y) * cellSize + cellSize / 2
|
||||||
|
let spriteX = CGFloat(pos.x) * cellSize + cellSize / 2
|
||||||
|
return CGPoint(x: gridOrigin.x + spriteX, y: gridOrigin.y + spriteY)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
CollapseLogic/CollapseLogic/Views/GameContainerView.swift
Normal file
214
CollapseLogic/CollapseLogic/Views/GameContainerView.swift
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import SwiftUI
|
||||||
|
import SpriteKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Observable game bridge (class so SwiftUI can observe it)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GameBridge: ObservableObject, GameSceneDelegate {
|
||||||
|
@Published var moveCount = 0
|
||||||
|
@Published var showWin = false
|
||||||
|
@Published var winStars = 0
|
||||||
|
|
||||||
|
func gameSceneDidUpdateMoveCount(_ count: Int) {
|
||||||
|
moveCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
func gameSceneDidWin(moveCount: Int, stars: Int) {
|
||||||
|
self.moveCount = moveCount
|
||||||
|
self.winStars = stars
|
||||||
|
showWin = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GameContainerView
|
||||||
|
|
||||||
|
struct GameContainerView: View {
|
||||||
|
let levelId: String
|
||||||
|
let levelNumber: Int
|
||||||
|
let worldNumber: Int
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@StateObject private var bridge = GameBridge()
|
||||||
|
@State private var gameScene: GameScene?
|
||||||
|
@State private var initialState: GameState?
|
||||||
|
@State private var par = 0
|
||||||
|
@State private var loadError: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "#1A1A2E").ignoresSafeArea()
|
||||||
|
|
||||||
|
if let error = loadError {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Load Error")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Back") { onDismiss() }
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
} else if let scene = gameScene {
|
||||||
|
SpriteView(scene: scene)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
topHUD
|
||||||
|
Spacer()
|
||||||
|
bottomHUD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { loadLevel() }
|
||||||
|
.sheet(isPresented: $bridge.showWin) {
|
||||||
|
WinSheet(stars: bridge.winStars, moveCount: bridge.moveCount, par: par) {
|
||||||
|
ProgressStore.shared.setStars(bridge.winStars, for: levelId)
|
||||||
|
bridge.showWin = false
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: HUD
|
||||||
|
|
||||||
|
private var topHUD: some View {
|
||||||
|
HStack {
|
||||||
|
Button { onDismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Color.white.opacity(0.1))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("Level \(levelNumber)")
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
Text("Moves: \(bridge.moveCount) / Par: \(par)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Color.clear.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bottomHUD: some View {
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
HUDButton(icon: "arrow.uturn.backward", label: "Undo") {
|
||||||
|
gameScene?.applyUndo()
|
||||||
|
}
|
||||||
|
HUDButton(icon: "arrow.counterclockwise", label: "Restart") {
|
||||||
|
if let s = initialState { gameScene?.applyRestart(initialState: s) }
|
||||||
|
}
|
||||||
|
HUDButton(icon: "line.3.horizontal", label: "Menu") {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Load
|
||||||
|
|
||||||
|
private func loadLevel() {
|
||||||
|
let filename = "level_\(String(format: "%02d", levelNumber))"
|
||||||
|
do {
|
||||||
|
let state = try LevelLoader.load(levelId: filename, world: worldNumber)
|
||||||
|
initialState = state
|
||||||
|
par = state.par
|
||||||
|
// Use a placeholder size; GameScene recalculates cell layout in didMove(to:)
|
||||||
|
// where it has the actual SKView bounds via self.size (set by scaleMode = .resizeFill)
|
||||||
|
let scene = GameScene(state: state, size: CGSize(width: 390, height: 844))
|
||||||
|
scene.sceneDelegate = bridge
|
||||||
|
gameScene = scene
|
||||||
|
} catch {
|
||||||
|
loadError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Win Sheet
|
||||||
|
|
||||||
|
private struct WinSheet: View {
|
||||||
|
let stars: Int
|
||||||
|
let moveCount: Int
|
||||||
|
let par: Int
|
||||||
|
let onContinue: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "#1A1A2E").ignoresSafeArea()
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Text("Level Complete!")
|
||||||
|
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(0..<3, id: \.self) { i in
|
||||||
|
Text(i < stars ? "★" : "☆")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(i < stars ? Color(hex: "#F4D35E") : .white.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("\(moveCount) moves · Par: \(par)")
|
||||||
|
.font(.system(size: 15, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
Button(action: onContinue) {
|
||||||
|
Text("Continue")
|
||||||
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 180, height: 52)
|
||||||
|
.background(Color(hex: "#E63946"))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
.presentationDetents([.fraction(0.45)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HUD Button
|
||||||
|
|
||||||
|
private struct HUDButton: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, design: .rounded))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
.background(Color.white.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color(hex:) for SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let h = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var val: UInt64 = 0
|
||||||
|
Scanner(string: h).scanHexInt64(&val)
|
||||||
|
self.init(
|
||||||
|
red: Double((val >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((val >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(val & 0xFF) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
166
CollapseLogic/CollapseLogic/Views/LevelSelectView.swift
Normal file
166
CollapseLogic/CollapseLogic/Views/LevelSelectView.swift
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LevelSelectView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject private var progress = ProgressStore.shared
|
||||||
|
@State private var selectedLevel: LevelEntry? = nil
|
||||||
|
@State private var selectedWorld: Int = 1
|
||||||
|
|
||||||
|
private struct LevelEntry: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let number: Int
|
||||||
|
let title: String
|
||||||
|
let file: String
|
||||||
|
let world: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World 2 unlock condition
|
||||||
|
|
||||||
|
private var world2Unlocked: Bool {
|
||||||
|
ProgressStore.shared.totalStars() >= 15
|
||||||
|
}
|
||||||
|
|
||||||
|
private var worlds: [WorldMeta] {
|
||||||
|
LevelLoader.loadMetadata()?.worlds ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentWorld: WorldMeta? {
|
||||||
|
worlds.first(where: { $0.id == selectedWorld })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var levels: [LevelEntry] {
|
||||||
|
guard let world = currentWorld else { return [] }
|
||||||
|
return world.levels.enumerated().map { idx, l in
|
||||||
|
LevelEntry(id: l.id, number: idx + 1, title: l.title, file: l.file, world: world.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let columns = [GridItem(.adaptive(minimum: 80), spacing: 12)]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "#1A1A2E").ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
worldSelector
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 12) {
|
||||||
|
ForEach(levels) { entry in
|
||||||
|
LevelCell(entry: entry) {
|
||||||
|
selectedLevel = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(currentWorld.map { "\($0.name)" } ?? "Levels")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Back") { dismiss() }
|
||||||
|
.foregroundColor(Color(hex: "#E63946"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.fullScreenCover(item: $selectedLevel) { entry in
|
||||||
|
GameContainerView(levelId: entry.id, levelNumber: entry.number,
|
||||||
|
worldNumber: entry.world) {
|
||||||
|
selectedLevel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World Selector
|
||||||
|
|
||||||
|
private var worldSelector: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(worlds, id: \.id) { world in
|
||||||
|
let isSelected = selectedWorld == world.id
|
||||||
|
let isLocked = world.id == 2 && !world2Unlocked
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !isLocked {
|
||||||
|
selectedWorld = world.id
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if isLocked {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
Text(world.name)
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||||
|
if isLocked {
|
||||||
|
Text("15 \u{2605}")
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(isLocked ? .white.opacity(0.35) : isSelected ? .white : .white.opacity(0.6))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(
|
||||||
|
isSelected ? Color(hex: "#E63946") :
|
||||||
|
isLocked ? Color.white.opacity(0.05) :
|
||||||
|
Color(hex: "#2A2A4E")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(
|
||||||
|
isSelected ? Color.clear :
|
||||||
|
isLocked ? Color.white.opacity(0.08) :
|
||||||
|
Color.white.opacity(0.12),
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.opacity(isLocked ? 0.4 : 1.0)
|
||||||
|
}
|
||||||
|
.disabled(isLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level Cell
|
||||||
|
|
||||||
|
private struct LevelCell: View {
|
||||||
|
let entry: LevelEntry
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let stars = ProgressStore.shared.starsFor(levelId: entry.id)
|
||||||
|
let completed = ProgressStore.shared.isCompleted(levelId: entry.id)
|
||||||
|
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("\(entry.number)")
|
||||||
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(completed ? .white : .white.opacity(0.6))
|
||||||
|
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
ForEach(0..<3, id: \.self) { i in
|
||||||
|
Text("\u{2605}")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(i < stars ? Color(hex: "#F4D35E") : .white.opacity(0.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.background(Color(hex: completed ? "#2A2A4E" : "#1E1E38"))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(completed ? Color(hex: "#457B9D") : Color.white.opacity(0.08),
|
||||||
|
lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
CollapseLogic/CollapseLogic/Views/MainMenuView.swift
Normal file
55
CollapseLogic/CollapseLogic/Views/MainMenuView.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainMenuView: View {
|
||||||
|
@ObservedObject private var progress = ProgressStore.shared
|
||||||
|
@State private var showLevelSelect = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: "#1A1A2E").ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 40) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("COLLAPSE")
|
||||||
|
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("LOGIC")
|
||||||
|
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||||
|
.foregroundColor(Color(hex: "#457B9D"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Push. Collide. Destroy.")
|
||||||
|
.font(.system(size: 16, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showLevelSelect = true
|
||||||
|
} label: {
|
||||||
|
Text("PLAY")
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 200, height: 56)
|
||||||
|
.background(Color(hex: "#E63946"))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = ProgressStore.shared.totalStars()
|
||||||
|
if total > 0 {
|
||||||
|
Text("⭐ \(total) stars collected")
|
||||||
|
.font(.system(size: 14, design: .rounded))
|
||||||
|
.foregroundColor(.white.opacity(0.4))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showLevelSelect) {
|
||||||
|
LevelSelectView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
474
CollapseLogic/CollapseLogicTests/GameEngineTests.swift
Normal file
474
CollapseLogic/CollapseLogicTests/GameEngineTests.swift
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import CollapseLogic
|
||||||
|
|
||||||
|
final class GameEngineTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Simple 5x5 grid, no walls, custom blocks
|
||||||
|
private func makeState(
|
||||||
|
width: Int = 5, height: Int = 5,
|
||||||
|
blocks: [(x: Int, y: Int, color: BlockColor)],
|
||||||
|
walls: [(x: Int, y: Int)] = [],
|
||||||
|
par: Int = 5,
|
||||||
|
allowsMerging: Bool = true
|
||||||
|
) -> GameState {
|
||||||
|
let bl = blocks.enumerated().map { idx, b in
|
||||||
|
Block(id: idx, position: GridPosition(x: b.x, y: b.y), color: b.color)
|
||||||
|
}
|
||||||
|
let ws = Set(walls.map { GridPosition(x: $0.x, y: $0.y) })
|
||||||
|
return GameState(
|
||||||
|
grid: GridSize(width: width, height: height),
|
||||||
|
blocks: bl, walls: ws, moveCount: 0,
|
||||||
|
par: par, objective: .clearAll,
|
||||||
|
allowsMerging: allowsMerging, history: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slide to boundary
|
||||||
|
|
||||||
|
func testSlideRightToBoundary() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 2, color: .red)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertTrue(result.isValid)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 4, y: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSlideLeftToBoundary() {
|
||||||
|
let state = makeState(blocks: [(x: 4, y: 2, color: .blue)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .left)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 0, y: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSlideUpToBoundary() {
|
||||||
|
let state = makeState(blocks: [(x: 2, y: 4, color: .red)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .up)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 2, y: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSlideDownToBoundary() {
|
||||||
|
let state = makeState(blocks: [(x: 2, y: 0, color: .blue)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .down)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 2, y: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slide stops at wall
|
||||||
|
|
||||||
|
func testStopsAtWall() {
|
||||||
|
let state = makeState(
|
||||||
|
blocks: [(x: 0, y: 2, color: .red)],
|
||||||
|
walls: [(x: 3, y: 2)]
|
||||||
|
)
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 2, y: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStopsAtWallAbove() {
|
||||||
|
let state = makeState(
|
||||||
|
blocks: [(x: 2, y: 4, color: .blue)],
|
||||||
|
walls: [(x: 2, y: 2)]
|
||||||
|
)
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .up)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 2, y: 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slide stops at another block
|
||||||
|
|
||||||
|
func testStopsAtOtherBlock() {
|
||||||
|
// Purple (secondary) at x=0, Red (primary) at x=3 → block stops, no merge
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 2, color: .purple),
|
||||||
|
(x: 3, y: 2, color: .red)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
// Purple should land at x=2, stopped by red at x=3
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 2, y: 2))
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Same-color destroy
|
||||||
|
|
||||||
|
func testSameColorDestroyBoth() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 2, color: .red),
|
||||||
|
(x: 3, y: 2, color: .red)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
// Red slides right, lands at x=2, adjacent is red at x=3 → both destroyed
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 0)
|
||||||
|
// Confirm destroy event
|
||||||
|
if case .destroyed = result.events.last { } else {
|
||||||
|
XCTFail("Expected destroyed event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSameColorDestroyVertical() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 2, y: 0, color: .blue),
|
||||||
|
(x: 2, y: 4, color: .blue)
|
||||||
|
])
|
||||||
|
// Push top blue down
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .down)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentNonMergeableColorStops() {
|
||||||
|
// Purple (secondary) at x=0, Green (secondary) at x=4 → different secondaries stop
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 2, color: .purple),
|
||||||
|
(x: 4, y: 2, color: .green)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
// Purple lands at x=3, green stays at x=4, no interaction
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Invalid moves
|
||||||
|
|
||||||
|
func testInvalidMoveAtBoundary() {
|
||||||
|
// Block already at left wall
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 2, color: .red)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .left)
|
||||||
|
XCTAssertFalse(result.isValid)
|
||||||
|
XCTAssertEqual(result.newState.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidMoveBlockedByWallImmediately() {
|
||||||
|
let state = makeState(
|
||||||
|
blocks: [(x: 2, y: 2, color: .red)],
|
||||||
|
walls: [(x: 3, y: 2)]
|
||||||
|
)
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertFalse(result.isValid)
|
||||||
|
XCTAssertEqual(result.newState.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidMoveBlockedByAdjacentBlock() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 2, y: 2, color: .red),
|
||||||
|
(x: 3, y: 2, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertFalse(result.isValid)
|
||||||
|
XCTAssertEqual(result.newState.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutOfRangeBlockIndex() {
|
||||||
|
let state = makeState(blocks: [(x: 2, y: 2, color: .red)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 5, direction: .right)
|
||||||
|
XCTAssertFalse(result.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Move count
|
||||||
|
|
||||||
|
func testMoveCountIncrementsOnValidMove() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 2, color: .red)])
|
||||||
|
let r1 = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(r1.newState.moveCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoveCountDoesNotIncrementOnInvalidMove() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 2, color: .red)])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .left)
|
||||||
|
XCTAssertEqual(result.newState.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoveCountAccumulatesAcrossMoves() {
|
||||||
|
var state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 0, y: 4, color: .blue)
|
||||||
|
])
|
||||||
|
state = GameEngine.processMove(state: state, blockIndex: 0, direction: .right).newState
|
||||||
|
state = GameEngine.processMove(state: state, blockIndex: 1, direction: .right).newState
|
||||||
|
XCTAssertEqual(state.moveCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Undo
|
||||||
|
|
||||||
|
func testUndoRestoresPreviousState() {
|
||||||
|
let initial = makeState(blocks: [(x: 0, y: 2, color: .red)])
|
||||||
|
let afterMove = GameEngine.processMove(state: initial, blockIndex: 0, direction: .right).newState
|
||||||
|
XCTAssertEqual(afterMove.blocks[0].position, GridPosition(x: 4, y: 2))
|
||||||
|
|
||||||
|
let undone = GameEngine.undo(state: afterMove)
|
||||||
|
XCTAssertEqual(undone.blocks[0].position, GridPosition(x: 0, y: 2))
|
||||||
|
XCTAssertEqual(undone.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUndoMultipleSteps() {
|
||||||
|
let s0 = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 4, color: .blue)
|
||||||
|
])
|
||||||
|
let s1 = GameEngine.processMove(state: s0, blockIndex: 0, direction: .right).newState
|
||||||
|
let s2 = GameEngine.processMove(state: s1, blockIndex: 1, direction: .left).newState
|
||||||
|
|
||||||
|
let back1 = GameEngine.undo(state: s2)
|
||||||
|
XCTAssertEqual(back1.moveCount, 1)
|
||||||
|
let back0 = GameEngine.undo(state: back1)
|
||||||
|
XCTAssertEqual(back0.moveCount, 0)
|
||||||
|
XCTAssertEqual(back0.blocks[0].position, GridPosition(x: 0, y: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUndoAtInitialStateReturnsUnchanged() {
|
||||||
|
let state = makeState(blocks: [(x: 2, y: 2, color: .red)])
|
||||||
|
let result = GameEngine.undo(state: state)
|
||||||
|
XCTAssertEqual(result.moveCount, 0)
|
||||||
|
XCTAssertEqual(result.blocks[0].position, GridPosition(x: 2, y: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Win detection
|
||||||
|
|
||||||
|
func testWinDetectedWhenAllBlocksGone() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 2, color: .red),
|
||||||
|
(x: 3, y: 2, color: .red)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertTrue(result.newState.isWon)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNotWonWhenBlocksRemain() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .red),
|
||||||
|
(x: 2, y: 2, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertFalse(result.newState.isWon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stars
|
||||||
|
|
||||||
|
func testThreeStarsAtPar() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 2, color: .red), (x: 3, y: 2, color: .red)], par: 1)
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.currentStars, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTwoStarsSlightlyOverPar() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 0, color: .red)], par: 1)
|
||||||
|
var s = GameEngine.processMove(state: state, blockIndex: 0, direction: .right).newState
|
||||||
|
// Force moveCount to par+1 by reflecting in a new state (simulate 2 moves)
|
||||||
|
// We just check the stars formula directly
|
||||||
|
XCTAssertEqual(state.stars(for: 2), 2)
|
||||||
|
XCTAssertEqual(state.stars(for: 3), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOneStarWellOverPar() {
|
||||||
|
let state = makeState(blocks: [(x: 0, y: 0, color: .red), (x: 1, y: 0, color: .red)], par: 3)
|
||||||
|
XCTAssertEqual(state.stars(for: 10), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World 1 merge gating
|
||||||
|
|
||||||
|
func testWorld1RedBluePrimariesStopWithoutMerging() {
|
||||||
|
// Same input as testRedBlueProducesPurple, but with allowsMerging = false.
|
||||||
|
// Expected: red lands adjacent to blue, both blocks remain, no purple created.
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
], allowsMerging: false)
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 2)
|
||||||
|
XCTAssertTrue(result.newState.blocks.contains(where: { $0.color == .red }))
|
||||||
|
XCTAssertTrue(result.newState.blocks.contains(where: { $0.color == .blue }))
|
||||||
|
XCTAssertFalse(result.newState.blocks.contains(where: { $0.color == .purple }))
|
||||||
|
XCTAssertFalse(result.events.contains(where: {
|
||||||
|
if case .merged = $0 { return true } else { return false }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWorld1LoadedLevelHasMergingDisabled() {
|
||||||
|
// Sanity: a level with world: 1 should produce a state with allowsMerging == false.
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "w1_test", "world": 1, "level": 1, "title": "T",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 1, "hints": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let data = Data(json.utf8)
|
||||||
|
let def = try! JSONDecoder().decode(LevelDefinition.self, from: data)
|
||||||
|
let state = LevelLoader.makeGameState(from: def)
|
||||||
|
XCTAssertFalse(state.allowsMerging)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWorld2LoadedLevelHasMergingEnabled() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "w2_test", "world": 2, "level": 1, "title": "T",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 1, "hints": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let data = Data(json.utf8)
|
||||||
|
let def = try! JSONDecoder().decode(LevelDefinition.self, from: data)
|
||||||
|
let state = LevelLoader.makeGameState(from: def)
|
||||||
|
XCTAssertTrue(state.allowsMerging)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World 2 Merge Tests
|
||||||
|
|
||||||
|
func testRedBlueProducesPurple() {
|
||||||
|
// red at (0,0), blue at (4,0), push red right
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
// Both originals removed, one purple remains
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 1)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].color, .purple)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRedYellowProducesOrange() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .yellow)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 1)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].color, .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlueYellowProducesGreen() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .blue),
|
||||||
|
(x: 4, y: 0, color: .yellow)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 1)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].color, .green)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergedBlockAtStationaryPosition() {
|
||||||
|
// red at (0,0), blue at (4,0), push red right
|
||||||
|
// Merged block should be at blue's position (4,0), not red's landing (3,0)
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 4, y: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSecondaryPlusPrimaryStops() {
|
||||||
|
// purple moving into red → block stops, no merge, no destroy
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .purple),
|
||||||
|
(x: 4, y: 0, color: .red)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 2)
|
||||||
|
// Purple should stop at x=3
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 3, y: 0))
|
||||||
|
// No merged or destroyed events (only slid)
|
||||||
|
XCTAssertEqual(result.events.count, 1)
|
||||||
|
if case .slid = result.events[0] { } else {
|
||||||
|
XCTFail("Expected only a slid event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentSecondariesStop() {
|
||||||
|
// purple moving into green → block stops
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .purple),
|
||||||
|
(x: 4, y: 0, color: .green)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 2)
|
||||||
|
XCTAssertEqual(result.newState.blocks[0].position, GridPosition(x: 3, y: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSameSecondaryDestroysBoth() {
|
||||||
|
// purple moving into purple → both destroyed
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .purple),
|
||||||
|
(x: 4, y: 0, color: .purple)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.blocks.count, 0)
|
||||||
|
XCTAssertTrue(result.events.contains(where: {
|
||||||
|
if case .destroyed = $0 { return true }
|
||||||
|
return false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoveCountIncrementsOnMerge() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
XCTAssertEqual(result.newState.moveCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMergeEmitsMergedEvent() {
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let result = GameEngine.processMove(state: state, blockIndex: 0, direction: .right)
|
||||||
|
let mergedEvent = result.events.first(where: {
|
||||||
|
if case .merged = $0 { return true }
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
XCTAssertNotNil(mergedEvent)
|
||||||
|
if case .merged(_, _, let resultColor, let at) = mergedEvent {
|
||||||
|
XCTAssertEqual(resultColor, .purple)
|
||||||
|
XCTAssertEqual(at, GridPosition(x: 4, y: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUndoAfterMergeRestoresState() {
|
||||||
|
let initial = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let afterMerge = GameEngine.processMove(state: initial, blockIndex: 0, direction: .right).newState
|
||||||
|
// After merge: 1 purple block
|
||||||
|
XCTAssertEqual(afterMerge.blocks.count, 1)
|
||||||
|
XCTAssertEqual(afterMerge.blocks[0].color, .purple)
|
||||||
|
|
||||||
|
// Undo should restore both original blocks
|
||||||
|
let undone = GameEngine.undo(state: afterMerge)
|
||||||
|
XCTAssertEqual(undone.blocks.count, 2)
|
||||||
|
XCTAssertEqual(undone.moveCount, 0)
|
||||||
|
// Original colors restored
|
||||||
|
let colors = Set(undone.blocks.map(\.color))
|
||||||
|
XCTAssertTrue(colors.contains(.red))
|
||||||
|
XCTAssertTrue(colors.contains(.blue))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWinConditionAfterMerge() {
|
||||||
|
// Board: one red + one blue. Merge them → purple remains → NOT a win.
|
||||||
|
let state = makeState(blocks: [
|
||||||
|
(x: 0, y: 0, color: .red),
|
||||||
|
(x: 4, y: 0, color: .blue)
|
||||||
|
])
|
||||||
|
let afterMerge = GameEngine.processMove(state: state, blockIndex: 0, direction: .right).newState
|
||||||
|
XCTAssertFalse(afterMerge.isWon, "Merged block still remains — should not be won")
|
||||||
|
|
||||||
|
// Now add a pre-placed purple to destroy the merged one.
|
||||||
|
// Set up: purple at (0,2), another purple at (4,2). Push left purple right → destroy both → win.
|
||||||
|
let state2 = makeState(blocks: [
|
||||||
|
(x: 0, y: 2, color: .purple),
|
||||||
|
(x: 4, y: 2, color: .purple)
|
||||||
|
])
|
||||||
|
let afterDestroy = GameEngine.processMove(state: state2, blockIndex: 0, direction: .right).newState
|
||||||
|
XCTAssertTrue(afterDestroy.isWon, "Both purples destroyed — should be won")
|
||||||
|
}
|
||||||
|
}
|
||||||
414
CollapseLogic/CollapseLogicTests/LevelLoaderTests.swift
Normal file
414
CollapseLogic/CollapseLogicTests/LevelLoaderTests.swift
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import CollapseLogic
|
||||||
|
|
||||||
|
final class LevelLoaderTests: XCTestCase {
|
||||||
|
|
||||||
|
// Inline JSON helper to avoid needing the bundle in unit tests
|
||||||
|
private func loadFromJSON(_ json: String) throws -> GameState {
|
||||||
|
let data = Data(json.utf8)
|
||||||
|
let def = try JSONDecoder().decode(LevelDefinition.self, from: data)
|
||||||
|
try LevelLoader.validate(def)
|
||||||
|
return LevelLoader.makeGameState(from: def)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let validJSON = """
|
||||||
|
{
|
||||||
|
"id": "w1_test",
|
||||||
|
"world": 1, "level": 1, "title": "Test",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 4, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 2 }],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 3,
|
||||||
|
"hints": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testValidLevelParses() throws {
|
||||||
|
let state = try loadFromJSON(validJSON)
|
||||||
|
XCTAssertEqual(state.blocks.count, 2)
|
||||||
|
XCTAssertEqual(state.walls.count, 1)
|
||||||
|
XCTAssertEqual(state.par, 3)
|
||||||
|
XCTAssertEqual(state.grid.width, 5)
|
||||||
|
XCTAssertEqual(state.grid.height, 5)
|
||||||
|
XCTAssertEqual(state.objective, .clearAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockPositionsCorrect() throws {
|
||||||
|
let state = try loadFromJSON(validJSON)
|
||||||
|
let redBlock = state.blocks.first(where: { $0.color == .red })
|
||||||
|
XCTAssertNotNil(redBlock)
|
||||||
|
XCTAssertEqual(redBlock?.position, GridPosition(x: 0, y: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWallPositionsCorrect() throws {
|
||||||
|
let state = try loadFromJSON(validJSON)
|
||||||
|
XCTAssertTrue(state.walls.contains(GridPosition(x: 2, y: 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGridTooSmallFails() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 2, "height": 5 },
|
||||||
|
"blocks": [{ "x": 0, "y": 0, "color": "red" }, { "x": 1, "y": 1, "color": "blue" }],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try loadFromJSON(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFewerThan2BlocksFails() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [{ "x": 0, "y": 0, "color": "red" }],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try loadFromJSON(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockOutOfBoundsFails() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 9, "y": 0, "color": "blue" }
|
||||||
|
],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try loadFromJSON(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOverlappingPositionsFails() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 1, "y": 1, "color": "red" },
|
||||||
|
{ "x": 1, "y": 1, "color": "blue" }
|
||||||
|
],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try loadFromJSON(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testZeroPar() {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 3, "y": 0, "color": "red" }
|
||||||
|
],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 0
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
XCTAssertThrowsError(try loadFromJSON(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearColorObjectiveParsed() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "x", "world": 1, "level": 1,
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 3, "y": 0, "color": "red" }
|
||||||
|
],
|
||||||
|
"objective": { "type": "clear_color", "color": "red" }, "par": 1
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let state = try loadFromJSON(json)
|
||||||
|
XCTAssertEqual(state.objective, .clearColor(.red))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialMoveCountIsZero() throws {
|
||||||
|
let state = try loadFromJSON(validJSON)
|
||||||
|
XCTAssertEqual(state.moveCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialHistoryIsEmpty() throws {
|
||||||
|
let state = try loadFromJSON(validJSON)
|
||||||
|
XCTAssertTrue(state.history.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World 2 Level Loading Tests
|
||||||
|
|
||||||
|
/// All 12 World 2 level JSONs parse and validate without errors.
|
||||||
|
func testAllWorld2LevelsLoad() throws {
|
||||||
|
let levelJSONs = world2LevelJSONs()
|
||||||
|
XCTAssertEqual(levelJSONs.count, 12, "Expected 12 World 2 level JSONs")
|
||||||
|
for (idx, json) in levelJSONs.enumerated() {
|
||||||
|
let levelNum = idx + 1
|
||||||
|
XCTAssertNoThrow(
|
||||||
|
try loadFromJSON(json),
|
||||||
|
"World 2 level \(levelNum) failed to parse"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-placed secondary color blocks (e.g. purple in w2_01) load with the correct color.
|
||||||
|
func testPreplacedSecondaryColorsLoad() throws {
|
||||||
|
// w2_07 "Inheritance": has pre-placed purple blocks at (4,1) and (4,3)
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "w2_07", "world": 2, "level": 7, "title": "Inheritance",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 1, "color": "purple" },
|
||||||
|
{ "x": 4, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }, { "x": 2, "y": 4 }],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 4, "hints": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let state = try loadFromJSON(json)
|
||||||
|
let purpleBlocks = state.blocks.filter { $0.color == .purple }
|
||||||
|
XCTAssertEqual(purpleBlocks.count, 2)
|
||||||
|
XCTAssertTrue(purpleBlocks.contains(where: { $0.position == GridPosition(x: 4, y: 1) }))
|
||||||
|
XCTAssertTrue(purpleBlocks.contains(where: { $0.position == GridPosition(x: 4, y: 3) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata file contains two worlds, World 2 has 12 levels.
|
||||||
|
func testWorld2MetadataLoads() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"worlds": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "Primary",
|
||||||
|
"description": "Learn the basics.",
|
||||||
|
"new_mechanic": null,
|
||||||
|
"levels": [
|
||||||
|
{ "id": "w1_01", "title": "First Push", "file": "world1/level_01.json", "is_challenge": false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let data = Data(json.utf8)
|
||||||
|
let metadata = try JSONDecoder().decode(MetadataFile.self, from: data)
|
||||||
|
XCTAssertEqual(metadata.worlds.count, 2)
|
||||||
|
let world2 = metadata.worlds.first(where: { $0.id == 2 })
|
||||||
|
XCTAssertNotNil(world2)
|
||||||
|
XCTAssertEqual(world2?.levels.count, 12)
|
||||||
|
XCTAssertEqual(world2?.name, "Fusion")
|
||||||
|
XCTAssertEqual(world2?.unlockStars, 15)
|
||||||
|
XCTAssertEqual(world2?.newMechanic, "merging")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World 2 level JSON fixtures
|
||||||
|
|
||||||
|
/// Returns inline JSON strings for all 12 World 2 levels.
|
||||||
|
private func world2LevelJSONs() -> [String] {
|
||||||
|
return [
|
||||||
|
// Level 1 — First Contact
|
||||||
|
"""
|
||||||
|
{ "id": "w2_01", "world": 2, "level": 1, "title": "First Contact",
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 1, "color": "blue" },
|
||||||
|
{ "x": 1, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 2, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 2 — Bound Together
|
||||||
|
"""
|
||||||
|
{ "id": "w2_02", "world": 2, "level": 2, "title": "Bound Together",
|
||||||
|
"grid": { "width": 5, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 3, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 3 — The Third Color
|
||||||
|
"""
|
||||||
|
{ "id": "w2_03", "world": 2, "level": 3, "title": "The Third Color",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 4, "color": "red" },
|
||||||
|
{ "x": 4, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 2, "color": "purple" },
|
||||||
|
{ "x": 2, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 4, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 4 — Sun Stone
|
||||||
|
"""
|
||||||
|
{ "id": "w2_04", "world": 2, "level": 4, "title": "Sun Stone",
|
||||||
|
"grid": { "width": 5, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 4, "y": 1, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 3, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 2, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 5 — Color Theory
|
||||||
|
"""
|
||||||
|
{ "id": "w2_05", "world": 2, "level": 5, "title": "Color Theory",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 4, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 2 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 3, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 6 — Triad
|
||||||
|
"""
|
||||||
|
{ "id": "w2_06", "world": 2, "level": 6, "title": "Triad",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 4, "color": "yellow" },
|
||||||
|
{ "x": 4, "y": 4, "color": "red" },
|
||||||
|
{ "x": 2, "y": 2, "color": "purple" },
|
||||||
|
{ "x": 3, "y": 3, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 5, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 7 — Inheritance
|
||||||
|
"""
|
||||||
|
{ "id": "w2_07", "world": 2, "level": 7, "title": "Inheritance",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 0, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 1, "color": "purple" },
|
||||||
|
{ "x": 4, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }, { "x": 2, "y": 4 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 4, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 8 — Reflection
|
||||||
|
"""
|
||||||
|
{ "id": "w2_08", "world": 2, "level": 8, "title": "Reflection",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 4, "y": 2, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 0, "color": "orange" },
|
||||||
|
{ "x": 2, "y": 4, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 2 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 4, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 9 — Cascade
|
||||||
|
"""
|
||||||
|
{ "id": "w2_09", "world": 2, "level": 9, "title": "Cascade",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 0, "y": 4, "color": "red" },
|
||||||
|
{ "x": 4, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 1, "color": "green" },
|
||||||
|
{ "x": 2, "y": 3, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 2 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 5, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 10 — Chain Reaction
|
||||||
|
"""
|
||||||
|
{ "id": "w2_10", "world": 2, "level": 10, "title": "Chain Reaction",
|
||||||
|
"grid": { "width": 6, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 4, "color": "yellow" },
|
||||||
|
{ "x": 5, "y": 4, "color": "red" },
|
||||||
|
{ "x": 3, "y": 2, "color": "purple" },
|
||||||
|
{ "x": 1, "y": 2, "color": "orange" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }, { "x": 3, "y": 4 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 6, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 11 — The Long Way
|
||||||
|
"""
|
||||||
|
{ "id": "w2_11", "world": 2, "level": 11, "title": "The Long Way",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "yellow" },
|
||||||
|
{ "x": 0, "y": 5, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "red" },
|
||||||
|
{ "x": 3, "y": 2, "color": "orange" },
|
||||||
|
{ "x": 2, "y": 3, "color": "purple" },
|
||||||
|
{ "x": 3, "y": 4, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }, { "x": 3, "y": 5 }], "special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 8, "hints": [] }
|
||||||
|
""",
|
||||||
|
// Level 12 — Prism (boss)
|
||||||
|
"""
|
||||||
|
{ "id": "w2_12", "world": 2, "level": 12, "title": "Prism",
|
||||||
|
"grid": { "width": 6, "height": 6 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 5, "y": 0, "color": "blue" },
|
||||||
|
{ "x": 0, "y": 5, "color": "yellow" },
|
||||||
|
{ "x": 5, "y": 2, "color": "red" },
|
||||||
|
{ "x": 0, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 5, "y": 5, "color": "yellow" },
|
||||||
|
{ "x": 2, "y": 1, "color": "purple" },
|
||||||
|
{ "x": 3, "y": 4, "color": "orange" },
|
||||||
|
{ "x": 1, "y": 4, "color": "green" }
|
||||||
|
],
|
||||||
|
"walls": [{ "x": 2, "y": 0 }, { "x": 4, "y": 1 }, { "x": 1, "y": 3 },
|
||||||
|
{ "x": 4, "y": 3 }, { "x": 2, "y": 5 }],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" }, "par": 9, "hints": [] }
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
343
CollapseLogic_LevelSpec.md
Normal file
343
CollapseLogic_LevelSpec.md
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
# Collapse Logic — Level Format Specification
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** March 2026
|
||||||
|
**Studio:** Vulcara Games
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Levels are defined as JSON files bundled in the app. Each world has a directory of level files. The format is designed to be human-readable for hand-crafting levels and machine-parseable for the game engine.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
CollapseLogic/
|
||||||
|
├── Levels/
|
||||||
|
│ ├── world1/
|
||||||
|
│ │ ├── level_01.json
|
||||||
|
│ │ ├── level_02.json
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── world2/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── metadata.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Level JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "w1_01",
|
||||||
|
"world": 1,
|
||||||
|
"level": 1,
|
||||||
|
"title": "First Steps",
|
||||||
|
"grid": {
|
||||||
|
"width": 5,
|
||||||
|
"height": 5
|
||||||
|
},
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 1, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 1, "color": "red" },
|
||||||
|
{ "x": 2, "y": 3, "color": "blue" },
|
||||||
|
{ "x": 4, "y": 3, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 2 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": {
|
||||||
|
"type": "clear_all"
|
||||||
|
},
|
||||||
|
"par": 4,
|
||||||
|
"hints": [
|
||||||
|
"Try pushing the top-left red block down first."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Definitions
|
||||||
|
|
||||||
|
### Top-Level Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | string | Yes | Unique identifier. Convention: `w{world}_{level:02d}` (e.g., `w1_01`, `w3_15`) |
|
||||||
|
| `world` | integer | Yes | World number (1-6) |
|
||||||
|
| `level` | integer | Yes | Level number within the world (1-20) |
|
||||||
|
| `title` | string | No | Display name for the level (optional flavor text) |
|
||||||
|
| `grid` | object | Yes | Grid dimensions |
|
||||||
|
| `blocks` | array | Yes | Array of block objects placed on the grid |
|
||||||
|
| `walls` | array | No | Array of wall positions. Default: `[]` |
|
||||||
|
| `special_tiles` | array | No | Array of special tile objects. Default: `[]` |
|
||||||
|
| `objective` | object | Yes | Win condition for the level |
|
||||||
|
| `par` | integer | Yes | Target move count for 3-star rating |
|
||||||
|
| `hints` | array | No | Array of hint strings (revealed progressively). Default: `[]` |
|
||||||
|
|
||||||
|
### Grid Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"width": 5,
|
||||||
|
"height": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Constraints | Description |
|
||||||
|
|-------|------|-------------|-------------|
|
||||||
|
| `width` | integer | 3–10 | Number of columns |
|
||||||
|
| `height` | integer | 3–10 | Number of rows |
|
||||||
|
|
||||||
|
Coordinate system: `(0, 0)` is the **top-left** cell. `x` increases rightward, `y` increases downward.
|
||||||
|
|
||||||
|
### Block Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "x": 2, "y": 3, "color": "red" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Values | Description |
|
||||||
|
|-------|------|--------|-------------|
|
||||||
|
| `x` | integer | 0 to `width-1` | Column position |
|
||||||
|
| `y` | integer | 0 to `height-1` | Row position |
|
||||||
|
| `color` | string | See Color Values | Block color |
|
||||||
|
|
||||||
|
### Color Values
|
||||||
|
|
||||||
|
**Primary colors** (can merge):
|
||||||
|
|
||||||
|
| Value | Display | Hex |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| `"red"` | Ruby | #E63946 |
|
||||||
|
| `"blue"` | Sapphire | #457B9D |
|
||||||
|
| `"yellow"` | Topaz | #F4D35E |
|
||||||
|
|
||||||
|
**Secondary colors** (result of merges, cannot merge further):
|
||||||
|
|
||||||
|
| Value | Created From | Hex |
|
||||||
|
|-------|-------------|-----|
|
||||||
|
| `"purple"` | red + blue | #7B2D8B |
|
||||||
|
| `"orange"` | red + yellow | #E76F51 |
|
||||||
|
| `"green"` | blue + yellow | #2A9D8F |
|
||||||
|
|
||||||
|
Secondary-color blocks can appear in level definitions as pre-placed blocks, allowing level designers to create puzzles that start with merged colors already on the board.
|
||||||
|
|
||||||
|
### Wall Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "x": 2, "y": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Simple position. Walls are impassable — blocks stop when they would move into a wall cell.
|
||||||
|
|
||||||
|
### Special Tile Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "x": 3, "y": 4, "type": "mirror", "direction": "horizontal" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Values | Description |
|
||||||
|
|-------|------|--------|-------------|
|
||||||
|
| `x` | integer | 0 to `width-1` | Column position |
|
||||||
|
| `y` | integer | 0 to `height-1` | Row position |
|
||||||
|
| `type` | string | See below | Tile type |
|
||||||
|
| Additional fields vary by type | | | |
|
||||||
|
|
||||||
|
**Tile types and their extra fields:**
|
||||||
|
|
||||||
|
#### `mirror`
|
||||||
|
Reverses block direction on contact.
|
||||||
|
|
||||||
|
| Field | Values | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `direction` | `"horizontal"`, `"vertical"`, `"both"` | Which axis the mirror reflects |
|
||||||
|
|
||||||
|
- `"horizontal"` — reverses left↔right movement; vertical movement passes through
|
||||||
|
- `"vertical"` — reverses up↔down movement; horizontal movement passes through
|
||||||
|
- `"both"` — reverses any direction (block bounces back the way it came)
|
||||||
|
|
||||||
|
#### `splitter`
|
||||||
|
Breaks a merged (secondary) block into its two primary components. The two resulting blocks are placed on either side of the splitter along the axis of movement.
|
||||||
|
|
||||||
|
No extra fields.
|
||||||
|
|
||||||
|
If a primary-color block hits a splitter, it passes through (no effect).
|
||||||
|
|
||||||
|
#### `void`
|
||||||
|
Absorbs any block that enters. Single use — the void tile is consumed along with the block.
|
||||||
|
|
||||||
|
| Field | Values | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `charges` | integer (default: 1) | Number of blocks it can absorb before being consumed |
|
||||||
|
|
||||||
|
#### `ice`
|
||||||
|
Block slides through without stopping. The block continues moving until it hits a non-ice cell's wall/block/boundary.
|
||||||
|
|
||||||
|
No extra fields.
|
||||||
|
|
||||||
|
#### `lock`
|
||||||
|
A block that can only be destroyed by a matching `key` block.
|
||||||
|
|
||||||
|
| Field | Values | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `lock_color` | color string | The color of key required to destroy this lock |
|
||||||
|
|
||||||
|
#### `key`
|
||||||
|
When colliding with a matching lock, both are destroyed.
|
||||||
|
|
||||||
|
| Field | Values | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `key_color` | color string | Must match a lock's `lock_color` to destroy it |
|
||||||
|
|
||||||
|
### Objective Object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "clear_all" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | Extra Fields | Description |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| `"clear_all"` | None | Remove all blocks from the board |
|
||||||
|
| `"clear_color"` | `"color": "red"` | Remove all blocks of a specific color |
|
||||||
|
| `"reduce_to"` | `"count": 1` | Reduce total blocks to the specified count |
|
||||||
|
| `"clear_targets"` | `"targets": [{"x":2,"y":3}, ...]` | Clear specific cells (blocks must be destroyed at those positions) |
|
||||||
|
|
||||||
|
## Metadata File
|
||||||
|
|
||||||
|
`Levels/metadata.json` provides an index of all worlds and levels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"worlds": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Primary",
|
||||||
|
"description": "Learn the basics of pushing and destroying.",
|
||||||
|
"new_mechanic": null,
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": "w1_01",
|
||||||
|
"title": "First Steps",
|
||||||
|
"file": "world1/level_01.json",
|
||||||
|
"is_challenge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "w1_16",
|
||||||
|
"title": "Ruby Gauntlet",
|
||||||
|
"file": "world1/level_16.json",
|
||||||
|
"is_challenge": true,
|
||||||
|
"stars_required": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Challenge levels have `is_challenge: true` and require a cumulative star count (`stars_required`) to unlock.
|
||||||
|
|
||||||
|
## Example Levels
|
||||||
|
|
||||||
|
### Tutorial Level (World 1, Level 1)
|
||||||
|
|
||||||
|
Two red blocks on a 4x4 grid. Push one into the other.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "w1_01",
|
||||||
|
"world": 1,
|
||||||
|
"level": 1,
|
||||||
|
"title": "First Steps",
|
||||||
|
"grid": { "width": 4, "height": 4 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 1, "color": "red" },
|
||||||
|
{ "x": 3, "y": 1, "color": "red" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 1,
|
||||||
|
"hints": ["Push the left block to the right."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intermediate Level (World 1, Level 8)
|
||||||
|
|
||||||
|
Walls force an indirect path.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "w1_08",
|
||||||
|
"world": 1,
|
||||||
|
"level": 8,
|
||||||
|
"title": "Detour",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 0, "color": "red" },
|
||||||
|
{ "x": 4, "y": 0, "color": "red" },
|
||||||
|
{ "x": 1, "y": 4, "color": "blue" },
|
||||||
|
{ "x": 3, "y": 4, "color": "blue" }
|
||||||
|
],
|
||||||
|
"walls": [
|
||||||
|
{ "x": 2, "y": 0 },
|
||||||
|
{ "x": 2, "y": 1 },
|
||||||
|
{ "x": 2, "y": 3 },
|
||||||
|
{ "x": 2, "y": 4 }
|
||||||
|
],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 6,
|
||||||
|
"hints": [
|
||||||
|
"The wall column splits the board in two.",
|
||||||
|
"Row 2 is the only crossing point."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merge Level (World 2, Level 5)
|
||||||
|
|
||||||
|
Create purple to clear it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "w2_05",
|
||||||
|
"world": 2,
|
||||||
|
"level": 5,
|
||||||
|
"title": "Color Theory",
|
||||||
|
"grid": { "width": 5, "height": 5 },
|
||||||
|
"blocks": [
|
||||||
|
{ "x": 0, "y": 2, "color": "red" },
|
||||||
|
{ "x": 4, "y": 2, "color": "blue" },
|
||||||
|
{ "x": 2, "y": 0, "color": "purple" }
|
||||||
|
],
|
||||||
|
"walls": [],
|
||||||
|
"special_tiles": [],
|
||||||
|
"objective": { "type": "clear_all" },
|
||||||
|
"par": 3,
|
||||||
|
"hints": [
|
||||||
|
"You need to make a purple block to match the one already on the board.",
|
||||||
|
"Merge red and blue first, then align the two purples."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
A level file is valid if:
|
||||||
|
|
||||||
|
1. `id` is unique across all levels
|
||||||
|
2. `grid.width` and `grid.height` are between 3 and 10
|
||||||
|
3. All block, wall, and special tile positions are within grid bounds
|
||||||
|
4. No two objects occupy the same cell
|
||||||
|
5. `par` is a positive integer
|
||||||
|
6. `objective.type` is one of the defined types
|
||||||
|
7. If `objective.type` is `"clear_color"`, the specified color exists in `blocks`
|
||||||
|
8. At least 2 blocks are present (a puzzle requires at minimum something to collide)
|
||||||
|
9. Block colors are valid primary or secondary color strings
|
||||||
|
|
||||||
|
## Extending the Format
|
||||||
|
|
||||||
|
New special tile types can be added by defining a new `type` string and its associated extra fields. The game engine should gracefully ignore unrecognized tile types (forward compatibility for older app versions loading newer level packs).
|
||||||
|
|
||||||
|
New objective types follow the same pattern. The `objective` object is intentionally flexible — additional fields can be added per type without breaking existing levels.
|
||||||
378
TASKS_World2.md
Normal file
378
TASKS_World2.md
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
# 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.**
|
||||||
Loading…
Add table
Reference in a new issue