crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 5c3c08de2fc9f641e5842789b62fcab9756f1382
parent e18227e2e9dc6679a058c48a9347656687708c18
Author: Michael Camilleri <[email protected]>
Date:   Fri, 10 Apr 2026 05:55:06 +0900

Add initial prototype

This is a prototype of Crossmate. It does not support puzzle selection
or collaborative editing at all. It does provide an operational puzzle
viewer, clue bar and answer keyboard. Checking, revealing and clearing
of squares, words and puzzles are working as is a draft (i.e. 'pencil')
mode.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
A.swift-format | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate.xcodeproj/project.pbxproj | 381+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate.xcodeproj/project.xcworkspace/contents.xcworkspacedata | 7+++++++
ACrossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/CrossmateApp.swift | 44++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Info.plist | 36++++++++++++++++++++++++++++++++++++
ACrossmate/Models/CellMark.swift | 40++++++++++++++++++++++++++++++++++++++++
ACrossmate/Models/Game.swift | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Models/PlayerColor.swift | 33+++++++++++++++++++++++++++++++++
ACrossmate/Models/PlayerSession.swift | 300+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Models/Puzzle.swift | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Models/XD.swift | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Resources/sample.xd | 19+++++++++++++++++++
ACrossmate/Views/CellView.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/GridView.swift | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/KeyboardView.swift | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/PuzzleView.swift | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aproject.yml | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 2291 insertions(+), 0 deletions(-)

