Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-01 14:37:34 -06:00
parent 3021951390
commit 2b15e57c33
25 changed files with 1746 additions and 942 deletions

View File

@ -11,9 +11,11 @@ import CasinoKit
struct BaccaratApp: App {
var body: some Scene {
WindowGroup {
SherpaContainerView(configuration: .default) {
AppLaunchView(config: .baccarat) {
ContentView()
}
}
}
}
}

View File

@ -0,0 +1,189 @@
//
// WalkthroughTags.swift
// Baccarat
//
// Defines the walkthrough steps for onboarding new players.
//
import SwiftUI
import CasinoKit
/// Walkthrough steps for Baccarat onboarding.
enum BaccaratWalkthroughTags: SherpaTags {
// MARK: - Top Bar Elements
/// Shows the current balance
case balance
/// Shows remaining cards in the shoe
case cardsRemaining
/// Opens statistics sheet
case statsButton
/// Opens rules/help sheet
case rulesButton
/// Opens settings sheet
case settingsButton
/// Shows the road map history display
case history
// MARK: - Gameplay Elements
/// Introduce the betting zones (Player, Banker, Tie)
case bettingZone
/// Explain the betting hints showing patterns and tips
case bettingHint
/// Explain the chip selector for choosing bet amounts
case chipSelector
/// Show the deal button to start the round
case dealButton
func makeCallout() -> Callout {
switch self {
// Top Bar
case .balance:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "dollarsign.circle.fill",
text: String(localized: "walkthrough.balance"),
onTap: onTap
)
}
case .cardsRemaining:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "rectangle.portrait.on.rectangle.portrait.fill",
text: String(localized: "walkthrough.cardsRemaining"),
onTap: onTap
)
}
case .statsButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "chart.bar.fill",
text: String(localized: "walkthrough.statsButton"),
onTap: onTap
)
}
case .rulesButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "info.circle",
text: String(localized: "walkthrough.rulesButton"),
onTap: onTap
)
}
case .settingsButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "gearshape.fill",
text: String(localized: "walkthrough.settingsButton"),
onTap: onTap
)
}
case .history:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "clock.fill",
text: String(localized: "walkthrough.history"),
onTap: onTap
)
}
// Gameplay
case .bettingZone:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "hand.tap.fill",
text: String(localized: "walkthrough.bettingZone"),
onTap: onTap
)
}
case .bettingHint:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "lightbulb.fill",
text: String(localized: "walkthrough.bettingHint"),
onTap: onTap
)
}
case .chipSelector:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "circle.grid.2x2.fill",
text: String(localized: "walkthrough.chipSelector"),
onTap: onTap
)
}
case .dealButton:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "play.fill",
text: String(localized: "walkthrough.dealButton"),
onTap: onTap
)
}
}
}
}
// MARK: - Custom Callout View
/// Fully custom callout view with dark background and white text
struct WalkthroughCalloutView: View {
let icon: String
let text: String
let onTap: () -> Void
// Maximum width to prevent edge clipping
private let maxCalloutWidth: CGFloat = 320
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.yellow)
Text(text)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
.lineLimit(2)
Spacer(minLength: 0)
Text(String(localized: "walkthrough.close"))
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.medium)
.frame(maxWidth: maxCalloutWidth)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.almostFull))
.shadow(
color: .black.opacity(Design.Opacity.medium),
radius: Design.Shadow.radiusLarge,
y: Design.Shadow.offsetMedium
)
)
}
.buttonStyle(.plain)
.padding(.horizontal, Design.Spacing.xLarge)
}
}

View File

