Compare commits

..

No commits in common. "ace6303a62f1794b4929402425a4e5050cf4c272" and "f1028bd6267a004e0bc0883ca51629574c6c8510" have entirely different histories.

11 changed files with 4259 additions and 4918 deletions

File diff suppressed because it is too large Load Diff

View File

@ -19,16 +19,6 @@ enum Design {
/// Set to true to show layout debug borders on views
static let showDebugBorders = false
/// Set to true to show debug log statements
static let showDebugLogs = false
/// Debug logger - only prints when showDebugLogs is true
static func debugLog(_ message: String) {
if showDebugLogs {
print(message)
}
}
// MARK: - Shared Constants (from CasinoKit)
typealias Spacing = CasinoDesign.Spacing
@ -81,33 +71,6 @@ enum Design {
// Table
static let tableHeight: CGFloat = 280
// Result banner
static let resultRowAmountWidth: CGFloat = 70
static let resultRowResultWidth: CGFloat = 150
// Side bet zones
static let sideBetLabelFontSize: CGFloat = 13
static let sideBetPayoutFontSize: CGFloat = 11
// Side bet toast notifications
static let sideBetToastTitleFontSize: CGFloat = 11
static let sideBetToastResultFontSize: CGFloat = 12
static let sideBetToastAmountFontSize: CGFloat = 14
}
// MARK: - Blackjack-Specific Delays
enum Delay {
/// Delay before playing game over sound
static let gameOverSound: Double = 1.0
}
// MARK: - Blackjack-Specific Animation
enum AnimationExtra {
/// Bounce for side bet toast animations
static let toastBounce: Double = 0.4
}
}

View File

@ -35,8 +35,10 @@ struct ActionButtonsView: View {
EmptyView()
}
}
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
.animation(.easeInOut(duration: Design.Animation.standard), value: state.currentBet > 0)
}
.frame(height: containerHeight)
.frame(minHeight: containerHeight)
.padding(.horizontal, Design.Spacing.large)
}

View File

@ -130,22 +130,20 @@ struct GameTableView: View {
)
.frame(maxWidth: maxContentWidth)
// Chip selector - only shown during betting phase AND when result banner is NOT showing
if state.currentPhase == .betting && !state.showResultBanner {
// Chip selector - only shown during betting phase
if state.currentPhase == .betting {
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "ChipSpacer")
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.minBetForChipSelector,
currentBet: state.minBetForChipSelector, // Use min bet so chips stay enabled if any bet type can accept more
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
.onAppear {
Design.debugLog("🎰 Chip selector APPEARED (banner showing: \(state.showResultBanner))")
}
.onDisappear {
Design.debugLog("🎰 Chip selector DISAPPEARED")
}
}
// Action buttons - minimal spacing during player turn
@ -154,33 +152,21 @@ struct GameTableView: View {
.padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
}
.frame(maxWidth: .infinity, alignment: .top)
.zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in
Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)")
}
.frame(maxWidth: .infinity)
// Insurance popup overlay (covers entire screen)
if state.currentPhase == .insurance {
Color.clear
.overlay(alignment: .center) {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.zIndex(100)
}
// Result banner overlay
if state.showResultBanner, let result = state.lastRoundResult {
Color.clear
.overlay(alignment: .center) {
ResultBannerView(
result: result,
currentBalance: state.balance,
@ -188,43 +174,20 @@ struct GameTableView: View {
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
.onAppear {
Design.debugLog("🎯 RESULT BANNER APPEARED")
}
.onDisappear {
Design.debugLog("❌ RESULT BANNER DISAPPEARED")
}
}
.ignoresSafeArea()
.allowsHitTesting(true)
.zIndex(100)
}
// Confetti for wins (matching Baccarat pattern)
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
ConfettiView()
.zIndex(101)
}
// Game over
if state.isGameOver && !state.showResultBanner {
Color.clear
.overlay(alignment: .center) {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.zIndex(100)
}
}
.onChange(of: state.playerHands.count) { oldCount, newCount in
Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)")
}
.onChange(of: state.balance) { oldBalance, newBalance in
Design.debugLog("💰 Balance: \(oldBalance)\(newBalance)")
}
}

View File

