feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS

Complete native rewrite of the web-based SoliCards game as a SwiftUI
multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+.

Three solitaire variants (Klondike, Spider, FreeCell) with full game
rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels,
SwiftData persistence, VoiceOver accessibility, and 57 unit tests.

Key features:
- MVVM + Protocol-Oriented Strategy architecture
- DragGesture with coordinate-space hit-testing (long press + drag)
- Smart zoom: cards auto-size to fit screen based on deepest column
- Landscape: 30% bigger cards with scrollable overflow (iOS)
- macOS: 120pt card cap, 92% height buffer for window resizing
- Auto-save, game resume, statistics tracking via SwiftData
- Privacy manifest, app icon, String Catalog, zero dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
idev2025 2026-04-14 07:33:52 -04:00
parent 25fb5d1b6f
commit 0f989f5c86
212 changed files with 6768 additions and 0 deletions

4
.gitignore vendored
View file

@ -6,3 +6,7 @@ node_modules/
xcuserdata/ xcuserdata/
.build/ .build/
DerivedData/ DerivedData/
.claude/settings.json
.claude/settings.local.json
*.xcodeproj/xcuserdata/
*.xcodeproj/project.xcworkspace/xcuserdata/

81
APP_STORE_METADATA.md Normal file
View file

@ -0,0 +1,81 @@
# 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

102
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,102 @@
# 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.

View file

@ -0,0 +1,78 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-04-14
### Fixed
- **macOS maximized window cutoff** — cards no longer overflow the bottom when the window is maximized. Height calculation uses 92% of available space as buffer for platform chrome and spacing.
- **macOS card size cap** — maximum card width capped at 120pt on macOS to prevent oversized cards on large displays. Extra space distributed as wider inter-card gaps.
- **iOS landscape boost scoped to iOS only** — the 30% landscape card size boost and 130% scroll content height no longer apply on macOS, where they caused overflow.
## [1.1.0] - 2026-04-14
### Changed
- **Drag and drop rewritten** — replaced `DragGesture` offset-based system with absolute position tracking (`dragPosition: CGPoint`). Cards now follow the finger exactly instead of appearing displaced.
- **Drop target resolution moved to ViewModel**`endDrag(at:)` method checks drop targets directly, eliminating the need for a separate global gesture on the board.
- **Long press + drag** — card dragging now requires a 0.15s long press before drag begins, disambiguating from scroll gestures and tap-to-move.
- **Gesture priority fixed** — card drag uses `.simultaneousGesture()` instead of `.gesture()` so tap-to-move (`onTapGesture`) is never blocked. Tapping an ace now reliably moves it to the foundation.
- **Bottom toolbar redesigned (iOS)** — reduced from 9 cramped icons to 4 clear items with labels: New, Undo, Hint, More (menu). Secondary actions (variant, difficulty, auto-complete, sound, rules, stats, settings) consolidated into the "More" menu.
- **Toolbar contrast fixed** — bottom bar uses `.ultraThinMaterial` background with `.toolbarColorScheme(.dark)` so icons are readable against all game themes.
- **Top row aligned to tableau columns** — stock at column 0, waste at column 1, gap at column 2, foundations at columns 36. No more `Spacer()` pushing elements to opposite edges.
### Added
- **Smart zoom** — card size dynamically adapts to the actual deepest tableau column so the entire board fits on screen without scrolling. Cards shrink as columns grow during play, and expand when columns shorten.
- **Landscape mode improvements:**
- 30% larger cards than the base height calculation
- Extra horizontal space distributed as wider inter-card gaps to fill the screen
- Vertical scrolling with long-press drag disambiguation (swipe = scroll, hold + drag = move card)
- Scroll content sized to 130% of viewport to guarantee scrollability
- Canvas background responds to scroll gestures (`.contentShape(Rectangle())`)
- Scroll automatically disabled during card drags to prevent interference
- **Stock pile recycling indicator** — empty stock shows an `arrow.clockwise` icon so users know to tap to recycle the waste pile.
### Fixed
- **Cards no longer displaced during drag** — root cause was overlay positioned with `.offset(translation)` from ZStack origin instead of `.position(location)` at the finger.
- **Tap-to-move works on iPad**`.simultaneousGesture()` prevents long press from swallowing taps.
- **ScrollView no longer fights card drags**`.scrollDisabled()` activates when a card drag is in progress.
- **Score bar no longer steals card area** — moved outside `GeometryReader` so card layout measures the actual play area, not the area minus an inaccurate chrome guess.
- **Static analyzer warnings resolved** — removed unused `freeCellsEmpty` variable, unused `destTableau` binding, fixed `@MainActor` isolation on `HapticManager`.
## [1.0.0] - 2026-04-13
### Added
- **Three solitaire variants:** Klondike, Spider (2-deck), and FreeCell with full rule implementations
- **Four difficulty levels:** Easy, Medium, Hard, Expert — each with distinct draw counts, undo limits, hint availability, and score multipliers
- **Drag and drop** card movement using SwiftUI DragGesture with coordinate-space hit-testing
- **Tap to move** cards to the best available destination (foundation preferred)
- **Undo system** with up to 20 states of history
- **Hint system** with 5-priority move suggestions (aces first, then foundations, then tableau reveals)
- **Auto-complete** detection and animated execution when all remaining cards are face-up
- **Win detection** with animated victory overlay
- **Six color themes:** Classic Green, Dark Mode, Ocean Blue, Royal Purple, Forest Green, Sunset Orange
- **12 card back designs** and 3 card face styles (classic, modern, extracted)
- **Game persistence:** auto-save on every move (debounced 2s), resume on app relaunch
- **Statistics tracking:** wins, losses, best score, best time, current/best streak per variant per difficulty
- **User preferences:** theme, card style, card back, sound toggle — all persisted via SwiftData
- **Sound effects:** card flip, place, error, victory (AVFoundation)
- **Haptic feedback** on iOS (UIImpactFeedbackGenerator)
- **Full VoiceOver accessibility:** labels, hints, and traits on all interactive elements
- **Dynamic Type** support on all text
- **Reduce Motion** support — animations skipped when user preference is enabled
- **Localization infrastructure:** String Catalog (Localizable.xcstrings) with 40+ strings, English source
- **Keyboard shortcuts:** Cmd+Z (undo), Cmd+N (new game), H (hint)
- **Privacy manifest** (PrivacyInfo.xcprivacy): no tracking, no data collection
- **App icon** with all required sizes for iOS and macOS
- **57 unit tests** across 12 test suites covering models, game engine, move validation, auto-complete, and game state
- **Multiplatform support:** iOS 17+, iPadOS 17+, macOS 14+ from a single target
### Architecture
- MVVM with Protocol-Oriented Strategy pattern
- `GameRules` protocol with `KlondikeRules`, `SpiderRules`, `FreeCellRules` implementations
- `@Observable` macro (Observation framework) for property-level UI updates
- `@MainActor` isolation for thread safety
- SwiftData for persistence (GameRecord, StatsRecord, PrefsRecord)
- Zero external dependencies

