Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions ComputerSolitaire/Animation/UndoAnimationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ enum UndoAnimationCoordinator {
case .drawFromStock:
for (index, id) in cardIDs.enumerated() {
guard let card = beforeCards[id] ?? afterCards[id] else { continue }
guard let startFrame = cardFrames[id] ?? wasteAnchorFrame(for: index, totalCards: cardIDs.count, stockFrame: stockFrame, wasteFrame: wasteFrame) else {
guard let startFrame = cardFrames[id] ?? wasteAnchorFrame(
for: index,
totalCards: cardIDs.count,
stockFrame: stockFrame,
wasteFrame: wasteFrame
) else {
continue
}
items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame))
Expand All @@ -49,7 +54,8 @@ enum UndoAnimationCoordinator {

case .recycleWaste:
for (index, id) in cardIDs.enumerated() {
guard let card = afterCards[id] ?? beforeCards[id], let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else {
guard let card = afterCards[id] ?? beforeCards[id],
let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else {
continue
}
items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame))
Expand Down
13 changes: 8 additions & 5 deletions ComputerSolitaire/ComputerSolitaireApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ struct ComputerSolitaireApp: App {
private var appCommands: some Commands {
#if os(macOS)
CommandGroup(replacing: .appInfo) {
Button(action: {
openWindow(id: "about")
}) {
Label("About Computer Solitaire", systemImage: "info.circle")
}
Button(
action: {
openWindow(id: "about")
},
label: {
Label("About Computer Solitaire", systemImage: "info.circle")
}
)
}
CommandGroup(replacing: .help) {
Button {
Expand Down
4 changes: 2 additions & 2 deletions ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -451,12 +451,12 @@ private extension FreeCellSolver {
// Cascade → cascade (supermoves included; the fitting length is unique per pair).
for source in board.cascades.indices {
let cascade = board.cascades[source]
guard !cascade.isEmpty else { continue }
guard let sourceTop = cascade.last else { continue }
let runLength = topRunLength(of: cascade)

for destination in board.cascades.indices where destination != source {
guard let top = board.cascades[destination].last else { continue }
let neededCount = rank(top) - rank(cascade.last!)
let neededCount = rank(top) - rank(sourceTop)
guard neededCount >= 1, neededCount <= runLength, neededCount <= transferCap else { continue }
let bottomMoving = cascade[cascade.count - neededCount]
if rank(bottomMoving) == rank(top) - 1, isRed(bottomMoving) != isRed(top) {
Expand Down
7 changes: 4 additions & 3 deletions ComputerSolitaire/Game/Klondike/KlondikePlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ enum KlondikePlanner {
let node = nodes[nodeIndex]

if node.score > rootScore {
if best == nil
|| node.score > best!.score
|| (node.score == best!.score && node.depth < best!.depth) {
let improvesBest = best.map {
node.score > $0.score || (node.score == $0.score && node.depth < $0.depth)
} ?? true
if improvesBest {
best = (nodeIndex, node.score, node.depth)
}
if isWon(node.state) { break }
Expand Down
32 changes: 32 additions & 0 deletions ComputerSolitaire/Game/Shared/Card.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ enum Suit: CaseIterable, Codable {
return "suit.club.fill"
}
}

var accessibilityName: String {
switch self {
case .spades: "Spades"
case .hearts: "Hearts"
case .diamonds: "Diamonds"
case .clubs: "Clubs"
}
}
}

enum Rank: Int, CaseIterable, Comparable, Codable {
Expand Down Expand Up @@ -62,6 +71,24 @@ enum Rank: Int, CaseIterable, Comparable, Codable {
return String(rawValue)
}
}

var accessibilityName: String {
switch self {
case .ace: "Ace"
case .two: "Two"
case .three: "Three"
case .four: "Four"
case .five: "Five"
case .six: "Six"
case .seven: "Seven"
case .eight: "Eight"
case .nine: "Nine"
case .ten: "Ten"
case .jack: "Jack"
case .queen: "Queen"
case .king: "King"
}
}
}

struct Card: Identifiable, Equatable, Codable {
Expand All @@ -79,6 +106,11 @@ struct Card: Identifiable, Equatable, Codable {
}

extension Card {
var accessibilityName: String {
guard isFaceUp else { return "Face-down card" }
return "\(rank.accessibilityName) of \(suit.accessibilityName)"
}

static func fullDeck() -> [Card] {
var deck: [Card] = []
for suit in Suit.allCases {
Expand Down
7 changes: 4 additions & 3 deletions ComputerSolitaire/Game/Shared/GamePersistence.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Foundation
import SwiftData


@Model
final class SavedGameRecord {
static let currentRecordKey = "current"
Expand Down Expand Up @@ -227,7 +226,6 @@ struct SavedGamePayload: Codable {
}
}


enum GamePersistenceError: Error {
case invalidPayload
}
Expand Down Expand Up @@ -321,7 +319,10 @@ struct GameStatistics: Codable, Equatable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

let decodedSchemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? Self.currentSchemaVersion
let decodedSchemaVersion = try container.decodeIfPresent(
Int.self,
forKey: .schemaVersion
) ?? Self.currentSchemaVersion
let decodedGamesPlayed = max(0, try container.decodeIfPresent(Int.self, forKey: .gamesPlayed) ?? 0)
let decodedGamesWon = max(
0,
Expand Down
5 changes: 4 additions & 1 deletion ComputerSolitaire/Game/Shared/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,10 @@ extension SolitaireViewModel {
switch destination {
case .foundation(let index):
guard selection.cards.count == 1 else { return false }
guard GameRules.canMoveToFoundation(card: movingCard, foundation: state.foundations[index]) else { return false }
guard GameRules.canMoveToFoundation(
card: movingCard,
foundation: state.foundations[index]
) else { return false }
clearHint()
pushHistory(
undoContext: UndoAnimationContext(
Expand Down
4 changes: 2 additions & 2 deletions ComputerSolitaire/Game/Shared/TapMovePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum TapMovePolicy {
var best: (destination: Destination, priority: Priority)?
for destination in destinations {
let priority = priority(of: destination, for: selection, in: state)
if best == nil || priority.isBetter(than: best!.priority) {
if best.map({ priority.isBetter(than: $0.priority) }) ?? true {
best = (destination, priority)
}
}
Expand All @@ -33,7 +33,7 @@ enum TapMovePolicy {
if case .foundation = selection.source { continue }
for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) {
let priority = priority(of: destination, for: selection, in: state)
if best == nil || priority.isBetter(than: best!.priority) {
if best.map({ priority.isBetter(than: $0.priority) }) ?? true {
best = (selection, destination, priority)
}
}
Expand Down
23 changes: 16 additions & 7 deletions ComputerSolitaire/Views/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import SwiftUI
enum AppInfo {
static let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
static let copyrightYear = String(Calendar.current.component(.year, from: Date()))
static let githubURL = URL(string: "https://github.com/austin-smith/ComputerSolitaire")!
static let githubURL = URL(string: "https://github.com/austin-smith/ComputerSolitaire")
}

struct AboutView: View {
Expand All @@ -30,7 +30,10 @@ struct AboutView: View {
.foregroundStyle(.secondary)
}

Text("Computer Solitaire is a low-frills, ad-free solitaire game for your computer. Includes Klondike, FreeCell, and other things you enjoy.")
Text(
"Computer Solitaire is a low-frills, ad-free solitaire game for your computer. "
+ "Includes Klondike, FreeCell, and other things you enjoy."
)
.font(.system(size: 14))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
Expand Down Expand Up @@ -58,9 +61,11 @@ struct AboutView: View {
Divider()
.padding(.horizontal, 24)

Link("GitHub", destination: AppInfo.githubURL)
.buttonStyle(.bordered)
.controlSize(.regular)
if let githubURL = AppInfo.githubURL {
Link("GitHub", destination: githubURL)
.buttonStyle(.bordered)
.controlSize(.regular)
}
}
}
.frame(maxWidth: .infinity)
Expand All @@ -83,7 +88,7 @@ struct AboutView: View {
@Environment(\.openURL) private var openURL

private var appVersion: String {
Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—"
}

private var copyrightYear: String {
Expand All @@ -101,6 +106,7 @@ struct AboutView: View {
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6)
.accessibilityHidden(true)

VStack(spacing: 3) {
Text("Computer Solitaire")
Expand All @@ -113,7 +119,10 @@ struct AboutView: View {
}
}

Text("Computer Solitaire is a low-frills, ad-free solitaire game for your computer. Includes Klondike, FreeCell, and other things you enjoy.")
Text(
"Computer Solitaire is a low-frills, ad-free solitaire game for your computer. "
+ "Includes Klondike, FreeCell, and other things you enjoy."
)
.font(.system(size: 11))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
Expand Down
2 changes: 2 additions & 0 deletions ComputerSolitaire/Views/AppIconPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ struct AppIconPreviewView: View {
.scaledToFill()
.frame(width: size, height: size)
.clipShape(shape)
.accessibilityHidden(true)
.overlay {
shape.stroke(Color.primary.opacity(0.12), lineWidth: 0.5)
}
Expand Down Expand Up @@ -140,6 +141,7 @@ struct AppIconPickerView: View {
.symbolRenderingMode(.palette)
.foregroundStyle(.white, Color.accentColor)
.padding(6)
.accessibilityHidden(true)
}
}
.contentShape(Rectangle())
Expand Down
8 changes: 7 additions & 1 deletion ComputerSolitaire/Views/Cards/CardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ struct CardView: View {
let hintWiggleToken: UUID?
let flipOnAppear: Bool
let flipDelay: Double
let isAccessibilityElement: Bool
@State private var flipRotation: Double
@State private var tiltAngle: Double = 0
@Environment(\.cardStyle) private var cardStyle
Expand All @@ -178,7 +179,8 @@ struct CardView: View {
cardTilts: Binding<[UUID: Double]>,
hintWiggleToken: UUID? = nil,
flipOnAppear: Bool = false,
flipDelay: Double = 0
flipDelay: Double = 0,
isAccessibilityElement: Bool = true
) {
self.card = card
self.isSelected = isSelected
Expand All @@ -188,6 +190,7 @@ struct CardView: View {
self.hintWiggleToken = hintWiggleToken
self.flipOnAppear = flipOnAppear
self.flipDelay = flipDelay
self.isAccessibilityElement = isAccessibilityElement
let startFaceDown = flipOnAppear && card.isFaceUp
_flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180))
}
Expand All @@ -208,6 +211,9 @@ struct CardView: View {
.rotation3DEffect(.degrees(backAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7)
}
.frame(width: cardSize.width, height: cardSize.height)
.accessibilityElement(children: .ignore)
.accessibilityLabel(card.accessibilityName)
.accessibilityHidden(!isAccessibilityElement)
.rotationEffect(.degrees(tiltAngle))
.hintWiggle(token: hintWiggleToken)
.scaleEffect(isSelected ? 1.03 : 1)
Expand Down
2 changes: 2 additions & 0 deletions ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ struct ClassicCardFrontView: View {
Spacer(minLength: 0)
Image(systemName: card.suit.symbolName)
.font(.system(size: cardSize.width * 0.2, weight: .semibold))
.accessibilityHidden(true)
}

ZStack {
Expand Down Expand Up @@ -114,6 +115,7 @@ struct ClassicCardFrontView: View {
.foregroundStyle(inkColor.opacity(0.12))
.rotationEffect(.degrees(8))
.frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center)
.accessibilityHidden(true)
}
}
}
Expand Down
26 changes: 14 additions & 12 deletions ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ struct PixelSprite {
init(_ art: String) {
let map: [Character: UInt8] = [
".": 0, "#": 1, "+": 2, "K": 3, "S": 4, "s": 5,
"G": 6, "R": 7, "D": 8, "H": 9, "W": 10, "A": 11, "B": 12,
"G": 6, "R": 7, "D": 8, "H": 9, "W": 10, "A": 11, "B": 12
]
let lines = art.split(separator: "\n").map(String.init)
let w = lines.map(\.count).max() ?? 0
Expand Down Expand Up @@ -269,16 +269,18 @@ enum PixelSprites {
}

// Rank glyphs — 5x7 ("10" is 7 wide).
static let aceRank = PixelSprite("""
.###.
#...#
#...#
#####
#...#
#...#
#...#
""")

static let ranks: [String: PixelSprite] = [
"A": PixelSprite("""
.###.
#...#
#...#
#####
#...#
#...#
#...#
"""),
"A": aceRank,
"2": PixelSprite("""
.###.
#...#
Expand Down Expand Up @@ -386,11 +388,11 @@ enum PixelSprites {
#.#..
#..#.
#...#
"""),
""")
]

static func rank(_ rank: Rank) -> PixelSprite {
ranks[rank.label] ?? ranks["A"]!
ranks[rank.label] ?? aceRank
}

// Face card portraits — 28x35, outlined forms in the classic style.
Expand Down
3 changes: 3 additions & 0 deletions ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ struct SimpleCardFrontView: View {
Spacer(minLength: 0)
Image(systemName: card.suit.symbolName)
.font(.system(size: cardSize.width * 0.22, weight: .semibold))
.accessibilityHidden(true)
}
.foregroundStyle(inkColor)
.padding(cardSize.width * 0.08)
Expand All @@ -108,6 +109,7 @@ struct SimpleCardFrontView: View {
y: cardSize.width * (isJack ? 0.36 : 0.32))
.frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.bottomTrailing)
.clipShape(RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous))
.accessibilityHidden(true)
} else {
// Optically centered in the region below the top marks, not
// the full card, so the face doesn't read bottom-heavy.
Expand All @@ -116,6 +118,7 @@ struct SimpleCardFrontView: View {
.foregroundStyle(inkColor)
.offset(y: cardSize.width * 0.14)
.frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center)
.accessibilityHidden(true)
}
}
}
Expand Down
Loading