refactor: clean template repo — move SoliCards to its own repo

Moved all SoliCards app code to a dedicated repository:
https://git.istratai.cloud/aj/SoliCards-iOS-iPadOS-MacOS

This repo is now a clean workflow template with just PROMPT.md,
CLAUDE.md, and README.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
idev2025 2026-04-14 08:27:41 -04:00
parent de0da01f25
commit 89286d10ae
208 changed files with 0 additions and 6538 deletions

View file

@ -1,81 +0,0 @@
# App Store Connect Metadata — SoliCards
## App Information
- **App Name:** SoliCards
- **Subtitle:** Classic Solitaire Collection
- **Bundle ID:** com.solicards.app
- **SKU:** SOLICARDS001
- **Category:** Games > Card
- **Content Rating:** 4+ (No objectionable content)
- **Price:** Free (or set pricing)
## Version 1.0.0
- **Build Number:** 1
- **What's New:** Initial release
## Description
SoliCards brings three classic solitaire card games to your iPhone, iPad, and Mac in one beautiful app.
Play Klondike, Spider, and FreeCell with four difficulty levels, six stunning themes, and full drag-and-drop gameplay. Every game auto-saves so you can pick up right where you left off.
Features:
- Three complete solitaire variants: Klondike, Spider, and FreeCell
- Four difficulty levels: Easy, Medium, Hard, Expert
- Six color themes with light and dark mode support
- 12 card back designs and multiple card face styles
- Smart hint system and auto-complete
- Unlimited undo (Easy) or limited undo for a challenge
- Game statistics tracking per variant and difficulty
- Full VoiceOver accessibility support
- Keyboard shortcuts for Mac
- No ads, no in-app purchases, no internet required
## Keywords
solitaire, klondike, spider, freecell, card game, patience, cards, classic, offline, free cell
## Privacy Policy URL
[Placeholder — required before submission]
## Support URL
[Placeholder — required before submission]
## Screenshots Required
### iPhone 6.9" (iPhone 16 Pro Max)
- 1320 x 2868 or 2868 x 1320
- Minimum 3 screenshots
### iPhone 6.3" (iPhone 16 Pro)
- 1206 x 2622 or 2622 x 1206
### iPad Pro 13" (M4)
- 2064 x 2752 or 2752 x 2064
### Mac
- 1280 x 800 minimum, 2560 x 1600 recommended
### Suggested Screenshots
1. Klondike game in progress (Classic Green theme)
2. Spider game showing 10-column layout
3. FreeCell game with power moves
4. Theme picker showing all 6 themes
5. New Game sheet with difficulty options
## Age Rating Questionnaire
- Unrestricted Web Access: No
- Gambling/Contests: No (solitaire is single-player, no real money)
- Mature/Suggestive Themes: None
- Violence: None
- All other categories: None
- **Result: 4+**
## In-App Purchases
None
## App Tracking Transparency
Not required — no tracking, no third-party SDKs
## Export Compliance
Uses no encryption beyond standard HTTPS (N/A — no networking)
- **ECCN:** Not applicable
- **Contains encryption:** No

View file