52
CLAUDE.md Normal file
View file

@ -0,0 +1,52 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Project Is
SoliCards is a native SwiftUI solitaire card game for iOS 17+, iPadOS 17+, and macOS 14+. It was ported from a web-based JavaScript game (`game-SoliCards/SoliCards.html`) following the 7-phase workflow defined in [PROMPT.md](PROMPT.md).
## Build Commands
```bash
# Generate Xcode project (required after cloning or editing project.yml)
xcodegen generate
# Build for iOS Simulator
xcodebuild build -project SoliCards.xcodeproj -scheme SoliCards -destination 'platform=iOS Simulator,name=iPhone 16'
# Build for macOS
xcodebuild build -project SoliCards.xcodeproj -scheme SoliCards -destination 'platform=macOS' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
# Run tests
xcodebuild test -project SoliCards.xcodeproj -scheme SoliCardsTests -destination 'platform=iOS Simulator,name=iPhone 16'
# Static analysis
xcodebuild analyze -project SoliCards.xcodeproj -scheme SoliCards -destination 'platform=iOS Simulator,name=iPhone 16'
```
## Architecture
**MVVM + Protocol-Oriented Strategy** — see [ARCHITECTURE.md](ARCHITECTURE.md) for full details.
- `GameRules` protocol with 3 conforming structs: `KlondikeRules`, `SpiderRules`, `FreeCellRules`
- `@Observable` macro (not `ObservableObject`) for property-level SwiftUI observation
- `@MainActor` on ViewModels and TimerService for Swift 6 concurrency safety
- `DragGesture` + `PreferenceKey` frame hit-testing for card drag & drop (not `onDrag`/`onDrop`)
- SwiftData for persistence (`GameRecord`, `StatsRecord`, `PrefsRecord`)
- Zero external dependencies
## Key Conventions
- `.xcodeproj` is generated by xcodegen from `project.yml` — do not edit it by hand
- `Array.trailingSuffix(while:)` extension replaces missing stdlib `suffix(while:)` — used in rules engines for face-up card runs
- Card images are loaded from the asset catalog via platform-conditional helpers (`UIImage`/`NSImage`) in `CardView`
- Sound playback is dispatched via `nonisolated func playSound()` to avoid `@MainActor` isolation on the audio path
## Test Suite
57 tests across 12 suites. All tests are in `SoliCardsTests/` using Swift Testing (`@Test`, `#expect`).
Key test coverage areas:
- `GameEngine/` — KlondikeRules, SpiderRules, FreeCellRules, MoveValidator, AutoCompleter, GameState
- `Models/` — Card, Deck, Difficulty, GameVariant, Rank, Suit

