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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This file defines hard project constraints for any coding agent working in this
- Use plain lowercase kebab-case for branch names. Keep names descriptive and do not include issue numbers, prefixes, or namespaces such as `feature/`, `fix/`, usernames, or agent names.
- Before every commit or amend, show the exact current diff and validation, then get explicit approval. Branch or pull-request requests are not commit approval; later changes require fresh approval.
- Never amend, rebase, squash, reset, rewrite history, or force-push without explicit approval for that exact operation.
- Write commit messages entirely lowercase. Use the imperative mood for the subject, keep each commit focused on one logical change, do not use type or scope prefixes, and do not end the subject with a period. Add a body when the reason or important tradeoffs are not clear from the subject.
- Write commit messages entirely lowercase. Use the imperative mood for the subject, keep each commit focused on one logical change when the changes separate cleanly, do not use type or scope prefixes, and do not end the subject with a period. Add a body when the reason or important tradeoffs are not clear from the subject. Do not arbitrarily split existing completed work into multiple commits.
- Keep each pull request focused on one coherent change.
- Write concise, specific, imperative pull request titles in sentence case. Do not use prefixes or trailing periods, and make the title understandable without the branch name.
- Pull request descriptions must include `What Changed`, `Why`, and `Validation`. Include `UI Changes` only when the pull request changes the UI. Keep descriptions concise, self-contained, complete, and accurate to the final diff.
Expand Down
67 changes: 67 additions & 0 deletions ComputerSolitaire/Animation/DealAnimationCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import CoreGraphics
import Foundation

/// Builds the forward flight for a stock-onto-tableau deal (Spider's ten-card
/// row, Scorpion's three-card stock): overlay cards leave the stock in pile
/// order, flip face up in the air, and land on their piles' tops. The mirror
/// of `UndoAnimationCoordinator`'s `.dealTableauRow` flight, which flies the
/// same cards back to the same stock anchors.
enum DealAnimationCoordinator {
struct Plan {
let cards: [DrawAnimationCard]
let cardIDs: Set<UUID>
let token: UUID
let travelDuration: Double
/// Spring tail after the nominal travel time; the overlay comes down
/// once the cards have visibly settled.
let settleDuration: Double
/// The last card's takeoff delay; the whole deal is done after
/// `maxDelay + travelDuration + settleDuration`.
let maxDelay: Double
}

/// Per-card takeoff stagger: the packet leaves the stock as one quick
/// left-to-right sweep, reading as a deal rather than simultaneous pops.
static let staggerInterval: Double = 0.05

/// `dealtCards` in pile order (leftmost pile's card first). Cards without
/// a published frame — a card banked the instant it landed — are skipped;
/// they surface through the banking animation instead.
static func makeDealPlan(
dealtCards: [Card],
cardFrames: [UUID: CGRect],
stockFrame: CGRect
) -> Plan? {
guard !dealtCards.isEmpty, stockFrame != .zero else { return nil }

var items: [DrawAnimationCard] = []
for (index, card) in dealtCards.enumerated() {
guard let startFrame = UndoAnimationCoordinator.stockAnchorFrame(
for: index,
stockFrame: stockFrame
),
let endFrame = cardFrames[card.id] else {
continue
}
items.append(
DrawAnimationCard(
id: card.id,
card: card,
start: CGPoint(x: startFrame.midX, y: startFrame.midY),
end: CGPoint(x: endFrame.midX, y: endFrame.midY),
delay: staggerInterval * Double(index)
)
)
}
guard !items.isEmpty else { return nil }

return Plan(
cards: items,
cardIDs: Set(items.map(\.id)),
token: UUID(),
travelDuration: 0.32,
settleDuration: 0.12,
maxDelay: items.last?.delay ?? 0
)
}
}
46 changes: 41 additions & 5 deletions ComputerSolitaire/Animation/UndoAnimationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import Foundation

struct UndoAnimationItem: Identifiable {
let id: UUID
/// The card as it should render at the current flight phase. Plans build
/// items with the pre-undo face; when the flight starts, the driver swaps
/// in `endFaceUp` so the card visibly flips in the air — the mirror of the
/// forward deal/draw flights — instead of snapping faces on landing.
let card: Card
let endFaceUp: Bool
let startFrame: CGRect
let endFrame: CGRect
}
Expand All @@ -27,11 +32,21 @@ enum UndoAnimationCoordinator {
var targets: [UUID: UndoAnimationEndTarget] = [:]
let cardIDs = context.cardIDs

func endFaceUp(_ id: UUID, fallback: Card) -> Bool {
(afterCards[id] ?? fallback).isFaceUp
}

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))
items.append(UndoAnimationItem(
id: id,
card: card,
endFaceUp: endFaceUp(id, fallback: card),
startFrame: startFrame,
endFrame: startFrame
))
targets[id] = .card(id)
}
return Plan(items: items, targets: targets, needsPostUndoFrames: true)
Expand All @@ -47,18 +62,33 @@ enum UndoAnimationCoordinator {
) else {
continue
}
items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame))
items.append(UndoAnimationItem(
id: id,
card: card,
endFaceUp: endFaceUp(id, fallback: card),
startFrame: startFrame,
endFrame: startFrame
))
targets[id] = .stock(index)
}
return Plan(items: items, targets: targets, needsPostUndoFrames: false)

case .recycleWaste:
// These cards sit face down on the stock when the flight begins,
// so the pre-undo face keeps the takeoff honest; they flip face up
// in the air on their way back to the waste.
for (index, id) in cardIDs.enumerated() {
guard let card = afterCards[id] ?? beforeCards[id],
guard let card = beforeCards[id] ?? afterCards[id],
let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else {
continue
}
items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame))
items.append(UndoAnimationItem(
id: id,
card: card,
endFaceUp: endFaceUp(id, fallback: card),
startFrame: startFrame,
endFrame: startFrame
))
targets[id] = .card(id)
}
return Plan(items: items, targets: targets, needsPostUndoFrames: true)
Expand All @@ -70,7 +100,13 @@ enum UndoAnimationCoordinator {
for (index, id) in cardIDs.enumerated() {
guard let card = beforeCards[id] ?? afterCards[id],
let startFrame = cardFrames[id] else { continue }
items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame))
items.append(UndoAnimationItem(
id: id,
card: card,
endFaceUp: endFaceUp(id, fallback: card),
startFrame: startFrame,
endFrame: startFrame
))
targets[id] = .stock(index)
}
return Plan(items: items, targets: targets, needsPostUndoFrames: false)
Expand Down
3 changes: 2 additions & 1 deletion ComputerSolitaire/Fixtures/ScreenshotFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ enum ScreenshotFixtures {
ScreenshotFixture(name: "spider", title: "Spider – 2 suits"),
ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal"),
ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal"),
ScreenshotFixture(name: "golf", title: "Golf – fresh deal")
ScreenshotFixture(name: "golf", title: "Golf – fresh deal"),
ScreenshotFixture(name: "scorpion", title: "Scorpion – fresh deal")
]

static func payloadFromLaunchArguments() -> SavedGamePayload? {
Expand Down
Loading