Skip to content
Closed
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -43,6 +70,7 @@ jobs:

ios-build:
name: iOS Build
needs: swiftlint
runs-on: macos-26

steps:
Expand Down
26 changes: 26 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
austin-smith marked this conversation as resolved.
- unhandled_throwing_task

reporter: xcode
109 changes: 72 additions & 37 deletions ComputerSolitaire/Animation/UndoAnimationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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],
Expand All @@ -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(
Expand All @@ -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)
}
}
109 changes: 63 additions & 46 deletions ComputerSolitaire/Animation/WinCascadeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,71 +77,88 @@ 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
}
if states[index].isSettled {
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
}

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
Expand Down
11 changes: 7 additions & 4 deletions ComputerSolitaire/ComputerSolitaireApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ struct ComputerSolitaireApp: App {
private var appCommands: some Commands {
#if os(macOS)
CommandGroup(replacing: .appInfo) {
Button(action: {
Button(
action: {
openWindow(id: "about")
}) {
Label("About Computer Solitaire", systemImage: "info.circle")
}
},
label: {
Label("About Computer Solitaire", systemImage: "info.circle")
}
)
}
CommandGroup(replacing: .help) {
Button {
Expand Down
4 changes: 3 additions & 1 deletion ComputerSolitaire/Feedback/HapticManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading