diff --git a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift index 728f24e..86ec68c 100644 --- a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift @@ -7,6 +7,15 @@ import Foundation /// of `UndoAnimationCoordinator`'s `.dealTableauRow` flight, which flies the /// same cards back to the same stock anchors. enum DealAnimationCoordinator { + /// A concrete, already-resolved source for a fresh-board deal. Callers + /// choose the semantic source first, then wait for its geometry; a missing + /// stock frame can never silently turn a stock deal into an above-board + /// deal. + enum NewGameDealSource { + case stock(frame: CGRect) + case aboveBoard(boardSize: CGSize) + } + struct Plan { let cards: [DrawAnimationCard] let cardIDs: Set @@ -108,8 +117,7 @@ enum DealAnimationCoordinator { static func makeNewGameDealPlan( dealtCards: [Card], cardFrames: [UUID: CGRect], - stockFrame: CGRect, - boardSize: CGSize + source: NewGameDealSource ) -> Plan? { let flying = dealtCards.compactMap { card -> (card: Card, frame: CGRect)? in guard let frame = cardFrames[card.id] else { return nil } @@ -118,10 +126,12 @@ enum DealAnimationCoordinator { guard !flying.isEmpty else { return nil } let start: CGPoint - if stockFrame != .zero { + switch source { + case .stock(let stockFrame): + guard !stockFrame.isEmpty else { return nil } start = CGPoint(x: stockFrame.midX, y: stockFrame.midY) - } else { - guard boardSize != .zero else { return nil } + case .aboveBoard(let boardSize): + guard boardSize.width > 0, boardSize.height > 0 else { return nil } let cardHeight = flying[0].frame.height start = CGPoint(x: boardSize.width * 0.5, y: -cardHeight) } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 6a70909..a0bf059 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -12,20 +12,31 @@ struct DropTargetFrameKey: PreferenceKey { } } -// Single-frame keys must ignore sizeless candidates: sibling subtrees that -// never set the key still run reduce, and a last-wins reducer lets them erase -// the real frame (the draw animation then never runs). Rejecting only `.zero` -// is not enough — a subtree measured before layout reports a rect with a real -// origin but no size, e.g. `(148, 0, 0 x 0)`, which clears that test and -// clobbers the live frame. Size is what makes a candidate meaningful here. -struct StockFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero +/// One layout-pass snapshot for fresh-deal source and destination frames. +/// Keeping them in one preference value prevents a cold layout from combining +/// current card destinations with a missing or stale stock anchor from another +/// pass. +struct BoardFramePreferences: Equatable { + var dealEventID: UUID? + var stockFrame: CGRect = .zero + var cardFrames: [UUID: CGRect] = [:] +} - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { +// Sizeless candidates are never meaningful geometry. A subtree measured +// before layout can report a real origin with a zero size, so checking only +// against `.zero` would still let it clobber the live stock frame. +struct BoardFrameKey: PreferenceKey { + static var defaultValue = BoardFramePreferences() + + static func reduce(value: inout BoardFramePreferences, nextValue: () -> BoardFramePreferences) { let next = nextValue() - if !next.isEmpty { - value = next + if let dealEventID = next.dealEventID { + value.dealEventID = dealEventID } + if !next.stockFrame.isEmpty { + value.stockFrame = next.stockFrame + } + value.cardFrames.merge(next.cardFrames, uniquingKeysWith: { _, new in new }) } } @@ -40,14 +51,6 @@ struct WasteFrameKey: PreferenceKey { } } -struct CardFrameKey: PreferenceKey { - static var defaultValue: [UUID: CGRect] = [:] - - static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, new in new }) - } -} - struct CardFramePreference: ViewModifier { let cardID: UUID let xOffset: CGFloat @@ -64,7 +67,10 @@ struct CardFramePreference: ViewModifier { height: frame.height ) Color.clear - .preference(key: CardFrameKey.self, value: [cardID: adjustedFrame]) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences(cardFrames: [cardID: adjustedFrame]) + ) } ) } @@ -77,6 +83,13 @@ extension View { } struct ContentView: View { + private struct PendingNewGameDeal { + let eventID: UUID + let cards: [Card] + let startsFromStock: Bool + let token: UUID + } + @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var isReduceMotionEnabled @@ -106,6 +119,7 @@ struct ContentView: View { @State private var wasteReturnAnchorCardID: UUID? @State private var wasteReturnAnchorFrame: CGRect? @State private var cardFrames: [UUID: CGRect] = [:] + @State private var boardFrameDealEventID: UUID? @State private var cardTilts: [UUID: Double] = [:] #if os(iOS) @State private var isShowingSettings = false @@ -120,6 +134,7 @@ struct ContentView: View { @State private var dealAnimationCards: [DrawAnimationCard] = [] @State private var dealingCardIDs: Set = [] @State private var dealAnimationToken = UUID() + @State private var pendingNewGameDeal: PendingNewGameDeal? /// True while the active deal flight is a fresh-board deal, whose queued /// cards hide until takeoff; stock deals keep their queues visible. @State private var dealFlightHidesQueuedCards = false @@ -593,6 +608,7 @@ struct ContentView: View { @ViewBuilder private func boardRoot(for geometry: GeometryProxy) -> some View { let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant.boardColumnCount) + let boardDealEventID = viewModel.latestBoardDealEvent?.id #if os(iOS) let metrics = Layout.metrics( for: geometry.size, @@ -796,6 +812,12 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .coordinateSpace(name: "board") + .transformPreference(BoardFrameKey.self) { preferences in + // Geometry belongs to a specific fresh-board event. Including the + // event in the Equatable preference guarantees that an unchanged + // redeal still publishes a new, causally tagged snapshot. + preferences.dealEventID = boardDealEventID + } .sensoryFeedback(trigger: hapticFeedback.trigger) { hapticFeedback.feedbackForTrigger } @@ -803,23 +825,20 @@ struct ContentView: View { 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 - } + .onPreferenceChange(BoardFrameKey.self) { frames in + acceptBoardFramePreferences(frames) } .onAppear { boardViewportSize = geometry.size + resolvePendingNewGameDealIfReady() refreshLoadedWinPresentationIfNeeded() } .onChange(of: geometry.size) { _, newSize in boardViewportSize = newSize + resolvePendingNewGameDealIfReady() refreshLoadedWinPresentationIfNeeded() } .onChange(of: viewModel.isWin) { _, isWin in @@ -900,15 +919,6 @@ struct ContentView: View { guard let event else { return } startDealAnimation(for: event.dealtCardIDs) } - .onChange(of: viewModel.latestBoardDealEvent) { _, event in - // A fresh board deals itself in from the stock: new game, - // redeal, Golf's next hole, or a game switch that found nothing - // to restore. Like the tableau deal above, the session publishes - // an explicit event — restores never set it — so a hydrated - // board can never replay a deal that already happened. - guard event != nil else { return } - startNewGameDealAnimation() - } .onChange(of: viewModel.movesCount) { _, movesCount in // The board stays live during a deal flight, and a move that lands // mid-flight can relocate a card the overlay is still flying toward @@ -1171,18 +1181,19 @@ struct ContentView: View { transaction.disablesAnimations = true withTransaction(transaction) { mutation() + guard let event = viewModel.latestBoardDealEvent, + event != eventBeforeMutation else { return } + // Redeal reuses card IDs, so no geometry from the outgoing board + // may satisfy the fresh deal. Both frame sets republish together + // from the new board through BoardFrameKey. + invalidateBoardFramePreferences() + prepareNewGameDealAnimation(for: event) } // Only wipe when the mutation actually dealt a fresh board: Golf's // final-hole advance completes the match and deliberately stays on // the finished board, and sweeping copies off a board that never // leaves would read as a ghost board peeling away. guard viewModel.latestBoardDealEvent != eventBeforeMutation else { return } - // Drop the outgoing layout's frames: Redeal reuses the outgoing - // game's card IDs, so stale entries would satisfy the deal flight's - // readiness check and land cards at their played-out positions. - // The wipe is unaffected — it plans from the snapshot captured - // above — and the fresh board republishes within a frame. - cardFrames = [:] startBoardWipe(for: wipedCards, frames: wipeFrames) } @@ -1210,6 +1221,7 @@ struct ContentView: View { DispatchQueue.main.asyncAfter(deadline: .now() + total) { guard wipeAnimationToken == token else { return } wipeAnimationCards = [] + resolvePendingNewGameDealIfReady() } } @@ -1253,7 +1265,11 @@ struct ContentView: View { isSettlingHydratedGame = true isScreenshotSession = false let payload = GamePersistence.load(mode: mode, from: modelContext) - viewModel.activateGame(mode, restoringFrom: payload) + let restored = viewModel.activateGame(mode, restoringFrom: payload) + if !restored, let event = viewModel.latestBoardDealEvent { + invalidateBoardFramePreferences() + prepareNewGameDealAnimation(for: event) + } rememberSelectedGame() reconcileTimeScoringPause() winCelebration.syncForLoadedGame( @@ -1632,16 +1648,43 @@ struct ContentView: View { } private func cancelDealAnimation() { + pendingNewGameDeal = nil dealAnimationCards = [] dealingCardIDs = [] dealFlightHidesQueuedCards = false dealAnimationToken = UUID() } - /// Flies a fresh board's cards in from the stock, sharing the deal - /// flight's overlay, hiding, and cancellation rules — a move or a game - /// switch mid-deal lands the flight the same way it lands a stock deal. - private func startNewGameDealAnimation() { + private func invalidateBoardFramePreferences() { + boardFrameDealEventID = nil + stockFrame = .zero + cardFrames = [:] + } + + private func acceptBoardFramePreferences(_ preferences: BoardFramePreferences) { + // A callback from the outgoing layout may already be queued when a + // new game or restore becomes current. Never let that stale snapshot + // replace the current board's geometry, even if card IDs match. + guard preferences.dealEventID == viewModel.latestBoardDealEvent?.id else { + return + } + + if boardFrameDealEventID != preferences.dealEventID { + boardFrameDealEventID = preferences.dealEventID + } + if !framesApproximatelyEqual(stockFrame, preferences.stockFrame) { + stockFrame = preferences.stockFrame + } + if shouldUpdateCardFrames(with: preferences.cardFrames) { + cardFrames = preferences.cardFrames + } + resolvePendingNewGameDealIfReady() + } + + /// Arms a fresh deal before its board is allowed to render. Real cards are + /// hidden immediately; the flight begins only after one current layout + /// snapshot contains every destination and the required source anchor. + private func prepareNewGameDealAnimation(for event: SolitaireViewModel.BoardDealEvent) { cancelDealAnimation() let dealtCards = DealAnimationCoordinator.newGameDealSequence(in: viewModel.state) guard !dealtCards.isEmpty else { return } @@ -1653,48 +1696,62 @@ struct ContentView: View { dealAnimationMovesCount = viewModel.movesCount let token = UUID() dealAnimationToken = token - DispatchQueue.main.async { - resolveDealFlight( - token: token, - attemptsRemaining: 75, - retryInterval: 0.02, - isReady: { - // A fresh board's landing frames arrive a beat after the - // state swap (dealFreshBoard drops the stale set first — - // New Game mints new card IDs, but Redeal reuses the - // outgoing game's, whose leftover frames would otherwise - // pass this check) — and whole seconds later when the - // deal rides a game switch or first launch, where the - // board tree is still building. Patience here is cheap: - // attempts burn only while frames are missing, and any - // interaction lands the flight through the usual cancel - // paths. The deal also waits for the wipe sweep to - // finish clearing the old board off the felt — the - // dealer doesn't deal onto a messy table. - wipeAnimationCards.isEmpty - && dealtCards.allSatisfy { cardFrames[$0.id] != nil } - }, - makePlan: { - DealAnimationCoordinator.makeNewGameDealPlan( - dealtCards: dealtCards, - cardFrames: cardFrames, - stockFrame: stockFrame, - boardSize: boardViewportSize - ) - }, - onTakeoff: { - SoundManager.shared.play(.cardDrawFromStock) - HapticManager.shared.play(.stockDraw) - } - ) + pendingNewGameDeal = PendingNewGameDeal( + eventID: event.id, + cards: dealtCards, + startsFromStock: !viewModel.state.stock.isEmpty, + token: token + ) + resolvePendingNewGameDealIfReady() + } + + /// Geometry publication drives this transition. There is no retry clock: + /// whichever required frame arrives last completes the coherent snapshot + /// and starts the flight exactly once. + private func resolvePendingNewGameDealIfReady() { + guard let pending = pendingNewGameDeal, + viewModel.latestBoardDealEvent?.id == pending.eventID, + dealAnimationToken == pending.token, + boardFrameDealEventID == pending.eventID, + wipeAnimationCards.isEmpty, + pending.cards.allSatisfy({ cardFrames[$0.id] != nil }) else { return } + + let source: DealAnimationCoordinator.NewGameDealSource + if pending.startsFromStock { + guard !stockFrame.isEmpty else { return } + source = .stock(frame: stockFrame) + } else { + guard boardViewportSize.width > 0, boardViewportSize.height > 0 else { return } + source = .aboveBoard(boardSize: boardViewportSize) + } + + guard let plan = DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: pending.cards, + cardFrames: cardFrames, + source: source + ) else { + assertionFailure("Complete fresh-deal geometry must produce a flight plan") + cancelDealAnimation() + return + } + + pendingNewGameDeal = nil + SoundManager.shared.play(.cardDrawFromStock) + HapticManager.shared.play(.stockDraw) + dealAnimationCards = plan.cards + dealingCardIDs = plan.cardIDs + + let total = motion.duration(plan.maxDelay + plan.travelDuration + plan.settleDuration) + DispatchQueue.main.asyncAfter(deadline: .now() + total) { + guard dealAnimationToken == pending.token else { return } + dealAnimationCards = [] + dealingCardIDs = [] } } - /// One resolver drives both deal flights (the stock deal and the fresh - /// board): poll until the flight's readiness condition holds, build its - /// plan or land the flight, then tear the overlay down once the last - /// card has settled — token-gated throughout, so a superseding flight - /// or any cancel path orphans the loop harmlessly. + /// Stock-onto-tableau deals can bank a card immediately and therefore use + /// their existing bounded resolver; fresh-board deals require complete + /// geometry and are driven by preference publication above. private func resolveDealFlight( token: UUID, attemptsRemaining: Int, @@ -2142,6 +2199,9 @@ struct ContentView: View { } else { winCelebration.reset(to: .idle) viewModel.newGame(mode: launchMode) + if let event = viewModel.latestBoardDealEvent { + prepareNewGameDealAnimation(for: event) + } rememberSelectedGame() persistGameNow() } diff --git a/ComputerSolitaire/Views/Shared/StockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift index bddcc6d..7b6e07f 100644 --- a/ComputerSolitaire/Views/Shared/StockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -54,7 +54,12 @@ struct StockView: View { .background( GeometryReader { proxy in Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences( + stockFrame: proxy.frame(in: .named("board")) + ) + ) } ) .contentShape(Rectangle()) diff --git a/ComputerSolitaire/Views/Shared/TableauStockView.swift b/ComputerSolitaire/Views/Shared/TableauStockView.swift index 59d277a..765f39f 100644 --- a/ComputerSolitaire/Views/Shared/TableauStockView.swift +++ b/ComputerSolitaire/Views/Shared/TableauStockView.swift @@ -44,7 +44,12 @@ struct TableauStockView: View { .background( GeometryReader { proxy in Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences( + stockFrame: proxy.frame(in: .named("board")) + ) + ) } ) .contentShape(Rectangle()) diff --git a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift index e966fff..82d7af2 100644 --- a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift +++ b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift @@ -81,8 +81,7 @@ final class NewGameDealAnimationTests: XCTestCase { let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: stockFrame, - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: stockFrame) ) XCTAssertEqual(plan?.cards.count, 28) @@ -94,15 +93,14 @@ final class NewGameDealAnimationTests: XCTestCase { // Verifies the stockless variants (FreeCell, Yukon) deal from an // invisible deck just above the board's top edge. - func testPlanFallsBackToAboveBoardWhenStockless() { + func testPlanUsesAboveBoardSourceWhenStockless() { let state = GameStateFixtures.seededFreeCellDeal(seed: 8) let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: .zero, - boardSize: CGSize(width: 800, height: 600) + source: .aboveBoard(boardSize: CGSize(width: 800, height: 600)) ) XCTAssertEqual(plan?.cards.first?.start, CGPoint(x: 400, y: -112)) @@ -116,10 +114,64 @@ final class NewGameDealAnimationTests: XCTestCase { DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: .zero, - boardSize: .zero + source: .aboveBoard(boardSize: .zero) ) ) + XCTAssertNil( + DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: sequence, + cardFrames: frames(for: sequence), + source: .stock(frame: .zero) + ) + ) + } + + // The launch planner receives one coherent layout snapshot: source and + // destination frames reduce together, and a sizeless layout candidate + // cannot erase the real stock anchor or deal generation. + func testBoardFramePreferencesCombineStockAndCardsAtomically() { + let dealEventID = UUID() + let stockFrame = CGRect(x: 20, y: 30, width: 80, height: 112) + let card = GameStateFixtures.seededKlondikeDeal(seed: 15).tableau[0][0] + let cardFrame = CGRect(x: 120, y: 200, width: 80, height: 112) + var snapshot = BoardFrameKey.defaultValue + + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(dealEventID: dealEventID, stockFrame: stockFrame) + } + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(cardFrames: [card.id: cardFrame]) + } + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(stockFrame: CGRect(x: 400, y: 0, width: 0, height: 0)) + } + + XCTAssertEqual(snapshot.dealEventID, dealEventID) + XCTAssertEqual(snapshot.stockFrame, stockFrame) + XCTAssertEqual(snapshot.cardFrames, [card.id: cardFrame]) + } + + // Redeal deliberately preserves card identity and may reproduce the exact + // same geometry. The event generation must still change the Equatable + // preference so SwiftUI publishes a fresh snapshot to the deal resolver. + func testBoardFramePreferencesDistinguishIdenticalRedealGeometry() { + let firstDealID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let redealID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let stockFrame = CGRect(x: 20, y: 30, width: 80, height: 112) + let card = GameStateFixtures.seededKlondikeDeal(seed: 16).tableau[0][0] + let cardFrames = [card.id: CGRect(x: 120, y: 200, width: 80, height: 112)] + let firstDeal = BoardFramePreferences( + dealEventID: firstDealID, + stockFrame: stockFrame, + cardFrames: cardFrames + ) + let identicalRedeal = BoardFramePreferences( + dealEventID: redealID, + stockFrame: stockFrame, + cardFrames: cardFrames + ) + + XCTAssertNotEqual(firstDeal, identicalRedeal) } func testPlanSkipsCardsWithoutLandingFrames() { @@ -130,8 +182,7 @@ final class NewGameDealAnimationTests: XCTestCase { let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: framedCards), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual(plan?.cards.count, sequence.count - 3) @@ -147,8 +198,7 @@ final class NewGameDealAnimationTests: XCTestCase { let spiderPlan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: spiderSequence, cardFrames: frames(for: spiderSequence), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual(spiderSequence.count, 54) XCTAssertEqual( @@ -162,8 +212,7 @@ final class NewGameDealAnimationTests: XCTestCase { let canfieldPlan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: canfieldSequence, cardFrames: frames(for: canfieldSequence), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual( canfieldPlan?.maxDelay ?? .infinity,