Initial import: World 1 + World 2 (Fusion) with merge gating

This commit is contained in:
Frank Fuentes 2026-04-27 12:02:58 -07:00
commit 04e638e50d
61 changed files with 4648 additions and 0 deletions

30
.gitignore vendored Normal file
View 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
View 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 13: Introduce one merge pair (red+blue=purple). No pre-placed secondaries.
- Levels 46: Add yellow. Teach red+yellow=orange and blue+yellow=green.
- Levels 79: Pre-placed secondary blocks appear. Player must match them.
- Levels 1011: 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

View 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

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

View file

@ -0,0 +1,11 @@
import SwiftUI
@main
struct CollapseLogicApp: App {
var body: some Scene {
WindowGroup {
MainMenuView()
.preferredColorScheme(.dark)
}
}
}

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "CollapseLogic_icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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)
}
}

View 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()
}
}
}

View 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()
}

View 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
}
}

View 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 310")
}
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"
}
}

View 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 }
]
}
]
}

View 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."]
}

View 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."]
}

View 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."]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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."
]
}

View 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
}

View file

@ -0,0 +1,3 @@
enum Direction: CaseIterable {
case up, down, left, right
}

View 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
}
}

View 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)
}
}
}

View 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
}

View 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 }
}

View 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, +)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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
)
}
}

View 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)
)
}
}
}
}

View 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()
}
}
}

View 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")
}
}

View 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
View 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 | 310 | Number of columns |
| `height` | integer | 310 | 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
View 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.**