@ -1,102 +0,0 @@
# ARCHITECTURE.md — SoliCards
## Overview
SoliCards is a native SwiftUI solitaire app supporting three game variants (Klondike, Spider, FreeCell) across iOS, iPadOS, and macOS from a single codebase. It was ported from a 2,843-line vanilla JavaScript web app to native Swift.
## Pattern: MVVM + Protocol-Oriented Strategy
### Why MVVM
SwiftUI's `@Observable` macro (iOS 17+) gives property-level observation — a score change does not re-render the tableau. This is critical for a card game with many rapidly-changing UI elements during drag operations.
### Why Strategy Pattern for Variants
The three variants share concepts (cards, foundations, tableaus, undo, scoring) but have radically different rules. A single `GameRules` protocol defines the contract:
```
GameRules (protocol)
├── KlondikeRules — alternating colors, draw 1/3, foundation A→K
├── SpiderRules — same-suit sequences, 2-deck, auto-complete K→A
└── FreeCellRules — power moves: (1+freecells) × 2^emptyTableaus
```
Adding a new variant means implementing one struct + one board view. Zero changes to existing code.
### Why Not TCA
The game has no networking, no side effects beyond sound and persistence, and all state mutations are synchronous UI gestures. TCA's unidirectional architecture would add overhead without benefit.
## Key Architectural Decisions
### 1. DragGesture, not onDrag/onDrop
SwiftUI's `onDrag`/`onDrop` uses the `Transferable` protocol, which serializes data through the system pasteboard. This is designed for inter-app drag and drop. For internal card movement:
- `DragGesture` gives direct control over positioning and animation
- `DropTargetPreferenceKey` reports frame geometry for hit-testing
- `DraggedCardsOverlay` renders floating cards in the board's coordinate space
- No serialization overhead
### 2. @Observable, not ObservableObject
The `@Observable` macro (Observation framework) was chosen over `ObservableObject`/`@Published` because:
- Property-level observation — only views reading a changed property re-render
- No `@Published` boilerplate
- Works with `@Bindable` for two-way bindings
- Cleaner nested observable objects
### 3. @MainActor on ViewModels, not actors for game logic
All game state mutations are triggered by user gestures on the main thread. Using an actor would force every game action to be `async`, adding `await` noise for no concurrency benefit. Only `SoundManager` uses async for off-main-thread audio loading.
### 4. Value-type GameSnapshot for undo
Undo is implemented via an array of `GameSnapshot` structs (Codable value types). Each snapshot is a deep copy of all card arrays, score, and moves. This avoids reference-type aliasing bugs that would occur with class-based state. The history caps at 20 entries.
### 5. PNG images from asset catalog, not Canvas drawing
The source project already had 52 card front PNGs in multiple styles. Re-drawing cards procedurally would be wasted effort. Asset catalog images are:
- Memory-mapped by the system (efficient loading)
- Automatically thinned for App Store builds
- Validated at compile time
### 6. Debounced auto-save
Game state is saved via SwiftData after 2 seconds of inactivity (not on every move). This prevents excessive disk writes during rapid play. Immediate save occurs on app background and pause.
### 7. trailingSuffix(while:) extension
Swift's standard library does not include `suffix(while:)` on `BidirectionalCollection`. Rather than adding a dependency (Swift Algorithms), a small `trailingSuffix(while:)` extension on `Array` provides the same functionality for finding face-up card runs.
## Data Flow
```
User Gesture (tap / drag)
CardStackView / CardView
│ DragGesture / onTapGesture
GameViewModel
│ rules.canMove() → validation
│ state.pushHistory() → undo stack
│ state mutation → @Observable triggers SwiftUI diff
│ soundManager.play() → audio feedback
│ rules.isWon() → phase transition
│ scheduleAutoSave() → debounced SwiftData write
GameBoardView re-renders only changed cards
```
## Persistence Model
Three SwiftData `@Model` types:
| Model | Purpose | Lifecycle |
|-------|---------|-----------|
| `GameRecord` | Saved game state (JSON-encoded `GameSnapshot`) | One per variant, deleted on win |
| `StatsRecord` | Win/loss/streak per variant+difficulty | Permanent, updated on win/loss |
| `PrefsRecord` | Theme, card style, sound toggle | Singleton, updated on change |
## File Organization
Files are organized by architectural layer, not by feature:
- **Models/** — pure data types, no dependencies
- **GameEngine/** — game logic, depends only on Models
- **ViewModels/** — UI state management, depends on GameEngine + Services
- **Views/** — SwiftUI views, depends on ViewModels
- **Services/** — sound, haptics, timer (side effects)
- **Persistence/** — SwiftData models + manager
- **Theme/** — color definitions and theme state
This layering ensures that GameEngine can be tested without any UI or persistence dependencies.

127
SETUP.md
View file

@ -1,127 +0,0 @@
# SETUP.md — SoliCards
## Prerequisites
- **Xcode 16.3+** (or latest stable)
- **macOS 14+** (Sonoma or later)
- **xcodegen** (for project generation from `project.yml`)
Install xcodegen:
```bash
brew install xcodegen
```
No other dependencies. The project has zero external packages.
## Getting Started
### 1. Generate the Xcode project
```bash
xcodegen generate
```
This reads `project.yml` and creates `SoliCards.xcodeproj`.
### 2. Open in Xcode
```bash
open SoliCards.xcodeproj
```
### 3. Select a target and run
| Target | Destination | Notes |
|--------|-------------|-------|
| SoliCards | iPhone/iPad Simulator | iOS 17+ |
| SoliCards | My Mac | macOS 14+ (native) |
| SoliCardsTests | iPhone Simulator | Unit tests |
## Build Commands (CLI)
### Debug build (iOS)
```bash
xcodebuild build \
-project SoliCards.xcodeproj \
-scheme SoliCards \
-destination 'platform=iOS Simulator,name=iPhone 16'
```
### Debug build (macOS)
```bash
xcodebuild build \
-project SoliCards.xcodeproj \
-scheme SoliCards \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
```
### Release build
```bash
xcodebuild build \
-project SoliCards.xcodeproj \
-scheme SoliCards \
-configuration Release \
-destination 'platform=iOS Simulator,name=iPhone 16'
```
### Run tests
```bash
xcodebuild test \
-project SoliCards.xcodeproj \
-scheme SoliCardsTests \
-destination 'platform=iOS Simulator,name=iPhone 16'
```
### Static analysis
```bash
xcodebuild analyze \
-project SoliCards.xcodeproj \
-scheme SoliCards \
-destination 'platform=iOS Simulator,name=iPhone 16'
```
## Schemes
| Scheme | Purpose |
|--------|---------|
| SoliCards | Main app (iOS + macOS multiplatform) |
| SoliCardsTests | Unit test bundle (57 tests, 12 suites) |
## Project Structure
```
SoliCards/ # 53 Swift source files
├── SoliCardsApp.swift # @main entry point
├── ContentView.swift # Root navigation, persistence wiring
├── Models/ # Card, Suit, Rank, GameVariant, Difficulty, etc.
├── GameEngine/ # GameRules protocol + 3 variant implementations
├── ViewModels/ # GameViewModel, SettingsViewModel, StatsViewModel
├── Views/ # SwiftUI views (Game, Menu, Settings, Statistics)
├── Services/ # SoundManager, TimerService, HapticManager
├── Persistence/ # SwiftData models + PersistenceManager
├── Theme/ # GameTheme, ThemeManager
├── Extensions/ # CardLayout, Array+Card
└── Resources/ # Assets.xcassets, Localizable.xcstrings, PrivacyInfo
SoliCardsTests/ # 9 test files, 57 tests
├── Models/ # CardTests, DeckTests, DifficultyTests
└── GameEngine/ # KlondikeRulesTests, SpiderRulesTests, FreeCellRulesTests,
# MoveValidatorTests, AutoCompleterTests, GameStateTests
```
## Card Assets
- **52 card front PNGs** imported from `game-SoliCards/svg_playing_cards/fronts/`
- **12 card back PNGs** imported from `game-SoliCards/svg_playing_cards/backs/`
- All stored in `SoliCards/Resources/Assets.xcassets/` as imagesets
## Regenerating the Project
If you modify `project.yml` (add targets, change settings, etc.):
```bash
xcodegen generate
```
The `.xcodeproj` is generated — do not edit it by hand.

View file

@ -1,823 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
05F732433249D0777BFE8F91 /* KlondikeRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31155959181B8D516A82096 /* KlondikeRulesTests.swift */; };
0D292BDC30C9D09CF4677FFA /* SpiderBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2065F4D438686C32E01E449F /* SpiderBoardView.swift */; };
0DB00631BF90F0626BAE21CD /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */; };
12A564800C8BAED47DAD0C47 /* GameState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3488A79053BB9BF5BF6C39 /* GameState.swift */; };
171B8FB2868AAE8D3198B05E /* CardStylePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */; };
19E266494C3F614EEEC6539B /* GameVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */; };
1A5B631C600469C9F28CF783 /* DraggedCardsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */; };
29B7A35FE0C531B8CF8BF814 /* GameStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */; };
2A123DBA7BA8AB08CADD6BDF /* CardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */; };
34CB07FE798181D502428D1D /* Array+Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */; };
37D0960283347728B64A2FB4 /* MoveValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38D83697D866ACA495ED4B8 /* MoveValidator.swift */; };
3997014B0E7EA0A651AB0216 /* FreeCellRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */; };
3D6A93AB14AEAD9E65BB181D /* SpiderRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */; };
3E5C08ACC630D771613ECA16 /* MainMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AE9622A04A543946250427 /* MainMenuView.swift */; };
40A295F6F752B57C290C49C5 /* SoliCardsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */; };
42FC69591B33DD5370784036 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA638FEAAD15EBE1E78FFE14 /* CardView.swift */; };
43F316092B0C12D7A7276309 /* CardBackPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */; };
452209A28A7E2AA36CDFEFCA /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */; };
4BF879FC8DB20D9ED32D4738 /* AutoCompleterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */; };
537755217C0912F24EE2F5C6 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1321C0FD7F644967F371B7A /* NewGameSheet.swift */; };
5550A93B56A7BE882A1C7303 /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */; };
55A796BFBF38FBA8B6C6808F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9810F299E93134997859B2BD /* ContentView.swift */; };
56B286A65F7D8845885E9AF1 /* MoveAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */; };
56D2056F3CA46D09F2D94F10 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634429F416F1A484018E084E /* StatisticsView.swift */; };
586EA99FAD450DB90C430CA0 /* GamePhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 488C04322FF37B821C662516 /* GamePhase.swift */; };
5B89DF181690B09ED9204598 /* GameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4677EC6C93B738F443D86 /* GameViewModel.swift */; };
5D4B0E1123A7870248DA8360 /* AutoCompleter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115321F0A0475538BA016151 /* AutoCompleter.swift */; };
5E2EA0FE1F0216236DF6BE56 /* FreeCellBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */; };
60111A0FEC2BB3227E4E02E4 /* ScoreBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */; };
64E9B99DFBCD0F1D59F3D890 /* Difficulty.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64A7B696016187523AD7277 /* Difficulty.swift */; };
6585CAA4B2AD823C1F6C20DE /* GameTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */; };
65E127C9C91486AF86F9A693 /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD303449EB1172C9B52E624 /* SoundManager.swift */; };
65E698A14BC2C0E659A46128 /* MoveValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4901135D3529A476DEF089F /* MoveValidatorTests.swift */; };
7084235B866C1493AC18A061 /* Rank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */; };
70C69A048BE67CDD84E38B26 /* StatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */; };
79BCE98C4E8F851A36D4F753 /* KlondikeRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */; };
7D5D7789D2BA535A59EFE6EA /* CardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7D93BC01A3997DD183D368 /* CardTests.swift */; };
800459364BF6FD875D15171D /* DropTargetPreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */; };
81B92CA2BDA948B74EEDA6B9 /* VictoryOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */; };
8E4A7DFD89F451F7499F2684 /* RulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04FAD6B4305ABFB4769DE792 /* RulesView.swift */; };
8F37719FF9A39485718BAD78 /* KlondikeBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */; };
A4BD7013B7979EA922BA5041 /* CardStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE2B9027264A1134D2B65DC /* CardStackView.swift */; };
A5D797208B75A1C7D23AFB64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB81C7AA5EDF842B3D841907 /* Assets.xcassets */; };
A68F6A65F5897179FA3632BF /* TimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61A63D3A6077A80ED09B24 /* TimerService.swift */; };
AA99FA31E0F4094CBE06FFAF /* Deck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16BEB4EF07C82D8B2A89D260 /* Deck.swift */; };
B7A5F011A7C538B27F382FB5 /* GameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */; };
BA010A1D6573E1DA0D182EF8 /* CardLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */; };
BAD817F065BBAAC4B7C89043 /* GameBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E14435814D19CE147D6D408 /* GameBoardView.swift */; };
CBEE7677D3E2D9A9CFAA90D0 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B25C883D3B49CADA34775F1 /* SettingsView.swift */; };
CFDAB7D49E3D6CBB420F4CF1 /* HintResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 864D497FD148C1DCA5761247 /* HintResult.swift */; };
D00C18D126362C368802C9E3 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E42967DA3AA21AEE42F177 /* Card.swift */; };
D24334636360CE9659A3040A /* GameRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */; };
D4520E37CA093BDA7DBAB7F5 /* DifficultyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58632F749B766B0E79DD0152 /* DifficultyTests.swift */; };
D75EE30A448E01FD3A2EAD0A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */; };
D780D65841792E26E9208B8D /* GameRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */; };
D9E61277B5EA0772F7B28BFF /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */; };
DC0D004F619DE9C61C6AF585 /* PrefsRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */; };
E15025574906B1F1C9A03C8F /* SpiderRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4319F36333564A75A3FEB0 /* SpiderRules.swift */; };
E9ED2A8341168D2915B4F495 /* DeckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B14B437E3A3475278EC5 /* DeckTests.swift */; };
F02DBFB48DB80D36451D436B /* FreeCellRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */; };
F3AA49C17343374CF1B82FF7 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396230FC7779389B46BE1246 /* ThemeManager.swift */; };
F4C3C28A20ECA87EC97FD2DC /* Suit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116E45BE9C698645A16CF6F3 /* Suit.swift */; };
F713DA5821F2B37B2019CA1D /* StatsRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66617C01DF8B874B51A55295 /* StatsRecord.swift */; };
FD1C9241508230FD8E553981 /* GameRulesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */; };
FDCB1D9771FAC46DCE25A6A6 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
0457F9A25F53E04E37959F48 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 867199988082B6D71FDDE2D8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 82669FCF0B13F7341360B265;
remoteInfo = SoliCards;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
04FAD6B4305ABFB4769DE792 /* RulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesView.swift; sourceTree = "<group>"; };
0A61A63D3A6077A80ED09B24 /* TimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerService.swift; sourceTree = "<group>"; };
0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
0C4319F36333564A75A3FEB0 /* SpiderRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRules.swift; sourceTree = "<group>"; };
115321F0A0475538BA016151 /* AutoCompleter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleter.swift; sourceTree = "<group>"; };
116E45BE9C698645A16CF6F3 /* Suit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suit.swift; sourceTree = "<group>"; };
1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsRecord.swift; sourceTree = "<group>"; };
16BEB4EF07C82D8B2A89D260 /* Deck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deck.swift; sourceTree = "<group>"; };
1B25C883D3B49CADA34775F1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
1EE2B9027264A1134D2B65DC /* CardStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStackView.swift; sourceTree = "<group>"; };
2065F4D438686C32E01E449F /* SpiderBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderBoardView.swift; sourceTree = "<group>"; };
2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoliCardsApp.swift; sourceTree = "<group>"; };
22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRules.swift; sourceTree = "<group>"; };
23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropTargetPreferenceKey.swift; sourceTree = "<group>"; };
26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRulesFactory.swift; sourceTree = "<group>"; };
26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRules.swift; sourceTree = "<group>"; };
2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSnapshot.swift; sourceTree = "<group>"; };
2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveAction.swift; sourceTree = "<group>"; };
396230FC7779389B46BE1246 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
3DD303449EB1172C9B52E624 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = "<group>"; };
403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeBoardView.swift; sourceTree = "<group>"; };
488C04322FF37B821C662516 /* GamePhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePhase.swift; sourceTree = "<group>"; };
4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecord.swift; sourceTree = "<group>"; };
4B7D93BC01A3997DD183D368 /* CardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardTests.swift; sourceTree = "<group>"; };
4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLayout.swift; sourceTree = "<group>"; };
58632F749B766B0E79DD0152 /* DifficultyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DifficultyTests.swift; sourceTree = "<group>"; };
5E14435814D19CE147D6D408 /* GameBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameBoardView.swift; sourceTree = "<group>"; };
634429F416F1A484018E084E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = "<group>"; };
66617C01DF8B874B51A55295 /* StatsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsRecord.swift; sourceTree = "<group>"; };
6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rank.swift; sourceTree = "<group>"; };
714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
72E42967DA3AA21AEE42F177 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = "<group>"; };
7561129FE301D2A5E3652648 /* SoliCards.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SoliCards.app; sourceTree = BUILT_PRODUCTS_DIR; };
7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SoliCardsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
864D497FD148C1DCA5761247 /* HintResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HintResult.swift; sourceTree = "<group>"; };
94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleterTests.swift; sourceTree = "<group>"; };
9810F299E93134997859B2BD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A64A7B696016187523AD7277 /* Difficulty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Difficulty.swift; sourceTree = "<group>"; };
A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Card.swift"; sourceTree = "<group>"; };
A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRules.swift; sourceTree = "<group>"; };
A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStateTests.swift; sourceTree = "<group>"; };
AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceManager.swift; sourceTree = "<group>"; };
AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellBoardView.swift; sourceTree = "<group>"; };
B1321C0FD7F644967F371B7A /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
B31155959181B8D516A82096 /* KlondikeRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRulesTests.swift; sourceTree = "<group>"; };
B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsViewModel.swift; sourceTree = "<group>"; };
BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRulesTests.swift; sourceTree = "<group>"; };
C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggedCardsOverlay.swift; sourceTree = "<group>"; };
C0B4677EC6C93B738F443D86 /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = "<group>"; };
C38D83697D866ACA495ED4B8 /* MoveValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidator.swift; sourceTree = "<group>"; };
C4901135D3529A476DEF089F /* MoveValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidatorTests.swift; sourceTree = "<group>"; };
C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreBarView.swift; sourceTree = "<group>"; };
C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRulesTests.swift; sourceTree = "<group>"; };
CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLocation.swift; sourceTree = "<group>"; };
CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardBackPickerView.swift; sourceTree = "<group>"; };
CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VictoryOverlayView.swift; sourceTree = "<group>"; };
CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameTheme.swift; sourceTree = "<group>"; };
CD3488A79053BB9BF5BF6C39 /* GameState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameState.swift; sourceTree = "<group>"; };
DA638FEAAD15EBE1E78FFE14 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
DB81C7AA5EDF842B3D841907 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
E6AE9622A04A543946250427 /* MainMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuView.swift; sourceTree = "<group>"; };
EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStylePickerView.swift; sourceTree = "<group>"; };
F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameVariant.swift; sourceTree = "<group>"; };
FDF0B14B437E3A3475278EC5 /* DeckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
08AF7AF8C77BED3EFA6A9AEA /* Statistics */ = {
isa = PBXGroup;
children = (
634429F416F1A484018E084E /* StatisticsView.swift */,
);
path = Statistics;
sourceTree = "<group>";
};
0FF95606A40279B4D3390FBA = {
isa = PBXGroup;
children = (
2182AF78CEA93B7431E85608 /* SoliCards */,
D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */,
9CCB9EC5C71BA2DF24919C93 /* Products */,
);
sourceTree = "<group>";
};
2182AF78CEA93B7431E85608 /* SoliCards */ = {
isa = PBXGroup;
children = (
9810F299E93134997859B2BD /* ContentView.swift */,
2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */,
E4D19DAEAABB74B9FDD84B73 /* Extensions */,
3B2FB59B0E86B8F19E698694 /* GameEngine */,
FF9DBC6C605DE18ED6DA5E1C /* Models */,
2D71FE366319D21065F80CFD /* Persistence */,
3271C08BF426FD74787C6217 /* Resources */,
A1D36CB5F370E4F3906E319A /* Services */,
A8376EC525EE5E88D4517BA1 /* Theme */,
C6C973AB5DCD1C0E766F633A /* ViewModels */,
F84CF6704CBB01C0C9875CE2 /* Views */,
);
path = SoliCards;
sourceTree = "<group>";
};
2D71FE366319D21065F80CFD /* Persistence */ = {
isa = PBXGroup;
children = (
4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */,
AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */,
1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */,
66617C01DF8B874B51A55295 /* StatsRecord.swift */,
);
path = Persistence;
sourceTree = "<group>";
};
3271C08BF426FD74787C6217 /* Resources */ = {
isa = PBXGroup;
children = (
DB81C7AA5EDF842B3D841907 /* Assets.xcassets */,
714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */,
8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */,
AB3F10466EA26B5E0B5827A8 /* Sounds */,
);
path = Resources;
sourceTree = "<group>";
};
3B2FB59B0E86B8F19E698694 /* GameEngine */ = {
isa = PBXGroup;
children = (
115321F0A0475538BA016151 /* AutoCompleter.swift */,
A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */,
26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */,
26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */,
CD3488A79053BB9BF5BF6C39 /* GameState.swift */,
22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */,
C38D83697D866ACA495ED4B8 /* MoveValidator.swift */,
0C4319F36333564A75A3FEB0 /* SpiderRules.swift */,
);
path = GameEngine;
sourceTree = "<group>";
};
45A518EB42C0CDB893716BBE /* Settings */ = {
isa = PBXGroup;
children = (
CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */,
EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */,
1B25C883D3B49CADA34775F1 /* SettingsView.swift */,
23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */ = {
isa = PBXGroup;
children = (
);
path = ViewModels;
sourceTree = "<group>";
};
9CCB9EC5C71BA2DF24919C93 /* Products */ = {
isa = PBXGroup;
children = (
7561129FE301D2A5E3652648 /* SoliCards.app */,
7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
A1D36CB5F370E4F3906E319A /* Services */ = {
isa = PBXGroup;
children = (
0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */,
3DD303449EB1172C9B52E624 /* SoundManager.swift */,
0A61A63D3A6077A80ED09B24 /* TimerService.swift */,
);
path = Services;
sourceTree = "<group>";
};
A5353C257EB10EB9526C6E39 /* Menu */ = {
isa = PBXGroup;
children = (
E6AE9622A04A543946250427 /* MainMenuView.swift */,
B1321C0FD7F644967F371B7A /* NewGameSheet.swift */,
04FAD6B4305ABFB4769DE792 /* RulesView.swift */,
);
path = Menu;
sourceTree = "<group>";
};
A8376EC525EE5E88D4517BA1 /* Theme */ = {
isa = PBXGroup;
children = (
CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */,
396230FC7779389B46BE1246 /* ThemeManager.swift */,
);
path = Theme;
sourceTree = "<group>";
};
AB3F10466EA26B5E0B5827A8 /* Sounds */ = {
isa = PBXGroup;
children = (
);
path = Sounds;
sourceTree = "<group>";
};
C6C973AB5DCD1C0E766F633A /* ViewModels */ = {
isa = PBXGroup;
children = (
C0B4677EC6C93B738F443D86 /* GameViewModel.swift */,
C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */,
B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */ = {
isa = PBXGroup;
children = (
DB86ADC0F0581A7B56EBE28E /* GameEngine */,
E5E67911DEBB2793B3D8F1AD /* Models */,
7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */,
);
path = SoliCardsTests;
sourceTree = "<group>";
};
DA0714078CFC7F8BCD993F8C /* Game */ = {
isa = PBXGroup;
children = (
1EE2B9027264A1134D2B65DC /* CardStackView.swift */,
DA638FEAAD15EBE1E78FFE14 /* CardView.swift */,
C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */,
25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */,
AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */,
5E14435814D19CE147D6D408 /* GameBoardView.swift */,
403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */,
C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */,
2065F4D438686C32E01E449F /* SpiderBoardView.swift */,
CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */,
);
path = Game;
sourceTree = "<group>";
};
DB86ADC0F0581A7B56EBE28E /* GameEngine */ = {
isa = PBXGroup;
children = (
94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */,
C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */,
A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */,
B31155959181B8D516A82096 /* KlondikeRulesTests.swift */,
C4901135D3529A476DEF089F /* MoveValidatorTests.swift */,
BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */,
);
path = GameEngine;
sourceTree = "<group>";
};
E4D19DAEAABB74B9FDD84B73 /* Extensions */ = {
isa = PBXGroup;
children = (
A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */,
4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
E5E67911DEBB2793B3D8F1AD /* Models */ = {
isa = PBXGroup;
children = (
4B7D93BC01A3997DD183D368 /* CardTests.swift */,
FDF0B14B437E3A3475278EC5 /* DeckTests.swift */,
58632F749B766B0E79DD0152 /* DifficultyTests.swift */,
);
path = Models;
sourceTree = "<group>";
};
F84CF6704CBB01C0C9875CE2 /* Views */ = {
isa = PBXGroup;
children = (
DA0714078CFC7F8BCD993F8C /* Game */,
A5353C257EB10EB9526C6E39 /* Menu */,
45A518EB42C0CDB893716BBE /* Settings */,
08AF7AF8C77BED3EFA6A9AEA /* Statistics */,
);
path = Views;
sourceTree = "<group>";
};
FF9DBC6C605DE18ED6DA5E1C /* Models */ = {
isa = PBXGroup;
children = (
72E42967DA3AA21AEE42F177 /* Card.swift */,
CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */,
16BEB4EF07C82D8B2A89D260 /* Deck.swift */,
A64A7B696016187523AD7277 /* Difficulty.swift */,
488C04322FF37B821C662516 /* GamePhase.swift */,
2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */,
F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */,
864D497FD148C1DCA5761247 /* HintResult.swift */,
2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */,
6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */,
116E45BE9C698645A16CF6F3 /* Suit.swift */,
);
path = Models;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
82669FCF0B13F7341360B265 /* SoliCards */ = {
isa = PBXNativeTarget;
buildConfigurationList = F6C31F00417F79A014C90960 /* Build configuration list for PBXNativeTarget "SoliCards" */;
buildPhases = (
8C4FBA4AEB4F83B6D76C3868 /* Sources */,
00D9BFDDDE0AF0067CA90858 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = SoliCards;
packageProductDependencies = (
);
productName = SoliCards;
productReference = 7561129FE301D2A5E3652648 /* SoliCards.app */;
productType = "com.apple.product-type.application";
};
C27736B4220A23D60C765E3E /* SoliCardsTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6F270CC209153B7149368FF8 /* Build configuration list for PBXNativeTarget "SoliCardsTests" */;
buildPhases = (
00839BE9C0947FF6620A82FC /* Sources */,
);
buildRules = (
);
dependencies = (
CE28FBA2CF52896AC36A1F13 /* PBXTargetDependency */,
);
name = SoliCardsTests;
packageProductDependencies = (
);
productName = SoliCardsTests;
productReference = 7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
867199988082B6D71FDDE2D8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630;
TargetAttributes = {
};
};
buildConfigurationList = 6AE5403E93959B4E9E707F1D /* Build configuration list for PBXProject "SoliCards" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 0FF95606A40279B4D3390FBA;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 9CCB9EC5C71BA2DF24919C93 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
82669FCF0B13F7341360B265 /* SoliCards */,
C27736B4220A23D60C765E3E /* SoliCardsTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
00D9BFDDDE0AF0067CA90858 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A5D797208B75A1C7D23AFB64 /* Assets.xcassets in Resources */,
D75EE30A448E01FD3A2EAD0A /* Localizable.xcstrings in Resources */,
452209A28A7E2AA36CDFEFCA /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
00839BE9C0947FF6620A82FC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4BF879FC8DB20D9ED32D4738 /* AutoCompleterTests.swift in Sources */,
7D5D7789D2BA535A59EFE6EA /* CardTests.swift in Sources */,
E9ED2A8341168D2915B4F495 /* DeckTests.swift in Sources */,
D4520E37CA093BDA7DBAB7F5 /* DifficultyTests.swift in Sources */,
3997014B0E7EA0A651AB0216 /* FreeCellRulesTests.swift in Sources */,
29B7A35FE0C531B8CF8BF814 /* GameStateTests.swift in Sources */,
05F732433249D0777BFE8F91 /* KlondikeRulesTests.swift in Sources */,
65E698A14BC2C0E659A46128 /* MoveValidatorTests.swift in Sources */,
3D6A93AB14AEAD9E65BB181D /* SpiderRulesTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
8C4FBA4AEB4F83B6D76C3868 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
34CB07FE798181D502428D1D /* Array+Card.swift in Sources */,
5D4B0E1123A7870248DA8360 /* AutoCompleter.swift in Sources */,
D00C18D126362C368802C9E3 /* Card.swift in Sources */,
43F316092B0C12D7A7276309 /* CardBackPickerView.swift in Sources */,
2A123DBA7BA8AB08CADD6BDF /* CardLayout.swift in Sources */,
BA010A1D6573E1DA0D182EF8 /* CardLocation.swift in Sources */,
A4BD7013B7979EA922BA5041 /* CardStackView.swift in Sources */,
171B8FB2868AAE8D3198B05E /* CardStylePickerView.swift in Sources */,
42FC69591B33DD5370784036 /* CardView.swift in Sources */,
55A796BFBF38FBA8B6C6808F /* ContentView.swift in Sources */,
AA99FA31E0F4094CBE06FFAF /* Deck.swift in Sources */,
64E9B99DFBCD0F1D59F3D890 /* Difficulty.swift in Sources */,
1A5B631C600469C9F28CF783 /* DraggedCardsOverlay.swift in Sources */,
800459364BF6FD875D15171D /* DropTargetPreferenceKey.swift in Sources */,
5E2EA0FE1F0216236DF6BE56 /* FreeCellBoardView.swift in Sources */,
F02DBFB48DB80D36451D436B /* FreeCellRules.swift in Sources */,
BAD817F065BBAAC4B7C89043 /* GameBoardView.swift in Sources */,
586EA99FAD450DB90C430CA0 /* GamePhase.swift in Sources */,
D24334636360CE9659A3040A /* GameRecord.swift in Sources */,
D780D65841792E26E9208B8D /* GameRules.swift in Sources */,
FD1C9241508230FD8E553981 /* GameRulesFactory.swift in Sources */,
B7A5F011A7C538B27F382FB5 /* GameSnapshot.swift in Sources */,
12A564800C8BAED47DAD0C47 /* GameState.swift in Sources */,
6585CAA4B2AD823C1F6C20DE /* GameTheme.swift in Sources */,
19E266494C3F614EEEC6539B /* GameVariant.swift in Sources */,
5B89DF181690B09ED9204598 /* GameViewModel.swift in Sources */,
FDCB1D9771FAC46DCE25A6A6 /* HapticManager.swift in Sources */,
CFDAB7D49E3D6CBB420F4CF1 /* HintResult.swift in Sources */,
8F37719FF9A39485718BAD78 /* KlondikeBoardView.swift in Sources */,
79BCE98C4E8F851A36D4F753 /* KlondikeRules.swift in Sources */,
3E5C08ACC630D771613ECA16 /* MainMenuView.swift in Sources */,
56B286A65F7D8845885E9AF1 /* MoveAction.swift in Sources */,
37D0960283347728B64A2FB4 /* MoveValidator.swift in Sources */,
537755217C0912F24EE2F5C6 /* NewGameSheet.swift in Sources */,
5550A93B56A7BE882A1C7303 /* PersistenceManager.swift in Sources */,
DC0D004F619DE9C61C6AF585 /* PrefsRecord.swift in Sources */,
7084235B866C1493AC18A061 /* Rank.swift in Sources */,
8E4A7DFD89F451F7499F2684 /* RulesView.swift in Sources */,
60111A0FEC2BB3227E4E02E4 /* ScoreBarView.swift in Sources */,
CBEE7677D3E2D9A9CFAA90D0 /* SettingsView.swift in Sources */,
0DB00631BF90F0626BAE21CD /* SettingsViewModel.swift in Sources */,
40A295F6F752B57C290C49C5 /* SoliCardsApp.swift in Sources */,
65E127C9C91486AF86F9A693 /* SoundManager.swift in Sources */,
0D292BDC30C9D09CF4677FFA /* SpiderBoardView.swift in Sources */,
E15025574906B1F1C9A03C8F /* SpiderRules.swift in Sources */,
56D2056F3CA46D09F2D94F10 /* StatisticsView.swift in Sources */,
F713DA5821F2B37B2019CA1D /* StatsRecord.swift in Sources */,
70C69A048BE67CDD84E38B26 /* StatsViewModel.swift in Sources */,
F4C3C28A20ECA87EC97FD2DC /* Suit.swift in Sources */,
F3AA49C17343374CF1B82FF7 /* ThemeManager.swift in Sources */,
D9E61277B5EA0772F7B28BFF /* ThemePickerView.swift in Sources */,
A68F6A65F5897179FA3632BF /* TimerService.swift in Sources */,
81B92CA2BDA948B74EEDA6B9 /* VictoryOverlayView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
CE28FBA2CF52896AC36A1F13 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 82669FCF0B13F7341360B265 /* SoliCards */;
targetProxy = 0457F9A25F53E04E37959F48 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
09C2A01D89409D6B798959DA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = SoliCards;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.card-games";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.app;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
3703194FF4D985541587656D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.tests;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoliCards.app/SoliCards";
};
name = Release;
};
57E91C868A14077EF84052C1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.tests;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoliCards.app/SoliCards";
};
name = Debug;
};
6964E173671E2805B5C3C50E /* 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++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;
DEBUG_INFORMATION_FORMAT = dwarf;
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 = 17.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
C48643C0D86B6F2505BC1E97 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = SoliCards;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.card-games";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.app;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
DCF01AC30957B9AA76361647 /* 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++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;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
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 = 17.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6AE5403E93959B4E9E707F1D /* Build configuration list for PBXProject "SoliCards" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6964E173671E2805B5C3C50E /* Debug */,
DCF01AC30957B9AA76361647 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
6F270CC209153B7149368FF8 /* Build configuration list for PBXNativeTarget "SoliCardsTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
57E91C868A14077EF84052C1 /* Debug */,
3703194FF4D985541587656D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
F6C31F00417F79A014C90960 /* Build configuration list for PBXNativeTarget "SoliCards" */ = {
isa = XCConfigurationList;
buildConfigurations = (
09C2A01D89409D6B798959DA /* Debug */,
C48643C0D86B6F2505BC1E97 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 867199988082B6D71FDDE2D8 /* Project object */;
}

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -1,307 +0,0 @@
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@State private var viewModel = GameViewModel()
@State private var themeManager = ThemeManager()
@State private var cardFaceStyle: CardFaceStyle = .classic
@State private var cardBackDesign: CardBackDesign = .blue
@State private var showingRules = false
@State private var showingNewGame = false
@State private var showingSettings = false
@State private var showingStats = false
@State private var hasLoaded = false
var body: some View {
NavigationStack {
GameBoardView(
viewModel: viewModel,
theme: themeManager.currentTheme,
cardFaceStyle: cardFaceStyle,
cardBackDesign: cardBackDesign
)
.toolbar {
#if os(iOS)
ToolbarItemGroup(placement: .bottomBar) {
iOSToolbar
}
#else
ToolbarItemGroup(placement: .primaryAction) {
macToolbar
}
#endif
}
.navigationTitle(viewModel.variant.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .bottomBar)
.toolbarBackground(.ultraThinMaterial, for: .bottomBar)
.toolbarColorScheme(.dark, for: .bottomBar)
#endif
.tint(.white)
.sheet(isPresented: $showingRules) {
RulesView(variant: viewModel.variant)
}
.sheet(isPresented: $showingNewGame) {
NewGameSheet(
variant: viewModel.variant,
difficulty: viewModel.difficulty
) { newVariant, newDifficulty in
showingNewGame = false
viewModel.difficulty = newDifficulty
viewModel.changeVariant(to: newVariant)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView(
theme: Binding(
get: { themeManager.currentTheme },
set: { themeManager.applyTheme($0) }
),
cardFaceStyle: $cardFaceStyle,
cardBackDesign: $cardBackDesign,
soundEnabled: Binding(
get: { viewModel.isSoundEnabled },
set: { viewModel.isSoundEnabled = $0 }
)
)
}
.sheet(isPresented: $showingStats) {
StatisticsView()
}
}
.onAppear {
guard !hasLoaded else { return }
hasLoaded = true
let pm = PersistenceManager(modelContext: modelContext)
viewModel.persistenceManager = pm
let prefs = pm.loadPreferences()
if let theme = GameTheme.allThemes.first(where: { $0.id == prefs.themeId }) {
themeManager.applyTheme(theme)
}
if let style = CardFaceStyle(rawValue: prefs.cardFaceStyle) {
cardFaceStyle = style
}
if let back = CardBackDesign(rawValue: prefs.cardBackDesign) {
cardBackDesign = back
}
viewModel.isSoundEnabled = prefs.soundEnabled
if let savedGame = pm.loadMostRecentGame() {
viewModel.resumeGame(from: savedGame)
} else {
if let savedVariant = GameVariant(rawValue: prefs.lastVariant) {
viewModel.changeVariant(to: savedVariant)
} else {
viewModel.newGame()
}
}
}
.onChange(of: cardFaceStyle) { savePreferences() }
.onChange(of: cardBackDesign) { savePreferences() }
.onChange(of: themeManager.currentTheme) { savePreferences() }
.onChange(of: viewModel.isSoundEnabled) { savePreferences() }
#if os(iOS)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
viewModel.saveGameNow()
savePreferences()
}
#endif
#if os(macOS)
.frame(minWidth: 800, minHeight: 600)
#endif
}
private func savePreferences() {
viewModel.persistenceManager?.savePreferences(
themeId: themeManager.currentTheme.id,
cardFaceStyle: cardFaceStyle,
cardBackDesign: cardBackDesign,
soundEnabled: viewModel.isSoundEnabled,
lastVariant: viewModel.variant,
lastDifficulty: viewModel.difficulty
)
}
// MARK: - iOS Toolbar (bottom bar 5 items max, with labels)
@ViewBuilder
private var iOSToolbar: some View {
Button { showingNewGame = true } label: {
VStack(spacing: 2) {
Image(systemName: "plus.circle.fill")
.font(.title3)
Text("New")
.font(.caption2)
}
}
.keyboardShortcut("n", modifiers: .command)
Spacer()
Button { viewModel.undo() } label: {
VStack(spacing: 2) {
Image(systemName: "arrow.uturn.backward.circle.fill")
.font(.title3)
Text("Undo")
.font(.caption2)
}
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!viewModel.canUndo)
Spacer()
Button { viewModel.requestHint() } label: {
VStack(spacing: 2) {
Image(systemName: "lightbulb.fill")
.font(.title3)
Text("Hint")
.font(.caption2)
}
}
.keyboardShortcut("h", modifiers: [])
Spacer()
// "More" menu consolidates secondary actions
Menu {
// Game variants
Menu("Game Variant") {
ForEach(GameVariant.allCases) { variant in
Button {
viewModel.changeVariant(to: variant)
} label: {
HStack {
Text(variant.displayName)
if variant == viewModel.variant {
Image(systemName: "checkmark")
}
}
}
}
}
// Difficulty
Menu("Difficulty") {
ForEach(Difficulty.allCases) { diff in
Button {
viewModel.difficulty = diff
viewModel.newGame()
} label: {
HStack {
Text(diff.displayName)
if diff == viewModel.difficulty {
Image(systemName: "checkmark")
}
}
}
}
}
Divider()
Button { viewModel.autoComplete() } label: {
Label("Auto-Complete", systemImage: "wand.and.stars")
}
Button {
viewModel.isSoundEnabled.toggle()
} label: {
Label(viewModel.isSoundEnabled ? "Sound On" : "Sound Off",
systemImage: viewModel.isSoundEnabled ? "speaker.wave.2" : "speaker.slash")
}
Divider()
Button { showingRules = true } label: {
Label("Rules", systemImage: "book")
}
Button { showingStats = true } label: {
Label("Statistics", systemImage: "chart.bar")
}
Button { showingSettings = true } label: {
Label("Settings", systemImage: "gearshape")
}
} label: {
VStack(spacing: 2) {
Image(systemName: "ellipsis.circle.fill")
.font(.title3)
Text("More")
.font(.caption2)
}
}
}
// MARK: - macOS Toolbar (all items fit, no labels needed)
@ViewBuilder
private var macToolbar: some View {
Menu {
ForEach(GameVariant.allCases) { variant in
Button(variant.displayName) {
viewModel.changeVariant(to: variant)
}
}
Divider()
ForEach(Difficulty.allCases) { diff in
Button {
viewModel.difficulty = diff
viewModel.newGame()
} label: {
HStack {
Text(diff.displayName)
if diff == viewModel.difficulty {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("Game", systemImage: "suit.spade.fill")
}
Button { showingNewGame = true } label: {
Label("New Game", systemImage: "plus.circle")
}
.keyboardShortcut("n", modifiers: .command)
Button { viewModel.undo() } label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!viewModel.canUndo)
Button { viewModel.requestHint() } label: {
Label("Hint", systemImage: "lightbulb")
}
.keyboardShortcut("h", modifiers: [])
Button { viewModel.autoComplete() } label: {
Label("Auto", systemImage: "wand.and.stars")
}
Button { showingRules = true } label: {
Label("Rules", systemImage: "book")
}
Button { showingStats = true } label: {
Label("Stats", systemImage: "chart.bar")
}
Button { showingSettings = true } label: {
Label("Settings", systemImage: "gearshape")
}
Button {
viewModel.isSoundEnabled.toggle()
} label: {
Label("Sound", systemImage: viewModel.isSoundEnabled ? "speaker.wave.2" : "speaker.slash")
}
}
}

View file

@ -1,28 +0,0 @@
import Foundation
extension Array where Element == Card {
/// Returns the suffix of face-up cards from the end of the array.
var faceUpSuffix: [Card] {
var result: [Card] = []
for card in reversed() {
guard card.isFaceUp else { break }
result.insert(card, at: 0)
}
return result
}
/// Returns the top card (last element), or nil if empty.
var topCard: Card? { last }
}
extension Array {
/// Returns the longest suffix where all elements satisfy the predicate.
func trailingSuffix(while predicate: (Element) -> Bool) -> ArraySlice<Element> {
var startIndex = endIndex
for index in indices.reversed() {
guard predicate(self[index]) else { break }
startIndex = index
}
return self[startIndex..<endIndex]
}
}

View file

@ -1,79 +0,0 @@
import SwiftUI
struct CardLayout {
let availableSize: CGSize
let variant: GameVariant
let deepestColumn: (faceDown: Int, faceUp: Int)
var columnCount: Int { variant.tableauCount }
var isLandscape: Bool { availableSize.width > availableSize.height }
/// Card size constrained by the tighter of width or height.
var cardWidth: CGFloat {
let computed = min(widthConstrainedCardWidth, heightConstrainedCardWidth)
#if os(macOS)
// Cap card width on macOS so cards don't get absurdly large on big displays
return min(computed, 120)
#else
return computed
#endif
}
var cardHeight: CGFloat { cardWidth * 1.4 }
/// Padding between cards. In landscape, this expands to fill the available width
/// so cards spread evenly across the screen.
var horizontalPadding: CGFloat {
let basePadding = max(2, availableSize.width * 0.008)
// If height-constrained (landscape), distribute extra width as padding
if heightConstrainedCardWidth < widthConstrainedCardWidth {
let totalCardWidth = CGFloat(columnCount) * cardWidth
let availableForPadding = availableSize.width - totalCardWidth
let gaps = CGFloat(columnCount + 1)
return max(basePadding, availableForPadding / gaps)
}
return basePadding
}
var verticalOverlapFaceDown: CGFloat { cardHeight * 0.15 }
var verticalOverlapFaceUp: CGFloat { cardHeight * 0.25 }
var cornerRadius: CGFloat { cardWidth * 0.08 }
func cardSize() -> CGSize {
CGSize(width: cardWidth, height: cardHeight)
}
var touchTargetPadding: CGFloat {
max(0, (44 - cardWidth) / 2)
}
// MARK: - Private
private var basePadding: CGFloat {
max(2, availableSize.width * 0.008)
}
private var widthConstrainedCardWidth: CGFloat {
let totalPadding = basePadding * CGFloat(columnCount + 1)
return (availableSize.width - totalPadding) / CGFloat(columnCount)
}
private var heightConstrainedCardWidth: CGFloat {
let d = CGFloat(deepestColumn.faceDown)
let u = CGFloat(max(0, deepestColumn.faceUp - 1))
let totalFactor = 1.0 + 0.1 + (d * 0.15 + u * 0.25 + 1.0)
// Use 92% of available height to leave room for VStack spacing,
// bottom padding, and any unmeasured platform chrome
let usableHeight = availableSize.height * 0.92
let base = max(30, usableHeight / (1.4 * totalFactor))
#if os(iOS)
return isLandscape ? base * 1.3 : base
#else
return base
#endif
}
}

View file

@ -1,38 +0,0 @@
import Foundation
enum AutoCompleter {
/// Finds the next card that can be auto-moved to a foundation.
/// Returns the source location and foundation index, or nil if no move is available.
static func findNextAutoMove(state: GameSnapshot, rules: GameRules) -> (from: CardLocation, to: CardLocation)? {
// Check waste pile
if let topWaste = state.waste.last {
for (i, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topWaste, topCard: foundation.last) {
return (.waste, .foundation(i))
}
}
}
// Check free cells
for (cellIndex, cell) in state.freeCells.enumerated() {
guard let card = cell else { continue }
for (i, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
return (.freeCell(cellIndex), .foundation(i))
}
}
}
// Check tableau tops
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last, topCard.isFaceUp else { continue }
for (i, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
return (.tableau(tabIndex), .foundation(i))
}
}
}
return nil
}
}

View file

@ -1,215 +0,0 @@
import Foundation
struct FreeCellRules: GameRules {
let variant: GameVariant = .freeCell
func deal(deck: [Card]) -> GameSnapshot {
var remaining = deck
var tableaus: [[Card]] = []
// Deal all 52 cards to 8 tableaus, all face-up
// First 4 tableaus get 7 cards, last 4 get 6 cards
for i in 0..<8 {
let cardCount = i < 4 ? 7 : 6
var column: [Card] = []
for _ in 0..<cardCount {
var card = remaining.removeFirst()
card.isFaceUp = true
column.append(card)
}
tableaus.append(column)
}
return GameSnapshot(
tableaus: tableaus,
foundations: [[], [], [], []],
stock: [],
waste: [],
freeCells: [nil, nil, nil, nil],
moves: 0,
score: 0
)
}
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
guard let firstCard = cards.first else { return false }
switch to {
case .foundation(let index):
guard cards.count == 1 else { return false }
guard index >= 0, index < state.foundations.count else { return false }
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: state.foundations[index].last)
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
let maxMovable = calculateMaxMovableCards(state: state, targetEmpty: state.tableaus[index].isEmpty)
guard cards.count <= maxMovable else { return false }
let tableau = state.tableaus[index]
if tableau.isEmpty { return true }
guard let topCard = tableau.last else { return false }
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
&& MoveValidator.isDescending(firstCard, onto: topCard)
case .freeCell(let index):
guard cards.count == 1 else { return false }
guard index >= 0, index < state.freeCells.count else { return false }
return state.freeCells[index] == nil
default:
return false
}
}
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
nil // FreeCell has no stock
}
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
switch (from, to) {
case (_, .foundation): return 10
case (.freeCell, .tableau): return 5
default: return 0
}
}
func isWon(state: GameSnapshot) -> Bool {
state.foundations.allSatisfy { $0.count == 13 }
}
func canAutoComplete(state: GameSnapshot) -> Bool {
// All tableau cards must be face-up and in descending order
let allFaceUp = state.tableaus.allSatisfy { $0.allSatisfy { $0.isFaceUp } }
guard allFaceUp else { return false }
// Check that all tableaus are in valid descending alternating-color order
for tableau in state.tableaus {
for i in 1..<tableau.count {
guard MoveValidator.isAlternatingColor(tableau[i], with: tableau[i - 1]),
MoveValidator.isDescending(tableau[i], onto: tableau[i - 1]) else {
return false
}
}
}
return true
}
/// Power move formula: (1 + empty_freecells) × 2^empty_tableaus
func calculateMaxMovableCards(state: GameSnapshot, targetEmpty: Bool) -> Int {
let emptyFreeCells = state.freeCells.filter { $0 == nil }.count
// Don't count the target tableau as empty if we're moving to an empty tableau
let emptyTableaus = state.tableaus.filter { $0.isEmpty }.count - (targetEmpty ? 1 : 0)
let adjustedEmptyTableaus = max(0, emptyTableaus)
return (1 + emptyFreeCells) * Int(pow(2.0, Double(adjustedEmptyTableaus)))
}
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
guard settings.hintsEnabled else { return [] }
var hints: [HintResult] = []
// Priority 1: Aces to foundation (from tableaus and free cells)
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last, MoveValidator.isAce(topCard) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
}
}
}
for (cellIndex, cell) in state.freeCells.enumerated() {
guard let card = cell, MoveValidator.isAce(card) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 1))
}
}
}
// Priority 2: Other foundation moves
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last, !MoveValidator.isAce(topCard) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
}
}
}
for (cellIndex, cell) in state.freeCells.enumerated() {
guard let card = cell, !MoveValidator.isAce(card) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 2))
}
}
}
// Priority 3: Tableau to tableau moves
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last else { continue }
for (destIndex, _) in state.tableaus.enumerated() {
guard destIndex != tabIndex else { continue }
if canMove(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
}
}
}
// Priority 4: Free cell to tableau
for (cellIndex, cell) in state.freeCells.enumerated() {
guard let card = cell else { continue }
for (tabIndex, _) in state.tableaus.enumerated() {
if canMove(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), state: state) {
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), priority: 4))
}
}
}
// Priority 5: Move to free cell
if state.freeCells.contains(where: { $0 == nil }) {
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last else { continue }
if let emptyCell = state.freeCells.firstIndex(where: { $0 == nil }) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .freeCell(emptyCell), priority: 5))
}
}
}
return hints.sorted { $0.priority < $1.priority }
}
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
MoveValidator.isAlternatingColor(card, with: target)
&& MoveValidator.isDescending(card, onto: target)
}
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
guard !cards.isEmpty else { return false }
switch from {
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
// All cards must be face-up and form a valid alternating-color descending sequence
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
for i in 1..<cards.count {
guard MoveValidator.isAlternatingColor(cards[i], with: cards[i - 1]),
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
return false
}
}
return true
case .freeCell(let index):
guard cards.count == 1, index >= 0, index < state.freeCells.count else { return false }
return state.freeCells[index] == cards.first
case .foundation(let index):
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
return cards[0] == state.foundations[index].last
default:
return false
}
}
}

