diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9947330..739ceb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,35 @@ concurrency: cancel-in-progress: true jobs: + swiftlint: + name: SwiftLint + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install pinned SwiftLint + env: + SWIFTLINT_VERSION: 0.65.0 + SWIFTLINT_SHA256: eb333bd76dfb5f46d21fdf3615fe39bb938956ca0b8e94c241c4b2db6e696b90 + run: | + ARCHIVE="$RUNNER_TEMP/SwiftLintBinary.artifactbundle.zip" + INSTALL_DIR="$RUNNER_TEMP/swiftlint" + curl --fail --location --retry 3 --show-error \ + --output "$ARCHIVE" \ + "https://github.com/realm/SwiftLint/releases/download/$SWIFTLINT_VERSION/SwiftLintBinary.artifactbundle.zip" + printf '%s %s\n' "$SWIFTLINT_SHA256" "$ARCHIVE" | shasum -a 256 --check + mkdir -p "$INSTALL_DIR" + ditto -x -k "$ARCHIVE" "$INSTALL_DIR" + echo "$INSTALL_DIR/SwiftLintBinary.artifactbundle/macos" >> "$GITHUB_PATH" + + - name: Run SwiftLint + run: swiftlint lint --strict --no-cache --reporter github-actions-logging + macos-unit-tests: name: macOS Build & Test + needs: swiftlint runs-on: macos-26 steps: @@ -43,6 +70,7 @@ jobs: ios-build: name: iOS Build + needs: swiftlint runs-on: macos-26 steps: diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..274c9e7 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,26 @@ +included: + - ComputerSolitaire + - ComputerSolitaireTests + - ComputerSolitaireUITests + +file_length: + warning: 800 + error: 1000 + +# Files temporarily exempt from file_length only: +# - ComputerSolitaire/Views/Shared/ContentView.swift +# - ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift + +disabled_rules: + - todo + +opt_in_rules: + - accessibility_label_for_image + - accessibility_trait_for_button + - async_without_await + - balanced_xctest_lifecycle + - discarded_notification_center_observer + - force_unwrapping + - unhandled_throwing_task + +reporter: xcode diff --git a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift index 12b748f..fe2ffa8 100644 --- a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift @@ -9,6 +9,21 @@ struct UndoAnimationItem: Identifiable { } enum UndoAnimationCoordinator { + struct Cards { + let before: [UUID: Card] + let after: [UUID: Card] + + func card(for id: UUID, preferringAfter: Bool = false) -> Card? { + preferringAfter ? after[id] ?? before[id] : before[id] ?? after[id] + } + } + + struct Frames { + let cards: [UUID: CGRect] + let stock: CGRect + let waste: CGRect + } + struct Plan { let items: [UndoAnimationItem] let targets: [UUID: UndoAnimationEndTarget] @@ -17,51 +32,71 @@ enum UndoAnimationCoordinator { static func buildPlan( context: UndoAnimationContext, - beforeCards: [UUID: Card], - afterCards: [UUID: Card], - cardFrames: [UUID: CGRect], - stockFrame: CGRect, - wasteFrame: CGRect + cards: Cards, + frames: Frames ) -> Plan { - var items: [UndoAnimationItem] = [] - var targets: [UUID: UndoAnimationEndTarget] = [:] - let cardIDs = context.cardIDs - switch context.action { case .moveSelection: - for id in cardIDs { - guard let card = beforeCards[id] ?? afterCards[id], let startFrame = cardFrames[id] else { continue } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) - targets[id] = .card(id) - } - return Plan(items: items, targets: targets, needsPostUndoFrames: true) + return moveSelectionPlan(cardIDs: context.cardIDs, cards: cards, frames: frames) 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 { - continue - } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) - targets[id] = .stock(index) - } - return Plan(items: items, targets: targets, needsPostUndoFrames: false) + return drawFromStockPlan(cardIDs: context.cardIDs, cards: cards, frames: frames) case .recycleWaste: - for (index, id) in cardIDs.enumerated() { - 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)) - targets[id] = .card(id) - } - return Plan(items: items, targets: targets, needsPostUndoFrames: true) + return recycleWastePlan(cardIDs: context.cardIDs, cards: cards, frames: frames) case .flipTableauTop: return Plan(items: [], targets: [:], needsPostUndoFrames: false) } } + private static func moveSelectionPlan(cardIDs: [UUID], cards: Cards, frames: Frames) -> Plan { + var items: [UndoAnimationItem] = [] + var targets: [UUID: UndoAnimationEndTarget] = [:] + for id in cardIDs { + guard let card = cards.card(for: id), let startFrame = frames.cards[id] else { continue } + items.append(item(id: id, card: card, startFrame: startFrame)) + targets[id] = .card(id) + } + return Plan(items: items, targets: targets, needsPostUndoFrames: true) + } + + private static func drawFromStockPlan(cardIDs: [UUID], cards: Cards, frames: Frames) -> Plan { + var items: [UndoAnimationItem] = [] + var targets: [UUID: UndoAnimationEndTarget] = [:] + for (index, id) in cardIDs.enumerated() { + guard let card = cards.card(for: id) else { continue } + let startFrame = frames.cards[id] ?? wasteAnchorFrame( + for: index, + totalCards: cardIDs.count, + stockFrame: frames.stock, + wasteFrame: frames.waste + ) + guard let startFrame else { continue } + items.append(item(id: id, card: card, startFrame: startFrame)) + targets[id] = .stock(index) + } + return Plan(items: items, targets: targets, needsPostUndoFrames: false) + } + + private static func recycleWastePlan(cardIDs: [UUID], cards: Cards, frames: Frames) -> Plan { + var items: [UndoAnimationItem] = [] + var targets: [UUID: UndoAnimationEndTarget] = [:] + for (index, id) in cardIDs.enumerated() { + guard let card = cards.card(for: id, preferringAfter: true), + let startFrame = stockAnchorFrame(for: index, stockFrame: frames.stock) else { + continue + } + items.append(item(id: id, card: card, startFrame: startFrame)) + targets[id] = .card(id) + } + return Plan(items: items, targets: targets, needsPostUndoFrames: true) + } + + private static func item(id: UUID, card: Card, startFrame: CGRect) -> UndoAnimationItem { + UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame) + } + static func resolveTargetFrame( _ target: UndoAnimationEndTarget, cardFrames: [UUID: CGRect], @@ -85,9 +120,9 @@ enum UndoAnimationCoordinator { static func stockAnchorFrame(for index: Int, stockFrame: CGRect) -> CGRect? { guard stockFrame != .zero else { return nil } - let dx = CGFloat(index) * 0.8 - let dy = CGFloat(index) * 0.5 - return stockFrame.offsetBy(dx: dx, dy: dy) + let horizontalOffset = CGFloat(index) * 0.8 + let verticalOffset = CGFloat(index) * 0.5 + return stockFrame.offsetBy(dx: horizontalOffset, dy: verticalOffset) } static func wasteAnchorFrame( @@ -101,7 +136,7 @@ enum UndoAnimationCoordinator { let baseHeight = stockFrame.height > 0 ? stockFrame.height : wasteFrame.height let fanSpacing = baseWidth * 0.25 let rightBias = max(0, totalCards - 1 - index) - let x = wasteFrame.minX + CGFloat(rightBias) * fanSpacing - return CGRect(x: x, y: wasteFrame.minY, width: baseWidth, height: baseHeight) + let horizontalPosition = wasteFrame.minX + CGFloat(rightBias) * fanSpacing + return CGRect(x: horizontalPosition, y: wasteFrame.minY, width: baseWidth, height: baseHeight) } } diff --git a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift index 69f4474..c93e59a 100644 --- a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift +++ b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift @@ -77,10 +77,10 @@ enum WinCascadeCoordinator { guard !states.isEmpty else { return } guard boardBounds.width > 0, boardBounds.height > 0 else { return } - let dt = CGFloat(max(1.0 / 120.0, min(1.0 / 30.0, deltaTime))) + let timeStep = CGFloat(max(1.0 / 120.0, min(1.0 / 30.0, deltaTime))) for index in states.indices { - states[index].elapsed += TimeInterval(dt) + states[index].elapsed += TimeInterval(timeStep) if states[index].elapsed < states[index].activationDelay { continue } @@ -88,49 +88,8 @@ enum WinCascadeCoordinator { continue } - var item = states[index] - - item.velocity.dy += gravity * dt - item.position.x += item.velocity.dx * dt - item.position.y += item.velocity.dy * dt - item.rotationDegrees += item.angularVelocityDegreesPerSecond * Double(dt) - - let halfWidth = item.size.width * 0.5 - let halfHeight = item.size.height * 0.5 - var bounced = false - - if item.position.x - halfWidth < boardBounds.minX { - item.position.x = boardBounds.minX + halfWidth - item.velocity.dx = abs(item.velocity.dx) * sideBounceDamping - bounced = true - } - if item.position.x + halfWidth > boardBounds.maxX { - item.position.x = boardBounds.maxX - halfWidth - item.velocity.dx = -abs(item.velocity.dx) * sideBounceDamping - bounced = true - } - if item.position.y - halfHeight < boardBounds.minY { - item.position.y = boardBounds.minY + halfHeight - item.velocity.dy = abs(item.velocity.dy) * topBounceDamping - bounced = true - } - if item.position.y + halfHeight > boardBounds.maxY { - item.position.y = boardBounds.maxY - halfHeight - let reboundSpeed = abs(item.velocity.dy) * floorBounceDamping - if reboundSpeed < floorSettleVerticalSpeed { - // Snap low-energy impacts to a settle state to avoid endless micro-bouncing. - item.velocity.dy = 0 - item.velocity.dx = 0 - item.angularVelocityDegreesPerSecond = 0 - item.isSettled = true - } else { - item.velocity.dy = -reboundSpeed - item.velocity.dx *= floorHorizontalFriction - } - bounced = true - } - - if bounced { + var item = advanced(states[index], timeStep: timeStep) + if resolveBoundaryCollisions(for: &item, in: boardBounds) { item.bounceCount += 1 item.angularVelocityDegreesPerSecond *= angularVelocityDampingOnBounce } @@ -138,10 +97,68 @@ enum WinCascadeCoordinator { states[index] = item } + settleExpiredStates(&states, in: boardBounds) + } + + private static func advanced(_ state: WinCascadeCardState, timeStep: CGFloat) -> WinCascadeCardState { + var result = state + result.velocity.dy += gravity * timeStep + result.position.x += result.velocity.dx * timeStep + result.position.y += result.velocity.dy * timeStep + result.rotationDegrees += result.angularVelocityDegreesPerSecond * Double(timeStep) + return result + } + + private static func resolveBoundaryCollisions( + for item: inout WinCascadeCardState, + in bounds: CGRect + ) -> Bool { + let halfWidth = item.size.width * 0.5 + let halfHeight = item.size.height * 0.5 + var bounced = false + if item.position.x - halfWidth < bounds.minX { + item.position.x = bounds.minX + halfWidth + item.velocity.dx = abs(item.velocity.dx) * sideBounceDamping + bounced = true + } + if item.position.x + halfWidth > bounds.maxX { + item.position.x = bounds.maxX - halfWidth + item.velocity.dx = -abs(item.velocity.dx) * sideBounceDamping + bounced = true + } + if item.position.y - halfHeight < bounds.minY { + item.position.y = bounds.minY + halfHeight + item.velocity.dy = abs(item.velocity.dy) * topBounceDamping + bounced = true + } + if item.position.y + halfHeight > bounds.maxY { + resolveFloorCollision(for: &item, floor: bounds.maxY - halfHeight) + bounced = true + } + return bounced + } + + private static func resolveFloorCollision(for item: inout WinCascadeCardState, floor: CGFloat) { + item.position.y = floor + let reboundSpeed = abs(item.velocity.dy) * floorBounceDamping + if reboundSpeed < floorSettleVerticalSpeed { + item.velocity = .zero + item.angularVelocityDegreesPerSecond = 0 + item.isSettled = true + } else { + item.velocity.dy = -reboundSpeed + item.velocity.dx *= floorHorizontalFriction + } + } + + private static func settleExpiredStates( + _ states: inout [WinCascadeCardState], + in bounds: CGRect + ) { for index in states.indices where !states[index].isSettled { let activeAge = max(0, states[index].elapsed - states[index].activationDelay) guard activeAge > maxActiveLifetime else { continue } - states[index].position.y = boardBounds.maxY - states[index].size.height * 0.5 + states[index].position.y = bounds.maxY - states[index].size.height * 0.5 states[index].velocity = .zero states[index].angularVelocityDegreesPerSecond = 0 states[index].isSettled = true diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index e5b2de4..1eaf9a1 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -74,11 +74,14 @@ struct ComputerSolitaireApp: App { private var appCommands: some Commands { #if os(macOS) CommandGroup(replacing: .appInfo) { - Button(action: { + Button( + action: { openWindow(id: "about") - }) { - Label("About Computer Solitaire", systemImage: "info.circle") - } + }, + label: { + Label("About Computer Solitaire", systemImage: "info.circle") + } + ) } CommandGroup(replacing: .help) { Button { diff --git a/ComputerSolitaire/Feedback/HapticManager.swift b/ComputerSolitaire/Feedback/HapticManager.swift index e5e07cc..57e9374 100644 --- a/ComputerSolitaire/Feedback/HapticManager.swift +++ b/ComputerSolitaire/Feedback/HapticManager.swift @@ -19,7 +19,9 @@ final class HapticManager { private(set) var trigger: UInt64 = 0 private var lastEvent: Event? - private init() {} + private init() { + // Shared instance only. + } func play(_ event: Event) { #if os(iOS) diff --git a/ComputerSolitaire/Feedback/SoundManager.swift b/ComputerSolitaire/Feedback/SoundManager.swift index 9cda9ad..b9892d3 100644 --- a/ComputerSolitaire/Feedback/SoundManager.swift +++ b/ComputerSolitaire/Feedback/SoundManager.swift @@ -19,7 +19,9 @@ final class SoundManager { private var players: [GameSound: AVAudioPlayer] = [:] private var hasConfiguredAudioSession = false - private init() {} + private init() { + // Shared instance only. + } func play(_ sound: GameSound) { guard isSoundEffectsEnabled else { return } @@ -48,7 +50,11 @@ private extension SoundManager { try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) try session.setActive(true) hasConfiguredAudioSession = true - } catch {} + } catch { +#if DEBUG + print("Failed to configure the audio session: \(error)") +#endif + } #endif } diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift index faebf03..cc8ec1f 100644 --- a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -11,19 +11,20 @@ enum FreeCellSolver { /// A card is `suitIndex << 4 | rank` (rank 1...13); suit order follows `Suit.allCases`. typealias Code = UInt8 - struct Move: Equatable { - enum Source: Equatable { - case cascade(pile: Int, count: Int) - case cell(Int) - } - enum Target: Equatable { - case cascade(Int) - case cell(Int) - case foundation - } + enum MoveSource: Equatable { + case cascade(pile: Int, count: Int) + case cell(Int) + } - let source: Source - let target: Target + enum MoveTarget: Equatable { + case cascade(Int) + case cell(Int) + case foundation + } + + struct Move: Equatable { + let source: MoveSource + let target: MoveTarget } struct Solution { @@ -45,46 +46,35 @@ enum FreeCellSolver { guard var rootBoard = Board(state: state) else { return nil } let rootAutoplay = applySafeAutoplay(&rootBoard) - var nodes: [Node] = [Node(board: rootBoard, parent: -1, movesFromParent: rootAutoplay, g: rootAutoplay.count)] - var visited: Set = [rootBoard.canonical()] - var heap = Heap() - heap.push(HeapEntry(f: heuristic(rootBoard), order: 0, index: 0)) - var order = 0 + var search = SearchStorage( + nodes: [Node(board: rootBoard, parent: -1, movesFromParent: rootAutoplay, cost: rootAutoplay.count)], + visited: [rootBoard.canonical()], + heap: Heap(), + order: 0 + ) + search.heap.push(HeapEntry(priority: heuristic(rootBoard), order: 0, index: 0)) var expansions = 0 - while let entry = heap.pop() { + while let entry = search.heap.pop() { let nodeIndex = entry.index - let board = nodes[nodeIndex].board + let board = search.nodes[nodeIndex].board if board.isWon { - return Solution(moves: reconstructMoves(endingAt: nodeIndex, nodes: nodes)) + return Solution(moves: reconstructMoves(endingAt: nodeIndex, nodes: search.nodes)) } expansions += 1 - if nodes.count >= limits.maxNodes { return nil } + if search.nodes.count >= limits.maxNodes { return nil } if expansions % 128 == 0, let deadline = limits.deadline, Date() > deadline { return nil } for move in generateMoves(from: board) { - var nextBoard = board - applyMove(move, to: &nextBoard) - let autoplay = applySafeAutoplay(&nextBoard) - - let canonical = nextBoard.canonical() - guard visited.insert(canonical).inserted else { continue } - - let g = nodes[nodeIndex].g + 1 + autoplay.count - nodes.append( - Node(board: nextBoard, parent: nodeIndex, movesFromParent: [move] + autoplay, g: g) - ) - order += 1 - heap.push( - HeapEntry( - f: g + heuristicWeight * heuristic(nextBoard), - order: order, - index: nodes.count - 1 - ) + appendSearchNode( + applying: move, + to: board, + parentIndex: nodeIndex, + search: &search ) } } @@ -125,36 +115,44 @@ enum FreeCellSolver { _ move: Move, in state: GameState ) -> (selection: Selection, destination: Destination)? { - let selection: Selection - switch move.source { + guard let selection = selection(for: move.source, in: state) else { return nil } + guard let destination = destination(for: move.target, selection: selection, in: state) else { return nil } + return (selection, destination) + } + + private static func selection(for source: MoveSource, in state: GameState) -> Selection? { + switch source { case .cascade(let pile, let count): guard state.tableau.indices.contains(pile) else { return nil } let cards = state.tableau[pile] guard count >= 1, count <= cards.count else { return nil } - selection = Selection( + return Selection( source: .tableau(pile: pile, index: cards.count - count), cards: Array(cards[(cards.count - count)...]) ) case .cell(let slot): guard state.freeCells.indices.contains(slot), let card = state.freeCells[slot] else { return nil } - selection = Selection(source: .freeCell(slot: slot), cards: [card]) + return Selection(source: .freeCell(slot: slot), cards: [card]) } + } - let destination: Destination - switch move.target { + private static func destination( + for target: MoveTarget, + selection: Selection, + in state: GameState + ) -> Destination? { + switch target { case .cascade(let pile): guard state.tableau.indices.contains(pile) else { return nil } - destination = .tableau(pile) + return .tableau(pile) case .cell(let slot): guard state.freeCells.indices.contains(slot), state.freeCells[slot] == nil else { return nil } - destination = .freeCell(slot) + return .freeCell(slot) case .foundation: guard let card = selection.cards.first, selection.cards.count == 1 else { return nil } guard let index = foundationPileIndex(for: card, in: state) else { return nil } - destination = .foundation(index) + return .foundation(index) } - - return (selection, destination) } static func foundationPileIndex(for card: Card, in state: GameState) -> Int? { @@ -197,7 +195,9 @@ extension FreeCellSolver { var canonicalBoard = self canonicalBoard.cells.sort() canonicalBoard.cascades.sort { lhs, rhs in - for (a, b) in zip(lhs, rhs) where a != b { return a < b } + for (leftCode, rightCode) in zip(lhs, rhs) where leftCode != rightCode { + return leftCode < rightCode + } return lhs.count < rhs.count } return canonicalBoard @@ -235,16 +235,16 @@ private extension FreeCellSolver { let board: Board let parent: Int let movesFromParent: [Move] - let g: Int + let cost: Int } struct HeapEntry { - let f: Int + let priority: Int let order: Int let index: Int func takesPriority(over other: HeapEntry) -> Bool { - f != other.f ? f < other.f : order < other.order + priority != other.priority ? priority < other.priority : order < other.order } } @@ -287,6 +287,38 @@ private extension FreeCellSolver { } } + struct SearchStorage { + var nodes: [Node] + var visited: Set + var heap: Heap + var order: Int + } + + static func appendSearchNode( + applying move: Move, + to board: Board, + parentIndex: Int, + search: inout SearchStorage + ) { + var nextBoard = board + applyMove(move, to: &nextBoard) + let autoplay = applySafeAutoplay(&nextBoard) + guard search.visited.insert(nextBoard.canonical()).inserted else { return } + + let cost = search.nodes[parentIndex].cost + 1 + autoplay.count + search.nodes.append( + Node(board: nextBoard, parent: parentIndex, movesFromParent: [move] + autoplay, cost: cost) + ) + search.order += 1 + search.heap.push( + HeapEntry( + priority: cost + heuristicWeight * heuristic(nextBoard), + order: search.order, + index: search.nodes.count - 1 + ) + ) + } + static func heuristic(_ board: Board) -> Int { var estimate = 0 for suitValue in 0..<4 { @@ -415,76 +447,80 @@ private extension FreeCellSolver { } static func generateMoves(from board: Board) -> [Move] { - var moves: [Move] = [] - let firstEmptyCell = board.cells.firstIndex(of: 0) - let firstEmptyCascade = board.cascades.firstIndex(where: \.isEmpty) - let transferCap = maxTransferCount(in: board, toEmptyCascade: false) - let transferCapToEmpty = maxTransferCount(in: board, toEmptyCascade: true) + foundationMoves(from: board) + + cellToCascadeMoves(from: board) + + cascadeToCascadeMoves(from: board) + + cascadeToCellMoves(from: board) + } - // Foundation moves (including unsafe ones; safety is only for autoplay). + static func foundationMoves(from board: Board) -> [Move] { + var moves: [Move] = [] for pile in board.cascades.indices { - if let top = board.cascades[pile].last, isFoundationEligible(top, in: board) { - moves.append(Move(source: .cascade(pile: pile, count: 1), target: .foundation)) - } + guard let top = board.cascades[pile].last, isFoundationEligible(top, in: board) else { continue } + moves.append(Move(source: .cascade(pile: pile, count: 1), target: .foundation)) } for slot in board.cells.indices where board.cells[slot] != 0 { - if isFoundationEligible(board.cells[slot], in: board) { - moves.append(Move(source: .cell(slot), target: .foundation)) - } + guard isFoundationEligible(board.cells[slot], in: board) else { continue } + moves.append(Move(source: .cell(slot), target: .foundation)) } + return moves + } - // Cell → cascade. + static func cellToCascadeMoves(from board: Board) -> [Move] { + var moves: [Move] = [] + let firstEmptyCascade = board.cascades.firstIndex(where: \.isEmpty) for slot in board.cells.indices { let code = board.cells[slot] guard code != 0 else { continue } for pile in board.cascades.indices { guard let top = board.cascades[pile].last else { continue } - if rank(code) == rank(top) - 1, isRed(code) != isRed(top) { - moves.append(Move(source: .cell(slot), target: .cascade(pile))) - } + guard rank(code) == rank(top) - 1, isRed(code) != isRed(top) else { continue } + moves.append(Move(source: .cell(slot), target: .cascade(pile))) } - if let emptyPile = firstEmptyCascade { - moves.append(Move(source: .cell(slot), target: .cascade(emptyPile))) + if let firstEmptyCascade { + moves.append(Move(source: .cell(slot), target: .cascade(firstEmptyCascade))) } } + return moves + } - // Cascade → cascade (supermoves included; the fitting length is unique per pair). + static func cascadeToCascadeMoves(from board: Board) -> [Move] { + var moves: [Move] = [] + let firstEmptyCascade = board.cascades.firstIndex(where: \.isEmpty) + let transferCap = maxTransferCount(in: board, toEmptyCascade: false) + let transferCapToEmpty = maxTransferCount(in: board, toEmptyCascade: true) 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) { - moves.append( - Move(source: .cascade(pile: source, count: neededCount), target: .cascade(destination)) - ) - } + guard rank(bottomMoving) == rank(top) - 1, isRed(bottomMoving) != isRed(top) else { continue } + moves.append( + Move(source: .cascade(pile: source, count: neededCount), target: .cascade(destination)) + ) } - - // Only the first empty cascade: the rest are symmetric. Relocating an entire - // cascade into another empty column is a no-op, so skip that count. - if let emptyPile = firstEmptyCascade { - let cap = min(runLength, transferCapToEmpty) - for count in stride(from: cap, through: 1, by: -1) where count < cascade.count { + if let firstEmptyCascade { + let transferLimit = min(runLength, transferCapToEmpty) + for count in stride(from: transferLimit, through: 1, by: -1) where count < cascade.count { moves.append( - Move(source: .cascade(pile: source, count: count), target: .cascade(emptyPile)) + Move(source: .cascade(pile: source, count: count), target: .cascade(firstEmptyCascade)) ) } } } + return moves + } - // Cascade top → first empty cell. - if let cellSlot = firstEmptyCell { - for pile in board.cascades.indices where !board.cascades[pile].isEmpty { - moves.append(Move(source: .cascade(pile: pile, count: 1), target: .cell(cellSlot))) - } + static func cascadeToCellMoves(from board: Board) -> [Move] { + guard let cellSlot = board.cells.firstIndex(of: 0) else { return [] } + var moves: [Move] = [] + for pile in board.cascades.indices where !board.cascades[pile].isEmpty { + moves.append(Move(source: .cascade(pile: pile, count: 1), target: .cell(cellSlot))) } - return moves } } diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 5131f8a..1d8258c 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -38,6 +38,13 @@ enum AutoFinishPlanner { } private extension AutoFinishPlanner { + struct Candidate { + let move: AutoFinishMove + let rankValue: Int + let sourceOrder: Int + let foundationPile: Int + } + static func isAutoFinishCandidateState(_ state: GameState) -> Bool { guard !isWin(state) else { return false } switch state.variant { @@ -54,49 +61,15 @@ private extension AutoFinishPlanner { } static func nextAutoFinishMoveInternal(in state: GameState) -> AutoFinishMove? { - var candidates: [(move: AutoFinishMove, rankValue: Int, sourceOrder: Int, foundationPile: Int)] = [] + var candidates: [Candidate] = [] for pileIndex in state.tableau.indices { - guard let topIndex = state.tableau[pileIndex].indices.last else { continue } - let card = state.tableau[pileIndex][topIndex] - guard card.isFaceUp else { continue } - - for foundationIndex in state.foundations.indices { - let foundation = state.foundations[foundationIndex] - guard GameRules.canMoveToFoundation(card: card, foundation: foundation) else { continue } - - let selection = Selection( - source: .tableau(pile: pileIndex, index: topIndex), - cards: [card] - ) - candidates.append( - ( - move: AutoFinishMove(selection: selection, destination: .foundation(foundationIndex)), - rankValue: card.rank.rawValue, - sourceOrder: pileIndex, - foundationPile: foundationIndex - ) - ) - } + candidates.append(contentsOf: tableauCandidates(in: state, pileIndex: pileIndex)) } if state.variant == .freecell { for slot in state.freeCells.indices { - guard let card = state.freeCells[slot] else { continue } - for foundationIndex in state.foundations.indices { - let foundation = state.foundations[foundationIndex] - guard GameRules.canMoveToFoundation(card: card, foundation: foundation) else { continue } - - let selection = Selection(source: .freeCell(slot: slot), cards: [card]) - candidates.append( - ( - move: AutoFinishMove(selection: selection, destination: .foundation(foundationIndex)), - rankValue: card.rank.rawValue, - sourceOrder: state.tableau.count + slot, - foundationPile: foundationIndex - ) - ) - } + candidates.append(contentsOf: freeCellCandidates(in: state, slot: slot)) } } @@ -112,6 +85,48 @@ private extension AutoFinishPlanner { return sorted.first?.move } + static func tableauCandidates(in state: GameState, pileIndex: Int) -> [Candidate] { + guard let topIndex = state.tableau[pileIndex].indices.last else { return [] } + let card = state.tableau[pileIndex][topIndex] + guard card.isFaceUp else { return [] } + let selection = Selection(source: .tableau(pile: pileIndex, index: topIndex), cards: [card]) + return foundationCandidates( + for: card, + selection: selection, + sourceOrder: pileIndex, + in: state + ) + } + + static func freeCellCandidates(in state: GameState, slot: Int) -> [Candidate] { + guard let card = state.freeCells[slot] else { return [] } + let selection = Selection(source: .freeCell(slot: slot), cards: [card]) + return foundationCandidates( + for: card, + selection: selection, + sourceOrder: state.tableau.count + slot, + in: state + ) + } + + static func foundationCandidates( + for card: Card, + selection: Selection, + sourceOrder: Int, + in state: GameState + ) -> [Candidate] { + state.foundations.indices.compactMap { foundationIndex in + let foundation = state.foundations[foundationIndex] + guard GameRules.canMoveToFoundation(card: card, foundation: foundation) else { return nil } + return Candidate( + move: AutoFinishMove(selection: selection, destination: .foundation(foundationIndex)), + rankValue: card.rank.rawValue, + sourceOrder: sourceOrder, + foundationPile: foundationIndex + ) + } + } + @discardableResult static func applyAutoFinishMove(_ move: AutoFinishMove, in state: inout GameState) -> Bool { guard case .foundation(let foundationIndex) = move.destination, diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 2283dae..f5e5fc9 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -28,77 +28,135 @@ enum KlondikePlanner { limits: Limits = Limits() ) -> HintAdvisor.Hint? { guard state.variant == .klondike else { return nil } - let rootScore = score(state) - var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] - var visited: Set = [stateHash(state)] + let result = search( + from: state, + stockDrawCount: stockDrawCount, + rootScore: rootScore, + limits: limits + ) + guard let best = result.best else { return nil } + return firstAction(leadingTo: best.index, nodes: result.nodes) + } +} + +// MARK: - Search internals + +private extension KlondikePlanner { + struct BestNode { + let index: Int + let score: Int + let depth: Int + + func isBetter(than other: BestNode?) -> Bool { + guard let other else { return true } + return score > other.score || (score == other.score && depth < other.depth) + } + } + + struct SearchResult { + let nodes: [Node] + let best: BestNode? + } + + struct SearchStorage { + var nodes: [Node] + var visited: Set + var heap: Heap + var order: Int + } + + static func search( + from state: GameState, + stockDrawCount: Int, + rootScore: Int, + limits: Limits + ) -> SearchResult { + let rootNode = Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore) var heap = Heap() heap.push(HeapEntry(priority: rootScore, order: 0, index: 0)) - var order = 0 + var storage = SearchStorage( + nodes: [rootNode], + visited: [stateHash(state)], + heap: heap, + order: 0 + ) var expansions = 0 - var best: (index: Int, score: Int, depth: Int)? + var best: BestNode? - while let entry = heap.pop() { + while let entry = storage.heap.pop() { let nodeIndex = entry.index - let node = nodes[nodeIndex] + let node = storage.nodes[nodeIndex] if node.score > rootScore { - if best == nil - || node.score > best!.score - || (node.score == best!.score && node.depth < best!.depth) { - best = (nodeIndex, node.score, node.depth) + let candidate = BestNode(index: nodeIndex, score: node.score, depth: node.depth) + if candidate.isBetter(than: best) { + best = candidate } if isWon(node.state) { break } } guard node.depth < limits.maxDepth else { continue } expansions += 1 - if nodes.count >= limits.maxNodes { break } - if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { - break - } - // A line that reveals a card or banks a foundation card is a solid hint; - // once one is in hand, cap how long we keep hunting for something better. - if let best, best.score - rootScore >= 20, expansions >= 768 { - break - } + if shouldStop( + nodeCount: storage.nodes.count, + expansions: expansions, + best: best, + rootScore: rootScore, + limits: limits + ) { break } + appendChildren( + of: node, + nodeIndex: nodeIndex, + stockDrawCount: stockDrawCount, + storage: &storage + ) + } + return SearchResult(nodes: storage.nodes, best: best) + } - for action in actions(from: node.state, stockDrawCount: stockDrawCount) { - guard let nextState = apply(action, to: node.state, stockDrawCount: stockDrawCount) else { - continue - } - guard visited.insert(stateHash(nextState)).inserted else { continue } - - let nextScore = score(nextState) - nodes.append( - Node( - state: nextState, - parent: nodeIndex, - action: action, - depth: node.depth + 1, - score: nextScore - ) + static func shouldStop( + nodeCount: Int, + expansions: Int, + best: BestNode?, + rootScore: Int, + limits: Limits + ) -> Bool { + if nodeCount >= limits.maxNodes { return true } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { return true } + return best.map { $0.score - rootScore >= 20 && expansions >= 768 } ?? false + } + + static func appendChildren( + of node: Node, + nodeIndex: Int, + stockDrawCount: Int, + storage: inout SearchStorage + ) { + for action in actions(from: node.state, stockDrawCount: stockDrawCount) { + guard let nextState = apply(action, to: node.state, stockDrawCount: stockDrawCount), + storage.visited.insert(stateHash(nextState)).inserted else { continue } + let nextScore = score(nextState) + storage.nodes.append( + Node( + state: nextState, + parent: nodeIndex, + action: action, + depth: node.depth + 1, + score: nextScore ) - order += 1 - // Best-first on score, shallow bias so equal outcomes prefer short lines. - heap.push( - HeapEntry( - priority: nextScore * 4 - (node.depth + 1), - order: order, - index: nodes.count - 1 - ) + ) + storage.order += 1 + storage.heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: storage.order, + index: storage.nodes.count - 1 ) - } + ) } - - guard let best else { return nil } - return firstAction(leadingTo: best.index, nodes: nodes) } -} -// MARK: - Search internals - -private extension KlondikePlanner { enum Action { case move(Selection, Destination) case stockTap @@ -201,41 +259,47 @@ private extension KlondikePlanner { switch action { case .move(let selection, let destination): var nextState = state - switch selection.source { - case .waste: - _ = nextState.waste.popLast() - if stockDrawCount == DrawMode.one.rawValue { - nextState.wasteDrawCount = min(1, nextState.waste.count) - } else { - nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) - } - case .freeCell(let slot): - nextState.freeCells[slot] = nil - case .foundation(let pile): - _ = nextState.foundations[pile].popLast() - case .tableau(let pile, let index): - nextState.tableau[pile].removeSubrange(index.. Bool { + switch destination { + case .foundation(let index): + guard cards.count == 1, let card = cards.first else { return false } + state.foundations[index].append(card) + case .tableau(let index): + state.tableau[index].append(contentsOf: cards) + case .freeCell(let index): + guard cards.count == 1, let card = cards.first else { return false } + state.freeCells[index] = card + } + return true + } + /// Mirrors drawFromStock / recycleWaste in the session. static func stockTapState(from state: GameState, stockDrawCount: Int) -> GameState? { var nextState = state diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 616cdb1..45f1d04 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -97,35 +97,8 @@ enum AutoMoveAdvisor { guard legalDestinations(for: selection, in: state).contains(destination) else { return nil } var nextState = state - - switch selection.source { - case .waste: - _ = nextState.waste.popLast() - if stockDrawCount == DrawMode.one.rawValue { - nextState.wasteDrawCount = min(1, nextState.waste.count) - } else { - nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) - } - case .freeCell(let slot): - nextState.freeCells[slot] = nil - case .foundation(let pile): - _ = nextState.foundations[pile].popLast() - case .tableau(let pile, let index): - nextState.tableau[pile].removeSubrange(index.. Bool { + switch destination { + case .foundation(let index): + guard cards.count == 1, let card = cards.first else { return false } + state.foundations[index].append(card) + case .tableau(let index): + state.tableau[index].append(contentsOf: cards) + case .freeCell(let index): + guard cards.count == 1, let card = cards.first else { return false } + state.freeCells[index] = card + } + return true + } + + static func matchesWaste(_ selection: Selection, in state: GameState) -> Bool { + guard selection.cards.count == 1, let topWaste = state.waste.last else { return false } + return topWaste.id == selection.cards[0].id + } + + static func matchesFreeCell(_ selection: Selection, slot: Int, in state: GameState) -> Bool { + guard selection.cards.count == 1, + state.freeCells.indices.contains(slot), + let card = state.freeCells[slot] else { return false } + return card.id == selection.cards[0].id + } + + static func matchesFoundation(_ selection: Selection, pile: Int, in state: GameState) -> Bool { + guard selection.cards.count == 1, + state.foundations.indices.contains(pile), + let card = state.foundations[pile].last else { return false } + return card.id == selection.cards[0].id + } + + static func matchesTableau( + _ selection: Selection, + pile: Int, + index: Int, + in state: GameState + ) -> Bool { + guard state.tableau.indices.contains(pile) else { return false } + let sourcePile = state.tableau[pile] + guard sourcePile.indices.contains(index) else { return false } + let selectedCards = Array(sourcePile[index...]) + guard selectedCards.count == selection.cards.count else { return false } + return zip(selectedCards, selection.cards).allSatisfy { $0.id == $1.id } + } static func variantAllowsTableauTransfer( selection: Selection, destinationTableauIndex: Int, diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 0a72671..45d78a0 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -1,7 +1,6 @@ import Foundation import SwiftData - @Model final class SavedGameRecord { static let currentRecordKey = "current" @@ -140,93 +139,75 @@ struct SavedGamePayload: Codable { guard schemaVersion == Self.currentSchemaVersion else { return nil } guard state.isValidForPersistence else { return nil } - let sanitizedStockDrawCount: Int = { - if state.variant == .klondike { - return DrawMode(rawValue: stockDrawCount)?.rawValue ?? DrawMode.three.rawValue - } - return DrawMode.three.rawValue - }() - let sanitizedMovesCount = max(0, movesCount) - let sanitizedScore = Scoring.clamped(score) - let sanitizedSavedAt = min(savedAt, now) + let sanitizedStockDrawCount = sanitizedDrawCount(stockDrawCount) + let sanitizedScoringDrawCount = sanitizedDrawCount( + scoringDrawCount, + fallback: sanitizedStockDrawCount + ) let sanitizedStartedAt = min(gameStartedAt, now) - let sanitizedScoringDrawCount: Int = { - if state.variant == .klondike { - return DrawMode(rawValue: scoringDrawCount)?.rawValue ?? sanitizedStockDrawCount - } - return DrawMode.three.rawValue - }() let sanitizedPauseStartedAt = pauseStartedAt .map { min($0, now) } .flatMap { $0 >= sanitizedStartedAt ? $0 : nil } - let sanitizedFinalElapsedSeconds: Int? = { - guard hasAppliedTimeBonus else { return nil } - return finalElapsedSeconds.map { max(0, $0) } - }() - let sanitizedHasStartedTrackedGame = hasStartedTrackedGame - let sanitizedIsCurrentGameFinalized = sanitizedHasStartedTrackedGame ? isCurrentGameFinalized : false - let sanitizedHintRequestsInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, hintRequestsInCurrentGame) : 0 - let sanitizedUndosUsedInCurrentGame = sanitizedHasStartedTrackedGame ? max(0, undosUsedInCurrentGame) : 0 - let sanitizedUsedRedealInCurrentGame = sanitizedHasStartedTrackedGame ? usedRedealInCurrentGame : false - let sanitizedHistory = history - .filter { $0.movesCount >= 0 && $0.state.isValidForPersistence } - .map { snapshot in - GameSnapshot( - state: snapshot.state, - movesCount: snapshot.movesCount, - score: Scoring.clamped(snapshot.score), - hasAppliedTimeBonus: snapshot.hasAppliedTimeBonus, - undoContext: snapshot.undoContext - ) - } - .suffix(SolitaireViewModel.maxUndoHistoryCount) - - var sanitizedState = state - if sanitizedState.variant == .klondike { - sanitizedState.wasteDrawCount = min( - max(0, sanitizedState.wasteDrawCount), - min(sanitizedStockDrawCount, sanitizedState.waste.count) - ) - } else { - sanitizedState.wasteDrawCount = 0 - } - - let sanitizedRedealState: GameState? = { - guard var baseState = redealState, baseState.isValidForPersistence else { return nil } - if baseState.variant == .klondike { - baseState.wasteDrawCount = min( - max(0, baseState.wasteDrawCount), - min(sanitizedStockDrawCount, baseState.waste.count) - ) - } else { - baseState.wasteDrawCount = 0 - } - return baseState - }() return SavedGamePayload( schemaVersion: schemaVersion, - savedAt: sanitizedSavedAt, - state: sanitizedState, - movesCount: sanitizedMovesCount, - score: sanitizedScore, + savedAt: min(savedAt, now), + state: sanitized(state, stockDrawCount: sanitizedStockDrawCount), + movesCount: max(0, movesCount), + score: Scoring.clamped(score), gameStartedAt: sanitizedStartedAt, pauseStartedAt: sanitizedPauseStartedAt, hasAppliedTimeBonus: hasAppliedTimeBonus, - finalElapsedSeconds: sanitizedFinalElapsedSeconds, + finalElapsedSeconds: hasAppliedTimeBonus ? finalElapsedSeconds : nil, stockDrawCount: sanitizedStockDrawCount, scoringDrawCount: sanitizedScoringDrawCount, - history: Array(sanitizedHistory), - redealState: sanitizedRedealState, - hasStartedTrackedGame: sanitizedHasStartedTrackedGame, - isCurrentGameFinalized: sanitizedIsCurrentGameFinalized, - hintRequestsInCurrentGame: sanitizedHintRequestsInCurrentGame, - undosUsedInCurrentGame: sanitizedUndosUsedInCurrentGame, - usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame + history: sanitizedHistory(), + redealState: redealState.flatMap { + $0.isValidForPersistence ? sanitized($0, stockDrawCount: sanitizedStockDrawCount) : nil + }, + hasStartedTrackedGame: hasStartedTrackedGame, + isCurrentGameFinalized: hasStartedTrackedGame && isCurrentGameFinalized, + hintRequestsInCurrentGame: hasStartedTrackedGame ? hintRequestsInCurrentGame : 0, + undosUsedInCurrentGame: hasStartedTrackedGame ? undosUsedInCurrentGame : 0, + usedRedealInCurrentGame: hasStartedTrackedGame && usedRedealInCurrentGame ) } -} + private func sanitizedDrawCount(_ count: Int, fallback: Int = DrawMode.three.rawValue) -> Int { + guard state.variant == .klondike else { return DrawMode.three.rawValue } + return DrawMode(rawValue: count)?.rawValue ?? fallback + } + + private func sanitized(_ source: GameState, stockDrawCount: Int) -> GameState { + var result = source + if result.variant == .klondike { + result.wasteDrawCount = min( + max(0, result.wasteDrawCount), + min(stockDrawCount, result.waste.count) + ) + } else { + result.wasteDrawCount = 0 + } + return result + } + + private func sanitizedHistory() -> [GameSnapshot] { + Array( + history + .filter { $0.movesCount >= 0 && $0.state.isValidForPersistence } + .map { snapshot in + GameSnapshot( + state: snapshot.state, + movesCount: snapshot.movesCount, + score: Scoring.clamped(snapshot.score), + hasAppliedTimeBonus: snapshot.hasAppliedTimeBonus, + undoContext: snapshot.undoContext + ) + } + .suffix(SolitaireViewModel.maxUndoHistoryCount) + ) + } +} enum GamePersistenceError: Error { case invalidPayload @@ -271,6 +252,16 @@ enum GamePersistence { } } +struct CompletedGame { + let didWin: Bool + let elapsedSeconds: Int + let finalScore: Int + let drawCount: Int + let hintsUsedInGame: Int + let undosUsedInGame: Int + let usedRedealInGame: Bool +} + struct GameStatistics: Codable, Equatable { static let currentSchemaVersion = 1 @@ -321,7 +312,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, @@ -418,24 +412,16 @@ struct GameStatistics: Codable, Equatable { ) } - mutating func recordCompletedGame( - didWin: Bool, - elapsedSeconds: Int, - finalScore: Int, - drawCount: Int, - hintsUsedInGame: Int, - undosUsedInGame: Int, - usedRedealInGame: Bool - ) { - let sanitizedElapsed = max(0, elapsedSeconds) - let sanitizedScore = max(0, finalScore) - let sanitizedHintsUsedInGame = max(0, hintsUsedInGame) - let sanitizedUndosUsedInGame = max(0, undosUsedInGame) + mutating func recordCompletedGame(_ game: CompletedGame) { + let sanitizedElapsed = max(0, game.elapsedSeconds) + let sanitizedScore = max(0, game.finalScore) + let sanitizedHintsUsedInGame = max(0, game.hintsUsedInGame) + let sanitizedUndosUsedInGame = max(0, game.undosUsedInGame) gamesPlayed = addingSafely(gamesPlayed, 1) totalTimeSeconds = addingSafely(totalTimeSeconds, sanitizedElapsed) - guard didWin else { return } + guard game.didWin else { return } gamesWon = min(gamesPlayed, addingSafely(gamesWon, 1)) if let bestTimeSeconds { @@ -444,15 +430,15 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds = sanitizedElapsed } - if drawCount == DrawMode.one.rawValue { + if game.drawCount == DrawMode.one.rawValue { highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) - } else if drawCount == DrawMode.three.rawValue { + } else if game.drawCount == DrawMode.three.rawValue { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) } let isCleanWin = sanitizedHintsUsedInGame == 0 && sanitizedUndosUsedInGame == 0 - && !usedRedealInGame + && !game.usedRedealInGame if isCleanWin { cleanWins = min(gamesWon, addingSafely(cleanWins, 1)) } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 9af52f7..1d650df 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -213,6 +213,9 @@ final class SolitaireViewModel { return true } +} + +extension SolitaireViewModel { func newGame(variant: GameVariant? = nil, drawMode: DrawMode = .three) { finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() @@ -559,66 +562,30 @@ extension SolitaireViewModel { func tryMoveSelection(to destination: Destination) -> Bool { guard let selection, let movingCard = selection.cards.first else { return false } - + guard canDrop(to: destination) else { return false } + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .moveSelection, + cardIDs: selection.cards.map(\.id) + ) + ) + removeSelection(selection) 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 } - clearHint() - pushHistory( - undoContext: UndoAnimationContext( - action: .moveSelection, - cardIDs: selection.cards.map(\.id) - ) - ) - removeSelection(selection) state.foundations[index].append(movingCard) - movesCount += 1 - applyScore(for: selection.source, destination: .foundation(index)) - applyTimeBonusIfWon() - self.selection = nil - SoundManager.shared.play(.cardPlaced) - refreshAutoFinishAvailability() - return true - case .tableau(let index): - guard canDrop(to: destination) else { return false } - clearHint() - pushHistory( - undoContext: UndoAnimationContext( - action: .moveSelection, - cardIDs: selection.cards.map(\.id) - ) - ) - removeSelection(selection) state.tableau[index].append(contentsOf: selection.cards) - movesCount += 1 - applyScore(for: selection.source, destination: .tableau(index)) - applyTimeBonusIfWon() - self.selection = nil - SoundManager.shared.play(.cardPlaced) - refreshAutoFinishAvailability() - return true - case .freeCell(let index): - guard canDrop(to: destination) else { return false } - clearHint() - pushHistory( - undoContext: UndoAnimationContext( - action: .moveSelection, - cardIDs: selection.cards.map(\.id) - ) - ) - removeSelection(selection) state.freeCells[index] = movingCard - movesCount += 1 - applyScore(for: selection.source, destination: .freeCell(index)) - applyTimeBonusIfWon() - self.selection = nil - SoundManager.shared.play(.cardPlaced) - refreshAutoFinishAvailability() - return true } + movesCount += 1 + applyScore(for: selection.source, destination: destination) + applyTimeBonusIfWon() + self.selection = nil + SoundManager.shared.play(.cardPlaced) + refreshAutoFinishAvailability() + return true } func removeSelection(_ selection: Selection) { @@ -701,13 +668,15 @@ extension SolitaireViewModel { let elapsedSeconds = elapsedActiveSeconds(at: endedAt) GameStatisticsStore.update(for: state.variant) { stats in stats.recordCompletedGame( - didWin: didWin, - elapsedSeconds: elapsedSeconds, - finalScore: score, - drawCount: statisticsDrawCountForCurrentVariant(), - hintsUsedInGame: hintRequestsInCurrentGame, - undosUsedInGame: undosUsedInCurrentGame, - usedRedealInGame: usedRedealInCurrentGame + CompletedGame( + didWin: didWin, + elapsedSeconds: elapsedSeconds, + finalScore: score, + drawCount: statisticsDrawCountForCurrentVariant(), + hintsUsedInGame: hintRequestsInCurrentGame, + undosUsedInGame: undosUsedInCurrentGame, + usedRedealInGame: usedRedealInCurrentGame + ) ) } isCurrentGameFinalized = true diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 99216df..cb30b23 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -6,6 +6,11 @@ import Foundation /// never dead-ends while a legal move exists. Destination preference is deterministic: /// higher tier wins, then a larger resulting build, then the lowest pile index. enum TapMovePolicy { + struct Move { + let selection: Selection + let destination: Destination + } + static func bestDestination(for selection: Selection, in state: GameState) -> Destination? { // Tapping a foundation card only selects it; pulling cards back off the // foundation is deliberate enough to require a drag. @@ -14,11 +19,15 @@ enum TapMovePolicy { let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) guard !destinations.isEmpty else { return nil } - var best: (destination: Destination, priority: Priority)? + var best: RankedDestination? for destination in destinations { let priority = priority(of: destination, for: selection, in: state) - if best == nil || priority.isBetter(than: best!.priority) { - best = (destination, priority) + if let currentBest = best { + if priority.isBetter(than: currentBest.priority) { + best = RankedDestination(destination: destination, priority: priority) + } + } else { + best = RankedDestination(destination: destination, priority: priority) } } return best?.destination @@ -27,18 +36,22 @@ enum TapMovePolicy { /// The single best legal move across every pickable selection, using the same /// destination preferences as taps. Used as the hint of last resort when the /// FreeCell solver can't find a winning line. - static func bestMove(in state: GameState) -> (selection: Selection, destination: Destination)? { - var best: (selection: Selection, destination: Destination, priority: Priority)? + static func bestMove(in state: GameState) -> Move? { + var best: RankedMove? for selection in AutoMoveAdvisor.candidateSelections(in: state) { 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) { - best = (selection, destination, priority) + if let currentBest = best { + if priority.isBetter(than: currentBest.priority) { + best = RankedMove(selection: selection, destination: destination, priority: priority) + } + } else { + best = RankedMove(selection: selection, destination: destination, priority: priority) } } } - return best.map { ($0.selection, $0.destination) } + return best.map { Move(selection: $0.selection, destination: $0.destination) } } /// A card is safe to send to the foundation when doing so can never cost the game: @@ -68,6 +81,17 @@ enum TapMovePolicy { } private extension TapMovePolicy { + struct RankedDestination { + let destination: Destination + let priority: Priority + } + + struct RankedMove { + let selection: Selection + let destination: Destination + let priority: Priority + } + struct Priority { let tier: Int let buildLength: Int diff --git a/ComputerSolitaire/Interaction/DragDropCoordinator.swift b/ComputerSolitaire/Interaction/DragDropCoordinator.swift index fd9e931..63957da 100644 --- a/ComputerSolitaire/Interaction/DragDropCoordinator.swift +++ b/ComputerSolitaire/Interaction/DragDropCoordinator.swift @@ -15,9 +15,10 @@ enum DragDropCoordinator { guard geometry.hitFrame.contains(location) else { continue } let candidateCanDrop = canDrop(target) - let dx = geometry.snapFrame.midX - location.x - let dy = geometry.snapFrame.midY - location.y - let candidateDistanceSquared = dx * dx + dy * dy + let horizontalDistance = geometry.snapFrame.midX - location.x + let verticalDistance = geometry.snapFrame.midY - location.y + let candidateDistanceSquared = horizontalDistance * horizontalDistance + + verticalDistance * verticalDistance let candidateSortKey = dropTargetSortKey(target) let shouldReplaceBest: Bool diff --git a/ComputerSolitaire/Views/AboutView.swift b/ComputerSolitaire/Views/AboutView.swift index d6601df..3e841de 100644 --- a/ComputerSolitaire/Views/AboutView.swift +++ b/ComputerSolitaire/Views/AboutView.swift @@ -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 { @@ -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) @@ -54,13 +57,15 @@ struct AboutView: View { } .accessibilityElement(children: .combine) - VStack(spacing: 8) { - Divider() - .padding(.horizontal, 24) + if let githubURL = AppInfo.githubURL { + VStack(spacing: 8) { + Divider() + .padding(.horizontal, 24) - Link("GitHub", destination: AppInfo.githubURL) - .buttonStyle(.bordered) - .controlSize(.regular) + Link("GitHub", destination: githubURL) + .buttonStyle(.bordered) + .controlSize(.regular) + } } } .frame(maxWidth: .infinity) @@ -83,7 +88,7 @@ struct AboutView: View { @Environment(\.openURL) private var openURL private var appVersion: String { - Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" } private var copyrightYear: String { @@ -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") @@ -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) diff --git a/ComputerSolitaire/Views/AppIconPickerView.swift b/ComputerSolitaire/Views/AppIconPickerView.swift index a7c5f34..aecd725 100644 --- a/ComputerSolitaire/Views/AppIconPickerView.swift +++ b/ComputerSolitaire/Views/AppIconPickerView.swift @@ -52,6 +52,7 @@ struct AppIconPreviewView: View { .overlay { shape.stroke(Color.primary.opacity(0.12), lineWidth: 0.5) } + .accessibilityHidden(true) } } @@ -86,67 +87,69 @@ struct AppIconPickerView: View { let isSelected = selection == icon return Button { - guard !isSelected else { return } - HapticManager.shared.play(.settingsSelection) - let previous = selection - withAnimation(.smooth(duration: 0.3)) { - selection = icon - } - UIApplication.shared.setAlternateIconName(icon.alternateIconName) { error in - guard error != nil else { return } - Task { @MainActor in - withAnimation(.smooth(duration: 0.3)) { - selection = previous - } - } - } + select(icon, isSelected: isSelected) } label: { - VStack(spacing: 6) { - AppIconPreviewView(icon: icon) + iconTileContent(icon, isSelected: isSelected) + } + .buttonStyle(.plain) + .accessibilityLabel("\(icon.name) app icon") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } - Text(icon.name) - .font(.caption.weight(.bold)) - } - .padding(.vertical, 12) - .padding(.horizontal, 10) - .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? .thickMaterial : .thinMaterial) - .shadow( - color: .black.opacity(isSelected ? 0.12 : 0.04), - radius: isSelected ? 8 : 2, - y: isSelected ? 4 : 1 - ) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke( - Color.accentColor.opacity(isSelected ? 1 : 0), - lineWidth: 2.5 - ) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke( - Color.primary.opacity(isSelected ? 0 : 0.1), - lineWidth: 1 - ) + private func iconTileContent(_ icon: AppIcon, isSelected: Bool) -> some View { + VStack(spacing: 6) { + AppIconPreviewView(icon: icon) + Text(icon.name) + .font(.caption.weight(.bold)) + } + .padding(.vertical, 12) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? .thickMaterial : .thinMaterial) + .shadow( + color: .black.opacity(isSelected ? 0.12 : 0.04), + radius: isSelected ? 8 : 2, + y: isSelected ? 4 : 1 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.accentColor.opacity(isSelected ? 1 : 0), lineWidth: 2.5) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(isSelected ? 0 : 0.1), lineWidth: 1) + } + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .padding(6) + .accessibilityHidden(true) } - .overlay(alignment: .topTrailing) { - if isSelected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18, weight: .semibold)) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, Color.accentColor) - .padding(6) + } + .contentShape(Rectangle()) + } + + private func select(_ icon: AppIcon, isSelected: Bool) { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + let previous = selection + withAnimation(.smooth(duration: 0.3)) { + selection = icon + } + UIApplication.shared.setAlternateIconName(icon.alternateIconName) { error in + guard error != nil else { return } + Task { @MainActor in + withAnimation(.smooth(duration: 0.3)) { + selection = previous } } - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .accessibilityLabel("\(icon.name) app icon") - .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift index 9222829..a45ee56 100644 --- a/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift @@ -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 { @@ -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) } } } @@ -203,22 +205,22 @@ private struct ClassicCardBackPattern: View { .stroke(Color.white.opacity(0.18), lineWidth: 1) Path { path in let step: CGFloat = 10 - var x: CGFloat = 0 - while x < size.width { - path.move(to: CGPoint(x: x, y: 0)) - path.addLine(to: CGPoint(x: x, y: size.height)) - x += step + var horizontalPosition: CGFloat = 0 + while horizontalPosition < size.width { + path.move(to: CGPoint(x: horizontalPosition, y: 0)) + path.addLine(to: CGPoint(x: horizontalPosition, y: size.height)) + horizontalPosition += step } } .stroke(Color.white.opacity(0.12), lineWidth: 1) Path { path in let step: CGFloat = 10 - var y: CGFloat = 0 - while y < size.height { - path.move(to: CGPoint(x: 0, y: y)) - path.addLine(to: CGPoint(x: size.width, y: y)) - y += step + var verticalPosition: CGFloat = 0 + while verticalPosition < size.height { + path.move(to: CGPoint(x: 0, y: verticalPosition)) + path.addLine(to: CGPoint(x: size.width, y: verticalPosition)) + verticalPosition += step } } .stroke(Color.white.opacity(0.08), lineWidth: 1) diff --git a/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift index 603b569..e765771 100644 --- a/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift @@ -1,5 +1,9 @@ import SwiftUI +// TODO: Separate the static sprite catalog from the pixel renderer without +// fragmenting the card-style UI, then remove this exception. +// swiftlint:disable file_length + enum PixelCardStyle { static let info = CardStyleInfo(title: "Pixel", subtitle: "8-bit Retro") } @@ -89,13 +93,13 @@ extension PixelPalette { struct PixelCardShape: InsettableShape { /// One virtual pixel unit (card width / PixelCardArt.gridWidth). - let px: CGFloat + let pixelSize: CGFloat var insetAmount: CGFloat = 0 func path(in rect: CGRect) -> Path { let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) let maxCorner = min(insetRect.width, insetRect.height) / 2 - let step = max(1, min(px, floor(maxCorner / 3))) + let step = max(1, min(pixelSize, floor(maxCorner / 3))) let corner = step * 3 var path = Path() @@ -159,16 +163,16 @@ 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 + let spriteWidth = lines.map(\.count).max() ?? 0 cells = lines.map { line in var row = line.map { map[$0] ?? 0 } - while row.count < w { row.append(0) } + while row.count < spriteWidth { row.append(0) } return row } - width = w + width = spriteWidth height = cells.count } } @@ -386,14 +390,20 @@ enum PixelSprites { #.#.. #..#. #...# - """), + """) ] static func rank(_ rank: Rank) -> PixelSprite { - ranks[rank.label] ?? ranks["A"]! + guard let sprite = ranks[rank.label] else { + preconditionFailure("Missing pixel-art glyph for rank \(rank.label)") + } + return sprite } - // Face card portraits — 28x35, outlined forms in the classic style. +} + +// Face card portraits — 28x35, outlined forms in the classic style. +extension PixelSprites { static let king = PixelSprite(""" ........G..G..G..G..G....... .......KGGGGGGGGGGGGGGK..... @@ -525,23 +535,41 @@ enum PixelCardArt { /// aspect ratio this yields an exact 40x58 pixel grid. static let gridWidth: CGFloat = 40 + struct SpritePlacement { + let origin: CGPoint + let unit: CGFloat + var scale: CGFloat = 1 + var flipped = false + } + + private static func placement( + x horizontalPosition: CGFloat, + y verticalPosition: CGFloat, + unit: CGFloat, + scale: CGFloat = 1, + flipped: Bool = false + ) -> SpritePlacement { + SpritePlacement( + origin: CGPoint(x: horizontalPosition, y: verticalPosition), + unit: unit, + scale: scale, + flipped: flipped + ) + } + /// Draws a sprite whose origin is given in grid units. `scale` is an /// integer multiplier that keeps the art on the same pixel grid. static func draw( _ sprite: PixelSprite, in context: GraphicsContext, - x: CGFloat, - y: CGFloat, - unit: CGFloat, - scale: CGFloat = 1, - flipped: Bool = false, + placement: SpritePlacement, color: (PixelInk) -> Color? ) { for row in 0.. Color? = { pixelInk in switch pixelInk { @@ -592,15 +624,7 @@ enum PixelCardArt { } } - // Corner indices: rank top-left / suit top-right, mirrored below. - draw(rankSprite, in: context, x: 3, y: 3, unit: unit, color: solid) - draw(suitSprite, in: context, x: gridWidth - 3 - 7, y: 3, unit: unit, color: solid) - draw( - rankSprite, in: context, - x: gridWidth - 3 - CGFloat(rankSprite.width), y: gridH - 3 - 7, - unit: unit, flipped: true, color: solid - ) - draw(suitSprite, in: context, x: 3, y: gridH - 3 - 7, unit: unit, flipped: true, color: solid) + drawCornerIndices(card: card, in: context, gridHeight: gridH, unit: unit) if let portrait = PixelSprites.portrait(for: card.rank) { drawPortrait(portrait, card: card, in: context, centerY: centerY, unit: unit) @@ -614,21 +638,62 @@ enum PixelCardArt { } draw( suitSprite, in: context, - x: (gridWidth - 14) / 2, y: centerY - 7, - unit: unit, scale: 2, color: shaded + placement: placement(x: (gridWidth - 14) / 2, y: centerY - 7, unit: unit, scale: 2), + color: shaded ) } else { let pipSprite = PixelSprites.pipSuit(card.suit) for pip in pipPlacements(count: card.rank.rawValue) { draw( pipSprite, in: context, - x: pip.x, y: centerY + pip.dy - 2.5, - unit: unit, flipped: pip.dy > 0, color: solid + placement: placement( + x: pip.originX, + y: centerY + pip.verticalOffset - 2.5, + unit: unit, + flipped: pip.verticalOffset > 0 + ), + color: solid ) } } } + private static func drawCornerIndices( + card: Card, + in context: GraphicsContext, + gridHeight: CGFloat, + unit: CGFloat + ) { + let rankSprite = PixelSprites.rank(card.rank) + let suitSprite = PixelSprites.suit(card.suit) + let ink = PixelPalette.suitColor(for: card.suit) + let solid: (PixelInk) -> Color? = { $0 == .ink || $0 == .inkHi ? ink : nil } + draw(rankSprite, in: context, placement: placement(x: 3, y: 3, unit: unit), color: solid) + draw( + suitSprite, + in: context, + placement: placement(x: gridWidth - 10, y: 3, unit: unit), + color: solid + ) + draw( + rankSprite, + in: context, + placement: placement( + x: gridWidth - 3 - CGFloat(rankSprite.width), + y: gridHeight - 10, + unit: unit, + flipped: true + ), + color: solid + ) + draw( + suitSprite, + in: context, + placement: placement(x: 3, y: gridHeight - 10, unit: unit, flipped: true), + color: solid + ) + } + private static func drawPortrait( _ portrait: PixelSprite, card: Card, @@ -640,28 +705,31 @@ enum PixelCardArt { let originX = (gridWidth - CGFloat(portrait.width)) / 2 let originY = centerY - CGFloat(portrait.height) / 2 - draw(portrait, in: context, x: originX, y: originY, unit: unit) { pixelInk in - switch pixelInk { - case .outlineDark: return PixelPalette.outline - case .skin: return PixelPalette.skinTone - case .skinShade: return PixelPalette.skinShadow - case .gold: return PixelPalette.gold - case .robe: return isRed ? PixelPalette.robeRed : PixelPalette.robeBlue - case .robeDark: return isRed ? PixelPalette.robeRedDark : PixelPalette.robeBlueDark - case .hair: return PixelPalette.hair - case .white: return PixelPalette.ermine - case .accent: return PixelPalette.accent - case .altRobe: return isRed ? PixelPalette.robeBlue : PixelPalette.robeRed - case .ink, .inkHi, .none: return nil - } - } + let colors: [PixelInk: Color] = [ + .outlineDark: PixelPalette.outline, + .skin: PixelPalette.skinTone, + .skinShade: PixelPalette.skinShadow, + .gold: PixelPalette.gold, + .robe: isRed ? PixelPalette.robeRed : PixelPalette.robeBlue, + .robeDark: isRed ? PixelPalette.robeRedDark : PixelPalette.robeBlueDark, + .hair: PixelPalette.hair, + .white: PixelPalette.ermine, + .accent: PixelPalette.accent, + .altRobe: isRed ? PixelPalette.robeBlue : PixelPalette.robeRed + ] + draw( + portrait, + in: context, + placement: placement(x: originX, y: originY, unit: unit), + color: { colors[$0] } + ) } // MARK: Pips struct PipPlacement { - let x: CGFloat // sprite origin column - let dy: CGFloat // sprite center offset from card center + let originX: CGFloat + let verticalOffset: CGFloat } private static let leftCol: CGFloat = 10.5 @@ -669,30 +737,37 @@ enum PixelCardArt { private static let rightCol: CGFloat = 24.5 static func pipPlacements(count: Int) -> [PipPlacement] { - func cols(_ dys: [CGFloat]) -> [PipPlacement] { - dys.flatMap { dy in - [PipPlacement(x: leftCol, dy: dy), PipPlacement(x: rightCol, dy: dy)] + func columns(_ verticalOffsets: [CGFloat]) -> [PipPlacement] { + verticalOffsets.flatMap { verticalOffset in + [ + PipPlacement(originX: leftCol, verticalOffset: verticalOffset), + PipPlacement(originX: rightCol, verticalOffset: verticalOffset) + ] } } - func mid(_ dys: [CGFloat]) -> [PipPlacement] { - dys.map { PipPlacement(x: midCol, dy: $0) } + func middle(_ verticalOffsets: [CGFloat]) -> [PipPlacement] { + verticalOffsets.map { PipPlacement(originX: midCol, verticalOffset: $0) } } switch count { - case 2: return mid([-10, 10]) - case 3: return mid([-12, 0, 12]) - case 4: return cols([-11, 11]) - case 5: return cols([-11, 11]) + mid([0]) - case 6: return cols([-11, 0, 11]) - case 7: return cols([-11, 0, 11]) + mid([-5.5]) - case 8: return cols([-12, -4, 4, 12]) - case 9: return cols([-12, -4, 4, 12]) + mid([0]) - case 10: return cols([-12, -4, 4, 12]) + mid([-8, 8]) + case 2: return middle([-10, 10]) + case 3: return middle([-12, 0, 12]) + case 4: return columns([-11, 11]) + case 5: return columns([-11, 11]) + middle([0]) + case 6: return columns([-11, 0, 11]) + case 7: return columns([-11, 0, 11]) + middle([-5.5]) + case 8: return columns([-12, -4, 4, 12]) + case 9: return columns([-12, -4, 4, 12]) + middle([0]) + case 10: return columns([-12, -4, 4, 12]) + middle([-8, 8]) default: return [] } } - // MARK: Back +} + +// MARK: Back + +extension PixelCardArt { /// Woven-lattice card back: a bright single-pixel frame around a diagonal /// weave in the colorway's muted tone with mid-tone intersections. @@ -703,36 +778,52 @@ enum PixelCardArt { let gridH = size.height / unit let lastRow = Int(gridH.rounded(.down)) - 3 - // Inner bright frame, one unit thick, inset 2 from the edge. - let frame = colorway.bright - fillCells(context, x: 2, y: 2, w: gridWidth - 4, h: 1, unit: unit, color: frame) - fillCells(context, x: 2, y: gridH - 3, w: gridWidth - 4, h: 1, unit: unit, color: frame) - fillCells(context, x: 2, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) - fillCells(context, x: gridWidth - 3, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) + drawBackFrame(in: context, gridHeight: gridH, unit: unit, color: colorway.bright) // Diagonal weave, phase-locked to the card center. let centerX = Int(gridWidth) / 2 let centerYCell = Int((gridH / 2).rounded(.down)) - for cy in 4...(lastRow - 1) { - for cx in 4...Int(gridWidth) - 5 { - let sum = (cx - centerX) + (cy - centerYCell) - let diff = (cx - centerX) - (cy - centerYCell) + for row in 4...(lastRow - 1) { + for column in 4...Int(gridWidth) - 5 { + let sum = (column - centerX) + (row - centerYCell) + let diff = (column - centerX) - (row - centerYCell) let onSum = ((sum % 6) + 6) % 6 == 0 let onDiff = ((diff % 6) + 6) % 6 == 0 if onSum && onDiff { fillCells( - context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, - unit: unit, color: colorway.mid + context, + gridRect: CGRect(x: CGFloat(column), y: CGFloat(row), width: 1, height: 1), + unit: unit, + color: colorway.mid ) } else if onSum || onDiff { fillCells( - context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, - unit: unit, color: colorway.muted + context, + gridRect: CGRect(x: CGFloat(column), y: CGFloat(row), width: 1, height: 1), + unit: unit, + color: colorway.muted ) } } } } + + private static func drawBackFrame( + in context: GraphicsContext, + gridHeight: CGFloat, + unit: CGFloat, + color: Color + ) { + let frameRects = [ + CGRect(x: 2, y: 2, width: gridWidth - 4, height: 1), + CGRect(x: 2, y: gridHeight - 3, width: gridWidth - 4, height: 1), + CGRect(x: 2, y: 3, width: 1, height: gridHeight - 6), + CGRect(x: gridWidth - 3, y: 3, width: 1, height: gridHeight - 6) + ] + for gridRect in frameRects { + fillCells(context, gridRect: gridRect, unit: unit, color: color) + } + } } // MARK: - Card Front @@ -744,7 +835,7 @@ struct PixelCardFrontView: View { var body: some View { let unit = cardSize.width / PixelCardArt.gridWidth - let shape = PixelCardShape(px: unit) + let shape = PixelCardShape(pixelSize: unit) let borderColor = isSelected ? Color.yellow.opacity(0.92) : PixelPalette.outline let borderWidth = isSelected ? max(2, unit * 1.6) : max(0.8, unit) @@ -783,7 +874,7 @@ struct PixelCardBackView: View { var body: some View { let unit = cardSize.width / PixelCardArt.gridWidth - let shape = PixelCardShape(px: unit) + let shape = PixelCardShape(pixelSize: unit) let borderColor = isSelected ? Color.yellow.opacity(0.88) : PixelPalette.outline let borderWidth = isSelected ? max(2, unit * 1.6) : max(0.8, unit) let colorway = PixelBackColorway.matching(.from(rawValue: cardBackColorRawValue)) diff --git a/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift index 034b3bc..03cdda1 100644 --- a/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift +++ b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift @@ -11,22 +11,23 @@ enum SimpleCardStyle { /// Royal artwork anchored to the bottom-right corner of the face, replacing /// the center suit glyph. Cards without art keep the plain glyph face. private enum SimpleCardArt { + private static let imageNames: [Rank: [Suit: String]] = [ + .queen: [ + .hearts: "Simple/QueenOfHearts", .clubs: "Simple/QueenOfClubs", + .spades: "Simple/QueenOfSpades", .diamonds: "Simple/QueenOfDiamonds" + ], + .jack: [ + .hearts: "Simple/JackOfHearts", .clubs: "Simple/JackOfClubs", + .spades: "Simple/JackOfSpades", .diamonds: "Simple/JackOfDiamonds" + ], + .king: [ + .hearts: "Simple/KingOfHearts", .clubs: "Simple/KingOfClubs", + .spades: "Simple/KingOfSpades", .diamonds: "Simple/KingOfDiamonds" + ] + ] + static func imageName(for card: Card) -> String? { - switch (card.rank, card.suit) { - case (.queen, .hearts): "Simple/QueenOfHearts" - case (.queen, .clubs): "Simple/QueenOfClubs" - case (.queen, .spades): "Simple/QueenOfSpades" - case (.queen, .diamonds): "Simple/QueenOfDiamonds" - case (.jack, .hearts): "Simple/JackOfHearts" - case (.jack, .clubs): "Simple/JackOfClubs" - case (.jack, .spades): "Simple/JackOfSpades" - case (.jack, .diamonds): "Simple/JackOfDiamonds" - case (.king, .hearts): "Simple/KingOfHearts" - case (.king, .clubs): "Simple/KingOfClubs" - case (.king, .spades): "Simple/KingOfSpades" - case (.king, .diamonds): "Simple/KingOfDiamonds" - default: nil - } + imageNames[card.rank]?[card.suit] } } @@ -91,7 +92,8 @@ struct SimpleCardFrontView: View { } .foregroundStyle(inkColor) .padding(cardSize.width * 0.08) - .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.top) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.top) + .accessibilityHidden(true) if let artName = SimpleCardArt.imageName(for: card) { // Royal figure planted in the bottom-right corner, dress and @@ -108,6 +110,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. @@ -116,6 +119,7 @@ struct SimpleCardFrontView: View { .foregroundStyle(inkColor) .offset(y: cardSize.width * 0.14) .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) + .accessibilityHidden(true) } } } diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift index 649678d..d906ce6 100644 --- a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift +++ b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift @@ -42,7 +42,10 @@ struct FreeCellView: View { cardTilts: $cardTilts, hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil ) - .opacity((viewModel.isDragging && viewModel.isSelected(card: card)) || hiddenCardIDs.contains(card.id) ? 0 : 1) + .opacity( + (viewModel.isDragging && viewModel.isSelected(card: card)) + || hiddenCardIDs.contains(card.id) ? 0 : 1 + ) .gesture(dragGesture(.freeCell(index))) .cardFramePreference(card.id) } @@ -50,6 +53,7 @@ struct FreeCellView: View { .onTapGesture { viewModel.handleFreeCellTap(index: index) } + .accessibilityAddTraits(.isButton) .background( GeometryReader { proxy in let boardFrame = proxy.frame(in: .named("board")) diff --git a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift index 11ab8ec..fa9dd0d 100644 --- a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift +++ b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift @@ -43,6 +43,7 @@ struct StockView: View { .onTapGesture { viewModel.handleStockTap() } + .accessibilityAddTraits(.isButton) .accessibilityLabel("Stock") } } @@ -114,6 +115,7 @@ struct WasteView: View { .onTapGesture { viewModel.handleWasteTap() } + .accessibilityAddTraits(.isButton) .zIndex(isDragSource || isSelected ? 10 : 0) .accessibilityLabel("Waste") } diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 6951ebc..20f9dd5 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -188,7 +188,11 @@ struct RulesAndScoringView: View { Text("On win, a time bonus is added.") .font(.caption) .foregroundStyle(.secondary) - Text("Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and \(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by \(Scoring.timedPointsLostPerSecond) point per second.") + Text( + "Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and " + + "\(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by " + + "\(Scoring.timedPointsLostPerSecond) point per second." + ) .font(.caption) .foregroundStyle(.secondary) Text("Score cannot go below \(Scoring.minimumScore).") @@ -223,7 +227,10 @@ struct RulesAndScoringView: View { TermRow(term: "Cascade", definition: "One of eight tableau columns where all cards are face up."), TermRow(term: "Free Cell", definition: "A temporary single-card holding slot (four total)."), TermRow(term: "Foundation", definition: "Four suit piles built from Ace to King."), - TermRow(term: "Supermove", definition: "A multi-card move enabled by available free cells and empty cascades.") + TermRow( + term: "Supermove", + definition: "A multi-card move enabled by available free cells and empty cascades." + ) ] } } @@ -258,7 +265,11 @@ struct RulesAndScoringView: View { case .freecell: return [ ScoringRow(move: "Move cards", points: 0, note: "FreeCell currently tracks time and completion."), - ScoringRow(move: "Win time bonus", points: Scoring.timedMaxBonusDrawThree, note: "Reduced by elapsed time.") + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) ] } } diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 17a5f6d..aceee71 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -62,7 +62,8 @@ struct SettingsView: View { @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue - @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @@ -136,7 +137,11 @@ struct SettingsView: View { } } - // MARK: - Sections +} + +// MARK: - Sections + +private extension SettingsView { private var tableSection: some View { Section { @@ -260,6 +265,7 @@ struct SettingsView: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .contentShape(Rectangle()) } @@ -282,6 +288,7 @@ struct SettingsView: View { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .contentShape(Rectangle()) } @@ -402,6 +409,7 @@ struct SettingsView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) + .accessibilityHidden(true) } } .overlay { @@ -434,6 +442,7 @@ struct SettingsView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) + .accessibilityHidden(true) } } .overlay { @@ -459,7 +468,11 @@ private extension View { .frame(maxWidth: .infinity) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.12)) : AnyShapeStyle(.quaternary.opacity(0.5))) + .fill( + isSelected + ? AnyShapeStyle(Color.accentColor.opacity(0.12)) + : AnyShapeStyle(.quaternary.opacity(0.5)) + ) } .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index b9048e7..c923a68 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -51,14 +51,39 @@ enum Layout { return max(minHeightFittedCardWidth, cardHeight / 1.45) } +#if os(iOS) + private struct IOSOffsetContext { + let boardSize: CGSize + let cardSize: CGSize + let tableauMaxHeight: CGFloat + let isCompactBoard: Bool + let isPadLandscape: Bool + } +#endif + static func metrics( for boardSize: CGSize, isRegularWidth: Bool = false, tableauColumnCount: Int = 7 ) -> Metrics { - let columnCount = max(1, tableauColumnCount) - let boardWidth = boardSize.width #if os(iOS) + iOSMetrics( + for: boardSize, + isRegularWidth: isRegularWidth, + columnCount: max(1, tableauColumnCount) + ) +#else + macOSMetrics(for: boardSize, columnCount: max(1, tableauColumnCount)) +#endif + } + +#if os(iOS) + private static func iOSMetrics( + for boardSize: CGSize, + isRegularWidth: Bool, + columnCount: Int + ) -> Metrics { + let boardWidth = boardSize.width let isCompactBoard = boardWidth <= 430 let isMediumBoard = boardWidth > 430 && boardWidth < 760 let isPadLandscape = isRegularWidth && boardSize.width > boardSize.height @@ -95,26 +120,15 @@ enum Layout { cardHeight: cardSize.height ) - let baseFaceDownOffset = max(isCompactBoard ? 10 : 16, cardSize.height * faceDownFraction) - let baseFaceUpOffset = max(isCompactBoard ? 14 : 22, cardSize.height * faceUpFraction) - - let faceUpOffset: CGFloat - let faceDownOffset: CGFloat - if isPadLandscape { - faceUpOffset = max(22, baseFaceUpOffset * landscapeOffsetScale) - faceDownOffset = max(14, baseFaceDownOffset * landscapeOffsetScale) - } else if isCompactBoard && boardSize.height > boardSize.width { - // Portrait phones have far more height than the width-fitted cards - // use; spread the worst-case pile into it, capped for readability. - let fittedFaceUp = (tableauMaxHeight - cardSize.height - maxFaceDownGaps * baseFaceDownOffset) / maxFaceUpGaps - faceUpOffset = min(max(baseFaceUpOffset, fittedFaceUp), cardSize.height * 0.38) - faceDownOffset = baseFaceDownOffset - } else { - faceUpOffset = baseFaceUpOffset - faceDownOffset = baseFaceDownOffset - } - - let wasteFanSpacing = cardSize.width * (isCompactBoard ? 0.18 : (isPadLandscape ? 0.2 : 0.25)) + let offsets = iOSTableauOffsets( + context: IOSOffsetContext( + boardSize: boardSize, + cardSize: cardSize, + tableauMaxHeight: tableauMaxHeight, + isCompactBoard: isCompactBoard, + isPadLandscape: isPadLandscape + ) + ) return Metrics( horizontalPadding: horizontalPadding, @@ -122,12 +136,38 @@ enum Layout { rowSpacing: rowSpacing, columnSpacing: columnSpacing, cardSize: cardSize, - tableauFaceDownOffset: faceDownOffset, - tableauFaceUpOffset: faceUpOffset, - wasteFanSpacing: wasteFanSpacing, + tableauFaceDownOffset: offsets.faceDown, + tableauFaceUpOffset: offsets.faceUp, + wasteFanSpacing: cardSize.width * (isCompactBoard ? 0.18 : (isPadLandscape ? 0.2 : 0.25)), tableauMaxHeight: tableauMaxHeight ) + } + + private static func iOSTableauOffsets(context: IOSOffsetContext) -> (faceDown: CGFloat, faceUp: CGFloat) { + let faceDownFraction: CGFloat = context.isCompactBoard ? 0.16 : 0.18 + let faceUpFraction: CGFloat = context.isCompactBoard ? 0.24 : 0.28 + let baseFaceDown = max( + context.isCompactBoard ? 10 : 16, + context.cardSize.height * faceDownFraction + ) + let baseFaceUp = max(context.isCompactBoard ? 14 : 22, context.cardSize.height * faceUpFraction) + if context.isPadLandscape { + return ( + max(14, baseFaceDown * 0.8), + max(22, baseFaceUp * 0.8) + ) + } + guard context.isCompactBoard, context.boardSize.height > context.boardSize.width else { + return (baseFaceDown, baseFaceUp) + } + let fittedFaceUp = ( + context.tableauMaxHeight - context.cardSize.height - maxFaceDownGaps * baseFaceDown + ) / maxFaceUpGaps + return (baseFaceDown, min(max(baseFaceUp, fittedFaceUp), context.cardSize.height * 0.38)) + } #else + private static func macOSMetrics(for boardSize: CGSize, columnCount: Int) -> Metrics { + let boardWidth = boardSize.width let horizontalPadding = min(24, max(14, boardWidth * 0.018)) let verticalPadding = min(22, max(14, boardWidth * 0.015)) let columnSpacing = min(18, max(10, boardWidth * 0.013)) @@ -173,8 +213,8 @@ enum Layout { wasteFanSpacing: wasteFanSpacing, tableauMaxHeight: tableauMaxHeight ) -#endif } +#endif private static func tableauHeightBudget( boardHeight: CGFloat, @@ -482,6 +522,7 @@ struct FoundationView: View { ) .zIndex(isDragSource ? 10 : 0) .accessibilityLabel("Foundation \(index + 1)") + .accessibilityAddTraits(.isButton) } } @@ -537,6 +578,7 @@ struct TableauPileView: View { .onTapGesture { viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) } + .accessibilityAddTraits(.isButton) PilePlaceholderView(cardSize: cardSize) DropHighlightView( @@ -567,6 +609,7 @@ struct TableauPileView: View { .onTapGesture { viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: index) } + .accessibilityAddTraits(.isButton) .cardFramePreference(card.id, yOffset: yOffset) cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) @@ -685,11 +728,13 @@ struct DropHighlightView: View { } struct TableBackground: View { - @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.tableBackgroundColor) + private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var feltEffectEnabled = true var body: some View { - let baseColor = (TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? TableBackgroundColor.defaultValue).color + let selectedColor = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? .defaultValue + let baseColor = selectedColor.color Group { if feltEffectEnabled { GeometryReader { proxy in @@ -740,5 +785,7 @@ struct WinOverlay: View { } #Preview("Win Overlay") { - WinOverlay(score: 1240) {} + WinOverlay(score: 1240) { + // Preview action. + } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index b3ddd4e..159c6b6 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1,6 +1,10 @@ import SwiftUI import SwiftData +// TODO: Split board layout, interaction, animation, lifecycle, and persistence +// coordination along stable ownership boundaries, then remove this exception. +// swiftlint:disable file_length + struct DropTargetFrameKey: PreferenceKey { static var defaultValue: [DropTarget: DropTargetGeometry] = [:] @@ -73,6 +77,20 @@ extension View { } } +private struct HeaderMetrics { + let elapsedSeconds: Int + let score: Int +} + +private struct BoardPresentation { + let metrics: Layout.Metrics + let columnCount: Int + let contentWidth: CGFloat + let scale: CGFloat + let effectiveCardSize: CGSize + let hintedTarget: DropTarget? + let centersContent: Bool +} struct ContentView: View { @Environment(\.modelContext) private var modelContext @@ -170,137 +188,95 @@ struct ContentView: View { var body: some View { sceneDecorations( - for: AnyView( - GeometryReader { geometry in - boardRoot(for: geometry) - } - .environment(\.cardStyle, currentCardStyle) - ) + for: GeometryReader { geometry in + boardRoot(for: geometry) + } + .environment(\.cardStyle, currentCardStyle) ) } - private func sceneDecorations(for baseView: AnyView) -> some View { +} + +private extension ContentView { + func sceneDecorations(for baseView: Content) -> some View { let toolbarView = applyToolbar(to: baseView) let sheetsView = applySheets(to: toolbarView) return applyObservers(to: sheetsView) } - private func applyToolbar(to view: AnyView) -> AnyView { - AnyView( - view + private func applyToolbar(to view: Content) -> some View { + view .toolbar { + gameToolbar + } + } + + @ToolbarContentBuilder + var gameToolbar: some ToolbarContent { #if os(iOS) - ToolbarItemGroup(placement: .bottomBar) { - Menu { - Button("New Game", systemImage: "plus") { - startNewGameFromUI() - } - Button("Redeal", systemImage: "arrow.clockwise") { - redealFromUI() - } - Button("Auto Finish", systemImage: "bolt") { - startAutoFinish() - } - .disabled(isAutoFinishDisabled) - if isHintButtonVisible { - Button("Hint", systemImage: "lightbulb") { - triggerHint() - } - .disabled(isHintDisabled) - } - } label: { - Label("Game", systemImage: "ellipsis.circle") - } - Button { - stopAutoFinish() - beginUndoAnimationIfNeeded() - } label: { - Label("Undo", systemImage: "arrow.uturn.backward") - } - .disabled(isUndoDisabled) - Spacer(minLength: 0) - Button { - isShowingStats = true - } label: { - Label("Statistics", systemImage: "chart.bar") - } - Button { - isShowingSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - } -#endif -#if os(macOS) - ToolbarSpacer(.flexible) - ToolbarItemGroup(placement: .primaryAction) { - Button { - startNewGameFromUI() - } label: { - Label("New Game", systemImage: "plus") - } - .labelStyle(.iconOnly) - .help("New Game") - Button { - redealFromUI() - } label: { - Label("Redeal", systemImage: "arrow.clockwise") - } - .labelStyle(.iconOnly) - .help("Redeal") - } - ToolbarSpacer(.fixed) - ToolbarItemGroup(placement: .primaryAction) { - Button { - stopAutoFinish() - beginUndoAnimationIfNeeded() - } label: { - Label("Undo", systemImage: "arrow.uturn.backward") - } - .labelStyle(.iconOnly) - .help("Undo") - .disabled(isUndoDisabled) - Button { - startAutoFinish() - } label: { - Label("Auto Finish", systemImage: "bolt") - } - .labelStyle(.iconOnly) - .help("Auto Finish") + ToolbarItemGroup(placement: .bottomBar) { + Menu { + Button("New Game", systemImage: "plus") { startNewGameFromUI() } + Button("Redeal", systemImage: "arrow.clockwise") { redealFromUI() } + Button("Auto Finish", systemImage: "bolt") { startAutoFinish() } .disabled(isAutoFinishDisabled) - if isHintButtonVisible { - Button { - triggerHint() - } label: { - Label("Hint", systemImage: "lightbulb") - } - .labelStyle(.iconOnly) - .help("Hint") + if isHintButtonVisible { + Button("Hint", systemImage: "lightbulb") { triggerHint() } .disabled(isHintDisabled) - } - Button { - isShowingStats = true - } label: { - Label("Statistics", systemImage: "chart.bar") - } - .labelStyle(.iconOnly) - .help("Statistics") - Button { - isShowingSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - .labelStyle(.iconOnly) - .help("Settings") } + } label: { + Label("Game", systemImage: "ellipsis.circle") + } + Button { + stopAutoFinish() + beginUndoAnimationIfNeeded() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .disabled(isUndoDisabled) + Spacer(minLength: 0) + Button { isShowingStats = true } label: { Label("Statistics", systemImage: "chart.bar") } + Button { isShowingSettings = true } label: { Label("Settings", systemImage: "gearshape") } + } #endif +#if os(macOS) + ToolbarSpacer(.flexible) + ToolbarItemGroup(placement: .primaryAction) { + toolbarButton("New Game", systemImage: "plus") { startNewGameFromUI() } + toolbarButton("Redeal", systemImage: "arrow.clockwise", action: redealFromUI) + } + ToolbarSpacer(.fixed) + ToolbarItemGroup(placement: .primaryAction) { + toolbarButton("Undo", systemImage: "arrow.uturn.backward") { + stopAutoFinish() + beginUndoAnimationIfNeeded() } - ) + .disabled(isUndoDisabled) + toolbarButton("Auto Finish", systemImage: "bolt", action: startAutoFinish) + .disabled(isAutoFinishDisabled) + if isHintButtonVisible { + toolbarButton("Hint", systemImage: "lightbulb", action: triggerHint) + .disabled(isHintDisabled) + } + toolbarButton("Statistics", systemImage: "chart.bar") { isShowingStats = true } + toolbarButton("Settings", systemImage: "gearshape") { isShowingSettings = true } + } +#endif } - private func applySheets(to view: AnyView) -> AnyView { - AnyView( - view.sheet(isPresented: $isShowingSettings) { +#if os(macOS) + func toolbarButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + } + .labelStyle(.iconOnly) + .help(title) + } +#endif + + private func applySheets(to view: Content) -> some View { + view + .sheet(isPresented: $isShowingSettings) { #if os(iOS) NavigationStack { SettingsView() @@ -323,22 +299,27 @@ struct ContentView: View { StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) #endif } - ) } - private func applyObservers(to view: AnyView) -> AnyView { - let commandObservedView = AnyView( - view - .onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in + private func applyObservers(to view: Content) -> some View { + let commands = applyCommandObservers(to: view) + let gameState = applyGameStateObservers(to: commands) + let interactions = applyInteractionObservers(to: gameState) + return applyLifecycleObservers(to: interactions) + } + + private func applyCommandObservers(to view: Content) -> some View { + view + .onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in isShowingSettings = true } .onReceive(NotificationCenter.default.publisher(for: .openRulesAndScoring)) { _ in presentRulesAndScoring(initialSection: .rules) } - ) + } - let gameStateObservedView = AnyView( - commandObservedView + private func applyGameStateObservers(to view: Content) -> some View { + view .onChange(of: gameVariantRawValue) { _, newValue in guard hasLoadedGame, !isHydratingGame else { return } let variant = GameVariant(rawValue: newValue) ?? .klondike @@ -378,6 +359,10 @@ struct ContentView: View { } } } + } + + private func applyInteractionObservers(to view: Content) -> some View { + view .onChange(of: viewModel.pendingAutoMove?.id) { _, _ in processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() @@ -394,10 +379,10 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } - ) + } - return AnyView( - gameStateObservedView + private func applyLifecycleObservers(to view: Content) -> some View { + view .onChange(of: scenePhase) { _, _ in syncLifecyclePauseState() } @@ -417,109 +402,57 @@ struct ContentView: View { .focusedSceneValue(\.gameMenuActions, gameMenuActions) .focusedSceneValue(\.gameMenuState, gameMenuState) #endif - ) } @ViewBuilder private func boardRoot(for geometry: GeometryProxy) -> some View { - let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant == .freecell ? 8 : 7) + let presentation = boardPresentation(for: geometry) + let surface = boardSurface(presentation: presentation) + let preferences = applyBoardPreferences(to: surface) + let geometryObserved = applyBoardGeometryObservers(to: preferences, geometry: geometry) + let wasteObserved = applyWasteObservers(to: geometryObserved, presentation: presentation) + applyBoardOverlays(to: wasteObserved, presentation: presentation) + } + + private func boardPresentation(for geometry: GeometryProxy) -> BoardPresentation { + let columnCount = max(viewModel.state.tableau.count, viewModel.gameVariant == .freecell ? 8 : 7) #if os(iOS) let metrics = Layout.metrics( for: geometry.size, isRegularWidth: horizontalSizeClass == .regular, - tableauColumnCount: boardColumnCount + tableauColumnCount: columnCount ) #else - let metrics = Layout.metrics(for: geometry.size, tableauColumnCount: boardColumnCount) + let metrics = Layout.metrics(for: geometry.size, tableauColumnCount: columnCount) #endif let cardSize = metrics.cardSize - let boardContentWidth = (cardSize.width * CGFloat(boardColumnCount)) - + (metrics.columnSpacing * CGFloat(max(0, boardColumnCount - 1))) - let boardScaleFactor = boardScaleFactor( + let contentWidth = (cardSize.width * CGFloat(columnCount)) + + (metrics.columnSpacing * CGFloat(max(0, columnCount - 1))) + let scale = boardScaleFactor( availableWidth: geometry.size.width, - requiredWidth: boardContentWidth + (metrics.horizontalPadding * 2) + requiredWidth: contentWidth + (metrics.horizontalPadding * 2) ) - let effectiveCardSize = CGSize(width: cardSize.width * boardScaleFactor, height: cardSize.height * boardScaleFactor) - let isBoardReady = hasLoadedGame && !isHydratingGame - let hintedTarget: DropTarget? = { - guard let destination = viewModel.hintedDestination else { return nil } - return dropTarget(for: destination) - }() - let openScoringDetails: () -> Void = { presentRulesAndScoring(initialSection: .scoring) } #if os(iOS) - let isPadLandscape = horizontalSizeClass == .regular && geometry.size.width > geometry.size.height + let centersContent = horizontalSizeClass == .regular && geometry.size.width > geometry.size.height +#else + let centersContent = true #endif + return BoardPresentation( + metrics: metrics, + columnCount: columnCount, + contentWidth: contentWidth, + scale: scale, + effectiveCardSize: CGSize(width: cardSize.width * scale, height: cardSize.height * scale), + hintedTarget: viewModel.hintedDestination.map(dropTarget(for:)), + centersContent: centersContent + ) + } + private func boardSurface(presentation: BoardPresentation) -> some View { ZStack { TableBackground() - if isBoardReady { - let boardLayout = VStack(alignment: .leading, spacing: metrics.rowSpacing) { - TimelineView(.periodic(from: .now, by: 1)) { context in - let headerMetrics = headerMetrics(at: context.date) - headerView( - elapsedSeconds: headerMetrics.elapsedSeconds, - score: headerMetrics.score, - boardContentWidth: boardContentWidth, - onScoreTapped: openScoringDetails - ) - } - TopRowView( - viewModel: viewModel, - variant: viewModel.gameVariant, - cardSize: cardSize, - columnSpacing: metrics.columnSpacing, - wasteFanSpacing: metrics.wasteFanSpacing, - activeTarget: activeTarget, - hintedTarget: hintedTarget, - isStockHinted: viewModel.isStockHinted, - isWasteHinted: viewModel.isWasteHinted, - hintHighlightOpacity: hintHighlightOpacity, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hiddenCardIDs: effectiveHiddenCardIDs, - hintedCardIDs: viewModel.hintedCardIDs, - hintWiggleToken: viewModel.hintWiggleToken, - drawingCardIDs: drawingCardIDs, - fanProgress: wasteFanProgress, - dragGesture: dragGesture(for:) - ) - .frame(width: boardContentWidth, alignment: .leading) - TableauRowView( - viewModel: viewModel, - cardSize: cardSize, - columnSpacing: metrics.columnSpacing, - faceDownOffset: metrics.tableauFaceDownOffset, - faceUpOffset: metrics.tableauFaceUpOffset, - maxPileHeight: metrics.tableauMaxHeight, - activeTarget: activeTarget, - hintedTarget: hintedTarget, - hintHighlightOpacity: hintHighlightOpacity, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hiddenCardIDs: effectiveHiddenCardIDs, - hintedCardIDs: viewModel.hintedCardIDs, - hintWiggleToken: viewModel.hintWiggleToken, - dragGesture: dragGesture(for:) - ) - .frame(width: boardContentWidth, alignment: .leading) - Spacer(minLength: 0) - } - .allowsHitTesting(!isWinCascadeAnimating) -#if os(iOS) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: isPadLandscape ? .top : .topLeading - ) -#else - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) -#endif - .padding(.horizontal, metrics.horizontalPadding) - .padding(.vertical, metrics.verticalPadding) - - boardLayout - .scaleEffect(boardScaleFactor, anchor: .top) - + if hasLoadedGame && !isHydratingGame { + boardLayout(presentation: presentation) Button("Cancel Drag") { handleEscape() } @@ -530,128 +463,227 @@ struct ContentView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .coordinateSpace(name: "board") + .coordinateSpace(.named("board")) .sensoryFeedback(trigger: hapticFeedback.trigger) { hapticFeedback.feedbackForTrigger } - .onPreferenceChange(DropTargetFrameKey.self) { frames in - dropFrames = frames - refreshLoadedWinPresentationIfNeeded() - } - .onPreferenceChange(StockFrameKey.self) { frame in - stockFrame = frame - } - .onPreferenceChange(WasteFrameKey.self) { frame in - wasteFrame = frame - } - .onPreferenceChange(CardFrameKey.self) { frames in - if shouldUpdateCardFrames(with: frames) { - cardFrames = frames - } - } - .onAppear { - boardViewportSize = geometry.size - refreshLoadedWinPresentationIfNeeded() - } - .onChange(of: geometry.size) { _, newSize in - boardViewportSize = newSize - refreshLoadedWinPresentationIfNeeded() - } - .onChange(of: viewModel.isWin) { _, isWin in - guard !isHydratingGame else { return } - if isWin { - winCelebration.beginIfNeededForWin( - foundations: viewModel.state.foundations, - dropFrames: dropFrames, - boardViewportSize: boardViewportSize + } + + private func boardLayout(presentation: BoardPresentation) -> some View { + VStack(alignment: .leading, spacing: presentation.metrics.rowSpacing) { + TimelineView(.periodic(from: .now, by: 1)) { context in + let metrics = headerMetrics(at: context.date) + headerView( + elapsedSeconds: metrics.elapsedSeconds, + score: metrics.score, + boardContentWidth: presentation.contentWidth, + onScoreTapped: { presentRulesAndScoring(initialSection: .scoring) } ) - } else if winCelebration.phase != .idle { - winCelebration.reset(to: .idle) } - } - .onChange(of: viewModel.state.waste.count) { _, newValue in - let stockCount = viewModel.state.stock.count - if newValue == 0 { - drawAnimationCards = [] - drawingCardIDs = [] - wasteFanProgress = [:] - previousWasteCount = 0 - previousStockCount = stockCount - return + topRow(presentation: presentation) + tableauRow(presentation: presentation) + Spacer(minLength: 0) + } + .allowsHitTesting(!isWinCascadeAnimating) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: presentation.centersContent ? .top : .topLeading + ) + .padding(.horizontal, presentation.metrics.horizontalPadding) + .padding(.vertical, presentation.metrics.verticalPadding) + .scaleEffect(presentation.scale, anchor: .top) + } + + private func topRow(presentation: BoardPresentation) -> some View { + TopRowView( + viewModel: viewModel, + variant: viewModel.gameVariant, + cardSize: presentation.metrics.cardSize, + columnSpacing: presentation.metrics.columnSpacing, + wasteFanSpacing: presentation.metrics.wasteFanSpacing, + activeTarget: activeTarget, + hintedTarget: presentation.hintedTarget, + isStockHinted: viewModel.isStockHinted, + isWasteHinted: viewModel.isWasteHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: effectiveHiddenCardIDs, + hintedCardIDs: viewModel.hintedCardIDs, + hintWiggleToken: viewModel.hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: wasteFanProgress, + dragGesture: dragGesture(for:) + ) + .frame(width: presentation.contentWidth, alignment: .leading) + } + + private func tableauRow(presentation: BoardPresentation) -> some View { + TableauRowView( + viewModel: viewModel, + cardSize: presentation.metrics.cardSize, + columnSpacing: presentation.metrics.columnSpacing, + faceDownOffset: presentation.metrics.tableauFaceDownOffset, + faceUpOffset: presentation.metrics.tableauFaceUpOffset, + maxPileHeight: presentation.metrics.tableauMaxHeight, + activeTarget: activeTarget, + hintedTarget: presentation.hintedTarget, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: effectiveHiddenCardIDs, + hintedCardIDs: viewModel.hintedCardIDs, + hintWiggleToken: viewModel.hintWiggleToken, + dragGesture: dragGesture(for:) + ) + .frame(width: presentation.contentWidth, alignment: .leading) + } + + private func applyBoardPreferences(to view: Content) -> some View { + view + .onPreferenceChange(DropTargetFrameKey.self) { frames in + dropFrames = frames + refreshLoadedWinPresentationIfNeeded() } - let addedCount = max(0, newValue - previousWasteCount) - let newCards = addedCount > 0 ? Array(viewModel.state.waste.suffix(addedCount)) : [] - syncFanProgress(with: viewModel.state.waste, excluding: Set(newCards.map(\.id))) - if addedCount > 0, stockCount < previousStockCount { - // The overlay cards fan themselves while flipping; the real - // cards wait fully fanned underneath. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - for card in newCards { - wasteFanProgress[card.id] = 1 - } + .onPreferenceChange(StockFrameKey.self) { frame in + stockFrame = frame + } + .onPreferenceChange(WasteFrameKey.self) { frame in + wasteFrame = frame + } + .onPreferenceChange(CardFrameKey.self) { frames in + if shouldUpdateCardFrames(with: frames) { + cardFrames = frames } - startDrawAnimation( - for: newCards, - cardSize: effectiveCardSize, - fanSpacing: metrics.wasteFanSpacing * boardScaleFactor - ) } - previousWasteCount = newValue - previousStockCount = stockCount - } - .animation(.spring(response: 0.35, dampingFraction: 0.86), value: viewModel.state) - .animation(.easeInOut(duration: 0.12), value: activeTarget) - .overlay { - GeometryReader { _ in - ZStack { - DrawOverlayView( - cards: drawAnimationCards, - cardSize: effectiveCardSize, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts - ) - .zIndex(50) - UndoOverlayView( - items: undoAnimationItems, - progress: undoAnimationProgress + } + + private func applyBoardGeometryObservers( + to view: Content, + geometry: GeometryProxy + ) -> some View { + view + .onAppear { + boardViewportSize = geometry.size + refreshLoadedWinPresentationIfNeeded() + } + .onChange(of: geometry.size) { _, newSize in + boardViewportSize = newSize + refreshLoadedWinPresentationIfNeeded() + } + .onChange(of: viewModel.isWin) { _, isWin in + guard !isHydratingGame else { return } + if isWin { + winCelebration.beginIfNeededForWin( + foundations: viewModel.state.foundations, + dropFrames: dropFrames, + boardViewportSize: boardViewportSize ) - .zIndex(75) - WinCascadeOverlayView(cards: winCelebration.cards) - .zIndex(90) - DragOverlayView( - viewModel: viewModel, - cardFrames: dragOverlayCardFrames, - cardTilts: cardTilts, - dragTranslation: dragTranslation, - dragReturnOffset: dragReturnOffset, - isReturningDrag: isReturningDrag, - returningCards: returningCards, - isDroppingCards: isDroppingCards, - droppingCards: droppingSelection?.cards ?? [], - dropAnimationOffset: dropAnimationOffset, - overlayTilt: overlayTilt + } else if winCelebration.phase != .idle { + winCelebration.reset(to: .idle) + } + } + } + + private func applyWasteObservers( + to view: Content, + presentation: BoardPresentation + ) -> some View { + view + .onChange(of: viewModel.state.waste.count) { _, newValue in + let stockCount = viewModel.state.stock.count + if newValue == 0 { + drawAnimationCards = [] + drawingCardIDs = [] + wasteFanProgress = [:] + previousWasteCount = 0 + previousStockCount = stockCount + return + } + let addedCount = max(0, newValue - previousWasteCount) + let newCards = addedCount > 0 ? Array(viewModel.state.waste.suffix(addedCount)) : [] + syncFanProgress(with: viewModel.state.waste, excluding: Set(newCards.map(\.id))) + if addedCount > 0, stockCount < previousStockCount { + // The overlay cards fan themselves while flipping; the real + // cards wait fully fanned underneath. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + for card in newCards { + wasteFanProgress[card.id] = 1 + } + } + startDrawAnimation( + for: newCards, + cardSize: presentation.effectiveCardSize, + fanSpacing: presentation.metrics.wasteFanSpacing * presentation.scale ) - .zIndex(100) - if viewModel.isWin && winCelebration.phase != .idle { - WinOverlay(score: viewModel.score) { - startNewGameFromUI() + } + previousWasteCount = newValue + previousStockCount = stockCount + } + } + + private func applyBoardOverlays( + to view: Content, + presentation: BoardPresentation + ) -> some View { + view + .animation(.spring(response: 0.35, dampingFraction: 0.86), value: viewModel.state) + .animation(.easeInOut(duration: 0.12), value: activeTarget) + .overlay { + GeometryReader { _ in + ZStack { + DrawOverlayView( + cards: drawAnimationCards, + cardSize: presentation.effectiveCardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts + ) + .zIndex(50) + UndoOverlayView( + items: undoAnimationItems, + progress: undoAnimationProgress + ) + .zIndex(75) + WinCascadeOverlayView(cards: winCelebration.cards) + .zIndex(90) + DragOverlayView( + viewModel: viewModel, + cardFrames: dragOverlayCardFrames, + cardTilts: cardTilts, + dragTranslation: dragTranslation, + dragReturnOffset: dragReturnOffset, + isReturningDrag: isReturningDrag, + returningCards: returningCards, + isDroppingCards: isDroppingCards, + droppingCards: droppingSelection?.cards ?? [], + dropAnimationOffset: dropAnimationOffset, + overlayTilt: overlayTilt + ) + .zIndex(100) + if viewModel.isWin && winCelebration.phase != .idle { + WinOverlay(score: viewModel.score) { + startNewGameFromUI() + } + .zIndex(200) + .transition(.opacity) } - .zIndex(200) - .transition(.opacity) } } + .accessibilityHidden(true) } - .accessibilityHidden(true) - } } - private func headerMetrics(at date: Date) -> (elapsedSeconds: Int, score: Int) { + private func headerMetrics(at date: Date) -> HeaderMetrics { if viewModel.isClockAdvancing { - return (viewModel.elapsedActiveSeconds(at: date), viewModel.displayScore(at: date)) + return HeaderMetrics( + elapsedSeconds: viewModel.elapsedActiveSeconds(at: date), + score: viewModel.displayScore(at: date) + ) } - return (viewModel.elapsedActiveSeconds(), viewModel.displayScore()) + return HeaderMetrics(elapsedSeconds: viewModel.elapsedActiveSeconds(), score: viewModel.displayScore()) } private func presentRulesAndScoring(initialSection: RulesAndScoringView.Section = .rules) { @@ -934,35 +966,34 @@ struct ContentView: View { } DispatchQueue.main.asyncAfter(deadline: .now() + dropDuration) { - // Clear old tilts so cards get fresh tilts at new position - if let cards = droppingSelection?.cards { - for card in cards { - cardTilts.removeValue(forKey: card.id) - } - } + completeDropAnimation() + } + } - // Update game state without animation to prevent double-animation - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - if let dest = pendingDropDestination { - viewModel.handleDrop(to: dest) - } - dragTranslation = .zero - dropAnimationOffset = .zero - isDroppingCards = false - droppingSelection = nil - pendingDropDestination = nil + private func completeDropAnimation() { + for card in droppingSelection?.cards ?? [] { + cardTilts.removeValue(forKey: card.id) + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + if let destination = pendingDropDestination { + viewModel.handleDrop(to: destination) } - wasteReturnAnchorCardID = nil - wasteReturnAnchorFrame = nil - if !isAutoFinishing { - DispatchQueue.main.async { - viewModel.refreshAutoFinishAvailability() - } + dragTranslation = .zero + dropAnimationOffset = .zero + isDroppingCards = false + droppingSelection = nil + pendingDropDestination = nil + } + wasteReturnAnchorCardID = nil + wasteReturnAnchorFrame = nil + if !isAutoFinishing { + DispatchQueue.main.async { + viewModel.refreshAutoFinishAvailability() } - processPendingAutoMoveIfPossible() } + processPendingAutoMoveIfPossible() } private func beginReturnAnimation() { @@ -1091,7 +1122,7 @@ struct ContentView: View { return } - let (startingItems, targets, needsPostUndoFrames): ([UndoAnimationItem], [UUID: UndoAnimationEndTarget], Bool) = { + let plan: UndoAnimationCoordinator.Plan = { if let context = snapshot.undoContext { return buildUndoAnimationPlan( context: context, @@ -1109,17 +1140,21 @@ struct ContentView: View { ) }() - guard !startingItems.isEmpty else { + guard !plan.items.isEmpty else { viewModel.undo() return } - undoAnimationItems = startingItems - undoAnimationTargets = targets + startUndoAnimation(with: plan) + } + + private func startUndoAnimation(with plan: UndoAnimationCoordinator.Plan) { + undoAnimationItems = plan.items + undoAnimationTargets = plan.targets undoAnimationProgress = 0 isUndoAnimating = true - hiddenCardIDs = Set(startingItems.map(\.id)) - if needsPostUndoFrames { + hiddenCardIDs = Set(plan.items.map(\.id)) + if plan.needsPostUndoFrames { cardFrames = [:] } @@ -1129,7 +1164,7 @@ struct ContentView: View { viewModel.undo() } - if needsPostUndoFrames { + if plan.needsPostUndoFrames { DispatchQueue.main.async { resolveUndoAnimationTargets(attemptsRemaining: 24) } @@ -1143,16 +1178,16 @@ struct ContentView: View { beforeCards: [UUID: Card], afterCards: [UUID: Card], startingFrames: [UUID: CGRect] - ) -> (items: [UndoAnimationItem], targets: [UUID: UndoAnimationEndTarget], needsPostUndoFrames: Bool) { - let plan = UndoAnimationCoordinator.buildPlan( + ) -> UndoAnimationCoordinator.Plan { + UndoAnimationCoordinator.buildPlan( context: context, - beforeCards: beforeCards, - afterCards: afterCards, - cardFrames: startingFrames, - stockFrame: stockFrame, - wasteFrame: wasteFrame + cards: UndoAnimationCoordinator.Cards(before: beforeCards, after: afterCards), + frames: UndoAnimationCoordinator.Frames( + cards: startingFrames, + stock: stockFrame, + waste: wasteFrame + ) ) - return (plan.items, plan.targets, plan.needsPostUndoFrames) } private func resolveUndoTargetFrame(_ target: UndoAnimationEndTarget) -> CGRect? { @@ -1180,7 +1215,10 @@ struct ContentView: View { private func resolveUndoAnimationTargets(attemptsRemaining: Int) { let resolvedItems = undoAnimationItems.compactMap { item -> UndoAnimationItem? in - guard let target = undoAnimationTargets[item.id], let endFrame = resolveUndoTargetFrame(target) else { return nil } + guard let target = undoAnimationTargets[item.id], + let endFrame = resolveUndoTargetFrame(target) else { + return nil + } return UndoAnimationItem(id: item.id, card: item.card, startFrame: item.startFrame, endFrame: endFrame) } @@ -1274,7 +1312,7 @@ struct ContentView: View { beforeCards: [UUID: Card], afterCards: [UUID: Card], startingFrames: [UUID: CGRect] - ) -> (items: [UndoAnimationItem], targets: [UUID: UndoAnimationEndTarget], needsPostUndoFrames: Bool) { + ) -> UndoAnimationCoordinator.Plan { let beforeLocations = cardLocations(in: beforeState) let afterLocations = cardLocations(in: afterState) let ids = Set(beforeLocations.keys).union(afterLocations.keys).filter { id in @@ -1317,7 +1355,11 @@ struct ContentView: View { return false } - return (items, targets, needsPostUndoFrames) + return UndoAnimationCoordinator.Plan( + items: items, + targets: targets, + needsPostUndoFrames: needsPostUndoFrames + ) } private func shouldAnimateFallbackTransition( diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 43a14eb..580503b 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -161,13 +161,18 @@ struct StatisticsView: View { Button(resetActionTitle, role: .destructive) { resetStatistics() } - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) { + // The system dismisses cancellation actions automatically. + } } message: { Text(resetMessage) } } - private var winLossBar: some View { +} + +private extension StatisticsView { + var winLossBar: some View { let losses = stats.gamesPlayed - stats.gamesWon let winsLabel = "\(stats.gamesWon) \(stats.gamesWon == 1 ? "win" : "wins")" let lossesLabel = "\(losses) \(losses == 1 ? "loss" : "losses")" @@ -278,6 +283,7 @@ struct StatisticsView: View { Image(systemName: icon) .font(.subheadline) .foregroundStyle(.secondary) + .accessibilityHidden(true) Text(value) .font(.system(.headline, design: .monospaced, weight: .bold)) Text(label) diff --git a/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift b/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift index fefa208..4642675 100644 --- a/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift +++ b/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift @@ -108,13 +108,13 @@ final class KlondikePlannerTests: XCTestCase { private func stockTap(_ state: GameState, drawCount: Int) -> GameState? { var next = state if !next.stock.isEmpty { - let n = min(drawCount, next.stock.count) - for _ in 0.. UserDefaults { - let defaults = UserDefaults(suiteName: defaultsSuiteName)! + guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else { + preconditionFailure("Unable to create isolated user defaults") + } defaults.removePersistentDomain(forName: defaultsSuiteName) return defaults } diff --git a/ComputerSolitaireUITests/SnapshotHelper.swift b/ComputerSolitaireUITests/SnapshotHelper.swift index eef2cfe..d0bdd3b 100644 --- a/ComputerSolitaireUITests/SnapshotHelper.swift +++ b/ComputerSolitaireUITests/SnapshotHelper.swift @@ -31,7 +31,8 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) { /// - Parameters: /// - name: The name of the snapshot -/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +/// - timeout: Seconds to wait for the network loading indicator to disappear. +/// Pass `0` to skip waiting. @MainActor func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { Snapshot.snapshot(name, timeWaitingForIdle: timeout) @@ -132,7 +133,11 @@ open class Snapshot: NSObject { do { let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) - let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let matches = regex.matches( + in: launchArguments, + options: [], + range: NSRange(location: 0, length: launchArguments.count) + ) let results = matches.map { result -> String in (launchArguments as NSString).substring(with: result.range) } @@ -147,7 +152,8 @@ open class Snapshot: NSObject { waitForLoadingIndicatorToDisappear(within: timeout) } - NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + // More information: https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + NSLog("snapshot: \(name)") if Snapshot.waitForAnimations { sleep(1) // Waiting for the animation to be finished (kind of) @@ -170,15 +176,20 @@ open class Snapshot: NSObject { let screenshot = XCUIScreen.main.screenshot() #if os(iOS) && !targetEnvironment(macCatalyst) - let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + let image = XCUIDevice.shared.orientation.isLandscape + ? fixLandscapeOrientation(image: screenshot.image) + : screenshot.image #else let image = screenshot.image #endif - guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], + let screenshotsDir = screenshotsDirectory else { + return + } do { - // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + // Parallel UI tests prefix cloned simulator names with "Clone X of ". let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") let range = NSRange(location: 0, length: simulator.count) simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") @@ -207,7 +218,7 @@ open class Snapshot: NSObject { let format = UIGraphicsImageRendererFormat() format.scale = image.scale let renderer = UIGraphicsImageRenderer(size: image.size, format: format) - return renderer.image { context in + return renderer.image { _ in image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) } } else { @@ -228,7 +239,10 @@ open class Snapshot: NSObject { } let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element - let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "exists == false"), + object: networkLoadingIndicator + ) _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) } @@ -256,7 +270,8 @@ private extension XCUIElementAttributes { if hasAllowListedIdentifier { return false } let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) - let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && + frame.size.height.isBetween(2, and: 3) return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize }