From 22884fcba7409c2fd0b4fde7750dd0da7b1f729c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 17 Dec 2025 14:14:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- CasinoKit/GAME_TEMPLATE.md | 200 +++++++++++++++++- .../CasinoKit/Resources/Localizable.xcstrings | 60 ++++-- 2 files changed, 235 insertions(+), 25 deletions(-) diff --git a/CasinoKit/GAME_TEMPLATE.md b/CasinoKit/GAME_TEMPLATE.md index b260366..ff30fcf 100644 --- a/CasinoKit/GAME_TEMPLATE.md +++ b/CasinoKit/GAME_TEMPLATE.md @@ -412,6 +412,192 @@ Text(String(localized: "Blackjack!")) Text(String(localized: "Bust!")) ``` +## Responsive Layout (iPhone vs iPad) + +### The Problem + +On iPad, content that looks great on iPhone will **stretch to fill the screen**, making: +- Betting areas look awkward and disproportionate +- Cards appear too spread out +- Buttons become oversized +- Overlays cover the entire screen unnecessarily + +### The Solution: Constrained Width Containers + +Use `horizontalSizeClass` to detect iPad and constrain content width: + +```swift +struct GameTableView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + /// Whether we're on iPad (regular horizontal size class) + private var isIPad: Bool { + horizontalSizeClass == .regular + } + + /// Maximum content width based on device and orientation + private var maxContentWidth: CGFloat { + if isIPad { + // Landscape on iPad gets more width + return verticalSizeClass == .compact + ? CasinoDesign.Size.maxContentWidthLandscape // 800pt + : CasinoDesign.Size.maxContentWidthPortrait // 500pt + } + return .infinity // iPhone uses full width + } + + var body: some View { + ZStack { + TableBackgroundView() + + VStack { + TopBarView(...) + .frame(maxWidth: maxContentWidth) + + // Game table - constrained on iPad + YourTableLayoutView(...) + .frame(maxWidth: maxContentWidth) + + // Chip selector - constrained + ChipSelectorView(...) + .frame(maxWidth: maxContentWidth) + + // Action buttons - constrained + ActionButtonsView(...) + .frame(maxWidth: maxContentWidth) + } + .frame(maxWidth: .infinity) // Centers constrained content + + // Overlays - full screen background, constrained content + if showResultBanner { + ResultBannerView(...) + } + } + } +} +``` + +### Overlay Pattern (Result Banner, Game Over) + +Overlays need special handling: **full-screen dim background** with **constrained content card**: + +```swift +struct ResultBannerView: View { + var body: some View { + ZStack { + // 1. Full-screen dark background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // 2. Constrained content card + VStack { + // Your content + } + .padding() + .background(RoundedRectangle(cornerRadius: 24).fill(...)) + .frame(maxWidth: CasinoDesign.Size.maxModalWidth) // 450pt + // Centered automatically by ZStack + } + } +} +``` + +### Fixed-Size Containers (Prevent Layout Shifts) + +When content changes (cards dealt, buttons appear/disappear), prevent jarring layout shifts: + +```swift +// ❌ BAD: Container resizes as cards are added +HStack { + ForEach(cards) { card in + CardView(card: card) + } +} + +// ✅ GOOD: Fixed container based on max possible content +ZStack { + // Reserve space for max cards (e.g., 3 cards with overlap) + Color.clear + .frame(width: calculateMaxWidth(), height: cardHeight) + + // Actual cards centered within + HStack(spacing: cardSpacing) { + ForEach(cards) { card in + CardView(card: card) + } + } +} +``` + +Same for buttons: + +```swift +// ✅ GOOD: Fixed height container for buttons +ZStack { + Color.clear + .frame(height: 60) // Fixed height + + // Buttons animate in/out within fixed space + if showDealButton { + ActionButton("Deal", ...) + .transition(.scale.combined(with: .opacity)) + } +} +.animation(.spring(duration: 0.3), value: currentPhase) +``` + +### Confetti Full-Screen Fix + +Confetti must cover the entire screen on iPad: + +```swift +struct ConfettiView: View { + var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(0..<50, id: \.self) { _ in + ConfettiPiece(containerSize: geometry.size) + } + } + } + .ignoresSafeArea() // Critical! + .allowsHitTesting(false) + } +} +``` + +### Design Constants for Responsive Layout + +```swift +// In CasinoDesign.swift +enum Size { + // Max widths for iPad constraint + static let maxContentWidthPortrait: CGFloat = 500 + static let maxContentWidthLandscape: CGFloat = 800 + static let maxModalWidth: CGFloat = 450 +} +``` + +### Common Pitfalls + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Stretched table | Betting zones look huge on iPad | Add `.frame(maxWidth: maxContentWidth)` | +| Overlay too wide | Result banner covers entire iPad screen | Use full-screen bg + constrained content card | +| Layout shifts | Cards/buttons cause content to jump | Use fixed-size `ZStack` containers | +| Confetti cut off | Only shows in center portion | Use `GeometryReader` + `.ignoresSafeArea()` | +| Settings rows cramped | Title/subtitle too close to divider | Add `.padding(.vertical, ...)` | + +### Testing Checklist + +- [ ] iPhone SE (smallest) +- [ ] iPhone Pro Max (largest iPhone) +- [ ] iPad Portrait +- [ ] iPad Landscape +- [ ] iPad Split View +- [ ] Dynamic Type at maximum accessibility size + ## Checklist for New Game ### Setup @@ -445,11 +631,19 @@ Text(String(localized: "Bust!")) - [ ] Add localization for all strings ### Polish -- [ ] Test Dynamic Type scaling -- [ ] Test VoiceOver -- [ ] Test iPad layout +- [ ] Test Dynamic Type scaling (all sizes including accessibility) +- [ ] Test VoiceOver navigation - [ ] Create app icon using `AppIconView` +### Responsive Layout (iPad) +- [ ] Add `maxContentWidth` constraint to main views +- [ ] Test iPad Portrait - content centered, not stretched +- [ ] Test iPad Landscape - wider constraint works well +- [ ] Verify overlays: full-screen bg, constrained content +- [ ] Fixed-size containers prevent layout shifts +- [ ] Confetti covers full screen +- [ ] Settings rows have proper padding + ## What Could Be Added to CasinoKit The following patterns from Baccarat could be abstracted: diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 4312230..8fd5192 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -65,6 +65,10 @@ "comment" : "The value of the balance displayed in the top bar.", "isCommentAutoGenerated" : true }, + "$%lld bet" : { + "comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.", + "isCommentAutoGenerated" : true + }, "1. Use Xcode's preview to screenshot these icons" : { }, @@ -124,6 +128,10 @@ } } }, + "Betting disabled" : { + "comment" : "A hint that appears when a betting zone is disabled.", + "isCommentAutoGenerated" : true + }, "Card face down" : { "localizations" : { "en" : { @@ -278,6 +286,10 @@ } } }, + "Double tap to place bet" : { + "comment" : "A hint text describing the action to take in the betting zone.", + "isCommentAutoGenerated" : true + }, "Eight" : { "localizations" : { "en" : { @@ -656,6 +668,10 @@ } } }, + "No bet" : { + "comment" : "A description of a zone with no active bet.", + "isCommentAutoGenerated" : true + }, "Perfect for learning" : { "localizations" : { "en" : { @@ -1078,28 +1094,6 @@ } } }, - "You've run out of chips!" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "You've run out of chips!" - } - }, - "es-MX" : { - "stringUnit" : { - "state" : "translated", - "value" : "¡Te quedaste sin fichas!" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous n'avez plus de jetons!" - } - } - } - }, "WIN" : { "localizations" : { "en" : { @@ -1121,6 +1115,28 @@ } } } + }, + "You've run out of chips!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You've run out of chips!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Te quedaste sin fichas!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous n'avez plus de jetons!" + } + } + } } }, "version" : "1.1"