View file

@ -1,61 +0,0 @@
import Foundation
protocol GameRules: Sendable {
var variant: GameVariant { get }
/// Deal cards from a shuffled deck into the initial board layout.
func deal(deck: [Card]) -> GameSnapshot
/// Check if moving the given cards from source to destination is valid.
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool
/// Return all valid destinations for the given cards from a source location.
func validDestinations(for cards: [Card], from: CardLocation, state: GameSnapshot) -> [CardLocation]
/// Draw card(s) from the stock pile.
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction?
/// Calculate the score change for a given move.
func scoreForMove(from: CardLocation, to: CardLocation) -> Int
/// Check if the game is won.
func isWon(state: GameSnapshot) -> Bool
/// Check if auto-complete is possible (all remaining cards are face-up and ordered).
func canAutoComplete(state: GameSnapshot) -> Bool
/// Find available hints, ordered by priority (1 = highest).
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult]
/// Check if a card can be stacked onto another card on a tableau.
func canStackOnTableau(card: Card, onto target: Card) -> Bool
/// Check if a group of cards can be picked up from a location.
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool
}
extension GameRules {
func validDestinations(for cards: [Card], from: CardLocation, state: GameSnapshot) -> [CardLocation] {
var destinations: [CardLocation] = []
for i in 0..<state.foundations.count {
if canMove(cards: cards, from: from, to: .foundation(i), state: state) {
destinations.append(.foundation(i))
}
}
for i in 0..<state.tableaus.count {
if canMove(cards: cards, from: from, to: .tableau(i), state: state) {
destinations.append(.tableau(i))
}
}
for i in 0..<state.freeCells.count {
if canMove(cards: cards, from: from, to: .freeCell(i), state: state) {
destinations.append(.freeCell(i))
}
}
return destinations
}
}

