working ios game

This commit is contained in:
2026-05-01 09:21:43 -04:00
commit 89b2e779f5
17 changed files with 2892 additions and 0 deletions

24
ios/README.md Normal file
View File

@@ -0,0 +1,24 @@
# Shikaku iOS
This directory contains a native SwiftUI version of the Shikaku puzzle game.
## Open In Xcode
Open `Shikaku.xcodeproj`, choose the `Shikaku` scheme, and run it on an iPhone simulator or your own device.
For sideloading to a personal iPhone, select the `Shikaku` target in Xcode and set your personal Apple developer team under **Signing & Capabilities**. The bundle identifier is currently `com.briannelson.Shikaku`; change it if Xcode says that identifier is already taken for your team.
## Project Shape
- `Shikaku/Core` holds the ported model, generator, and validation rules.
- `Shikaku/Views` holds SwiftUI drawing and visual styling.
- `ShikakuTests` covers generation, validation, and player rectangle replacement behavior.
The app target is intentionally small and uses generated Info.plist settings so there is less project metadata to maintain while you are learning Swift and Xcode.
## Current Controls
- Pick grid sizes from `5x5` through `25x25`.
- Use Draw mode to drag rectangles and Erase mode to remove rectangles by touching them.
- Zoom with the magnifying-glass buttons, the `100%` reset button, or pinch-to-zoom in the board area.
- Use Seed to view the current puzzle seed or start a puzzle from a specific seed.

View File

@@ -0,0 +1,493 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
A1B2C3D4E5F6000000000201 /* ShikakuApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000102 /* ShikakuApp.swift */; };
A1B2C3D4E5F6000000000202 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000103 /* ContentView.swift */; };
A1B2C3D4E5F6000000000203 /* ShikakuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000104 /* ShikakuModels.swift */; };
A1B2C3D4E5F6000000000204 /* PuzzleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000105 /* PuzzleGenerator.swift */; };
A1B2C3D4E5F6000000000205 /* ShikakuGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000106 /* ShikakuGame.swift */; };
A1B2C3D4E5F6000000000206 /* ShikakuTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000107 /* ShikakuTheme.swift */; };
A1B2C3D4E5F6000000000207 /* ShikakuGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000108 /* ShikakuGridView.swift */; };
A1B2C3D4E5F6000000000208 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6000000000109 /* Assets.xcassets */; };
A1B2C3D4E5F6000000000209 /* ShikakuCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000000010B /* ShikakuCoreTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A1B2C3D4E5F6000000000401 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A1B2C3D4E5F6000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1B2C3D4E5F6000000000002;
remoteInfo = Shikaku;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
A1B2C3D4E5F6000000000100 /* Shikaku.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Shikaku.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F6000000000101 /* ShikakuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ShikakuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F6000000000102 /* ShikakuApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuApp.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000103 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000104 /* ShikakuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuModels.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000105 /* PuzzleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGenerator.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000106 /* ShikakuGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuGame.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000107 /* ShikakuTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuTheme.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000108 /* ShikakuGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuGridView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6000000000109 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A1B2C3D4E5F600000000010A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
A1B2C3D4E5F600000000010B /* ShikakuCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuCoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A1B2C3D4E5F6000000000303 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3D4E5F6000000000306 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A1B2C3D4E5F6000000000004 /* Products */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6000000000100 /* Shikaku.app */,
A1B2C3D4E5F6000000000101 /* ShikakuTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
A1B2C3D4E5F6000000000005 = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6000000000006 /* Shikaku */,
A1B2C3D4E5F6000000000009 /* ShikakuTests */,
A1B2C3D4E5F6000000000004 /* Products */,
);
sourceTree = "<group>";
};
A1B2C3D4E5F6000000000006 /* Shikaku */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6000000000102 /* ShikakuApp.swift */,
A1B2C3D4E5F6000000000103 /* ContentView.swift */,
A1B2C3D4E5F6000000000007 /* Core */,
A1B2C3D4E5F6000000000008 /* Views */,
A1B2C3D4E5F6000000000109 /* Assets.xcassets */,
A1B2C3D4E5F600000000000B /* Preview Content */,
);
path = Shikaku;
sourceTree = "<group>";
};
A1B2C3D4E5F6000000000007 /* Core */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6000000000104 /* ShikakuModels.swift */,
A1B2C3D4E5F6000000000105 /* PuzzleGenerator.swift */,
A1B2C3D4E5F6000000000106 /* ShikakuGame.swift */,
);
path = Core;
sourceTree = "<group>";
};
A1B2C3D4E5F6000000000008 /* Views */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6000000000107 /* ShikakuTheme.swift */,
A1B2C3D4E5F6000000000108 /* ShikakuGridView.swift */,
);
path = Views;
sourceTree = "<group>";
};
A1B2C3D4E5F6000000000009 /* ShikakuTests */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F600000000010B /* ShikakuCoreTests.swift */,
);
path = ShikakuTests;
sourceTree = "<group>";
};
A1B2C3D4E5F600000000000B /* Preview Content */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F600000000010A /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A1B2C3D4E5F6000000000002 /* Shikaku */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1B2C3D4E5F6000000000502 /* Build configuration list for PBXNativeTarget "Shikaku" */;
buildPhases = (
A1B2C3D4E5F6000000000301 /* Sources */,
A1B2C3D4E5F6000000000303 /* Frameworks */,
A1B2C3D4E5F6000000000302 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Shikaku;
productName = Shikaku;
productReference = A1B2C3D4E5F6000000000100 /* Shikaku.app */;
productType = "com.apple.product-type.application";
};
A1B2C3D4E5F6000000000003 /* ShikakuTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1B2C3D4E5F6000000000503 /* Build configuration list for PBXNativeTarget "ShikakuTests" */;
buildPhases = (
A1B2C3D4E5F6000000000304 /* Sources */,
A1B2C3D4E5F6000000000306 /* Frameworks */,
A1B2C3D4E5F6000000000305 /* Resources */,
);
buildRules = (
);
dependencies = (
A1B2C3D4E5F6000000000402 /* PBXTargetDependency */,
);
name = ShikakuTests;
productName = ShikakuTests;
productReference = A1B2C3D4E5F6000000000101 /* ShikakuTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A1B2C3D4E5F6000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
A1B2C3D4E5F6000000000002 = {
CreatedOnToolsVersion = 26.2;
};
A1B2C3D4E5F6000000000003 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = A1B2C3D4E5F6000000000002;
};
};
};
buildConfigurationList = A1B2C3D4E5F6000000000501 /* Build configuration list for PBXProject "Shikaku" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A1B2C3D4E5F6000000000005;
productRefGroup = A1B2C3D4E5F6000000000004 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A1B2C3D4E5F6000000000002 /* Shikaku */,
A1B2C3D4E5F6000000000003 /* ShikakuTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A1B2C3D4E5F6000000000302 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F6000000000208 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3D4E5F6000000000305 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A1B2C3D4E5F6000000000301 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F6000000000201 /* ShikakuApp.swift in Sources */,
A1B2C3D4E5F6000000000202 /* ContentView.swift in Sources */,
A1B2C3D4E5F6000000000203 /* ShikakuModels.swift in Sources */,
A1B2C3D4E5F6000000000204 /* PuzzleGenerator.swift in Sources */,
A1B2C3D4E5F6000000000205 /* ShikakuGame.swift in Sources */,
A1B2C3D4E5F6000000000206 /* ShikakuTheme.swift in Sources */,
A1B2C3D4E5F6000000000207 /* ShikakuGridView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A1B2C3D4E5F6000000000304 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F6000000000209 /* ShikakuCoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A1B2C3D4E5F6000000000402 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1B2C3D4E5F6000000000002 /* Shikaku */;
targetProxy = A1B2C3D4E5F6000000000401 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
A1B2C3D4E5F6000000000601 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
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;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = 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 = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A1B2C3D4E5F6000000000602 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
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";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = 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 = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A1B2C3D4E5F6000000000603 /* 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_ASSET_PATHS = "\"Shikaku/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Shikaku;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.briannelson.Shikaku;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
A1B2C3D4E5F6000000000604 /* 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_ASSET_PATHS = "\"Shikaku/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Shikaku;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.briannelson.Shikaku;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
A1B2C3D4E5F6000000000605 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.briannelson.ShikakuTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Shikaku.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Shikaku";
};
name = Debug;
};
A1B2C3D4E5F6000000000606 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.briannelson.ShikakuTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Shikaku.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Shikaku";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A1B2C3D4E5F6000000000501 /* Build configuration list for PBXProject "Shikaku" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3D4E5F6000000000601 /* Debug */,
A1B2C3D4E5F6000000000602 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A1B2C3D4E5F6000000000502 /* Build configuration list for PBXNativeTarget "Shikaku" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3D4E5F6000000000603 /* Debug */,
A1B2C3D4E5F6000000000604 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A1B2C3D4E5F6000000000503 /* Build configuration list for PBXNativeTarget "ShikakuTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A1B2C3D4E5F6000000000605 /* Debug */,
A1B2C3D4E5F6000000000606 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A1B2C3D4E5F6000000000001 /* Project object */;
}

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1B2C3D4E5F6000000000002"
BuildableName = "Shikaku.app"
BlueprintName = "Shikaku"
ReferencedContainer = "container:Shikaku.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1B2C3D4E5F6000000000003"
BuildableName = "ShikakuTests.xctest"
BlueprintName = "ShikakuTests"
ReferencedContainer = "container:Shikaku.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1B2C3D4E5F6000000000002"
BuildableName = "Shikaku.app"
BlueprintName = "Shikaku"
ReferencedContainer = "container:Shikaku.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A1B2C3D4E5F6000000000002"
BuildableName = "Shikaku.app"
BlueprintName = "Shikaku"
ReferencedContainer = "container:Shikaku.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,327 @@
import SwiftUI
struct ContentView: View {
@StateObject private var game = ShikakuGame(size: 5)
@State private var mode: DrawingMode = .draw
@State private var zoom: CGFloat = 1.0
@State private var pinchStartZoom: CGFloat?
@State private var isShowingSeedSheet = false
@State private var seedInput = ""
@State private var seedError: String?
private let zoomMinimum: CGFloat = 0.5
private let zoomMaximum: CGFloat = 3.0
private let zoomStep: CGFloat = 0.15
var body: some View {
VStack(spacing: 12) {
header
sizePicker
modePicker
boardViewport
zoomControls
controls
statusText
}
.padding(.top, 18)
.padding(.bottom, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ShikakuTheme.background.ignoresSafeArea())
.sheet(isPresented: $isShowingSeedSheet) {
seedSheet
}
}
private var header: some View {
HStack {
Text("SHIKAKU")
.font(.system(size: 24, weight: .black, design: .rounded))
.foregroundStyle(ShikakuTheme.primaryText)
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(game.formattedElapsed(at: timeline.date))
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.foregroundStyle(ShikakuTheme.mutedText)
.monospacedDigit()
}
}
.padding(.horizontal, 20)
}
private var sizePicker: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(ShikakuGame.supportedSizes, id: \.self) { size in
Button {
guard size != game.size else {
return
}
game.newPuzzle(size: size)
mode = .draw
resetZoom()
} label: {
Text("\(size)x\(size)")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.frame(minWidth: 56)
}
.buttonStyle(.bordered)
.tint(size == game.size ? ShikakuTheme.accent : ShikakuTheme.mutedText)
}
}
.padding(.horizontal, 20)
}
}
private var modePicker: some View {
Picker("Mode", selection: $mode) {
ForEach(DrawingMode.allCases) { mode in
Label(mode.title, systemImage: mode.symbolName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
}
private var boardViewport: some View {
GeometryReader { proxy in
let currentBoardSide = boardSide
let contentWidth = max(currentBoardSide + 32, proxy.size.width)
let contentHeight = max(currentBoardSide + 32, proxy.size.height)
ScrollView([.horizontal, .vertical]) {
ZStack {
ShikakuGridView(game: game, mode: mode, side: currentBoardSide)
}
.frame(width: contentWidth, height: contentHeight, alignment: .center)
}
.scrollIndicators(.visible)
.background(ShikakuTheme.panel)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.simultaneousGesture(pinchZoomGesture)
}
.padding(.horizontal, 12)
.frame(maxHeight: .infinity)
}
private var zoomControls: some View {
HStack(spacing: 12) {
Button {
updateZoom(by: -zoomStep)
} label: {
Image(systemName: "minus.magnifyingglass")
.frame(width: 34, height: 34)
}
.buttonStyle(.bordered)
.disabled(zoom <= zoomMinimum)
Text("\(Int((zoom * 100).rounded()))%")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundStyle(ShikakuTheme.mutedText)
.monospacedDigit()
.frame(width: 58)
Button {
updateZoom(by: zoomStep)
} label: {
Image(systemName: "plus.magnifyingglass")
.frame(width: 34, height: 34)
}
.buttonStyle(.bordered)
.disabled(zoom >= zoomMaximum)
Button {
resetZoom()
} label: {
Label("100%", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.tint(ShikakuTheme.mutedText)
}
.font(.system(size: 14, weight: .semibold, design: .rounded))
.padding(.horizontal, 20)
}
private var controls: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
Button {
game.newPuzzle()
mode = .draw
} label: {
Label("New", systemImage: "arrow.clockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(ShikakuTheme.accent)
Button {
game.checkSolution()
} label: {
Label("Check", systemImage: "checkmark.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(ShikakuTheme.primaryText)
Button(role: .destructive) {
game.clear()
mode = .draw
} label: {
Label("Clear", systemImage: "trash")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
seedInput = ""
seedError = nil
isShowingSeedSheet = true
} label: {
Label("Seed", systemImage: "number")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(ShikakuTheme.primaryText)
}
.font(.system(size: 15, weight: .semibold, design: .rounded))
.padding(.horizontal, 20)
}
private var statusText: some View {
Text(game.message)
.font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundStyle(statusColor)
.frame(height: 20)
.lineLimit(1)
.minimumScaleFactor(0.75)
.padding(.horizontal, 20)
}
private var statusColor: Color {
switch game.status {
case .playing:
return ShikakuTheme.mutedText
case .solved:
return ShikakuTheme.success
case .error:
return ShikakuTheme.error
}
}
private var seedSheet: some View {
VStack(alignment: .leading, spacing: 16) {
Text("CURRENT SEED")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(ShikakuTheme.mutedText)
Text("\(game.currentSeed)")
.font(.system(size: 22, weight: .bold, design: .monospaced))
.foregroundStyle(ShikakuTheme.primaryText)
.textSelection(.enabled)
Divider()
TextField("e.g. 123456789", text: $seedInput)
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.keyboardType(.numbersAndPunctuation)
.submitLabel(.go)
.onSubmit(startPuzzleFromSeed)
.textFieldStyle(.roundedBorder)
if let seedError {
Text(seedError)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(ShikakuTheme.error)
}
Spacer(minLength: 0)
HStack {
Button("Cancel") {
isShowingSeedSheet = false
}
.buttonStyle(.bordered)
Spacer()
Button {
startPuzzleFromSeed()
} label: {
Label("Start Puzzle", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.tint(ShikakuTheme.accent)
}
}
.padding(24)
.presentationDetents([.height(320)])
.presentationDragIndicator(.visible)
.background(ShikakuTheme.background)
}
private var boardSide: CGFloat {
CGFloat(game.size) * baseCellSide(for: game.size) * zoom
}
private var pinchZoomGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
if pinchStartZoom == nil {
pinchStartZoom = zoom
}
zoom = clampedZoom((pinchStartZoom ?? zoom) * value)
}
.onEnded { _ in
pinchStartZoom = nil
}
}
private func baseCellSide(for size: Int) -> CGFloat {
switch size {
case 5:
return 56
case 7:
return 52
case 10:
return 46
case 15:
return 36
case 20:
return 28
case 25:
return 24
default:
return 40
}
}
private func updateZoom(by delta: CGFloat) {
zoom = clampedZoom(zoom + delta)
}
private func resetZoom() {
zoom = 1.0
pinchStartZoom = nil
}
private func clampedZoom(_ value: CGFloat) -> CGFloat {
min(zoomMaximum, max(zoomMinimum, value))
}
private func startPuzzleFromSeed() {
let trimmed = seedInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard let seed = UInt64(trimmed) else {
seedError = "Enter a whole-number seed."
return
}
game.newPuzzle(seed: seed)
mode = .draw
resetZoom()
isShowingSeedSheet = false
}
}

View File

@@ -0,0 +1,232 @@
import Foundation
enum PuzzleGenerationError: Error, LocalizedError {
case unsupportedSize(Int)
case failed(size: Int, attempts: Int)
var errorDescription: String? {
switch self {
case .unsupportedSize(let size):
return "Grid size \(size) is not supported."
case .failed(let size, let attempts):
return "Puzzle generation failed for \(size)x\(size) after \(attempts) attempts."
}
}
}
struct SeededRandomNumberGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
self.state = seed == 0 ? 0x9E3779B97F4A7C15 : seed
}
mutating func next() -> UInt64 {
state &+= 0x9E3779B97F4A7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
return z ^ (z >> 31)
}
mutating func randomDouble() -> Double {
let value = next() >> 11
return Double(value) / Double(1 << 53)
}
mutating func weightedLowIndex(count: Int) -> Int {
guard count > 1 else {
return 0
}
// The Python version sampled a beta distribution to favor smaller rectangles.
// This power curve keeps the same bias without pulling in a statistics library.
let fraction = pow(randomDouble(), 2.2)
return min(Int(fraction * Double(count)), count - 1)
}
}
enum ShikakuGenerator {
static let maximumAttempts = 200
private static let maximumRandomSeed: UInt64 = 2_147_483_648
static func generate(size: Int, seed requestedSeed: UInt64? = nil) throws -> ShikakuPuzzle {
guard size > 1 else {
throw PuzzleGenerationError.unsupportedSize(size)
}
let seed = requestedSeed ?? UInt64.random(in: 0...maximumRandomSeed)
var rng = SeededRandomNumberGenerator(seed: seed)
for _ in 0..<maximumAttempts {
if let puzzle = attempt(size: size, seed: seed, rng: &rng) {
return puzzle
}
}
throw PuzzleGenerationError.failed(size: size, attempts: maximumAttempts)
}
private static func attempt(size: Int, seed: UInt64, rng: inout SeededRandomNumberGenerator) -> ShikakuPuzzle? {
var grid = Array(repeating: Array(repeating: -1, count: size), count: size)
var rects: [SolutionRect] = []
var uncovered = (0..<size).flatMap { row in
(0..<size).map { col in GridPoint(row: row, col: col) }
}
uncovered.shuffle(using: &rng)
while !uncovered.isEmpty {
let start = uncovered[0]
if grid[start.row][start.col] != -1 {
uncovered.removeFirst()
continue
}
var candidates: [SolutionRect] = []
for height in 1...(size - start.row) {
for width in 1...(size - start.col) {
if height == 1 && width == 1 {
continue
}
for rowOffset in 0..<height {
for colOffset in 0..<width {
let row0 = start.row - rowOffset
let col0 = start.col - colOffset
let row1 = row0 + height - 1
let col1 = col0 + width - 1
guard row0 >= 0, col0 >= 0, row1 < size, col1 < size else {
continue
}
if rectangleIsUncovered(row0: row0, col0: col0, row1: row1, col1: col1, grid: grid) {
candidates.append(SolutionRect(row: row0, col: col0, height: height, width: width))
}
}
}
}
}
if candidates.isEmpty {
guard absorbIsolatedCell(start, size: size, grid: &grid, rects: &rects, uncovered: &uncovered, rng: &rng) else {
return nil
}
continue
}
let keyedCandidates = candidates.map { rect in
(rect: rect, tieBreak: rng.randomDouble())
}
let sortedCandidates = keyedCandidates
.sorted { lhs, rhs in
if lhs.rect.area == rhs.rect.area {
return lhs.tieBreak < rhs.tieBreak
}
return lhs.rect.area < rhs.rect.area
}
.map(\.rect)
let chosen = sortedCandidates[rng.weightedLowIndex(count: sortedCandidates.count)]
let rectID = rects.count
for cell in chosen.cells {
grid[cell.row][cell.col] = rectID
}
rects.append(chosen)
uncovered.removeAll { grid[$0.row][$0.col] != -1 }
}
guard (0..<size).allSatisfy({ row in (0..<size).allSatisfy { grid[row][$0] != -1 } }) else {
return nil
}
var clues: [GridPoint: Int] = [:]
for rect in rects {
guard let clueCell = rect.cells.randomElement(using: &rng) else {
return nil
}
clues[clueCell] = rect.area
}
return ShikakuPuzzle(size: size, clues: clues, solution: rects, seed: seed)
}
private static func rectangleIsUncovered(
row0: Int,
col0: Int,
row1: Int,
col1: Int,
grid: [[Int]]
) -> Bool {
for row in row0...row1 {
for col in col0...col1 where grid[row][col] != -1 {
return false
}
}
return true
}
private static func absorbIsolatedCell(
_ cell: GridPoint,
size: Int,
grid: inout [[Int]],
rects: inout [SolutionRect],
uncovered: inout [GridPoint],
rng: inout SeededRandomNumberGenerator
) -> Bool {
var neighbors = [
GridPoint(row: cell.row - 1, col: cell.col),
GridPoint(row: cell.row + 1, col: cell.col),
GridPoint(row: cell.row, col: cell.col - 1),
GridPoint(row: cell.row, col: cell.col + 1)
]
neighbors.shuffle(using: &rng)
for neighbor in neighbors {
guard (0..<size).contains(neighbor.row),
(0..<size).contains(neighbor.col),
grid[neighbor.row][neighbor.col] != -1 else {
continue
}
let rectID = grid[neighbor.row][neighbor.col]
let old = rects[rectID]
let oldBounds = old.bounds
let newRow0 = min(oldBounds.startRow, cell.row)
let newCol0 = min(oldBounds.startCol, cell.col)
let newRow1 = max(oldBounds.endRow, cell.row)
let newCol1 = max(oldBounds.endCol, cell.col)
let expandedCells = CellBounds(
startRow: newRow0,
startCol: newCol0,
endRow: newRow1,
endCol: newCol1
).cells
guard expandedCells.allSatisfy({ grid[$0.row][$0.col] == -1 || grid[$0.row][$0.col] == rectID }) else {
continue
}
for expandedCell in expandedCells {
grid[expandedCell.row][expandedCell.col] = rectID
}
rects[rectID] = SolutionRect(
row: newRow0,
col: newCol0,
height: newRow1 - newRow0 + 1,
width: newCol1 - newCol0 + 1
)
uncovered.removeAll { grid[$0.row][$0.col] != -1 }
return true
}
return false
}
}

View File

@@ -0,0 +1,154 @@
import Foundation
enum GameStatus: Equatable {
case playing
case solved
case error
}
@MainActor
final class ShikakuGame: ObservableObject {
static let supportedSizes = [5, 7, 10, 15, 20, 25]
@Published private(set) var puzzle: ShikakuPuzzle
@Published private(set) var playerRects: [PlayerRect] = []
@Published private(set) var errorRectIDs: Set<PlayerRect.ID> = []
@Published private(set) var message = "Ready"
@Published private(set) var status: GameStatus = .playing
private var startDate = Date()
private var solvedElapsed: TimeInterval?
var size: Int {
puzzle.size
}
var currentSeed: UInt64 {
puzzle.seed
}
init(size: Int = 5) {
self.puzzle = Self.makePuzzle(size: size)
self.startDate = Date()
}
func newPuzzle(size requestedSize: Int? = nil, seed requestedSeed: UInt64? = nil) {
let nextSize = requestedSize ?? size
puzzle = Self.makePuzzle(size: nextSize, seed: requestedSeed)
playerRects = []
errorRectIDs = []
message = "Ready"
status = .playing
startDate = Date()
solvedElapsed = nil
}
func clear() {
playerRects = []
errorRectIDs = []
message = "Cleared"
status = .playing
if let solvedElapsed {
startDate = Date().addingTimeInterval(-solvedElapsed)
}
solvedElapsed = nil
}
func placeRect(_ rect: PlayerRect) {
guard rect.area > 1 else {
errorRectIDs = []
status = .playing
message = "Drag over at least 2 cells"
return
}
let newCells = Set(rect.cells)
playerRects.removeAll { !Set($0.cells).isDisjoint(with: newCells) }
playerRects.append(rect)
errorRectIDs = []
status = .playing
runAutoCheck()
}
func removeRect(at cell: GridPoint) {
let oldCount = playerRects.count
playerRects.removeAll { $0.contains(cell) }
if playerRects.count != oldCount {
errorRectIDs = []
status = .playing
message = "Ready"
solvedElapsed = nil
}
}
func checkSolution() {
let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues)
if result.isSolved {
markSolved()
errorRectIDs = []
message = "Solved in \(formattedElapsed())"
} else {
status = .error
errorRectIDs = invalidRectIDs()
message = result.message
}
}
func formattedElapsed(at date: Date = Date()) -> String {
let elapsed = solvedElapsed ?? max(0, date.timeIntervalSince(startDate))
let minutes = Int(elapsed) / 60
let seconds = Int(elapsed) % 60
return String(format: "%d:%02d", minutes, seconds)
}
private func runAutoCheck() {
guard status != .solved else {
return
}
let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues)
if result.isSolved {
markSolved()
message = "Solved in \(formattedElapsed())"
} else {
status = .playing
message = "Ready"
}
}
private func markSolved() {
if solvedElapsed == nil {
solvedElapsed = Date().timeIntervalSince(startDate)
}
status = .solved
}
private func invalidRectIDs() -> Set<PlayerRect.ID> {
Set(playerRects.compactMap { rect in
let clueValues = rect.cells.compactMap { puzzle.clues[$0] }
if clueValues.count != 1 || clueValues.first != rect.area {
return rect.id
}
return nil
})
}
private static func makePuzzle(size: Int, seed requestedSeed: UInt64? = nil) -> ShikakuPuzzle {
do {
return try ShikakuGenerator.generate(size: size, seed: requestedSeed)
} catch {
assertionFailure("Falling back after puzzle generation error: \(error)")
return fallbackPuzzle(size: size)
}
}
private static func fallbackPuzzle(size: Int) -> ShikakuPuzzle {
let rect = SolutionRect(row: 0, col: 0, height: size, width: size)
let clue = GridPoint(row: size / 2, col: size / 2)
return ShikakuPuzzle(size: size, clues: [clue: rect.area], solution: [rect], seed: 0)
}
}

View File

@@ -0,0 +1,152 @@
import Foundation
struct GridPoint: Hashable, Sendable {
let row: Int
let col: Int
}
struct CellBounds: Hashable, Sendable {
let startRow: Int
let startCol: Int
let endRow: Int
let endCol: Int
var height: Int {
endRow - startRow + 1
}
var width: Int {
endCol - startCol + 1
}
var area: Int {
height * width
}
func contains(_ point: GridPoint) -> Bool {
startRow <= point.row && point.row <= endRow && startCol <= point.col && point.col <= endCol
}
var cells: [GridPoint] {
var result: [GridPoint] = []
result.reserveCapacity(area)
for row in startRow...endRow {
for col in startCol...endCol {
result.append(GridPoint(row: row, col: col))
}
}
return result
}
}
struct SolutionRect: Hashable, Sendable {
let row: Int
let col: Int
let height: Int
let width: Int
var area: Int {
height * width
}
var bounds: CellBounds {
CellBounds(startRow: row, startCol: col, endRow: row + height - 1, endCol: col + width - 1)
}
var cells: [GridPoint] {
bounds.cells
}
}
struct PlayerRect: Identifiable, Hashable, Sendable {
let id: UUID
let startRow: Int
let startCol: Int
let endRow: Int
let endCol: Int
init(id: UUID = UUID(), startRow: Int, startCol: Int, endRow: Int, endCol: Int) {
self.id = id
self.startRow = startRow
self.startCol = startCol
self.endRow = endRow
self.endCol = endCol
}
var bounds: CellBounds {
CellBounds(
startRow: min(startRow, endRow),
startCol: min(startCol, endCol),
endRow: max(startRow, endRow),
endCol: max(startCol, endCol)
)
}
var area: Int {
bounds.area
}
var cells: [GridPoint] {
bounds.cells
}
func contains(_ point: GridPoint) -> Bool {
bounds.contains(point)
}
}
struct ShikakuPuzzle: Sendable {
let size: Int
let clues: [GridPoint: Int]
let solution: [SolutionRect]
let seed: UInt64
}
struct ValidationResult: Equatable, Sendable {
let isSolved: Bool
let message: String
}
enum ShikakuValidation {
static func verify(size: Int, playerRects: [PlayerRect], clues: [GridPoint: Int]) -> ValidationResult {
var coverage: [GridPoint: Int] = [:]
for (index, rect) in playerRects.enumerated() {
for cell in rect.cells {
guard (0..<size).contains(cell.row), (0..<size).contains(cell.col) else {
return ValidationResult(isSolved: false, message: "A rectangle extends outside the grid")
}
if coverage[cell] != nil {
return ValidationResult(isSolved: false, message: "Cells overlap between two rectangles")
}
coverage[cell] = index
}
}
let totalCells = size * size
if coverage.count != totalCells {
return ValidationResult(isSolved: false, message: "Not all cells are covered (\(coverage.count)/\(totalCells))")
}
for rect in playerRects {
let clueValues = rect.cells.compactMap { clues[$0] }
if clueValues.isEmpty {
return ValidationResult(isSolved: false, message: "A rectangle contains no clue number")
}
if clueValues.count > 1 {
return ValidationResult(isSolved: false, message: "A rectangle contains more than one clue number")
}
let clueValue = clueValues[0]
if rect.area != clueValue {
return ValidationResult(isSolved: false, message: "A rectangle contains \(clueValue) but has \(rect.area) cells")
}
}
return ValidationResult(isSolved: true, message: "Solved!")
}
}

View File

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

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct ShikakuApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,209 @@
import SwiftUI
enum DrawingMode: String, CaseIterable, Identifiable {
case draw
case erase
var id: String {
rawValue
}
var title: String {
switch self {
case .draw:
return "Draw"
case .erase:
return "Erase"
}
}
var symbolName: String {
switch self {
case .draw:
return "rectangle"
case .erase:
return "eraser"
}
}
}
struct ShikakuGridView: View {
@ObservedObject var game: ShikakuGame
let mode: DrawingMode
let side: CGFloat
@State private var dragStart: GridPoint?
@State private var dragEnd: GridPoint?
@State private var lastErasedCell: GridPoint?
var body: some View {
Canvas { context, _ in
drawBoard(in: CGRect(x: 0, y: 0, width: side, height: side), context: &context)
}
.frame(width: side, height: side)
.contentShape(Rectangle())
.gesture(boardGesture(side: side))
.accessibilityLabel("Shikaku grid")
.accessibilityValue("\(game.size) by \(game.size)")
}
private func boardGesture(side: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let cell = cell(at: value.location, side: side) else {
return
}
switch mode {
case .draw:
if dragStart == nil {
dragStart = cell
}
dragEnd = cell
case .erase:
guard lastErasedCell != cell else {
return
}
game.removeRect(at: cell)
lastErasedCell = cell
dragStart = nil
dragEnd = nil
}
}
.onEnded { value in
defer {
dragStart = nil
dragEnd = nil
lastErasedCell = nil
}
guard mode == .draw,
let start = dragStart,
let end = cell(at: value.location, side: side) ?? dragEnd else {
return
}
game.placeRect(
PlayerRect(
startRow: start.row,
startCol: start.col,
endRow: end.row,
endCol: end.col
)
)
}
}
private func drawBoard(in rect: CGRect, context: inout GraphicsContext) {
let cellSide = rect.width / CGFloat(game.size)
let boardRect = CGRect(origin: .zero, size: rect.size)
context.fill(Path(boardRect), with: .color(ShikakuTheme.paper))
for (index, playerRect) in game.playerRects.enumerated() {
draw(playerRect, index: index, cellSide: cellSide, context: &context)
}
if let start = dragStart, let end = dragEnd, mode == .draw {
let preview = PlayerRect(startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col)
drawPreview(preview, cellSide: cellSide, context: &context)
}
drawGridLines(cellSide: cellSide, boardRect: boardRect, context: &context)
drawClues(cellSide: cellSide, context: &context)
}
private func draw(_ rect: PlayerRect, index: Int, cellSide: CGFloat, context: inout GraphicsContext) {
let pathRect = cgRect(for: rect.bounds, cellSide: cellSide).insetBy(dx: 2, dy: 2)
let path = Path(roundedRect: pathRect, cornerRadius: 6)
let hasError = game.errorRectIDs.contains(rect.id)
if hasError {
context.fill(path, with: .color(ShikakuTheme.errorFill))
context.stroke(path, with: .color(ShikakuTheme.errorStroke), lineWidth: 2.5)
} else {
let colorIndex = index % ShikakuTheme.rectFills.count
context.fill(path, with: .color(ShikakuTheme.rectFills[colorIndex]))
context.stroke(path, with: .color(ShikakuTheme.rectStrokes[colorIndex]), lineWidth: 2.5)
}
}
private func drawPreview(_ rect: PlayerRect, cellSide: CGFloat, context: inout GraphicsContext) {
let pathRect = cgRect(for: rect.bounds, cellSide: cellSide).insetBy(dx: 2, dy: 2)
let path = Path(roundedRect: pathRect, cornerRadius: 6)
let isInvalid = rect.area == 1
context.fill(path, with: .color(isInvalid ? ShikakuTheme.errorFill : ShikakuTheme.dragFill))
context.stroke(path, with: .color(isInvalid ? ShikakuTheme.errorStroke : ShikakuTheme.dragStroke), lineWidth: 2)
let fontSize = max(10, min(26, cellSide * 0.38))
let center = CGPoint(x: pathRect.midX, y: pathRect.midY)
let label = Text("\(rect.area)")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
context.draw(
label.foregroundStyle(Color.black.opacity(0.55)),
at: CGPoint(x: center.x + 1, y: center.y + 1),
anchor: .center
)
context.draw(
label.foregroundStyle(Color.white.opacity(0.95)),
at: center,
anchor: .center
)
}
private func drawGridLines(cellSide: CGFloat, boardRect: CGRect, context: inout GraphicsContext) {
var gridPath = Path()
for index in 0...game.size {
let offset = CGFloat(index) * cellSide
gridPath.move(to: CGPoint(x: offset, y: 0))
gridPath.addLine(to: CGPoint(x: offset, y: boardRect.maxY))
gridPath.move(to: CGPoint(x: 0, y: offset))
gridPath.addLine(to: CGPoint(x: boardRect.maxX, y: offset))
}
context.stroke(gridPath, with: .color(ShikakuTheme.gridLine), lineWidth: 0.8)
context.stroke(Path(boardRect), with: .color(ShikakuTheme.gridBorder), lineWidth: 3)
}
private func drawClues(cellSide: CGFloat, context: inout GraphicsContext) {
let fontSize = max(14, min(24, cellSide * 0.36))
for (cell, value) in game.puzzle.clues {
let point = CGPoint(
x: (CGFloat(cell.col) + 0.5) * cellSide,
y: (CGFloat(cell.row) + 0.5) * cellSide
)
let text = Text("\(value)")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(ShikakuTheme.clue)
context.draw(text, at: point, anchor: .center)
}
}
private func cgRect(for bounds: CellBounds, cellSide: CGFloat) -> CGRect {
CGRect(
x: CGFloat(bounds.startCol) * cellSide,
y: CGFloat(bounds.startRow) * cellSide,
width: CGFloat(bounds.width) * cellSide,
height: CGFloat(bounds.height) * cellSide
)
}
private func cell(at location: CGPoint, side: CGFloat) -> GridPoint? {
guard location.x >= 0,
location.y >= 0,
location.x <= side,
location.y <= side else {
return nil
}
let cellSide = side / CGFloat(game.size)
let row = min(Int(location.y / cellSide), game.size - 1)
let col = min(Int(location.x / cellSide), game.size - 1)
return GridPoint(row: row, col: col)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
enum ShikakuTheme {
static let background = Color(red: 0.11, green: 0.10, blue: 0.13)
static let panel = Color(red: 0.16, green: 0.15, blue: 0.18)
static let paper = Color(red: 0.95, green: 0.93, blue: 0.88)
static let gridLine = Color(red: 0.55, green: 0.52, blue: 0.48)
static let gridBorder = Color(red: 0.20, green: 0.18, blue: 0.22)
static let clue = Color(red: 0.10, green: 0.10, blue: 0.12)
static let mutedText = Color(red: 0.67, green: 0.62, blue: 0.56)
static let primaryText = Color(red: 0.91, green: 0.89, blue: 0.84)
static let success = Color(red: 0.34, green: 0.78, blue: 0.48)
static let error = Color(red: 0.88, green: 0.28, blue: 0.28)
static let accent = Color(red: 0.49, green: 0.38, blue: 0.72)
static let dragFill = Color(red: 0.50, green: 0.70, blue: 0.95).opacity(0.30)
static let dragStroke = Color(red: 0.30, green: 0.55, blue: 0.90).opacity(0.90)
static let errorFill = Color(red: 0.90, green: 0.25, blue: 0.25).opacity(0.30)
static let errorStroke = Color(red: 0.80, green: 0.10, blue: 0.10).opacity(0.90)
static let rectFills: [Color] = [
Color(red: 0.36, green: 0.61, blue: 0.84).opacity(0.45),
Color(red: 0.42, green: 0.78, blue: 0.60).opacity(0.45),
Color(red: 0.90, green: 0.55, blue: 0.35).opacity(0.45),
Color(red: 0.76, green: 0.45, blue: 0.82).opacity(0.45),
Color(red: 0.88, green: 0.76, blue: 0.30).opacity(0.45),
Color(red: 0.45, green: 0.82, blue: 0.85).opacity(0.45),
Color(red: 0.90, green: 0.65, blue: 0.80).opacity(0.45),
Color(red: 0.55, green: 0.72, blue: 0.35).opacity(0.45)
]
static let rectStrokes: [Color] = [
Color(red: 0.20, green: 0.42, blue: 0.70),
Color(red: 0.22, green: 0.60, blue: 0.40),
Color(red: 0.75, green: 0.35, blue: 0.12),
Color(red: 0.56, green: 0.25, blue: 0.65),
Color(red: 0.70, green: 0.58, blue: 0.08),
Color(red: 0.22, green: 0.62, blue: 0.68),
Color(red: 0.80, green: 0.45, blue: 0.65),
Color(red: 0.35, green: 0.54, blue: 0.18)
]
}

View File

@@ -0,0 +1,124 @@
import XCTest
@testable import Shikaku
final class ShikakuCoreTests: XCTestCase {
func testGeneratedPuzzlesAreCoveredAndValidated() throws {
let cases: [(size: Int, seeds: ClosedRange<UInt64>)] = [
(5, 1...20),
(7, 1...20),
(10, 1...5),
(15, 1...5),
(20, 1...2),
(25, 1...2)
]
for testCase in cases {
for seed in testCase.seeds {
let size = testCase.size
let puzzle = try ShikakuGenerator.generate(size: size, seed: seed)
var covered: Set<GridPoint> = []
XCTAssertEqual(puzzle.size, size)
XCTAssertEqual(puzzle.clues.count, puzzle.solution.count)
for rect in puzzle.solution {
XCTAssertGreaterThanOrEqual(rect.area, 2)
for cell in rect.cells {
XCTAssertTrue((0..<size).contains(cell.row))
XCTAssertTrue((0..<size).contains(cell.col))
XCTAssertTrue(covered.insert(cell).inserted, "Cell \(cell) was covered more than once")
}
let cluesInside = rect.cells.compactMap { puzzle.clues[$0] }
XCTAssertEqual(cluesInside, [rect.area])
}
XCTAssertEqual(covered.count, size * size)
let playerRects = puzzle.solution.map {
PlayerRect(
startRow: $0.row,
startCol: $0.col,
endRow: $0.row + $0.height - 1,
endCol: $0.col + $0.width - 1
)
}
let validation = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues)
XCTAssertTrue(validation.isSolved)
}
}
}
func testSeededGenerationIsRepeatable() throws {
let first = try ShikakuGenerator.generate(size: 10, seed: 123_456_789)
let second = try ShikakuGenerator.generate(size: 10, seed: 123_456_789)
XCTAssertEqual(first.seed, second.seed)
XCTAssertEqual(first.clues, second.clues)
XCTAssertEqual(first.solution, second.solution)
}
func testValidationRejectsOverlappingRectangles() {
let clues = [
GridPoint(row: 0, col: 0): 2,
GridPoint(row: 1, col: 1): 2
]
let playerRects = [
PlayerRect(startRow: 0, startCol: 0, endRow: 0, endCol: 1),
PlayerRect(startRow: 0, startCol: 1, endRow: 1, endCol: 1)
]
let validation = ShikakuValidation.verify(size: 2, playerRects: playerRects, clues: clues)
XCTAssertFalse(validation.isSolved)
XCTAssertEqual(validation.message, "Cells overlap between two rectangles")
}
@MainActor
func testPlacingARectangleRemovesOverlappingPlayerRects() {
let game = ShikakuGame(size: 5)
game.placeRect(PlayerRect(startRow: 0, startCol: 0, endRow: 0, endCol: 1))
game.placeRect(PlayerRect(startRow: 0, startCol: 1, endRow: 1, endCol: 1))
XCTAssertEqual(game.playerRects.count, 1)
XCTAssertTrue(game.playerRects[0].contains(GridPoint(row: 0, col: 1)))
XCTAssertTrue(game.playerRects[0].contains(GridPoint(row: 1, col: 1)))
}
@MainActor
func testSingleCellPlacementIsRejected() {
let game = ShikakuGame(size: 5)
game.placeRect(PlayerRect(startRow: 0, startCol: 0, endRow: 0, endCol: 0))
XCTAssertTrue(game.playerRects.isEmpty)
XCTAssertEqual(game.message, "Drag over at least 2 cells")
}
@MainActor
func testGameCanStartFromRequestedSeed() {
let game = ShikakuGame(size: 5)
game.newPuzzle(seed: 987_654_321)
XCTAssertEqual(game.currentSeed, 987_654_321)
XCTAssertEqual(game.size, 5)
XCTAssertTrue(game.playerRects.isEmpty)
}
func testValidationAcceptsKnownSolution() {
let clues = [
GridPoint(row: 0, col: 0): 2,
GridPoint(row: 1, col: 0): 2
]
let playerRects = [
PlayerRect(startRow: 0, startCol: 0, endRow: 0, endCol: 1),
PlayerRect(startRow: 1, startCol: 0, endRow: 1, endCol: 1)
]
let validation = ShikakuValidation.verify(size: 2, playerRects: playerRects, clues: clues)
XCTAssertTrue(validation.isSolved)
}
}

1001
reference/shikaku.py Normal file

File diff suppressed because it is too large Load Diff