diff --git a/.gitignore b/.gitignore index 00bfcfa..1b2a48a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ node_modules/ xcuserdata/ .build/ DerivedData/ +.claude/settings.json +.claude/settings.local.json +*.xcodeproj/xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ diff --git a/APP_STORE_METADATA.md b/APP_STORE_METADATA.md new file mode 100644 index 0000000..b5b5df9 --- /dev/null +++ b/APP_STORE_METADATA.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8b8ae19 --- /dev/null +++ b/ARCHITECTURE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..74feada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 3–6. 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6b541e9 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..41bcb62 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,96 @@ + + + + +Xcode + Claude Code — Code-to-App-Store Workflow + + + +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. + + + + +## 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. + + + + +- 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. + + + +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. + \ No newline at end of file diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..f15e488 --- /dev/null +++ b/SETUP.md @@ -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. diff --git a/SoliCards.xcodeproj/project.pbxproj b/SoliCards.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bb25564 --- /dev/null +++ b/SoliCards.xcodeproj/project.pbxproj @@ -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 = ""; }; + 0A61A63D3A6077A80ED09B24 /* TimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerService.swift; sourceTree = ""; }; + 0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; + 0C4319F36333564A75A3FEB0 /* SpiderRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRules.swift; sourceTree = ""; }; + 115321F0A0475538BA016151 /* AutoCompleter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleter.swift; sourceTree = ""; }; + 116E45BE9C698645A16CF6F3 /* Suit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suit.swift; sourceTree = ""; }; + 1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsRecord.swift; sourceTree = ""; }; + 16BEB4EF07C82D8B2A89D260 /* Deck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deck.swift; sourceTree = ""; }; + 1B25C883D3B49CADA34775F1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 1EE2B9027264A1134D2B65DC /* CardStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStackView.swift; sourceTree = ""; }; + 2065F4D438686C32E01E449F /* SpiderBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderBoardView.swift; sourceTree = ""; }; + 2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoliCardsApp.swift; sourceTree = ""; }; + 22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRules.swift; sourceTree = ""; }; + 23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = ""; }; + 25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropTargetPreferenceKey.swift; sourceTree = ""; }; + 26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRulesFactory.swift; sourceTree = ""; }; + 26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRules.swift; sourceTree = ""; }; + 2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSnapshot.swift; sourceTree = ""; }; + 2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveAction.swift; sourceTree = ""; }; + 396230FC7779389B46BE1246 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; + 3DD303449EB1172C9B52E624 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = ""; }; + 403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeBoardView.swift; sourceTree = ""; }; + 488C04322FF37B821C662516 /* GamePhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePhase.swift; sourceTree = ""; }; + 4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecord.swift; sourceTree = ""; }; + 4B7D93BC01A3997DD183D368 /* CardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardTests.swift; sourceTree = ""; }; + 4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLayout.swift; sourceTree = ""; }; + 58632F749B766B0E79DD0152 /* DifficultyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DifficultyTests.swift; sourceTree = ""; }; + 5E14435814D19CE147D6D408 /* GameBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameBoardView.swift; sourceTree = ""; }; + 634429F416F1A484018E084E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = ""; }; + 66617C01DF8B874B51A55295 /* StatsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsRecord.swift; sourceTree = ""; }; + 6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rank.swift; sourceTree = ""; }; + 714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 72E42967DA3AA21AEE42F177 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; + 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 = ""; }; + 864D497FD148C1DCA5761247 /* HintResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HintResult.swift; sourceTree = ""; }; + 94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleterTests.swift; sourceTree = ""; }; + 9810F299E93134997859B2BD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A64A7B696016187523AD7277 /* Difficulty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Difficulty.swift; sourceTree = ""; }; + A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Card.swift"; sourceTree = ""; }; + A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRules.swift; sourceTree = ""; }; + A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStateTests.swift; sourceTree = ""; }; + AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceManager.swift; sourceTree = ""; }; + AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellBoardView.swift; sourceTree = ""; }; + B1321C0FD7F644967F371B7A /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = ""; }; + B31155959181B8D516A82096 /* KlondikeRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRulesTests.swift; sourceTree = ""; }; + B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsViewModel.swift; sourceTree = ""; }; + BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRulesTests.swift; sourceTree = ""; }; + C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggedCardsOverlay.swift; sourceTree = ""; }; + C0B4677EC6C93B738F443D86 /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = ""; }; + C38D83697D866ACA495ED4B8 /* MoveValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidator.swift; sourceTree = ""; }; + C4901135D3529A476DEF089F /* MoveValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidatorTests.swift; sourceTree = ""; }; + C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreBarView.swift; sourceTree = ""; }; + C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRulesTests.swift; sourceTree = ""; }; + CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLocation.swift; sourceTree = ""; }; + CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardBackPickerView.swift; sourceTree = ""; }; + CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VictoryOverlayView.swift; sourceTree = ""; }; + CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameTheme.swift; sourceTree = ""; }; + CD3488A79053BB9BF5BF6C39 /* GameState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameState.swift; sourceTree = ""; }; + DA638FEAAD15EBE1E78FFE14 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; + DB81C7AA5EDF842B3D841907 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E6AE9622A04A543946250427 /* MainMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuView.swift; sourceTree = ""; }; + EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStylePickerView.swift; sourceTree = ""; }; + F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameVariant.swift; sourceTree = ""; }; + FDF0B14B437E3A3475278EC5 /* DeckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 08AF7AF8C77BED3EFA6A9AEA /* Statistics */ = { + isa = PBXGroup; + children = ( + 634429F416F1A484018E084E /* StatisticsView.swift */, + ); + path = Statistics; + sourceTree = ""; + }; + 0FF95606A40279B4D3390FBA = { + isa = PBXGroup; + children = ( + 2182AF78CEA93B7431E85608 /* SoliCards */, + D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */, + 9CCB9EC5C71BA2DF24919C93 /* Products */, + ); + sourceTree = ""; + }; + 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 = ""; + }; + 2D71FE366319D21065F80CFD /* Persistence */ = { + isa = PBXGroup; + children = ( + 4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */, + AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */, + 1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */, + 66617C01DF8B874B51A55295 /* StatsRecord.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + 3271C08BF426FD74787C6217 /* Resources */ = { + isa = PBXGroup; + children = ( + DB81C7AA5EDF842B3D841907 /* Assets.xcassets */, + 714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */, + 8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */, + AB3F10466EA26B5E0B5827A8 /* Sounds */, + ); + path = Resources; + sourceTree = ""; + }; + 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 = ""; + }; + 45A518EB42C0CDB893716BBE /* Settings */ = { + isa = PBXGroup; + children = ( + CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */, + EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */, + 1B25C883D3B49CADA34775F1 /* SettingsView.swift */, + 23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */ = { + isa = PBXGroup; + children = ( + ); + path = ViewModels; + sourceTree = ""; + }; + 9CCB9EC5C71BA2DF24919C93 /* Products */ = { + isa = PBXGroup; + children = ( + 7561129FE301D2A5E3652648 /* SoliCards.app */, + 7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + A1D36CB5F370E4F3906E319A /* Services */ = { + isa = PBXGroup; + children = ( + 0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */, + 3DD303449EB1172C9B52E624 /* SoundManager.swift */, + 0A61A63D3A6077A80ED09B24 /* TimerService.swift */, + ); + path = Services; + sourceTree = ""; + }; + A5353C257EB10EB9526C6E39 /* Menu */ = { + isa = PBXGroup; + children = ( + E6AE9622A04A543946250427 /* MainMenuView.swift */, + B1321C0FD7F644967F371B7A /* NewGameSheet.swift */, + 04FAD6B4305ABFB4769DE792 /* RulesView.swift */, + ); + path = Menu; + sourceTree = ""; + }; + A8376EC525EE5E88D4517BA1 /* Theme */ = { + isa = PBXGroup; + children = ( + CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */, + 396230FC7779389B46BE1246 /* ThemeManager.swift */, + ); + path = Theme; + sourceTree = ""; + }; + AB3F10466EA26B5E0B5827A8 /* Sounds */ = { + isa = PBXGroup; + children = ( + ); + path = Sounds; + sourceTree = ""; + }; + C6C973AB5DCD1C0E766F633A /* ViewModels */ = { + isa = PBXGroup; + children = ( + C0B4677EC6C93B738F443D86 /* GameViewModel.swift */, + C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */, + B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */ = { + isa = PBXGroup; + children = ( + DB86ADC0F0581A7B56EBE28E /* GameEngine */, + E5E67911DEBB2793B3D8F1AD /* Models */, + 7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */, + ); + path = SoliCardsTests; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + E4D19DAEAABB74B9FDD84B73 /* Extensions */ = { + isa = PBXGroup; + children = ( + A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */, + 4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + E5E67911DEBB2793B3D8F1AD /* Models */ = { + isa = PBXGroup; + children = ( + 4B7D93BC01A3997DD183D368 /* CardTests.swift */, + FDF0B14B437E3A3475278EC5 /* DeckTests.swift */, + 58632F749B766B0E79DD0152 /* DifficultyTests.swift */, + ); + path = Models; + sourceTree = ""; + }; + F84CF6704CBB01C0C9875CE2 /* Views */ = { + isa = PBXGroup; + children = ( + DA0714078CFC7F8BCD993F8C /* Game */, + A5353C257EB10EB9526C6E39 /* Menu */, + 45A518EB42C0CDB893716BBE /* Settings */, + 08AF7AF8C77BED3EFA6A9AEA /* Statistics */, + ); + path = Views; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 */; +} diff --git a/SoliCards.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SoliCards.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SoliCards.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SoliCards/ContentView.swift b/SoliCards/ContentView.swift new file mode 100644 index 0000000..3fd34f9 --- /dev/null +++ b/SoliCards/ContentView.swift @@ -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") + } + } +} diff --git a/SoliCards/Extensions/Array+Card.swift b/SoliCards/Extensions/Array+Card.swift new file mode 100644 index 0000000..b964342 --- /dev/null +++ b/SoliCards/Extensions/Array+Card.swift @@ -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 { + var startIndex = endIndex + for index in indices.reversed() { + guard predicate(self[index]) else { break } + startIndex = index + } + return self[startIndex.. 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 + } +} diff --git a/SoliCards/GameEngine/AutoCompleter.swift b/SoliCards/GameEngine/AutoCompleter.swift new file mode 100644 index 0000000..dc29dc6 --- /dev/null +++ b/SoliCards/GameEngine/AutoCompleter.swift @@ -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 + } +} diff --git a/SoliCards/GameEngine/FreeCellRules.swift b/SoliCards/GameEngine/FreeCellRules.swift new file mode 100644 index 0000000..fb2732f --- /dev/null +++ b/SoliCards/GameEngine/FreeCellRules.swift @@ -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.. 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.. 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..= 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 + } + } +} diff --git a/SoliCards/GameEngine/GameRules.swift b/SoliCards/GameEngine/GameRules.swift new file mode 100644 index 0000000..bc68b70 --- /dev/null +++ b/SoliCards/GameEngine/GameRules.swift @@ -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.. GameRules { + switch variant { + case .klondike: KlondikeRules() + case .spider: SpiderRules() + case .freeCell: FreeCellRules() + } + } +} diff --git a/SoliCards/GameEngine/GameState.swift b/SoliCards/GameEngine/GameState.swift new file mode 100644 index 0000000..dc2969f --- /dev/null +++ b/SoliCards/GameEngine/GameState.swift @@ -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 = [] + } + } +} diff --git a/SoliCards/GameEngine/KlondikeRules.swift b/SoliCards/GameEngine/KlondikeRules.swift new file mode 100644 index 0000000..90138af --- /dev/null +++ b/SoliCards/GameEngine/KlondikeRules.swift @@ -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.. 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 + } + } +} diff --git a/SoliCards/GameEngine/MoveValidator.swift b/SoliCards/GameEngine/MoveValidator.swift new file mode 100644 index 0000000..5b8c4c6 --- /dev/null +++ b/SoliCards/GameEngine/MoveValidator.swift @@ -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 A→K). + 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 K→A). + 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) + } +} diff --git a/SoliCards/GameEngine/SpiderRules.swift b/SoliCards/GameEngine/SpiderRules.swift new file mode 100644 index 0000000..2a8a55d --- /dev/null +++ b/SoliCards/GameEngine/SpiderRules.swift @@ -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.. 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 K→A 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.. 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 K→A 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.. [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.. [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 + } +} diff --git a/SoliCards/Models/Card.swift b/SoliCards/Models/Card.swift new file mode 100644 index 0000000..40060fe --- /dev/null +++ b/SoliCards/Models/Card.swift @@ -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))" + } +} diff --git a/SoliCards/Models/CardLocation.swift b/SoliCards/Models/CardLocation.swift new file mode 100644 index 0000000..9f837e5 --- /dev/null +++ b/SoliCards/Models/CardLocation.swift @@ -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) +} diff --git a/SoliCards/Models/Deck.swift b/SoliCards/Models/Deck.swift new file mode 100644 index 0000000..acee203 --- /dev/null +++ b/SoliCards/Models/Deck.swift @@ -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 + } +} diff --git a/SoliCards/Models/Difficulty.swift b/SoliCards/Models/Difficulty.swift new file mode 100644 index 0000000..73dce3f --- /dev/null +++ b/SoliCards/Models/Difficulty.swift @@ -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 +} diff --git a/SoliCards/Models/GamePhase.swift b/SoliCards/Models/GamePhase.swift new file mode 100644 index 0000000..5bb3ebc --- /dev/null +++ b/SoliCards/Models/GamePhase.swift @@ -0,0 +1,9 @@ +import Foundation + +enum GamePhase: Equatable, Sendable { + case notStarted + case playing + case paused + case autoCompleting + case won +} diff --git a/SoliCards/Models/GameSnapshot.swift b/SoliCards/Models/GameSnapshot.swift new file mode 100644 index 0000000..9488204 --- /dev/null +++ b/SoliCards/Models/GameSnapshot.swift @@ -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 +} diff --git a/SoliCards/Models/GameVariant.swift b/SoliCards/Models/GameVariant.swift new file mode 100644 index 0000000..5fdf00d --- /dev/null +++ b/SoliCards/Models/GameVariant.swift @@ -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 + } + } +} diff --git a/SoliCards/Models/HintResult.swift b/SoliCards/Models/HintResult.swift new file mode 100644 index 0000000..0c8dc35 --- /dev/null +++ b/SoliCards/Models/HintResult.swift @@ -0,0 +1,8 @@ +import Foundation + +struct HintResult: Sendable { + let cards: [Card] + let from: CardLocation + let to: CardLocation + let priority: Int +} diff --git a/SoliCards/Models/MoveAction.swift b/SoliCards/Models/MoveAction.swift new file mode 100644 index 0000000..7cc5fda --- /dev/null +++ b/SoliCards/Models/MoveAction.swift @@ -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 +} diff --git a/SoliCards/Models/Rank.swift b/SoliCards/Models/Rank.swift new file mode 100644 index 0000000..2aab4eb --- /dev/null +++ b/SoliCards/Models/Rank.swift @@ -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" + } + } +} diff --git a/SoliCards/Models/Suit.swift b/SoliCards/Models/Suit.swift new file mode 100644 index 0000000..98aef7f --- /dev/null +++ b/SoliCards/Models/Suit.swift @@ -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 + } +} diff --git a/SoliCards/Persistence/GameRecord.swift b/SoliCards/Persistence/GameRecord.swift new file mode 100644 index 0000000..3a29c6f --- /dev/null +++ b/SoliCards/Persistence/GameRecord.swift @@ -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) + } +} diff --git a/SoliCards/Persistence/PersistenceManager.swift b/SoliCards/Persistence/PersistenceManager.swift new file mode 100644 index 0000000..005c66a --- /dev/null +++ b/SoliCards/Persistence/PersistenceManager.swift @@ -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( + predicate: #Predicate { $0.variant == variantRaw }, + sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)] + ) + return try? modelContext.fetch(descriptor).first + } + + func loadMostRecentGame() -> GameRecord? { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)] + ) + return try? modelContext.fetch(descriptor).first + } + + func deleteSavedGame(for variant: GameVariant) { + let variantRaw = variant.rawValue + let descriptor = FetchDescriptor( + 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( + 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() + 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() + } +} diff --git a/SoliCards/Persistence/PrefsRecord.swift b/SoliCards/Persistence/PrefsRecord.swift new file mode 100644 index 0000000..f5dce0e --- /dev/null +++ b/SoliCards/Persistence/PrefsRecord.swift @@ -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 + } +} diff --git a/SoliCards/Persistence/StatsRecord.swift b/SoliCards/Persistence/StatsRecord.swift new file mode 100644 index 0000000..213dc2b --- /dev/null +++ b/SoliCards/Persistence/StatsRecord.swift @@ -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) + } +} diff --git a/SoliCards/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/SoliCards/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..eca79a6 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_128.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_128.png new file mode 100644 index 0000000..2f22373 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_128.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_16.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_16.png new file mode 100644 index 0000000..7a01c1c Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_16.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_256.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_256.png new file mode 100644 index 0000000..836bc38 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_256.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_32.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_32.png new file mode 100644 index 0000000..74040fe Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_32.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_512.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_512.png new file mode 100644 index 0000000..5bd81e6 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_512.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_64.png b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_64.png new file mode 100644 index 0000000..2b0dd2e Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon_64.png differ diff --git a/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9d1f6e8 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/Contents.json b/SoliCards/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_01.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_01.imageset/Contents.json new file mode 100644 index 0000000..3c3809b --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_01.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_01.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_01.imageset/back_01.png b/SoliCards/Resources/Assets.xcassets/back_01.imageset/back_01.png new file mode 100644 index 0000000..563556a Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_01.imageset/back_01.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_02.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_02.imageset/Contents.json new file mode 100644 index 0000000..1612a17 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_02.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_02.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_02.imageset/back_02.png b/SoliCards/Resources/Assets.xcassets/back_02.imageset/back_02.png new file mode 100644 index 0000000..d85e83b Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_02.imageset/back_02.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_03.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_03.imageset/Contents.json new file mode 100644 index 0000000..49ffafb --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_03.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_03.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_03.imageset/back_03.png b/SoliCards/Resources/Assets.xcassets/back_03.imageset/back_03.png new file mode 100644 index 0000000..47c9b3b Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_03.imageset/back_03.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_04.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_04.imageset/Contents.json new file mode 100644 index 0000000..230cb4f --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_04.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_04.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_04.imageset/back_04.png b/SoliCards/Resources/Assets.xcassets/back_04.imageset/back_04.png new file mode 100644 index 0000000..87eeee6 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_04.imageset/back_04.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_05.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_05.imageset/Contents.json new file mode 100644 index 0000000..af24007 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_05.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_05.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_05.imageset/back_05.png b/SoliCards/Resources/Assets.xcassets/back_05.imageset/back_05.png new file mode 100644 index 0000000..f24b21a Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_05.imageset/back_05.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_06.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_06.imageset/Contents.json new file mode 100644 index 0000000..32c1b0f --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_06.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_06.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_06.imageset/back_06.png b/SoliCards/Resources/Assets.xcassets/back_06.imageset/back_06.png new file mode 100644 index 0000000..313e608 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_06.imageset/back_06.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_07.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_07.imageset/Contents.json new file mode 100644 index 0000000..00cb6a0 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_07.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_07.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_07.imageset/back_07.png b/SoliCards/Resources/Assets.xcassets/back_07.imageset/back_07.png new file mode 100644 index 0000000..0eab442 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_07.imageset/back_07.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_08.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_08.imageset/Contents.json new file mode 100644 index 0000000..7c39174 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_08.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_08.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_08.imageset/back_08.png b/SoliCards/Resources/Assets.xcassets/back_08.imageset/back_08.png new file mode 100644 index 0000000..dad8d95 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_08.imageset/back_08.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_09.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_09.imageset/Contents.json new file mode 100644 index 0000000..59e48ae --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_09.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_09.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_09.imageset/back_09.png b/SoliCards/Resources/Assets.xcassets/back_09.imageset/back_09.png new file mode 100644 index 0000000..f10ad18 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_09.imageset/back_09.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_10.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_10.imageset/Contents.json new file mode 100644 index 0000000..e1aab8e --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_10.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_10.imageset/back_10.png b/SoliCards/Resources/Assets.xcassets/back_10.imageset/back_10.png new file mode 100644 index 0000000..81f4f77 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_10.imageset/back_10.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_11.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_11.imageset/Contents.json new file mode 100644 index 0000000..66bf304 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_11.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_11.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_11.imageset/back_11.png b/SoliCards/Resources/Assets.xcassets/back_11.imageset/back_11.png new file mode 100644 index 0000000..176afd7 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_11.imageset/back_11.png differ diff --git a/SoliCards/Resources/Assets.xcassets/back_12.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/back_12.imageset/Contents.json new file mode 100644 index 0000000..e29b561 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/back_12.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "back_12.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/back_12.imageset/back_12.png b/SoliCards/Resources/Assets.xcassets/back_12.imageset/back_12.png new file mode 100644 index 0000000..5160634 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/back_12.imageset/back_12.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/Contents.json new file mode 100644 index 0000000..cadbb8f --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_10.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/classic_clubs_10.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/classic_clubs_10.png new file mode 100644 index 0000000..f9158e9 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/classic_clubs_10.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/Contents.json new file mode 100644 index 0000000..35c9414 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/classic_clubs_2.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/classic_clubs_2.png new file mode 100644 index 0000000..e655ea9 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/classic_clubs_2.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/Contents.json new file mode 100644 index 0000000..f16e5dc --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/classic_clubs_3.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/classic_clubs_3.png new file mode 100644 index 0000000..ace0ff5 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/classic_clubs_3.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/Contents.json new file mode 100644 index 0000000..c8af9c0 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/classic_clubs_4.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/classic_clubs_4.png new file mode 100644 index 0000000..9d58b9e Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/classic_clubs_4.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/Contents.json new file mode 100644 index 0000000..321ee4e --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/classic_clubs_5.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/classic_clubs_5.png new file mode 100644 index 0000000..37d870c Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/classic_clubs_5.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/Contents.json new file mode 100644 index 0000000..fd86cb6 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_6.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/classic_clubs_6.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/classic_clubs_6.png new file mode 100644 index 0000000..f2da6b4 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/classic_clubs_6.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/Contents.json new file mode 100644 index 0000000..630b2ee --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_7.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/classic_clubs_7.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/classic_clubs_7.png new file mode 100644 index 0000000..8de0a4e Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/classic_clubs_7.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/Contents.json new file mode 100644 index 0000000..badd999 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_8.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/classic_clubs_8.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/classic_clubs_8.png new file mode 100644 index 0000000..bc59f18 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/classic_clubs_8.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/Contents.json new file mode 100644 index 0000000..8603075 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_9.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/classic_clubs_9.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/classic_clubs_9.png new file mode 100644 index 0000000..0b69ca1 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/classic_clubs_9.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/Contents.json new file mode 100644 index 0000000..a3d1913 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_ace.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/classic_clubs_ace.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/classic_clubs_ace.png new file mode 100644 index 0000000..09c52b6 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/classic_clubs_ace.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/Contents.json new file mode 100644 index 0000000..c257f8e --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_jack.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/classic_clubs_jack.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/classic_clubs_jack.png new file mode 100644 index 0000000..fbf5f7a Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/classic_clubs_jack.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/Contents.json new file mode 100644 index 0000000..4bd227b --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_king.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/classic_clubs_king.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/classic_clubs_king.png new file mode 100644 index 0000000..d4e8b14 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/classic_clubs_king.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/Contents.json new file mode 100644 index 0000000..5fa5b9d --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_clubs_queen.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/classic_clubs_queen.png b/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/classic_clubs_queen.png new file mode 100644 index 0000000..71add12 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/classic_clubs_queen.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/Contents.json new file mode 100644 index 0000000..58d268b --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_10.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/classic_diamonds_10.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/classic_diamonds_10.png new file mode 100644 index 0000000..ad6b845 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/classic_diamonds_10.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/Contents.json new file mode 100644 index 0000000..483a518 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/classic_diamonds_2.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/classic_diamonds_2.png new file mode 100644 index 0000000..76460b3 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/classic_diamonds_2.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/Contents.json new file mode 100644 index 0000000..92a29df --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/classic_diamonds_3.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/classic_diamonds_3.png new file mode 100644 index 0000000..920a94b Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/classic_diamonds_3.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/Contents.json new file mode 100644 index 0000000..422e672 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/classic_diamonds_4.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/classic_diamonds_4.png new file mode 100644 index 0000000..6820735 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_4.imageset/classic_diamonds_4.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/Contents.json new file mode 100644 index 0000000..9e8e524 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/classic_diamonds_5.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/classic_diamonds_5.png new file mode 100644 index 0000000..a390f82 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_5.imageset/classic_diamonds_5.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/Contents.json new file mode 100644 index 0000000..c25936b --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_6.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/classic_diamonds_6.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/classic_diamonds_6.png new file mode 100644 index 0000000..0b8e87b Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_6.imageset/classic_diamonds_6.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/Contents.json new file mode 100644 index 0000000..e2f861f --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_7.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/classic_diamonds_7.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/classic_diamonds_7.png new file mode 100644 index 0000000..f35b880 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_7.imageset/classic_diamonds_7.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/Contents.json new file mode 100644 index 0000000..1670252 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_8.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/classic_diamonds_8.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/classic_diamonds_8.png new file mode 100644 index 0000000..268852b Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_8.imageset/classic_diamonds_8.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/Contents.json new file mode 100644 index 0000000..b71b466 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_9.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/classic_diamonds_9.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/classic_diamonds_9.png new file mode 100644 index 0000000..8d7e789 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_9.imageset/classic_diamonds_9.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/Contents.json new file mode 100644 index 0000000..f0ee3a2 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_ace.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/classic_diamonds_ace.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/classic_diamonds_ace.png new file mode 100644 index 0000000..93b964a Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_ace.imageset/classic_diamonds_ace.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/Contents.json new file mode 100644 index 0000000..386ca05 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_jack.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/classic_diamonds_jack.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/classic_diamonds_jack.png new file mode 100644 index 0000000..2fab1b9 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_jack.imageset/classic_diamonds_jack.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/Contents.json new file mode 100644 index 0000000..05b9848 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_king.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/classic_diamonds_king.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/classic_diamonds_king.png new file mode 100644 index 0000000..fc930da Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_king.imageset/classic_diamonds_king.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/Contents.json new file mode 100644 index 0000000..278cf59 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_diamonds_queen.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/classic_diamonds_queen.png b/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/classic_diamonds_queen.png new file mode 100644 index 0000000..7e2f86c Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_diamonds_queen.imageset/classic_diamonds_queen.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/Contents.json new file mode 100644 index 0000000..e687cfe --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_10.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/classic_hearts_10.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/classic_hearts_10.png new file mode 100644 index 0000000..bab6d78 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_10.imageset/classic_hearts_10.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/Contents.json new file mode 100644 index 0000000..45709b3 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/classic_hearts_2.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/classic_hearts_2.png new file mode 100644 index 0000000..982a61d Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_2.imageset/classic_hearts_2.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/Contents.json new file mode 100644 index 0000000..48a5606 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/classic_hearts_3.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/classic_hearts_3.png new file mode 100644 index 0000000..e24f3d1 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_3.imageset/classic_hearts_3.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/Contents.json new file mode 100644 index 0000000..6f06b86 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/classic_hearts_4.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/classic_hearts_4.png new file mode 100644 index 0000000..2f6a7fe Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_4.imageset/classic_hearts_4.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/Contents.json new file mode 100644 index 0000000..535fc96 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/classic_hearts_5.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/classic_hearts_5.png new file mode 100644 index 0000000..e103e2f Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_5.imageset/classic_hearts_5.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/Contents.json new file mode 100644 index 0000000..649145d --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_6.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/classic_hearts_6.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/classic_hearts_6.png new file mode 100644 index 0000000..5b6e416 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_6.imageset/classic_hearts_6.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/Contents.json new file mode 100644 index 0000000..0929ed0 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_7.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/classic_hearts_7.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/classic_hearts_7.png new file mode 100644 index 0000000..02a68e2 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_7.imageset/classic_hearts_7.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/Contents.json new file mode 100644 index 0000000..7c3e7f5 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_8.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/classic_hearts_8.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/classic_hearts_8.png new file mode 100644 index 0000000..bb2ad6d Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_8.imageset/classic_hearts_8.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/Contents.json new file mode 100644 index 0000000..e9cb789 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_9.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/classic_hearts_9.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/classic_hearts_9.png new file mode 100644 index 0000000..5011949 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_9.imageset/classic_hearts_9.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/Contents.json new file mode 100644 index 0000000..275009a --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_ace.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/classic_hearts_ace.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/classic_hearts_ace.png new file mode 100644 index 0000000..bd2f43a Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_ace.imageset/classic_hearts_ace.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/Contents.json new file mode 100644 index 0000000..fb2eac2 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_jack.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/classic_hearts_jack.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/classic_hearts_jack.png new file mode 100644 index 0000000..e37a150 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_jack.imageset/classic_hearts_jack.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/Contents.json new file mode 100644 index 0000000..81b0412 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_king.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/classic_hearts_king.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/classic_hearts_king.png new file mode 100644 index 0000000..712bd18 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_king.imageset/classic_hearts_king.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/Contents.json new file mode 100644 index 0000000..ee49b59 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_hearts_queen.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/classic_hearts_queen.png b/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/classic_hearts_queen.png new file mode 100644 index 0000000..90e3e64 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_hearts_queen.imageset/classic_hearts_queen.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/Contents.json new file mode 100644 index 0000000..77cedc8 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_10.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/classic_spades_10.png b/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/classic_spades_10.png new file mode 100644 index 0000000..6aa1536 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_10.imageset/classic_spades_10.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/Contents.json new file mode 100644 index 0000000..a88f364 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/classic_spades_2.png b/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/classic_spades_2.png new file mode 100644 index 0000000..3eb8a21 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_2.imageset/classic_spades_2.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/Contents.json new file mode 100644 index 0000000..4d6f8d1 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/classic_spades_3.png b/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/classic_spades_3.png new file mode 100644 index 0000000..e4b12e7 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_3.imageset/classic_spades_3.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/Contents.json new file mode 100644 index 0000000..d769ccf --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/classic_spades_4.png b/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/classic_spades_4.png new file mode 100644 index 0000000..30dcb16 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_4.imageset/classic_spades_4.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/Contents.json new file mode 100644 index 0000000..2c504b5 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/classic_spades_5.png b/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/classic_spades_5.png new file mode 100644 index 0000000..f1b0cdd Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_5.imageset/classic_spades_5.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/Contents.json new file mode 100644 index 0000000..81fe011 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_6.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/classic_spades_6.png b/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/classic_spades_6.png new file mode 100644 index 0000000..e508fc1 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_6.imageset/classic_spades_6.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/Contents.json new file mode 100644 index 0000000..b1b93d8 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_7.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/classic_spades_7.png b/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/classic_spades_7.png new file mode 100644 index 0000000..10e22a5 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_7.imageset/classic_spades_7.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/Contents.json new file mode 100644 index 0000000..63fcdfd --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_8.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/classic_spades_8.png b/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/classic_spades_8.png new file mode 100644 index 0000000..65ab056 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_8.imageset/classic_spades_8.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/Contents.json new file mode 100644 index 0000000..ee0e36f --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_9.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/classic_spades_9.png b/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/classic_spades_9.png new file mode 100644 index 0000000..3371865 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_9.imageset/classic_spades_9.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/Contents.json new file mode 100644 index 0000000..bff30e9 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_ace.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/classic_spades_ace.png b/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/classic_spades_ace.png new file mode 100644 index 0000000..23fbfdb Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_ace.imageset/classic_spades_ace.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/Contents.json new file mode 100644 index 0000000..639b790 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_jack.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/classic_spades_jack.png b/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/classic_spades_jack.png new file mode 100644 index 0000000..34785eb Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_jack.imageset/classic_spades_jack.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/Contents.json new file mode 100644 index 0000000..2b373b2 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_king.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/classic_spades_king.png b/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/classic_spades_king.png new file mode 100644 index 0000000..adda6c6 Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_king.imageset/classic_spades_king.png differ diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/Contents.json b/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/Contents.json new file mode 100644 index 0000000..5356cf8 --- /dev/null +++ b/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classic_spades_queen.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/classic_spades_queen.png b/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/classic_spades_queen.png new file mode 100644 index 0000000..208895d Binary files /dev/null and b/SoliCards/Resources/Assets.xcassets/classic_spades_queen.imageset/classic_spades_queen.png differ diff --git a/SoliCards/Resources/Localizable.xcstrings b/SoliCards/Resources/Localizable.xcstrings new file mode 100644 index 0000000..927d74c --- /dev/null +++ b/SoliCards/Resources/Localizable.xcstrings @@ -0,0 +1,120 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "You Win!" : { + "comment" : "Victory overlay title" + }, + "New Game" : { + "comment" : "Button to start a new game" + }, + "Game" : { + "comment" : "Game menu label" + }, + "Undo" : { + "comment" : "Undo button label" + }, + "Hint" : { + "comment" : "Hint button label" + }, + "Auto" : { + "comment" : "Auto-complete button label" + }, + "Rules" : { + "comment" : "Rules button label" + }, + "Settings" : { + "comment" : "Settings button/title" + }, + "Stats" : { + "comment" : "Statistics button label" + }, + "Sound" : { + "comment" : "Sound toggle button label" + }, + "Theme" : { + "comment" : "Theme section title" + }, + "Card Style" : { + "comment" : "Card style section title" + }, + "Card Back" : { + "comment" : "Card back section title" + }, + "Sound Effects" : { + "comment" : "Sound effects toggle label" + }, + "Done" : { + "comment" : "Dismiss button" + }, + "Cancel" : { + "comment" : "Cancel button" + }, + "Start Game" : { + "comment" : "Start game button" + }, + "Variant" : { + "comment" : "Game variant picker label" + }, + "Difficulty" : { + "comment" : "Difficulty section/picker label" + }, + "Klondike" : { + "comment" : "Game variant name" + }, + "Spider" : { + "comment" : "Game variant name" + }, + "FreeCell" : { + "comment" : "Game variant name" + }, + "Easy" : { + "comment" : "Difficulty level" + }, + "Medium" : { + "comment" : "Difficulty level" + }, + "Hard" : { + "comment" : "Difficulty level" + }, + "Expert" : { + "comment" : "Difficulty level" + }, + "Objective" : { + "comment" : "Rules section title" + }, + "Tableau" : { + "comment" : "Rules section title" + }, + "Foundation" : { + "comment" : "Rules section title" + }, + "Stock & Waste" : { + "comment" : "Rules section title" + }, + "Free Cells" : { + "comment" : "Rules section title" + }, + "Power Moves" : { + "comment" : "Rules section title" + }, + "No Statistics Yet" : { + "comment" : "Empty state title for statistics" + }, + "Play some games to see your stats here." : { + "comment" : "Empty state description for statistics" + }, + "Statistics" : { + "comment" : "Statistics view title" + }, + "Empty card slot" : { + "comment" : "Accessibility label for empty card position" + }, + "Face down card" : { + "comment" : "Accessibility label for face-down card" + }, + "Double tap to move to best available position" : { + "comment" : "Accessibility hint for face-up cards" + } + }, + "version" : "1.0" +} diff --git a/SoliCards/Resources/PrivacyInfo.xcprivacy b/SoliCards/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5704bed --- /dev/null +++ b/SoliCards/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/SoliCards/Services/HapticManager.swift b/SoliCards/Services/HapticManager.swift new file mode 100644 index 0000000..49de5a6 --- /dev/null +++ b/SoliCards/Services/HapticManager.swift @@ -0,0 +1,44 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +enum HapticManager { + static func impact(_ style: HapticStyle = .medium) { + #if canImport(UIKit) && !os(macOS) + let generator: UIImpactFeedbackGenerator + switch style { + case .light: + generator = UIImpactFeedbackGenerator(style: .light) + case .medium: + generator = UIImpactFeedbackGenerator(style: .medium) + case .heavy: + generator = UIImpactFeedbackGenerator(style: .heavy) + } + generator.impactOccurred() + #endif + } + + static func notification(_ type: HapticNotification) { + #if canImport(UIKit) && !os(macOS) + let generator = UINotificationFeedbackGenerator() + switch type { + case .success: + generator.notificationOccurred(.success) + case .warning: + generator.notificationOccurred(.warning) + case .error: + generator.notificationOccurred(.error) + } + #endif + } +} + +enum HapticStyle: Sendable { + case light, medium, heavy +} + +enum HapticNotification: Sendable { + case success, warning, error +} diff --git a/SoliCards/Services/SoundManager.swift b/SoliCards/Services/SoundManager.swift new file mode 100644 index 0000000..d9a7fc0 --- /dev/null +++ b/SoliCards/Services/SoundManager.swift @@ -0,0 +1,46 @@ +import AVFoundation +import Foundation + +enum SoundEffect: String, Sendable { + case cardFlip = "card_flip" + case cardPlace = "card_place" + case error = "error" + case victory = "victory" +} + +@Observable +final class SoundManager: @unchecked Sendable { + var isEnabled: Bool = true + private var players: [SoundEffect: AVAudioPlayer] = [:] + private let queue = DispatchQueue(label: "com.solicards.sound") + + init() { + preload() + } + + func play(_ effect: SoundEffect) async { + guard isEnabled else { return } + queue.async { [weak self] in + guard let player = self?.players[effect] else { return } + player.currentTime = 0 + player.play() + } + } + + private func preload() { + for effect in SoundEffect.allCases { + if let url = Bundle.main.url(forResource: effect.rawValue, withExtension: "caf", + subdirectory: "Sounds") { + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + players[effect] = player + } catch { + // Sound will be unavailable — not a fatal error + } + } + } + } +} + +extension SoundEffect: CaseIterable {} diff --git a/SoliCards/Services/TimerService.swift b/SoliCards/Services/TimerService.swift new file mode 100644 index 0000000..f8afef8 --- /dev/null +++ b/SoliCards/Services/TimerService.swift @@ -0,0 +1,39 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class TimerService { + var elapsedSeconds: Int = 0 + private var task: Task? + private var isRunning = false + + func start() { + guard !isRunning else { return } + isRunning = true + task = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard let self, self.isRunning else { break } + self.elapsedSeconds += 1 + } + } + } + + func stop() { + isRunning = false + task?.cancel() + task = nil + } + + func reset() { + stop() + elapsedSeconds = 0 + } + + var formattedTime: String { + let minutes = elapsedSeconds / 60 + let seconds = elapsedSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/SoliCards/SoliCardsApp.swift b/SoliCards/SoliCardsApp.swift new file mode 100644 index 0000000..a4d4a32 --- /dev/null +++ b/SoliCards/SoliCardsApp.swift @@ -0,0 +1,12 @@ +import SwiftUI +import SwiftData + +@main +struct SoliCardsApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: [GameRecord.self, StatsRecord.self, PrefsRecord.self]) + } +} diff --git a/SoliCards/Theme/GameTheme.swift b/SoliCards/Theme/GameTheme.swift new file mode 100644 index 0000000..7dfc2cf --- /dev/null +++ b/SoliCards/Theme/GameTheme.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct GameTheme: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let backgroundColor: Color + let tableauColor: Color + let accentColor: Color + let cardTintColor: Color + + static let allThemes: [GameTheme] = [ + .classicGreen, + .darkMode, + .oceanBlue, + .royalPurple, + .forestGreen, + .sunsetOrange, + ] + + static let classicGreen = GameTheme( + id: "classic", + displayName: "Classic Green", + backgroundColor: Color(red: 0.0, green: 0.5, blue: 0.0), + tableauColor: Color(red: 0.0, green: 0.4, blue: 0.0), + accentColor: Color.white, + cardTintColor: Color(red: 0.0, green: 0.6, blue: 0.0) + ) + + static let darkMode = GameTheme( + id: "dark", + displayName: "Dark Mode", + backgroundColor: Color(red: 0.12, green: 0.12, blue: 0.14), + tableauColor: Color(red: 0.18, green: 0.18, blue: 0.2), + accentColor: Color(red: 0.4, green: 0.6, blue: 1.0), + cardTintColor: Color(red: 0.25, green: 0.25, blue: 0.28) + ) + + static let oceanBlue = GameTheme( + id: "ocean", + displayName: "Ocean Blue", + backgroundColor: Color(red: 0.1, green: 0.3, blue: 0.5), + tableauColor: Color(red: 0.08, green: 0.25, blue: 0.42), + accentColor: Color(red: 0.6, green: 0.85, blue: 1.0), + cardTintColor: Color(red: 0.15, green: 0.35, blue: 0.55) + ) + + static let royalPurple = GameTheme( + id: "purple", + displayName: "Royal Purple", + backgroundColor: Color(red: 0.3, green: 0.15, blue: 0.45), + tableauColor: Color(red: 0.25, green: 0.12, blue: 0.38), + accentColor: Color(red: 0.85, green: 0.7, blue: 1.0), + cardTintColor: Color(red: 0.35, green: 0.2, blue: 0.5) + ) + + static let forestGreen = GameTheme( + id: "forest", + displayName: "Forest Green", + backgroundColor: Color(red: 0.1, green: 0.35, blue: 0.15), + tableauColor: Color(red: 0.08, green: 0.28, blue: 0.12), + accentColor: Color(red: 0.6, green: 0.9, blue: 0.65), + cardTintColor: Color(red: 0.15, green: 0.4, blue: 0.2) + ) + + static let sunsetOrange = GameTheme( + id: "sunset", + displayName: "Sunset Orange", + backgroundColor: Color(red: 0.6, green: 0.25, blue: 0.1), + tableauColor: Color(red: 0.5, green: 0.2, blue: 0.08), + accentColor: Color(red: 1.0, green: 0.85, blue: 0.5), + cardTintColor: Color(red: 0.65, green: 0.3, blue: 0.15) + ) +} diff --git a/SoliCards/Theme/ThemeManager.swift b/SoliCards/Theme/ThemeManager.swift new file mode 100644 index 0000000..524dd66 --- /dev/null +++ b/SoliCards/Theme/ThemeManager.swift @@ -0,0 +1,11 @@ +import Foundation +import Observation + +@Observable +final class ThemeManager { + var currentTheme: GameTheme = .classicGreen + + func applyTheme(_ theme: GameTheme) { + currentTheme = theme + } +} diff --git a/SoliCards/ViewModels/GameViewModel.swift b/SoliCards/ViewModels/GameViewModel.swift new file mode 100644 index 0000000..91359ce --- /dev/null +++ b/SoliCards/ViewModels/GameViewModel.swift @@ -0,0 +1,372 @@ +import Foundation +import Observation +import SwiftUI + +@MainActor +@Observable +final class GameViewModel { + // MARK: - Dependencies + + private(set) var rules: GameRules + private let soundManager = SoundManager() + private let timerService = TimerService() + + // MARK: - Persistence + + var persistenceManager: PersistenceManager? + private var autoSaveTask: Task? + + // MARK: - State + + private(set) var state = GameState() + private(set) var variant: GameVariant + var difficulty: Difficulty + + // MARK: - Drag state + + var draggedCards: [Card] = [] + var dragSource: CardLocation? + var dragPosition: CGPoint = .zero + var dropTargets: [DropTargetData] = [] + + // MARK: - Hint state + + var currentHint: HintResult? + var isShowingHint: Bool = false + + // MARK: - Timer + + var elapsedSeconds: Int { timerService.elapsedSeconds } + + // MARK: - Computed + + var canUndo: Bool { + state.canUndo && undosRemaining > 0 + } + + var isWon: Bool { state.phase == .won } + + private var undosRemaining: Int + + // MARK: - Init + + init(variant: GameVariant = .klondike, difficulty: Difficulty = .medium) { + self.variant = variant + self.difficulty = difficulty + self.rules = GameRulesFactory.rules(for: variant) + self.undosRemaining = difficulty.settings.maxUndos + } + + // MARK: - Game Actions + + func newGame() { + // Record loss for previous game if it was in progress + if state.phase == .playing { + persistenceManager?.recordLoss(variant: variant, difficulty: difficulty) + } + + rules = GameRulesFactory.rules(for: variant) + let deck = variant.deckCount == 2 ? Deck.double() : Deck.standard() + let snapshot = rules.deal(deck: deck) + state.reset(from: snapshot, variant: variant) + undosRemaining = difficulty.settings.maxUndos + timerService.reset() + timerService.start() + playSound(.cardFlip) + scheduleAutoSave() + } + + func changeVariant(to newVariant: GameVariant) { + variant = newVariant + newGame() + } + + /// Resume a saved game from a GameRecord. + func resumeGame(from record: GameRecord) { + guard let snapshot = record.decodedSnapshot, + let savedVariant = record.gameVariant, + let savedDifficulty = record.gameDifficulty else { return } + + variant = savedVariant + difficulty = savedDifficulty + rules = GameRulesFactory.rules(for: variant) + + state.restore(from: snapshot) + state.phase = .playing + if variant.hasFreeCells && state.freeCells.isEmpty { + state.freeCells = Array(repeating: nil, count: variant.freeCellCount) + } + + undosRemaining = difficulty.settings.maxUndos + timerService.reset() + timerService.elapsedSeconds = record.elapsedSeconds + timerService.start() + } + + func tapCard(at location: CardLocation, cardIndex: Int) { + guard state.phase == .playing else { return } + + switch location { + case .stock: + drawFromStock() + case .waste: + guard let card = state.waste.last else { return } + if let dest = findBestDestination(for: [card], from: .waste) { + executeMove(cards: [card], from: .waste, to: dest) + } + case .tableau(let tabIndex): + let tableau = state.tableaus[tabIndex] + guard cardIndex == tableau.count - 1, let card = tableau.last, card.isFaceUp else { return } + if let dest = findBestDestination(for: [card], from: location) { + executeMove(cards: [card], from: location, to: dest) + } + case .freeCell(let cellIndex): + guard let card = state.freeCells[cellIndex] else { return } + if let dest = findBestDestination(for: [card], from: location) { + executeMove(cards: [card], from: location, to: dest) + } + case .foundation: + break + } + } + + func beginDrag(cards: [Card], from: CardLocation) { + guard state.phase == .playing else { return } + guard rules.canPickUp(cards: cards, from: from, state: state.snapshot()) else { return } + draggedCards = cards + dragSource = from + } + + func drop(at destination: CardLocation) -> Bool { + guard let source = dragSource, !draggedCards.isEmpty else { + cancelDrag() + return false + } + + let snapshot = state.snapshot() + guard rules.canMove(cards: draggedCards, from: source, to: destination, state: snapshot) else { + cancelDrag() + return false + } + + executeMove(cards: draggedCards, from: source, to: destination) + clearDrag() + return true + } + + func cancelDrag() { + clearDrag() + } + + /// Attempt to drop at the given point in the board coordinate space. + /// Falls back to cancel if no valid target found. + func endDrag(at point: CGPoint) { + guard !draggedCards.isEmpty else { + clearDrag() + return + } + + // Find the drop target under the finger + let candidates = dropTargets.filter { $0.frame.contains(point) } + let target = candidates.min(by: { + $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height + }) + + if let target { + _ = drop(at: target.location) + } else { + cancelDrag() + } + } + + func undo() { + guard canUndo, let snapshot = state.popHistory() else { return } + state.restore(from: snapshot) + undosRemaining -= 1 + playSound(.cardFlip) + scheduleAutoSave() + } + + func requestHint() { + let hints = rules.findHints(state: state.snapshot(), settings: difficulty.settings) + currentHint = hints.first + if currentHint != nil { + isShowingHint = true + } + } + + func drawFromStock() { + guard state.phase == .playing else { return } + state.pushHistory() + var snapshot = state.snapshot() + if rules.drawFromStock(state: &snapshot, drawCount: difficulty.settings.drawCount) != nil { + state.restore(from: snapshot) + state.moves += 1 + playSound(.cardFlip) + scheduleAutoSave() + } + } + + func autoComplete() { + guard state.phase == .playing else { return } + guard rules.canAutoComplete(state: state.snapshot()) else { return } + state.phase = .autoCompleting + + Task { + while state.phase == .autoCompleting { + guard let (from, to) = AutoCompleter.findNextAutoMove(state: state.snapshot(), rules: rules) else { + break + } + guard let card = cardAt(location: from) else { break } + executeMove(cards: [card], from: from, to: to) + try? await Task.sleep(for: .milliseconds(150)) + } + + if rules.isWon(state: state.snapshot()) { + handleWin() + } else { + state.phase = .playing + } + } + } + + func pause() { + guard state.phase == .playing else { return } + state.phase = .paused + timerService.stop() + saveGameNow() + } + + func resume() { + guard state.phase == .paused else { return } + state.phase = .playing + timerService.start() + } + + /// Save game state immediately (called on app background/close). + func saveGameNow() { + guard state.phase == .playing || state.phase == .paused else { return } + persistenceManager?.saveGame( + variant: variant, + difficulty: difficulty, + snapshot: state.snapshot(), + elapsedSeconds: timerService.elapsedSeconds + ) + } + + var isSoundEnabled: Bool { + get { soundManager.isEnabled } + set { soundManager.isEnabled = newValue } + } + + // MARK: - Private + + private func cardAt(location: CardLocation) -> Card? { + switch location { + case .tableau(let i): state.tableaus[i].last + case .waste: state.waste.last + case .freeCell(let i): state.freeCells[i] + case .foundation(let i): state.foundations[i].last + case .stock: state.stock.last + } + } + + private func executeMove(cards: [Card], from: CardLocation, to: CardLocation) { + state.pushHistory() + + // Remove cards from source + switch from { + case .tableau(let index): + state.tableaus[index].removeLast(cards.count) + if let lastIndex = state.tableaus[index].indices.last, + !state.tableaus[index][lastIndex].isFaceUp { + state.tableaus[index][lastIndex].isFaceUp = true + } + case .waste: + state.waste.removeLast() + case .freeCell(let index): + state.freeCells[index] = nil + case .foundation(let index): + state.foundations[index].removeLast() + case .stock: + break + } + + // Add cards to destination + switch to { + case .tableau(let index): + state.tableaus[index].append(contentsOf: cards) + case .foundation(let index): + state.foundations[index].append(contentsOf: cards) + case .freeCell(let index): + state.freeCells[index] = cards.first + case .waste: + state.waste.append(contentsOf: cards) + case .stock: + break + } + + state.moves += 1 + state.score += rules.scoreForMove(from: from, to: to) + + // Check for Spider complete sequences + if variant == .spider, let spiderRules = rules as? SpiderRules { + var snapshot = state.snapshot() + if spiderRules.checkAndMoveCompleteSequences(state: &snapshot) { + state.restore(from: snapshot) + playSound(.cardPlace) + } + } + + // Check win + if rules.isWon(state: state.snapshot()) { + handleWin() + } else { + playSound(.cardPlace) + scheduleAutoSave() + } + } + + private func handleWin() { + state.phase = .won + timerService.stop() + playSound(.victory) + + // Record win in statistics + persistenceManager?.recordWin( + variant: variant, + difficulty: difficulty, + score: state.score, + time: timerService.elapsedSeconds + ) + + // Delete saved game (game is over) + persistenceManager?.deleteSavedGame(for: variant) + } + + /// Debounced auto-save: waits 2 seconds of inactivity before saving. + private func scheduleAutoSave() { + autoSaveTask?.cancel() + autoSaveTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + saveGameNow() + } + } + + private func findBestDestination(for cards: [Card], from: CardLocation) -> CardLocation? { + let destinations = rules.validDestinations(for: cards, from: from, state: state.snapshot()) + return destinations.first { if case .foundation = $0 { return true }; return false } + ?? destinations.first + } + + private func clearDrag() { + draggedCards = [] + dragSource = nil + dragPosition = .zero + } + + private nonisolated func playSound(_ effect: SoundEffect) { + Task { await soundManager.play(effect) } + } +} diff --git a/SoliCards/ViewModels/SettingsViewModel.swift b/SoliCards/ViewModels/SettingsViewModel.swift new file mode 100644 index 0000000..c03182a --- /dev/null +++ b/SoliCards/ViewModels/SettingsViewModel.swift @@ -0,0 +1,11 @@ +import Foundation +import Observation + +@Observable +final class SettingsViewModel { + var theme: GameTheme = .classicGreen + var cardFaceStyle: CardFaceStyle = .classic + var cardBackDesign: CardBackDesign = .blue + var soundEnabled: Bool = true + var difficulty: Difficulty = .medium +} diff --git a/SoliCards/ViewModels/StatsViewModel.swift b/SoliCards/ViewModels/StatsViewModel.swift new file mode 100644 index 0000000..1484247 --- /dev/null +++ b/SoliCards/ViewModels/StatsViewModel.swift @@ -0,0 +1,17 @@ +import Foundation +import Observation +import SwiftData + +@Observable +final class StatsViewModel { + var records: [StatsRecord] = [] + + func loadStats(context: ModelContext) { + let descriptor = FetchDescriptor() + records = (try? context.fetch(descriptor)) ?? [] + } + + func stats(for variant: GameVariant, difficulty: Difficulty) -> StatsRecord? { + records.first { $0.variant == variant.rawValue && $0.difficulty == difficulty.rawValue } + } +} diff --git a/SoliCards/Views/Game/CardStackView.swift b/SoliCards/Views/Game/CardStackView.swift new file mode 100644 index 0000000..c84244d --- /dev/null +++ b/SoliCards/Views/Game/CardStackView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct CardStackView: View { + let cards: [Card] + let location: CardLocation + let layout: CardLayout + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + @Bindable var viewModel: GameViewModel + + var body: some View { + ZStack(alignment: .top) { + // Empty slot placeholder — also a drop target + CardView(card: nil, cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign, + size: layout.cardSize()) + .dropTarget(location) + .accessibilityLabel(emptySlotLabel) + + // Stacked cards + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + let isInDrag = viewModel.draggedCards.contains(card) + let isLast = index == cards.count - 1 + + CardView(card: card, cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign, + size: layout.cardSize(), + isHighlighted: isHinted(card)) + .offset(y: yOffset(for: index)) + .opacity(isInDrag ? 0.3 : 1.0) + .onTapGesture { + viewModel.tapCard(at: location, cardIndex: index) + } + .simultaneousGesture( + card.isFaceUp ? dragGesture(for: card, at: index) : nil + ) + .overlay { + if isLast { + Color.clear + .dropTarget(location) + .offset(y: yOffset(for: index)) + } + } + } + } + .frame(width: layout.cardWidth) + .accessibilityElement(children: .contain) + .accessibilityLabel(stackAccessibilityLabel) + } + + private var emptySlotLabel: Text { + switch location { + case .tableau(let i): Text("Empty tableau column \(i + 1)") + case .foundation(let i): Text("Empty foundation \(i + 1)") + case .freeCell(let i): Text("Empty free cell \(i + 1)") + default: Text("Empty slot") + } + } + + private var stackAccessibilityLabel: Text { + switch location { + case .tableau(let i): + let count = cards.count + return Text("Tableau column \(i + 1), \(count) card\(count == 1 ? "" : "s")") + default: + return Text("") + } + } + + private func yOffset(for index: Int) -> CGFloat { + var offset: CGFloat = 0 + for i in 0.. some Gesture { + LongPressGesture(minimumDuration: 0.15) + .sequenced(before: DragGesture(coordinateSpace: .named("board"))) + .onChanged { value in + if case .second(true, let drag?) = value { + if viewModel.draggedCards.isEmpty { + let cardsToMove = Array(cards[index...]) + viewModel.beginDrag(cards: cardsToMove, from: location) + } + viewModel.dragPosition = drag.location + } + } + .onEnded { value in + if case .second(true, let drag?) = value { + viewModel.endDrag(at: drag.location) + } else { + viewModel.cancelDrag() + } + } + } + + private func isHinted(_ card: Card) -> Bool { + guard viewModel.isShowingHint, let hint = viewModel.currentHint else { return false } + return hint.cards.contains(card) + } +} diff --git a/SoliCards/Views/Game/CardView.swift b/SoliCards/Views/Game/CardView.swift new file mode 100644 index 0000000..215b119 --- /dev/null +++ b/SoliCards/Views/Game/CardView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct CardView: View { + let card: Card? + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + let size: CGSize + var isHighlighted: Bool = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + if let card { + if card.isFaceUp { + cardFront(card) + } else { + cardBack + } + } else { + emptySlot + } + } + .frame(width: size.width, height: size.height) + .clipShape(RoundedRectangle(cornerRadius: size.width * 0.08)) + .shadow(color: .black.opacity(0.15), radius: 2, y: 1) + .overlay { + if isHighlighted { + RoundedRectangle(cornerRadius: size.width * 0.08) + .stroke(Color.yellow, lineWidth: 3) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.8).repeatForever(), value: isHighlighted) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(accessibilityHint) + .accessibilityAddTraits(card?.isFaceUp == true ? .isButton : []) + } + + private var accessibilityLabel: Text { + guard let card else { return Text("Empty card slot") } + if card.isFaceUp { + return Text("\(card.rank.displayName) of \(card.suit.displayName)") + } else { + return Text("Face down card") + } + } + + private var accessibilityHint: Text { + guard let card, card.isFaceUp else { return Text("") } + return Text("Double tap to move to best available position") + } + + private func cardFront(_ card: Card) -> some View { + let imageName = card.frontImageName(style: cardFaceStyle) + + return ZStack { + RoundedRectangle(cornerRadius: size.width * 0.08) + .fill(.white) + + if let uiImage = loadImage(named: imageName) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(1) + .accessibilityHidden(true) + } else { + VStack(spacing: 2) { + Text(card.rank.shortName) + .font(.system(size: size.width * 0.28, weight: .bold)) + Text(card.suit.symbol) + .font(.system(size: size.width * 0.22)) + } + .foregroundStyle(card.color == .red ? .red : .black) + .accessibilityHidden(true) + } + } + } + + private var cardBack: some View { + let imageName = cardBackDesign.imageName + + return ZStack { + RoundedRectangle(cornerRadius: size.width * 0.08) + .fill(Color.blue) + + if let uiImage = loadImage(named: imageName) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(2) + .accessibilityHidden(true) + } else { + RoundedRectangle(cornerRadius: size.width * 0.08) + .strokeBorder(Color.white.opacity(0.3), lineWidth: 2) + .padding(3) + } + } + } + + private var emptySlot: some View { + RoundedRectangle(cornerRadius: size.width * 0.08) + .strokeBorder(Color.white.opacity(0.3), lineWidth: 2) + } + + #if os(macOS) + private func loadImage(named name: String) -> NSImage? { + NSImage(named: name) + } + + private func Image(uiImage: NSImage) -> SwiftUI.Image { + SwiftUI.Image(nsImage: uiImage) + } + #else + private func loadImage(named name: String) -> UIImage? { + UIImage(named: name) + } + + private func Image(uiImage: UIImage) -> SwiftUI.Image { + SwiftUI.Image(uiImage: uiImage) + } + #endif +} diff --git a/SoliCards/Views/Game/DraggedCardsOverlay.swift b/SoliCards/Views/Game/DraggedCardsOverlay.swift new file mode 100644 index 0000000..57b4d96 --- /dev/null +++ b/SoliCards/Views/Game/DraggedCardsOverlay.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct DraggedCardsOverlay: View { + @Bindable var viewModel: GameViewModel + let layout: CardLayout + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + + var body: some View { + if !viewModel.draggedCards.isEmpty { + ZStack(alignment: .top) { + ForEach(Array(viewModel.draggedCards.enumerated()), id: \.element.id) { index, card in + CardView(card: card, cardFaceStyle: cardFaceStyle, + cardBackDesign: cardBackDesign, size: layout.cardSize()) + .offset(y: CGFloat(index) * layout.verticalOverlapFaceUp) + } + } + .frame(width: layout.cardWidth) + .position(viewModel.dragPosition) + .allowsHitTesting(false) + } + } +} diff --git a/SoliCards/Views/Game/DropTargetPreferenceKey.swift b/SoliCards/Views/Game/DropTargetPreferenceKey.swift new file mode 100644 index 0000000..474b7e0 --- /dev/null +++ b/SoliCards/Views/Game/DropTargetPreferenceKey.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct DropTargetData: Equatable, Sendable { + let location: CardLocation + let frame: CGRect +} + +struct DropTargetPreferenceKey: PreferenceKey { + nonisolated(unsafe) static var defaultValue: [DropTargetData] = [] + + static func reduce(value: inout [DropTargetData], nextValue: () -> [DropTargetData]) { + value.append(contentsOf: nextValue()) + } +} + +extension View { + func dropTarget(_ location: CardLocation) -> some View { + background( + GeometryReader { geometry in + Color.clear.preference( + key: DropTargetPreferenceKey.self, + value: [DropTargetData(location: location, + frame: geometry.frame(in: .named("board")))] + ) + } + ) + } +} diff --git a/SoliCards/Views/Game/FreeCellBoardView.swift b/SoliCards/Views/Game/FreeCellBoardView.swift new file mode 100644 index 0000000..9e29191 --- /dev/null +++ b/SoliCards/Views/Game/FreeCellBoardView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct FreeCellBoardView: View { + @Bindable var viewModel: GameViewModel + let layout: CardLayout + let theme: GameTheme + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + + var body: some View { + VStack(spacing: layout.horizontalPadding) { + HStack(spacing: layout.horizontalPadding) { + ForEach(0.. some View { + let card = viewModel.state.freeCells[index] + return CardView(card: card, + cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign, + size: layout.cardSize()) + .dropTarget(.freeCell(index)) + .accessibilityLabel(card != nil + ? Text("Free cell \(index + 1), \(card!.rank.displayName) of \(card!.suit.displayName)") + : Text("Free cell \(index + 1), empty")) + .onTapGesture { viewModel.tapCard(at: .freeCell(index), cardIndex: 0) } + .simultaneousGesture(freeCellDragGesture(index: index)) + } + + private func foundationView(index: Int) -> some View { + let topCard = viewModel.state.foundations[index].last + return CardView(card: topCard, + cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign, + size: layout.cardSize()) + .dropTarget(.foundation(index)) + .accessibilityLabel(topCard != nil + ? Text("Foundation \(index + 1), \(topCard!.rank.displayName) of \(topCard!.suit.displayName)") + : Text("Foundation \(index + 1), empty")) + } + + private func freeCellDragGesture(index: Int) -> some Gesture { + LongPressGesture(minimumDuration: 0.15) + .sequenced(before: DragGesture(coordinateSpace: .named("board"))) + .onChanged { value in + if case .second(true, let drag?) = value { + if viewModel.draggedCards.isEmpty, let card = viewModel.state.freeCells[index] { + viewModel.beginDrag(cards: [card], from: .freeCell(index)) + } + viewModel.dragPosition = drag.location + } + } + .onEnded { value in + if case .second(true, let drag?) = value { + viewModel.endDrag(at: drag.location) + } else { + viewModel.cancelDrag() + } + } + } +} diff --git a/SoliCards/Views/Game/GameBoardView.swift b/SoliCards/Views/Game/GameBoardView.swift new file mode 100644 index 0000000..1ccc208 --- /dev/null +++ b/SoliCards/Views/Game/GameBoardView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct GameBoardView: View { + @Bindable var viewModel: GameViewModel + let theme: GameTheme + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + + private var isReady: Bool { + viewModel.state.phase != .notStarted && !viewModel.state.tableaus.isEmpty + } + + private var deepestColumn: (faceDown: Int, faceUp: Int) { + var worst = (faceDown: 0, faceUp: 1) + for column in viewModel.state.tableaus { + let faceUp = column.reversed().prefix(while: { $0.isFaceUp }).count + let faceDown = column.count - faceUp + let depth = faceDown * 15 + max(0, faceUp - 1) * 25 + 100 + let worstDepth = worst.faceDown * 15 + max(0, worst.faceUp - 1) * 25 + 100 + if depth > worstDepth { + worst = (faceDown, faceUp) + } + } + return worst + } + + var body: some View { + if isReady { + VStack(spacing: 0) { + ScoreBarView( + moves: viewModel.state.moves, + score: viewModel.state.score, + time: viewModel.elapsedSeconds.formattedTime, + theme: theme + ) + + GeometryReader { geometry in + let layout = CardLayout( + availableSize: geometry.size, + variant: viewModel.variant, + deepestColumn: deepestColumn + ) + + ScrollView(.vertical) { + boardContent(layout: layout) + .padding(.bottom, layout.horizontalPadding) + .frame(maxWidth: .infinity, + minHeight: landscapeMinHeight(viewportHeight: geometry.size.height, + isLandscape: layout.isLandscape), + alignment: .top) + .contentShape(Rectangle()) + } + .scrollIndicators(.hidden) + .scrollDisabled(!viewModel.draggedCards.isEmpty) + .coordinateSpace(name: "board") + .onPreferenceChange(DropTargetPreferenceKey.self) { targets in + viewModel.dropTargets = targets + } + .overlay { + DraggedCardsOverlay( + viewModel: viewModel, + layout: layout, + cardFaceStyle: cardFaceStyle, + cardBackDesign: cardBackDesign + ) + } + } + } + .background(theme.backgroundColor) + .overlay { + if viewModel.isWon { + VictoryOverlayView { + viewModel.newGame() + } + } + } + } else { + Color.clear + .background(theme.backgroundColor) + } + } + + private func landscapeMinHeight(viewportHeight: CGFloat, isLandscape: Bool) -> CGFloat? { + #if os(iOS) + return isLandscape ? viewportHeight * 1.3 : nil + #else + return nil + #endif + } + + @ViewBuilder + private func boardContent(layout: CardLayout) -> some View { + switch viewModel.variant { + case .klondike: + KlondikeBoardView(viewModel: viewModel, layout: layout, + theme: theme, cardFaceStyle: cardFaceStyle, + cardBackDesign: cardBackDesign) + case .spider: + SpiderBoardView(viewModel: viewModel, layout: layout, + theme: theme, cardFaceStyle: cardFaceStyle, + cardBackDesign: cardBackDesign) + case .freeCell: + FreeCellBoardView(viewModel: viewModel, layout: layout, + theme: theme, cardFaceStyle: cardFaceStyle, + cardBackDesign: cardBackDesign) + } + } +} + +extension Int { + var formattedTime: String { + let minutes = self / 60 + let seconds = self % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/SoliCards/Views/Game/KlondikeBoardView.swift b/SoliCards/Views/Game/KlondikeBoardView.swift new file mode 100644 index 0000000..d4edbee --- /dev/null +++ b/SoliCards/Views/Game/KlondikeBoardView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct KlondikeBoardView: View { + @Bindable var viewModel: GameViewModel + let layout: CardLayout + let theme: GameTheme + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + + var body: some View { + VStack(spacing: layout.horizontalPadding) { + HStack(spacing: layout.horizontalPadding) { + stockView + wasteView + + // Empty gap at column 2 position + Color.clear + .frame(width: layout.cardWidth, height: layout.cardHeight) + + ForEach(0.. some View { + let topCard = viewModel.state.foundations[index].last + return CardView(card: topCard, + cardFaceStyle: cardFaceStyle, cardBackDesign: cardBackDesign, + size: layout.cardSize()) + .dropTarget(.foundation(index)) + .accessibilityLabel(topCard != nil + ? Text("Foundation \(index + 1), \(topCard!.rank.displayName) of \(topCard!.suit.displayName)") + : Text("Foundation \(index + 1), empty")) + } + + private var wasteDragGesture: some Gesture { + LongPressGesture(minimumDuration: 0.15) + .sequenced(before: DragGesture(coordinateSpace: .named("board"))) + .onChanged { value in + if case .second(true, let drag?) = value { + if viewModel.draggedCards.isEmpty, let card = viewModel.state.waste.last { + viewModel.beginDrag(cards: [card], from: .waste) + } + viewModel.dragPosition = drag.location + } + } + .onEnded { value in + if case .second(true, let drag?) = value { + viewModel.endDrag(at: drag.location) + } else { + viewModel.cancelDrag() + } + } + } +} diff --git a/SoliCards/Views/Game/ScoreBarView.swift b/SoliCards/Views/Game/ScoreBarView.swift new file mode 100644 index 0000000..598a909 --- /dev/null +++ b/SoliCards/Views/Game/ScoreBarView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct ScoreBarView: View { + let moves: Int + let score: Int + let time: String + let theme: GameTheme + + var body: some View { + HStack { + Label("\(moves)", systemImage: "arrow.left.arrow.right") + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("\(moves) moves")) + Spacer() + Label("\(score)", systemImage: "star.fill") + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Score \(score)")) + Spacer() + Label(time, systemImage: "clock") + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Time \(time)")) + } + .font(.subheadline.monospacedDigit()) + .foregroundStyle(theme.accentColor) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("Game status")) + } +} diff --git a/SoliCards/Views/Game/SpiderBoardView.swift b/SoliCards/Views/Game/SpiderBoardView.swift new file mode 100644 index 0000000..cde8540 --- /dev/null +++ b/SoliCards/Views/Game/SpiderBoardView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct SpiderBoardView: View { + @Bindable var viewModel: GameViewModel + let layout: CardLayout + let theme: GameTheme + let cardFaceStyle: CardFaceStyle + let cardBackDesign: CardBackDesign + + var body: some View { + VStack(spacing: layout.horizontalPadding) { + HStack(alignment: .top, spacing: layout.horizontalPadding) { + ForEach(0.. 0 else { return 0 } + return (remaining + 9) / 10 + } +} diff --git a/SoliCards/Views/Game/VictoryOverlayView.swift b/SoliCards/Views/Game/VictoryOverlayView.swift new file mode 100644 index 0000000..6c9bba6 --- /dev/null +++ b/SoliCards/Views/Game/VictoryOverlayView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct VictoryOverlayView: View { + let onNewGame: () -> Void + + @State private var showContent = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + Color.black.opacity(0.6) + .ignoresSafeArea() + + VStack(spacing: 24) { + Text("You Win!") + .font(.largeTitle.bold()) + .foregroundStyle(.white) + + Button("New Game") { + onNewGame() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .scaleEffect(showContent ? 1.0 : 0.5) + .opacity(showContent ? 1.0 : 0) + } + .onAppear { + if reduceMotion { + showContent = true + } else { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { + showContent = true + } + } + } + .accessibilityAddTraits(.isModal) + .accessibilityLabel(Text("You win! Double tap New Game to start a new game.")) + } +} diff --git a/SoliCards/Views/Menu/MainMenuView.swift b/SoliCards/Views/Menu/MainMenuView.swift new file mode 100644 index 0000000..0d2a853 --- /dev/null +++ b/SoliCards/Views/Menu/MainMenuView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct MainMenuView: View { + @Binding var selectedVariant: GameVariant + let onStart: () -> Void + + var body: some View { + VStack(spacing: 32) { + Text("SoliCards") + .font(.largeTitle.bold()) + + VStack(spacing: 16) { + ForEach(GameVariant.allCases) { variant in + Button { + selectedVariant = variant + onStart() + } label: { + HStack { + Image(systemName: iconName(for: variant)) + .font(.title2) + .frame(width: 30) + VStack(alignment: .leading) { + Text(variant.displayName) + .font(.headline) + Text(subtitle(for: variant)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: 400) + } + .padding() + } + + private func iconName(for variant: GameVariant) -> String { + switch variant { + case .klondike: "suit.spade.fill" + case .spider: "suit.club.fill" + case .freeCell: "suit.diamond.fill" + } + } + + private func subtitle(for variant: GameVariant) -> String { + switch variant { + case .klondike: "Classic solitaire with stock and waste" + case .spider: "Build same-suit sequences with two decks" + case .freeCell: "Strategic play with four free cells" + } + } +} diff --git a/SoliCards/Views/Menu/NewGameSheet.swift b/SoliCards/Views/Menu/NewGameSheet.swift new file mode 100644 index 0000000..d5309fa --- /dev/null +++ b/SoliCards/Views/Menu/NewGameSheet.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct NewGameSheet: View { + @State var variant: GameVariant + @State var difficulty: Difficulty + let onStart: (GameVariant, Difficulty) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Game") { + Picker("Variant", selection: $variant) { + ForEach(GameVariant.allCases) { v in + Text(v.displayName).tag(v) + } + } + } + + Section("Difficulty") { + Picker("Difficulty", selection: $difficulty) { + ForEach(Difficulty.allCases) { d in + Text(d.displayName).tag(d) + } + } + .pickerStyle(.segmented) + + difficultyDetails + } + + Section { + Button { + onStart(variant, difficulty) + } label: { + Text("Start Game") + .frame(maxWidth: .infinity) + .font(.headline) + } + .buttonStyle(.borderedProminent) + .listRowBackground(Color.clear) + } + } + .navigationTitle("New Game") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + #if os(macOS) + .frame(width: 400, height: 350) + #endif + } + + private var difficultyDetails: some View { + let settings = difficulty.settings + return VStack(alignment: .leading, spacing: 6) { + if variant == .klondike { + detailRow("Draw", "\(settings.drawCount) card\(settings.drawCount == 1 ? "" : "s")") + } + detailRow("Undos", settings.maxUndos == .max ? "Unlimited" : "\(settings.maxUndos)") + detailRow("Hints", settings.hintsEnabled ? "On" : "Off") + detailRow("Score multiplier", String(format: "%.1fx", settings.scoreMultiplier)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + private func detailRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).fontWeight(.medium) + } + } +} diff --git a/SoliCards/Views/Menu/RulesView.swift b/SoliCards/Views/Menu/RulesView.swift new file mode 100644 index 0000000..47ff9a9 --- /dev/null +++ b/SoliCards/Views/Menu/RulesView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct RulesView: View { + let variant: GameVariant + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + switch variant { + case .klondike: + klondikeRules + case .spider: + spiderRules + case .freeCell: + freeCellRules + } + } + .padding() + } + .navigationTitle("\(variant.displayName) Rules") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var klondikeRules: some View { + VStack(alignment: .leading, spacing: 12) { + ruleSection("Objective", "Move all 52 cards to the four foundation piles, building each from Ace to King in the same suit.") + ruleSection("Tableau", "Build downward in alternating colors (red on black, black on red). Only Kings can be placed on empty columns.") + ruleSection("Foundation", "Build upward by suit from Ace to King.") + ruleSection("Stock & Waste", "Draw cards from the stock pile. The number drawn depends on difficulty (1 or 3 cards).") + ruleSection("Moving Groups", "You can move a properly sequenced group of face-up cards as a unit.") + } + } + + private var spiderRules: some View { + VStack(alignment: .leading, spacing: 12) { + ruleSection("Objective", "Build eight complete King-to-Ace sequences of the same suit. Completed sequences are automatically removed to the foundation.") + ruleSection("Tableau", "Build downward regardless of suit. However, only same-suit sequences can be moved as a group.") + ruleSection("Stock", "Deal one card to each of the 10 columns. All columns must have at least one card before dealing.") + ruleSection("Completing", "When a complete K through A sequence of the same suit is formed on a tableau, it automatically moves to a foundation pile.") + } + } + + private var freeCellRules: some View { + VStack(alignment: .leading, spacing: 12) { + ruleSection("Objective", "Move all 52 cards to the four foundation piles, building each from Ace to King in the same suit.") + ruleSection("Tableau", "Build downward in alternating colors. Any card can go on an empty column.") + ruleSection("Free Cells", "Four temporary storage cells. Each holds one card at a time.") + ruleSection("Power Moves", "The number of cards you can move at once depends on empty free cells and empty columns: (1 + empty cells) \u{00D7} 2^(empty columns).") + ruleSection("Foundation", "Build upward by suit from Ace to King.") + } + } + + private func ruleSection(_ title: String, _ body: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(body) + .font(.body) + .foregroundStyle(.secondary) + } + } +} diff --git a/SoliCards/Views/Settings/CardBackPickerView.swift b/SoliCards/Views/Settings/CardBackPickerView.swift new file mode 100644 index 0000000..9dce32a --- /dev/null +++ b/SoliCards/Views/Settings/CardBackPickerView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct CardBackPickerView: View { + @Binding var selectedBack: CardBackDesign + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))], spacing: 8) { + ForEach(CardBackDesign.allCases, id: \.self) { design in + Button { + selectedBack = design + } label: { + cardBackImage(design) + .frame(width: 55, height: 77) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay { + if design == selectedBack { + RoundedRectangle(cornerRadius: 4) + .stroke(Color.accentColor, lineWidth: 3) + } + } + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private func cardBackImage(_ design: CardBackDesign) -> some View { + #if os(macOS) + if let image = NSImage(named: design.imageName) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + } + #else + if let image = UIImage(named: design.imageName) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + RoundedRectangle(cornerRadius: 4) + .fill(.blue) + } + #endif + } +} diff --git a/SoliCards/Views/Settings/CardStylePickerView.swift b/SoliCards/Views/Settings/CardStylePickerView.swift new file mode 100644 index 0000000..516fef4 --- /dev/null +++ b/SoliCards/Views/Settings/CardStylePickerView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct CardStylePickerView: View { + @Binding var selectedStyle: CardFaceStyle + + var body: some View { + Picker("Card Face Style", selection: $selectedStyle) { + ForEach(CardFaceStyle.allCases, id: \.self) { style in + Text(style.rawValue.capitalized).tag(style) + } + } + .pickerStyle(.segmented) + } +} diff --git a/SoliCards/Views/Settings/SettingsView.swift b/SoliCards/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..a03fded --- /dev/null +++ b/SoliCards/Views/Settings/SettingsView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct SettingsView: View { + @Binding var theme: GameTheme + @Binding var cardFaceStyle: CardFaceStyle + @Binding var cardBackDesign: CardBackDesign + @Binding var soundEnabled: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Theme") { + ThemePickerView(selectedTheme: $theme) + } + + Section("Card Style") { + Picker("Face Style", selection: $cardFaceStyle) { + ForEach(CardFaceStyle.allCases, id: \.self) { style in + Text(style.rawValue.capitalized).tag(style) + } + } + .pickerStyle(.segmented) + } + + Section("Card Back") { + CardBackPickerView(selectedBack: $cardBackDesign) + } + + Section("Sound") { + Toggle("Sound Effects", isOn: $soundEnabled) + } + } + .navigationTitle("Settings") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/SoliCards/Views/Settings/ThemePickerView.swift b/SoliCards/Views/Settings/ThemePickerView.swift new file mode 100644 index 0000000..fe1a8f4 --- /dev/null +++ b/SoliCards/Views/Settings/ThemePickerView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ThemePickerView: View { + @Binding var selectedTheme: GameTheme + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 12) { + ForEach(GameTheme.allThemes) { theme in + Button { + selectedTheme = theme + } label: { + VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 8) + .fill(theme.backgroundColor) + .frame(height: 50) + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(theme.accentColor, lineWidth: 2) + } + + Text(theme.displayName) + .font(.caption2) + .lineLimit(1) + } + .overlay { + if theme == selectedTheme { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.accentColor, lineWidth: 3) + .padding(-4) + } + } + } + .buttonStyle(.plain) + } + } + } +} diff --git a/SoliCards/Views/Statistics/StatisticsView.swift b/SoliCards/Views/Statistics/StatisticsView.swift new file mode 100644 index 0000000..0f31804 --- /dev/null +++ b/SoliCards/Views/Statistics/StatisticsView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import SwiftData + +struct StatisticsView: View { + @Query private var records: [StatsRecord] + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if records.isEmpty { + ContentUnavailableView("No Statistics Yet", + systemImage: "chart.bar", + description: Text("Play some games to see your stats here.")) + } else { + ForEach(GameVariant.allCases) { variant in + let variantRecords = records.filter { $0.variant == variant.rawValue } + if !variantRecords.isEmpty { + Section(variant.displayName) { + ForEach(variantRecords, id: \.variant) { record in + statsRow(record) + } + } + } + } + } + } + .navigationTitle("Statistics") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private func statsRow(_ record: StatsRecord) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(record.difficulty.capitalized) + .font(.subheadline.bold()) + Spacer() + Text("\(record.gamesWon)/\(record.gamesPlayed) wins") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { + if record.gamesPlayed > 0 { + Text("Win rate: \(record.winRate * 100, specifier: "%.0f")%") + Spacer() + if record.bestStreak > 0 { + Text("Best streak: \(record.bestStreak)") + } + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/SoliCardsTests/GameEngine/AutoCompleterTests.swift b/SoliCardsTests/GameEngine/AutoCompleterTests.swift new file mode 100644 index 0000000..8cf75af --- /dev/null +++ b/SoliCardsTests/GameEngine/AutoCompleterTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import SoliCards + +@Suite("AutoCompleter Tests") +struct AutoCompleterTests { + let rules = KlondikeRules() + + @Test("Finds ace to move to empty foundation") + func findAceMove() { + let ace = Card(suit: .hearts, rank: .ace, isFaceUp: true) + var snapshot = emptySnapshot() + snapshot.tableaus[0] = [ace] + + let result = AutoCompleter.findNextAutoMove(state: snapshot, rules: rules) + #expect(result != nil) + #expect(result?.to == .foundation(0)) + } + + @Test("Finds card to build on foundation") + func findFoundationBuild() { + let ace = Card(suit: .hearts, rank: .ace, isFaceUp: true) + let two = Card(suit: .hearts, rank: .two, isFaceUp: true) + var snapshot = emptySnapshot() + snapshot.foundations[0] = [ace] + snapshot.tableaus[0] = [two] + + let result = AutoCompleter.findNextAutoMove(state: snapshot, rules: rules) + #expect(result != nil) + #expect(result?.from == .tableau(0)) + #expect(result?.to == .foundation(0)) + } + + @Test("Returns nil when no moves available") + func noMovesAvailable() { + let snapshot = emptySnapshot() + let result = AutoCompleter.findNextAutoMove(state: snapshot, rules: rules) + #expect(result == nil) + } + + @Test("Finds waste pile ace") + func findWasteAce() { + let ace = Card(suit: .spades, rank: .ace, isFaceUp: true) + var snapshot = emptySnapshot() + snapshot.waste = [ace] + + let result = AutoCompleter.findNextAutoMove(state: snapshot, rules: rules) + #expect(result != nil) + #expect(result?.from == .waste) + } + + @Test("Finds free cell card for FreeCell variant") + func findFreeCellMove() { + let freeCellRules = FreeCellRules() + let ace = Card(suit: .diamonds, rank: .ace, isFaceUp: true) + var snapshot = GameSnapshot( + tableaus: Array(repeating: [], count: 8), + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [ace, nil, nil, nil], + moves: 0, + score: 0 + ) + + let result = AutoCompleter.findNextAutoMove(state: snapshot, rules: freeCellRules) + #expect(result != nil) + #expect(result?.from == .freeCell(0)) + } + + private func emptySnapshot() -> GameSnapshot { + GameSnapshot( + tableaus: Array(repeating: [], count: 7), + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [], + moves: 0, + score: 0 + ) + } +} diff --git a/SoliCardsTests/GameEngine/FreeCellRulesTests.swift b/SoliCardsTests/GameEngine/FreeCellRulesTests.swift new file mode 100644 index 0000000..35ce2e4 --- /dev/null +++ b/SoliCardsTests/GameEngine/FreeCellRulesTests.swift @@ -0,0 +1,87 @@ +import Testing +@testable import SoliCards + +@Suite("FreeCell Rules Tests") +struct FreeCellRulesTests { + let rules = FreeCellRules() + + @Test("Deal distributes all 52 cards face-up") + func dealLayout() { + let deck = Deck.standard() + let snapshot = rules.deal(deck: deck) + + #expect(snapshot.tableaus.count == 8) + // First 4 tableaus: 7 cards, last 4: 6 cards + for i in 0..<4 { + #expect(snapshot.tableaus[i].count == 7) + } + for i in 4..<8 { + #expect(snapshot.tableaus[i].count == 6) + } + // All face-up + let allCards = snapshot.tableaus.flatMap { $0 } + #expect(allCards.count == 52) + #expect(allCards.allSatisfy { $0.isFaceUp }) + + // 4 empty free cells + #expect(snapshot.freeCells.count == 4) + #expect(snapshot.freeCells.allSatisfy { $0 == nil }) + + // No stock + #expect(snapshot.stock.isEmpty) + } + + @Test("Power move calculation") + func powerMoves() { + // (1 + empty_freecells) × 2^empty_tableaus + var snapshot = emptySnapshot() + // All 4 free cells empty, target is empty (so 7 empty tableaus count) + let max1 = rules.calculateMaxMovableCards(state: snapshot, targetEmpty: true) + // (1 + 4) × 2^7 = 5 × 128 = 640 + #expect(max1 == 640) + + // 2 free cells occupied, no empty tableaus + snapshot.freeCells = [Card(suit: .hearts, rank: .ace, isFaceUp: true), + Card(suit: .spades, rank: .two, isFaceUp: true), + nil, nil] + for i in 0..<8 { + snapshot.tableaus[i] = [Card(suit: .clubs, rank: .king, isFaceUp: true)] + } + let max2 = rules.calculateMaxMovableCards(state: snapshot, targetEmpty: false) + // (1 + 2) × 2^0 = 3 + #expect(max2 == 3) + } + + @Test("Can move single card to empty free cell") + func moveToFreeCell() { + let card = Card(suit: .hearts, rank: .five, isFaceUp: true) + var snapshot = emptySnapshot() + snapshot.tableaus[0] = [card] + + #expect(rules.canMove(cards: [card], from: .tableau(0), to: .freeCell(0), state: snapshot)) + } + + @Test("Cannot move multiple cards to free cell") + func multipleToFreeCell() { + let card1 = Card(suit: .spades, rank: .six, isFaceUp: true) + let card2 = Card(suit: .hearts, rank: .five, isFaceUp: true) + var snapshot = emptySnapshot() + snapshot.tableaus[0] = [card1, card2] + + #expect(!rules.canMove(cards: [card1, card2], from: .tableau(0), to: .freeCell(0), state: snapshot)) + } + + // MARK: - Helpers + + private func emptySnapshot() -> GameSnapshot { + GameSnapshot( + tableaus: Array(repeating: [], count: 8), + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [nil, nil, nil, nil], + moves: 0, + score: 0 + ) + } +} diff --git a/SoliCardsTests/GameEngine/GameStateTests.swift b/SoliCardsTests/GameEngine/GameStateTests.swift new file mode 100644 index 0000000..472d45b --- /dev/null +++ b/SoliCardsTests/GameEngine/GameStateTests.swift @@ -0,0 +1,107 @@ +import Testing +@testable import SoliCards + +@Suite("GameState Tests") +struct GameStateTests { + + @Test("Snapshot captures current state") + func snapshotCapture() { + let state = GameState() + let card = Card(suit: .hearts, rank: .ace, isFaceUp: true) + state.tableaus = [[card]] + state.foundations = [[], [], [], []] + state.moves = 5 + state.score = 100 + + let snapshot = state.snapshot() + #expect(snapshot.tableaus[0].count == 1) + #expect(snapshot.moves == 5) + #expect(snapshot.score == 100) + } + + @Test("Restore from snapshot") + func restoreFromSnapshot() { + let state = GameState() + let card = Card(suit: .spades, rank: .king, isFaceUp: true) + let snapshot = GameSnapshot( + tableaus: [[card]], + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [], + moves: 10, + score: 50 + ) + + state.restore(from: snapshot) + #expect(state.tableaus[0].count == 1) + #expect(state.moves == 10) + #expect(state.score == 50) + } + + @Test("Push and pop history") + func historyStack() { + let state = GameState() + state.tableaus = [[]] + state.foundations = [[], [], [], []] + state.moves = 0 + state.score = 0 + + state.pushHistory() + state.moves = 5 + + #expect(state.canUndo) + let restored = state.popHistory() + #expect(restored != nil) + #expect(restored?.moves == 0) + #expect(!state.canUndo) + } + + @Test("History caps at 20 entries") + func historyCap() { + let state = GameState() + state.tableaus = [[]] + state.foundations = [[], [], [], []] + + for i in 0..<25 { + state.moves = i + state.pushHistory() + } + + #expect(state.history.count == 20) + // First entries should have been removed + #expect(state.history.first?.moves == 5) + } + + @Test("Clear history") + func clearHistory() { + let state = GameState() + state.tableaus = [[]] + state.foundations = [[], [], [], []] + state.pushHistory() + state.pushHistory() + #expect(state.canUndo) + + state.clearHistory() + #expect(!state.canUndo) + #expect(state.history.isEmpty) + } + + @Test("Reset from snapshot sets phase to playing") + func resetSetsPlaying() { + let state = GameState() + let snapshot = GameSnapshot( + tableaus: Array(repeating: [], count: 7), + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [], + moves: 0, + score: 0 + ) + + state.reset(from: snapshot, variant: .klondike) + #expect(state.phase == .playing) + #expect(state.history.isEmpty) + } +} diff --git a/SoliCardsTests/GameEngine/KlondikeRulesTests.swift b/SoliCardsTests/GameEngine/KlondikeRulesTests.swift new file mode 100644 index 0000000..d492955 --- /dev/null +++ b/SoliCardsTests/GameEngine/KlondikeRulesTests.swift @@ -0,0 +1,116 @@ +import Testing +@testable import SoliCards + +@Suite("Klondike Rules Tests") +struct KlondikeRulesTests { + let rules = KlondikeRules() + + @Test("Deal creates correct tableau layout") + func dealLayout() { + let deck = Deck.standard() + let snapshot = rules.deal(deck: deck) + + // 7 tableaus with 1,2,3,4,5,6,7 cards + #expect(snapshot.tableaus.count == 7) + for i in 0..<7 { + #expect(snapshot.tableaus[i].count == i + 1) + // Top card face-up + #expect(snapshot.tableaus[i].last!.isFaceUp) + // All others face-down + for j in 0.. GameSnapshot { + GameSnapshot( + tableaus: Array(repeating: [], count: 7), + foundations: [[], [], [], []], + stock: [], + waste: [], + freeCells: [], + moves: 0, + score: 0 + ) + } +} diff --git a/SoliCardsTests/GameEngine/MoveValidatorTests.swift b/SoliCardsTests/GameEngine/MoveValidatorTests.swift new file mode 100644 index 0000000..8565676 --- /dev/null +++ b/SoliCardsTests/GameEngine/MoveValidatorTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import SoliCards + +@Suite("MoveValidator Tests") +struct MoveValidatorTests { + + @Test("Alternating color detection") + func alternatingColor() { + let redCard = Card(suit: .hearts, rank: .five, isFaceUp: true) + let blackCard = Card(suit: .spades, rank: .four, isFaceUp: true) + let anotherRed = Card(suit: .diamonds, rank: .three, isFaceUp: true) + + #expect(MoveValidator.isAlternatingColor(redCard, with: blackCard)) + #expect(MoveValidator.isAlternatingColor(blackCard, with: redCard)) + #expect(!MoveValidator.isAlternatingColor(redCard, with: anotherRed)) + } + + @Test("Same suit detection") + func sameSuit() { + let spade1 = Card(suit: .spades, rank: .ace, isFaceUp: true) + let spade2 = Card(suit: .spades, rank: .king, isFaceUp: true) + let heart = Card(suit: .hearts, rank: .ace, isFaceUp: true) + + #expect(MoveValidator.isSameSuit(spade1, as: spade2)) + #expect(!MoveValidator.isSameSuit(spade1, as: heart)) + } + + @Test("Ascending rank (for foundations)") + func ascending() { + let ace = Card(suit: .hearts, rank: .ace, isFaceUp: true) + let two = Card(suit: .hearts, rank: .two, isFaceUp: true) + let three = Card(suit: .hearts, rank: .three, isFaceUp: true) + + #expect(MoveValidator.isAscending(two, onto: ace)) + #expect(MoveValidator.isAscending(three, onto: two)) + #expect(!MoveValidator.isAscending(three, onto: ace)) + #expect(!MoveValidator.isAscending(ace, onto: two)) + } + + @Test("Descending rank (for tableaus)") + func descending() { + let king = Card(suit: .spades, rank: .king, isFaceUp: true) + let queen = Card(suit: .hearts, rank: .queen, isFaceUp: true) + let jack = Card(suit: .spades, rank: .jack, isFaceUp: true) + + #expect(MoveValidator.isDescending(queen, onto: king)) + #expect(MoveValidator.isDescending(jack, onto: queen)) + #expect(!MoveValidator.isDescending(king, onto: queen)) + } + + @Test("Can place on foundation") + func canPlaceOnFoundation() { + let ace = Card(suit: .hearts, rank: .ace, isFaceUp: true) + let two = Card(suit: .hearts, rank: .two, isFaceUp: true) + let twoSpades = Card(suit: .spades, rank: .two, isFaceUp: true) + let three = Card(suit: .hearts, rank: .three, isFaceUp: true) + + // Ace on empty foundation + #expect(MoveValidator.canPlaceOnFoundation(ace, topCard: nil)) + // Non-ace on empty foundation + #expect(!MoveValidator.canPlaceOnFoundation(two, topCard: nil)) + // Two of hearts on ace of hearts + #expect(MoveValidator.canPlaceOnFoundation(two, topCard: ace)) + // Two of spades on ace of hearts (wrong suit) + #expect(!MoveValidator.canPlaceOnFoundation(twoSpades, topCard: ace)) + // Three on ace (skipping) + #expect(!MoveValidator.canPlaceOnFoundation(three, topCard: ace)) + } + + @Test("Ace and King detection") + func aceAndKing() { + let ace = Card(suit: .clubs, rank: .ace) + let king = Card(suit: .clubs, rank: .king) + let five = Card(suit: .clubs, rank: .five) + + #expect(MoveValidator.isAce(ace)) + #expect(!MoveValidator.isAce(king)) + #expect(MoveValidator.isKing(king)) + #expect(!MoveValidator.isKing(five)) + } +} diff --git a/SoliCardsTests/GameEngine/SpiderRulesTests.swift b/SoliCardsTests/GameEngine/SpiderRulesTests.swift new file mode 100644 index 0000000..a497389 --- /dev/null +++ b/SoliCardsTests/GameEngine/SpiderRulesTests.swift @@ -0,0 +1,86 @@ +import Testing +@testable import SoliCards + +@Suite("Spider Rules Tests") +struct SpiderRulesTests { + let rules = SpiderRules() + + @Test("Deal creates correct layout with 2 decks") + func dealLayout() { + let deck = Deck.double() + let snapshot = rules.deal(deck: deck) + + #expect(snapshot.tableaus.count == 10) + // First 4 get 6 cards, last 6 get 5 cards + for i in 0..<4 { + #expect(snapshot.tableaus[i].count == 6) + } + for i in 4..<10 { + #expect(snapshot.tableaus[i].count == 5) + } + + // Total dealt: 4*6 + 6*5 = 24 + 30 = 54 + let totalDealt = snapshot.tableaus.reduce(0) { $0 + $1.count } + #expect(totalDealt == 54) + + // Remaining in stock: 104 - 54 = 50 + #expect(snapshot.stock.count == 50) + + // 8 empty foundations + #expect(snapshot.foundations.count == 8) + } + + @Test("Can place any card on descending rank in tableau") + func tableauStacking() { + let ten = Card(suit: .spades, rank: .ten, isFaceUp: true) + let nine = Card(suit: .hearts, rank: .nine, isFaceUp: true) // different suit OK + var snapshot = emptySpiderSnapshot() + snapshot.tableaus[0] = [ten] + snapshot.tableaus[1] = [nine] + + #expect(rules.canMove(cards: [nine], from: .tableau(1), to: .tableau(0), state: snapshot)) + } + + @Test("Complete K-A same-suit sequence detected") + func completeSequence() { + var tableau: [Card] = [] + for rank in Rank.allCases.reversed() { + tableau.append(Card(suit: .spades, rank: rank, isFaceUp: true)) + } + #expect(rules.isCompleteSequence(in: tableau)) + } + + @Test("Mixed-suit sequence not complete") + func mixedSuitSequence() { + var tableau: [Card] = [] + for (i, rank) in Rank.allCases.reversed().enumerated() { + let suit: Suit = i == 5 ? .hearts : .spades + tableau.append(Card(suit: suit, rank: rank, isFaceUp: true)) + } + #expect(!rules.isCompleteSequence(in: tableau)) + } + + @Test("Cannot pick up mixed-suit sequence") + func cannotPickUpMixedSuit() { + let spade5 = Card(suit: .spades, rank: .five, isFaceUp: true) + let heart4 = Card(suit: .hearts, rank: .four, isFaceUp: true) + var snapshot = emptySpiderSnapshot() + snapshot.tableaus[0] = [spade5, heart4] + + #expect(!rules.canPickUp(cards: [spade5, heart4], from: .tableau(0), state: snapshot)) + } + + // MARK: - Helpers + + private func emptySpiderSnapshot() -> GameSnapshot { + GameSnapshot( + tableaus: Array(repeating: [], count: 10), + foundations: Array(repeating: [], count: 8), + stock: [], + waste: [], + freeCells: [], + moves: 0, + score: 0 + ) + } +} diff --git a/SoliCardsTests/Models/CardTests.swift b/SoliCardsTests/Models/CardTests.swift new file mode 100644 index 0000000..7af5486 --- /dev/null +++ b/SoliCardsTests/Models/CardTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import SoliCards + +@Suite("Card Model Tests") +struct CardTests { + + @Test("Card color matches suit") + func cardColor() { + let redCard = Card(suit: .hearts, rank: .ace) + let blackCard = Card(suit: .spades, rank: .king) + + #expect(redCard.color == .red) + #expect(blackCard.color == .black) + } + + @Test("Card accessibility description") + func accessibilityDescription() { + let faceUp = Card(suit: .diamonds, rank: .queen, isFaceUp: true) + let faceDown = Card(suit: .clubs, rank: .two, isFaceUp: false) + + #expect(faceUp.accessibilityDescription == "Queen of Diamonds") + #expect(faceDown.accessibilityDescription == "Card, face down") + } + + @Test("Card identity") + func uniqueIdentity() { + let card1 = Card(suit: .spades, rank: .ace) + let card2 = Card(suit: .spades, rank: .ace) + + // Same suit/rank but different IDs + #expect(card1.id != card2.id) + } + + @Test("Front image name") + func frontImageName() { + let card = Card(suit: .hearts, rank: .king) + #expect(card.frontImageName(style: .classic) == "classic_hearts_king") + #expect(card.frontImageName(style: .modern) == "modern_hearts_king") + } +} diff --git a/SoliCardsTests/Models/DeckTests.swift b/SoliCardsTests/Models/DeckTests.swift new file mode 100644 index 0000000..daedf11 --- /dev/null +++ b/SoliCardsTests/Models/DeckTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import SoliCards + +@Suite("Deck Tests") +struct DeckTests { + + @Test("Standard deck has 52 cards") + func standardDeckCount() { + let deck = Deck.standard() + #expect(deck.count == 52) + } + + @Test("Double deck has 104 cards") + func doubleDeckCount() { + let deck = Deck.double() + #expect(deck.count == 104) + } + + @Test("Standard deck has all suits and ranks") + func standardDeckCompleteness() { + let deck = Deck.standard() + for suit in Suit.allCases { + for rank in Rank.allCases { + let count = deck.filter { $0.suit == suit && $0.rank == rank }.count + #expect(count == 1, "Expected exactly 1 \(rank.displayName) of \(suit.displayName)") + } + } + } + + @Test("Deck starts with all cards face down") + func allFaceDown() { + let deck = Deck.standard() + #expect(deck.allSatisfy { !$0.isFaceUp }) + } + + @Test("Shuffle produces different orderings") + func shuffleRandomness() { + let deck1 = Deck.standard() + let deck2 = Deck.standard() + // Extremely unlikely to be identical after shuffle + let sameOrder = zip(deck1, deck2).allSatisfy { $0.suit == $1.suit && $0.rank == $1.rank } + #expect(!sameOrder) + } +} diff --git a/SoliCardsTests/Models/DifficultyTests.swift b/SoliCardsTests/Models/DifficultyTests.swift new file mode 100644 index 0000000..79b3c97 --- /dev/null +++ b/SoliCardsTests/Models/DifficultyTests.swift @@ -0,0 +1,133 @@ +import Testing +@testable import SoliCards + +@Suite("Difficulty Tests") +struct DifficultyTests { + + @Test("Easy settings") + func easySettings() { + let settings = Difficulty.easy.settings + #expect(settings.drawCount == 1) + #expect(settings.maxUndos == .max) + #expect(settings.hintsEnabled) + #expect(settings.scoreMultiplier == 0.5) + } + + @Test("Medium settings") + func mediumSettings() { + let settings = Difficulty.medium.settings + #expect(settings.drawCount == 3) + #expect(settings.maxUndos == 20) + #expect(settings.hintsEnabled) + } + + @Test("Hard settings disable hints") + func hardNoHints() { + let settings = Difficulty.hard.settings + #expect(!settings.hintsEnabled) + #expect(settings.maxUndos == 10) + } + + @Test("Expert settings are most restrictive") + func expertSettings() { + let settings = Difficulty.expert.settings + #expect(!settings.hintsEnabled) + #expect(settings.maxUndos == 5) + #expect(settings.scoreMultiplier == 2.0) + } + + @Test("All difficulties have display names") + func displayNames() { + for difficulty in Difficulty.allCases { + #expect(!difficulty.displayName.isEmpty) + } + } +} + +@Suite("GameVariant Tests") +struct GameVariantTests { + + @Test("Klondike properties") + func klondikeProps() { + let v = GameVariant.klondike + #expect(v.tableauCount == 7) + #expect(v.foundationCount == 4) + #expect(v.deckCount == 1) + #expect(v.hasWaste) + #expect(v.hasStock) + #expect(!v.hasFreeCells) + } + + @Test("Spider properties") + func spiderProps() { + let v = GameVariant.spider + #expect(v.tableauCount == 10) + #expect(v.foundationCount == 8) + #expect(v.deckCount == 2) + #expect(!v.hasFreeCells) + } + + @Test("FreeCell properties") + func freeCellProps() { + let v = GameVariant.freeCell + #expect(v.tableauCount == 8) + #expect(v.foundationCount == 4) + #expect(v.deckCount == 1) + #expect(!v.hasWaste) + #expect(!v.hasStock) + #expect(v.hasFreeCells) + #expect(v.freeCellCount == 4) + } +} + +@Suite("Rank Tests") +struct RankTests { + + @Test("Rank ordering") + func rankOrder() { + #expect(Rank.ace < Rank.two) + #expect(Rank.queen < Rank.king) + #expect(Rank.ace.rawValue == 1) + #expect(Rank.king.rawValue == 13) + } + + @Test("All 13 ranks exist") + func allRanks() { + #expect(Rank.allCases.count == 13) + } + + @Test("File names for assets") + func fileNames() { + #expect(Rank.ace.fileName == "ace") + #expect(Rank.two.fileName == "2") + #expect(Rank.ten.fileName == "10") + #expect(Rank.jack.fileName == "jack") + #expect(Rank.queen.fileName == "queen") + #expect(Rank.king.fileName == "king") + } +} + +@Suite("Suit Tests") +struct SuitTests { + + @Test("Red and black suits") + func suitColors() { + #expect(Suit.hearts.color == .red) + #expect(Suit.diamonds.color == .red) + #expect(Suit.spades.color == .black) + #expect(Suit.clubs.color == .black) + } + + @Test("All 4 suits exist") + func allSuits() { + #expect(Suit.allCases.count == 4) + } + + @Test("Suit symbols") + func symbols() { + #expect(Suit.spades.symbol == "♠") + #expect(Suit.hearts.symbol == "♥") + #expect(Suit.diamonds.symbol == "♦") + #expect(Suit.clubs.symbol == "♣") + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..9375a34 --- /dev/null +++ b/project.yml @@ -0,0 +1,54 @@ +name: SoliCards +options: + bundleIdPrefix: com.solicards + deploymentTarget: + iOS: "17.0" + macOS: "14.0" + xcodeVersion: "16.3" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "6.0" + ENABLE_USER_SCRIPT_SANDBOXING: true + +targets: + SoliCards: + type: application + supportedDestinations: [iOS, macOS] + sources: + - SoliCards + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.solicards.app + GENERATE_INFOPLIST_FILE: true + INFOPLIST_KEY_CFBundleDisplayName: SoliCards + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.card-games + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_EMIT_LOC_STRINGS: "YES" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + INFOPLIST_KEY_UILaunchScreen_Generation: true + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: false + INFOPLIST_KEY_UIRequiresFullScreen: false + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_COMPILATION_MODE: wholemodule + SWIFT_OPTIMIZATION_LEVEL: "-O" + + SoliCardsTests: + type: bundle.unit-test + supportedDestinations: [iOS, macOS] + sources: + - SoliCardsTests + dependencies: + - target: SoliCards + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.solicards.tests + GENERATE_INFOPLIST_FILE: true