View file

@ -1,11 +0,0 @@
import Foundation
enum GameRulesFactory {
static func rules(for variant: GameVariant) -> GameRules {
switch variant {
case .klondike: KlondikeRules()
case .spider: SpiderRules()
case .freeCell: FreeCellRules()
}
}
}

View file

@ -1,77 +0,0 @@
import Foundation
import Observation
@Observable
final class GameState {
var tableaus: [[Card]]
var foundations: [[Card]]
var stock: [Card]
var waste: [Card]
var freeCells: [Card?]
var moves: Int = 0
var score: Int = 0
var phase: GamePhase = .notStarted
private(set) var history: [GameSnapshot] = []
private let maxHistorySize = 20
var canUndo: Bool { !history.isEmpty }
init() {
self.tableaus = []
self.foundations = []
self.stock = []
self.waste = []
self.freeCells = []
}
func snapshot() -> GameSnapshot {
GameSnapshot(
tableaus: tableaus,
foundations: foundations,
stock: stock,
waste: waste,
freeCells: freeCells,
moves: moves,
score: score
)
}
func restore(from snapshot: GameSnapshot) {
tableaus = snapshot.tableaus
foundations = snapshot.foundations
stock = snapshot.stock
waste = snapshot.waste
freeCells = snapshot.freeCells
moves = snapshot.moves
score = snapshot.score
}
func pushHistory() {
history.append(snapshot())
if history.count > maxHistorySize {
history.removeFirst()
}
}
func popHistory() -> GameSnapshot? {
history.popLast()
}
func clearHistory() {
history.removeAll()
}
func reset(from snapshot: GameSnapshot, variant: GameVariant) {
restore(from: snapshot)
phase = .playing
clearHistory()
if variant.hasFreeCells {
freeCells = Array(repeating: nil, count: variant.freeCellCount)
} else {
freeCells = []
}
}
}

View file

@ -1,200 +0,0 @@
import Foundation
struct KlondikeRules: GameRules {
let variant: GameVariant = .klondike
func deal(deck: [Card]) -> GameSnapshot {
var remaining = deck
var tableaus: [[Card]] = []
// Deal 7 tableaus: column i gets i+1 cards, top card face-up
for i in 0..<7 {
var column: [Card] = []
for j in 0...i {
var card = remaining.removeFirst()
card.isFaceUp = (j == i)
column.append(card)
}
tableaus.append(column)
}
return GameSnapshot(
tableaus: tableaus,
foundations: [[], [], [], []],
stock: remaining,
waste: [],
freeCells: [],
moves: 0,
score: 0
)
}
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
guard let firstCard = cards.first else { return false }
switch to {
case .foundation(let index):
guard cards.count == 1 else { return false }
guard index >= 0, index < state.foundations.count else { return false }
let foundation = state.foundations[index]
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: foundation.last)
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
let tableau = state.tableaus[index]
if tableau.isEmpty {
return MoveValidator.isKing(firstCard)
}
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
&& MoveValidator.isDescending(firstCard, onto: topCard)
case .waste, .stock, .freeCell:
return false
}
}
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
if state.stock.isEmpty {
guard !state.waste.isEmpty else { return nil }
// Reset: move waste back to stock, reversed
state.stock = state.waste.reversed().map { card in
var c = card
c.isFaceUp = false
return c
}
state.waste = []
return MoveAction(cards: [], from: .waste, to: .stock, didFlipCard: false, scoreChange: 0)
}
var drawn: [Card] = []
for _ in 0..<min(drawCount, state.stock.count) {
var card = state.stock.removeLast()
card.isFaceUp = true
drawn.append(card)
}
state.waste.append(contentsOf: drawn)
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
}
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
switch (from, to) {
case (.waste, .foundation): return 10
case (.tableau, .foundation): return 10
case (.waste, .tableau): return 5
case (.foundation, .tableau): return -15
default: return 0
}
}
func isWon(state: GameSnapshot) -> Bool {
state.foundations.allSatisfy { $0.count == 13 }
}
func canAutoComplete(state: GameSnapshot) -> Bool {
guard state.stock.isEmpty, state.waste.isEmpty else { return false }
// All tableau cards must be face-up
return state.tableaus.allSatisfy { column in
column.allSatisfy { $0.isFaceUp }
}
}
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
guard settings.hintsEnabled else { return [] }
var hints: [HintResult] = []
// Priority 1: Aces to foundation
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last, topCard.isFaceUp, MoveValidator.isAce(topCard) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
}
}
}
// Priority 1: Ace from waste
if let wasteTop = state.waste.last, MoveValidator.isAce(wasteTop) {
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(wasteTop, topCard: foundation.last) {
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .foundation(fIndex), priority: 1))
}
}
}
// Priority 2: Other foundation moves
for (tabIndex, tableau) in state.tableaus.enumerated() {
guard let topCard = tableau.last, topCard.isFaceUp, !MoveValidator.isAce(topCard) else { continue }
for (fIndex, foundation) in state.foundations.enumerated() {
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
}
}
}
// Priority 3: Tableau moves that reveal face-down cards
for (tabIndex, tableau) in state.tableaus.enumerated() {
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
guard !faceUpCards.isEmpty else { continue }
let hasFaceDown = tableau.count > faceUpCards.count
guard hasFaceDown else { continue }
for (destIndex, _) in state.tableaus.enumerated() {
guard destIndex != tabIndex else { continue }
let cardsToMove = Array(faceUpCards)
if canMove(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
hints.append(HintResult(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
}
}
}
// Priority 4: Waste to tableau
if let wasteTop = state.waste.last {
for (tabIndex, _) in state.tableaus.enumerated() {
if canMove(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), state: state) {
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), priority: 4))
}
}
}
// Priority 5: Draw from stock
if !state.stock.isEmpty {
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
}
return hints.sorted { $0.priority < $1.priority }
}
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
MoveValidator.isAlternatingColor(card, with: target)
&& MoveValidator.isDescending(card, onto: target)
}
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
guard !cards.isEmpty else { return false }
switch from {
case .waste:
return cards.count == 1 && cards[0] == state.waste.last
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
let tableau = state.tableaus[index]
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
// Cards must be a valid sequence from the bottom of the face-up portion
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
guard cards.count <= faceUpCards.count else { return false }
let startIdx = faceUpCards.count - cards.count
let expectedCards = Array(faceUpCards.dropFirst(startIdx))
return cards == expectedCards
case .foundation(let index):
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
return cards[0] == state.foundations[index].last
default:
return false
}
}
}

