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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions ComputerSolitaire/Game/Shared/GameRulesShared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ nonisolated enum GameRules {
SharedGameRules.isValidDescendingAlternatingSequence(cards)
}

/// Pure, pile-scoped equivalent of the session's `canSelectTableauCards`
/// for a run that is a suffix of `pile`; board views call this so
/// rendering never reads the observable session. Canfield's whole-pile
/// transfer check reduces to a count comparison here: a suffix can only
/// be "some entire pile" when it is this entire pile, because card
/// identities are unique across the board. `TableauPickupParityTests`
/// pins this equivalence against the session method.
static func canSelectTableauCards(
_ cards: [Card],
within pile: [Card],
variant: GameVariant
) -> Bool {
switch variant {
case .klondike, .yukon, .scorpion:
return true
case .freecell:
return isValidDescendingAlternatingSequence(cards)
case .spider:
return SharedGameRules.isDescendingSameSuitRun(cards)
case .golf, .fortyThieves:
return cards.count == 1
case .canfield:
return cards.count == 1
|| (CanfieldGameRules.isPackedSequence(cards) && cards.count == pile.count)
case .pyramid, .tripeaks:
return false
}
}

static func maxFreeCellTransferCount(
freeCellSlots: [Card?],
tableau: [[Card]],
Expand Down
12 changes: 6 additions & 6 deletions ComputerSolitaire/Interaction/BoardInteractionTypes.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import SwiftUI

enum DropTarget: Hashable {
nonisolated enum DropTarget: Hashable {
case foundation(Int)
case tableau(Int)
case freeCell(Int)
Expand All @@ -9,7 +9,7 @@ enum DropTarget: Hashable {
case discard
}

enum DragOrigin: Hashable {
nonisolated enum DragOrigin: Hashable {
case waste
case foundation(Int)
case freeCell(Int)
Expand All @@ -19,12 +19,12 @@ enum DragOrigin: Hashable {
case reserve
}

struct DropTargetGeometry: Equatable {
nonisolated struct DropTargetGeometry: Equatable {
let snapFrame: CGRect
let hitFrame: CGRect
}

enum DropTargetHitArea {
nonisolated enum DropTargetHitArea {
static let freeCellHorizontalGrace: CGFloat = 16
static let freeCellTopGrace: CGFloat = 14
static let freeCellBottomGrace: CGFloat = 18
Expand All @@ -44,7 +44,7 @@ enum DropTargetHitArea {
static let pyramidBottomGrace: CGFloat = 8
}

extension CGRect {
nonisolated extension CGRect {
func expanded(horizontal: CGFloat, top: CGFloat, bottom: CGFloat) -> CGRect {
CGRect(
x: minX - horizontal,
Expand All @@ -55,7 +55,7 @@ extension CGRect {
}
}

enum UndoAnimationEndTarget {
nonisolated enum UndoAnimationEndTarget {
case card(UUID)
case stock(Int)
}
90 changes: 73 additions & 17 deletions ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import SwiftUI
import Observation

/// Canfield's top row matches Klondike's shape — stock, fanned waste, a
/// spacer column the fan can overflow into, and four foundations. The
/// reserve renders in the tableau band (see `CanfieldBoardRowView`), beside
/// the piles it feeds, as on a physical table.
struct CanfieldTopRowView: View {
@Bindable var viewModel: SolitaireViewModel
/// Event wiring only; never read in body.
let session: SolitaireViewModel
let board: TopRowSnapshot
let selection: SelectionSnapshot
let cardSize: CGSize
let columnSpacing: CGFloat
let wasteFanSpacing: CGFloat
Expand All @@ -27,7 +29,10 @@ struct CanfieldTopRowView: View {
var body: some View {
HStack(alignment: .top, spacing: columnSpacing) {
StockView(
viewModel: viewModel,
session: session,
stockCount: board.stockCount,
canInteract: board.canInteractWithStock,
recyclesRemaining: board.stockRecyclesRemaining,
cardSize: cardSize,
isHintTargeted: isStockHinted,
hintHighlightOpacity: hintHighlightOpacity,
Expand All @@ -36,7 +41,9 @@ struct CanfieldTopRowView: View {
.frame(width: cardSize.width, alignment: .leading)

WasteView(
viewModel: viewModel,
session: session,
cards: board.visibleWasteCards,
selection: selection,
cardSize: cardSize,
fanSpacing: wasteFanSpacing,
isHintTargeted: isWasteHinted,
Expand All @@ -59,8 +66,11 @@ struct CanfieldTopRowView: View {

ForEach(0..<4, id: \.self) { index in
FoundationView(
viewModel: viewModel,
session: session,
pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil,
index: index,
placeholder: board.foundationPlaceholder,
selection: selection,
cardSize: cardSize,
isTargeted: activeTarget == .foundation(index),
isHintTargeted: hintedTarget == .foundation(index),
Expand All @@ -85,7 +95,11 @@ struct CanfieldTopRowView: View {
/// stock, as on a physical table — a spacer pair, then the four tableau
/// piles aligned directly beneath the four foundations.
struct CanfieldBoardRowView: View {
@Bindable var viewModel: SolitaireViewModel
/// Event wiring only; never read in body.
let session: SolitaireViewModel
let reserve: [Card]
let tableau: [[Card]]
let selection: SelectionSnapshot
let cardSize: CGSize
let columnSpacing: CGFloat
let faceDownOffset: CGFloat
Expand All @@ -104,7 +118,9 @@ struct CanfieldBoardRowView: View {
var body: some View {
HStack(alignment: .top, spacing: columnSpacing) {
CanfieldReserveView(
viewModel: viewModel,
session: session,
reserve: reserve,
selection: selection,
cardSize: cardSize,
isCardTiltEnabled: isCardTiltEnabled,
cardTilts: $cardTilts,
Expand All @@ -122,7 +138,10 @@ struct CanfieldBoardRowView: View {
}

TableauRowView(
viewModel: viewModel,
session: session,
tableau: tableau,
variant: .canfield,
selection: selection,
cardSize: cardSize,
columnSpacing: columnSpacing,
faceDownOffset: faceDownOffset,
Expand All @@ -148,7 +167,10 @@ struct CanfieldBoardRowView: View {
/// The reserve pile ("the demon"): a face-down packet whose exposed top card
/// is always playable. It is never a drop target — cards only ever leave.
struct CanfieldReserveView: View {
@Bindable var viewModel: SolitaireViewModel
/// Event wiring only; never read in body.
let session: SolitaireViewModel
let reserve: [Card]
let selection: SelectionSnapshot
let cardSize: CGSize
let isCardTiltEnabled: Bool
@Binding var cardTilts: [UUID: Double]
Expand All @@ -158,22 +180,20 @@ struct CanfieldReserveView: View {
let dragGesture: (DragOrigin) -> AnyGesture<DragGesture.Value>

var body: some View {
let reserve = viewModel.state.reserve
let topCard = reserve.last
let isDragSource: Bool = {
guard viewModel.isDragging, let selection = viewModel.selection else { return false }
if case .reserve = selection.source {
if case .reserve = selection.dragSource {
return true
}
return false
}()
let accessibleTopCard: Card? = topCard.flatMap { card in
guard card.isFaceUp else { return nil }
let isDragged = viewModel.isDragging && viewModel.isSelected(card: card)
let isDragged = selection.isDragging && selection.isSelected(card)
return isDragged || hiddenCardIDs.contains(card.id) ? nil : card
}
let isAccessibleTopCardSelected = accessibleTopCard.map {
viewModel.isSelected(card: $0)
selection.isSelected($0)
} ?? false

VStack(spacing: 4) {
Expand All @@ -183,11 +203,11 @@ struct CanfieldReserveView: View {
CardBackView(cardSize: cardSize)
}
if let topCard, topCard.isFaceUp {
let isDragged = viewModel.isDragging && viewModel.isSelected(card: topCard)
let isDragged = selection.isDragging && selection.isSelected(topCard)
let isHidden = hiddenCardIDs.contains(topCard.id)
CardView(
card: topCard,
isSelected: viewModel.isSelected(card: topCard),
isSelected: selection.isSelected(topCard),
cardSize: cardSize,
isCardTiltEnabled: isCardTiltEnabled,
cardTilts: $cardTilts,
Expand All @@ -209,7 +229,7 @@ struct CanfieldReserveView: View {
.allowsHitTesting(false)
}
.onTapGesture {
viewModel.handleReserveTap()
session.handleReserveTap()
}
.zIndex(isDragSource ? 10 : 0)
.accessibilityElement(children: .ignore)
Expand All @@ -226,3 +246,39 @@ struct CanfieldReserveView: View {
return "\(topCard.accessibilityName). \(count) cards"
}
}

/// See TableauPileView's Equatable note for the exclusion contract.
extension CanfieldReserveView: Equatable {
nonisolated static func == (lhs: CanfieldReserveView, rhs: CanfieldReserveView) -> Bool {
lhs.session === rhs.session
&& lhs.reserve == rhs.reserve
&& lhs.selection == rhs.selection
&& lhs.cardSize == rhs.cardSize
&& lhs.isCardTiltEnabled == rhs.isCardTiltEnabled
&& lhs.hiddenCardIDs == rhs.hiddenCardIDs
&& lhs.hintedCardIDs == rhs.hintedCardIDs
&& lhs.hintWiggleToken == rhs.hintWiggleToken
}
}

/// See TableauPileView's Equatable note for the exclusion contract.
extension CanfieldBoardRowView: Equatable {
nonisolated static func == (lhs: CanfieldBoardRowView, rhs: CanfieldBoardRowView) -> Bool {
lhs.session === rhs.session
&& lhs.reserve == rhs.reserve
&& lhs.tableau == rhs.tableau
&& lhs.selection == rhs.selection
&& lhs.cardSize == rhs.cardSize
&& lhs.columnSpacing == rhs.columnSpacing
&& lhs.faceDownOffset == rhs.faceDownOffset
&& lhs.faceUpOffset == rhs.faceUpOffset
&& lhs.maxPileHeight == rhs.maxPileHeight
&& lhs.activeTarget == rhs.activeTarget
&& lhs.hintedTarget == rhs.hintedTarget
&& lhs.hintHighlightOpacity == rhs.hintHighlightOpacity
&& lhs.isCardTiltEnabled == rhs.isCardTiltEnabled
&& lhs.hiddenCardIDs == rhs.hiddenCardIDs
&& lhs.hintedCardIDs == rhs.hintedCardIDs
&& lhs.hintWiggleToken == rhs.hintWiggleToken
}
}
22 changes: 16 additions & 6 deletions ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import SwiftUI
import Observation

struct FortyThievesTopRowView: View {
@Bindable var viewModel: SolitaireViewModel
/// Event wiring only; never read in body.
let session: SolitaireViewModel
let board: TopRowSnapshot
let selection: SelectionSnapshot
let cardSize: CGSize
let columnSpacing: CGFloat
let wasteFanSpacing: CGFloat
Expand All @@ -25,7 +27,10 @@ struct FortyThievesTopRowView: View {
// Stock and waste on the left like Klondike's, then the eight
// foundations — two per suit — aligned over tableau columns 3-10.
StockView(
viewModel: viewModel,
session: session,
stockCount: board.stockCount,
canInteract: board.canInteractWithStock,
recyclesRemaining: board.stockRecyclesRemaining,
cardSize: cardSize,
isHintTargeted: isStockHinted,
hintHighlightOpacity: hintHighlightOpacity,
Expand All @@ -34,7 +39,9 @@ struct FortyThievesTopRowView: View {
.frame(width: cardSize.width, alignment: .leading)

WasteView(
viewModel: viewModel,
session: session,
cards: board.visibleWasteCards,
selection: selection,
cardSize: cardSize,
fanSpacing: wasteFanSpacing,
isHintTargeted: isWasteHinted,
Expand All @@ -53,10 +60,13 @@ struct FortyThievesTopRowView: View {
// during a game switch this row can re-evaluate against the
// incoming variant's four-foundation state before the board
// replaces it.
ForEach(viewModel.state.foundations.indices, id: \.self) { index in
ForEach(board.foundations.indices, id: \.self) { index in
FoundationView(
viewModel: viewModel,
session: session,
pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil,
index: index,
placeholder: board.foundationPlaceholder,
selection: selection,
cardSize: cardSize,
isTargeted: activeTarget == .foundation(index),
isHintTargeted: hintedTarget == .foundation(index),
Expand Down
Loading