96
PROMPT.md Normal file
View file

@ -0,0 +1,96 @@
<!-- PROMPT METADATA -->
<!-- Model: Claude Opus | Mode: 🗺️ PLAN | Research: OFF | Style: Technical | Connectors: None -->
<task>
Xcode + Claude Code — Code-to-App-Store Workflow
</task>
<context>
I will point you to a local folder containing existing code (partial app, prototype, web-based code, or raw logic). Your job is to act as a senior iOS/macOS engineer and guide me — step by step, without skipping — through transforming that code into a fully functional, Apple App Store-ready application. You are working alongside me inside Xcode via Claude Code. Quality and correctness are the only priorities. Do not rush. Do not consolidate steps to save time.
</context>
<requirements>
## Phase 0 — Codebase Intake & Audit
- Read every file in the target folder before taking any action.
- Produce a structured audit report covering: language(s) detected, frameworks used, entry points, data models, UI layer (if any), external dependencies, build system, and missing pieces.
- Identify what platform this is being built for: iOS, macOS, iPadOS, watchOS, visionOS, or a multi-platform target. Confirm with me before proceeding.
- Flag any code that is incompatible with Swift/SwiftUI/UIKit and propose a migration or bridging strategy.
- Do not write a single line of new code until the audit is reviewed and I approve the plan.
## Phase 1 — Architecture & Project Setup
- Propose an architecture pattern appropriate to the app's scope (MV, MVVM, TCA, etc.) with written justification.
- Scaffold or audit the Xcode project structure: targets, schemes, build configurations (Debug/Release/TestFlight), bundle ID, and deployment target.
- Set up or verify: Swift Package Manager dependencies, entitlements, Info.plist keys, and capability requirements (push notifications, iCloud, camera, location, etc.).
- Establish a folder structure following Apple's recommended conventions.
- Configure SwiftLint (or equivalent) with a project-appropriate ruleset.
- Confirm all of the above with me before moving to Phase 2.
## Phase 2 — Core Feature Implementation
- Implement or refactor features one at a time. Never bundle multiple features into a single step.
- For each feature:
1. Write the logic layer first (models, services, view models).
2. Write unit tests before or alongside the implementation (TDD where practical).
3. Implement the UI layer last.
4. Run a build check after each feature. Stop and fix any errors before continuing.
- Use SwiftUI by default unless UIKit is explicitly required for a specific component.
- Follow Apple Human Interface Guidelines for every UI component — spacing, typography, touch targets, dynamic type, and color semantics.
- Support both light and dark mode for every view. No hardcoded colors — use semantic color assets.
- Support Dynamic Type on all text. No hardcoded font sizes.
- Implement proper error handling with user-facing messages. No silent failures.
## Phase 3 — Data, Persistence & Networking
- Use Swift Concurrency (async/await, actors) — no completion handler callbacks unless bridging legacy code.
- For local persistence: use SwiftData (preferred for new projects) or Core Data with a proper migration strategy.
- For networking: use URLSession with structured concurrency. Define typed API response models. Handle all HTTP error codes explicitly.
- Implement proper loading, empty, and error states for every data-dependent view.
- Store all secrets (API keys, tokens) in the Keychain — never in UserDefaults or Info.plist.
## Phase 4 — Accessibility & Localization
- Audit every view for VoiceOver compatibility: labels, hints, traits, and grouping.
- Ensure all interactive elements meet the 44×44pt minimum touch target.
- Implement localization infrastructure (Localizable.strings or String Catalogs) even if launching English-only — future-proof from day one.
- Test with Accessibility Inspector before marking this phase complete.
## Phase 5 — Performance & Quality
- Profile with Instruments: Memory (Leaks, Allocations), Time Profiler, and Hangs. Fix any issues found.
- Eliminate all purple runtime warnings, main-thread checker violations, and memory leaks.
- Optimize image assets: use SF Symbols where possible, provide @1x/@2x/@3x or vector assets for everything else.
- Audit app launch time. Cold launch must be under 400ms on the minimum supported device.
- Run the full test suite. Achieve minimum 70% code coverage on business logic. All tests must pass.
## Phase 6 — App Store Preparation
- Configure all required app metadata: bundle ID, version, build number, privacy manifest (PrivacyInfo.xcprivacy).
- Implement App Tracking Transparency prompt if any third-party analytics or advertising SDKs are present.
- Write and include a complete Privacy Policy URL (placeholder if needed) in the Info.plist and App Store Connect listing.
- Create all required app icons (using an Asset Catalog with a single 1024×1024 source image).
- Create all required launch screen assets. Use a LaunchScreen.storyboard or Info.plist key — never a static image only.
- Prepare App Store screenshots specification (device sizes, required orientations).
- Complete an App Store Connect metadata checklist: app name, subtitle, description, keywords, category, age rating, and in-app purchase declarations.
- Perform a full Archive build in Release configuration. Validate the archive against App Store requirements using Xcode's built-in validator.
- Run `xcodebuild analyze` and resolve every static analyzer warning.
- Submit to TestFlight and confirm the build processes without rejection before marking the project complete.
## Phase 7 — Handoff Documentation
- Generate a SETUP.md covering: prerequisites, environment setup, build instructions, scheme descriptions, and how to run tests.
- Generate a CHANGELOG.md following Keep a Changelog format.
- Document every non-obvious architectural decision in an ARCHITECTURE.md.
- Create a .env.example if any environment-specific configuration exists.
</requirements>
<constraints>
- Never skip a phase or combine phases without my explicit approval.
- Never mark a phase complete if there are compiler errors, warnings, or failing tests.
- Always show me a summary of what you're about to do before doing it. Wait for my go-ahead.
- When you encounter ambiguity (missing requirements, unclear UI behavior, unknown business logic), stop and ask — do not assume and proceed.
- All code must be production-quality: no TODO comments left in final output, no print() debug statements, no force-unwraps unless explicitly justified in a comment.
- Target the latest stable Xcode version and iOS/macOS SDK unless I specify otherwise.
</constraints>
<output_format>
At the start of each phase, output a Phase Header like:
---
## 🔵 Phase N — [Name] | Status: IN PROGRESS
Then list the steps you will execute as a numbered checklist. Check off each item as it completes. At the end of each phase, output a Phase Summary with: what was done, any decisions made, any open questions, and a prompt asking me to approve moving to the next phase.
</output_format>

127
SETUP.md Normal file
View file

@ -0,0 +1,127 @@
# 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

@ -0,0 +1,823 @@
// !$*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

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

307
SoliCards/ContentView.swift Normal file
View file

@ -0,0 +1,307 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,79 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,215 @@
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

@ -0,0 +1,61 @@
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

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

View file

@ -0,0 +1,77 @@
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

@ -0,0 +1,200 @@
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

@ -0,0 +1,41 @@
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

@ -0,0 +1,195 @@
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

@ -0,0 +1,52 @@
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

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

View file

@ -0,0 +1,29 @@
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

@ -0,0 +1,59 @@
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

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

View file

@ -0,0 +1,11 @@
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

@ -0,0 +1,65 @@
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

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

View file

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

View file

@ -0,0 +1,58 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,109 @@
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

@ -0,0 +1,21 @@
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

@ -0,0 +1,44 @@
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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,74 @@
{
"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

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

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

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