View file

@ -1,41 +0,0 @@
import Foundation
enum MoveValidator {
/// True if the two cards have alternating colors (one red, one black).
static func isAlternatingColor(_ card: Card, with other: Card) -> Bool {
card.color != other.color
}
/// True if both cards share the same suit.
static func isSameSuit(_ card: Card, as other: Card) -> Bool {
card.suit == other.suit
}
/// True if card's rank is exactly one higher than target (for foundation building AK).
static func isAscending(_ card: Card, onto target: Card) -> Bool {
card.rank.rawValue == target.rank.rawValue + 1
}
/// True if card's rank is exactly one lower than target (for tableau stacking KA).
static func isDescending(_ card: Card, onto target: Card) -> Bool {
card.rank.rawValue == target.rank.rawValue - 1
}
/// True if the card is an Ace.
static func isAce(_ card: Card) -> Bool {
card.rank == .ace
}
/// True if the card is a King.
static func isKing(_ card: Card) -> Bool {
card.rank == .king
}
/// True if the card can be placed on a foundation pile (same suit, ascending).
static func canPlaceOnFoundation(_ card: Card, topCard: Card?) -> Bool {
guard let topCard else {
return isAce(card)
}
return isSameSuit(card, as: topCard) && isAscending(card, onto: topCard)
}
}

