feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS
Complete native rewrite of the web-based SoliCards game as a SwiftUI multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+. Three solitaire variants (Klondike, Spider, FreeCell) with full game rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels, SwiftData persistence, VoiceOver accessibility, and 57 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.chat-history/
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
.claude/settings.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
*.xcodeproj/xcuserdata/
|
||||||
|
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||||
81
APP_STORE_METADATA.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# App Store Connect Metadata — SoliCards
|
||||||
|
|
||||||
|
## App Information
|
||||||
|
- **App Name:** SoliCards
|
||||||
|
- **Subtitle:** Classic Solitaire Collection
|
||||||
|
- **Bundle ID:** com.solicards.app
|
||||||
|
- **SKU:** SOLICARDS001
|
||||||
|
- **Category:** Games > Card
|
||||||
|
- **Content Rating:** 4+ (No objectionable content)
|
||||||
|
- **Price:** Free (or set pricing)
|
||||||
|
|
||||||
|
## Version 1.0.0
|
||||||
|
- **Build Number:** 1
|
||||||
|
- **What's New:** Initial release
|
||||||
|
|
||||||
|
## Description
|
||||||
|
SoliCards brings three classic solitaire card games to your iPhone, iPad, and Mac in one beautiful app.
|
||||||
|
|
||||||
|
Play Klondike, Spider, and FreeCell with four difficulty levels, six stunning themes, and full drag-and-drop gameplay. Every game auto-saves so you can pick up right where you left off.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Three complete solitaire variants: Klondike, Spider, and FreeCell
|
||||||
|
- Four difficulty levels: Easy, Medium, Hard, Expert
|
||||||
|
- Six color themes with light and dark mode support
|
||||||
|
- 12 card back designs and multiple card face styles
|
||||||
|
- Smart hint system and auto-complete
|
||||||
|
- Unlimited undo (Easy) or limited undo for a challenge
|
||||||
|
- Game statistics tracking per variant and difficulty
|
||||||
|
- Full VoiceOver accessibility support
|
||||||
|
- Keyboard shortcuts for Mac
|
||||||
|
- No ads, no in-app purchases, no internet required
|
||||||
|
|
||||||
|
## Keywords
|
||||||
|
solitaire, klondike, spider, freecell, card game, patience, cards, classic, offline, free cell
|
||||||
|
|
||||||
|
## Privacy Policy URL
|
||||||
|
[Placeholder — required before submission]
|
||||||
|
|
||||||
|
## Support URL
|
||||||
|
[Placeholder — required before submission]
|
||||||
|
|
||||||
|
## Screenshots Required
|
||||||
|
|
||||||
|
### iPhone 6.9" (iPhone 16 Pro Max)
|
||||||
|
- 1320 x 2868 or 2868 x 1320
|
||||||
|
- Minimum 3 screenshots
|
||||||
|
|
||||||
|
### iPhone 6.3" (iPhone 16 Pro)
|
||||||
|
- 1206 x 2622 or 2622 x 1206
|
||||||
|
|
||||||
|
### iPad Pro 13" (M4)
|
||||||
|
- 2064 x 2752 or 2752 x 2064
|
||||||
|
|
||||||
|
### Mac
|
||||||
|
- 1280 x 800 minimum, 2560 x 1600 recommended
|
||||||
|
|
||||||
|
### Suggested Screenshots
|
||||||
|
1. Klondike game in progress (Classic Green theme)
|
||||||
|
2. Spider game showing 10-column layout
|
||||||
|
3. FreeCell game with power moves
|
||||||
|
4. Theme picker showing all 6 themes
|
||||||
|
5. New Game sheet with difficulty options
|
||||||
|
|
||||||
|
## Age Rating Questionnaire
|
||||||
|
- Unrestricted Web Access: No
|
||||||
|
- Gambling/Contests: No (solitaire is single-player, no real money)
|
||||||
|
- Mature/Suggestive Themes: None
|
||||||
|
- Violence: None
|
||||||
|
- All other categories: None
|
||||||
|
- **Result: 4+**
|
||||||
|
|
||||||
|
## In-App Purchases
|
||||||
|
None
|
||||||
|
|
||||||
|
## App Tracking Transparency
|
||||||
|
Not required — no tracking, no third-party SDKs
|
||||||
|
|
||||||
|
## Export Compliance
|
||||||
|
Uses no encryption beyond standard HTTPS (N/A — no networking)
|
||||||
|
- **ECCN:** Not applicable
|
||||||
|
- **Contains encryption:** No
|
||||||
102
ARCHITECTURE.md
Normal file
|
|
@ -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.
|
||||||
78
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-04-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **macOS maximized window cutoff** — cards no longer overflow the bottom when the window is maximized. Height calculation uses 92% of available space as buffer for platform chrome and spacing.
|
||||||
|
- **macOS card size cap** — maximum card width capped at 120pt on macOS to prevent oversized cards on large displays. Extra space distributed as wider inter-card gaps.
|
||||||
|
- **iOS landscape boost scoped to iOS only** — the 30% landscape card size boost and 130% scroll content height no longer apply on macOS, where they caused overflow.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Drag and drop rewritten** — replaced `DragGesture` offset-based system with absolute position tracking (`dragPosition: CGPoint`). Cards now follow the finger exactly instead of appearing displaced.
|
||||||
|
- **Drop target resolution moved to ViewModel** — `endDrag(at:)` method checks drop targets directly, eliminating the need for a separate global gesture on the board.
|
||||||
|
- **Long press + drag** — card dragging now requires a 0.15s long press before drag begins, disambiguating from scroll gestures and tap-to-move.
|
||||||
|
- **Gesture priority fixed** — card drag uses `.simultaneousGesture()` instead of `.gesture()` so tap-to-move (`onTapGesture`) is never blocked. Tapping an ace now reliably moves it to the foundation.
|
||||||
|
- **Bottom toolbar redesigned (iOS)** — reduced from 9 cramped icons to 4 clear items with labels: New, Undo, Hint, More (menu). Secondary actions (variant, difficulty, auto-complete, sound, rules, stats, settings) consolidated into the "More" menu.
|
||||||
|
- **Toolbar contrast fixed** — bottom bar uses `.ultraThinMaterial` background with `.toolbarColorScheme(.dark)` so icons are readable against all game themes.
|
||||||
|
- **Top row aligned to tableau columns** — stock at column 0, waste at column 1, gap at column 2, foundations at columns 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
|
||||||
48
CLAUDE.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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 following the XCode-Claude-Workflow methodology.
|
||||||
|
|
||||||
|
## 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 for full details.
|
||||||
|
|
||||||
|
- `GameRules` protocol with 3 conforming structs: `KlondikeRules`, `SpiderRules`, `FreeCellRules`
|
||||||
|
- `@Observable` macro for property-level SwiftUI observation
|
||||||
|
- `@MainActor` on ViewModels for Swift 6 concurrency safety
|
||||||
|
- `DragGesture` + `PreferenceKey` frame hit-testing for card drag & drop
|
||||||
|
- 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:)`
|
||||||
|
- Card images loaded from asset catalog via platform-conditional helpers in `CardView`
|
||||||
|
- Long press (0.15s) + drag for card movement; tap for auto-move; swipe to scroll (iOS landscape)
|
||||||
|
|
||||||
|
## Test Suite
|
||||||
|
|
||||||
|
57 tests across 12 suites in `SoliCardsTests/` using Swift Testing (`@Test`, `#expect`).
|
||||||
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# SoliCards
|
||||||
|
|
||||||
|
Native SwiftUI solitaire card game for **iOS 17+**, **iPadOS 17+**, and **macOS 14+**.
|
||||||
|
|
||||||
|
Three classic variants — Klondike, Spider, and FreeCell — with drag & drop, smart zoom layout, six themes, four difficulty levels, and full accessibility support.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **3 game variants:** Klondike, Spider (2-deck), FreeCell (power moves)
|
||||||
|
- **4 difficulty levels:** Easy, Medium, Hard, Expert
|
||||||
|
- **6 color themes:** Classic Green, Dark Mode, Ocean Blue, Royal Purple, Forest Green, Sunset Orange
|
||||||
|
- **12 card back designs** and multiple card face styles
|
||||||
|
- **Smart zoom:** cards auto-size to fit any screen and orientation
|
||||||
|
- **Drag & drop** with long-press disambiguation and tap-to-move
|
||||||
|
- **Landscape optimized:** 30% bigger cards on iOS with scrollable overflow
|
||||||
|
- **Auto-save** and game resume via SwiftData
|
||||||
|
- **Statistics:** wins, losses, streaks per variant/difficulty
|
||||||
|
- **VoiceOver accessible** with Dynamic Type and Reduce Motion support
|
||||||
|
- **Keyboard shortcuts:** Cmd+Z (undo), Cmd+N (new game), H (hint)
|
||||||
|
- **Zero external dependencies**
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
xcodegen generate
|
||||||
|
open SoliCards.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Press **Cmd+R** to build and run.
|
||||||
|
|
||||||
|
See [SETUP.md](SETUP.md) for full build commands and project structure.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
MVVM + Protocol-Oriented Strategy pattern. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
Ported from a web-based JavaScript solitaire game using the [XCode-Claude-Workflow](https://git.istratai.cloud/aj/XCode-Claude-Workflow) methodology.
|
||||||
127
SETUP.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# SETUP.md — SoliCards
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Xcode 16.3+** (or latest stable)
|
||||||
|
- **macOS 14+** (Sonoma or later)
|
||||||
|
- **xcodegen** (for project generation from `project.yml`)
|
||||||
|
|
||||||
|
Install xcodegen:
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
```
|
||||||
|
|
||||||
|
No other dependencies. The project has zero external packages.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 1. Generate the Xcode project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodegen generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This reads `project.yml` and creates `SoliCards.xcodeproj`.
|
||||||
|
|
||||||
|
### 2. Open in Xcode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open SoliCards.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Select a target and run
|
||||||
|
|
||||||
|
| Target | Destination | Notes |
|
||||||
|
|--------|-------------|-------|
|
||||||
|
| SoliCards | iPhone/iPad Simulator | iOS 17+ |
|
||||||
|
| SoliCards | My Mac | macOS 14+ (native) |
|
||||||
|
| SoliCardsTests | iPhone Simulator | Unit tests |
|
||||||
|
|
||||||
|
## Build Commands (CLI)
|
||||||
|
|
||||||
|
### Debug build (iOS)
|
||||||
|
```bash
|
||||||
|
xcodebuild build \
|
||||||
|
-project SoliCards.xcodeproj \
|
||||||
|
-scheme SoliCards \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug build (macOS)
|
||||||
|
```bash
|
||||||
|
xcodebuild build \
|
||||||
|
-project SoliCards.xcodeproj \
|
||||||
|
-scheme SoliCards \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release build
|
||||||
|
```bash
|
||||||
|
xcodebuild build \
|
||||||
|
-project SoliCards.xcodeproj \
|
||||||
|
-scheme SoliCards \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
```bash
|
||||||
|
xcodebuild test \
|
||||||
|
-project SoliCards.xcodeproj \
|
||||||
|
-scheme SoliCardsTests \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static analysis
|
||||||
|
```bash
|
||||||
|
xcodebuild analyze \
|
||||||
|
-project SoliCards.xcodeproj \
|
||||||
|
-scheme SoliCards \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schemes
|
||||||
|
|
||||||
|
| Scheme | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| SoliCards | Main app (iOS + macOS multiplatform) |
|
||||||
|
| SoliCardsTests | Unit test bundle (57 tests, 12 suites) |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SoliCards/ # 53 Swift source files
|
||||||
|
├── SoliCardsApp.swift # @main entry point
|
||||||
|
├── ContentView.swift # Root navigation, persistence wiring
|
||||||
|
├── Models/ # Card, Suit, Rank, GameVariant, Difficulty, etc.
|
||||||
|
├── GameEngine/ # GameRules protocol + 3 variant implementations
|
||||||
|
├── ViewModels/ # GameViewModel, SettingsViewModel, StatsViewModel
|
||||||
|
├── Views/ # SwiftUI views (Game, Menu, Settings, Statistics)
|
||||||
|
├── Services/ # SoundManager, TimerService, HapticManager
|
||||||
|
├── Persistence/ # SwiftData models + PersistenceManager
|
||||||
|
├── Theme/ # GameTheme, ThemeManager
|
||||||
|
├── Extensions/ # CardLayout, Array+Card
|
||||||
|
└── Resources/ # Assets.xcassets, Localizable.xcstrings, PrivacyInfo
|
||||||
|
|
||||||
|
SoliCardsTests/ # 9 test files, 57 tests
|
||||||
|
├── Models/ # CardTests, DeckTests, DifficultyTests
|
||||||
|
└── GameEngine/ # KlondikeRulesTests, SpiderRulesTests, FreeCellRulesTests,
|
||||||
|
# MoveValidatorTests, AutoCompleterTests, GameStateTests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Card Assets
|
||||||
|
|
||||||
|
- **52 card front PNGs** imported from `game-SoliCards/svg_playing_cards/fronts/`
|
||||||
|
- **12 card back PNGs** imported from `game-SoliCards/svg_playing_cards/backs/`
|
||||||
|
- All stored in `SoliCards/Resources/Assets.xcassets/` as imagesets
|
||||||
|
|
||||||
|
## Regenerating the Project
|
||||||
|
|
||||||
|
If you modify `project.yml` (add targets, change settings, etc.):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodegen generate
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.xcodeproj` is generated — do not edit it by hand.
|
||||||
823
SoliCards.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,823 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
05F732433249D0777BFE8F91 /* KlondikeRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B31155959181B8D516A82096 /* KlondikeRulesTests.swift */; };
|
||||||
|
0D292BDC30C9D09CF4677FFA /* SpiderBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2065F4D438686C32E01E449F /* SpiderBoardView.swift */; };
|
||||||
|
0DB00631BF90F0626BAE21CD /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */; };
|
||||||
|
12A564800C8BAED47DAD0C47 /* GameState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3488A79053BB9BF5BF6C39 /* GameState.swift */; };
|
||||||
|
171B8FB2868AAE8D3198B05E /* CardStylePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */; };
|
||||||
|
19E266494C3F614EEEC6539B /* GameVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */; };
|
||||||
|
1A5B631C600469C9F28CF783 /* DraggedCardsOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */; };
|
||||||
|
29B7A35FE0C531B8CF8BF814 /* GameStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */; };
|
||||||
|
2A123DBA7BA8AB08CADD6BDF /* CardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */; };
|
||||||
|
34CB07FE798181D502428D1D /* Array+Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */; };
|
||||||
|
37D0960283347728B64A2FB4 /* MoveValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38D83697D866ACA495ED4B8 /* MoveValidator.swift */; };
|
||||||
|
3997014B0E7EA0A651AB0216 /* FreeCellRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */; };
|
||||||
|
3D6A93AB14AEAD9E65BB181D /* SpiderRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */; };
|
||||||
|
3E5C08ACC630D771613ECA16 /* MainMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6AE9622A04A543946250427 /* MainMenuView.swift */; };
|
||||||
|
40A295F6F752B57C290C49C5 /* SoliCardsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */; };
|
||||||
|
42FC69591B33DD5370784036 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA638FEAAD15EBE1E78FFE14 /* CardView.swift */; };
|
||||||
|
43F316092B0C12D7A7276309 /* CardBackPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */; };
|
||||||
|
452209A28A7E2AA36CDFEFCA /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */; };
|
||||||
|
4BF879FC8DB20D9ED32D4738 /* AutoCompleterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */; };
|
||||||
|
537755217C0912F24EE2F5C6 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1321C0FD7F644967F371B7A /* NewGameSheet.swift */; };
|
||||||
|
5550A93B56A7BE882A1C7303 /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */; };
|
||||||
|
55A796BFBF38FBA8B6C6808F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9810F299E93134997859B2BD /* ContentView.swift */; };
|
||||||
|
56B286A65F7D8845885E9AF1 /* MoveAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */; };
|
||||||
|
56D2056F3CA46D09F2D94F10 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634429F416F1A484018E084E /* StatisticsView.swift */; };
|
||||||
|
586EA99FAD450DB90C430CA0 /* GamePhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 488C04322FF37B821C662516 /* GamePhase.swift */; };
|
||||||
|
5B89DF181690B09ED9204598 /* GameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4677EC6C93B738F443D86 /* GameViewModel.swift */; };
|
||||||
|
5D4B0E1123A7870248DA8360 /* AutoCompleter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115321F0A0475538BA016151 /* AutoCompleter.swift */; };
|
||||||
|
5E2EA0FE1F0216236DF6BE56 /* FreeCellBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */; };
|
||||||
|
60111A0FEC2BB3227E4E02E4 /* ScoreBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */; };
|
||||||
|
64E9B99DFBCD0F1D59F3D890 /* Difficulty.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64A7B696016187523AD7277 /* Difficulty.swift */; };
|
||||||
|
6585CAA4B2AD823C1F6C20DE /* GameTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */; };
|
||||||
|
65E127C9C91486AF86F9A693 /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD303449EB1172C9B52E624 /* SoundManager.swift */; };
|
||||||
|
65E698A14BC2C0E659A46128 /* MoveValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4901135D3529A476DEF089F /* MoveValidatorTests.swift */; };
|
||||||
|
7084235B866C1493AC18A061 /* Rank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */; };
|
||||||
|
70C69A048BE67CDD84E38B26 /* StatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */; };
|
||||||
|
79BCE98C4E8F851A36D4F753 /* KlondikeRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */; };
|
||||||
|
7D5D7789D2BA535A59EFE6EA /* CardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7D93BC01A3997DD183D368 /* CardTests.swift */; };
|
||||||
|
800459364BF6FD875D15171D /* DropTargetPreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */; };
|
||||||
|
81B92CA2BDA948B74EEDA6B9 /* VictoryOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */; };
|
||||||
|
8E4A7DFD89F451F7499F2684 /* RulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04FAD6B4305ABFB4769DE792 /* RulesView.swift */; };
|
||||||
|
8F37719FF9A39485718BAD78 /* KlondikeBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */; };
|
||||||
|
A4BD7013B7979EA922BA5041 /* CardStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE2B9027264A1134D2B65DC /* CardStackView.swift */; };
|
||||||
|
A5D797208B75A1C7D23AFB64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB81C7AA5EDF842B3D841907 /* Assets.xcassets */; };
|
||||||
|
A68F6A65F5897179FA3632BF /* TimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A61A63D3A6077A80ED09B24 /* TimerService.swift */; };
|
||||||
|
AA99FA31E0F4094CBE06FFAF /* Deck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16BEB4EF07C82D8B2A89D260 /* Deck.swift */; };
|
||||||
|
B7A5F011A7C538B27F382FB5 /* GameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */; };
|
||||||
|
BA010A1D6573E1DA0D182EF8 /* CardLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */; };
|
||||||
|
BAD817F065BBAAC4B7C89043 /* GameBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E14435814D19CE147D6D408 /* GameBoardView.swift */; };
|
||||||
|
CBEE7677D3E2D9A9CFAA90D0 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B25C883D3B49CADA34775F1 /* SettingsView.swift */; };
|
||||||
|
CFDAB7D49E3D6CBB420F4CF1 /* HintResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 864D497FD148C1DCA5761247 /* HintResult.swift */; };
|
||||||
|
D00C18D126362C368802C9E3 /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72E42967DA3AA21AEE42F177 /* Card.swift */; };
|
||||||
|
D24334636360CE9659A3040A /* GameRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */; };
|
||||||
|
D4520E37CA093BDA7DBAB7F5 /* DifficultyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58632F749B766B0E79DD0152 /* DifficultyTests.swift */; };
|
||||||
|
D75EE30A448E01FD3A2EAD0A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */; };
|
||||||
|
D780D65841792E26E9208B8D /* GameRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */; };
|
||||||
|
D9E61277B5EA0772F7B28BFF /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */; };
|
||||||
|
DC0D004F619DE9C61C6AF585 /* PrefsRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */; };
|
||||||
|
E15025574906B1F1C9A03C8F /* SpiderRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4319F36333564A75A3FEB0 /* SpiderRules.swift */; };
|
||||||
|
E9ED2A8341168D2915B4F495 /* DeckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B14B437E3A3475278EC5 /* DeckTests.swift */; };
|
||||||
|
F02DBFB48DB80D36451D436B /* FreeCellRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */; };
|
||||||
|
F3AA49C17343374CF1B82FF7 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 396230FC7779389B46BE1246 /* ThemeManager.swift */; };
|
||||||
|
F4C3C28A20ECA87EC97FD2DC /* Suit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 116E45BE9C698645A16CF6F3 /* Suit.swift */; };
|
||||||
|
F713DA5821F2B37B2019CA1D /* StatsRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66617C01DF8B874B51A55295 /* StatsRecord.swift */; };
|
||||||
|
FD1C9241508230FD8E553981 /* GameRulesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */; };
|
||||||
|
FDCB1D9771FAC46DCE25A6A6 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
0457F9A25F53E04E37959F48 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 867199988082B6D71FDDE2D8 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 82669FCF0B13F7341360B265;
|
||||||
|
remoteInfo = SoliCards;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
04FAD6B4305ABFB4769DE792 /* RulesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesView.swift; sourceTree = "<group>"; };
|
||||||
|
0A61A63D3A6077A80ED09B24 /* TimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerService.swift; sourceTree = "<group>"; };
|
||||||
|
0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
|
||||||
|
0C4319F36333564A75A3FEB0 /* SpiderRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRules.swift; sourceTree = "<group>"; };
|
||||||
|
115321F0A0475538BA016151 /* AutoCompleter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleter.swift; sourceTree = "<group>"; };
|
||||||
|
116E45BE9C698645A16CF6F3 /* Suit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suit.swift; sourceTree = "<group>"; };
|
||||||
|
1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsRecord.swift; sourceTree = "<group>"; };
|
||||||
|
16BEB4EF07C82D8B2A89D260 /* Deck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Deck.swift; sourceTree = "<group>"; };
|
||||||
|
1B25C883D3B49CADA34775F1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
1EE2B9027264A1134D2B65DC /* CardStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStackView.swift; sourceTree = "<group>"; };
|
||||||
|
2065F4D438686C32E01E449F /* SpiderBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderBoardView.swift; sourceTree = "<group>"; };
|
||||||
|
2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoliCardsApp.swift; sourceTree = "<group>"; };
|
||||||
|
22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRules.swift; sourceTree = "<group>"; };
|
||||||
|
23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
|
||||||
|
25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropTargetPreferenceKey.swift; sourceTree = "<group>"; };
|
||||||
|
26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRulesFactory.swift; sourceTree = "<group>"; };
|
||||||
|
26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRules.swift; sourceTree = "<group>"; };
|
||||||
|
2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSnapshot.swift; sourceTree = "<group>"; };
|
||||||
|
2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveAction.swift; sourceTree = "<group>"; };
|
||||||
|
396230FC7779389B46BE1246 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||||
|
3DD303449EB1172C9B52E624 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = "<group>"; };
|
||||||
|
403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeBoardView.swift; sourceTree = "<group>"; };
|
||||||
|
488C04322FF37B821C662516 /* GamePhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePhase.swift; sourceTree = "<group>"; };
|
||||||
|
4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRecord.swift; sourceTree = "<group>"; };
|
||||||
|
4B7D93BC01A3997DD183D368 /* CardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardTests.swift; sourceTree = "<group>"; };
|
||||||
|
4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLayout.swift; sourceTree = "<group>"; };
|
||||||
|
58632F749B766B0E79DD0152 /* DifficultyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DifficultyTests.swift; sourceTree = "<group>"; };
|
||||||
|
5E14435814D19CE147D6D408 /* GameBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameBoardView.swift; sourceTree = "<group>"; };
|
||||||
|
634429F416F1A484018E084E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = "<group>"; };
|
||||||
|
66617C01DF8B874B51A55295 /* StatsRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsRecord.swift; sourceTree = "<group>"; };
|
||||||
|
6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rank.swift; sourceTree = "<group>"; };
|
||||||
|
714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
|
72E42967DA3AA21AEE42F177 /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = "<group>"; };
|
||||||
|
7561129FE301D2A5E3652648 /* SoliCards.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SoliCards.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SoliCardsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
864D497FD148C1DCA5761247 /* HintResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HintResult.swift; sourceTree = "<group>"; };
|
||||||
|
94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleterTests.swift; sourceTree = "<group>"; };
|
||||||
|
9810F299E93134997859B2BD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
A64A7B696016187523AD7277 /* Difficulty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Difficulty.swift; sourceTree = "<group>"; };
|
||||||
|
A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Card.swift"; sourceTree = "<group>"; };
|
||||||
|
A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRules.swift; sourceTree = "<group>"; };
|
||||||
|
A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStateTests.swift; sourceTree = "<group>"; };
|
||||||
|
AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceManager.swift; sourceTree = "<group>"; };
|
||||||
|
AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellBoardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1321C0FD7F644967F371B7A /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
|
||||||
|
B31155959181B8D516A82096 /* KlondikeRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlondikeRulesTests.swift; sourceTree = "<group>"; };
|
||||||
|
B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpiderRulesTests.swift; sourceTree = "<group>"; };
|
||||||
|
C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggedCardsOverlay.swift; sourceTree = "<group>"; };
|
||||||
|
C0B4677EC6C93B738F443D86 /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
C38D83697D866ACA495ED4B8 /* MoveValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidator.swift; sourceTree = "<group>"; };
|
||||||
|
C4901135D3529A476DEF089F /* MoveValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveValidatorTests.swift; sourceTree = "<group>"; };
|
||||||
|
C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreBarView.swift; sourceTree = "<group>"; };
|
||||||
|
C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeCellRulesTests.swift; sourceTree = "<group>"; };
|
||||||
|
CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardLocation.swift; sourceTree = "<group>"; };
|
||||||
|
CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardBackPickerView.swift; sourceTree = "<group>"; };
|
||||||
|
CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VictoryOverlayView.swift; sourceTree = "<group>"; };
|
||||||
|
CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameTheme.swift; sourceTree = "<group>"; };
|
||||||
|
CD3488A79053BB9BF5BF6C39 /* GameState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameState.swift; sourceTree = "<group>"; };
|
||||||
|
DA638FEAAD15EBE1E78FFE14 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
|
||||||
|
DB81C7AA5EDF842B3D841907 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
E6AE9622A04A543946250427 /* MainMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenuView.swift; sourceTree = "<group>"; };
|
||||||
|
EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardStylePickerView.swift; sourceTree = "<group>"; };
|
||||||
|
F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameVariant.swift; sourceTree = "<group>"; };
|
||||||
|
FDF0B14B437E3A3475278EC5 /* DeckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckTests.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
08AF7AF8C77BED3EFA6A9AEA /* Statistics */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
634429F416F1A484018E084E /* StatisticsView.swift */,
|
||||||
|
);
|
||||||
|
path = Statistics;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0FF95606A40279B4D3390FBA = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2182AF78CEA93B7431E85608 /* SoliCards */,
|
||||||
|
D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */,
|
||||||
|
9CCB9EC5C71BA2DF24919C93 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2182AF78CEA93B7431E85608 /* SoliCards */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9810F299E93134997859B2BD /* ContentView.swift */,
|
||||||
|
2123C4E1A2A72E80EC1123A5 /* SoliCardsApp.swift */,
|
||||||
|
E4D19DAEAABB74B9FDD84B73 /* Extensions */,
|
||||||
|
3B2FB59B0E86B8F19E698694 /* GameEngine */,
|
||||||
|
FF9DBC6C605DE18ED6DA5E1C /* Models */,
|
||||||
|
2D71FE366319D21065F80CFD /* Persistence */,
|
||||||
|
3271C08BF426FD74787C6217 /* Resources */,
|
||||||
|
A1D36CB5F370E4F3906E319A /* Services */,
|
||||||
|
A8376EC525EE5E88D4517BA1 /* Theme */,
|
||||||
|
C6C973AB5DCD1C0E766F633A /* ViewModels */,
|
||||||
|
F84CF6704CBB01C0C9875CE2 /* Views */,
|
||||||
|
);
|
||||||
|
path = SoliCards;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2D71FE366319D21065F80CFD /* Persistence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4991B4E4B3E6B9AC2EDA629F /* GameRecord.swift */,
|
||||||
|
AAA573DB9A21FE34ECFF6794 /* PersistenceManager.swift */,
|
||||||
|
1391353BDCE5C135A9FA1F0A /* PrefsRecord.swift */,
|
||||||
|
66617C01DF8B874B51A55295 /* StatsRecord.swift */,
|
||||||
|
);
|
||||||
|
path = Persistence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3271C08BF426FD74787C6217 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DB81C7AA5EDF842B3D841907 /* Assets.xcassets */,
|
||||||
|
714F0FD173ED0BC8E680E85A /* Localizable.xcstrings */,
|
||||||
|
8595FFC9DDE3EA38CE0A617B /* PrivacyInfo.xcprivacy */,
|
||||||
|
AB3F10466EA26B5E0B5827A8 /* Sounds */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3B2FB59B0E86B8F19E698694 /* GameEngine */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
115321F0A0475538BA016151 /* AutoCompleter.swift */,
|
||||||
|
A8ABFB6829A8D0B281CCD3E9 /* FreeCellRules.swift */,
|
||||||
|
26F1DE1DBC8F3FFC732DA618 /* GameRules.swift */,
|
||||||
|
26A1B8A570F6B4F573E29FCA /* GameRulesFactory.swift */,
|
||||||
|
CD3488A79053BB9BF5BF6C39 /* GameState.swift */,
|
||||||
|
22BD39F68FBBFFB35FCB0EC5 /* KlondikeRules.swift */,
|
||||||
|
C38D83697D866ACA495ED4B8 /* MoveValidator.swift */,
|
||||||
|
0C4319F36333564A75A3FEB0 /* SpiderRules.swift */,
|
||||||
|
);
|
||||||
|
path = GameEngine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
45A518EB42C0CDB893716BBE /* Settings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CB728E0216832C7887E0B0E4 /* CardBackPickerView.swift */,
|
||||||
|
EF9D48C269BE378EF381ADF6 /* CardStylePickerView.swift */,
|
||||||
|
1B25C883D3B49CADA34775F1 /* SettingsView.swift */,
|
||||||
|
23DEF0FCB8B782702D108DF3 /* ThemePickerView.swift */,
|
||||||
|
);
|
||||||
|
path = Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9CCB9EC5C71BA2DF24919C93 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7561129FE301D2A5E3652648 /* SoliCards.app */,
|
||||||
|
7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A1D36CB5F370E4F3906E319A /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0B4ED587E94AC6FAE79FDAE8 /* HapticManager.swift */,
|
||||||
|
3DD303449EB1172C9B52E624 /* SoundManager.swift */,
|
||||||
|
0A61A63D3A6077A80ED09B24 /* TimerService.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A5353C257EB10EB9526C6E39 /* Menu */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E6AE9622A04A543946250427 /* MainMenuView.swift */,
|
||||||
|
B1321C0FD7F644967F371B7A /* NewGameSheet.swift */,
|
||||||
|
04FAD6B4305ABFB4769DE792 /* RulesView.swift */,
|
||||||
|
);
|
||||||
|
path = Menu;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A8376EC525EE5E88D4517BA1 /* Theme */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CD2BABA5FED1321ABA0A2F55 /* GameTheme.swift */,
|
||||||
|
396230FC7779389B46BE1246 /* ThemeManager.swift */,
|
||||||
|
);
|
||||||
|
path = Theme;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AB3F10466EA26B5E0B5827A8 /* Sounds */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = Sounds;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C6C973AB5DCD1C0E766F633A /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0B4677EC6C93B738F443D86 /* GameViewModel.swift */,
|
||||||
|
C60AA0F17A17B13707CB0228 /* SettingsViewModel.swift */,
|
||||||
|
B8D4A7ACB3BD58FB808D7F89 /* StatsViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D4605BF9A1E255A1F2E44B4F /* SoliCardsTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DB86ADC0F0581A7B56EBE28E /* GameEngine */,
|
||||||
|
E5E67911DEBB2793B3D8F1AD /* Models */,
|
||||||
|
7A41E8DA0C1CCBC6C102E3A0 /* ViewModels */,
|
||||||
|
);
|
||||||
|
path = SoliCardsTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DA0714078CFC7F8BCD993F8C /* Game */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1EE2B9027264A1134D2B65DC /* CardStackView.swift */,
|
||||||
|
DA638FEAAD15EBE1E78FFE14 /* CardView.swift */,
|
||||||
|
C01DD3D6B3B0DEA17B94C81C /* DraggedCardsOverlay.swift */,
|
||||||
|
25D311E1FE85DB24EA48404B /* DropTargetPreferenceKey.swift */,
|
||||||
|
AE7880E1D80183ABCFBF49F4 /* FreeCellBoardView.swift */,
|
||||||
|
5E14435814D19CE147D6D408 /* GameBoardView.swift */,
|
||||||
|
403990D89B8854B6A45A44A2 /* KlondikeBoardView.swift */,
|
||||||
|
C5C2150140E0A5A3C4CD18D0 /* ScoreBarView.swift */,
|
||||||
|
2065F4D438686C32E01E449F /* SpiderBoardView.swift */,
|
||||||
|
CCE1E3886FE8A8FA0DD3C54A /* VictoryOverlayView.swift */,
|
||||||
|
);
|
||||||
|
path = Game;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DB86ADC0F0581A7B56EBE28E /* GameEngine */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
94A81A01510385FC02760FB4 /* AutoCompleterTests.swift */,
|
||||||
|
C8A7187F45271D5F9FBB0A07 /* FreeCellRulesTests.swift */,
|
||||||
|
A8E9415DE47EA69B52839CA5 /* GameStateTests.swift */,
|
||||||
|
B31155959181B8D516A82096 /* KlondikeRulesTests.swift */,
|
||||||
|
C4901135D3529A476DEF089F /* MoveValidatorTests.swift */,
|
||||||
|
BC022E983E9801D0A3A448B4 /* SpiderRulesTests.swift */,
|
||||||
|
);
|
||||||
|
path = GameEngine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E4D19DAEAABB74B9FDD84B73 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A7DAC2213D876C2E0F48CBB3 /* Array+Card.swift */,
|
||||||
|
4E2E837BB1ADC907A8F991A0 /* CardLayout.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E5E67911DEBB2793B3D8F1AD /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4B7D93BC01A3997DD183D368 /* CardTests.swift */,
|
||||||
|
FDF0B14B437E3A3475278EC5 /* DeckTests.swift */,
|
||||||
|
58632F749B766B0E79DD0152 /* DifficultyTests.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F84CF6704CBB01C0C9875CE2 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DA0714078CFC7F8BCD993F8C /* Game */,
|
||||||
|
A5353C257EB10EB9526C6E39 /* Menu */,
|
||||||
|
45A518EB42C0CDB893716BBE /* Settings */,
|
||||||
|
08AF7AF8C77BED3EFA6A9AEA /* Statistics */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FF9DBC6C605DE18ED6DA5E1C /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
72E42967DA3AA21AEE42F177 /* Card.swift */,
|
||||||
|
CA7C39CE680C4AFC5D7959BC /* CardLocation.swift */,
|
||||||
|
16BEB4EF07C82D8B2A89D260 /* Deck.swift */,
|
||||||
|
A64A7B696016187523AD7277 /* Difficulty.swift */,
|
||||||
|
488C04322FF37B821C662516 /* GamePhase.swift */,
|
||||||
|
2BF99A0055488CAE6E45D4A3 /* GameSnapshot.swift */,
|
||||||
|
F31B3F7875D68C0BA0B8FB54 /* GameVariant.swift */,
|
||||||
|
864D497FD148C1DCA5761247 /* HintResult.swift */,
|
||||||
|
2E568D8FC36EF5D9937E0F97 /* MoveAction.swift */,
|
||||||
|
6C9B49166D2E8BE0D9AA2B46 /* Rank.swift */,
|
||||||
|
116E45BE9C698645A16CF6F3 /* Suit.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
82669FCF0B13F7341360B265 /* SoliCards */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F6C31F00417F79A014C90960 /* Build configuration list for PBXNativeTarget "SoliCards" */;
|
||||||
|
buildPhases = (
|
||||||
|
8C4FBA4AEB4F83B6D76C3868 /* Sources */,
|
||||||
|
00D9BFDDDE0AF0067CA90858 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = SoliCards;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SoliCards;
|
||||||
|
productReference = 7561129FE301D2A5E3652648 /* SoliCards.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
C27736B4220A23D60C765E3E /* SoliCardsTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 6F270CC209153B7149368FF8 /* Build configuration list for PBXNativeTarget "SoliCardsTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
00839BE9C0947FF6620A82FC /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
CE28FBA2CF52896AC36A1F13 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = SoliCardsTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SoliCardsTests;
|
||||||
|
productReference = 7E57D0CF303B42DD18A317F1 /* SoliCardsTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
867199988082B6D71FDDE2D8 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1630;
|
||||||
|
TargetAttributes = {
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 6AE5403E93959B4E9E707F1D /* Build configuration list for PBXProject "SoliCards" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = 0FF95606A40279B4D3390FBA;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 9CCB9EC5C71BA2DF24919C93 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
82669FCF0B13F7341360B265 /* SoliCards */,
|
||||||
|
C27736B4220A23D60C765E3E /* SoliCardsTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
00D9BFDDDE0AF0067CA90858 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A5D797208B75A1C7D23AFB64 /* Assets.xcassets in Resources */,
|
||||||
|
D75EE30A448E01FD3A2EAD0A /* Localizable.xcstrings in Resources */,
|
||||||
|
452209A28A7E2AA36CDFEFCA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
00839BE9C0947FF6620A82FC /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4BF879FC8DB20D9ED32D4738 /* AutoCompleterTests.swift in Sources */,
|
||||||
|
7D5D7789D2BA535A59EFE6EA /* CardTests.swift in Sources */,
|
||||||
|
E9ED2A8341168D2915B4F495 /* DeckTests.swift in Sources */,
|
||||||
|
D4520E37CA093BDA7DBAB7F5 /* DifficultyTests.swift in Sources */,
|
||||||
|
3997014B0E7EA0A651AB0216 /* FreeCellRulesTests.swift in Sources */,
|
||||||
|
29B7A35FE0C531B8CF8BF814 /* GameStateTests.swift in Sources */,
|
||||||
|
05F732433249D0777BFE8F91 /* KlondikeRulesTests.swift in Sources */,
|
||||||
|
65E698A14BC2C0E659A46128 /* MoveValidatorTests.swift in Sources */,
|
||||||
|
3D6A93AB14AEAD9E65BB181D /* SpiderRulesTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
8C4FBA4AEB4F83B6D76C3868 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
34CB07FE798181D502428D1D /* Array+Card.swift in Sources */,
|
||||||
|
5D4B0E1123A7870248DA8360 /* AutoCompleter.swift in Sources */,
|
||||||
|
D00C18D126362C368802C9E3 /* Card.swift in Sources */,
|
||||||
|
43F316092B0C12D7A7276309 /* CardBackPickerView.swift in Sources */,
|
||||||
|
2A123DBA7BA8AB08CADD6BDF /* CardLayout.swift in Sources */,
|
||||||
|
BA010A1D6573E1DA0D182EF8 /* CardLocation.swift in Sources */,
|
||||||
|
A4BD7013B7979EA922BA5041 /* CardStackView.swift in Sources */,
|
||||||
|
171B8FB2868AAE8D3198B05E /* CardStylePickerView.swift in Sources */,
|
||||||
|
42FC69591B33DD5370784036 /* CardView.swift in Sources */,
|
||||||
|
55A796BFBF38FBA8B6C6808F /* ContentView.swift in Sources */,
|
||||||
|
AA99FA31E0F4094CBE06FFAF /* Deck.swift in Sources */,
|
||||||
|
64E9B99DFBCD0F1D59F3D890 /* Difficulty.swift in Sources */,
|
||||||
|
1A5B631C600469C9F28CF783 /* DraggedCardsOverlay.swift in Sources */,
|
||||||
|
800459364BF6FD875D15171D /* DropTargetPreferenceKey.swift in Sources */,
|
||||||
|
5E2EA0FE1F0216236DF6BE56 /* FreeCellBoardView.swift in Sources */,
|
||||||
|
F02DBFB48DB80D36451D436B /* FreeCellRules.swift in Sources */,
|
||||||
|
BAD817F065BBAAC4B7C89043 /* GameBoardView.swift in Sources */,
|
||||||
|
586EA99FAD450DB90C430CA0 /* GamePhase.swift in Sources */,
|
||||||
|
D24334636360CE9659A3040A /* GameRecord.swift in Sources */,
|
||||||
|
D780D65841792E26E9208B8D /* GameRules.swift in Sources */,
|
||||||
|
FD1C9241508230FD8E553981 /* GameRulesFactory.swift in Sources */,
|
||||||
|
B7A5F011A7C538B27F382FB5 /* GameSnapshot.swift in Sources */,
|
||||||
|
12A564800C8BAED47DAD0C47 /* GameState.swift in Sources */,
|
||||||
|
6585CAA4B2AD823C1F6C20DE /* GameTheme.swift in Sources */,
|
||||||
|
19E266494C3F614EEEC6539B /* GameVariant.swift in Sources */,
|
||||||
|
5B89DF181690B09ED9204598 /* GameViewModel.swift in Sources */,
|
||||||
|
FDCB1D9771FAC46DCE25A6A6 /* HapticManager.swift in Sources */,
|
||||||
|
CFDAB7D49E3D6CBB420F4CF1 /* HintResult.swift in Sources */,
|
||||||
|
8F37719FF9A39485718BAD78 /* KlondikeBoardView.swift in Sources */,
|
||||||
|
79BCE98C4E8F851A36D4F753 /* KlondikeRules.swift in Sources */,
|
||||||
|
3E5C08ACC630D771613ECA16 /* MainMenuView.swift in Sources */,
|
||||||
|
56B286A65F7D8845885E9AF1 /* MoveAction.swift in Sources */,
|
||||||
|
37D0960283347728B64A2FB4 /* MoveValidator.swift in Sources */,
|
||||||
|
537755217C0912F24EE2F5C6 /* NewGameSheet.swift in Sources */,
|
||||||
|
5550A93B56A7BE882A1C7303 /* PersistenceManager.swift in Sources */,
|
||||||
|
DC0D004F619DE9C61C6AF585 /* PrefsRecord.swift in Sources */,
|
||||||
|
7084235B866C1493AC18A061 /* Rank.swift in Sources */,
|
||||||
|
8E4A7DFD89F451F7499F2684 /* RulesView.swift in Sources */,
|
||||||
|
60111A0FEC2BB3227E4E02E4 /* ScoreBarView.swift in Sources */,
|
||||||
|
CBEE7677D3E2D9A9CFAA90D0 /* SettingsView.swift in Sources */,
|
||||||
|
0DB00631BF90F0626BAE21CD /* SettingsViewModel.swift in Sources */,
|
||||||
|
40A295F6F752B57C290C49C5 /* SoliCardsApp.swift in Sources */,
|
||||||
|
65E127C9C91486AF86F9A693 /* SoundManager.swift in Sources */,
|
||||||
|
0D292BDC30C9D09CF4677FFA /* SpiderBoardView.swift in Sources */,
|
||||||
|
E15025574906B1F1C9A03C8F /* SpiderRules.swift in Sources */,
|
||||||
|
56D2056F3CA46D09F2D94F10 /* StatisticsView.swift in Sources */,
|
||||||
|
F713DA5821F2B37B2019CA1D /* StatsRecord.swift in Sources */,
|
||||||
|
70C69A048BE67CDD84E38B26 /* StatsViewModel.swift in Sources */,
|
||||||
|
F4C3C28A20ECA87EC97FD2DC /* Suit.swift in Sources */,
|
||||||
|
F3AA49C17343374CF1B82FF7 /* ThemeManager.swift in Sources */,
|
||||||
|
D9E61277B5EA0772F7B28BFF /* ThemePickerView.swift in Sources */,
|
||||||
|
A68F6A65F5897179FA3632BF /* TimerService.swift in Sources */,
|
||||||
|
81B92CA2BDA948B74EEDA6B9 /* VictoryOverlayView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
CE28FBA2CF52896AC36A1F13 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 82669FCF0B13F7341360B265 /* SoliCards */;
|
||||||
|
targetProxy = 0457F9A25F53E04E37959F48 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
09C2A01D89409D6B798959DA /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = SoliCards;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.card-games";
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIRequiresFullScreen = NO;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.app;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
3703194FF4D985541587656D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.tests;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoliCards.app/SoliCards";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
57E91C868A14077EF84052C1 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.tests;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SoliCards.app/SoliCards";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
6964E173671E2805B5C3C50E /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
C48643C0D86B6F2505BC1E97 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = SoliCards;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.card-games";
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIRequiresFullScreen = NO;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.solicards.app;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
DCF01AC30957B9AA76361647 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
6AE5403E93959B4E9E707F1D /* Build configuration list for PBXProject "SoliCards" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6964E173671E2805B5C3C50E /* Debug */,
|
||||||
|
DCF01AC30957B9AA76361647 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
6F270CC209153B7149368FF8 /* Build configuration list for PBXNativeTarget "SoliCardsTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
57E91C868A14077EF84052C1 /* Debug */,
|
||||||
|
3703194FF4D985541587656D /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
F6C31F00417F79A014C90960 /* Build configuration list for PBXNativeTarget "SoliCards" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
09C2A01D89409D6B798959DA /* Debug */,
|
||||||
|
C48643C0D86B6F2505BC1E97 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 867199988082B6D71FDDE2D8 /* Project object */;
|
||||||
|
}
|
||||||
7
SoliCards.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
307
SoliCards/ContentView.swift
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SoliCards/Extensions/Array+Card.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Array where Element == Card {
|
||||||
|
/// Returns the suffix of face-up cards from the end of the array.
|
||||||
|
var faceUpSuffix: [Card] {
|
||||||
|
var result: [Card] = []
|
||||||
|
for card in reversed() {
|
||||||
|
guard card.isFaceUp else { break }
|
||||||
|
result.insert(card, at: 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the top card (last element), or nil if empty.
|
||||||
|
var topCard: Card? { last }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array {
|
||||||
|
/// Returns the longest suffix where all elements satisfy the predicate.
|
||||||
|
func trailingSuffix(while predicate: (Element) -> Bool) -> ArraySlice<Element> {
|
||||||
|
var startIndex = endIndex
|
||||||
|
for index in indices.reversed() {
|
||||||
|
guard predicate(self[index]) else { break }
|
||||||
|
startIndex = index
|
||||||
|
}
|
||||||
|
return self[startIndex..<endIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
79
SoliCards/Extensions/CardLayout.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CardLayout {
|
||||||
|
let availableSize: CGSize
|
||||||
|
let variant: GameVariant
|
||||||
|
let deepestColumn: (faceDown: Int, faceUp: Int)
|
||||||
|
|
||||||
|
var columnCount: Int { variant.tableauCount }
|
||||||
|
var isLandscape: Bool { availableSize.width > availableSize.height }
|
||||||
|
|
||||||
|
/// Card size — constrained by the tighter of width or height.
|
||||||
|
var cardWidth: CGFloat {
|
||||||
|
let computed = min(widthConstrainedCardWidth, heightConstrainedCardWidth)
|
||||||
|
#if os(macOS)
|
||||||
|
// Cap card width on macOS so cards don't get absurdly large on big displays
|
||||||
|
return min(computed, 120)
|
||||||
|
#else
|
||||||
|
return computed
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardHeight: CGFloat { cardWidth * 1.4 }
|
||||||
|
|
||||||
|
/// Padding between cards. In landscape, this expands to fill the available width
|
||||||
|
/// so cards spread evenly across the screen.
|
||||||
|
var horizontalPadding: CGFloat {
|
||||||
|
let basePadding = max(2, availableSize.width * 0.008)
|
||||||
|
|
||||||
|
// If height-constrained (landscape), distribute extra width as padding
|
||||||
|
if heightConstrainedCardWidth < widthConstrainedCardWidth {
|
||||||
|
let totalCardWidth = CGFloat(columnCount) * cardWidth
|
||||||
|
let availableForPadding = availableSize.width - totalCardWidth
|
||||||
|
let gaps = CGFloat(columnCount + 1)
|
||||||
|
return max(basePadding, availableForPadding / gaps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePadding
|
||||||
|
}
|
||||||
|
|
||||||
|
var verticalOverlapFaceDown: CGFloat { cardHeight * 0.15 }
|
||||||
|
var verticalOverlapFaceUp: CGFloat { cardHeight * 0.25 }
|
||||||
|
var cornerRadius: CGFloat { cardWidth * 0.08 }
|
||||||
|
|
||||||
|
func cardSize() -> CGSize {
|
||||||
|
CGSize(width: cardWidth, height: cardHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
var touchTargetPadding: CGFloat {
|
||||||
|
max(0, (44 - cardWidth) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var basePadding: CGFloat {
|
||||||
|
max(2, availableSize.width * 0.008)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var widthConstrainedCardWidth: CGFloat {
|
||||||
|
let totalPadding = basePadding * CGFloat(columnCount + 1)
|
||||||
|
return (availableSize.width - totalPadding) / CGFloat(columnCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heightConstrainedCardWidth: CGFloat {
|
||||||
|
let d = CGFloat(deepestColumn.faceDown)
|
||||||
|
let u = CGFloat(max(0, deepestColumn.faceUp - 1))
|
||||||
|
let totalFactor = 1.0 + 0.1 + (d * 0.15 + u * 0.25 + 1.0)
|
||||||
|
|
||||||
|
// Use 92% of available height to leave room for VStack spacing,
|
||||||
|
// bottom padding, and any unmeasured platform chrome
|
||||||
|
let usableHeight = availableSize.height * 0.92
|
||||||
|
let base = max(30, usableHeight / (1.4 * totalFactor))
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
return isLandscape ? base * 1.3 : base
|
||||||
|
#else
|
||||||
|
return base
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
38
SoliCards/GameEngine/AutoCompleter.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AutoCompleter {
|
||||||
|
/// Finds the next card that can be auto-moved to a foundation.
|
||||||
|
/// Returns the source location and foundation index, or nil if no move is available.
|
||||||
|
static func findNextAutoMove(state: GameSnapshot, rules: GameRules) -> (from: CardLocation, to: CardLocation)? {
|
||||||
|
// Check waste pile
|
||||||
|
if let topWaste = state.waste.last {
|
||||||
|
for (i, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topWaste, topCard: foundation.last) {
|
||||||
|
return (.waste, .foundation(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check free cells
|
||||||
|
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||||||
|
guard let card = cell else { continue }
|
||||||
|
for (i, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
|
||||||
|
return (.freeCell(cellIndex), .foundation(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tableau tops
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last, topCard.isFaceUp else { continue }
|
||||||
|
for (i, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||||||
|
return (.tableau(tabIndex), .foundation(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
215
SoliCards/GameEngine/FreeCellRules.swift
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FreeCellRules: GameRules {
|
||||||
|
let variant: GameVariant = .freeCell
|
||||||
|
|
||||||
|
func deal(deck: [Card]) -> GameSnapshot {
|
||||||
|
var remaining = deck
|
||||||
|
var tableaus: [[Card]] = []
|
||||||
|
|
||||||
|
// Deal all 52 cards to 8 tableaus, all face-up
|
||||||
|
// First 4 tableaus get 7 cards, last 4 get 6 cards
|
||||||
|
for i in 0..<8 {
|
||||||
|
let cardCount = i < 4 ? 7 : 6
|
||||||
|
var column: [Card] = []
|
||||||
|
for _ in 0..<cardCount {
|
||||||
|
var card = remaining.removeFirst()
|
||||||
|
card.isFaceUp = true
|
||||||
|
column.append(card)
|
||||||
|
}
|
||||||
|
tableaus.append(column)
|
||||||
|
}
|
||||||
|
|
||||||
|
return GameSnapshot(
|
||||||
|
tableaus: tableaus,
|
||||||
|
foundations: [[], [], [], []],
|
||||||
|
stock: [],
|
||||||
|
waste: [],
|
||||||
|
freeCells: [nil, nil, nil, nil],
|
||||||
|
moves: 0,
|
||||||
|
score: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard let firstCard = cards.first else { return false }
|
||||||
|
|
||||||
|
switch to {
|
||||||
|
case .foundation(let index):
|
||||||
|
guard cards.count == 1 else { return false }
|
||||||
|
guard index >= 0, index < state.foundations.count else { return false }
|
||||||
|
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: state.foundations[index].last)
|
||||||
|
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
let maxMovable = calculateMaxMovableCards(state: state, targetEmpty: state.tableaus[index].isEmpty)
|
||||||
|
guard cards.count <= maxMovable else { return false }
|
||||||
|
|
||||||
|
let tableau = state.tableaus[index]
|
||||||
|
if tableau.isEmpty { return true }
|
||||||
|
guard let topCard = tableau.last else { return false }
|
||||||
|
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
|
||||||
|
&& MoveValidator.isDescending(firstCard, onto: topCard)
|
||||||
|
|
||||||
|
case .freeCell(let index):
|
||||||
|
guard cards.count == 1 else { return false }
|
||||||
|
guard index >= 0, index < state.freeCells.count else { return false }
|
||||||
|
return state.freeCells[index] == nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
|
||||||
|
nil // FreeCell has no stock
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
||||||
|
switch (from, to) {
|
||||||
|
case (_, .foundation): return 10
|
||||||
|
case (.freeCell, .tableau): return 5
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWon(state: GameSnapshot) -> Bool {
|
||||||
|
state.foundations.allSatisfy { $0.count == 13 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAutoComplete(state: GameSnapshot) -> Bool {
|
||||||
|
// All tableau cards must be face-up and in descending order
|
||||||
|
let allFaceUp = state.tableaus.allSatisfy { $0.allSatisfy { $0.isFaceUp } }
|
||||||
|
guard allFaceUp else { return false }
|
||||||
|
|
||||||
|
// Check that all tableaus are in valid descending alternating-color order
|
||||||
|
for tableau in state.tableaus {
|
||||||
|
for i in 1..<tableau.count {
|
||||||
|
guard MoveValidator.isAlternatingColor(tableau[i], with: tableau[i - 1]),
|
||||||
|
MoveValidator.isDescending(tableau[i], onto: tableau[i - 1]) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Power move formula: (1 + empty_freecells) × 2^empty_tableaus
|
||||||
|
func calculateMaxMovableCards(state: GameSnapshot, targetEmpty: Bool) -> Int {
|
||||||
|
let emptyFreeCells = state.freeCells.filter { $0 == nil }.count
|
||||||
|
// Don't count the target tableau as empty if we're moving to an empty tableau
|
||||||
|
let emptyTableaus = state.tableaus.filter { $0.isEmpty }.count - (targetEmpty ? 1 : 0)
|
||||||
|
let adjustedEmptyTableaus = max(0, emptyTableaus)
|
||||||
|
return (1 + emptyFreeCells) * Int(pow(2.0, Double(adjustedEmptyTableaus)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
||||||
|
guard settings.hintsEnabled else { return [] }
|
||||||
|
var hints: [HintResult] = []
|
||||||
|
|
||||||
|
// Priority 1: Aces to foundation (from tableaus and free cells)
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last, MoveValidator.isAce(topCard) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||||||
|
guard let card = cell, MoveValidator.isAce(card) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Other foundation moves
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last, !MoveValidator.isAce(topCard) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||||||
|
guard let card = cell, !MoveValidator.isAce(card) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(card, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .foundation(fIndex), priority: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Tableau to tableau moves
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last else { continue }
|
||||||
|
for (destIndex, _) in state.tableaus.enumerated() {
|
||||||
|
guard destIndex != tabIndex else { continue }
|
||||||
|
if canMove(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Free cell to tableau
|
||||||
|
for (cellIndex, cell) in state.freeCells.enumerated() {
|
||||||
|
guard let card = cell else { continue }
|
||||||
|
for (tabIndex, _) in state.tableaus.enumerated() {
|
||||||
|
if canMove(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), state: state) {
|
||||||
|
hints.append(HintResult(cards: [card], from: .freeCell(cellIndex), to: .tableau(tabIndex), priority: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 5: Move to free cell
|
||||||
|
if state.freeCells.contains(where: { $0 == nil }) {
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last else { continue }
|
||||||
|
if let emptyCell = state.freeCells.firstIndex(where: { $0 == nil }) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .freeCell(emptyCell), priority: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints.sorted { $0.priority < $1.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
||||||
|
MoveValidator.isAlternatingColor(card, with: target)
|
||||||
|
&& MoveValidator.isDescending(card, onto: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard !cards.isEmpty else { return false }
|
||||||
|
|
||||||
|
switch from {
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
// All cards must be face-up and form a valid alternating-color descending sequence
|
||||||
|
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
||||||
|
for i in 1..<cards.count {
|
||||||
|
guard MoveValidator.isAlternatingColor(cards[i], with: cards[i - 1]),
|
||||||
|
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .freeCell(let index):
|
||||||
|
guard cards.count == 1, index >= 0, index < state.freeCells.count else { return false }
|
||||||
|
return state.freeCells[index] == cards.first
|
||||||
|
|
||||||
|
case .foundation(let index):
|
||||||
|
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
|
||||||
|
return cards[0] == state.foundations[index].last
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
SoliCards/GameEngine/GameRules.swift
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol GameRules: Sendable {
|
||||||
|
var variant: GameVariant { get }
|
||||||
|
|
||||||
|
/// Deal cards from a shuffled deck into the initial board layout.
|
||||||
|
func deal(deck: [Card]) -> GameSnapshot
|
||||||
|
|
||||||
|
/// Check if moving the given cards from source to destination is valid.
|
||||||
|
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool
|
||||||
|
|
||||||
|
/// Return all valid destinations for the given cards from a source location.
|
||||||
|
func validDestinations(for cards: [Card], from: CardLocation, state: GameSnapshot) -> [CardLocation]
|
||||||
|
|
||||||
|
/// Draw card(s) from the stock pile.
|
||||||
|
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction?
|
||||||
|
|
||||||
|
/// Calculate the score change for a given move.
|
||||||
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int
|
||||||
|
|
||||||
|
/// Check if the game is won.
|
||||||
|
func isWon(state: GameSnapshot) -> Bool
|
||||||
|
|
||||||
|
/// Check if auto-complete is possible (all remaining cards are face-up and ordered).
|
||||||
|
func canAutoComplete(state: GameSnapshot) -> Bool
|
||||||
|
|
||||||
|
/// Find available hints, ordered by priority (1 = highest).
|
||||||
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult]
|
||||||
|
|
||||||
|
/// Check if a card can be stacked onto another card on a tableau.
|
||||||
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool
|
||||||
|
|
||||||
|
/// Check if a group of cards can be picked up from a location.
|
||||||
|
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GameRules {
|
||||||
|
func validDestinations(for cards: [Card], from: CardLocation, state: GameSnapshot) -> [CardLocation] {
|
||||||
|
var destinations: [CardLocation] = []
|
||||||
|
|
||||||
|
for i in 0..<state.foundations.count {
|
||||||
|
if canMove(cards: cards, from: from, to: .foundation(i), state: state) {
|
||||||
|
destinations.append(.foundation(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..<state.tableaus.count {
|
||||||
|
if canMove(cards: cards, from: from, to: .tableau(i), state: state) {
|
||||||
|
destinations.append(.tableau(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..<state.freeCells.count {
|
||||||
|
if canMove(cards: cards, from: from, to: .freeCell(i), state: state) {
|
||||||
|
destinations.append(.freeCell(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return destinations
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SoliCards/GameEngine/GameRulesFactory.swift
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GameRulesFactory {
|
||||||
|
static func rules(for variant: GameVariant) -> GameRules {
|
||||||
|
switch variant {
|
||||||
|
case .klondike: KlondikeRules()
|
||||||
|
case .spider: SpiderRules()
|
||||||
|
case .freeCell: FreeCellRules()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
SoliCards/GameEngine/GameState.swift
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class GameState {
|
||||||
|
var tableaus: [[Card]]
|
||||||
|
var foundations: [[Card]]
|
||||||
|
var stock: [Card]
|
||||||
|
var waste: [Card]
|
||||||
|
var freeCells: [Card?]
|
||||||
|
|
||||||
|
var moves: Int = 0
|
||||||
|
var score: Int = 0
|
||||||
|
var phase: GamePhase = .notStarted
|
||||||
|
|
||||||
|
private(set) var history: [GameSnapshot] = []
|
||||||
|
private let maxHistorySize = 20
|
||||||
|
|
||||||
|
var canUndo: Bool { !history.isEmpty }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.tableaus = []
|
||||||
|
self.foundations = []
|
||||||
|
self.stock = []
|
||||||
|
self.waste = []
|
||||||
|
self.freeCells = []
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> GameSnapshot {
|
||||||
|
GameSnapshot(
|
||||||
|
tableaus: tableaus,
|
||||||
|
foundations: foundations,
|
||||||
|
stock: stock,
|
||||||
|
waste: waste,
|
||||||
|
freeCells: freeCells,
|
||||||
|
moves: moves,
|
||||||
|
score: score
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(from snapshot: GameSnapshot) {
|
||||||
|
tableaus = snapshot.tableaus
|
||||||
|
foundations = snapshot.foundations
|
||||||
|
stock = snapshot.stock
|
||||||
|
waste = snapshot.waste
|
||||||
|
freeCells = snapshot.freeCells
|
||||||
|
moves = snapshot.moves
|
||||||
|
score = snapshot.score
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushHistory() {
|
||||||
|
history.append(snapshot())
|
||||||
|
if history.count > maxHistorySize {
|
||||||
|
history.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func popHistory() -> GameSnapshot? {
|
||||||
|
history.popLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
history.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(from snapshot: GameSnapshot, variant: GameVariant) {
|
||||||
|
restore(from: snapshot)
|
||||||
|
phase = .playing
|
||||||
|
clearHistory()
|
||||||
|
|
||||||
|
if variant.hasFreeCells {
|
||||||
|
freeCells = Array(repeating: nil, count: variant.freeCellCount)
|
||||||
|
} else {
|
||||||
|
freeCells = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
SoliCards/GameEngine/KlondikeRules.swift
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct KlondikeRules: GameRules {
|
||||||
|
let variant: GameVariant = .klondike
|
||||||
|
|
||||||
|
func deal(deck: [Card]) -> GameSnapshot {
|
||||||
|
var remaining = deck
|
||||||
|
var tableaus: [[Card]] = []
|
||||||
|
|
||||||
|
// Deal 7 tableaus: column i gets i+1 cards, top card face-up
|
||||||
|
for i in 0..<7 {
|
||||||
|
var column: [Card] = []
|
||||||
|
for j in 0...i {
|
||||||
|
var card = remaining.removeFirst()
|
||||||
|
card.isFaceUp = (j == i)
|
||||||
|
column.append(card)
|
||||||
|
}
|
||||||
|
tableaus.append(column)
|
||||||
|
}
|
||||||
|
|
||||||
|
return GameSnapshot(
|
||||||
|
tableaus: tableaus,
|
||||||
|
foundations: [[], [], [], []],
|
||||||
|
stock: remaining,
|
||||||
|
waste: [],
|
||||||
|
freeCells: [],
|
||||||
|
moves: 0,
|
||||||
|
score: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard let firstCard = cards.first else { return false }
|
||||||
|
|
||||||
|
switch to {
|
||||||
|
case .foundation(let index):
|
||||||
|
guard cards.count == 1 else { return false }
|
||||||
|
guard index >= 0, index < state.foundations.count else { return false }
|
||||||
|
let foundation = state.foundations[index]
|
||||||
|
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: foundation.last)
|
||||||
|
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
let tableau = state.tableaus[index]
|
||||||
|
if tableau.isEmpty {
|
||||||
|
return MoveValidator.isKing(firstCard)
|
||||||
|
}
|
||||||
|
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
|
||||||
|
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
|
||||||
|
&& MoveValidator.isDescending(firstCard, onto: topCard)
|
||||||
|
|
||||||
|
case .waste, .stock, .freeCell:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
|
||||||
|
if state.stock.isEmpty {
|
||||||
|
guard !state.waste.isEmpty else { return nil }
|
||||||
|
// Reset: move waste back to stock, reversed
|
||||||
|
state.stock = state.waste.reversed().map { card in
|
||||||
|
var c = card
|
||||||
|
c.isFaceUp = false
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
state.waste = []
|
||||||
|
return MoveAction(cards: [], from: .waste, to: .stock, didFlipCard: false, scoreChange: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawn: [Card] = []
|
||||||
|
for _ in 0..<min(drawCount, state.stock.count) {
|
||||||
|
var card = state.stock.removeLast()
|
||||||
|
card.isFaceUp = true
|
||||||
|
drawn.append(card)
|
||||||
|
}
|
||||||
|
state.waste.append(contentsOf: drawn)
|
||||||
|
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
||||||
|
switch (from, to) {
|
||||||
|
case (.waste, .foundation): return 10
|
||||||
|
case (.tableau, .foundation): return 10
|
||||||
|
case (.waste, .tableau): return 5
|
||||||
|
case (.foundation, .tableau): return -15
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWon(state: GameSnapshot) -> Bool {
|
||||||
|
state.foundations.allSatisfy { $0.count == 13 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAutoComplete(state: GameSnapshot) -> Bool {
|
||||||
|
guard state.stock.isEmpty, state.waste.isEmpty else { return false }
|
||||||
|
// All tableau cards must be face-up
|
||||||
|
return state.tableaus.allSatisfy { column in
|
||||||
|
column.allSatisfy { $0.isFaceUp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
||||||
|
guard settings.hintsEnabled else { return [] }
|
||||||
|
var hints: [HintResult] = []
|
||||||
|
|
||||||
|
// Priority 1: Aces to foundation
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last, topCard.isFaceUp, MoveValidator.isAce(topCard) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 1: Ace from waste
|
||||||
|
if let wasteTop = state.waste.last, MoveValidator.isAce(wasteTop) {
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(wasteTop, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .foundation(fIndex), priority: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Other foundation moves
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
guard let topCard = tableau.last, topCard.isFaceUp, !MoveValidator.isAce(topCard) else { continue }
|
||||||
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
||||||
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
||||||
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Tableau moves that reveal face-down cards
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
|
||||||
|
guard !faceUpCards.isEmpty else { continue }
|
||||||
|
let hasFaceDown = tableau.count > faceUpCards.count
|
||||||
|
|
||||||
|
guard hasFaceDown else { continue }
|
||||||
|
|
||||||
|
for (destIndex, _) in state.tableaus.enumerated() {
|
||||||
|
guard destIndex != tabIndex else { continue }
|
||||||
|
let cardsToMove = Array(faceUpCards)
|
||||||
|
if canMove(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
||||||
|
hints.append(HintResult(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 4: Waste to tableau
|
||||||
|
if let wasteTop = state.waste.last {
|
||||||
|
for (tabIndex, _) in state.tableaus.enumerated() {
|
||||||
|
if canMove(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), state: state) {
|
||||||
|
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), priority: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 5: Draw from stock
|
||||||
|
if !state.stock.isEmpty {
|
||||||
|
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints.sorted { $0.priority < $1.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
||||||
|
MoveValidator.isAlternatingColor(card, with: target)
|
||||||
|
&& MoveValidator.isDescending(card, onto: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard !cards.isEmpty else { return false }
|
||||||
|
|
||||||
|
switch from {
|
||||||
|
case .waste:
|
||||||
|
return cards.count == 1 && cards[0] == state.waste.last
|
||||||
|
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
let tableau = state.tableaus[index]
|
||||||
|
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
||||||
|
// Cards must be a valid sequence from the bottom of the face-up portion
|
||||||
|
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
|
||||||
|
guard cards.count <= faceUpCards.count else { return false }
|
||||||
|
let startIdx = faceUpCards.count - cards.count
|
||||||
|
let expectedCards = Array(faceUpCards.dropFirst(startIdx))
|
||||||
|
return cards == expectedCards
|
||||||
|
|
||||||
|
case .foundation(let index):
|
||||||
|
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
|
||||||
|
return cards[0] == state.foundations[index].last
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
SoliCards/GameEngine/MoveValidator.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MoveValidator {
|
||||||
|
/// True if the two cards have alternating colors (one red, one black).
|
||||||
|
static func isAlternatingColor(_ card: Card, with other: Card) -> Bool {
|
||||||
|
card.color != other.color
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if both cards share the same suit.
|
||||||
|
static func isSameSuit(_ card: Card, as other: Card) -> Bool {
|
||||||
|
card.suit == other.suit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if card's rank is exactly one higher than target (for foundation building 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
195
SoliCards/GameEngine/SpiderRules.swift
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SpiderRules: GameRules {
|
||||||
|
let variant: GameVariant = .spider
|
||||||
|
|
||||||
|
func deal(deck: [Card]) -> GameSnapshot {
|
||||||
|
var remaining = deck
|
||||||
|
var tableaus: [[Card]] = []
|
||||||
|
|
||||||
|
// Deal 10 tableaus: first 4 get 6 cards, last 6 get 5 cards, top face-up
|
||||||
|
for i in 0..<10 {
|
||||||
|
let cardCount = i < 4 ? 6 : 5
|
||||||
|
var column: [Card] = []
|
||||||
|
for j in 0..<cardCount {
|
||||||
|
var card = remaining.removeFirst()
|
||||||
|
card.isFaceUp = (j == cardCount - 1)
|
||||||
|
column.append(card)
|
||||||
|
}
|
||||||
|
tableaus.append(column)
|
||||||
|
}
|
||||||
|
|
||||||
|
return GameSnapshot(
|
||||||
|
tableaus: tableaus,
|
||||||
|
foundations: Array(repeating: [], count: 8),
|
||||||
|
stock: remaining,
|
||||||
|
waste: [],
|
||||||
|
freeCells: [],
|
||||||
|
moves: 0,
|
||||||
|
score: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard let firstCard = cards.first else { return false }
|
||||||
|
|
||||||
|
switch to {
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
let tableau = state.tableaus[index]
|
||||||
|
if tableau.isEmpty { return true }
|
||||||
|
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
|
||||||
|
return MoveValidator.isDescending(firstCard, onto: topCard)
|
||||||
|
|
||||||
|
case .foundation:
|
||||||
|
// Foundations are auto-filled when a complete 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..<dealCount {
|
||||||
|
var card = state.stock.removeLast()
|
||||||
|
card.isFaceUp = true
|
||||||
|
state.tableaus[i].append(card)
|
||||||
|
drawn.append(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
||||||
|
switch (from, to) {
|
||||||
|
case (.tableau, .foundation): return 100
|
||||||
|
case (.tableau, .tableau): return 1
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWon(state: GameSnapshot) -> Bool {
|
||||||
|
state.foundations.allSatisfy { $0.count == 13 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAutoComplete(state: GameSnapshot) -> Bool {
|
||||||
|
guard state.stock.isEmpty else { return false }
|
||||||
|
return state.tableaus.allSatisfy { column in
|
||||||
|
column.allSatisfy { $0.isFaceUp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the top cards of a tableau form a complete 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..<state.tableaus.count {
|
||||||
|
if isCompleteSequence(in: state.tableaus[tabIndex]) {
|
||||||
|
let sequence = Array(state.tableaus[tabIndex].suffix(13))
|
||||||
|
state.tableaus[tabIndex].removeLast(13)
|
||||||
|
// Find first empty foundation
|
||||||
|
if let fIndex = state.foundations.firstIndex(where: { $0.isEmpty }) {
|
||||||
|
state.foundations[fIndex] = sequence
|
||||||
|
foundComplete = true
|
||||||
|
}
|
||||||
|
// Flip new top card
|
||||||
|
if let lastIndex = state.tableaus[tabIndex].indices.last,
|
||||||
|
!state.tableaus[tabIndex][lastIndex].isFaceUp {
|
||||||
|
state.tableaus[tabIndex][lastIndex].isFaceUp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foundComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
||||||
|
guard settings.hintsEnabled else { return [] }
|
||||||
|
var hints: [HintResult] = []
|
||||||
|
|
||||||
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
||||||
|
let faceUpRun = sameSuitDescendingRun(in: tableau)
|
||||||
|
guard !faceUpRun.isEmpty else { continue }
|
||||||
|
|
||||||
|
for (destIndex, _) in state.tableaus.enumerated() {
|
||||||
|
guard destIndex != tabIndex else { continue }
|
||||||
|
if canMove(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
||||||
|
let priority = (tableau.count - faceUpRun.count > 0 &&
|
||||||
|
!tableau[tableau.count - faceUpRun.count - 1].isFaceUp) ? 2 : 4
|
||||||
|
hints.append(HintResult(cards: faceUpRun, from: .tableau(tabIndex), to: .tableau(destIndex), priority: priority))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.stock.isEmpty {
|
||||||
|
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints.sorted { $0.priority < $1.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
||||||
|
MoveValidator.isDescending(card, onto: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
|
||||||
|
guard !cards.isEmpty else { return false }
|
||||||
|
|
||||||
|
switch from {
|
||||||
|
case .tableau(let index):
|
||||||
|
guard index >= 0, index < state.tableaus.count else { return false }
|
||||||
|
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
||||||
|
// Must be a same-suit descending sequence
|
||||||
|
for i in 1..<cards.count {
|
||||||
|
guard MoveValidator.isSameSuit(cards[i], as: cards[i - 1]),
|
||||||
|
MoveValidator.isDescending(cards[i], onto: cards[i - 1]) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Returns the longest same-suit descending run from the bottom of the face-up portion.
|
||||||
|
private func sameSuitDescendingRun(in tableau: [Card]) -> [Card] {
|
||||||
|
let faceUp = Array(tableau.trailingSuffix(while: { $0.isFaceUp }))
|
||||||
|
guard !faceUp.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var run = [faceUp.last!]
|
||||||
|
for i in stride(from: faceUp.count - 2, through: 0, by: -1) {
|
||||||
|
let card = faceUp[i]
|
||||||
|
guard MoveValidator.isSameSuit(card, as: run[0]),
|
||||||
|
card.rank.rawValue == run[0].rank.rawValue + 1 else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
run.insert(card, at: 0)
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
}
|
||||||
52
SoliCards/Models/Card.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Card: Identifiable, Equatable, Hashable, Codable, Sendable {
|
||||||
|
let id: UUID
|
||||||
|
let suit: Suit
|
||||||
|
let rank: Rank
|
||||||
|
var isFaceUp: Bool
|
||||||
|
|
||||||
|
init(suit: Suit, rank: Rank, isFaceUp: Bool = false) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.suit = suit
|
||||||
|
self.rank = rank
|
||||||
|
self.isFaceUp = isFaceUp
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Suit.Color { suit.color }
|
||||||
|
|
||||||
|
var accessibilityDescription: String {
|
||||||
|
if isFaceUp {
|
||||||
|
"\(rank.displayName) of \(suit.displayName)"
|
||||||
|
} else {
|
||||||
|
"Card, face down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func frontImageName(style: CardFaceStyle) -> String {
|
||||||
|
"\(style.rawValue)_\(suit.rawValue)_\(rank.fileName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CardFaceStyle: String, CaseIterable, Codable, Sendable {
|
||||||
|
case classic, modern, extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CardBackDesign: Int, CaseIterable, Codable, Sendable {
|
||||||
|
case abstractClouds = 1
|
||||||
|
case abstractScene
|
||||||
|
case abstract
|
||||||
|
case astronaut
|
||||||
|
case blue
|
||||||
|
case blue2
|
||||||
|
case cars
|
||||||
|
case castle
|
||||||
|
case fish
|
||||||
|
case frog
|
||||||
|
case red
|
||||||
|
case red2
|
||||||
|
|
||||||
|
var imageName: String {
|
||||||
|
"back_\(String(format: "%02d", rawValue))"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
SoliCards/Models/CardLocation.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CardLocation: Equatable, Hashable, Codable, Sendable {
|
||||||
|
case tableau(Int)
|
||||||
|
case foundation(Int)
|
||||||
|
case waste
|
||||||
|
case stock
|
||||||
|
case freeCell(Int)
|
||||||
|
}
|
||||||
29
SoliCards/Models/Deck.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Deck {
|
||||||
|
/// Creates a standard 52-card deck, shuffled.
|
||||||
|
static func standard() -> [Card] {
|
||||||
|
var cards: [Card] = []
|
||||||
|
for suit in Suit.allCases {
|
||||||
|
for rank in Rank.allCases {
|
||||||
|
cards.append(Card(suit: suit, rank: rank))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards.shuffle()
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a double deck (104 cards) for Spider solitaire, shuffled.
|
||||||
|
static func double() -> [Card] {
|
||||||
|
var cards: [Card] = []
|
||||||
|
for _ in 0..<2 {
|
||||||
|
for suit in Suit.allCases {
|
||||||
|
for rank in Rank.allCases {
|
||||||
|
cards.append(Card(suit: suit, rank: rank))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cards.shuffle()
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
}
|
||||||
59
SoliCards/Models/Difficulty.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Difficulty: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
|
case easy, medium, hard, expert
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String { rawValue.capitalized }
|
||||||
|
|
||||||
|
var settings: DifficultySettings {
|
||||||
|
switch self {
|
||||||
|
case .easy:
|
||||||
|
DifficultySettings(
|
||||||
|
drawCount: 1,
|
||||||
|
maxUndos: .max,
|
||||||
|
hintsEnabled: true,
|
||||||
|
scoreMultiplier: 0.5,
|
||||||
|
timePenalty: 0,
|
||||||
|
autoFlipDelay: 0.5
|
||||||
|
)
|
||||||
|
case .medium:
|
||||||
|
DifficultySettings(
|
||||||
|
drawCount: 3,
|
||||||
|
maxUndos: 20,
|
||||||
|
hintsEnabled: true,
|
||||||
|
scoreMultiplier: 1.0,
|
||||||
|
timePenalty: 2,
|
||||||
|
autoFlipDelay: 0.3
|
||||||
|
)
|
||||||
|
case .hard:
|
||||||
|
DifficultySettings(
|
||||||
|
drawCount: 3,
|
||||||
|
maxUndos: 10,
|
||||||
|
hintsEnabled: false,
|
||||||
|
scoreMultiplier: 1.5,
|
||||||
|
timePenalty: 5,
|
||||||
|
autoFlipDelay: 0.2
|
||||||
|
)
|
||||||
|
case .expert:
|
||||||
|
DifficultySettings(
|
||||||
|
drawCount: 3,
|
||||||
|
maxUndos: 5,
|
||||||
|
hintsEnabled: false,
|
||||||
|
scoreMultiplier: 2.0,
|
||||||
|
timePenalty: 10,
|
||||||
|
autoFlipDelay: 0.1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DifficultySettings: Sendable {
|
||||||
|
let drawCount: Int
|
||||||
|
let maxUndos: Int
|
||||||
|
let hintsEnabled: Bool
|
||||||
|
let scoreMultiplier: Double
|
||||||
|
let timePenalty: Int
|
||||||
|
let autoFlipDelay: TimeInterval
|
||||||
|
}
|
||||||
9
SoliCards/Models/GamePhase.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GamePhase: Equatable, Sendable {
|
||||||
|
case notStarted
|
||||||
|
case playing
|
||||||
|
case paused
|
||||||
|
case autoCompleting
|
||||||
|
case won
|
||||||
|
}
|
||||||
11
SoliCards/Models/GameSnapshot.swift
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GameSnapshot: Codable, Sendable {
|
||||||
|
var tableaus: [[Card]]
|
||||||
|
var foundations: [[Card]]
|
||||||
|
var stock: [Card]
|
||||||
|
var waste: [Card]
|
||||||
|
var freeCells: [Card?]
|
||||||
|
var moves: Int
|
||||||
|
var score: Int
|
||||||
|
}
|
||||||
65
SoliCards/Models/GameVariant.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GameVariant: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
|
case klondike
|
||||||
|
case spider
|
||||||
|
case freeCell
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .klondike: "Klondike"
|
||||||
|
case .spider: "Spider"
|
||||||
|
case .freeCell: "FreeCell"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableauCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .klondike: 7
|
||||||
|
case .spider: 10
|
||||||
|
case .freeCell: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundationCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .klondike: 4
|
||||||
|
case .spider: 8
|
||||||
|
case .freeCell: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deckCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .klondike, .freeCell: 1
|
||||||
|
case .spider: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasWaste: Bool {
|
||||||
|
switch self {
|
||||||
|
case .klondike, .spider: true
|
||||||
|
case .freeCell: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFreeCells: Bool {
|
||||||
|
self == .freeCell
|
||||||
|
}
|
||||||
|
|
||||||
|
var freeCellCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .freeCell: 4
|
||||||
|
case .klondike, .spider: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStock: Bool {
|
||||||
|
switch self {
|
||||||
|
case .klondike, .spider: true
|
||||||
|
case .freeCell: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SoliCards/Models/HintResult.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HintResult: Sendable {
|
||||||
|
let cards: [Card]
|
||||||
|
let from: CardLocation
|
||||||
|
let to: CardLocation
|
||||||
|
let priority: Int
|
||||||
|
}
|
||||||
9
SoliCards/Models/MoveAction.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MoveAction: Codable, Sendable {
|
||||||
|
let cards: [Card]
|
||||||
|
let from: CardLocation
|
||||||
|
let to: CardLocation
|
||||||
|
let didFlipCard: Bool
|
||||||
|
let scoreChange: Int
|
||||||
|
}
|
||||||
58
SoliCards/Models/Rank.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Rank: Int, CaseIterable, Codable, Comparable, Sendable {
|
||||||
|
case ace = 1
|
||||||
|
case two, three, four, five, six, seven
|
||||||
|
case eight, nine, ten, jack, queen, king
|
||||||
|
|
||||||
|
static func < (lhs: Rank, rhs: Rank) -> Bool {
|
||||||
|
lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .ace: "Ace"
|
||||||
|
case .two: "2"
|
||||||
|
case .three: "3"
|
||||||
|
case .four: "4"
|
||||||
|
case .five: "5"
|
||||||
|
case .six: "6"
|
||||||
|
case .seven: "7"
|
||||||
|
case .eight: "8"
|
||||||
|
case .nine: "9"
|
||||||
|
case .ten: "10"
|
||||||
|
case .jack: "Jack"
|
||||||
|
case .queen: "Queen"
|
||||||
|
case .king: "King"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortName: String {
|
||||||
|
switch self {
|
||||||
|
case .ace: "A"
|
||||||
|
case .jack: "J"
|
||||||
|
case .queen: "Q"
|
||||||
|
case .king: "K"
|
||||||
|
default: "\(rawValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File name component for asset lookup
|
||||||
|
var fileName: String {
|
||||||
|
switch self {
|
||||||
|
case .ace: "ace"
|
||||||
|
case .two: "2"
|
||||||
|
case .three: "3"
|
||||||
|
case .four: "4"
|
||||||
|
case .five: "5"
|
||||||
|
case .six: "6"
|
||||||
|
case .seven: "7"
|
||||||
|
case .eight: "8"
|
||||||
|
case .nine: "9"
|
||||||
|
case .ten: "10"
|
||||||
|
case .jack: "jack"
|
||||||
|
case .queen: "queen"
|
||||||
|
case .king: "king"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
SoliCards/Models/Suit.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Suit: String, CaseIterable, Codable, Sendable {
|
||||||
|
case spades, hearts, diamonds, clubs
|
||||||
|
|
||||||
|
enum Color: Sendable {
|
||||||
|
case red, black
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .hearts, .diamonds: .red
|
||||||
|
case .spades, .clubs: .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .spades: "♠"
|
||||||
|
case .hearts: "♥"
|
||||||
|
case .diamonds: "♦"
|
||||||
|
case .clubs: "♣"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
rawValue.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SoliCards/Persistence/GameRecord.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class GameRecord {
|
||||||
|
var variant: String
|
||||||
|
var difficulty: String
|
||||||
|
var snapshotData: Data
|
||||||
|
var elapsedSeconds: Int
|
||||||
|
var lastPlayedDate: Date
|
||||||
|
|
||||||
|
init(variant: GameVariant, difficulty: Difficulty, snapshot: GameSnapshot, elapsedSeconds: Int) {
|
||||||
|
self.variant = variant.rawValue
|
||||||
|
self.difficulty = difficulty.rawValue
|
||||||
|
self.snapshotData = (try? JSONEncoder().encode(snapshot)) ?? Data()
|
||||||
|
self.elapsedSeconds = elapsedSeconds
|
||||||
|
self.lastPlayedDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodedSnapshot: GameSnapshot? {
|
||||||
|
try? JSONDecoder().decode(GameSnapshot.self, from: snapshotData)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameVariant: GameVariant? {
|
||||||
|
GameVariant(rawValue: variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gameDifficulty: Difficulty? {
|
||||||
|
Difficulty(rawValue: difficulty)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
SoliCards/Persistence/PersistenceManager.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PersistenceManager {
|
||||||
|
private let modelContext: ModelContext
|
||||||
|
|
||||||
|
init(modelContext: ModelContext) {
|
||||||
|
self.modelContext = modelContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game State
|
||||||
|
|
||||||
|
func saveGame(variant: GameVariant, difficulty: Difficulty, snapshot: GameSnapshot, elapsedSeconds: Int) {
|
||||||
|
// Delete any existing save for this variant
|
||||||
|
deleteSavedGame(for: variant)
|
||||||
|
|
||||||
|
let record = GameRecord(variant: variant, difficulty: difficulty,
|
||||||
|
snapshot: snapshot, elapsedSeconds: elapsedSeconds)
|
||||||
|
modelContext.insert(record)
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSavedGame(for variant: GameVariant) -> GameRecord? {
|
||||||
|
let variantRaw = variant.rawValue
|
||||||
|
let descriptor = FetchDescriptor<GameRecord>(
|
||||||
|
predicate: #Predicate { $0.variant == variantRaw },
|
||||||
|
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
return try? modelContext.fetch(descriptor).first
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMostRecentGame() -> GameRecord? {
|
||||||
|
let descriptor = FetchDescriptor<GameRecord>(
|
||||||
|
sortBy: [SortDescriptor(\.lastPlayedDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
return try? modelContext.fetch(descriptor).first
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSavedGame(for variant: GameVariant) {
|
||||||
|
let variantRaw = variant.rawValue
|
||||||
|
let descriptor = FetchDescriptor<GameRecord>(
|
||||||
|
predicate: #Predicate { $0.variant == variantRaw }
|
||||||
|
)
|
||||||
|
if let records = try? modelContext.fetch(descriptor) {
|
||||||
|
for record in records {
|
||||||
|
modelContext.delete(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
func recordWin(variant: GameVariant, difficulty: Difficulty, score: Int, time: Int) {
|
||||||
|
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
|
||||||
|
record.recordWin(score: score, time: time)
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordLoss(variant: GameVariant, difficulty: Difficulty) {
|
||||||
|
let record = fetchOrCreateStats(variant: variant, difficulty: difficulty)
|
||||||
|
record.recordLoss()
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord? {
|
||||||
|
let variantRaw = variant.rawValue
|
||||||
|
let difficultyRaw = difficulty.rawValue
|
||||||
|
let descriptor = FetchDescriptor<StatsRecord>(
|
||||||
|
predicate: #Predicate { $0.variant == variantRaw && $0.difficulty == difficultyRaw }
|
||||||
|
)
|
||||||
|
return try? modelContext.fetch(descriptor).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchOrCreateStats(variant: GameVariant, difficulty: Difficulty) -> StatsRecord {
|
||||||
|
if let existing = fetchStats(variant: variant, difficulty: difficulty) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let record = StatsRecord(variant: variant, difficulty: difficulty)
|
||||||
|
modelContext.insert(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preferences
|
||||||
|
|
||||||
|
func loadPreferences() -> PrefsRecord {
|
||||||
|
let descriptor = FetchDescriptor<PrefsRecord>()
|
||||||
|
if let existing = try? modelContext.fetch(descriptor).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let record = PrefsRecord()
|
||||||
|
modelContext.insert(record)
|
||||||
|
try? modelContext.save()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePreferences(themeId: String, cardFaceStyle: CardFaceStyle,
|
||||||
|
cardBackDesign: CardBackDesign, soundEnabled: Bool,
|
||||||
|
lastVariant: GameVariant, lastDifficulty: Difficulty) {
|
||||||
|
let prefs = loadPreferences()
|
||||||
|
prefs.themeId = themeId
|
||||||
|
prefs.cardFaceStyle = cardFaceStyle.rawValue
|
||||||
|
prefs.cardBackDesign = cardBackDesign.rawValue
|
||||||
|
prefs.soundEnabled = soundEnabled
|
||||||
|
prefs.lastVariant = lastVariant.rawValue
|
||||||
|
prefs.lastDifficulty = lastDifficulty.rawValue
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
21
SoliCards/Persistence/PrefsRecord.swift
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class PrefsRecord {
|
||||||
|
var themeId: String
|
||||||
|
var cardFaceStyle: String
|
||||||
|
var cardBackDesign: Int
|
||||||
|
var soundEnabled: Bool
|
||||||
|
var lastVariant: String
|
||||||
|
var lastDifficulty: String
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.themeId = GameTheme.classicGreen.id
|
||||||
|
self.cardFaceStyle = CardFaceStyle.classic.rawValue
|
||||||
|
self.cardBackDesign = CardBackDesign.blue.rawValue
|
||||||
|
self.soundEnabled = true
|
||||||
|
self.lastVariant = GameVariant.klondike.rawValue
|
||||||
|
self.lastDifficulty = Difficulty.medium.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
44
SoliCards/Persistence/StatsRecord.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class StatsRecord {
|
||||||
|
var variant: String
|
||||||
|
var difficulty: String
|
||||||
|
var gamesPlayed: Int
|
||||||
|
var gamesWon: Int
|
||||||
|
var bestScore: Int
|
||||||
|
var bestTime: Int
|
||||||
|
var currentStreak: Int
|
||||||
|
var bestStreak: Int
|
||||||
|
|
||||||
|
init(variant: GameVariant, difficulty: Difficulty) {
|
||||||
|
self.variant = variant.rawValue
|
||||||
|
self.difficulty = difficulty.rawValue
|
||||||
|
self.gamesPlayed = 0
|
||||||
|
self.gamesWon = 0
|
||||||
|
self.bestScore = 0
|
||||||
|
self.bestTime = Int.max
|
||||||
|
self.currentStreak = 0
|
||||||
|
self.bestStreak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordWin(score: Int, time: Int) {
|
||||||
|
gamesPlayed += 1
|
||||||
|
gamesWon += 1
|
||||||
|
if score > bestScore { bestScore = score }
|
||||||
|
if time < bestTime { bestTime = time }
|
||||||
|
currentStreak += 1
|
||||||
|
if currentStreak > bestStreak { bestStreak = currentStreak }
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordLoss() {
|
||||||
|
gamesPlayed += 1
|
||||||
|
currentStreak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var winRate: Double {
|
||||||
|
guard gamesPlayed > 0 else { return 0 }
|
||||||
|
return Double(gamesWon) / Double(gamesPlayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 616 B |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
SoliCards/Resources/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
12
SoliCards/Resources/Assets.xcassets/back_01.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_01.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_01.imageset/back_01.png
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
12
SoliCards/Resources/Assets.xcassets/back_02.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_02.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_02.imageset/back_02.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
12
SoliCards/Resources/Assets.xcassets/back_03.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_03.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_03.imageset/back_03.png
vendored
Normal file
|
After Width: | Height: | Size: 87 KiB |
12
SoliCards/Resources/Assets.xcassets/back_04.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_04.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_04.imageset/back_04.png
vendored
Normal file
|
After Width: | Height: | Size: 45 KiB |
12
SoliCards/Resources/Assets.xcassets/back_05.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_05.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_05.imageset/back_05.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
12
SoliCards/Resources/Assets.xcassets/back_06.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_06.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_06.imageset/back_06.png
vendored
Normal file
|
After Width: | Height: | Size: 51 KiB |
12
SoliCards/Resources/Assets.xcassets/back_07.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_07.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_07.imageset/back_07.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
12
SoliCards/Resources/Assets.xcassets/back_08.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_08.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_08.imageset/back_08.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
12
SoliCards/Resources/Assets.xcassets/back_09.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_09.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_09.imageset/back_09.png
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
12
SoliCards/Resources/Assets.xcassets/back_10.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_10.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_10.imageset/back_10.png
vendored
Normal file
|
After Width: | Height: | Size: 37 KiB |
12
SoliCards/Resources/Assets.xcassets/back_11.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_11.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_11.imageset/back_11.png
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
12
SoliCards/Resources/Assets.xcassets/back_12.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back_12.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/back_12.imageset/back_12.png
vendored
Normal file
|
After Width: | Height: | Size: 47 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_10.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_10.imageset/classic_clubs_10.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_2.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_2.imageset/classic_clubs_2.png
vendored
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_3.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_3.imageset/classic_clubs_3.png
vendored
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_4.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_4.imageset/classic_clubs_4.png
vendored
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_5.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_5.imageset/classic_clubs_5.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_6.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_6.imageset/classic_clubs_6.png
vendored
Normal file
|
After Width: | Height: | Size: 13 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_7.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_7.imageset/classic_clubs_7.png
vendored
Normal file
|
After Width: | Height: | Size: 13 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_8.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_8.imageset/classic_clubs_8.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_9.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_9.imageset/classic_clubs_9.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_ace.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_ace.imageset/classic_clubs_ace.png
vendored
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_jack.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_jack.imageset/classic_clubs_jack.png
vendored
Normal file
|
After Width: | Height: | Size: 66 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_king.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_king.imageset/classic_clubs_king.png
vendored
Normal file
|
After Width: | Height: | Size: 64 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_clubs_queen.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_clubs_queen.imageset/classic_clubs_queen.png
vendored
Normal file
|
After Width: | Height: | Size: 68 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_diamonds_10.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_diamonds_10.imageset/classic_diamonds_10.png
vendored
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_diamonds_2.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SoliCards/Resources/Assets.xcassets/classic_diamonds_2.imageset/classic_diamonds_2.png
vendored
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
12
SoliCards/Resources/Assets.xcassets/classic_diamonds_3.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "classic_diamonds_3.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||