@ -1983,6 +1983,29 @@
}
}
},
"Game Sessions" : {
"comment" : "Title for the Game Sessions help page",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Game Sessions"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sesiones de Juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions de Jeu"
}
}
}
},
"GAME STATS" : {
"comment" : "Section in the statistics sheet dedicated to displaying statistics specific to baccarat.",
"localizations" : {
@ -3620,6 +3643,7 @@
},
"Results appear here, then in the road maps below" : {
"comment" : "Instructional text for new players.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3736,6 +3760,7 @@
},
"Select a chip and tap a bet zone" : {
"comment" : "Onboarding hint for placing bets.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3803,6 +3828,144 @@
}
}
},
"sessions.autoStart" : {
"comment" : "Game Sessions help - auto start explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "A new session starts automatically when you begin playing."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Una nueva sesión comienza automáticamente cuando empiezas a jugar."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Une nouvelle session démarre automatiquement lorsque vous commencez à jouer."
}
}
}
},
"sessions.endSession" : {
"comment" : "Game Sessions help - end session explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End your session anytime from the Statistics screen."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termina tu sesión en cualquier momento desde la pantalla de Estadísticas."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminez votre session à tout moment depuis l'écran Statistiques."
}
}
}
},
"sessions.history" : {
"comment" : "Game Sessions help - history explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View your complete session history to see past performance."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consulta tu historial completo de sesiones para ver tu rendimiento pasado."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consultez votre historique complet de sessions pour voir vos performances passées."
}
}
}
},
"sessions.likeRealCasino" : {
"comment" : "Game Sessions help - like real casino",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track your play just like at a real casino table!"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "¡Rastrea tu juego como en una mesa de casino real!"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez votre jeu comme à une vraie table de casino !"
}
}
}
},
"sessions.trackProgress" : {
"comment" : "Game Sessions help - track progress",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions track your playing time, hands, wins, losses, and chip balance."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Las sesiones rastrean tu tiempo de juego, manos, victorias, derrotas y saldo de fichas."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les sessions suivent votre temps de jeu, mains, victoires, défaites et solde de jetons."
}
}
}
},
"sessions.viewStats" : {
"comment" : "Game Sessions help - view stats",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tap the chart icon to view your current session stats."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Toca el ícono de gráfico para ver las estadísticas de tu sesión actual."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Appuyez sur l'icône de graphique pour voir les statistiques de votre session actuelle."
}
}
}
},
"Set a budget and stick to it." : {
"comment" : "Tip for players to set a budget and stick to it when playing baccarat.",
"localizations" : {
@ -4238,6 +4401,7 @@
},
"Tap Deal to start the round" : {
"comment" : "Instructional text for new players.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4787,6 +4951,259 @@
}
}
},
"walkthrough.balance" : {
"comment" : "Walkthrough hint for the balance display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your current balance is shown here"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tu saldo actual se muestra aquí"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Votre solde actuel est affiché ici"
}
}
}
},
"walkthrough.bettingHint" : {
"comment" : "Walkthrough hint for the betting hint display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tips based on patterns and trends"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consejos según patrones y tendencias"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conseils selon les tendances"
}
}
}
},
"walkthrough.bettingZone" : {
"comment" : "Walkthrough hint for the betting zone",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Select a chip and tap a bet zone"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Selecciona una ficha y toca una zona de apuesta"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sélectionnez un jeton et touchez une zone de mise"
}
}
}
},
"walkthrough.cardsRemaining" : {
"comment" : "Walkthrough hint for cards remaining display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cards remaining in the shoe"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cartas restantes en el zapato"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cartes restantes dans le sabot"
}
}
}
},
"walkthrough.chipSelector" : {
"comment" : "Walkthrough hint for the chip selector",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose a chip value to bet"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elige el valor de la ficha para apostar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choisissez une valeur de jeton pour miser"
}
}
}
},
"walkthrough.close" : {
"comment" : "Walkthrough button to close/advance to next step",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Close"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cerrar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fermer"
}
}
}
},
"walkthrough.dealButton" : {
"comment" : "Walkthrough hint for the deal button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tap Deal to start the round"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Toca Repartir para comenzar la ronda"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Touchez Distribuer pour commencer la manche"
}
}
}
},
"walkthrough.history" : {
"comment" : "Walkthrough hint for the road map history display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track game results and spot trends"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rastrea resultados y detecta tendencias"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez les résultats et repérez les tendances"
}
}
}
},
"walkthrough.rulesButton" : {
"comment" : "Walkthrough hint for the rules/help button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Learn the rules and how to play"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aprende las reglas y cómo jugar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apprenez les règles et comment jouer"
}
}
}
},
"walkthrough.settingsButton" : {
"comment" : "Walkthrough hint for the settings button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Customize game rules and options"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personaliza las reglas y opciones del juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personnalisez les règles et options du jeu"
}
}
}
},
"walkthrough.statsButton" : {
"comment" : "Walkthrough hint for the statistics button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View session stats and history"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ver estadísticas e historial"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voir statistiques et historique"
}
}
}
},
"WIN" : {
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
"localizations" : {

View File

@ -61,6 +61,7 @@ struct ActionButtonsView: View {
onClear: onClear,
onDeal: onDeal
)
.sherpaTag(BaccaratWalkthroughTags.dealButton)
}
@ViewBuilder

View File

@ -9,7 +9,7 @@ import SwiftUI
import CasinoKit
/// The main game table view containing all game elements.
struct GameTableView: View {
struct GameTableView: View, SherpaDelegate {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .hundred
@ -18,10 +18,10 @@ struct GameTableView: View {
@State private var showStats = false
@State private var showWelcome = false
// MARK: - Onboarding State
// MARK: - Walkthrough State
/// Tooltip manager for contextual hints
@State private var tooltipManager: TooltipManager?
/// Whether the Sherpa walkthrough is active
@State private var isWalkthroughActive = false
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
@ -85,6 +85,17 @@ struct GameTableView: View {
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
/// Sherpa tags for the top bar elements
private var topBarSherpaTags: TopBarSherpaTags<BaccaratWalkthroughTags> {
TopBarSherpaTags(
balance: .balance,
cardsRemaining: .cardsRemaining,
stats: .statsButton,
rules: .rulesButton,
settings: .settingsButton
)
}
// MARK: - Body
var body: some View {
@ -109,9 +120,6 @@ struct GameTableView: View {
if gameState == nil {
gameState = GameState(settings: settings)
}
if tooltipManager == nil {
tooltipManager = TooltipManager(onboarding: state.onboarding)
}
checkForWelcomeSheet()
}
.sheet(isPresented: $showSettings) {
@ -161,30 +169,17 @@ struct GameTableView: View {
],
onboarding: state.onboarding,
onDismiss: { showWelcome = false },
onShowHints: checkOnboardingHints
onShowHints: startWalkthrough
)
}
.onChange(of: showWelcome) { wasShowing, isShowing in
// Handle swipe-down dismissal: treat as "Start Playing" (no tooltips)
// Handle swipe-down dismissal: treat as "Start Playing" (no walkthrough)
if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome {
state.onboarding.skipOnboarding()
}
}
.onChange(of: state.totalBetAmount) { _, newTotal in
if newTotal > 0, state.onboarding.shouldShowHint("dealButton") {
showDealHintWithDelay()
}
}
.onChange(of: state.currentPhase) { oldPhase, newPhase in
if newPhase == .showingResult, oldPhase != newPhase {
if state.onboarding.shouldShowHint("firstResult") {
showResultHintWithDelay()
}
}
}
// Dynamic tooltip display
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
// Sherpa walkthrough modifier
.sherpa(isActive: isWalkthroughActive, tags: BaccaratWalkthroughTags.self, delegate: self)
}
// MARK: - Onboarding Helpers
@ -198,36 +193,29 @@ struct GameTableView: View {
}
}
private func checkOnboardingHints() {
if state.onboarding.shouldShowHint("bettingZone") {
tooltipManager?.show(
key: "bettingZone",
message: String(localized: "Select a chip and tap a bet zone"),
icon: "hand.tap.fill",
position: .bottom,
delay: 1.0
)
}
/// Starts the Sherpa walkthrough when user taps "Show Me How"
private func startWalkthrough() {
// Reset onboarding hints so walkthrough can be seen again
state.onboarding.reset()
state.onboarding.completeWelcome()
isWalkthroughActive = true
}
private func showDealHintWithDelay() {
tooltipManager?.show(
key: "dealButton",
message: String(localized: "Tap Deal to start the round"),
icon: "play.fill",
position: .bottom,
delay: 0.5
)
// MARK: - SherpaDelegate
/// Returns nil to hide skip button and progress indicator
func accessoryView(sherpa: Sherpa) -> AnyView? {
nil
}
private func showResultHintWithDelay() {
tooltipManager?.show(
key: "firstResult",
message: String(localized: "Results appear here, then in the road maps below"),
icon: "chart.bar.fill",
position: .bottom,
delay: 2.0
)
func onWalkthroughComplete(sherpa: Sherpa) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
// MARK: - Private Views
@ -256,7 +244,8 @@ struct GameTableView: View {
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
onStats: { showStats = true },
sherpaTags: topBarSherpaTags
)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
@ -288,6 +277,7 @@ struct GameTableView: View {
Spacer(minLength: 0)
}
.sherpaTag(BaccaratWalkthroughTags.history)
.frame(width: 240)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
@ -341,6 +331,7 @@ struct GameTableView: View {
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.sherpaTag(BaccaratWalkthroughTags.bettingHint)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
@ -356,6 +347,7 @@ struct GameTableView: View {
currentBet: state.minBetForChipSelector,
maxBet: state.maxBet
)
.sherpaTag(BaccaratWalkthroughTags.chipSelector)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
@ -399,7 +391,8 @@ struct GameTableView: View {
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
onStats: { showStats = true },
sherpaTags: topBarSherpaTags
)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
@ -430,6 +423,7 @@ struct GameTableView: View {
// Road map history - show in portrait before deal on larger screens
if settings.showHistory && !isSmallScreen && !isDealing {
RoadMapView(results: state.recentResults)
.sherpaTag(BaccaratWalkthroughTags.history)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
@ -454,6 +448,7 @@ struct GameTableView: View {
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.sherpaTag(BaccaratWalkthroughTags.bettingHint)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
@ -467,6 +462,7 @@ struct GameTableView: View {
currentBet: state.minBetForChipSelector,
maxBet: state.maxBet
)
.sherpaTag(BaccaratWalkthroughTags.chipSelector)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}

View File

@ -23,6 +23,18 @@ struct RulesHelpView: View {
String(localized: "Baccarat has one of the lowest house edges in the casino.")
]
),
RulePage(
title: String(localized: "Game Sessions"),
icon: "clock.badge.checkmark.fill",
content: [
String(localized: "sessions.trackProgress"),
String(localized: "sessions.autoStart"),
String(localized: "sessions.viewStats"),
String(localized: "sessions.endSession"),
String(localized: "sessions.history"),
String(localized: "sessions.likeRealCasino")
]
),
RulePage(
title: String(localized: "Card Values"),
icon: "suit.spade.fill",

View File

@ -163,6 +163,7 @@ struct BettingTableView: View {
)
)
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
.sherpaTag(BaccaratWalkthroughTags.bettingZone)
}
.debugBorder(showDebugBorders, color: .orange, label: "BettingTable")
}

View File

@ -10,8 +10,8 @@ A feature-rich Baccarat (Punto Banco) app for iOS built with SwiftUI. Experience
### 🎓 First-Time User Experience
- **Welcome Sheet** — Interactive introduction on first launch
- **Tutorial Mode** — Optional guided walkthrough of your first round
- **Contextual Hints** — Tips appear at the right moment during gameplay
- **Spotlight Walkthrough** — Guided tour with spotlight focus on key UI elements
- **Sherpa Framework** — Modern walkthrough with progress indicators and haptic feedback
- **Never Intrusive** — All onboarding is skippable and shown only once
### 🎰 Authentic Punto Banco Gameplay

View File

@ -18,9 +18,11 @@ struct BlackjackApp: App {
var body: some Scene {
WindowGroup {
SherpaContainerView(configuration: .default) {
AppLaunchView(config: .blackjack) {
ContentView()
}
}
}
}
}

View File

@ -0,0 +1,189 @@
//
// WalkthroughTags.swift
// Blackjack
//
// Defines the walkthrough steps for onboarding new players.
//
import SwiftUI
import CasinoKit
/// Walkthrough steps for Blackjack onboarding.
enum BlackjackWalkthroughTags: SherpaTags {
// MARK: - Top Bar Elements
/// Shows the current balance
case balance
/// Shows remaining cards in the shoe
case cardsRemaining
/// Opens statistics sheet
case statsButton
/// Opens rules/help sheet
case rulesButton
/// Opens settings sheet
case settingsButton
// MARK: - Gameplay Elements
/// Introduce the betting zone where chips are placed
case bettingZone
/// Explain the betting hints based on card count
case bettingHint
/// Explain the chip selector for choosing bet amounts
case chipSelector
/// Show the deal button to start the round
case dealButton
/// Explain player actions during the hand
case playerActions
func makeCallout() -> Callout {
switch self {
// Top Bar
case .balance:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "dollarsign.circle.fill",
text: String(localized: "walkthrough.balance"),
onTap: onTap
)
}
case .cardsRemaining:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "rectangle.portrait.on.rectangle.portrait.fill",
text: String(localized: "walkthrough.cardsRemaining"),
onTap: onTap
)
}
case .statsButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "chart.bar.fill",
text: String(localized: "walkthrough.statsButton"),
onTap: onTap
)
}
case .rulesButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "info.circle",
text: String(localized: "walkthrough.rulesButton"),
onTap: onTap
)
}
case .settingsButton:
return .custom(edge: .bottom) { onTap in
WalkthroughCalloutView(
icon: "gearshape.fill",
text: String(localized: "walkthrough.settingsButton"),
onTap: onTap
)
}
// Gameplay
case .bettingZone:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "hand.tap.fill",
text: String(localized: "walkthrough.bettingZone"),
onTap: onTap
)
}
case .bettingHint:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "lightbulb.fill",
text: String(localized: "walkthrough.bettingHint"),
onTap: onTap
)
}
case .chipSelector:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "circle.grid.2x2.fill",
text: String(localized: "walkthrough.chipSelector"),
onTap: onTap
)
}
case .dealButton:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "play.fill",
text: String(localized: "walkthrough.dealButton"),
onTap: onTap
)
}
case .playerActions:
return .custom(edge: .top) { onTap in
WalkthroughCalloutView(
icon: "hand.point.up.left.fill",
text: String(localized: "walkthrough.playerActions"),
onTap: onTap
)
}
}
}
}
// MARK: - Custom Callout View
/// Fully custom callout view with dark background and white text
struct WalkthroughCalloutView: View {
let icon: String
let text: String
let onTap: () -> Void
// Maximum width to prevent edge clipping
private let maxCalloutWidth: CGFloat = 320
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.yellow)
Text(text)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
.multilineTextAlignment(.leading)
.lineLimit(2)
Spacer(minLength: 0)
Text(String(localized: "walkthrough.close"))
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.medium)
.frame(maxWidth: maxCalloutWidth)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.almostFull))
.shadow(
color: .black.opacity(Design.Opacity.medium),
radius: Design.Shadow.radiusLarge,
y: Design.Shadow.offsetMedium
)
)
}
.buttonStyle(.plain)
.padding(.horizontal, Design.Spacing.xLarge)
}
}