View file

@ -1,195 +0,0 @@
import Foundation
struct SpiderRules: GameRules {
let variant: GameVariant = .spider
func deal(deck: [Card]) -> GameSnapshot {
var remaining = deck
var tableaus: [[Card]] = []
// Deal 10 tableaus: first 4 get 6 cards, last 6 get 5 cards, top face-up
for i in 0..<10 {
let cardCount = i < 4 ? 6 : 5
var column: [Card] = []
for j in 0..<cardCount {
var card = remaining.removeFirst()
card.isFaceUp = (j == cardCount - 1)
column.append(card)
}
tableaus.append(column)
}
return GameSnapshot(
tableaus: tableaus,
foundations: Array(repeating: [], count: 8),
stock: remaining,
waste: [],
freeCells: [],
moves: 0,
score: 0
)
}
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
guard let firstCard = cards.first else { return false }
switch to {
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
let tableau = state.tableaus[index]
if tableau.isEmpty { return true }
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
return MoveValidator.isDescending(firstCard, onto: topCard)
case .foundation:
// Foundations are auto-filled when a complete KA same-suit sequence is formed
return false
default:
return false
}
}
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
// Spider deals one card to each tableau (must have at least one card per tableau)
guard !state.stock.isEmpty else { return nil }
guard state.tableaus.allSatisfy({ !$0.isEmpty }) else { return nil }
var drawn: [Card] = []
let dealCount = min(state.tableaus.count, state.stock.count)
for i in 0..<dealCount {
var card = state.stock.removeLast()
card.isFaceUp = true
state.tableaus[i].append(card)
drawn.append(card)
}
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
}
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
switch (from, to) {
case (.tableau, .foundation): return 100
case (.tableau, .tableau): return 1
default: return 0
}
}
func isWon(state: GameSnapshot) -> Bool {
state.foundations.allSatisfy { $0.count == 13 }
}
func canAutoComplete(state: GameSnapshot) -> Bool {
guard state.stock.isEmpty else { return false }
return state.tableaus.allSatisfy { column in
column.allSatisfy { $0.isFaceUp }
}
}
/// Check if the top cards of a tableau form a complete KA same-suit sequence.
func isCompleteSequence(in tableau: [Card]) -> Bool {
guard tableau.count >= 13 else { return false }
let sequence = tableau.suffix(13)
let suit = sequence.first!.suit
for (offset, card) in sequence.enumerated() {
guard card.isFaceUp,
card.suit == suit,
card.rank.rawValue == 13 - offset else {
return false
}
}
return true
}
/// Check all tableaus for complete sequences and move them to foundations.
func checkAndMoveCompleteSequences(state: inout GameSnapshot) -> Bool {
var foundComplete = false
for tabIndex in 0..<state.tableaus.count {
if isCompleteSequence(in: state.tableaus[tabIndex]) {
let sequence = Array(state.tableaus[tabIndex].suffix(13))
state.tableaus[tabIndex].removeLast(13)
// Find first empty foundation
if let fIndex = state.foundations.firstIndex(where: { $0.isEmpty }) {
state.foundations[fIndex] = sequence
foundComplete = true
}
// Flip new top card
if let lastIndex = state.tableaus[tabIndex].indices.last,
!state.tableaus[tabIndex][lastIndex].isFaceUp {
state.tableaus[tabIndex][lastIndex].isFaceUp = true
}
}
}
return foundComplete
}
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
guard settings.hintsEnabled else { return [] }
var hints: [HintResult] = []
for (tabIndex, tableau) in state.tableaus.enumerated() {
let faceUpRun = sameSuitDescendingRun(in: tableau)
guard !faceUpRun.isEmpty else { continue }
for (destIndex, _) in state.tableaus.enumerated() {
guard destIndex != tabIndex else { continue }
if canMove(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
let priority = (tableau.count - faceUpRun.count > 0 &&
!tableau[tableau.count - faceUpRun.count - 1].isFaceUp) ? 2 : 4
hints.append(HintResult(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), priority: priority))
}
}
}
if !state.stock.isEmpty {
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
}
return hints.sorted { $0.priority < $1.priority }
}
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
MoveValidator.isDescending(card, onto: target)
}
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
guard !cards.isEmpty else { return false }
switch from {
case .tableau(let index):
guard index >= 0, index < state.tableaus.count else { return false }
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
// Must be a same-suit descending sequence
for i in 1..<cards.count {
guard MoveValidator.isSameSuit(cards[i], as: cards[i - 1]),
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
return false
}
}
return true
default:
return false
}
}
// MARK: - Helpers
/// Returns the longest same-suit descending run from the bottom of the face-up portion.
private func sameSuitDescendingRun(in tableau: [Card]) -> [Card] {
let faceUp = Array(tableau.trailingSuffix(while: { $0.isFaceUp }))
guard !faceUp.isEmpty else { return [] }
var run = [faceUp.last!]
for i in stride(from: faceUp.count - 2, through: 0, by: -1) {
let card = faceUp[i]
guard MoveValidator.isSameSuit(card, as: run[0]),
card.rank.rawValue == run[0].rank.rawValue + 1 else {
break
}
run.insert(card, at: 0)
}
return run
}
}

View file

@ -1,52 +0,0 @@
import Foundation
struct Card: Identifiable, Equatable, Hashable, Codable, Sendable {
let id: UUID
let suit: Suit
let rank: Rank
var isFaceUp: Bool
init(suit: Suit, rank: Rank, isFaceUp: Bool = false) {
self.id = UUID()
self.suit = suit
self.rank = rank
self.isFaceUp = isFaceUp
}
var color: Suit.Color { suit.color }
var accessibilityDescription: String {
if isFaceUp {
"\(rank.displayName) of \(suit.displayName)"
} else {
"Card, face down"
}
}
func frontImageName(style: CardFaceStyle) -> String {
"\(style.rawValue)_\(suit.rawValue)_\(rank.fileName)"
}
}
enum CardFaceStyle: String, CaseIterable, Codable, Sendable {
case classic, modern, extracted
}
enum CardBackDesign: Int, CaseIterable, Codable, Sendable {
case abstractClouds = 1
case abstractScene
case abstract
case astronaut
case blue
case blue2
case cars
case castle
case fish
case frog
case red
case red2
var imageName: String {
"back_\(String(format: "%02d", rawValue))"
}
}

View file

@ -1,9 +0,0 @@
import Foundation
enum CardLocation: Equatable, Hashable, Codable, Sendable {
case tableau(Int)
case foundation(Int)
case waste
case stock
case freeCell(Int)
}

View file

@ -1,29 +0,0 @@
import Foundation
enum Deck {
/// Creates a standard 52-card deck, shuffled.
static func standard() -> [Card] {
var cards: [Card] = []
for suit in Suit.allCases {
for rank in Rank.allCases {
cards.append(Card(suit: suit, rank: rank))
}
}
cards.shuffle()
return cards
}
/// Creates a double deck (104 cards) for Spider solitaire, shuffled.
static func double() -> [Card] {
var cards: [Card] = []
for _ in 0..<2 {
for suit in Suit.allCases {
for rank in Rank.allCases {
cards.append(Card(suit: suit, rank: rank))
}
}
}
cards.shuffle()
return cards
}
}

View file

@ -1,59 +0,0 @@
import Foundation
enum Difficulty: String, CaseIterable, Codable, Identifiable, Sendable {
case easy, medium, hard, expert
var id: String { rawValue }
var displayName: String { rawValue.capitalized }
var settings: DifficultySettings {
switch self {
case .easy:
DifficultySettings(
drawCount: 1,
maxUndos: .max,
hintsEnabled: true,
scoreMultiplier: 0.5,
timePenalty: 0,
autoFlipDelay: 0.5
)
case .medium:
DifficultySettings(
drawCount: 3,
maxUndos: 20,
hintsEnabled: true,
scoreMultiplier: 1.0,
timePenalty: 2,
autoFlipDelay: 0.3
)
case .hard:
DifficultySettings(
drawCount: 3,
maxUndos: 10,
hintsEnabled: false,
scoreMultiplier: 1.5,
timePenalty: 5,
autoFlipDelay: 0.2
)
case .expert:
DifficultySettings(
drawCount: 3,
maxUndos: 5,
hintsEnabled: false,
scoreMultiplier: 2.0,
timePenalty: 10,
autoFlipDelay: 0.1
)
}
}
}
struct DifficultySettings: Sendable {
let drawCount: Int
let maxUndos: Int
let hintsEnabled: Bool
let scoreMultiplier: Double
let timePenalty: Int
let autoFlipDelay: TimeInterval
}