@ -54,6 +54,7 @@ struct ResultBannerView: View {
ZStack {
// Full screen dark background
Color.black.opacity(Design.Opacity.strong)
.ignoresSafeArea()
// Content card
VStack(spacing: Design.Spacing.xLarge) {
@ -192,18 +193,18 @@ struct ResultBannerView: View {
.shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
.frame(maxWidth: CasinoDesign.Size.maxModalWidth)
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.scaleEffect(showContent ? 1.0 : 0.8)
.opacity(showContent ? 1.0 : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) {
showContent = true
}
// Play game over sound if out of chips (after a delay so it doesn't overlap with result sound)
if isGameOver {
Task {
try? await Task.sleep(for: .seconds(Design.Delay.gameOverSound))
try? await Task.sleep(for: .seconds(1))
SoundManager.shared.play(.gameOver)
}
}
@ -221,8 +222,6 @@ struct ResultRow: View {
let result: HandResult
var amount: Int? = nil
private var showDebugBorders: Bool { Design.showDebugBorders }
private var amountText: String? {
guard let amount = amount else { return nil }
if amount > 0 {
@ -244,31 +243,24 @@ struct ResultRow: View {
var body: some View {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Show amount if provided
if let amountText = amountText {
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
.frame(width: 70, alignment: .trailing)
}
Text(result.displayText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
.foregroundStyle(result.color)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
.frame(width: 100, alignment: .trailing)
}
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
}
}
@ -280,8 +272,6 @@ struct SideBetResultRow: View {
let isWin: Bool
let amount: Int
private var showDebugBorders: Bool { Design.showDebugBorders }
private var amountText: String {
if amount > 0 {
return "+$\(amount)"
@ -305,28 +295,23 @@ struct SideBetResultRow: View {
var body: some View {
HStack {
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
.frame(width: 70, alignment: .trailing)
Text(resultText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
.foregroundStyle(resultColor)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.frame(width: 100, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
}
}

View File

@ -31,9 +31,9 @@ struct PlayerHandsView: View {
// Display hands in reverse order (right to left play order)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
ForEach(hands.indices.reversed(), id: \.self) { index in
PlayerHandView(
hand: hand,
hand: hands[index],
isActive: index == activeHandIndex && isPlayerTurn,
showCardCount: showCardCount,
// Hand numbers: rightmost (index 0) is Hand 1, played first
@ -41,39 +41,35 @@ struct PlayerHandsView: View {
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
.id(hand.id)
.transition(.scale.combined(with: .opacity))
.id(index)
}
}
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
}
.scrollClipDisabled()
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
.defaultScrollAnchor(.center) // Center the content by default
.onChange(of: activeHandIndex) { _, newIndex in
scrollToActiveHand(proxy: proxy)
scrollToHand(proxy: proxy, index: newIndex)
}
.onChange(of: totalCardCount) { _, _ in
// Scroll to active hand when cards are added (hit)
scrollToActiveHand(proxy: proxy)
scrollToHand(proxy: proxy, index: activeHandIndex)
}
.onChange(of: hands.count) { _, _ in
// Scroll to active hand when split occurs
scrollToActiveHand(proxy: proxy)
scrollToHand(proxy: proxy, index: activeHandIndex)
}
.onAppear {
scrollToActiveHand(proxy: proxy)
scrollToHand(proxy: proxy, index: activeHandIndex)
}
}
.frame(maxWidth: .infinity)
}
private func scrollToActiveHand(proxy: ScrollViewProxy) {
guard activeHandIndex < hands.count else { return }
let activeHandId = hands[activeHandIndex].id
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
proxy.scrollTo(activeHandId, anchor: .center)
proxy.scrollTo(index, anchor: .center)
}
}
}

View File

@ -18,9 +18,9 @@ struct SideBetToastView: View {
@State private var isShowing = false
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = Design.Size.sideBetToastTitleFontSize
@ScaledMetric(relativeTo: .callout) private var resultFontSize: CGFloat = Design.Size.sideBetToastResultFontSize
@ScaledMetric(relativeTo: .body) private var amountFontSize: CGFloat = Design.Size.sideBetToastAmountFontSize
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = 10
@ScaledMetric(relativeTo: .caption2) private var resultFontSize: CGFloat = 11
@ScaledMetric(relativeTo: .caption2) private var amountFontSize: CGFloat = 13
private var backgroundColor: Color {
isWin ? Color.green.opacity(Design.Opacity.heavy) : Color.red.opacity(Design.Opacity.heavy)
@ -68,14 +68,14 @@ struct SideBetToastView: View {
)
)
.shadow(color: borderColor.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
.scaleEffect(isShowing ? Design.Scale.normal : Design.Scale.shrunk)
.scaleEffect(isShowing ? 1.0 : 0.5)
.opacity(isShowing ? 1.0 : 0)
.offset(x: isShowing ? 0 : (showOnLeft ? -Design.Spacing.toastSlide : Design.Spacing.toastSlide))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title): \(result)")
.accessibilityValue(amountText)
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.AnimationExtra.toastBounce).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.4).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
isShowing = true
}
}

View File

@ -16,8 +16,8 @@ struct SideBetZoneView: View {
let isAtMax: Bool
let onTap: () -> Void
@ScaledMetric(relativeTo: .callout) private var labelFontSize: CGFloat = Design.Size.sideBetLabelFontSize
@ScaledMetric(relativeTo: .caption) private var payoutFontSize: CGFloat = Design.Size.sideBetPayoutFontSize
@ScaledMetric(relativeTo: .caption) private var labelFontSize: CGFloat = 11
@ScaledMetric(relativeTo: .caption2) private var payoutFontSize: CGFloat = 9
private var backgroundColor: Color {
switch betType {
@ -49,7 +49,7 @@ struct SideBetZoneView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.strokeBorder(
Color.white.opacity(Design.Opacity.hint),
lineWidth: Design.LineWidth.medium
lineWidth: Design.LineWidth.thin
)
)

View File

@ -1,17 +0,0 @@
//
// BlackjackTests.swift
// BlackjackTests
//
// Created by Matt Bruce on 12/17/25.
//
import Testing
@testable import Blackjack
struct BlackjackTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View File

@ -1,41 +0,0 @@
//
// BlackjackUITests.swift
// BlackjackUITests
//
// Created by Matt Bruce on 12/17/25.
//
import XCTest
final class BlackjackUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@ -1,33 +0,0 @@
//
// BlackjackUITestsLaunchTests.swift
// BlackjackUITests
//
// Created by Matt Bruce on 12/17/25.
//
import XCTest
final class BlackjackUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}