diff --git a/.swift-format b/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -0,0 +1,381 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; + 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; + 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; + 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; + 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; + 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; + 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; + 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; + 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; + CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; + DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; + F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; + 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; + 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; + 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; + 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; + 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; + 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; + B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; + B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; + CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; + DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; + F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 12BCF7948BC2C200C647C279 /* Products */ = { + isa = PBXGroup; + children = ( + B689A7138429641E61E9E558 /* Crossmate.app */, + ); + name = Products; + sourceTree = "<group>"; + }; + 41DB2417FF67A47FE6890256 /* Models */ = { + isa = PBXGroup; + children = ( + B135C285570F91181595B405 /* CellMark.swift */, + 465F2BB469EFE84CF3733398 /* Game.swift */, + DB55FC337CF72C650373210A /* PlayerColor.swift */, + 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, + 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, + B9031A1574C21866940F6A2C /* XD.swift */, + ); + path = Models; + sourceTree = "<group>"; + }; + 5770CE69DB2B0B7462FACE53 /* Crossmate */ = { + isa = PBXGroup; + children = ( + 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */, + 9447F0FE34C63810C6F1D8BE /* Info.plist */, + 41DB2417FF67A47FE6890256 /* Models */, + F53443E4827221C62DB7AA36 /* Resources */, + 84445EA9CACB6AAAEDE6965F /* Views */, + ); + path = Crossmate; + sourceTree = "<group>"; + }; + 84445EA9CACB6AAAEDE6965F /* Views */ = { + isa = PBXGroup; + children = ( + F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, + CAB4BB9E160C3A59C653E7A9 /* GridView.swift */, + 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */, + AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */, + ); + path = Views; + sourceTree = "<group>"; + }; + C5342A31D253372339517EEE = { + isa = PBXGroup; + children = ( + 5770CE69DB2B0B7462FACE53 /* Crossmate */, + 12BCF7948BC2C200C647C279 /* Products */, + ); + sourceTree = "<group>"; + }; + F53443E4827221C62DB7AA36 /* Resources */ = { + isa = PBXGroup; + children = ( + 5C63A148D98E2D37EABF2CF5 /* sample.xd */, + ); + path = Resources; + sourceTree = "<group>"; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7708D1C8A0145D43BD15DEB7 /* Crossmate */ = { + isa = PBXNativeTarget; + buildConfigurationList = AB7D49875A042FD78EDD157A /* Build configuration list for PBXNativeTarget "Crossmate" */; + buildPhases = ( + C17B62906BBF281D006D8DC2 /* Sources */, + C475EFB2B47245175F9B415C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Crossmate; + packageProductDependencies = ( + ); + productName = Crossmate; + productReference = B689A7138429641E61E9E558 /* Crossmate.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9167165F088B7698D1319D3C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 7708D1C8A0145D43BD15DEB7 = { + DevelopmentTeam = 7TD7PZBNXP; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = C5342A31D253372339517EEE; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 12BCF7948BC2C200C647C279 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7708D1C8A0145D43BD15DEB7 /* Crossmate */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C475EFB2B47245175F9B415C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97D77230A98330DCB757FA81 /* sample.xd in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C17B62906BBF281D006D8DC2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, + CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, + DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, + 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, + 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, + F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, + 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, + 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, + 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, + 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, + 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 209C1E6D178C7EF962FC85A5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 7TD7PZBNXP; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6; + }; + name = Release; + }; + 8BC97916898B0BF1E6951C48 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = Crossmate/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + AF49D30A1B81631106E05429 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = Crossmate/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + E7B092DD549FA4FFED8BC20E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 7TD7PZBNXP; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E7B092DD549FA4FFED8BC20E /* Debug */, + 209C1E6D178C7EF962FC85A5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + AB7D49875A042FD78EDD157A /* Build configuration list for PBXNativeTarget "Crossmate" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF49D30A1B81631106E05429 /* Debug */, + 8BC97916898B0BF1E6951C48 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9167165F088B7698D1319D3C /* Project object */; +} diff --git a/Crossmate.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Crossmate.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Workspace + version = "1.0"> + <FileRef + location = "self:"> + </FileRef> +</Workspace> diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1430" + version = "1.7"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "7708D1C8A0145D43BD15DEB7" + BuildableName = "Crossmate.app" + BlueprintName = "Crossmate" + ReferencedContainer = "container:Crossmate.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "7708D1C8A0145D43BD15DEB7" + BuildableName = "Crossmate.app" + BlueprintName = "Crossmate" + ReferencedContainer = "container:Crossmate.xcodeproj"> + </BuildableReference> + </MacroExpansion> + <Testables> + </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 = "7708D1C8A0145D43BD15DEB7" + BuildableName = "Crossmate.app" + BlueprintName = "Crossmate" + ReferencedContainer = "container:Crossmate.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "7708D1C8A0145D43BD15DEB7" + BuildableName = "Crossmate.app" + BlueprintName = "Crossmate" + ReferencedContainer = "container:Crossmate.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + <CommandLineArguments> + </CommandLineArguments> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -0,0 +1,44 @@ +import SwiftUI + +@main +struct CrossmateApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} + +struct RootView: View { + @State private var session: PlayerSession? + @State private var loadError: String? + + var body: some View { + NavigationStack { + Group { + if let session { + PuzzleView(session: session) + } else if let loadError { + ContentUnavailableView( + "Couldn't load puzzle", + systemImage: "exclamationmark.triangle", + description: Text(loadError) + ) + } else { + ProgressView() + } + } + .navigationTitle(session?.puzzle.title ?? "Crossmate") + .navigationBarTitleDisplayMode(.inline) + } + .task { + do { + let puzzle = try Puzzle.load(resource: "sample") + let game = Game(puzzle: puzzle) + session = PlayerSession(game: game) + } catch { + loadError = String(describing: error) + } + } + } +} diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Crossmate</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>0.1</string> + <key>CFBundleVersion</key> + <string>1</string> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>UILaunchScreen</key> + <dict/> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> +</dict> +</plist> diff --git a/Crossmate/Models/CellMark.swift b/Crossmate/Models/CellMark.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Non-letter state attached to a single cell. The letter itself lives in +/// `Game.entries`; `CellMark` describes how that letter (or the absence of +/// one) should be interpreted and rendered. +/// +/// Cases model two orthogonal dimensions — whether the entry is pen or pencil, +/// and whether a check has flagged it as wrong — plus `revealed`, which is a +/// distinct, locked state. `.none` is the canonical "no mark" value; we never +/// store `.pen(checkedWrong: false)` because it's semantically equivalent to +/// `.none`. `.pencil(checkedWrong: false)` is a real state (a tentative entry +/// that hasn't been checked) and is preserved. +/// +/// All marks are shared state — they live on `Game` alongside `entries` so +/// both players in a collaborative session see the same marks. +enum CellMark: Sendable, Equatable { + case none + case pen(checkedWrong: Bool) + case pencil(checkedWrong: Bool) + case revealed + + var isRevealed: Bool { + if case .revealed = self { return true } + return false + } + + var isPencil: Bool { + if case .pencil = self { return true } + return false + } + + var isCheckedWrong: Bool { + switch self { + case .pen(let wrong), .pencil(let wrong): + return wrong + case .none, .revealed: + return false + } + } +} diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -0,0 +1,110 @@ +import Foundation +import Observation + +/// The shared, eventually-synced state of a single crossword game. Everything +/// on `Game` is content that both players see — the puzzle itself, the +/// letters that have been entered, and the per-cell marks for pencil / check +/// / reveal state. Anything that's local to a single player (cursor position, +/// pencil-mode toggle, chosen colour) lives on `PlayerSession`, not here. +@MainActor +@Observable +final class Game { + let puzzle: Puzzle + var entries: [[String]] + var marks: [[CellMark]] + + init(puzzle: Puzzle) { + self.puzzle = puzzle + self.entries = Array( + repeating: Array(repeating: "", count: puzzle.width), + count: puzzle.height + ) + self.marks = Array( + repeating: Array(repeating: .none, count: puzzle.width), + count: puzzle.height + ) + } + + // MARK: - Letter writes + + /// Writes `letter` into `(row, col)`. If `pencil` is true the cell is + /// marked `.pencil(checkedWrong: false)`; otherwise the mark is cleared + /// to `.none`. Revealed cells are locked — writes are silently ignored. + func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) { + let cell = puzzle.cells[row][col] + guard !cell.isBlock else { return } + guard !marks[row][col].isRevealed else { return } + entries[row][col] = letter.uppercased() + marks[row][col] = pencil ? .pencil(checkedWrong: false) : .none + } + + /// Clears the entry and mark at `(row, col)`. No-op on revealed cells. + func clearLetter(atRow row: Int, atCol col: Int) { + let cell = puzzle.cells[row][col] + guard !cell.isBlock else { return } + guard !marks[row][col].isRevealed else { return } + entries[row][col] = "" + marks[row][col] = .none + } + + // MARK: - Check / Reveal / Clear + + /// For each non-empty, non-revealed target cell, compares the current + /// entry against the solution. Wrong entries get `checkedWrong = true` + /// (preserving pen-vs-pencil style); correct entries have the wrong mark + /// cleared. Empty cells and cells without a known solution are skipped. + func checkCells(_ cells: [Puzzle.Cell]) { + for cell in cells { + guard !cell.isBlock else { continue } + guard let solution = cell.solution else { continue } + let entry = entries[cell.row][cell.col] + guard !entry.isEmpty else { continue } + guard !marks[cell.row][cell.col].isRevealed else { continue } + + let isWrong = entry != solution.uppercased() + switch marks[cell.row][cell.col] { + case .pencil: + marks[cell.row][cell.col] = .pencil(checkedWrong: isWrong) + case .none, .pen: + marks[cell.row][cell.col] = isWrong ? .pen(checkedWrong: true) : .none + case .revealed: + break // unreachable; guarded above + } + } + } + + func checkPuzzle() { + checkCells(puzzle.cells.flatMap { $0 }) + } + + /// For each target cell, writes the solution into the entry and sets the + /// mark to `.revealed` (which locks the cell against future edits). + func revealCells(_ cells: [Puzzle.Cell]) { + for cell in cells { + guard !cell.isBlock else { continue } + guard let solution = cell.solution else { continue } + entries[cell.row][cell.col] = solution.uppercased() + marks[cell.row][cell.col] = .revealed + } + } + + func revealPuzzle() { + revealCells(puzzle.cells.flatMap { $0 }) + } + + /// Clears the entry and mark for each non-revealed target cell. Revealed + /// cells are left untouched — once revealed, a cell's contents are + /// permanent for the rest of the game. + func clearCells(_ cells: [Puzzle.Cell]) { + for cell in cells { + guard !cell.isBlock else { continue } + guard !marks[cell.row][cell.col].isRevealed else { continue } + entries[cell.row][cell.col] = "" + marks[cell.row][cell.col] = .none + } + } + + func clearPuzzle() { + clearCells(puzzle.cells.flatMap { $0 }) + } +} diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// A player's chosen highlight colour. Each player picks one of these so that +/// — once collaborative play is wired up — both players' selections can be +/// shown side-by-side without ambiguity. +struct PlayerColor: Sendable, Identifiable, Hashable { + let id: String + let name: String + let tint: Color + + /// Opacity used when this player's cell is the active selection. + var selectedOpacity: Double { 0.55 } + + /// Opacity used for the rest of this player's active word. + var highlightedOpacity: Double { 0.18 } +} + +extension PlayerColor { + static let blue = PlayerColor(id: "blue", name: "Blue", tint: .blue) + static let red = PlayerColor(id: "red", name: "Red", tint: .red) + static let green = PlayerColor(id: "green", name: "Green", tint: .green) + static let orange = PlayerColor(id: "orange", name: "Orange", tint: .orange) + static let purple = PlayerColor(id: "purple", name: "Purple", tint: .purple) + static let pink = PlayerColor(id: "pink", name: "Pink", tint: .pink) + + /// Order in which colours appear in any picker UI. + static let palette: [PlayerColor] = [.blue, .red, .green, .orange, .purple, .pink] +} + +extension EnvironmentValues { + /// The local player's chosen highlight colour. Defaults to blue. + @Entry var playerColor: PlayerColor = .blue +} diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -0,0 +1,300 @@ +import Foundation +import Observation + +/// Local, per-player state for a single crossword game. Holds everything that +/// belongs to one player and is *not* shared with the other side of a +/// collaborative session: cursor position, current direction, pencil mode, +/// and (eventually) chosen colour. All shared mutations are routed through +/// the underlying `Game`. +@MainActor +@Observable +final class PlayerSession { + let game: Game + var selectedRow: Int + var selectedCol: Int + var direction: Puzzle.Direction = .across + var isPencilMode: Bool = false + + /// Rebus mode lets the player type a multi-character value into a single + /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in + /// `rebusBuffer` rather than going straight to `Game.entries`; on commit + /// the buffer is written to the cell and the cursor advances. + var isRebusActive: Bool = false + var rebusBuffer: String = "" + + var puzzle: Puzzle { game.puzzle } + + init(game: Game) { + self.game = game + let puzzle = game.puzzle + + // Start at the first across clue. Fall back to the first down clue if + // the puzzle has no across answers, then to (0, 0) for a degenerate + // puzzle with no clues at all. + if let first = puzzle.acrossClues.first, + let cell = puzzle.cell(numbered: first.number) { + self.selectedRow = cell.row + self.selectedCol = cell.col + self.direction = .across + } else if let first = puzzle.downClues.first, + let cell = puzzle.cell(numbered: first.number) { + self.selectedRow = cell.row + self.selectedCol = cell.col + self.direction = .down + } else { + self.selectedRow = 0 + self.selectedCol = 0 + } + } + + // MARK: - Selection + + func select(row: Int, col: Int) { + guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return } + if row == selectedRow && col == selectedCol { + direction = direction.opposite + if !hasWord(at: row, col: col, direction: direction) { + direction = direction.opposite + } + } else { + selectedRow = row + selectedCol = col + if !hasWord(at: row, col: col, direction: direction) { + direction = direction.opposite + } + } + } + + func togglePencil() { + // TODO: when wired up, letters typed in pencil mode should be stored + // as tentative entries (rendered in a lighter weight) until confirmed. + isPencilMode.toggle() + } + + func toggleDirection() { + let next = direction.opposite + if hasWord(at: selectedRow, col: selectedCol, direction: next) { + direction = next + } + } + + // MARK: - Clue navigation + + func goToNextClue() { + moveClue(by: +1) + } + + func goToPreviousClue() { + moveClue(by: -1) + } + + private func moveClue(by offset: Int) { + // Walk every clue in order: all acrosses, then all downs. Stepping past + // the last across rolls into the first down (and vice versa going + // backwards), which matches how most crossword apps behave. + let ordered: [(Puzzle.Direction, Puzzle.Clue)] = + puzzle.acrossClues.map { (.across, $0) } + + puzzle.downClues.map { (.down, $0) } + guard !ordered.isEmpty else { return } + + let currentNumber = currentClueNumber() + let currentIndex = ordered.firstIndex { + $0.0 == direction && $0.1.number == currentNumber + } ?? 0 + let count = ordered.count + let nextIndex = ((currentIndex + offset) % count + count) % count + + let (newDirection, newClue) = ordered[nextIndex] + direction = newDirection + moveToClueStart(number: newClue.number) + } + + // MARK: - Check / Reveal / Clear + // + // These translate the player's cursor into a set of cells (or the whole + // puzzle) and ask `Game` to apply the operation. The actual marking lives + // on `Game` because checks and reveals are shared state. + + func checkSquare() { + let cell = puzzle.cells[selectedRow][selectedCol] + guard !cell.isBlock else { return } + game.checkCells([cell]) + } + + func checkCurrentWord() { + game.checkCells(currentWordCells()) + } + + func checkPuzzle() { + game.checkPuzzle() + } + + func revealSquare() { + let cell = puzzle.cells[selectedRow][selectedCol] + guard !cell.isBlock else { return } + game.revealCells([cell]) + } + + func revealCurrentWord() { + game.revealCells(currentWordCells()) + } + + func revealPuzzle() { + game.revealPuzzle() + } + + func clearCurrentWord() { + game.clearCells(currentWordCells()) + } + + func clearPuzzle() { + game.clearPuzzle() + } + + private func currentClueNumber() -> Int? { + let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) + return puzzle.cells[start.row][start.col].number + } + + private func moveToClueStart(number: Int) { + for r in 0..<puzzle.height { + for c in 0..<puzzle.width where puzzle.cells[r][c].number == number { + selectedRow = r + selectedCol = c + return + } + } + } + + // MARK: - Input + + func enter(_ letter: String) { + let cell = puzzle.cells[selectedRow][selectedCol] + guard !cell.isBlock else { return } + game.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + advance() + } + + func deleteBackward() { + // If the cursor is on an empty cell or a revealed (locked) cell, + // retreat first — revealed cells can't be cleared in place, so delete + // tunnels past them to the previous editable cell. `clearLetter` + // itself no-ops on revealed cells, so calling it unconditionally + // after retreat is safe. + let currentMark = game.marks[selectedRow][selectedCol] + let currentEmpty = game.entries[selectedRow][selectedCol].isEmpty + if currentEmpty || currentMark.isRevealed { + retreat() + } + game.clearLetter(atRow: selectedRow, atCol: selectedCol) + } + + // MARK: - Rebus + + func startRebus() { + let cell = puzzle.cells[selectedRow][selectedCol] + guard !cell.isBlock else { return } + rebusBuffer = game.entries[selectedRow][selectedCol] + isRebusActive = true + } + + func appendRebusLetter(_ letter: String) { + rebusBuffer += letter.uppercased() + } + + func deleteRebusLetter() { + guard !rebusBuffer.isEmpty else { return } + rebusBuffer.removeLast() + } + + func commitRebus() { + let value = rebusBuffer + isRebusActive = false + rebusBuffer = "" + game.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode) + advance() + } + + // MARK: - Word geometry + + func isInCurrentWord(row: Int, col: Int) -> Bool { + currentWordCells().contains(where: { $0.row == row && $0.col == col }) + } + + func currentClue() -> Puzzle.Clue? { + let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) + guard let number = puzzle.cells[start.row][start.col].number else { return nil } + let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues + return clues.first { $0.number == number } + } + + private func currentWordCells() -> [Puzzle.Cell] { + let (dr, dc) = step(for: direction) + let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) + var cells: [Puzzle.Cell] = [] + var r = start.row + var c = start.col + while isValid(row: r, col: c) && !puzzle.cells[r][c].isBlock { + cells.append(puzzle.cells[r][c]) + r += dr + c += dc + } + return cells + } + + private func wordStart(row: Int, col: Int, direction: Puzzle.Direction) -> (row: Int, col: Int) { + let (dr, dc) = step(for: direction) + var r = row + var c = col + while isValid(row: r - dr, col: c - dc) && !puzzle.cells[r - dr][c - dc].isBlock { + r -= dr + c -= dc + } + return (r, c) + } + + private func hasWord(at row: Int, col: Int, direction: Puzzle.Direction) -> Bool { + let (dr, dc) = step(for: direction) + let hasNext = isValid(row: row + dr, col: col + dc) + && !puzzle.cells[row + dr][col + dc].isBlock + let hasPrev = isValid(row: row - dr, col: col - dc) + && !puzzle.cells[row - dr][col - dc].isBlock + return hasNext || hasPrev + } + + private func step(for direction: Puzzle.Direction) -> (Int, Int) { + direction == .across ? (0, 1) : (1, 0) + } + + private func advance() { + let (dr, dc) = step(for: direction) + var r = selectedRow + dr + var c = selectedCol + dc + while isValid(row: r, col: c) && puzzle.cells[r][c].isBlock { + r += dr + c += dc + } + if isValid(row: r, col: c) { + selectedRow = r + selectedCol = c + } + } + + private func retreat() { + let (dr, dc) = step(for: direction) + var r = selectedRow - dr + var c = selectedCol - dc + while isValid(row: r, col: c) && puzzle.cells[r][c].isBlock { + r -= dr + c -= dc + } + if isValid(row: r, col: c) { + selectedRow = r + selectedCol = c + } + } + + private func isValid(row: Int, col: Int) -> Bool { + row >= 0 && row < puzzle.height && col >= 0 && col < puzzle.width + } +} diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Normalized in-memory representation of a crossword. Independent of the +/// source format so the rest of the app doesn't have to know how it was +/// loaded. +struct Puzzle: Sendable { + enum Direction: Sendable { + case across + case down + + var opposite: Direction { self == .across ? .down : .across } + } + + let title: String + let author: String? + let width: Int + let height: Int + let cells: [[Cell]] + let acrossClues: [Clue] + let downClues: [Clue] + + struct Cell: Sendable, Hashable { + let row: Int + let col: Int + let isBlock: Bool + let number: Int? + let solution: String? + } + + struct Clue: Sendable, Hashable, Identifiable { + let number: Int + let text: String + var id: Int { number } + } + + enum LoadError: Error { + case notFound(String) + } + + init(xd: XD) { + self.title = xd.title ?? "Untitled" + self.author = xd.author + self.width = xd.width + self.height = xd.height + + // Clue numbering is computed from grid topology rather than carried + // in the source, since .xd has no per-cell number field. A cell is + // numbered if it begins an across or down word — i.e. its preceding + // neighbour in that direction is a block (or the edge) and its + // following neighbour is open. + var cells: [[Cell]] = [] + cells.reserveCapacity(xd.height) + var counter = 1 + for r in 0..<xd.height { + var rowCells: [Cell] = [] + rowCells.reserveCapacity(xd.width) + for c in 0..<xd.width { + switch xd.cells[r][c] { + case .block: + rowCells.append(Cell(row: r, col: c, isBlock: true, number: nil, solution: nil)) + case .open(let solution): + let leftBlock = c == 0 || Self.isBlock(xd.cells, r, c - 1) + let rightOpen = c + 1 < xd.width && !Self.isBlock(xd.cells, r, c + 1) + let topBlock = r == 0 || Self.isBlock(xd.cells, r - 1, c) + let bottomOpen = r + 1 < xd.height && !Self.isBlock(xd.cells, r + 1, c) + let startsWord = (leftBlock && rightOpen) || (topBlock && bottomOpen) + let number: Int? + if startsWord { + number = counter + counter += 1 + } else { + number = nil + } + rowCells.append(Cell(row: r, col: c, isBlock: false, number: number, solution: solution)) + } + } + cells.append(rowCells) + } + self.cells = cells + self.acrossClues = xd.acrossClues.map { Clue(number: $0.number, text: $0.text) } + self.downClues = xd.downClues.map { Clue(number: $0.number, text: $0.text) } + } + + /// Returns the cell labelled with the given clue number, if any. + func cell(numbered number: Int) -> Cell? { + for row in cells { + for cell in row where cell.number == number { + return cell + } + } + return nil + } + + private static func isBlock(_ cells: [[XD.Cell]], _ row: Int, _ col: Int) -> Bool { + if case .block = cells[row][col] { return true } + return false + } + + static func load(resource: String) throws -> Puzzle { + guard let url = Bundle.main.url(forResource: resource, withExtension: "xd") else { + throw LoadError.notFound("\(resource).xd") + } + let source = try String(contentsOf: url, encoding: .utf8) + let xd = try XD.parse(source) + return Puzzle(xd: xd) + } +} diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -0,0 +1,263 @@ +import Foundation + +/// Minimal `.xd` decoder. See https://github.com/century-arcade/xd for the +/// full specification. Supports just enough of the format to parse our +/// bundled puzzles: metadata, grid (with rebus), and across/down clues. +struct XD: Sendable { + let title: String? + let author: String? + let copyright: String? + let width: Int + let height: Int + let cells: [[Cell]] + let acrossClues: [Clue] + let downClues: [Clue] + + /// A single grid cell as it appears in the .xd source. Open cells carry + /// an optional solution string which may be 1+ characters long once any + /// `Rebus:` mapping has been applied. + enum Cell: Sendable, Equatable { + case block + case open(solution: String?) + } + + struct Clue: Sendable, Equatable { + let number: Int + let text: String + } + + enum ParseError: Error, CustomStringConvertible { + case missingGrid + case missingClues + case raggedGrid + case malformedClue(String) + case unknownGridCharacter(Character) + + var description: String { + switch self { + case .missingGrid: + return ".xd source has no grid section" + case .missingClues: + return ".xd source has no clues section" + case .raggedGrid: + return ".xd grid rows have inconsistent widths" + case .malformedClue(let line): + return "malformed .xd clue: \(line)" + case .unknownGridCharacter(let ch): + return "unknown .xd grid character: \(ch)" + } + } + } + + static func parse(_ source: String) throws -> XD { + let sections = splitIntoSections(source) + guard sections.count >= 2 else { throw ParseError.missingGrid } + guard sections.count >= 3 else { throw ParseError.missingClues } + + let metadata = parseMetadata(sections[0]) + let rebus = parseRebusHeader(metadata["Rebus"]) + let (cells, width, height) = try parseGrid(sections[1], rebus: rebus) + let (across, down) = try parseClues(sections[2]) + + return XD( + title: metadata["Title"], + author: metadata["Author"], + copyright: metadata["Copyright"], + width: width, + height: height, + cells: cells, + acrossClues: across, + downClues: down + ) + } + + // MARK: - Sections + + /// Splits the source into top-level sections. Per the .xd spec, sections + /// are delimited either by runs of two or more blank lines, or by + /// `## SectionName` header lines. We accept both: blank-line runs end the + /// current section, and a `##` line also ends the current section (the + /// header line itself is consumed and discarded — section identity comes + /// from implicit order). + private static func splitIntoSections(_ source: String) -> [[String]] { + let lines = source + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + + var sections: [[String]] = [] + var current: [String] = [] + var blankRun = 0 + + func flush() { + while current.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { + current.removeLast() + } + while current.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { + current.removeFirst() + } + if !current.isEmpty { + sections.append(current) + } + current = [] + } + + for rawLine in lines { + let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("## ") || trimmed == "##" { + flush() + blankRun = 0 + continue + } + + if trimmed.isEmpty { + blankRun += 1 + if blankRun >= 2 { + flush() + } + continue + } + + blankRun = 0 + current.append(line) + } + flush() + return sections + } + + // MARK: - Metadata + + private static func parseMetadata(_ lines: [String]) -> [String: String] { + var dict: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[..<colon].trimmingCharacters(in: .whitespaces) + let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + if !key.isEmpty { + dict[key] = value + } + } + return dict + } + + /// Parses a `Rebus:` header value such as `1=ONE 2=TWO 3=THREE` into a + /// map from grid placeholder character to its expanded solution string. + private static func parseRebusHeader(_ value: String?) -> [Character: String] { + guard let value, !value.isEmpty else { return [:] } + var map: [Character: String] = [:] + for entry in value.split(whereSeparator: { $0.isWhitespace }) { + guard let equals = entry.firstIndex(of: "=") else { continue } + let key = entry[..<equals] + let val = entry[entry.index(after: equals)...] + guard key.count == 1, let keyChar = key.first, !val.isEmpty else { continue } + map[keyChar] = String(val) + } + return map + } + + // MARK: - Grid + + private static func parseGrid( + _ lines: [String], + rebus: [Character: String] + ) throws -> (cells: [[Cell]], width: Int, height: Int) { + var rows: [[Cell]] = [] + var width: Int? = nil + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + + var row: [Cell] = [] + for ch in trimmed { + row.append(try gridCell(for: ch, rebus: rebus)) + } + + if let w = width, row.count != w { + throw ParseError.raggedGrid + } + width = row.count + rows.append(row) + } + + guard let w = width, !rows.isEmpty else { throw ParseError.missingGrid } + return (rows, w, rows.count) + } + + private static func gridCell( + for ch: Character, + rebus: [Character: String] + ) throws -> Cell { + // Blocks: '#' is a normal block; '_' marks a non-existing cell on the + // edge of an irregularly-shaped grid. Both render as non-playable. + if ch == "#" || ch == "_" { + return .block + } + // '.' is an open cell with no known solution. + if ch == "." { + return .open(solution: nil) + } + // Rebus placeholders are looked up in the Rebus header. Lowercase + // letters that appear in the Rebus header behave as combined + // rebus + special cells; we don't model "special" (circled/shaded) + // yet, so for now they collapse to their rebus expansion. + if let expansion = rebus[ch] { + return .open(solution: expansion.uppercased()) + } + // Lowercase letters not in the Rebus header are "special" cells + // (circled or shaded). The solution character is the uppercase form. + // Plain uppercase letters are normal solution cells. + if ch.isLetter { + return .open(solution: String(ch).uppercased()) + } + throw ParseError.unknownGridCharacter(ch) + } + + // MARK: - Clues + + private static func parseClues( + _ lines: [String] + ) throws -> (across: [Clue], down: [Clue]) { + var across: [Clue] = [] + var down: [Clue] = [] + + for rawLine in lines { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + + guard let leading = line.first, leading == "A" || leading == "D" else { + throw ParseError.malformedClue(line) + } + guard let dot = line.firstIndex(of: ".") else { + throw ParseError.malformedClue(line) + } + + let numberSlice = line[line.index(after: line.startIndex)..<dot] + guard let number = Int(numberSlice) else { + throw ParseError.malformedClue(line) + } + + var afterDot = line[line.index(after: dot)...] + .trimmingCharacters(in: .whitespaces) + + // Strip the trailing " ~ ANSWER" if present. We don't currently + // use the answer (the grid is the source of truth), but the + // separator is part of the format and must not leak into the + // clue text. + if let tilde = afterDot.range(of: " ~ ", options: .backwards) { + afterDot = String(afterDot[..<tilde.lowerBound]) + .trimmingCharacters(in: .whitespaces) + } + + let clue = Clue(number: number, text: afterDot) + if leading == "A" { + across.append(clue) + } else { + down.append(clue) + } + } + + return (across, down) + } +} diff --git a/Crossmate/Resources/sample.xd b/Crossmate/Resources/sample.xd @@ -0,0 +1,19 @@ +Title: Crossmate Demo +Author: Crossmate +Copyright: Public domain test puzzle + + +##L## +#BOY# +RIVER +#SEW# +##R## + + +A2. Young lad ~ BOY +A4. Flowing waterway ~ RIVER +A5. Stitch with needle and thread ~ SEW + +D1. Romantic partner ~ LOVER +D2. Encore, in music ~ BIS +D3. Evergreen tree with red berries ~ YEW diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct CellView: View { + let cell: Puzzle.Cell + let entry: String + let mark: CellMark + let isSelected: Bool + let isHighlighted: Bool + + @Environment(\.playerColor) private var playerColor + + var body: some View { + ZStack(alignment: .topLeading) { + background + if !cell.isBlock { + if let number = cell.number { + Text("\(number)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.leading, 2) + .padding(.top, 1) + } + Text(entry) + .font(.system(size: 34, weight: .semibold, design: .rounded)) + .foregroundStyle(entryStyle) + .lineLimit(1) + .minimumScaleFactor(0.2) + .allowsTightening(true) + .padding(.horizontal, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + if let triangleColor = cornerTriangleColor { + CornerTriangle() + .fill(triangleColor) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + + /// Foreground style for the main entry letter. Pencil entries use the + /// hierarchical `.secondary` style so they render lighter and respect + /// dark mode; everything else — including revealed and checkedWrong + /// cells — uses the primary label colour. Reveal/wrong state is shown + /// purely via the corner triangle, not by recolouring the letter. + private var entryStyle: AnyShapeStyle { + switch mark { + case .pencil: + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + case .none, .pen, .revealed: + return AnyShapeStyle(HierarchicalShapeStyle.primary) + } + } + + /// Fill colour for the top-right corner triangle: yellow for revealed + /// cells, red for checkedWrong, nothing otherwise. Revealed and + /// checkedWrong are mutually exclusive (a revealed cell can't be wrong), + /// so the order of the checks doesn't matter. + private var cornerTriangleColor: Color? { + if mark.isRevealed { return .yellow } + if mark.isCheckedWrong { return .red } + return nil + } + + @ViewBuilder + private var background: some View { + if cell.isBlock { + Color.black + } else { + ZStack { + Color.white + if isSelected { + playerColor.tint.opacity(playerColor.selectedOpacity) + } else if isHighlighted { + playerColor.tint.opacity(playerColor.highlightedOpacity) + } + } + } + } +} + +/// Right-angled triangle pinned to the top-right corner of the cell, sized +/// as a fraction of the shorter cell dimension. Used as a small marker for +/// revealed and checkedWrong cells. +private struct CornerTriangle: Shape { + var fraction: CGFloat = 0.3 + + func path(in rect: CGRect) -> Path { + let side = min(rect.width, rect.height) * fraction + var path = Path() + path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) + path.closeSubpath() + return path + } +} diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct GridView: View { + @Bindable var session: PlayerSession + + private let spacing: CGFloat = 1 + + var body: some View { + let width = session.puzzle.width + let height = session.puzzle.height + PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { + ForEach(0..<(width * height), id: \.self) { index in + let r = index / width + let c = index % width + CellView( + cell: session.puzzle.cells[r][c], + entry: session.game.entries[r][c], + mark: session.game.marks[r][c], + isSelected: session.selectedRow == r && session.selectedCol == c, + isHighlighted: session.isInCurrentWord(row: r, col: c) + ) + .onTapGesture { + session.select(row: r, col: c) + } + } + } + .background(Color.black) + } +} + +// MARK: - Layout + +/// Lays out puzzle cells in a `rows × columns` grid with a uniform square +/// cell size. The cell size is chosen to fit the proposed container, with +/// `spacing` points between every cell and around the outer edge (the black +/// grid border shows through the gaps). The laid-out grid reports its tight +/// size via `sizeThatFits`, so the parent view sizes us to exactly the grid +/// dimensions — no `.aspectRatio` modifier needed. +private struct PuzzleGridLayout: Layout { + let columns: Int + let rows: Int + let spacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let cellSize = cellSize(for: proposal) + let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) + let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) + return CGSize(width: width, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let cellSize = cellSize(for: ProposedViewSize(width: bounds.width, height: bounds.height)) + let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) + let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) + let originX = bounds.minX + (bounds.width - gridWidth) / 2 + let originY = bounds.minY + (bounds.height - gridHeight) / 2 + + let cellProposal = ProposedViewSize(width: cellSize, height: cellSize) + for (index, subview) in subviews.enumerated() { + let r = index / columns + let c = index % columns + let x = originX + spacing + CGFloat(c) * (cellSize + spacing) + let y = originY + spacing + CGFloat(r) * (cellSize + spacing) + subview.place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: cellProposal + ) + } + } + + private func cellSize(for proposal: ProposedViewSize) -> CGFloat { + let cols = CGFloat(columns) + let rs = CGFloat(rows) + let availableWidth = proposal.width ?? .infinity + let availableHeight = proposal.height ?? .infinity + let widthBased = availableWidth.isFinite + ? (availableWidth - spacing * (cols + 1)) / cols + : .infinity + let heightBased = availableHeight.isFinite + ? (availableHeight - spacing * (rs + 1)) / rs + : .infinity + return max(0, min(widthBased, heightBased)) + } +} diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/KeyboardView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +/// Custom on-screen keyboard. We use a hand-rolled keyboard rather than the +/// system keyboard because crossword input is single-character and needs to +/// stay glued to the bottom of the screen alongside the grid. +struct KeyboardView: View { + @Bindable var session: PlayerSession + @State private var showingOverflow = false + + private let topRow = Array("QWERTYUIOP").map(String.init) + private let middleRow = Array("ASDFGHJKL").map(String.init) + private let bottomLetters = Array("ZXCVBNM").map(String.init) + + private let spacing: CGFloat = 6 + private let keyHeight: CGFloat = 46 + + var body: some View { + VStack(spacing: spacing) { + KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { + ForEach(topRow, id: \.self) { letter in + letterKey(letter) + } + } + KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { + ForEach(middleRow, id: \.self) { letter in + letterKey(letter) + } + } + KeyboardRow(referenceColumns: 10, spacing: spacing, keyHeight: keyHeight) { + if session.isRebusActive { + actionKey(text: "Done", background: .blue, foreground: .white) { + session.commitRebus() + } + .keyWidthMultiplier(1.5) + } else { + actionKey(systemImage: "ellipsis") { + showingOverflow = true + } + .keyWidthMultiplier(1.5) + .popover(isPresented: $showingOverflow) { + VStack(alignment: .leading, spacing: 0) { + Button { + showingOverflow = false + session.startRebus() + } label: { + Text("Rebus") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + .frame(minWidth: 160) + .presentationCompactAdaptation(.popover) + .presentationBackground(.thinMaterial) + } + } + ForEach(bottomLetters, id: \.self) { letter in + letterKey(letter) + } + actionKey(systemImage: "delete.left") { + if session.isRebusActive { + session.deleteRebusLetter() + } else { + session.deleteBackward() + } + } + .keyWidthMultiplier(1.5) + } + } + .padding(.horizontal, 4) + .padding(.bottom, 8) + } + + private func letterKey(_ letter: String) -> some View { + Button { + if session.isRebusActive { + session.appendRebusLetter(letter) + } else { + session.enter(letter) + } + } label: { + Text(letter) + .font(.system(size: 22, weight: .medium, design: .rounded)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.tertiarySystemBackground)) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func actionKey( + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemFill)) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func actionKey( + text: String, + background: Color = Color(.systemFill), + foreground: Color = .primary, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(text) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(background) + .foregroundStyle(foreground) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + } + +} + +// MARK: - Layout + +/// Lays out a row of keys at a fixed key height. Every key is sized as a +/// fraction of `referenceColumns`, so a row containing fewer keys ends up +/// narrower than the reference row and is centered. Action keys can opt into +/// a wider width via `keyWidthMultiplier`. +private struct KeyboardRow: Layout { + let referenceColumns: Int + let spacing: CGFloat + let keyHeight: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + CGSize(width: proposal.width ?? 0, height: keyHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let containerWidth = bounds.width + let columns = CGFloat(referenceColumns) + let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns + + var totalWidth: CGFloat = 0 + for (index, subview) in subviews.enumerated() { + let multiplier = subview[KeyWidthMultiplier.self] + totalWidth += baseKeyWidth * multiplier + if index > 0 { totalWidth += spacing } + } + + var x = bounds.minX + (containerWidth - totalWidth) / 2 + for subview in subviews { + let multiplier = subview[KeyWidthMultiplier.self] + let width = baseKeyWidth * multiplier + subview.place( + at: CGPoint(x: x, y: bounds.minY), + anchor: .topLeading, + proposal: ProposedViewSize(width: width, height: keyHeight) + ) + x += width + spacing + } + } +} + +private struct KeyWidthMultiplier: LayoutValueKey { + static let defaultValue: CGFloat = 1.0 +} + +private extension View { + func keyWidthMultiplier(_ multiplier: CGFloat) -> some View { + layoutValue(key: KeyWidthMultiplier.self, value: multiplier) + } +} diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct PuzzleView: View { + @Bindable var session: PlayerSession + @Environment(\.playerColor) private var playerColor + + var body: some View { + VStack(spacing: 0) { + ZStack { + VStack(spacing: 0) { + GridView(session: session) + .padding(.horizontal) + .padding(.top) + Spacer(minLength: 12) + } + + if session.isRebusActive { + Color.black.opacity(0.35) + .ignoresSafeArea(edges: .top) + .contentShape(Rectangle()) + .onTapGesture { + session.commitRebus() + } + RebusModal(text: session.rebusBuffer) + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { /* swallow */ } + } + } + + VStack(spacing: 0) { + ClueBar(session: session) + KeyboardView(session: session) + } + .background(Color(.systemGroupedBackground)) + } + .background(Color(.systemBackground)) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + session.togglePencil() + } label: { + Image(systemName: "pencil") + .foregroundStyle(session.isPencilMode ? Color.white : Color.primary) + .padding(6) + .glassEffect( + session.isPencilMode + ? .regular.tint(playerColor.tint) + : .identity, + in: Circle() + ) + } + .accessibilityLabel("Pencil") + + Menu { + Section { + Button("Check Square") { session.checkSquare() } + Button("Check Word") { session.checkCurrentWord() } + Button("Check Puzzle") { session.checkPuzzle() } + } + Section { + Button("Reveal Square") { session.revealSquare() } + Button("Reveal Word") { session.revealCurrentWord() } + Button("Reveal Puzzle") { session.revealPuzzle() } + } + } label: { + Label("Hints", systemImage: "lightbulb") + } + + Menu { + Button("Clear Word") { session.clearCurrentWord() } + Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } + } label: { + Label("Clear", systemImage: "eraser") + } + } + } + } +} + +private struct ClueBar: View { + @Bindable var session: PlayerSession + + var body: some View { + let clue = session.currentClue() + VStack(alignment: .leading, spacing: 0) { + Text(label(for: clue)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 44) + .padding(.bottom, -4) + + HStack(spacing: 12) { + Button { + session.goToPreviousClue() + } label: { + Image(systemName: "chevron.left") + .font(.title3.weight(.semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + + Text(clue?.text ?? "—") + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + session.goToNextClue() + } label: { + Image(systemName: "chevron.right") + .font(.title3.weight(.semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background(Color(.systemGroupedBackground)) + } + + private func label(for clue: Puzzle.Clue?) -> String { + let direction = session.direction == .across ? "Across" : "Down" + if let clue { + return "\(clue.number) \(direction)" + } + return direction + } +} + +private struct RebusModal: View { + let text: String + + var body: some View { + Text(text.isEmpty ? " " : text) + .font(.system(size: 32, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, minHeight: 56) + .padding(.horizontal, 16) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(20) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} diff --git a/LICENSE b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/project.yml b/project.yml @@ -0,0 +1,55 @@ +name: Crossmate + +options: + bundleIdPrefix: net.inqk.crossmate + deploymentTarget: + iOS: "26.0" + +settings: + SWIFT_VERSION: 6 + DEAD_CODE_STRIPPING: YES + ENABLE_USER_SCRIPT_SANDBOXING: YES + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOL_EXTENSIONS: YES + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES + SWIFT_EMIT_LOC_STRINGS: YES + STRING_CATALOG_GENERATE_SYMBOLS: YES + DEVELOPMENT_TEAM: 7TD7PZBNXP + +targets: + Crossmate: + type: application + platform: iOS + sources: + - Crossmate + info: + path: Crossmate/Info.plist + properties: + CFBundleDisplayName: Crossmate + CFBundleShortVersionString: "0.1" + CFBundleVersion: "1" + ITSAppUsesNonExemptEncryption: false + LSRequiresIPhoneOS: true + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + settings: + PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate + INFOPLIST_FILE: Crossmate/Info.plist + TARGETED_DEVICE_FAMILY: "1" + CODE_SIGN_STYLE: Automatic + +schemes: + Crossmate: + build: + targets: + Crossmate: all + run: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release