View File

@ -2010,9 +2010,6 @@
}
}
}
},
"Choose your action based on the hint above" : {
},
"Clear" : {
"localizations" : {
@ -3552,6 +3549,29 @@
}
}
},
"Game Sessions" : {
"comment" : "Title for the Game Sessions help page",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Game Sessions"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sesiones de Juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions de Jeu"
}
}
}
},
"GAME STYLE" : {
"localizations" : {
"en" : {
@ -6034,6 +6054,7 @@
},
"Select a chip and tap the bet area" : {
"comment" : "Onboarding hint for placing bets.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -6124,6 +6145,144 @@
}
}
},
"sessions.autoStart" : {
"comment" : "Game Sessions help - auto start explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "A new session starts automatically when you begin playing."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Una nueva sesión comienza automáticamente cuando empiezas a jugar."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Une nouvelle session démarre automatiquement lorsque vous commencez à jouer."
}
}
}
},
"sessions.endSession" : {
"comment" : "Game Sessions help - end session explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End your session anytime from the Statistics screen."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termina tu sesión en cualquier momento desde la pantalla de Estadísticas."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminez votre session à tout moment depuis l'écran Statistiques."
}
}
}
},
"sessions.history" : {
"comment" : "Game Sessions help - history explanation",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View your complete session history to see past performance."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consulta tu historial completo de sesiones para ver tu rendimiento pasado."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consultez votre historique complet de sessions pour voir vos performances passées."
}
}
}
},
"sessions.likeRealCasino" : {
"comment" : "Game Sessions help - like real casino",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track your play just like at a real casino table!"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "¡Rastrea tu juego como en una mesa de casino real!"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez votre jeu comme à une vraie table de casino !"
}
}
}
},
"sessions.trackProgress" : {
"comment" : "Game Sessions help - track progress",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions track your playing time, hands, wins, losses, and chip balance."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Las sesiones rastrean tu tiempo de juego, manos, victorias, derrotas y saldo de fichas."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les sessions suivent votre temps de jeu, mains, victoires, défaites et solde de jetons."
}
}
}
},
"sessions.viewStats" : {
"comment" : "Game Sessions help - view stats",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tap the chart icon to view your current session stats."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Toca el ícono de gráfico para ver las estadísticas de tu sesión actual."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Appuyez sur l'icône de graphique pour voir les statistiques de votre session actuelle."
}
}
}
},
"Settings" : {
"localizations" : {
"en" : {
@ -7191,9 +7350,6 @@
}
}
}
},
"Tap Deal to start the round" : {
},
"TAP TO BET" : {
"localizations" : {
@ -7608,6 +7764,259 @@
}
}
},
"walkthrough.balance" : {
"comment" : "Walkthrough hint for the balance display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your current balance is shown here"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tu saldo actual se muestra aquí"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Votre solde actuel est affiché ici"
}
}
}
},
"walkthrough.bettingHint" : {
"comment" : "Walkthrough hint for the betting hint display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Betting tips based on card count"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consejos de apuesta según el conteo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conseils de mise selon le comptage"
}
}
}
},
"walkthrough.bettingZone" : {
"comment" : "Walkthrough hint for the betting zone",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Select a chip and tap the bet area"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Selecciona una ficha y toca el área de apuesta"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sélectionnez un jeton et touchez la zone de mise"
}
}
}
},
"walkthrough.cardsRemaining" : {
"comment" : "Walkthrough hint for cards remaining display",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cards remaining in the shoe"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cartas restantes en el zapato"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cartes restantes dans le sabot"
}
}
}
},
"walkthrough.close" : {
"comment" : "Walkthrough button to close/advance to next step",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Close"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cerrar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fermer"
}
}
}
},
"walkthrough.chipSelector" : {
"comment" : "Walkthrough hint for the chip selector",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose a chip value to bet"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elige el valor de la ficha para apostar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choisissez une valeur de jeton pour miser"
}
}
}
},
"walkthrough.dealButton" : {
"comment" : "Walkthrough hint for the deal button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tap Deal to start the round"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Toca Repartir para comenzar la ronda"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Touchez Distribuer pour commencer la manche"
}
}
}
},
"walkthrough.playerActions" : {
"comment" : "Walkthrough hint for player action buttons",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose your action based on the hint above"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Elige tu acción según la sugerencia de arriba"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choisissez votre action selon l'indice ci-dessus"
}
}
}
},
"walkthrough.rulesButton" : {
"comment" : "Walkthrough hint for the rules/help button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Learn the rules and how to play"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aprende las reglas y cómo jugar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apprenez les règles et comment jouer"
}
}
}
},
"walkthrough.settingsButton" : {
"comment" : "Walkthrough hint for the settings button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Customize game rules and options"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personaliza las reglas y opciones del juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personnalisez les règles et options du jeu"
}
}
}
},
"walkthrough.statsButton" : {
"comment" : "Walkthrough hint for the statistics button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "View session stats and history"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ver estadísticas e historial"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voir statistiques et historique"
}
}
}
},
"Win Rate" : {
"localizations" : {
"en" : {

View File

@ -50,6 +50,7 @@ struct ActionButtonsView: View {
onClear: { state.clearBet() },
onDeal: { Task { await state.deal() } }
)
.sherpaTag(BlackjackWalkthroughTags.dealButton)
}
// MARK: - Player Turn Buttons
@ -83,6 +84,7 @@ struct ActionButtonsView: View {
.transition(.scale.combined(with: .opacity))
}
}
.sherpaTag(BlackjackWalkthroughTags.playerActions)
.onAppear {
animatedActions = availableActions
}

View File

@ -8,7 +8,7 @@
import SwiftUI
import CasinoKit
struct GameTableView: View {
struct GameTableView: View, SherpaDelegate {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .twentyFive
@ -20,10 +20,10 @@ struct GameTableView: View {
@State private var showStats = false
@State private var showWelcome = false
// MARK: - Onboarding State
// MARK: - Walkthrough State
/// Tooltip manager for contextual hints
@State private var tooltipManager: TooltipManager?
/// Whether the Sherpa walkthrough is active
@State private var isWalkthroughActive = false
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
@ -61,9 +61,6 @@ struct GameTableView: View {
if gameState == nil {
gameState = GameState(settings: settings)
}
if tooltipManager == nil {
tooltipManager = TooltipManager(onboarding: state.onboarding)
}
checkForWelcomeSheet()
}
.sheet(isPresented: $showSettings) {
@ -111,27 +108,17 @@ struct GameTableView: View {
],
onboarding: state.onboarding,
onDismiss: { showWelcome = false },
onShowHints: checkOnboardingHints
onShowHints: startWalkthrough
)
}
.onChange(of: showWelcome) { wasShowing, isShowing in
// Handle swipe-down dismissal: treat as "Start Playing" (no tooltips)
// Handle swipe-down dismissal: treat as "Start Playing" (no walkthrough)
if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome {
state.onboarding.skipOnboarding()
}
}
.onChange(of: state.currentBet) { _, newBet in
if newBet > 0, state.onboarding.shouldShowHint("dealButton") {
showDealHintWithDelay()
}
}
.onChange(of: state.currentPhase) { oldPhase, newPhase in
if case .playerTurn = newPhase, oldPhase != newPhase {
if state.onboarding.shouldShowHint("playerActions") {
showActionsHintWithDelay()
}
}
}
// Sherpa walkthrough modifier
.sherpa(isActive: isWalkthroughActive, tags: BlackjackWalkthroughTags.self, delegate: self)
}
// Use global debug flag from Design constants
@ -198,7 +185,14 @@ struct GameTableView: View {
leadingButtons: hintToolbarButtons(for: state),
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
onStats: { showStats = true },
sherpaTags: TopBarSherpaTags(
balance: BlackjackWalkthroughTags.balance,
cardsRemaining: BlackjackWalkthroughTags.cardsRemaining,
stats: BlackjackWalkthroughTags.statsButton,
rules: BlackjackWalkthroughTags.rulesButton,
settings: BlackjackWalkthroughTags.settingsButton
)
)
.frame(maxWidth: maxContentWidth)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
@ -223,6 +217,7 @@ struct GameTableView: View {
currentBet: state.minBetForChipSelector,
maxBet: state.settings.maxBet
)
.sherpaTag(BlackjackWalkthroughTags.chipSelector)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
@ -315,9 +310,6 @@ struct GameTableView: View {
.onChange(of: state.balance) { oldBalance, newBalance in
Design.debugLog("💰 Balance: \(oldBalance)\(newBalance)")
}
// Dynamic tooltip display
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
}
// MARK: - Onboarding Helpers
@ -332,39 +324,30 @@ struct GameTableView: View {
}
}
private func checkOnboardingHints() {
// Show betting hint if not yet shown
if state.onboarding.shouldShowHint("bettingZone") {
tooltipManager?.show(
key: "bettingZone",
message: String(localized: "Select a chip and tap the bet area"),
icon: "hand.tap.fill",
position: .bottom,
delay: 1.0
)
}
/// Starts the Sherpa walkthrough when user taps "Show Me How"
private func startWalkthrough() {
// Reset onboarding hints so walkthrough can be seen again
state.onboarding.reset()
state.onboarding.completeWelcome()
isWalkthroughActive = true
}
private func showDealHintWithDelay() {
tooltipManager?.show(
key: "dealButton",
message: String(localized: "Tap Deal to start the round"),
icon: "play.fill",
position: .bottom,
delay: 0.5
)
// MARK: - SherpaDelegate
/// Returns nil to hide skip button and progress indicator
func accessoryView(sherpa: Sherpa) -> AnyView? {
nil
}
private func showActionsHintWithDelay() {
tooltipManager?.show(
key: "playerActions",
message: String(localized: "Choose your action based on the hint above"),
icon: "hand.point.up.left.fill",
position: .bottom,
delay: 1.0
)
func onWalkthroughComplete(sherpa: Sherpa) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
}
// MARK: - Preview

View File

@ -22,6 +22,18 @@ struct RulesHelpView: View {
String(localized: "If the dealer busts and you haven't, you win.")
]
),
RulePage(
title: String(localized: "Game Sessions"),
icon: "clock.badge.checkmark.fill",
content: [
String(localized: "sessions.trackProgress"),
String(localized: "sessions.autoStart"),
String(localized: "sessions.viewStats"),
String(localized: "sessions.endSession"),
String(localized: "sessions.history"),
String(localized: "sessions.likeRealCasino")
]
),
RulePage(
title: String(localized: "Card Values"),
icon: "suit.spade.fill",

View File

@ -213,12 +213,14 @@ struct BlackjackTableView: View {
state: state,
selectedChip: selectedChip
)
.sherpaTag(BlackjackWalkthroughTags.bettingZone)
.transition(.scale.combined(with: .opacity))
.debugBorder(showDebugBorders, color: .blue, label: "BetZone")
// Betting hint based on count (only when card counting enabled)
if let hint = state.bettingHint {
BlackjackBettingHintView(hint: hint, trueCount: state.engine.trueCount)
.sherpaTag(BlackjackWalkthroughTags.bettingHint)
.transition(.opacity)
.padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")

View File

@ -10,8 +10,8 @@ A feature-rich Blackjack app for iOS built with SwiftUI. Master basic strategy,
### 🎓 First-Time User Experience
- **Welcome Sheet** — Interactive introduction on first launch
- **Tutorial Mode** — Optional guided walkthrough of your first hand
- **Contextual Hints** — Tips appear at the right moment during gameplay
- **Spotlight Walkthrough** — Guided tour with spotlight focus on key UI elements
- **Sherpa Framework** — Modern walkthrough with progress indicators and haptic feedback
- **Never Intrusive** — All onboarding is skippable and shown only once
### 🎰 Authentic Casino Gameplay

View File

@ -16,9 +16,13 @@ let package = Package(
targets: ["CasinoKit"]
)
],
dependencies: [
.package(url: "https://github.com/mbrucedogs/Sherpa.git", branch: "develop")
],
targets: [
.target(
name: "CasinoKit",
dependencies: ["Sherpa"],
resources: [
.process("Resources")
]

View File

@ -140,84 +140,71 @@ WelcomeSheet(
)
```
**ContextualTooltip** - Show hints at the right moment.
**Sherpa Walkthrough** - Guided spotlight walkthrough (re-exported from Sherpa package).
```swift
@State private var showBettingHint = false
// 1. Define walkthrough steps
enum MyTags: SherpaTags {
case bettingZone
case dealButton
func makeCallout() -> Callout {
switch self {
case .bettingZone:
return .localizedLabeled("walkthrough.bettingZone", systemImage: "hand.tap.fill")
case .dealButton:
return .localizedLabeled("walkthrough.dealButton", systemImage: "play.fill")
}
}
}
// 2. Wrap app in SherpaContainerView (in @main App)
SherpaContainerView(configuration: .default) {
ContentView()
}
// 3. Tag views and activate walkthrough
struct GameTableView: View, SherpaDelegate {
@State private var isWalkthroughActive = false
var body: some View {
BettingZone(/* ... */)
.contextualTooltip(
"Tap chips, then tap here to bet",
icon: "hand.tap.fill",
position: .bottom,
isShowing: $showBettingHint,
onDismiss: {
gameState.onboarding.markHintShown("bettingZone")
VStack {
BettingZone()
.sherpaTag(MyTags.bettingZone)
DealButton()
.sherpaTag(MyTags.dealButton)
}
.sherpa(isActive: isWalkthroughActive, tags: MyTags.self, delegate: self)
}
func onWalkthroughComplete(sherpa: Sherpa) {
isWalkthroughActive = false
onboarding.completeWelcome()
}
)
}
```
**OnboardingState** - Track which hints have been shown.
**OnboardingState** - Track onboarding completion status.
```swift
let onboarding = OnboardingState(gameIdentifier: "blackjack")
// Check if a hint should be shown
if onboarding.shouldShowHint("bettingZone") {
showBettingHint = true
}
// Mark hint as shown (persisted automatically)
onboarding.markHintShown("bettingZone")
// Check if user has seen welcome
if !onboarding.hasCompletedWelcome {
showWelcome = true
}
// Enable tutorial mode (shows all hints again)
onboarding.startTutorialMode()
// Mark welcome/walkthrough as completed
onboarding.completeWelcome()
// Skip onboarding entirely
onboarding.skipOnboarding()
// Reset onboarding (for testing)
onboarding.reset()
```
**TooltipManager** - Generic, scalable tooltip management (recommended).
```swift
@State private var tooltipManager: TooltipManager?
var body: some View {
GameView()
.onAppear {
tooltipManager = TooltipManager(onboarding: gameState.onboarding)
}
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding))
}
// Show a tooltip with automatic dismiss tracking
private func showBettingHint() {
tooltipManager?.show(
key: "bettingZone",
message: "Select a chip and tap here to bet",
icon: "hand.tap.fill",
position: .bottom,
delay: 1.0
)
}
// Dismiss current tooltip manually
tooltipManager?.dismiss()
```
The `TooltipManager` automatically:
- Ensures only one tooltip shows at a time
- Marks tooltips as shown when dismissed or replaced
- Respects `OnboardingState.shouldShowHint()` checks
- Handles delayed presentation with cancellation if needed
**PulsingModifier** - Draw attention to interactive elements.
```swift
@ -776,7 +763,6 @@ CasinoKit/
│ │ ├── ChipDenomination.swift
│ │ ├── TableLimits.swift # Betting limit presets
│ │ ├── OnboardingState.swift # Onboarding tracking
│ │ ├── TooltipManager.swift # Tooltip management
│ │ └── Session/
│ │ ├── GameSession.swift # Generic session with stats
│ │ ├── GameSessionProtocol.swift # Session protocols
@ -794,7 +780,6 @@ CasinoKit/
│ │ │ └── SheetContainerView.swift
│ │ ├── Onboarding/
│ │ │ ├── WelcomeSheet.swift # First-launch welcome
│ │ │ ├── ContextualTooltip.swift # In-game hints
│ │ │ └── PulsingModifier.swift # Attention-grabbing pulse
│ │ ├── Branding/
│ │ │ ├── AppIconView.swift

View File

@ -8,13 +8,15 @@
// This file ensures all public types are exported from the module.
// Clients can simply `import CasinoKit` to access all components.
// Re-export Sherpa for walkthrough functionality
@_exported import Sherpa
// MARK: - Models
// - Card, Suit, Rank
// - Deck
// - ChipDenomination
// - TableLimits
// - OnboardingState
// - TooltipManager, TooltipConfig
// - GameSettingsProtocol (shared settings interface)
// - SettingsKeys, SettingsDefaults (persistence helpers)
@ -26,9 +28,14 @@
// - ChipStackView, ChipOnTableView
// - SheetContainerView, SheetSection
// - WelcomeSheet, WelcomeFeature
// - ContextualTooltip, ContextualTooltipModifier
// - PulsingModifier
// MARK: - Walkthrough (via Sherpa)
// Re-exported from Sherpa package:
// - SherpaContainerView, SherpaConfiguration
// - SherpaTags, Callout, Sherpa, SherpaDelegate
// - .sherpaTag(), .sherpa() view modifiers
// MARK: - Effects
// - ConfettiView, ConfettiPiece

View File

@ -1,114 +0,0 @@
//
// TooltipManager.swift
// CasinoKit
//
// Manages contextual tooltips for onboarding.
//
import SwiftUI
/// Configuration for a contextual tooltip
public struct TooltipConfig: Equatable {
public let key: String
public let message: String
public let icon: String
public let position: ContextualTooltip.TooltipPosition
public init(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition) {
self.key = key
self.message = message
self.icon = icon
self.position = position
}
}
/// Manages the display of contextual tooltips with automatic dismissal and persistence
@Observable
@MainActor
public final class TooltipManager {
private let onboarding: OnboardingState
/// Currently showing tooltip (nil if none showing)
public var currentTooltip: TooltipConfig? {
didSet {
// Mark the old tooltip as shown when replaced by a new one
if let oldValue = oldValue, oldValue.key != currentTooltip?.key {
onboarding.markHintShown(oldValue.key)
}
}
}
public init(onboarding: OnboardingState) {
self.onboarding = onboarding
}
/// Shows a tooltip with a delay
public func show(
key: String,
message: String,
icon: String = "lightbulb.fill",
position: ContextualTooltip.TooltipPosition = .bottom,
delay: TimeInterval = 1.0
) {
Task { @MainActor in
try? await Task.sleep(for: .seconds(delay))
// Double-check hint hasn't been shown while we were waiting
guard onboarding.shouldShowHint(key) else { return }
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
currentTooltip = TooltipConfig(
key: key,
message: message,
icon: icon,
position: position
)
}
}
}
/// Dismisses the current tooltip and marks it as shown
public func dismiss() {
if let current = currentTooltip {
onboarding.markHintShown(current.key)
}
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
currentTooltip = nil
}
}
}
// MARK: - View Modifier
public extension View {
/// Displays the current tooltip from a TooltipManager
func dynamicTooltip(_ manager: TooltipManager) -> some View {
self.overlay {
if let tooltip = manager.currentTooltip {
VStack {
if tooltip.position == .bottom {
Spacer()
.allowsHitTesting(false)
}
ContextualTooltip(
message: tooltip.message,
icon: tooltip.icon,
position: tooltip.position,
onDismiss: {
manager.dismiss()
}
)
.padding(.horizontal, CasinoDesign.Spacing.medium)
.transition(.move(edge: tooltip.position == .bottom ? .bottom : .top).combined(with: .opacity))
if tooltip.position == .top {
Spacer()
.allowsHitTesting(false)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.zIndex(1000)
}
}
}
}

View File

@ -1130,9 +1130,6 @@
"Global" : {
"comment" : "Title for the \"Global\" tab in the statistics view.",
"isCommentAutoGenerated" : true
},
"Got it" : {
},
"Hands" : {
"comment" : "Label for the number of hands played in the current session.",

View File

@ -32,8 +32,32 @@ public struct TopBarButton: Identifiable {
}
}
/// Optional Sherpa tags for TopBarView elements.
/// Use this to enable walkthrough highlighting of individual top bar elements.
public struct TopBarSherpaTags<Tags: SherpaTags> {
public let balance: Tags?
public let cardsRemaining: Tags?
public let stats: Tags?
public let rules: Tags?
public let settings: Tags?
public init(
balance: Tags? = nil,
cardsRemaining: Tags? = nil,
stats: Tags? = nil,
rules: Tags? = nil,
settings: Tags? = nil
) {
self.balance = balance
self.cardsRemaining = cardsRemaining
self.stats = stats
self.rules = rules
self.settings = settings
}
}
/// A top bar showing balance and customizable toolbar buttons.
public struct TopBarView: View {
public struct TopBarView<Tags: SherpaTags>: View {
/// The current balance to display.
public let balance: Int
@ -55,6 +79,9 @@ public struct TopBarView: View {
/// Action when stats is tapped.
public let onStats: (() -> Void)?
/// Optional Sherpa tags for walkthrough highlighting.
public let sherpaTags: TopBarSherpaTags<Tags>?
// MARK: - Font Sizes (fixed for top bar constraints)
private let balanceFontSize: CGFloat = 24
@ -71,7 +98,140 @@ public struct TopBarView: View {
/// - onSettings: Settings button action.
/// - onHelp: Help button action.
/// - onStats: Stats button action.
/// - sherpaTags: Optional Sherpa tags for walkthrough highlighting.
public init(
balance: Int,
secondaryInfo: String? = nil,
secondaryIcon: String? = nil,
leadingButtons: [TopBarButton] = [],
onSettings: (() -> Void)? = nil,
onHelp: (() -> Void)? = nil,
onStats: (() -> Void)? = nil,
sherpaTags: TopBarSherpaTags<Tags>? = nil
) {
self.balance = balance
self.secondaryInfo = secondaryInfo
self.secondaryIcon = secondaryIcon
self.leadingButtons = leadingButtons
self.onSettings = onSettings
self.onHelp = onHelp
self.onStats = onStats
self.sherpaTags = sherpaTags
}
public var body: some View {
HStack {
// Balance display
balanceView
Spacer()
// Secondary info (centered)
if let info = secondaryInfo {
secondaryInfoView(info: info)
}
Spacer()
// Toolbar buttons
toolbarButtonsView
}
.padding(.horizontal, CasinoDesign.Spacing.large)
.padding(.vertical, CasinoDesign.Spacing.small)
}
// MARK: - Private Views
@ViewBuilder
private var balanceView: some View {
let view = HStack(spacing: CasinoDesign.Spacing.xxSmall) {
Text("$")
.font(.system(size: dollarFontSize, weight: .bold))
.foregroundStyle(Color.CasinoTopBar.balanceText)
Text(balance.formatted())
.font(.system(size: balanceFontSize, weight: .bold, design: .rounded))
.foregroundStyle(Color.CasinoTopBar.balanceText)
.contentTransition(.numericText())
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Balance", bundle: .module))
.accessibilityValue("$\(balance.formatted())")
if let tag = sherpaTags?.balance {
view.sherpaTag(tag)
} else {
view
}
}
@ViewBuilder
private func secondaryInfoView(info: String) -> some View {
let view = HStack(spacing: CasinoDesign.Spacing.xSmall) {
if let icon = secondaryIcon {
Image(systemName: icon)
}
Text(info)
}
.font(.system(size: secondaryFontSize))
.foregroundStyle(Color.CasinoTopBar.secondaryText)
if let tag = sherpaTags?.cardsRemaining {
view.sherpaTag(tag)
} else {
view
}
}
@ViewBuilder
private var toolbarButtonsView: some View {
HStack(spacing: CasinoDesign.Spacing.medium) {
// Custom leading buttons (game-specific)
ForEach(leadingButtons) { button in
ToolbarButton(icon: button.icon, action: button.action)
.accessibilityLabel(button.accessibilityLabel)
}
if let onStats = onStats {
let statsButton = ToolbarButton(icon: "chart.bar.fill", action: onStats)
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
if let tag = sherpaTags?.stats {
statsButton.sherpaTag(tag)
} else {
statsButton
}
}
if let onHelp = onHelp {
let helpButton = ToolbarButton(icon: "info.circle", action: onHelp)
.accessibilityLabel(String(localized: "Rules", bundle: .module))
if let tag = sherpaTags?.rules {
helpButton.sherpaTag(tag)
} else {
helpButton
}
}
if let onSettings = onSettings {
let settingsButton = ToolbarButton(icon: "gearshape.fill", action: onSettings)
.accessibilityLabel(String(localized: "Settings", bundle: .module))
if let tag = sherpaTags?.settings {
settingsButton.sherpaTag(tag)
} else {
settingsButton
}
}
}
}
}
/// Convenience initializer for TopBarView without Sherpa tags.
public extension TopBarView where Tags == NoSherpaTags {
/// Creates a top bar without Sherpa walkthrough support.
init(
balance: Int,
secondaryInfo: String? = nil,
secondaryIcon: String? = nil,
@ -87,72 +247,19 @@ public struct TopBarView: View {
self.onSettings = onSettings
self.onHelp = onHelp
self.onStats = onStats
self.sherpaTags = nil
}
}
public var body: some View {
HStack {
// Balance display
HStack(spacing: CasinoDesign.Spacing.xxSmall) {
Text("$")
.font(.system(size: dollarFontSize, weight: .bold))
.foregroundStyle(Color.CasinoTopBar.balanceText)
Text(balance.formatted())
.font(.system(size: balanceFontSize, weight: .bold, design: .rounded))
.foregroundStyle(Color.CasinoTopBar.balanceText)
.contentTransition(.numericText())
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Balance", bundle: .module))
.accessibilityValue("$\(balance.formatted())")
Spacer()
// Secondary info (centered)
if let info = secondaryInfo {
HStack(spacing: CasinoDesign.Spacing.xSmall) {
if let icon = secondaryIcon {
Image(systemName: icon)
}
Text(info)
}
.font(.system(size: secondaryFontSize))
.foregroundStyle(Color.CasinoTopBar.secondaryText)
}
Spacer()
// Toolbar buttons
HStack(spacing: CasinoDesign.Spacing.medium) {
// Custom leading buttons (game-specific)
ForEach(leadingButtons) { button in
ToolbarButton(icon: button.icon, action: button.action)
.accessibilityLabel(button.accessibilityLabel)
}
if let onStats = onStats {
ToolbarButton(icon: "chart.bar.fill", action: onStats)
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
}
if let onHelp = onHelp {
ToolbarButton(icon: "info.circle", action: onHelp)
.accessibilityLabel(String(localized: "Rules", bundle: .module))
}
if let onSettings = onSettings {
ToolbarButton(icon: "gearshape.fill", action: onSettings)
.accessibilityLabel(String(localized: "Settings", bundle: .module))
}
}
}
.padding(.horizontal, CasinoDesign.Spacing.large)
.padding(.vertical, CasinoDesign.Spacing.small)
/// A placeholder SherpaTags type for when no walkthrough is needed.
public enum NoSherpaTags: SherpaTags {
public func makeCallout() -> Callout {
.text("")
}
}
/// A single toolbar button.
private struct ToolbarButton: View {
struct ToolbarButton: View {
let icon: String
let action: () -> Void
@ -172,7 +279,7 @@ private struct ToolbarButton: View {
Color.CasinoTable.felt.ignoresSafeArea()
VStack {
TopBarView(
TopBarView<NoSherpaTags>(
balance: 10_500,
secondaryInfo: "411",
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",

View File

@ -1,164 +0,0 @@
//
// ContextualTooltip.swift
// CasinoKit
//
// Contextual tooltip for showing hints and tips to users.
//
import SwiftUI
/// A tooltip that appears to guide users through features.
public struct ContextualTooltip: View {
let message: String
let icon: String
let position: TooltipPosition
let onDismiss: () -> Void
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = CasinoDesign.BaseFontSize.body
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.IconSize.small
public enum TooltipPosition {
case top, bottom, leading, trailing
}
public init(
message: String,
icon: String = "lightbulb.fill",
position: TooltipPosition = .top,
onDismiss: @escaping () -> Void
) {
self.message = message
self.icon = icon
self.position = position
self.onDismiss = onDismiss
}
public var body: some View {
HStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: icon)
.font(.system(size: iconSize))
.foregroundStyle(Color.Sheet.accent)
Text(message)
.font(.system(size: bodyFontSize))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button {
onDismiss()
} label: {
Text("Got it")
.font(.system(size: bodyFontSize, weight: .semibold))
.foregroundStyle(Color.Sheet.accent)
}
}
.padding(CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.black.opacity(CasinoDesign.Opacity.almostFull))
.shadow(
color: .black.opacity(CasinoDesign.Opacity.medium),
radius: CasinoDesign.Shadow.radiusLarge,
y: CasinoDesign.Shadow.offsetMedium
)
)
.padding(.horizontal, CasinoDesign.Spacing.medium)
.transition(.move(edge: edgeForPosition).combined(with: .opacity))
}
private var edgeForPosition: Edge {
switch position {
case .top: return .top
case .bottom: return .bottom
case .leading: return .leading
case .trailing: return .trailing
}
}
}
/// Modifier to show a contextual tooltip above a view.
public struct ContextualTooltipModifier: ViewModifier {
let message: String
let icon: String
let position: ContextualTooltip.TooltipPosition
@Binding var isShowing: Bool
let onDismiss: () -> Void
public func body(content: Content) -> some View {
ZStack(alignment: alignmentForPosition) {
content
if isShowing {
ContextualTooltip(
message: message,
icon: icon,
position: position,
onDismiss: {
withAnimation(.spring(duration: CasinoDesign.Animation.quick)) {
isShowing = false
}
onDismiss()
}
)
.offset(offsetForPosition)
.zIndex(1000)
}
}
}
private var alignmentForPosition: Alignment {
switch position {
case .top: return .top
case .bottom: return .bottom
case .leading: return .leading
case .trailing: return .trailing
}
}
private var offsetForPosition: CGSize {
switch position {
case .top: return CGSize(width: 0, height: -CasinoDesign.Spacing.small)
case .bottom: return CGSize(width: 0, height: CasinoDesign.Spacing.small)
case .leading: return CGSize(width: -CasinoDesign.Spacing.small, height: 0)
case .trailing: return CGSize(width: CasinoDesign.Spacing.small, height: 0)
}
}
}
public extension View {
/// Shows a contextual tooltip when the binding is true.
func contextualTooltip(
_ message: String,
icon: String = "lightbulb.fill",
position: ContextualTooltip.TooltipPosition = .top,
isShowing: Binding<Bool>,
onDismiss: @escaping () -> Void = {}
) -> some View {
modifier(ContextualTooltipModifier(
message: message,
icon: icon,
position: position,
isShowing: isShowing,
onDismiss: onDismiss
))
}
}
#Preview {
ZStack {
Color.black
VStack {
Text("Some Content")
.foregroundStyle(.white)
}
}
.contextualTooltip(
"This is a helpful tip that appears at the right moment!",
position: .top,
isShowing: .constant(true)
)
}

View File

@ -2,93 +2,154 @@
## Overview
A comprehensive, non-intrusive onboarding system has been implemented for both Blackjack and Baccarat games. The system provides a great first-time user experience without annoying experienced players.
A comprehensive, non-intrusive onboarding system has been implemented for both Blackjack and Baccarat games using the **Sherpa** walkthrough framework. The system provides a great first-time user experience without annoying experienced players.
## Components Created (in CasinoKit)
## Architecture
### 1. OnboardingState.swift
The onboarding system uses:
- **Sherpa** - A SwiftUI walkthrough framework for spotlight-based guided tours
- **OnboardingState** - Tracks completion status and hint visibility
- **WelcomeSheet** - First-launch welcome screen with feature highlights
## Sherpa Integration
### Package Dependency
Sherpa is added as a dependency in `CasinoKit/Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/mbrucedogs/Sherpa.git", branch: "develop")
]
```
It's re-exported from CasinoKit, so games only need to `import CasinoKit`.
### App Structure
Each game wraps its content in `SherpaContainerView`:
```swift
@main
struct BlackjackApp: App {
var body: some Scene {
WindowGroup {
SherpaContainerView(configuration: .default) {
AppLaunchView(config: .blackjack) {
ContentView()
}
}
}
}
}
```
### Walkthrough Tags
Each game defines walkthrough steps using `SherpaTags`:
```swift
enum BlackjackWalkthroughTags: SherpaTags {
case bettingZone
case dealButton
case playerActions
func makeCallout() -> Callout {
switch self {
case .bettingZone:
return .localizedLabeled(
"walkthrough.bettingZone",
systemImage: "hand.tap.fill",
edge: .top
)
// ...
}
}
}
```
### View Tagging
Views are tagged for spotlight highlighting:
```swift
BettingZoneView(state: state, selectedChip: selectedChip)
.sherpaTag(BlackjackWalkthroughTags.bettingZone)
BettingActionsView(...)
.sherpaTag(BlackjackWalkthroughTags.dealButton)
```
### Activation
The walkthrough is activated when the user taps "Show Me How":
```swift
struct GameTableView: View, SherpaDelegate {
@State private var isWalkthroughActive = false
var body: some View {
mainGameView(state: state)
.sherpa(isActive: isWalkthroughActive, tags: BlackjackWalkthroughTags.self, delegate: self)
}
private func startWalkthrough() {
isWalkthroughActive = true
}
// MARK: - SherpaDelegate
func onWalkthroughComplete(sherpa: Sherpa) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
}
```
## Components
### 1. OnboardingState.swift (CasinoKit)
- Tracks first-time user progress
- Manages which hints have been shown
- Persists state to UserDefaults
- Supports tutorial mode (replay hints)
- Game-specific identifiers (separate state per game)
**Key Methods:**
- `shouldShowHint(key:)` - Check if hint should be displayed
- `markHintShown(key:)` - Mark hint as seen (persisted)
- `completeWelcome()` - Mark welcome sheet as completed
- `startTutorialMode()` / `endTutorialMode()` - Enable/disable tutorial replay
- `completeWelcome()` - Mark welcome/walkthrough as completed
- `skipOnboarding()` - Skip onboarding entirely
- `reset()` - Clear all onboarding data (for testing)
### 2. WelcomeSheet.swift
### 2. WelcomeSheet.swift (CasinoKit)
- First-launch welcome screen
- Lists key features with icons
- Two CTAs: "Show Me How" (tutorial) or "Start Playing" (skip)
- Automatically shows game emoji (🃏 for Blackjack, 🎴 for Baccarat)
- Two CTAs: "Show Me How" (starts walkthrough) or "Start Playing" (skip)
- Fully localized
### 3. ContextualTooltip.swift
- In-game hint tooltips
- Appears at the right moment
- "Got it" button to dismiss
- Animated entry/exit
- Automatically marks hint as shown on dismiss
- View modifier for easy integration
### 3. WalkthroughTags (Per Game)
- Defines walkthrough steps for each game
- Provides localized callout content
- Specifies icons and positioning
### 4. PulsingModifier.swift
- Visual pulse animation to draw attention
- Optional enhancement for interactive elements
- Configurable color and scale
- Currently available but not actively used (can be added later)
## Integration in Games
## Walkthrough Steps
### Blackjack
**Welcome Sheet Features:**
1. Beat the Dealer - Get closer to 21
2. Learn Strategy - Built-in hints
3. Practice Free - Start with $1,000
4. Customize Rules - Change settings
**Contextual Hints:**
- **Betting Zone**: "Tap chips, then tap here to bet" (when entering betting phase for first time)
- **Deal Button**: "Tap Deal to start the round" (after placing first bet)
- **Player Actions**: "Choose your action based on the hint above" (during first player turn)
| Step | Tag | Description |
|------|-----|-------------|
| 1 | `bettingZone` | Highlights betting area - "Select a chip and tap the bet area" |
| 2 | `dealButton` | Highlights deal button - "Tap Deal to start the round" |
| 3 | `playerActions` | Highlights action buttons - "Choose your action based on the hint above" |
### Baccarat
**Welcome Sheet Features:**
1. Bet on Player, Banker, or Tie
2. Track Patterns - Road maps show history
3. Practice Free - Start with $1,000
4. Customize Settings - Change table limits
**Contextual Hints:**
- **Betting Zone**: "Tap chips, then tap a betting zone" (when entering betting phase for first time)
- **Deal Button**: "Tap Deal to start the round" (after placing first bet)
- **Result Display**: "Results appear here, then in the road maps below" (after first round completes)
## Settings Changes
Both games now default to more beginner-friendly settings:
| Setting | Old Default | New Default |
|---------|-------------|-------------|
| Table Limits | Low Stakes ($10-$1,000) | Casual ($5-$500) |
| Starting Balance | $10,000 | $1,000 |
**Rationale:**
- Lower minimum bet ($5) is less intimidating for beginners
- $1,000 balance creates more meaningful decisions (not too much/little)
- Still allows ~200 hands at $5/hand for good learning experience
- Users can always increase in settings
## Bug Fix
**Blackjack Late Surrender:**
- Fixed inconsistency where `resetToDefaults()` set it to `true` but Vegas preset overrode to `false`
- Now correctly defaults to `false` to match Vegas rules
| Step | Tag | Description |
|------|-----|-------------|
| 1 | `bettingZone` | Highlights betting table - "Select a chip and tap a bet zone" |
| 2 | `dealButton` | Highlights deal button - "Tap Deal to start the round" |
## User Flow
@ -96,72 +157,73 @@ Both games now default to more beginner-friendly settings:
1. App loads
2. Welcome sheet appears automatically after 500ms delay
3. User chooses:
- **"Show Me How"**: Enables tutorial mode, shows contextual hints during first round
- **"Start Playing"**: Skips tutorial, hints still appear once naturally during gameplay
- **"Show Me How"**: Starts Sherpa walkthrough with spotlight focus
- **"Start Playing"**: Skips walkthrough, marks onboarding complete
### Walkthrough Experience:
1. Screen dims with spotlight on highlighted element
2. Callout tooltip explains the element
3. User taps to advance to next step
4. Progress indicator shows current step
5. Skip button available at all times
### Subsequent Launches:
- Welcome sheet never shows again
- Contextual hints appear once at the right moment
- Each hint only shows once per hint key
- Completely transparent to returning users
- Walkthrough can be replayed via settings (if implemented)
### Tutorial Mode:
- User can replay tutorial by tapping "Show Me How" on welcome
- All hints re-enabled for that session
- Tutorial mode doesn't persist across app launches
## Localization
## Technical Details
Walkthrough strings are stored in each game's `Localizable.xcstrings`:
### Hint Timing
- Hints are shown with delays using `Task.sleep(for:)` on MainActor
- Animations use spring duration for smooth transitions
- onChange modifiers trigger hints based on game state changes
| Key | English | Spanish (MX) | French (CA) |
|-----|---------|--------------|-------------|
| `walkthrough.bettingZone` | Select a chip and tap the bet area | Selecciona una ficha y toca el área de apuesta | Sélectionnez un jeton et touchez la zone de mise |
| `walkthrough.dealButton` | Tap Deal to start the round | Toca Repartir para comenzar la ronda | Touchez Distribuer pour commencer la manche |
| `walkthrough.playerActions` | Choose your action based on the hint above | Elige tu acción según la sugerencia de arriba | Choisissez votre action selon l'indice ci-dessus |
### Persistence
- Uses UserDefaults with game-specific keys
- Format: `"onboarding.{gameIdentifier}.{property}"`
- Examples: `"onboarding.blackjack.hasLaunched"`, `"onboarding.baccarat.hintsShown"`
## Benefits of Sherpa Migration
### Thread Safety
- All onboarding state is @MainActor
- Safe to use from SwiftUI views
- No threading concerns
| Before (TooltipManager) | After (Sherpa) |
|------------------------|----------------|
| Simple tooltips without focus | Spotlight effect highlights elements |
| Manual positioning | Smart auto-positioning |
| No navigation controls | Back/Next/Skip controls |
| No progress indicator | Step indicator dots |
| Basic animations | Smooth transitions with haptics |
| Custom implementation | Well-tested framework |
| 10+ languages | 10 languages built-in |
## Documentation Updates
## Files Changed
### Blackjack README
- Added "First-Time User Experience" section at top of features
- Documents welcome sheet, tutorial mode, and contextual hints
### CasinoKit
- `Package.swift` - Added Sherpa dependency
- `Exports.swift` - Re-exports Sherpa
- Deleted: `TooltipManager.swift`, `ContextualTooltip.swift`
### Baccarat README
- Added "First-Time User Experience" section at top of features
- Same structure as Blackjack for consistency
### Blackjack
- Added: `Models/WalkthroughTags.swift`
- Modified: `BlackjackApp.swift`, `GameTableView.swift`, `ActionButtonsView.swift`
- Modified: `BlackjackTableView.swift` (added sherpaTag)
- Modified: `Localizable.xcstrings` (walkthrough strings)
### CasinoKit README
- New "Onboarding & Tutorials" section with full API documentation
- Code examples for WelcomeSheet, ContextualTooltip, OnboardingState
- Updated file structure to show new components
- Added Blackjack to "Apps Using CasinoKit" list
### Baccarat
- Added: `Models/WalkthroughTags.swift`
- Modified: `BaccaratApp.swift`, `GameTableView.swift`, `ActionButtonsView.swift`
- Modified: `BettingTableView.swift` (added sherpaTag)
- Modified: `Localizable.xcstrings` (walkthrough strings)
## Best Practices Followed
**Non-intrusive**: All onboarding is skippable
**One-time only**: Hints never show twice (unless tutorial mode)
**Right moment**: Hints appear contextually, not all at once
**Short & visual**: Messages are concise with icons
**Sequential flow**: Clear progression through steps
**Visual focus**: Spotlight draws attention to key elements
**Haptic feedback**: Tactile response on step changes
**Localized**: All strings use String Catalog
**Accessible**: Uses standard SwiftUI components
**Persistent**: User's progress is saved
**Game-specific**: Each game has independent onboarding state
**Accessible**: VoiceOver announcements for each step
**Persistent**: User's completion status is saved
**Game-specific**: Each game has independent walkthrough
## Testing Recommendations
### Manual Testing:
1. **Fresh install**: Delete app, reinstall, verify welcome sheet appears
2. **Tutorial mode**: Tap "Show Me How", verify hints appear in sequence
3. **Skip mode**: Tap "Start Playing", verify hints still appear once naturally
4. **Second launch**: Relaunch app, verify welcome doesn't show again
5. **Settings reset**: Check if onboarding can be reset (could add to settings)
## Testing
### Reset for Testing:
Add this to a development menu if needed:
@ -171,60 +233,9 @@ Button("Reset Onboarding") {
}
```
## Future Enhancements (Not Implemented)
These could be added later if desired:
1. **Progressive discovery hints**: Tips after 5/10/20 hands
- "Enable card counting in settings" (Blackjack)
- "Side bets offer bigger payouts" (both games)
2. **Onboarding analytics**: Track which hints are most helpful
3. **User-requested replay**: "Show tutorial again" in settings
4. **Pulsing hints**: Use PulsingModifier on interactive elements during tutorial
5. **Accessibility announcements**: Post VoiceOver announcements for key moments
## Files Created
```
CasinoKit/Sources/CasinoKit/
├── Models/OnboardingState.swift
└── Views/
├── ContextualTooltip.swift
├── WelcomeSheet.swift
└── PulsingModifier.swift
```
## Files Modified
```
Blackjack/
├── Models/GameSettings.swift (defaults changed, bug fix)
├── Engine/GameState.swift (added onboarding property)
└── Views/Game/GameTableView.swift (integrated onboarding UI)
Baccarat/
├── Models/GameSettings.swift (defaults changed)
├── Engine/GameState.swift (added onboarding property)
└── Views/Game/GameTableView.swift (integrated onboarding UI)
README Files:
├── Blackjack/README.md
├── Baccarat/README.md
└── CasinoKit/README.md
```
## Conclusion
The onboarding system provides a polished first-time user experience that:
- Helps new users understand the game quickly
- Never annoys experienced players
- Maintains the premium feel of the apps
- Follows iOS best practices
- Is fully reusable for future casino games
All code follows the project's Swift/SwiftUI guidelines, uses design constants, and includes proper accessibility support.
### Manual Testing:
1. Delete app, reinstall, verify welcome sheet appears
2. Tap "Show Me How", verify spotlight walkthrough starts
3. Navigate through all steps
4. Verify completion callback fires
5. Relaunch app, verify welcome doesn't show again

View File

@ -1,246 +0,0 @@
# Tooltip System Refactoring
## Overview
The tooltip management system has been refactored from game-specific implementations to a generic, reusable system in CasinoKit.
## Problem
The original implementation had several issues:
- **Verbose**: Each game had duplicate `TooltipConfig` structs and complex state management
- **Not scalable**: Adding new tooltips required significant boilerplate
- **Repetitive logic**: Similar code duplicated across Blackjack and Baccarat
- **Difficult to maintain**: Changes required updates in multiple places
## Solution
Created a centralized `TooltipManager` class in CasinoKit that:
- Manages tooltip state generically
- Automatically handles dismiss tracking
- Ensures only one tooltip shows at a time
- Integrates seamlessly with `OnboardingState`
## New Components
### 1. `TooltipConfig` (CasinoKit)
A simple struct defining tooltip properties:
```swift
public struct TooltipConfig: Equatable {
public let key: String
public let message: String
public let icon: String
public let position: ContextualTooltip.TooltipPosition
}
```
### 2. `TooltipManager` (CasinoKit)
An `@Observable` class that manages tooltip lifecycle:
```swift
@Observable
@MainActor
public final class TooltipManager {
private let onboarding: OnboardingState
public var currentTooltip: TooltipConfig?
public func show(
key: String,
message: String,
icon: String = "lightbulb.fill",
position: ContextualTooltip.TooltipPosition = .bottom,
delay: TimeInterval = 1.0
)
public func dismiss()
}
```
**Key Features:**
- Automatic dismiss tracking via `didSet` on `currentTooltip`
- Delayed presentation with cancellation if hint already shown
- Respects `OnboardingState.shouldShowHint()` checks
- Single source of truth for current tooltip
### 3. `dynamicTooltip` View Modifier (CasinoKit)
A convenient view modifier that displays tooltips:
```swift
public extension View {
func dynamicTooltip(_ manager: TooltipManager) -> some View
}
```
## Usage in Games
### Before (Verbose)
```swift
// State variables for each tooltip
@State private var showBettingHint = false
@State private var showDealHint = false
@State private var showActionsHint = false
// Separate struct definition
private struct TooltipConfig {
let key: String
let message: String
let icon: String
let position: ContextualTooltip.TooltipPosition
}
// Complex state with tracking
@State private var currentTooltip: TooltipConfig? {
didSet {
if let oldValue = oldValue, oldValue.key != currentTooltip?.key {
state.onboarding.markHintShown(oldValue.key)
}
}
}
// Generic helper with lots of parameters
private func showTooltip(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition, delay: TimeInterval) {
Task { @MainActor in
try? await Task.sleep(for: .seconds(delay))
guard state.onboarding.shouldShowHint(key) else { return }
withAnimation(.spring(duration: Design.Animation.springDuration)) {
currentTooltip = TooltipConfig(key: key, message: message, icon: icon, position: position)
}
}
}
// Complex overlay in view body
.overlay {
if let tooltip = currentTooltip {
VStack {
// ... positioning logic
ContextualTooltip(
message: tooltip.message,
icon: tooltip.icon,
position: tooltip.position,
onDismiss: {
state.onboarding.markHintShown(tooltip.key)
withAnimation(.spring(duration: Design.Animation.springDuration)) {
currentTooltip = nil
}
}
)
// ... more positioning
}
}
}
```
### After (Clean)
```swift
// Single state variable
@State private var tooltipManager: TooltipManager?
// Initialize in onAppear
.onAppear {
tooltipManager = TooltipManager(onboarding: state.onboarding)
}
// Simple show calls
private func showBettingHint() {
tooltipManager?.show(
key: "bettingZone",
message: "Select a chip and tap here to bet",
icon: "hand.tap.fill",
position: .bottom,
delay: 1.0
)
}
// One-line view modifier
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
```
## Benefits
1. **Reduced code**: ~70 lines of boilerplate eliminated per game
2. **Better scalability**: Adding new tooltips is now trivial
3. **Single source of truth**: All tooltip logic in CasinoKit
4. **Easier maintenance**: Changes only need to be made in one place
5. **Reusable**: Any new game can use `TooltipManager` with minimal setup
## Files Changed
### CasinoKit
- **Created**: `Sources/CasinoKit/Models/TooltipManager.swift`
- **Updated**: `Sources/CasinoKit/Exports.swift` (added TooltipManager export)
- **Updated**: `README.md` (added TooltipManager documentation)
### Blackjack
- **Updated**: `Views/Game/GameTableView.swift`
- Removed local `TooltipConfig` struct
- Removed `currentTooltip` @State with didSet
- Removed generic `showTooltip()` helper
- Added `@State private var tooltipManager: TooltipManager?`
- Simplified tooltip show methods
- Replaced complex overlay with `.dynamicTooltip()` modifier
### Baccarat
- **Updated**: `Views/Game/GameTableView.swift`
- Same changes as Blackjack
## Migration Guide
To use `TooltipManager` in a new game:
1. **Add state variable:**
```swift
@State private var tooltipManager: TooltipManager?
```
2. **Initialize in onAppear:**
```swift
.onAppear {
tooltipManager = TooltipManager(onboarding: gameState.onboarding)
}
```
3. **Apply view modifier:**
```swift
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding))
```
4. **Show tooltips:**
```swift
tooltipManager?.show(
key: "uniqueKey",
message: "Your hint text",
icon: "lightbulb.fill",
position: .bottom,
delay: 1.0
)
```
## Testing
Both Blackjack and Baccarat have been built and tested successfully with the new system.
```bash
# Blackjack
xcodebuild -workspace CasinoGames.xcworkspace -scheme Blackjack \
-configuration Debug -destination 'platform=iOS Simulator,id=...' build
# ✅ BUILD SUCCEEDED
# Baccarat
xcodebuild -workspace CasinoGames.xcworkspace -scheme Baccarat \
-configuration Debug -destination 'platform=iOS Simulator,id=...' build
# ✅ BUILD SUCCEEDED
```
## Future Enhancements
Potential improvements:
- **Tooltip queue**: Allow queueing multiple tooltips
- **Positioning hints**: Auto-detect best position based on screen space
- **Animation customization**: Per-tooltip animation styles
- **Analytics**: Track which tooltips are most helpful