From 89b2e779f558ad00d48b56a188657e31fae7118f Mon Sep 17 00:00:00 2001 From: Brian Nelson Date: Fri, 1 May 2026 09:21:43 -0400 Subject: [PATCH] working ios game --- ios/README.md | 24 + ios/Shikaku.xcodeproj/project.pbxproj | 493 ++++++++ .../UserInterfaceState.xcuserstate | Bin 0 -> 11413 bytes .../xcshareddata/xcschemes/Shikaku.xcscheme | 89 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + ios/Shikaku/Assets.xcassets/Contents.json | 6 + ios/Shikaku/ContentView.swift | 327 ++++++ ios/Shikaku/Core/PuzzleGenerator.swift | 232 ++++ ios/Shikaku/Core/ShikakuGame.swift | 154 +++ ios/Shikaku/Core/ShikakuModels.swift | 152 +++ .../Preview Assets.xcassets/Contents.json | 6 + ios/Shikaku/ShikakuApp.swift | 10 + ios/Shikaku/Views/ShikakuGridView.swift | 209 ++++ ios/Shikaku/Views/ShikakuTheme.swift | 41 + ios/ShikakuTests/ShikakuCoreTests.swift | 124 ++ reference/shikaku.py | 1001 +++++++++++++++++ 17 files changed, 2892 insertions(+) create mode 100644 ios/README.md create mode 100644 ios/Shikaku.xcodeproj/project.pbxproj create mode 100644 ios/Shikaku.xcodeproj/project.xcworkspace/xcuserdata/briannelson.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 ios/Shikaku.xcodeproj/xcshareddata/xcschemes/Shikaku.xcscheme create mode 100644 ios/Shikaku/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Shikaku/Assets.xcassets/Contents.json create mode 100644 ios/Shikaku/ContentView.swift create mode 100644 ios/Shikaku/Core/PuzzleGenerator.swift create mode 100644 ios/Shikaku/Core/ShikakuGame.swift create mode 100644 ios/Shikaku/Core/ShikakuModels.swift create mode 100644 ios/Shikaku/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 ios/Shikaku/ShikakuApp.swift create mode 100644 ios/Shikaku/Views/ShikakuGridView.swift create mode 100644 ios/Shikaku/Views/ShikakuTheme.swift create mode 100644 ios/ShikakuTests/ShikakuCoreTests.swift create mode 100644 reference/shikaku.py diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..90ee8ce --- /dev/null +++ b/ios/README.md @@ -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. diff --git a/ios/Shikaku.xcodeproj/project.pbxproj b/ios/Shikaku.xcodeproj/project.pbxproj new file mode 100644 index 0000000..cd76714 --- /dev/null +++ b/ios/Shikaku.xcodeproj/project.pbxproj @@ -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 = ""; }; + A1B2C3D4E5F6000000000103 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000104 /* ShikakuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuModels.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000105 /* PuzzleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGenerator.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000106 /* ShikakuGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuGame.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000107 /* ShikakuTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuTheme.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000108 /* ShikakuGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuGridView.swift; sourceTree = ""; }; + A1B2C3D4E5F6000000000109 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A1B2C3D4E5F600000000010A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + A1B2C3D4E5F600000000010B /* ShikakuCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShikakuCoreTests.swift; sourceTree = ""; }; +/* 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 = ""; + }; + A1B2C3D4E5F6000000000005 = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F6000000000006 /* Shikaku */, + A1B2C3D4E5F6000000000009 /* ShikakuTests */, + A1B2C3D4E5F6000000000004 /* Products */, + ); + sourceTree = ""; + }; + A1B2C3D4E5F6000000000006 /* Shikaku */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F6000000000102 /* ShikakuApp.swift */, + A1B2C3D4E5F6000000000103 /* ContentView.swift */, + A1B2C3D4E5F6000000000007 /* Core */, + A1B2C3D4E5F6000000000008 /* Views */, + A1B2C3D4E5F6000000000109 /* Assets.xcassets */, + A1B2C3D4E5F600000000000B /* Preview Content */, + ); + path = Shikaku; + sourceTree = ""; + }; + A1B2C3D4E5F6000000000007 /* Core */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F6000000000104 /* ShikakuModels.swift */, + A1B2C3D4E5F6000000000105 /* PuzzleGenerator.swift */, + A1B2C3D4E5F6000000000106 /* ShikakuGame.swift */, + ); + path = Core; + sourceTree = ""; + }; + A1B2C3D4E5F6000000000008 /* Views */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F6000000000107 /* ShikakuTheme.swift */, + A1B2C3D4E5F6000000000108 /* ShikakuGridView.swift */, + ); + path = Views; + sourceTree = ""; + }; + A1B2C3D4E5F6000000000009 /* ShikakuTests */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F600000000010B /* ShikakuCoreTests.swift */, + ); + path = ShikakuTests; + sourceTree = ""; + }; + A1B2C3D4E5F600000000000B /* Preview Content */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F600000000010A /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/ios/Shikaku.xcodeproj/project.xcworkspace/xcuserdata/briannelson.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Shikaku.xcodeproj/project.xcworkspace/xcuserdata/briannelson.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..4ac6a0e747864ad43089deb9cdec592aface6b4b GIT binary patch literal 11413 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Shikaku/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Shikaku/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/Shikaku/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Shikaku/Assets.xcassets/Contents.json b/ios/Shikaku/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Shikaku/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Shikaku/ContentView.swift b/ios/Shikaku/ContentView.swift new file mode 100644 index 0000000..e3523b3 --- /dev/null +++ b/ios/Shikaku/ContentView.swift @@ -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 + } +} diff --git a/ios/Shikaku/Core/PuzzleGenerator.swift b/ios/Shikaku/Core/PuzzleGenerator.swift new file mode 100644 index 0000000..612387c --- /dev/null +++ b/ios/Shikaku/Core/PuzzleGenerator.swift @@ -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.. ShikakuPuzzle? { + var grid = Array(repeating: Array(repeating: -1, count: size), count: size) + var rects: [SolutionRect] = [] + var uncovered = (0..= 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.. 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.. = [] + @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 { + 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) + } +} diff --git a/ios/Shikaku/Core/ShikakuModels.swift b/ios/Shikaku/Core/ShikakuModels.swift new file mode 100644 index 0000000..1c79072 --- /dev/null +++ b/ios/Shikaku/Core/ShikakuModels.swift @@ -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.. 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!") + } +} diff --git a/ios/Shikaku/Preview Content/Preview Assets.xcassets/Contents.json b/ios/Shikaku/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Shikaku/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Shikaku/ShikakuApp.swift b/ios/Shikaku/ShikakuApp.swift new file mode 100644 index 0000000..54ce193 --- /dev/null +++ b/ios/Shikaku/ShikakuApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct ShikakuApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/Shikaku/Views/ShikakuGridView.swift b/ios/Shikaku/Views/ShikakuGridView.swift new file mode 100644 index 0000000..8ef1c23 --- /dev/null +++ b/ios/Shikaku/Views/ShikakuGridView.swift @@ -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) + } +} diff --git a/ios/Shikaku/Views/ShikakuTheme.swift b/ios/Shikaku/Views/ShikakuTheme.swift new file mode 100644 index 0000000..d5b0542 --- /dev/null +++ b/ios/Shikaku/Views/ShikakuTheme.swift @@ -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) + ] +} diff --git a/ios/ShikakuTests/ShikakuCoreTests.swift b/ios/ShikakuTests/ShikakuCoreTests.swift new file mode 100644 index 0000000..9811bf0 --- /dev/null +++ b/ios/ShikakuTests/ShikakuCoreTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import Shikaku + +final class ShikakuCoreTests: XCTestCase { + func testGeneratedPuzzlesAreCoveredAndValidated() throws { + let cases: [(size: Int, seeds: ClosedRange)] = [ + (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 = [] + + 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.. tuple[dict, list[Rect]]: + """ + Returns (clues, solution_rects). + clues: {(row, col): number} — the numbers shown on the grid + solution_rects: list of Rect — the correct partition + + Algorithm: + - Repeatedly pick an uncovered cell and try to place a random rectangle + that covers it and only uncovered cells. + - Retry with a fresh random state if we get stuck. + """ + rng = random.Random(seed) + + for _attempt in range(200): + grid = [[-1] * size for _ in range(size)] # -1 = uncovered + rects = [] + failed = False + + uncovered = [(r, c) for r in range(size) for c in range(size)] + rng.shuffle(uncovered) + + while uncovered: + # pick first uncovered cell + start_r, start_c = uncovered[0] + if grid[start_r][start_c] != -1: + uncovered.pop(0) + continue + + # gather candidate rectangles that: + # - include (start_r, start_c) + # - fit inside the grid + # - cover only uncovered cells + # - have area >= 2 (single-cell rectangles are not allowed) + candidates = [] + for h in range(1, size - start_r + 1): + for w in range(1, size - start_c + 1): + if h == 1 and w == 1: + continue # minimum area is 2 + # also try rectangles that start before the anchor + for ro in range(0, h): + for co in range(0, w): + r0 = start_r - ro + c0 = start_c - co + r1 = r0 + h - 1 + c1 = c0 + w - 1 + if r0 < 0 or c0 < 0 or r1 >= size or c1 >= size: + continue + if all(grid[r][c] == -1 + for r in range(r0, r1 + 1) + for c in range(c0, c1 + 1)): + candidates.append(Rect(r0, c0, h, w)) + + if not candidates: + # This cell is isolated — no area-2+ rectangle fits. + # Absorb it into an orthogonally adjacent already-placed rectangle + # by expanding that rect to include this cell, if possible. + absorbed = False + neighbours = [ + (start_r - 1, start_c), + (start_r + 1, start_c), + (start_r, start_c - 1), + (start_r, start_c + 1), + ] + rng.shuffle(neighbours) + for nr, nc in neighbours: + if 0 <= nr < size and 0 <= nc < size and grid[nr][nc] != -1: + rid = grid[nr][nc] + old = rects[rid] + # Compute the bounding box of the existing rect + this cell + new_r0 = min(old.row, start_r) + new_c0 = min(old.col, start_c) + new_r1 = max(old.row + old.height - 1, start_r) + new_c1 = max(old.col + old.width - 1, start_c) + new_h = new_r1 - new_r0 + 1 + new_w = new_c1 - new_c0 + 1 + # Only absorb if the expanded bounding box covers only + # cells already owned by this rect or the isolated cell + expanded_cells = [ + (r, c) + for r in range(new_r0, new_r1 + 1) + for c in range(new_c0, new_c1 + 1) + ] + if all(grid[r][c] in (-1, rid) for r, c in expanded_cells): + # Re-assign all expanded cells to this rect + for r, c in expanded_cells: + grid[r][c] = rid + rects[rid] = Rect(new_r0, new_c0, new_h, new_w) + uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1] + absorbed = True + break + if not absorbed: + failed = True + break + continue # cell was absorbed into a neighbour, skip candidate placement + + # Prefer smaller rectangles (keeps the puzzle interesting) + candidates.sort(key=lambda rect: (rect.area, rng.random())) + # pick from smaller end with some randomness + idx = min(int(rng.betavariate(1.2, 3.0) * len(candidates)), len(candidates) - 1) + chosen = candidates[idx] + + rect_id = len(rects) + for r, c in chosen.cells(): + grid[r][c] = rect_id + rects.append(chosen) + uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1] + + if not failed and all(grid[r][c] != -1 for r in range(size) for c in range(size)): + # Place clue numbers — one per rectangle, at a random interior cell + clues = {} + for rect in rects: + cells = list(rect.cells()) + cr, cc = rng.choice(cells) + clues[(cr, cc)] = rect.area + return clues, rects + + raise RuntimeError("Puzzle generation failed after many attempts") + + +def verify_solution(size: int, player_rects: list[PlayerRect], clues: dict) -> tuple[bool, str]: + """Check whether the player's rectangles constitute a valid solution.""" + # Each cell must be covered exactly once + coverage = {} + for i, pr in enumerate(player_rects): + for cell in pr.cells(): + if cell in coverage: + return False, f"Cells overlap between two of your rectangles" + coverage[cell] = i + + total = size * size + if len(coverage) != total: + return False, f"Not all cells are covered ({len(coverage)}/{total})" + + # Each rectangle must contain exactly one clue and match its number + for i, pr in enumerate(player_rects): + cells = set(pr.cells()) + clues_inside = {cell: clues[cell] for cell in cells if cell in clues} + if len(clues_inside) == 0: + return False, "One of your rectangles contains no clue number" + if len(clues_inside) > 1: + return False, "One of your rectangles contains more than one clue number" + clue_val = next(iter(clues_inside.values())) + if pr.area() != clue_val: + return False, f"A rectangle contains {clue_val} but has {pr.area()} cells" + + return True, "Solved!" + + +# ─── GTK Application ────────────────────────────────────────────────────────── + +PADDING = 32 + +# Base cell sizes per grid — zoom multiplier is applied on top of these +BASE_CELL_SIZES = { + 5: 56, + 7: 52, + 10: 46, + 15: 36, + 20: 28, + 25: 24, +} +BASE_FONT_SIZES = { + 5: 14, + 7: 13, + 10: 12, + 15: 10, + 20: 8, + 25: 7, +} + +ZOOM_MIN = 0.5 +ZOOM_MAX = 3.0 +ZOOM_STEP = 0.15 + +def cell_size(grid_size: int, zoom: float = 1.0) -> int: + return max(8, int(BASE_CELL_SIZES.get(grid_size, 40) * zoom)) + +def font_size(grid_size: int, zoom: float = 1.0) -> int: + return max(5, int(BASE_FONT_SIZES.get(grid_size, 10) * zoom)) + +# Approximate height of window chrome (toolbar + size bar + bottom bar + status) +CHROME_HEIGHT = 160 + +# Palette — deep ink & paper feel +BG_COLOR = (0.11, 0.10, 0.13, 1.0) # near-black background +PAPER_COLOR = (0.95, 0.93, 0.88, 1.0) # warm off-white grid +GRID_LINE = (0.55, 0.52, 0.48, 1.0) # warm grey lines +GRID_LINE_BOLD = (0.20, 0.18, 0.22, 1.0) # border +CLUE_COLOR = (0.10, 0.10, 0.12, 1.0) # near black text +RECT_COLORS = [ + (0.36, 0.61, 0.84, 0.45), # blue + (0.42, 0.78, 0.60, 0.45), # green + (0.90, 0.55, 0.35, 0.45), # orange + (0.76, 0.45, 0.82, 0.45), # purple + (0.88, 0.76, 0.30, 0.45), # yellow + (0.45, 0.82, 0.85, 0.45), # cyan + (0.90, 0.65, 0.80, 0.45), # rose + (0.55, 0.72, 0.35, 0.45), # lime +] +RECT_BORDER = [ + (0.20, 0.42, 0.70, 1.0), + (0.22, 0.60, 0.40, 1.0), + (0.75, 0.35, 0.12, 1.0), + (0.56, 0.25, 0.65, 1.0), + (0.70, 0.58, 0.08, 1.0), + (0.22, 0.62, 0.68, 1.0), + (0.80, 0.45, 0.65, 1.0), + (0.35, 0.54, 0.18, 1.0), +] +DRAG_COLOR = (0.50, 0.70, 0.95, 0.30) +DRAG_BORDER = (0.30, 0.55, 0.90, 0.90) +ERROR_COLOR = (0.90, 0.25, 0.25, 0.30) +ERROR_BORDER = (0.80, 0.10, 0.10, 0.90) + + +class ShikakuCanvas(Gtk.DrawingArea): + def __init__(self, game): + super().__init__() + self.game = game + self.zoom = 1.0 + self.set_draw_func(self._draw) + self.set_can_focus(True) + + # Drag state + self._drag_start = None + self._drag_end = None + self._drag_preview: Optional[PlayerRect] = None + self._error_rects: set[int] = set() + + # Callback fired after every board change — set by the window + self.on_board_changed = None + # Callback fired when zoom changes — set by the window + self.on_zoom_changed = None + + # Gestures + drag = Gtk.GestureDrag() + drag.connect("drag-begin", self._on_drag_begin) + drag.connect("drag-update", self._on_drag_update) + drag.connect("drag-end", self._on_drag_end) + self.add_controller(drag) + + click = Gtk.GestureClick() + click.set_button(3) # right click + click.connect("pressed", self._on_right_click) + self.add_controller(click) + + # Scroll wheel for zoom + scroll = Gtk.EventControllerScroll() + scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.add_controller(scroll) + + def _cell_at(self, x, y): + cs = cell_size(self.game.size, self.zoom) + col = int((x - PADDING) // cs) + row = int((y - PADDING) // cs) + size = self.game.size + if 0 <= row < size and 0 <= col < size: + return row, col + return None + + def _on_drag_begin(self, gesture, x, y): + cell = self._cell_at(x, y) + if cell: + self._drag_start = cell + self._drag_end = cell + self._drag_preview = PlayerRect(*cell, *cell) + self.queue_draw() + + def _on_drag_update(self, gesture, dx, dy): + if self._drag_start is None: + return + sx, sy = gesture.get_start_point()[1], gesture.get_start_point()[2] + cell = self._cell_at(sx + dx, sy + dy) + if cell: + self._drag_end = cell + sr, sc = self._drag_start + er, ec = cell + self._drag_preview = PlayerRect(sr, sc, er, ec) + self.queue_draw() + + def _on_drag_end(self, gesture, dx, dy): + if self._drag_start and self._drag_end: + sr, sc = self._drag_start + er, ec = self._drag_end + pr = PlayerRect(sr, sc, er, ec) + if pr.area() > 1: # enforce minimum area of 2 + self.game.place_rect(pr) + if self.on_board_changed: + self.on_board_changed() + self._drag_start = None + self._drag_end = None + self._drag_preview = None + self.queue_draw() + + def _on_right_click(self, gesture, n, x, y): + cell = self._cell_at(x, y) + if cell: + self.game.remove_rect_at(*cell) + self.queue_draw() + if self.on_board_changed: + self.on_board_changed() + + def _on_scroll(self, controller, dx, dy): + # Ctrl+scroll or plain scroll both zoom + old_zoom = self.zoom + self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, self.zoom - dy * ZOOM_STEP)) + if self.zoom != old_zoom: + if self.on_zoom_changed: + self.on_zoom_changed() + return True # consume event + + def set_zoom(self, zoom: float): + self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, zoom)) + if self.on_zoom_changed: + self.on_zoom_changed() + + def mark_errors(self, error_indices: set[int]): + self._error_rects = error_indices + self.queue_draw() + + def clear_errors(self): + self._error_rects = set() + self.queue_draw() + + def _draw(self, area, cr, width, height): + size = self.game.size + clues = self.game.clues + player_rects = self.game.player_rects + cs = cell_size(size, self.zoom) + grid_w = size * cs + + # Background + cr.set_source_rgba(*BG_COLOR) + cr.paint() + + # Grid paper + cr.set_source_rgba(*PAPER_COLOR) + cr.rectangle(PADDING, PADDING, grid_w, grid_w) + cr.fill() + + # Draw placed rectangles + for i, pr in enumerate(player_rects): + color_idx = i % len(RECT_COLORS) + n = pr.normalized() + rx = PADDING + n.start_col * cs + ry = PADDING + n.start_row * cs + rw = (n.end_col - n.start_col + 1) * cs + rh = (n.end_row - n.start_row + 1) * cs + + if i in self._error_rects: + cr.set_source_rgba(*ERROR_COLOR) + else: + cr.set_source_rgba(*RECT_COLORS[color_idx]) + _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) + cr.fill() + + if i in self._error_rects: + cr.set_source_rgba(*ERROR_BORDER) + else: + cr.set_source_rgba(*RECT_BORDER[color_idx]) + cr.set_line_width(2.5) + _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) + cr.stroke() + + # Drag preview + if self._drag_preview: + n = self._drag_preview.normalized() + rx = PADDING + n.start_col * cs + ry = PADDING + n.start_row * cs + rw = (n.end_col - n.start_col + 1) * cs + rh = (n.end_row - n.start_row + 1) * cs + preview_area = self._drag_preview.area() + is_invalid = preview_area == 1 + + cr.set_source_rgba(*(ERROR_COLOR if is_invalid else DRAG_COLOR)) + _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) + cr.fill() + cr.set_source_rgba(*(ERROR_BORDER if is_invalid else DRAG_BORDER)) + cr.set_line_width(2.0) + _rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6) + cr.stroke() + + # Draw cell count label inside the drag preview + count_layout = self.create_pango_layout("") + count_fs = max(8, int(font_size(size, self.zoom) * 0.95)) + count_layout.set_font_description( + Pango.FontDescription(f"Iosevka, Monospace Bold {count_fs}") + ) + count_layout.set_text(str(preview_area), -1) + _, ext = count_layout.get_pixel_extents() + tx = rx + rw // 2 - ext.width // 2 + ty = ry + rh // 2 - ext.height // 2 + # White text with dark shadow for readability on any background + cr.set_source_rgba(0.1, 0.05, 0.15, 0.6) + cr.move_to(tx + 1, ty + 1) + from gi.repository import PangoCairo + PangoCairo.show_layout(cr, count_layout) + cr.set_source_rgba(1.0, 1.0, 1.0, 0.95) + cr.move_to(tx, ty) + PangoCairo.show_layout(cr, count_layout) + + # Grid lines + cr.set_line_width(0.8) + cr.set_source_rgba(*GRID_LINE) + for i in range(size + 1): + x = PADDING + i * cs + cr.move_to(x, PADDING) + cr.line_to(x, PADDING + grid_w) + cr.stroke() + cr.move_to(PADDING, x) + cr.line_to(PADDING + grid_w, x) + cr.stroke() + + # Border + cr.set_line_width(3.0) + cr.set_source_rgba(*GRID_LINE_BOLD) + cr.rectangle(PADDING, PADDING, grid_w, grid_w) + cr.stroke() + + # Clue numbers + layout = self.create_pango_layout("") + fs = font_size(size, self.zoom) + layout.set_font_description(Pango.FontDescription(f"Iosevka, Monospace Bold {fs}")) + for (r, c), num in clues.items(): + layout.set_text(str(num), -1) + _, ext = layout.get_pixel_extents() + tx = PADDING + c * cs + cs // 2 - ext.width // 2 + ty = PADDING + r * cs + cs // 2 - ext.height // 2 + cr.set_source_rgba(*CLUE_COLOR) + cr.move_to(tx, ty) + from gi.repository import PangoCairo + PangoCairo.show_layout(cr, layout) + + +def _rounded_rect(cr, x, y, w, h, r): + cr.new_sub_path() + cr.arc(x + r, y + r, r, 3.14159, 1.5 * 3.14159) + cr.arc(x + w - r, y + r, r, 1.5 * 3.14159, 0) + cr.arc(x + w - r, y + h - r, r, 0, 0.5 * 3.14159) + cr.arc(x + r, y + h - r, r, 0.5 * 3.14159, 3.14159) + cr.close_path() + + +# ─── Game state ─────────────────────────────────────────────────────────────── + +class ShikakuGame: + def __init__(self, size: int): + self.size = size + self.clues: dict = {} + self.solution: list[Rect] = [] + self.player_rects: list[PlayerRect] = [] + self.start_time: float = 0 + self.elapsed: float = 0 + self.solved = False + self.new_puzzle() + + def new_puzzle(self, seed=None): + self.current_seed = seed or random.randint(0, 2**31) + self.clues, self.solution = generate_puzzle(self.size, self.current_seed) + self.player_rects = [] + self.start_time = time.time() + self.elapsed = 0 + self.solved = False + + def place_rect(self, pr: PlayerRect): + """Add a rectangle, removing any existing ones that overlap.""" + new_cells = set(pr.cells()) + self.player_rects = [r for r in self.player_rects + if not set(r.cells()) & new_cells] + self.player_rects.append(pr) + + def remove_rect_at(self, row, col): + self.player_rects = [r for r in self.player_rects + if (row, col) not in set(r.cells())] + + def check_solution(self) -> tuple[bool, str, set[int]]: + ok, msg = verify_solution(self.size, self.player_rects, self.clues) + error_indices = set() + if not ok: + # Mark rectangles that are invalid + for i, pr in enumerate(self.player_rects): + cells = set(pr.cells()) + clues_inside = {cell: self.clues[cell] for cell in cells if cell in self.clues} + if len(clues_inside) != 1 or pr.area() != next(iter(clues_inside.values()), -1): + error_indices.add(i) + else: + self.solved = True + self.elapsed = time.time() - self.start_time + return ok, msg, error_indices + + +# ─── Main window ────────────────────────────────────────────────────────────── + +class ShikakuWindow(Gtk.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title="Shikaku") + self.set_resizable(True) + + self._current_size = 5 + self.game = ShikakuGame(self._current_size) + + self._build_ui() + self._update_canvas_size() + self._start_timer() + + def _build_ui(self): + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.set_child(outer) + + # ── Top bar ── + topbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + topbar.set_margin_top(14) + topbar.set_margin_bottom(10) + topbar.set_margin_start(20) + topbar.set_margin_end(20) + outer.append(topbar) + + title = Gtk.Label(label="SHIKAKU") + title.add_css_class("title-label") + topbar.append(title) + + spacer = Gtk.Box() + spacer.set_hexpand(True) + topbar.append(spacer) + + self._timer_label = Gtk.Label(label="0:00") + self._timer_label.add_css_class("timer-label") + topbar.append(self._timer_label) + + # ── Size buttons ── + size_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + size_bar.set_halign(Gtk.Align.CENTER) + size_bar.set_margin_bottom(10) + outer.append(size_bar) + + sizes_label = Gtk.Label(label="Grid:") + sizes_label.add_css_class("sizes-label") + size_bar.append(sizes_label) + + self._size_buttons = {} + for sz in [5, 7, 10, 15, 20, 25]: + btn = Gtk.Button(label=f"{sz}×{sz}") + btn.add_css_class("size-btn") + btn.connect("clicked", self._on_size_clicked, sz) + size_bar.append(btn) + self._size_buttons[sz] = btn + + self._update_size_buttons() + + # ── Canvas inside a scrolled window for large grids ── + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_hexpand(True) + scroll.set_vexpand(True) + self.canvas = ShikakuCanvas(self.game) + self.canvas.set_halign(Gtk.Align.CENTER) + self.canvas.set_valign(Gtk.Align.CENTER) + self.canvas.on_board_changed = self._trigger_auto_check + self.canvas.on_zoom_changed = self._on_zoom_changed + scroll.set_child(self.canvas) + outer.append(scroll) + + # ── Bottom bar ── + botbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + botbar.set_halign(Gtk.Align.CENTER) + botbar.set_margin_top(14) + botbar.set_margin_bottom(18) + outer.append(botbar) + + zoombar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + zoombar.set_halign(Gtk.Align.CENTER) + zoombar.set_margin_bottom(10) + outer.append(zoombar) + + zoom_out_btn = Gtk.Button(label="−") + zoom_out_btn.add_css_class("zoom-btn") + zoom_out_btn.connect("clicked", self._on_zoom_out) + zoombar.append(zoom_out_btn) + + self._zoom_label = Gtk.Label(label="100%") + self._zoom_label.add_css_class("zoom-label") + self._zoom_label.set_width_chars(5) + zoombar.append(self._zoom_label) + + zoom_in_btn = Gtk.Button(label="+") + zoom_in_btn.add_css_class("zoom-btn") + zoom_in_btn.connect("clicked", self._on_zoom_in) + zoombar.append(zoom_in_btn) + + zoom_reset_btn = Gtk.Button(label="1:1") + zoom_reset_btn.add_css_class("action-btn-ghost") + zoom_reset_btn.connect("clicked", self._on_zoom_reset) + zoombar.append(zoom_reset_btn) + + new_btn = Gtk.Button(label="New Puzzle") + new_btn.add_css_class("action-btn") + new_btn.connect("clicked", self._on_new) + botbar.append(new_btn) + + check_btn = Gtk.Button(label="Check") + check_btn.add_css_class("action-btn") + check_btn.connect("clicked", self._on_check) + botbar.append(check_btn) + + clear_btn = Gtk.Button(label="Clear") + clear_btn.add_css_class("action-btn-ghost") + clear_btn.connect("clicked", self._on_clear) + botbar.append(clear_btn) + + seed_btn = Gtk.Button(label="Seed") + seed_btn.add_css_class("action-btn-ghost") + seed_btn.connect("clicked", self._on_seed) + botbar.append(seed_btn) + + self._status_label = Gtk.Label(label="Draw rectangles around each number — right-click to erase") + self._status_label.add_css_class("status-label") + self._status_label.set_margin_top(4) + self._status_label.set_margin_bottom(8) + outer.append(self._status_label) + + # ── CSS ── + css = Gtk.CssProvider() + css.load_from_string(self._css()) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _css(self): + return """ + window { + background-color: #1c1a21; + } + .title-label { + font-family: "Iosevka", "Monospace"; + font-size: 22px; + font-weight: 900; + letter-spacing: 6px; + color: #e8e4dc; + } + .timer-label { + font-family: "Iosevka", "Monospace"; + font-size: 18px; + color: #8a8070; + font-weight: 600; + letter-spacing: 2px; + } + .sizes-label { + font-family: "Iosevka", "Monospace"; + font-size: 12px; + color: #6a6060; + letter-spacing: 2px; + } + .size-btn { + font-family: "Iosevka", "Monospace"; + font-size: 12px; + padding: 4px 12px; + border-radius: 4px; + background: #2a2730; + color: #9a9090; + border: 1px solid #3a3540; + } + .size-btn:hover { + background: #3a3540; + color: #d0c8c0; + } + .size-btn-active { + background: #4a4060; + color: #e8e0d8; + border: 1px solid #7a6090; + } + .action-btn { + font-family: "Iosevka", "Monospace"; + font-size: 13px; + font-weight: 700; + letter-spacing: 1px; + padding: 8px 22px; + border-radius: 5px; + background: #5a4880; + color: #e8e0ff; + border: none; + } + .action-btn:hover { + background: #7060a0; + } + .action-btn-ghost { + font-family: "Iosevka", "Monospace"; + font-size: 13px; + letter-spacing: 1px; + padding: 8px 22px; + border-radius: 5px; + background: transparent; + color: #6a6070; + border: 1px solid #3a3540; + } + .action-btn-ghost:hover { + color: #a09090; + border-color: #5a5060; + } + .status-label { + font-family: "Iosevka", "Monospace"; + font-size: 11px; + color: #5a5560; + letter-spacing: 1px; + } + .status-ok { + color: #60c080; + } + .status-err { + color: #c06060; + } + .seed-heading { + font-family: "Iosevka", "Monospace"; + font-size: 10px; + letter-spacing: 3px; + color: #6a6070; + } + .seed-value { + font-family: "Iosevka", "Monospace"; + font-size: 20px; + font-weight: 700; + color: #c0b8d8; + letter-spacing: 2px; + } + .seed-entry { + font-family: "Iosevka", "Monospace"; + font-size: 14px; + color: #e8e0ff; + } + """ + + def _update_size_buttons(self): + for sz, btn in self._size_buttons.items(): + btn.remove_css_class("size-btn-active") + if sz == self._current_size: + btn.add_css_class("size-btn-active") + + def _update_canvas_size(self): + sz = self.game.size + cs = cell_size(sz, self.canvas.zoom) + total = sz * cs + 2 * PADDING + self.canvas.set_content_width(total) + self.canvas.set_content_height(total) + self.set_default_size(total + 80, total + CHROME_HEIGHT + 60) + + def _on_size_clicked(self, btn, size): + self._current_size = size + self.game = ShikakuGame(size) + self.canvas.game = self.game + self.canvas.on_board_changed = self._trigger_auto_check + self.canvas._error_rects = set() + self.canvas.zoom = 1.0 # reset zoom on size change + self._zoom_label.set_text("100%") # reset zoom label + self._update_canvas_size() + self._update_size_buttons() + self._status_label.remove_css_class("status-ok") + self._status_label.remove_css_class("status-err") + self._status_label.set_text("Draw rectangles around each number — right-click to erase") + self.canvas.queue_draw() + + def _on_new(self, btn): + self.game.new_puzzle() + self.canvas._error_rects = set() + self._status_label.remove_css_class("status-ok") + self._status_label.remove_css_class("status-err") + self._status_label.set_text("Draw rectangles around each number — right-click to erase") + self.canvas.queue_draw() + + def _trigger_auto_check(self): + """Silently check after every move; announce only on a correct solution.""" + if self.game.solved: + return + ok, msg, errors = self.game.check_solution() + if ok: + self.canvas.clear_errors() + mins = int(self.game.elapsed) // 60 + secs = int(self.game.elapsed) % 60 + self._status_label.remove_css_class("status-err") + self._status_label.add_css_class("status-ok") + self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!") + else: + # Don't highlight errors on auto-check — only the manual Check button does that + self.canvas.clear_errors() + + def _on_check(self, btn): + ok, msg, errors = self.game.check_solution() + self.canvas.mark_errors(errors) + self._status_label.remove_css_class("status-ok") + self._status_label.remove_css_class("status-err") + if ok: + mins = int(self.game.elapsed) // 60 + secs = int(self.game.elapsed) % 60 + self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!") + self._status_label.add_css_class("status-ok") + else: + self._status_label.set_text(f"✗ {msg}") + self._status_label.add_css_class("status-err") + + def _on_clear(self, btn): + self.game.player_rects = [] + self.canvas.clear_errors() + self._status_label.remove_css_class("status-ok") + self._status_label.remove_css_class("status-err") + self._status_label.set_text("Cleared") + self.canvas.queue_draw() + + def _start_timer(self): + GLib.timeout_add(1000, self._tick) + + def _tick(self): + if not self.game.solved: + elapsed = int(time.time() - self.game.start_time) + m = elapsed // 60 + s = elapsed % 60 + self._timer_label.set_text(f"{m}:{s:02d}") + return True # keep ticking + + def _on_zoom_changed(self): + pct = int(self.canvas.zoom * 100) + self._zoom_label.set_text(f"{pct}%") + self._update_canvas_size() + self.canvas.queue_draw() + + def _on_zoom_in(self, btn): + self.canvas.set_zoom(self.canvas.zoom + ZOOM_STEP) + + def _on_zoom_out(self, btn): + self.canvas.set_zoom(self.canvas.zoom - ZOOM_STEP) + + def _on_zoom_reset(self, btn): + self.canvas.set_zoom(1.0) + + def _on_seed(self, btn): + dialog = Gtk.Dialog(title="Puzzle Seed", transient_for=self, modal=True) + dialog.set_resizable(False) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.set_margin_top(20) + box.set_margin_bottom(20) + box.set_margin_start(24) + box.set_margin_end(24) + dialog.get_content_area().append(box) + + # Current seed display + current_label = Gtk.Label(label="CURRENT SEED") + current_label.add_css_class("seed-heading") + current_label.set_halign(Gtk.Align.START) + box.append(current_label) + + seed_val = Gtk.Label(label=str(self.game.current_seed)) + seed_val.add_css_class("seed-value") + seed_val.set_selectable(True) + seed_val.set_halign(Gtk.Align.START) + box.append(seed_val) + + sep = Gtk.Separator() + sep.set_margin_top(4) + sep.set_margin_bottom(4) + box.append(sep) + + # Custom seed entry + entry_label = Gtk.Label(label="ENTER SEED") + entry_label.add_css_class("seed-heading") + entry_label.set_halign(Gtk.Align.START) + box.append(entry_label) + + entry = Gtk.Entry() + entry.set_placeholder_text("e.g. 123456789") + entry.set_input_purpose(Gtk.InputPurpose.DIGITS) + entry.add_css_class("seed-entry") + box.append(entry) + + # Buttons + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_box.set_halign(Gtk.Align.END) + btn_box.set_margin_top(8) + box.append(btn_box) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.add_css_class("action-btn-ghost") + cancel_btn.connect("clicked", lambda b: dialog.close()) + btn_box.append(cancel_btn) + + go_btn = Gtk.Button(label="Start Puzzle") + go_btn.add_css_class("action-btn") + btn_box.append(go_btn) + + def _on_go(b): + text = entry.get_text().strip() + try: + seed = int(text) + except ValueError: + entry.add_css_class("error") + return + dialog.close() + self.game.new_puzzle(seed=seed) + self.canvas._error_rects = set() + self.canvas.zoom = 1.0 + self._zoom_label.set_text("100%") + self._update_canvas_size() + self._status_label.remove_css_class("status-ok") + self._status_label.remove_css_class("status-err") + self._status_label.set_text("Draw rectangles around each number — right-click to erase") + self.canvas.queue_draw() + + go_btn.connect("clicked", _on_go) + entry.connect("activate", _on_go) # Enter key submits + + dialog.present() + + +# ─── Application entry ──────────────────────────────────────────────────────── + +class ShikakuApp(Gtk.Application): + def __init__(self): + super().__init__(application_id="com.shikaku.puzzle") + + def do_activate(self): + win = ShikakuWindow(self) + win.present() + + +def main(): + app = ShikakuApp() + sys.exit(app.run(sys.argv)) + + +if __name__ == "__main__": + main() \ No newline at end of file