working ios game
This commit is contained in:
24
ios/README.md
Normal file
24
ios/README.md
Normal 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.
|
||||||
493
ios/Shikaku.xcodeproj/project.pbxproj
Normal file
493
ios/Shikaku.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
13
ios/Shikaku/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/Shikaku/Assets.xcassets/Contents.json
Normal file
6
ios/Shikaku/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
327
ios/Shikaku/ContentView.swift
Normal file
327
ios/Shikaku/ContentView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
232
ios/Shikaku/Core/PuzzleGenerator.swift
Normal file
232
ios/Shikaku/Core/PuzzleGenerator.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
154
ios/Shikaku/Core/ShikakuGame.swift
Normal file
154
ios/Shikaku/Core/ShikakuGame.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
ios/Shikaku/Core/ShikakuModels.swift
Normal file
152
ios/Shikaku/Core/ShikakuModels.swift
Normal 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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/Shikaku/ShikakuApp.swift
Normal file
10
ios/Shikaku/ShikakuApp.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ShikakuApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
209
ios/Shikaku/Views/ShikakuGridView.swift
Normal file
209
ios/Shikaku/Views/ShikakuGridView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ios/Shikaku/Views/ShikakuTheme.swift
Normal file
41
ios/Shikaku/Views/ShikakuTheme.swift
Normal 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)
|
||||||
|
]
|
||||||
|
}
|
||||||
124
ios/ShikakuTests/ShikakuCoreTests.swift
Normal file
124
ios/ShikakuTests/ShikakuCoreTests.swift
Normal 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
1001
reference/shikaku.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user