View file

@ -1,9 +0,0 @@
import Foundation
enum GamePhase: Equatable, Sendable {
case notStarted
case playing
case paused
case autoCompleting
case won
}

View file

@ -1,11 +0,0 @@
import Foundation
struct GameSnapshot: Codable, Sendable {
var tableaus: [[Card]]
var foundations: [[Card]]
var stock: [Card]
var waste: [Card]
var freeCells: [Card?]
var moves: Int
var score: Int
}

View file

@ -1,65 +0,0 @@
import Foundation
enum GameVariant: String, CaseIterable, Codable, Identifiable, Sendable {
case klondike
case spider
case freeCell
var id: String { rawValue }
var displayName: String {
switch self {
case .klondike: "Klondike"
case .spider: "Spider"
case .freeCell: "FreeCell"
}
}
var tableauCount: Int {
switch self {
case .klondike: 7
case .spider: 10
case .freeCell: 8
}
}
var foundationCount: Int {
switch self {
case .klondike: 4
case .spider: 8
case .freeCell: 4
}
}
var deckCount: Int {
switch self {
case .klondike, .freeCell: 1
case .spider: 2
}
}
var hasWaste: Bool {
switch self {
case .klondike, .spider: true
case .freeCell: false
}
}
var hasFreeCells: Bool {
self == .freeCell
}
var freeCellCount: Int {
switch self {
case .freeCell: 4
case .klondike, .spider: 0
}
}
var hasStock: Bool {
switch self {
case .klondike, .spider: true
case .freeCell: false
}
}
}

View file

@ -1,8 +0,0 @@
import Foundation
struct HintResult: Sendable {
let cards: [Card]
let from: CardLocation
let to: CardLocation
let priority: Int
}

View file

@ -1,9 +0,0 @@
import Foundation
struct MoveAction: Codable, Sendable {
let cards: [Card]
let from: CardLocation
let to: CardLocation
let didFlipCard: Bool
let scoreChange: Int
}

View file

@ -1,58 +0,0 @@
import Foundation
enum Rank: Int, CaseIterable, Codable, Comparable, Sendable {
case ace = 1
case two, three, four, five, six, seven
case eight, nine, ten, jack, queen, king
static func < (lhs: Rank, rhs: Rank) -> Bool {
lhs.rawValue < rhs.rawValue
}
var displayName: String {
switch self {
case .ace: "Ace"
case .two: "2"
case .three: "3"
case .four: "4"
case .five: "5"
case .six: "6"
case .seven: "7"
case .eight: "8"
case .nine: "9"
case .ten: "10"
case .jack: "Jack"
case .queen: "Queen"
case .king: "King"
}
}
var shortName: String {
switch self {
case .ace: "A"
case .jack: "J"
case .queen: "Q"
case .king: "K"
default: "\(rawValue)"
}
}
/// File name component for asset lookup
var fileName: String {
switch self {
case .ace: "ace"
case .two: "2"
case .three: "3"
case .four: "4"
case .five: "5"
case .six: "6"
case .seven: "7"
case .eight: "8"
case .nine: "9"
case .ten: "10"
case .jack: "jack"
case .queen: "queen"
case .king: "king"
}
}
}

View file

@ -1,29 +0,0 @@
import Foundation
enum Suit: String, CaseIterable, Codable, Sendable {
case spades, hearts, diamonds, clubs
enum Color: Sendable {
case red, black
}
var color: Color {
switch self {
case .hearts, .diamonds: .red
case .spades, .clubs: .black
}
}
var symbol: String {
switch self {
case .spades: ""
case .hearts: ""
case .diamonds: ""
case .clubs: ""
}
}
var displayName: String {
rawValue.capitalized
}
}

View file

@ -1,31 +0,0 @@
import Foundation
import SwiftData
@Model
final class GameRecord {
var variant: String
var difficulty: String
var snapshotData: Data
var elapsedSeconds: Int
var lastPlayedDate: Date
init(variant: GameVariant, difficulty: Difficulty, snapshot: GameSnapshot, elapsedSeconds: Int) {
self.variant = variant.rawValue
self.difficulty = difficulty.rawValue
self.snapshotData = (try? JSONEncoder().encode(snapshot)) ?? Data()
self.elapsedSeconds = elapsedSeconds
self.lastPlayedDate = Date()
}
var decodedSnapshot: GameSnapshot? {
try? JSONDecoder().decode(GameSnapshot.self, from: snapshotData)
}
var gameVariant: GameVariant? {
GameVariant(rawValue: variant)
}
var gameDifficulty: Difficulty? {
Difficulty(rawValue: difficulty)
}
}

View file

@ -1,109 +0,0 @@
import Foundation
import SwiftData
@MainActor
final class PersistenceManager {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
// MARK: - Game State
func saveGame(variant: GameVariant, difficulty: Difficulty, snapshot: GameSnapshot, elapsedSeconds: Int) {
// Delete any existing save for this variant
deleteSavedGame(for: variant)
let record = GameRecord(variant: variant, difficulty: difficulty,
snapshot: snapshot, elapsedSeconds: elapsedSeconds)
modelContext.insert(record)
try? modelContext.save()
}
func loadSavedGame(for variant: GameVariant) -> GameRecord? {
let variantRaw = variant.rawValue
let descriptor = FetchDescriptor<GameRecord>(
predicate: #Predicate { $0.variant == variantRaw },
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
)
return try? modelContext.fetch(descriptor).first
}
func loadMostRecentGame() -> GameRecord? {
let descriptor = FetchDescriptor<GameRecord>(
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
)
return try? modelContext.fetch(descriptor).first
}
func deleteSavedGame(for variant: GameVariant) {
let variantRaw = variant.rawValue
let descriptor = FetchDescriptor<GameRecord>(
predicate: #Predicate { $0.variant == variantRaw }
)
if let records = try? modelContext.fetch(descriptor) {
for record in records {
modelContext.delete(record)
}
}
}
// MARK: - Statistics
func recordWin(variant: GameVariant, difficulty: Difficulty, score: Int, time: Int) {
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
record.recordWin(score: score, time: time)
try? modelContext.save()
}
func recordLoss(variant: GameVariant, difficulty: Difficulty) {
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
record.recordLoss()
try? modelContext.save()
}
func fetchStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord? {
let variantRaw = variant.rawValue
let difficultyRaw = difficulty.rawValue
let descriptor = FetchDescriptor<StatsRecord>(
predicate: #Predicate { $0.variant == variantRaw && $0.difficulty == difficultyRaw }
)
return try? modelContext.fetch(descriptor).first
}
private func fetchOrCreateStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord {
if let existing = fetchStats(variant: variant, difficulty: difficulty) {
return existing
}
let record = StatsRecord(variant: variant, difficulty: difficulty)
modelContext.insert(record)
return record
}
// MARK: - Preferences
func loadPreferences() -> PrefsRecord {
let descriptor = FetchDescriptor<PrefsRecord>()
if let existing = try? modelContext.fetch(descriptor).first {
return existing
}
let record = PrefsRecord()
modelContext.insert(record)
try? modelContext.save()
return record
}
func savePreferences(themeId: String, cardFaceStyle: CardFaceStyle,
cardBackDesign: CardBackDesign, soundEnabled: Bool,
lastVariant: GameVariant, lastDifficulty: Difficulty) {
let prefs = loadPreferences()
prefs.themeId = themeId
prefs.cardFaceStyle = cardFaceStyle.rawValue
prefs.cardBackDesign = cardBackDesign.rawValue
prefs.soundEnabled = soundEnabled
prefs.lastVariant = lastVariant.rawValue
prefs.lastDifficulty = lastDifficulty.rawValue
try? modelContext.save()
}
}

View file

@ -1,21 +0,0 @@
import Foundation
import SwiftData
@Model
final class PrefsRecord {
var themeId: String
var cardFaceStyle: String
var cardBackDesign: Int
var soundEnabled: Bool
var lastVariant: String
var lastDifficulty: String
init() {
self.themeId = GameTheme.classicGreen.id
self.cardFaceStyle = CardFaceStyle.classic.rawValue
self.cardBackDesign = CardBackDesign.blue.rawValue
self.soundEnabled = true
self.lastVariant = GameVariant.klondike.rawValue
self.lastDifficulty = Difficulty.medium.rawValue
}
}

View file

@ -1,44 +0,0 @@
import Foundation
import SwiftData
@Model
final class StatsRecord {
var variant: String
var difficulty: String
var gamesPlayed: Int
var gamesWon: Int
var bestScore: Int
var bestTime: Int
var currentStreak: Int
var bestStreak: Int
init(variant: GameVariant, difficulty: Difficulty) {
self.variant = variant.rawValue
self.difficulty = difficulty.rawValue
self.gamesPlayed = 0
self.gamesWon = 0
self.bestScore = 0
self.bestTime = Int.max
self.currentStreak = 0
self.bestStreak = 0
}
func recordWin(score: Int, time: Int) {
gamesPlayed += 1
gamesWon += 1
if score > bestScore { bestScore = score }
if time < bestTime { bestTime = time }
currentStreak += 1
if currentStreak > bestStreak { bestStreak = currentStreak }
}
func recordLoss() {
gamesPlayed += 1
currentStreak = 0
}
var winRate: Double {
guard gamesPlayed > 0 else { return 0 }
return Double(gamesWon) / Double(gamesPlayed)
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,74 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "AppIcon_16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon_32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon_32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon_64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon_128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon_256.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon_256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon_512.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon_512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

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

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_01.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_02.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_03.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_04.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_05.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_06.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_07.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_08.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_09.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_10.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_11.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "back_12.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_10.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_2.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_3.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_4.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_5.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_6.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_7.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_8.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_9.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_ace.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_jack.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_king.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_clubs_queen.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_diamonds_10.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_diamonds_2.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_diamonds_3.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_diamonds_4.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "classic_diamonds_5.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Some files were not shown because too many files have changed in this diff Show more