commit 04e638e50d1e11937e8898bdfa53c2cb6434fb24 Author: Frank Fuentes Date: Mon Apr 27 12:02:58 2026 -0700 Initial import: World 1 + World 2 (Fusion) with merge gating diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..892098a --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c87a26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,272 @@ +# CLAUDE.md — Collapse Logic iOS + +## Project Overview + +Collapse Logic is a grid-based puzzle game for iPhone where the player pushes colored blocks to trigger same-color destruction and different-color merging. + +**Studio:** Vulcara Games +**Platform:** iOS 17.0+ (iPhone primary) +**Language:** Swift 5.9+ +**Frameworks:** SpriteKit (gameplay), SwiftUI (menus/HUD) +**Architecture:** MVVM with pure-struct game state + +--- + +## Current Build Status + +- **World 1 — COMPLETE.** All 10 levels playable. Engine, SpriteKit scene, SwiftUI shell, undo, star rating, and persistence are all working. +- **World 2 — IN PROGRESS.** Adding color merging. See `TASKS_World2.md` for the step-by-step build plan. + +--- + +## Architecture + +``` +CollapseLogic/ +├── App/ +│ └── CollapseLogicApp.swift +├── Models/ +│ ├── GameState.swift +│ ├── Block.swift # BlockColor enum lives here — add yellow + secondaries +│ ├── GridPosition.swift +│ ├── Direction.swift +│ ├── MoveResult.swift # .merged event already stubbed — wire it up +│ └── LevelDefinition.swift +├── Engine/ +│ ├── GameEngine.swift # Core change: different-color collision now merges +│ └── LevelLoader.swift +├── Views/ +│ ├── MainMenuView.swift +│ ├── LevelSelectView.swift # Needs world selector (World 1 / World 2) +│ └── GameContainerView.swift +├── Scene/ +│ ├── GameScene.swift +│ ├── BlockNode.swift # Add color rendering for yellow + 3 secondaries +│ └── GridRenderer.swift +├── Audio/ +│ └── SFXManager.swift # Add merge.wav +├── Persistence/ +│ └── ProgressStore.swift +├── Levels/ +│ ├── metadata.json # Add World 2 entry +│ └── world1/ +│ └── level_01.json … level_10.json +│ └── world2/ +│ └── level_01.json … level_12.json +└── Resources/ + └── SFX/ +``` + +--- + +## Block Colors — Full Set + +### Primary colors (can be pushed into each other to merge) + +| Color | Hex | Name | +|-------|-----|------| +| `red` | #E63946 | Ruby | +| `blue` | #457B9D | Sapphire | +| `yellow` | #F4D35E | Topaz | + +### Secondary colors (result of merges — can destroy each other but cannot merge further) + +| Color | Created From | Hex | Name | +|-------|-------------|-----|------| +| `purple` | red + blue | #7B2D8B | Amethyst | +| `orange` | red + yellow | #E76F51 | Ember | +| `green` | blue + yellow | #2A9D8F | Jade | + +Secondary blocks **can** be pre-placed in level JSON. They **can** destroy each other on same-color collision. They **cannot** merge — two different secondaries just stop (no interaction). + +--- + +## Merge Rules (World 2 Engine Logic) + +When a moving block collides with a stationary block of a **different** color: + +``` +if both are PRIMARY colors → merge into the secondary color, placed at the stationary block's position +if moving is PRIMARY, stationary is SECONDARY → block stops (no interaction) +if moving is SECONDARY, stationary is PRIMARY → block stops (no interaction) +if both are SECONDARY colors (different) → block stops (no interaction) +if same color (primary or secondary) → both destroyed (existing World 1 rule, unchanged) +``` + +### Merge color table + +```swift +static func mergeColor(_ a: BlockColor, _ b: BlockColor) -> BlockColor? { + let pair = Set([a, b]) + if pair == [.red, .blue] { return .purple } + if pair == [.red, .yellow] { return .orange } + if pair == [.blue, .yellow] { return .green } + return nil // no merge possible +} +``` + +When a merge occurs: +1. Both source blocks are removed from state. +2. A new block of the merged color is placed at the **stationary block's position**. +3. A `.merged` event is emitted (for animation). +4. Move count increments (merge is a valid move). +5. Win condition is checked after the merge. + +--- + +## GameEngine Changes (World 2) + +The only change to `GameEngine.processMove` is step 5 of the movement algorithm: + +**Before (World 1):** +``` +5. Check cell adjacent to T in direction D: + - Same color → destroy both + - Different color → block stops +``` + +**After (World 2):** +``` +5. Check cell adjacent to T in direction D: + - Same color (any) → destroy both [unchanged] + - Both are primary, different colors → merge; new secondary block at stationary position + - Any other different-color combination → block stops [unchanged] +``` + +No other engine logic changes. The pure-struct GameState design means this is fully unit-testable in isolation before touching SpriteKit. + +--- + +## MoveResult Events + +```swift +enum GameEvent { + case slid(blockIndex: Int, from: GridPosition, to: GridPosition) + case destroyed(blockIndex1: Int, blockIndex2: Int, at: GridPosition) + case merged(blockIndex1: Int, blockIndex2: Int, resultColor: BlockColor, at: GridPosition) +} +``` + +The `.merged` event was already stubbed in World 1. Wire it up now. + +--- + +## Animation — Merge + +In `GameScene`, handle `.merged` events: + +1. Slide the moving block to position T (same 0.15s ease-out as existing slide). +2. Run a **merge burst**: scale both blocks up to 1.2× over 0.1s, then scale down and fade out over 0.1s simultaneously. +3. Spawn a **new block node** of the merged color at the stationary position, starting at scale 0.0, and animate it scaling up to 1.0 over 0.15s with a slight overshoot (spring feel — scale to 1.15 then settle to 1.0). +4. Total merge animation duration: ~0.35s. + +Keep it snappy. The new merged block "popping" into existence is the payoff moment. + +--- + +## New Color Rendering (BlockNode) + +Add cases for `yellow`, `purple`, `orange`, `green` to the color switch in `BlockNode`. Use the hex values from the color table above. + +Visual treatment: +- Primary blocks (red, blue, yellow): same rounded rect style as World 1. +- Secondary blocks (purple, orange, green): add a subtle **white inner ring** (2px, 40% opacity) to visually distinguish them as merged/special. This gives players a quick read that these are second-tier blocks. + +--- + +## World Select UI + +`LevelSelectView` currently shows World 1 levels only. For World 2, add a simple world selector at the top — two tappable pills: "World 1" and "World 2". World 2 unlocks when the player has earned at least **15 stars** in World 1 (out of 30 possible). + +Lock state: if World 2 is locked, show the pill grayed out with a lock icon and "15 ★ to unlock" label. + +Do not over-engineer this — a simple `@State var selectedWorld: Int` in `LevelSelectView` is enough. + +--- + +## Sound Effects + +Add one new sound: + +5. `merge.wav` — A two-tone "blip-bloom" sound (0.25s). Higher pitch than destroy. Suggests transformation rather than destruction. + +Use `SKAction.playSoundFileNamed()` same as the existing four sounds. + +--- + +## Level Design — World 2 (12 levels) + +World 2 introduces merging progressively across 12 levels. All level JSON files are in `Levels/world2/`. Grid sizes 5×5 to 6×6. + +**Teaching sequence:** +- Levels 1–3: Introduce one merge pair (red+blue=purple). No pre-placed secondaries. +- Levels 4–6: Add yellow. Teach red+yellow=orange and blue+yellow=green. +- Levels 7–9: Pre-placed secondary blocks appear. Player must match them. +- Levels 10–11: Multi-step chains — merge, then use the result to destroy another secondary. +- Level 12: Boss level — all three primaries, all three secondaries, careful sequencing required. + +See `Levels/world2/level_*.json` for the hand-crafted level files. + +--- + +## Testing Priorities (World 2 additions) + +**Engine unit tests — add these:** +- `red` + `blue` collision → produces `purple` block at stationary position +- `red` + `yellow` collision → produces `orange` +- `blue` + `yellow` collision → produces `green` +- Secondary + primary collision → block stops (no merge) +- Secondary + different secondary collision → block stops (no merge) +- Same secondary collision → both destroyed (e.g. purple + purple) +- Merged block is placed at correct position (stationary block's cell) +- Move count increments on merge +- Undo correctly restores state before a merge +- Win condition detected after a merge clears the last blocks + +**Level loading tests:** +- All 12 World 2 JSON files parse without errors +- Pre-placed secondary color blocks load correctly + +**Manual playtesting:** +- Each World 2 level is solvable at par +- Merge animation feels satisfying, not jarring +- World 2 unlock (15 stars) triggers correctly + +--- + +## Style & Conventions + +- Swift naming conventions (camelCase properties, PascalCase types) +- Prefer value types (structs, enums) over classes except for SpriteKit nodes +- No force unwraps except in tests +- Keep files under 200 lines. Split if needed. +- Comment non-obvious game logic +- `// MARK: -` sections in longer files + +--- + +## Build & Run + +- Xcode 15+ +- iOS 17.0 deployment target +- No external dependencies +- Primary simulator: iPhone 15 Pro +- Small-screen test: iPhone SE (3rd gen) + +--- + +## Definition of Done — World 2 + +- [ ] `mergeColor(_:_:)` function implemented and all merge combinations correct +- [ ] `GameEngine.processMove` handles primary+primary different-color collision as merge +- [ ] `.merged` GameEvent emitted with correct blockIndex values and resultColor +- [ ] All existing World 1 unit tests still pass (no regressions) +- [ ] New merge unit tests pass (see Testing Priorities above) +- [ ] `yellow`, `purple`, `orange`, `green` render correctly in `BlockNode` +- [ ] Secondary blocks have white inner ring visual treatment +- [ ] Merge animation plays: slide → burst → new block pop-in (~0.35s total) +- [ ] `merge.wav` plays on merge +- [ ] All 12 World 2 level JSONs load and are playable +- [ ] World selector UI in `LevelSelectView` (two pills, lock at <15 stars) +- [ ] `metadata.json` updated with World 2 entry +- [ ] World 2 levels are fun and teach the mechanic clearly diff --git a/CollapseLogic/CollapseLogic.xcodeproj/project.pbxproj b/CollapseLogic/CollapseLogic.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0df5be6 --- /dev/null +++ b/CollapseLogic/CollapseLogic.xcodeproj/project.pbxproj @@ -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 = ""; + }; + 584AD0522F6A5E0700B97C0B /* CollapseLogic */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 584AD0C12F6A72C300B97C0B /* Exceptions for "CollapseLogic" folder in "CollapseLogic" target */, + ); + path = CollapseLogic; + sourceTree = ""; + }; +/* 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 = ""; + }; + 584AD0512F6A5E0700B97C0B /* Products */ = { + isa = PBXGroup; + children = ( + 584AD0502F6A5E0700B97C0B /* CollapseLogic.app */, + 583D3E712F9FE03400867DC9 /* CollapseLogicTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/xcuserdata/frankfuentes.xcuserdatad/UserInterfaceState.xcuserstate b/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/xcuserdata/frankfuentes.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..7b07bfc Binary files /dev/null and b/CollapseLogic/CollapseLogic.xcodeproj/project.xcworkspace/xcuserdata/frankfuentes.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/CollapseLogic/CollapseLogic.xcodeproj/xcuserdata/frankfuentes.xcuserdatad/xcschemes/xcschememanagement.plist b/CollapseLogic/CollapseLogic.xcodeproj/xcuserdata/frankfuentes.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..859148c --- /dev/null +++ b/CollapseLogic/CollapseLogic.xcodeproj/xcuserdata/frankfuentes.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + CollapseLogic.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/CollapseLogic/CollapseLogic/App/CollapseLogicApp.swift b/CollapseLogic/CollapseLogic/App/CollapseLogicApp.swift new file mode 100644 index 0000000..4031f49 --- /dev/null +++ b/CollapseLogic/CollapseLogic/App/CollapseLogicApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct CollapseLogicApp: App { + var body: some Scene { + WindowGroup { + MainMenuView() + .preferredColorScheme(.dark) + } + } +} diff --git a/CollapseLogic/CollapseLogic/Assets.xcassets/AccentColor.colorset/Contents.json b/CollapseLogic/CollapseLogic/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/CollapseLogic_icon.png b/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/CollapseLogic_icon.png new file mode 100644 index 0000000..752cb08 Binary files /dev/null and b/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/CollapseLogic_icon.png differ diff --git a/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/Contents.json b/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..67d35ac --- /dev/null +++ b/CollapseLogic/CollapseLogic/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "CollapseLogic_icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollapseLogic/CollapseLogic/Assets.xcassets/Contents.json b/CollapseLogic/CollapseLogic/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CollapseLogic/CollapseLogic/Audio/SFXManager.swift b/CollapseLogic/CollapseLogic/Audio/SFXManager.swift new file mode 100644 index 0000000..c35d3c2 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Audio/SFXManager.swift @@ -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) + } +} diff --git a/CollapseLogic/CollapseLogic/CollapseLogicApp.swift b/CollapseLogic/CollapseLogic/CollapseLogicApp.swift new file mode 100644 index 0000000..2413f2a --- /dev/null +++ b/CollapseLogic/CollapseLogic/CollapseLogicApp.swift @@ -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() + } + } +} diff --git a/CollapseLogic/CollapseLogic/ContentView.swift b/CollapseLogic/CollapseLogic/ContentView.swift new file mode 100644 index 0000000..0d40c64 --- /dev/null +++ b/CollapseLogic/CollapseLogic/ContentView.swift @@ -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() +} diff --git a/CollapseLogic/CollapseLogic/Engine/GameEngine.swift b/CollapseLogic/CollapseLogic/Engine/GameEngine.swift new file mode 100644 index 0000000..913becb --- /dev/null +++ b/CollapseLogic/CollapseLogic/Engine/GameEngine.swift @@ -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 + } +} diff --git a/CollapseLogic/CollapseLogic/Engine/LevelLoader.swift b/CollapseLogic/CollapseLogic/Engine/LevelLoader.swift new file mode 100644 index 0000000..8ed7595 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Engine/LevelLoader.swift @@ -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 = Set((def.walls ?? []).map { + GridPosition(x: $0.x, y: $0.y) + }) + + let objective = parseObjective(def.objective) + + return GameState( + grid: gridSize, + blocks: blocks, + walls: walls, + moveCount: 0, + par: def.par, + objective: objective, + allowsMerging: def.world >= 2, + history: [] + ) + } + + // MARK: - Metadata + + static func loadMetadata() -> MetadataFile? { + guard let url = Bundle.main.url(forResource: "metadata", withExtension: "json") else { return nil } + return try? JSONDecoder().decode(MetadataFile.self, from: Data(contentsOf: url)) + } + + // MARK: - Validation + + static func validate(_ def: LevelDefinition) throws { + let id = def.id + guard def.grid.width >= 3, def.grid.width <= 10, + def.grid.height >= 3, def.grid.height <= 10 else { + throw LevelLoadError.validationFailed(id, "Grid dimensions out of range 3–10") + } + guard def.par > 0 else { + throw LevelLoadError.validationFailed(id, "Par must be positive") + } + guard def.blocks.count >= 2 else { + throw LevelLoadError.validationFailed(id, "At least 2 blocks required") + } + + var occupied = Set() + 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" + } +} diff --git a/CollapseLogic/CollapseLogic/Levels/metadata.json b/CollapseLogic/CollapseLogic/Levels/metadata.json new file mode 100644 index 0000000..c0521b5 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/metadata.json @@ -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 } + ] + } + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_01.json b/CollapseLogic/CollapseLogic/Levels/world1/level_01.json new file mode 100644 index 0000000..c99d2e5 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_01.json @@ -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."] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_02.json b/CollapseLogic/CollapseLogic/Levels/world1/level_02.json new file mode 100644 index 0000000..6bbc27b --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_02.json @@ -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."] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_03.json b/CollapseLogic/CollapseLogic/Levels/world1/level_03.json new file mode 100644 index 0000000..4c74798 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_03.json @@ -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."] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_04.json b/CollapseLogic/CollapseLogic/Levels/world1/level_04.json new file mode 100644 index 0000000..75012b0 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_04.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_05.json b/CollapseLogic/CollapseLogic/Levels/world1/level_05.json new file mode 100644 index 0000000..ba3d3a7 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_05.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_06.json b/CollapseLogic/CollapseLogic/Levels/world1/level_06.json new file mode 100644 index 0000000..72cc648 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_06.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_07.json b/CollapseLogic/CollapseLogic/Levels/world1/level_07.json new file mode 100644 index 0000000..7582305 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_07.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_08.json b/CollapseLogic/CollapseLogic/Levels/world1/level_08.json new file mode 100644 index 0000000..f605e52 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_08.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_09.json b/CollapseLogic/CollapseLogic/Levels/world1/level_09.json new file mode 100644 index 0000000..a2f8784 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_09.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world1/level_10.json b/CollapseLogic/CollapseLogic/Levels/world1/level_10.json new file mode 100644 index 0000000..fd4c720 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world1/level_10.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_01.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_01.json new file mode 100644 index 0000000..1f54690 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_01.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_02.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_02.json new file mode 100644 index 0000000..71238e6 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_02.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_03.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_03.json new file mode 100644 index 0000000..1477f41 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_03.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_04.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_04.json new file mode 100644 index 0000000..c3d6b40 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_04.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_05.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_05.json new file mode 100644 index 0000000..3fafcb9 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_05.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_06.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_06.json new file mode 100644 index 0000000..4b0c7fe --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_06.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_07.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_07.json new file mode 100644 index 0000000..ac792c1 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_07.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_08.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_08.json new file mode 100644 index 0000000..7bda703 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_08.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_09.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_09.json new file mode 100644 index 0000000..3e5f56e --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_09.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_10.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_10.json new file mode 100644 index 0000000..e785063 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_10.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_11.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_11.json new file mode 100644 index 0000000..48951b9 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_11.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Levels/world2/w2_level_12.json b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_12.json new file mode 100644 index 0000000..f060012 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Levels/world2/w2_level_12.json @@ -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." + ] +} diff --git a/CollapseLogic/CollapseLogic/Models/Block.swift b/CollapseLogic/CollapseLogic/Models/Block.swift new file mode 100644 index 0000000..c26f40e --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/Block.swift @@ -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 +} diff --git a/CollapseLogic/CollapseLogic/Models/Direction.swift b/CollapseLogic/CollapseLogic/Models/Direction.swift new file mode 100644 index 0000000..dac746f --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/Direction.swift @@ -0,0 +1,3 @@ +enum Direction: CaseIterable { + case up, down, left, right +} diff --git a/CollapseLogic/CollapseLogic/Models/GameState.swift b/CollapseLogic/CollapseLogic/Models/GameState.swift new file mode 100644 index 0000000..0c62c64 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/GameState.swift @@ -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 + 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 + } +} diff --git a/CollapseLogic/CollapseLogic/Models/GridPosition.swift b/CollapseLogic/CollapseLogic/Models/GridPosition.swift new file mode 100644 index 0000000..53fa377 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/GridPosition.swift @@ -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) + } + } +} diff --git a/CollapseLogic/CollapseLogic/Models/LevelDefinition.swift b/CollapseLogic/CollapseLogic/Models/LevelDefinition.swift new file mode 100644 index 0000000..ff62377 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/LevelDefinition.swift @@ -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 +} diff --git a/CollapseLogic/CollapseLogic/Models/MoveResult.swift b/CollapseLogic/CollapseLogic/Models/MoveResult.swift new file mode 100644 index 0000000..fb54cf3 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Models/MoveResult.swift @@ -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 } +} diff --git a/CollapseLogic/CollapseLogic/Persistence/ProgressStore.swift b/CollapseLogic/CollapseLogic/Persistence/ProgressStore.swift new file mode 100644 index 0000000..a679248 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Persistence/ProgressStore.swift @@ -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, +) + } +} diff --git a/CollapseLogic/CollapseLogic/Resources/SFX/complete.mp3 b/CollapseLogic/CollapseLogic/Resources/SFX/complete.mp3 new file mode 100644 index 0000000..7ac07c9 Binary files /dev/null and b/CollapseLogic/CollapseLogic/Resources/SFX/complete.mp3 differ diff --git a/CollapseLogic/CollapseLogic/Resources/SFX/destroy.mp3 b/CollapseLogic/CollapseLogic/Resources/SFX/destroy.mp3 new file mode 100644 index 0000000..6c8d603 Binary files /dev/null and b/CollapseLogic/CollapseLogic/Resources/SFX/destroy.mp3 differ diff --git a/CollapseLogic/CollapseLogic/Resources/SFX/merge.wav b/CollapseLogic/CollapseLogic/Resources/SFX/merge.wav new file mode 100644 index 0000000..e69de29 diff --git a/CollapseLogic/CollapseLogic/Resources/SFX/select.mp3 b/CollapseLogic/CollapseLogic/Resources/SFX/select.mp3 new file mode 100644 index 0000000..4ba5841 Binary files /dev/null and b/CollapseLogic/CollapseLogic/Resources/SFX/select.mp3 differ diff --git a/CollapseLogic/CollapseLogic/Resources/SFX/slide.mp3 b/CollapseLogic/CollapseLogic/Resources/SFX/slide.mp3 new file mode 100644 index 0000000..9bca655 Binary files /dev/null and b/CollapseLogic/CollapseLogic/Resources/SFX/slide.mp3 differ diff --git a/CollapseLogic/CollapseLogic/Scene/BlockNode.swift b/CollapseLogic/CollapseLogic/Scene/BlockNode.swift new file mode 100644 index 0000000..9d2bbba --- /dev/null +++ b/CollapseLogic/CollapseLogic/Scene/BlockNode.swift @@ -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) + } +} diff --git a/CollapseLogic/CollapseLogic/Scene/GameScene.swift b/CollapseLogic/CollapseLogic/Scene/GameScene.swift new file mode 100644 index 0000000..b2676cf --- /dev/null +++ b/CollapseLogic/CollapseLogic/Scene/GameScene.swift @@ -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, 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, with event: UIEvent?) { + // Handled by swipe detection below + } + + private var swipeStart: CGPoint? + + override func touchesMoved(_ touches: Set, 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 + } +} diff --git a/CollapseLogic/CollapseLogic/Scene/GridRenderer.swift b/CollapseLogic/CollapseLogic/Scene/GridRenderer.swift new file mode 100644 index 0000000..b6e2d9e --- /dev/null +++ b/CollapseLogic/CollapseLogic/Scene/GridRenderer.swift @@ -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) -> 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) + } +} diff --git a/CollapseLogic/CollapseLogic/Views/GameContainerView.swift b/CollapseLogic/CollapseLogic/Views/GameContainerView.swift new file mode 100644 index 0000000..665e707 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Views/GameContainerView.swift @@ -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 + ) + } +} diff --git a/CollapseLogic/CollapseLogic/Views/LevelSelectView.swift b/CollapseLogic/CollapseLogic/Views/LevelSelectView.swift new file mode 100644 index 0000000..8c24fdb --- /dev/null +++ b/CollapseLogic/CollapseLogic/Views/LevelSelectView.swift @@ -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) + ) + } + } + } +} diff --git a/CollapseLogic/CollapseLogic/Views/MainMenuView.swift b/CollapseLogic/CollapseLogic/Views/MainMenuView.swift new file mode 100644 index 0000000..2287a49 --- /dev/null +++ b/CollapseLogic/CollapseLogic/Views/MainMenuView.swift @@ -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() + } + } +} diff --git a/CollapseLogic/CollapseLogicTests/GameEngineTests.swift b/CollapseLogic/CollapseLogicTests/GameEngineTests.swift new file mode 100644 index 0000000..f324bdb --- /dev/null +++ b/CollapseLogic/CollapseLogicTests/GameEngineTests.swift @@ -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") + } +} diff --git a/CollapseLogic/CollapseLogicTests/LevelLoaderTests.swift b/CollapseLogic/CollapseLogicTests/LevelLoaderTests.swift new file mode 100644 index 0000000..8b041b0 --- /dev/null +++ b/CollapseLogic/CollapseLogicTests/LevelLoaderTests.swift @@ -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": [] } + """ + ] + } +} diff --git a/CollapseLogic_LevelSpec.md b/CollapseLogic_LevelSpec.md new file mode 100644 index 0000000..8258ac7 --- /dev/null +++ b/CollapseLogic_LevelSpec.md @@ -0,0 +1,343 @@ +# Collapse Logic — Level Format Specification + +**Version:** 1.0 +**Last Updated:** March 2026 +**Studio:** Vulcara Games + +--- + +## Overview + +Levels are defined as JSON files bundled in the app. Each world has a directory of level files. The format is designed to be human-readable for hand-crafting levels and machine-parseable for the game engine. + +## File Structure + +``` +CollapseLogic/ +├── Levels/ +│ ├── world1/ +│ │ ├── level_01.json +│ │ ├── level_02.json +│ │ └── ... +│ ├── world2/ +│ │ └── ... +│ └── metadata.json +``` + +## Level JSON Schema + +```json +{ + "id": "w1_01", + "world": 1, + "level": 1, + "title": "First Steps", + "grid": { + "width": 5, + "height": 5 + }, + "blocks": [ + { "x": 1, "y": 1, "color": "red" }, + { "x": 3, "y": 1, "color": "red" }, + { "x": 2, "y": 3, "color": "blue" }, + { "x": 4, "y": 3, "color": "blue" } + ], + "walls": [ + { "x": 2, "y": 2 } + ], + "special_tiles": [], + "objective": { + "type": "clear_all" + }, + "par": 4, + "hints": [ + "Try pushing the top-left red block down first." + ] +} +``` + +## Field Definitions + +### Top-Level Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Unique identifier. Convention: `w{world}_{level:02d}` (e.g., `w1_01`, `w3_15`) | +| `world` | integer | Yes | World number (1-6) | +| `level` | integer | Yes | Level number within the world (1-20) | +| `title` | string | No | Display name for the level (optional flavor text) | +| `grid` | object | Yes | Grid dimensions | +| `blocks` | array | Yes | Array of block objects placed on the grid | +| `walls` | array | No | Array of wall positions. Default: `[]` | +| `special_tiles` | array | No | Array of special tile objects. Default: `[]` | +| `objective` | object | Yes | Win condition for the level | +| `par` | integer | Yes | Target move count for 3-star rating | +| `hints` | array | No | Array of hint strings (revealed progressively). Default: `[]` | + +### Grid Object + +```json +{ + "width": 5, + "height": 5 +} +``` + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `width` | integer | 3–10 | Number of columns | +| `height` | integer | 3–10 | Number of rows | + +Coordinate system: `(0, 0)` is the **top-left** cell. `x` increases rightward, `y` increases downward. + +### Block Object + +```json +{ "x": 2, "y": 3, "color": "red" } +``` + +| Field | Type | Values | Description | +|-------|------|--------|-------------| +| `x` | integer | 0 to `width-1` | Column position | +| `y` | integer | 0 to `height-1` | Row position | +| `color` | string | See Color Values | Block color | + +### Color Values + +**Primary colors** (can merge): + +| Value | Display | Hex | +|-------|---------|-----| +| `"red"` | Ruby | #E63946 | +| `"blue"` | Sapphire | #457B9D | +| `"yellow"` | Topaz | #F4D35E | + +**Secondary colors** (result of merges, cannot merge further): + +| Value | Created From | Hex | +|-------|-------------|-----| +| `"purple"` | red + blue | #7B2D8B | +| `"orange"` | red + yellow | #E76F51 | +| `"green"` | blue + yellow | #2A9D8F | + +Secondary-color blocks can appear in level definitions as pre-placed blocks, allowing level designers to create puzzles that start with merged colors already on the board. + +### Wall Object + +```json +{ "x": 2, "y": 2 } +``` + +Simple position. Walls are impassable — blocks stop when they would move into a wall cell. + +### Special Tile Object + +```json +{ "x": 3, "y": 4, "type": "mirror", "direction": "horizontal" } +``` + +| Field | Type | Values | Description | +|-------|------|--------|-------------| +| `x` | integer | 0 to `width-1` | Column position | +| `y` | integer | 0 to `height-1` | Row position | +| `type` | string | See below | Tile type | +| Additional fields vary by type | | | | + +**Tile types and their extra fields:** + +#### `mirror` +Reverses block direction on contact. + +| Field | Values | Description | +|-------|--------|-------------| +| `direction` | `"horizontal"`, `"vertical"`, `"both"` | Which axis the mirror reflects | + +- `"horizontal"` — reverses left↔right movement; vertical movement passes through +- `"vertical"` — reverses up↔down movement; horizontal movement passes through +- `"both"` — reverses any direction (block bounces back the way it came) + +#### `splitter` +Breaks a merged (secondary) block into its two primary components. The two resulting blocks are placed on either side of the splitter along the axis of movement. + +No extra fields. + +If a primary-color block hits a splitter, it passes through (no effect). + +#### `void` +Absorbs any block that enters. Single use — the void tile is consumed along with the block. + +| Field | Values | Description | +|-------|--------|-------------| +| `charges` | integer (default: 1) | Number of blocks it can absorb before being consumed | + +#### `ice` +Block slides through without stopping. The block continues moving until it hits a non-ice cell's wall/block/boundary. + +No extra fields. + +#### `lock` +A block that can only be destroyed by a matching `key` block. + +| Field | Values | Description | +|-------|--------|-------------| +| `lock_color` | color string | The color of key required to destroy this lock | + +#### `key` +When colliding with a matching lock, both are destroyed. + +| Field | Values | Description | +|-------|--------|-------------| +| `key_color` | color string | Must match a lock's `lock_color` to destroy it | + +### Objective Object + +```json +{ "type": "clear_all" } +``` + +| Type | Extra Fields | Description | +|------|-------------|-------------| +| `"clear_all"` | None | Remove all blocks from the board | +| `"clear_color"` | `"color": "red"` | Remove all blocks of a specific color | +| `"reduce_to"` | `"count": 1` | Reduce total blocks to the specified count | +| `"clear_targets"` | `"targets": [{"x":2,"y":3}, ...]` | Clear specific cells (blocks must be destroyed at those positions) | + +## Metadata File + +`Levels/metadata.json` provides an index of all worlds and levels: + +```json +{ + "version": "1.0", + "worlds": [ + { + "id": 1, + "name": "Primary", + "description": "Learn the basics of pushing and destroying.", + "new_mechanic": null, + "levels": [ + { + "id": "w1_01", + "title": "First Steps", + "file": "world1/level_01.json", + "is_challenge": false + }, + { + "id": "w1_16", + "title": "Ruby Gauntlet", + "file": "world1/level_16.json", + "is_challenge": true, + "stars_required": 30 + } + ] + } + ] +} +``` + +Challenge levels have `is_challenge: true` and require a cumulative star count (`stars_required`) to unlock. + +## Example Levels + +### Tutorial Level (World 1, Level 1) + +Two red blocks on a 4x4 grid. Push one into the other. + +```json +{ + "id": "w1_01", + "world": 1, + "level": 1, + "title": "First Steps", + "grid": { "width": 4, "height": 4 }, + "blocks": [ + { "x": 0, "y": 1, "color": "red" }, + { "x": 3, "y": 1, "color": "red" } + ], + "walls": [], + "special_tiles": [], + "objective": { "type": "clear_all" }, + "par": 1, + "hints": ["Push the left block to the right."] +} +``` + +### Intermediate Level (World 1, Level 8) + +Walls force an indirect path. + +```json +{ + "id": "w1_08", + "world": 1, + "level": 8, + "title": "Detour", + "grid": { "width": 5, "height": 5 }, + "blocks": [ + { "x": 0, "y": 0, "color": "red" }, + { "x": 4, "y": 0, "color": "red" }, + { "x": 1, "y": 4, "color": "blue" }, + { "x": 3, "y": 4, "color": "blue" } + ], + "walls": [ + { "x": 2, "y": 0 }, + { "x": 2, "y": 1 }, + { "x": 2, "y": 3 }, + { "x": 2, "y": 4 } + ], + "special_tiles": [], + "objective": { "type": "clear_all" }, + "par": 6, + "hints": [ + "The wall column splits the board in two.", + "Row 2 is the only crossing point." + ] +} +``` + +### Merge Level (World 2, Level 5) + +Create purple to clear it. + +```json +{ + "id": "w2_05", + "world": 2, + "level": 5, + "title": "Color Theory", + "grid": { "width": 5, "height": 5 }, + "blocks": [ + { "x": 0, "y": 2, "color": "red" }, + { "x": 4, "y": 2, "color": "blue" }, + { "x": 2, "y": 0, "color": "purple" } + ], + "walls": [], + "special_tiles": [], + "objective": { "type": "clear_all" }, + "par": 3, + "hints": [ + "You need to make a purple block to match the one already on the board.", + "Merge red and blue first, then align the two purples." + ] +} +``` + +## Validation Rules + +A level file is valid if: + +1. `id` is unique across all levels +2. `grid.width` and `grid.height` are between 3 and 10 +3. All block, wall, and special tile positions are within grid bounds +4. No two objects occupy the same cell +5. `par` is a positive integer +6. `objective.type` is one of the defined types +7. If `objective.type` is `"clear_color"`, the specified color exists in `blocks` +8. At least 2 blocks are present (a puzzle requires at minimum something to collide) +9. Block colors are valid primary or secondary color strings + +## Extending the Format + +New special tile types can be added by defining a new `type` string and its associated extra fields. The game engine should gracefully ignore unrecognized tile types (forward compatibility for older app versions loading newer level packs). + +New objective types follow the same pattern. The `objective` object is intentionally flexible — additional fields can be added per type without breaking existing levels. diff --git a/TASKS_World2.md b/TASKS_World2.md new file mode 100644 index 0000000..8efb864 --- /dev/null +++ b/TASKS_World2.md @@ -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.**