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

This commit is contained in:
Matt Bruce 2026-01-25 09:14:43 -06:00
parent 100eb83422
commit 2d07147fa7
12 changed files with 2175 additions and 143 deletions

View File

@ -0,0 +1,282 @@
//
// RoadMapBuilder.swift
// Baccarat
//
// Builds road map data structures from round results.
// Implements Big Road layout and derived road algorithms (Big Eye Boy, Small Road, Cockroach Pig).
//
import Foundation
// MARK: - Road Map Builder
/// Builds road map data from round results.
/// Converts raw round history into structured Big Road and derived road entries.
enum RoadMapBuilder {
/// Maximum number of rows before dragon tail wrapping.
static let maxRows = 6
// MARK: - Big Road
/// Builds Big Road entries from round results.
/// - Parameter results: The round history to convert.
/// - Returns: An array of BigRoadEntry positioned in the grid.
static func buildBigRoad(from results: [RoundResult]) -> [BigRoadEntry] {
guard !results.isEmpty else { return [] }
var entries: [BigRoadEntry] = []
var currentColumn = 0
var currentRow = 0
var lastNonTieResult: GameResult?
var pendingTies = 0
for result in results {
if result.result == .tie {
// Ties don't create new entries - they attach to the previous result
pendingTies += 1
// If we have entries, update the last one with the tie count
if !entries.isEmpty {
let lastIndex = entries.count - 1
let lastEntry = entries[lastIndex]
entries[lastIndex] = BigRoadEntry(
id: lastEntry.id,
result: lastEntry.result,
column: lastEntry.column,
row: lastEntry.row,
tieCount: lastEntry.tieCount + 1,
isNatural: lastEntry.isNatural,
hasPair: lastEntry.hasPair,
roundResult: lastEntry.roundResult
)
}
continue
}
// Determine if we need a new column
if let lastResult = lastNonTieResult {
if result.result != lastResult {
// Winner changed - start new column
currentColumn += 1
currentRow = 0
} else {
// Same winner - continue down the column
currentRow += 1
// Handle dragon tail (wrap to next column if exceeding max rows)
if currentRow >= maxRows {
currentColumn += 1
currentRow = maxRows - 1
}
}
}
let entry = BigRoadEntry(
result: result.result,
column: currentColumn,
row: currentRow,
tieCount: 0,
isNatural: result.isNatural,
hasPair: result.hasPair,
roundResult: result
)
entries.append(entry)
lastNonTieResult = result.result
pendingTies = 0
}
return entries
}
/// Builds Big Road columns from entries for easier pattern analysis.
/// - Parameter entries: The Big Road entries.
/// - Returns: An array of BigRoadColumn containing grouped entries.
static func buildBigRoadColumns(from entries: [BigRoadEntry]) -> [BigRoadColumn] {
guard !entries.isEmpty else { return [] }
var columns: [BigRoadColumn] = []
var currentColumnIndex = -1
for entry in entries {
if entry.column > currentColumnIndex {
// Start new column(s) - handle gaps from dragon tail
while columns.count <= entry.column {
columns.append(BigRoadColumn(index: columns.count))
}
currentColumnIndex = entry.column
}
if entry.column < columns.count {
columns[entry.column].entries.append(entry)
}
}
return columns
}
// MARK: - Derived Roads
/// Builds a derived road (Big Eye Boy, Small Road, or Cockroach Pig) from Big Road columns.
/// - Parameters:
/// - columns: The Big Road columns to analyze.
/// - roadType: The type of derived road to build.
/// - Returns: An array of DerivedRoadEntry for the specified road type.
static func buildDerivedRoad(
from columns: [BigRoadColumn],
roadType: RoadType
) -> [DerivedRoadEntry] {
guard roadType.isDerived else { return [] }
let offset = roadType.columnOffset
let startingColumn = roadType.startingColumn
guard columns.count >= startingColumn else { return [] }
var entries: [DerivedRoadEntry] = []
var derivedColumn = 0
var derivedRow = 0
// Process each column starting from the required starting column
for columnIndex in (startingColumn - 1)..<columns.count {
let column = columns[columnIndex]
// Process each entry in the column
for (entryIndex, _) in column.entries.enumerated() {
let isRed: Bool
if entryIndex == 0 {
// First entry in column - compare column depths
isRed = compareColumnDepths(
columns: columns,
currentColumn: columnIndex,
offset: offset
)
// New column in derived road
if !entries.isEmpty {
derivedColumn += 1
derivedRow = 0
}
} else {
// Continuation entry - compare cells
isRed = compareCells(
columns: columns,
currentColumn: columnIndex,
currentRow: entryIndex,
offset: offset
)
derivedRow += 1
// Handle wrapping in derived road
if derivedRow >= maxRows {
derivedColumn += 1
derivedRow = maxRows - 1
}
}
let entry = DerivedRoadEntry(
isRed: isRed,
column: derivedColumn,
row: derivedRow
)
entries.append(entry)
}
}
return entries
}
// MARK: - Private Helpers
/// Compares column depths for the first entry in a new Big Road column.
/// Used by derived roads to determine if patterns are repeating.
/// - Parameters:
/// - columns: All Big Road columns.
/// - currentColumn: The current column index being analyzed.
/// - offset: The column offset (1 for Big Eye Boy, 2 for Small Road, 3 for Cockroach Pig).
/// - Returns: `true` (red) if depths match, `false` (blue) if they differ.
private static func compareColumnDepths(
columns: [BigRoadColumn],
currentColumn: Int,
offset: Int
) -> Bool {
let previousColumn = currentColumn - 1
let compareColumn = currentColumn - 1 - offset
guard previousColumn >= 0, compareColumn >= 0 else {
return false // Not enough history - default to blue
}
guard previousColumn < columns.count, compareColumn < columns.count else {
return false
}
let previousDepth = columns[previousColumn].depth
let compareDepth = columns[compareColumn].depth
return previousDepth == compareDepth
}
/// Compares cells for continuation entries in a Big Road column.
/// Looks at the cell one column left and the cell above that.
/// - Parameters:
/// - columns: All Big Road columns.
/// - currentColumn: The current column index.
/// - currentRow: The current row index.
/// - offset: The column offset for the derived road type.
/// - Returns: `true` (red) if cells match or both empty, `false` (blue) if they differ.
private static func compareCells(
columns: [BigRoadColumn],
currentColumn: Int,
currentRow: Int,
offset: Int
) -> Bool {
let lookbackColumn = currentColumn - offset
let lookbackRow = currentRow - 1
guard lookbackColumn >= 0, lookbackRow >= 0 else {
return false // Not enough history - default to blue
}
guard lookbackColumn < columns.count else {
return false
}
let lookbackColumnData = columns[lookbackColumn]
// Check if cell at (lookbackColumn, currentRow-1) exists
let cellExists = lookbackRow < lookbackColumnData.depth
// Check if cell at (lookbackColumn, currentRow) exists
let cellBelowExists = currentRow < lookbackColumnData.depth
// If both exist or both don't exist, return red (pattern)
// If one exists and one doesn't, return blue (break)
return cellExists == cellBelowExists
}
// MARK: - Convenience
/// Builds all road maps from round results.
/// - Parameter results: The round history.
/// - Returns: A tuple containing Big Road entries and all derived road entries.
static func buildAllRoads(from results: [RoundResult]) -> (
bigRoad: [BigRoadEntry],
bigEyeBoy: [DerivedRoadEntry],
smallRoad: [DerivedRoadEntry],
cockroachPig: [DerivedRoadEntry]
) {
let bigRoad = buildBigRoad(from: results)
let columns = buildBigRoadColumns(from: bigRoad)
return (
bigRoad: bigRoad,
bigEyeBoy: buildDerivedRoad(from: columns, roadType: .bigEyeBoy),
smallRoad: buildDerivedRoad(from: columns, roadType: .smallRoad),
cockroachPig: buildDerivedRoad(from: columns, roadType: .cockroachPig)
)
}
}

View File

@ -121,6 +121,14 @@ final class GameSettings: GameSettingsProtocol {
/// Whether to show betting hints and recommendations.
var showHints: Bool = true
// MARK: - Road Map Settings
/// The preferred road type to display.
var preferredRoadType: RoadType = .big
/// Whether to show streak alerts.
var showStreakAlerts: Bool = true
// MARK: - Sound Settings
/// Whether sound effects are enabled.
@ -147,6 +155,8 @@ final class GameSettings: GameSettingsProtocol {
static let hapticsEnabled = "settings.hapticsEnabled"
static let soundVolume = "settings.soundVolume"
static let revealStyle = "settings.revealStyle"
static let preferredRoadType = "settings.preferredRoadType"
static let showStreakAlerts = "settings.showStreakAlerts"
}
// MARK: - iCloud
@ -267,6 +277,15 @@ final class GameSettings: GameSettingsProtocol {
let style = RevealStyle(rawValue: rawStyle) {
self.revealStyle = style
}
if let rawRoadType = defaults.string(forKey: Keys.preferredRoadType),
let roadType = RoadType(rawValue: rawRoadType) {
self.preferredRoadType = roadType
}
if defaults.object(forKey: Keys.showStreakAlerts) != nil {
self.showStreakAlerts = defaults.bool(forKey: Keys.showStreakAlerts)
}
}
/// Loads settings from iCloud.
@ -323,6 +342,15 @@ final class GameSettings: GameSettingsProtocol {
let style = RevealStyle(rawValue: rawStyle) {
self.revealStyle = style
}
if let rawRoadType = store.string(forKey: Keys.preferredRoadType),
let roadType = RoadType(rawValue: rawRoadType) {
self.preferredRoadType = roadType
}
if store.object(forKey: Keys.showStreakAlerts) != nil {
self.showStreakAlerts = store.bool(forKey: Keys.showStreakAlerts)
}
}
/// Saves settings to UserDefaults and iCloud.
@ -341,6 +369,8 @@ final class GameSettings: GameSettingsProtocol {
defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
defaults.set(soundVolume, forKey: Keys.soundVolume)
defaults.set(revealStyle.rawValue, forKey: Keys.revealStyle)
defaults.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType)
defaults.set(showStreakAlerts, forKey: Keys.showStreakAlerts)
// Also save to iCloud
if iCloudAvailable, let store = iCloudStore {
@ -356,6 +386,8 @@ final class GameSettings: GameSettingsProtocol {
store.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
store.set(Double(soundVolume), forKey: Keys.soundVolume)
store.set(revealStyle.rawValue, forKey: Keys.revealStyle)
store.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType)
store.set(showStreakAlerts, forKey: Keys.showStreakAlerts)
store.synchronize()
}
}
@ -392,6 +424,8 @@ final class GameSettings: GameSettingsProtocol {
hapticsEnabled = true
soundVolume = 1.0
revealStyle = .auto
preferredRoadType = .big
showStreakAlerts = true
save()
}
}

View File

@ -0,0 +1,230 @@
//
// RoadMapModels.swift
// Baccarat
//
// Models for Baccarat road map displays including Big Road and derived roads.
//
import Foundation
import SwiftUI
// MARK: - Road Types
/// The available road map display types.
enum RoadType: String, CaseIterable, Identifiable, Codable {
case bead = "bead"
case big = "big"
case bigEyeBoy = "bigEyeBoy"
case smallRoad = "smallRoad"
case cockroachPig = "cockroachPig"
var id: String { rawValue }
/// Localized display name for the road type.
var displayName: String {
switch self {
case .bead: return String(localized: "Bead Road")
case .big: return String(localized: "Big Road")
case .bigEyeBoy: return String(localized: "Big Eye Boy")
case .smallRoad: return String(localized: "Small Road")
case .cockroachPig: return String(localized: "Cockroach Pig")
}
}
/// Short description for tooltips.
var shortDescription: String {
switch self {
case .bead:
return String(localized: "Simple grid showing all results in order.")
case .big:
return String(localized: "Shows streaks. New column when winner changes.")
case .bigEyeBoy:
return String(localized: "Compares current streak to previous. Red = repeating pattern.")
case .smallRoad:
return String(localized: "Like Big Eye Boy, but looks 2 columns back.")
case .cockroachPig:
return String(localized: "Finest pattern detection. Looks 3 columns back.")
}
}
/// Icon for the road type.
var icon: String {
switch self {
case .bead: return "circle.grid.3x3"
case .big: return "chart.bar.xaxis"
case .bigEyeBoy: return "eye.fill"
case .smallRoad: return "road.lanes"
case .cockroachPig: return "ant.fill"
}
}
/// Whether this is a derived road (Big Eye Boy, Small Road, Cockroach Pig).
var isDerived: Bool {
switch self {
case .bead, .big: return false
case .bigEyeBoy, .smallRoad, .cockroachPig: return true
}
}
/// The column offset used for derived road calculations.
/// Big Eye Boy compares to 1 column back, Small Road to 2, Cockroach Pig to 3.
var columnOffset: Int {
switch self {
case .bead, .big: return 0
case .bigEyeBoy: return 1
case .smallRoad: return 2
case .cockroachPig: return 3
}
}
/// The starting column requirement for derived roads.
/// Big Eye Boy starts after column 2, Small Road after column 3, Cockroach Pig after column 4.
var startingColumn: Int {
switch self {
case .bead, .big: return 1
case .bigEyeBoy: return 2
case .smallRoad: return 3
case .cockroachPig: return 4
}
}
}
// MARK: - Big Road Entry
/// An entry in the Big Road grid.
/// Represents a single result positioned in the column-based streak layout.
struct BigRoadEntry: Identifiable, Equatable {
let id: UUID
/// The game result (Player wins or Banker wins).
/// Ties are tracked separately via `tieCount`.
let result: GameResult
/// The column position (0-indexed).
let column: Int
/// The row position (0-indexed, 0 is top).
let row: Int
/// Number of ties that occurred after this result.
let tieCount: Int
/// Whether this result was a natural (8 or 9).
let isNatural: Bool
/// Whether a pair occurred in this hand.
let hasPair: Bool
/// The original round result for additional data access.
let roundResult: RoundResult?
init(
id: UUID = UUID(),
result: GameResult,
column: Int,
row: Int,
tieCount: Int = 0,
isNatural: Bool = false,
hasPair: Bool = false,
roundResult: RoundResult? = nil
) {
self.id = id
self.result = result
self.column = column
self.row = row
self.tieCount = tieCount
self.isNatural = isNatural
self.hasPair = hasPair
self.roundResult = roundResult
}
// MARK: - Equatable (custom implementation excluding roundResult)
static func == (lhs: BigRoadEntry, rhs: BigRoadEntry) -> Bool {
lhs.id == rhs.id &&
lhs.result == rhs.result &&
lhs.column == rhs.column &&
lhs.row == rhs.row &&
lhs.tieCount == rhs.tieCount &&
lhs.isNatural == rhs.isNatural &&
lhs.hasPair == rhs.hasPair
}
/// The color for this entry based on the result.
var color: Color {
result.color
}
/// Label for display (P or B).
var label: String {
switch result {
case .playerWins: return "P"
case .bankerWins: return "B"
case .tie: return "T"
}
}
}
// MARK: - Derived Road Entry
/// An entry in a derived road (Big Eye Boy, Small Road, or Cockroach Pig).
/// Uses hollow circles: red indicates pattern repeating, blue indicates pattern breaking.
struct DerivedRoadEntry: Identifiable, Equatable {
let id: UUID
/// Whether this entry is red (pattern repeating) or blue (pattern breaking).
let isRed: Bool
/// The column position (0-indexed).
let column: Int
/// The row position (0-indexed, 0 is top).
let row: Int
init(
id: UUID = UUID(),
isRed: Bool,
column: Int,
row: Int
) {
self.id = id
self.isRed = isRed
self.column = column
self.row = row
}
/// The color for this entry.
var color: Color {
isRed ? .red : .blue
}
}
// MARK: - Big Road Column
/// A column in the Big Road, containing multiple entries.
/// Used internally by the road map builder for layout calculations.
struct BigRoadColumn: Identifiable, Equatable {
let id: UUID
/// The column index (0-indexed).
let index: Int
/// The entries in this column (top to bottom).
var entries: [BigRoadEntry]
/// The result type for this column (all entries have the same result).
var result: GameResult? {
entries.first?.result
}
/// The depth (number of entries) in this column.
var depth: Int {
entries.count
}
init(id: UUID = UUID(), index: Int, entries: [BigRoadEntry] = []) {
self.id = id
self.index = index
self.entries = entries
}
}

View File

@ -69,6 +69,18 @@
}
}
},
"%@, %@" : {
"comment" : "A button that selects a specific road type. The label shows the name of the road type and whether it is currently selected.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@, %2$@"
}
}
}
},
"%@×%lld" : {
"comment" : "A text view displaying the streak count and type (e.g., \"3×Banker\"). The first argument is the streak count. The second argument is the type of streak (\"Banker\" or \"Player\").",
"localizations" : {
@ -353,6 +365,7 @@
},
"+%lld" : {
"comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -511,6 +524,13 @@
}
}
},
"A new column starts when the winner changes." : {
},
"About This Road" : {
"comment" : "The title of the popover that appears when a user taps a road type in the game.",
"isCommentAutoGenerated" : true
},
"Ace: 1 point" : {
"comment" : "Card value description for an Ace.",
"localizations" : {
@ -652,6 +672,10 @@
}
}
},
"Analyzes patterns from the Big Road." : {
"comment" : "Tooltip text for the \"Big Eye Boy\" road type in the road map interface.",
"isCommentAutoGenerated" : true
},
"Animate dealing and flipping" : {
"comment" : "Subtitle for card animations toggle.",
"localizations" : {
@ -1140,6 +1164,10 @@
}
}
},
"Banker win" : {
"comment" : "Accessibility label for a Bead Road cell showing a banker win.",
"isCommentAutoGenerated" : true
},
"Banker Wins" : {
"comment" : "Label for the number of banker win rounds in the statistics display.",
"localizations" : {
@ -1163,6 +1191,26 @@
}
}
},
"Bead Road" : {
"comment" : "Localized display name for the Bead Road road type.",
"isCommentAutoGenerated" : true
},
"Bead Road: %lld results. Player %lld, Banker %lld, Ties %lld" : {
"comment" : "Accessibility label for the Bead Road view, showing the number of results and the count of each result type.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Bead Road: %1$lld results. Player %2$lld, Banker %3$lld, Ties %4$lld"
}
}
}
},
"Bead Road: No results yet" : {
"comment" : "Accessibility label for the Bead Road view when there are no results yet.",
"isCommentAutoGenerated" : true
},
"Bet on Player, Banker, or Tie" : {
"comment" : "Welcome screen feature title for betting options.",
"localizations" : {
@ -1232,8 +1280,20 @@
}
}
},
"Big Eye Boy" : {
"comment" : "Name of a derived road type that compares the current streak to the previous one.",
"isCommentAutoGenerated" : true
},
"Big Eye Boy, Small Road, and Cockroach Pig analyze the Big Road." : {
},
"Big Road" : {
"comment" : "Localized display name for the \"Big Road\" road type.",
"isCommentAutoGenerated" : true
},
"BIG ROAD" : {
"comment" : "Title for the section in the statistics sheet that shows the user's performance on the Big Road.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1255,6 +1315,22 @@
}
}
},
"Big Road: %lld results. Player %lld, Banker %lld, Ties %lld" : {
"comment" : "Accessibility label for the Big Road view, showing the number of results and the count of each result type.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Big Road: %1$lld results. Player %2$lld, Banker %3$lld, Ties %4$lld"
}
}
}
},
"Big Road: No results yet" : {
"comment" : "Accessibility label for the Big Road view when there are no results yet.",
"isCommentAutoGenerated" : true
},
"Blackjack" : {
"comment" : "The name of a blackjack game.",
"extractionState" : "stale",
@ -1279,6 +1355,10 @@
}
}
},
"Blue = Player win, Red = Banker win, Green = Tie." : {
"comment" : "Label for the color coding of road map results.",
"isCommentAutoGenerated" : true
},
"Blue Circle (P): Player won the hand" : {
"comment" : "Explains the blue circle icon in the history.",
"localizations" : {
@ -1302,6 +1382,13 @@
}
}
},
"Blue circle = 'The shoe is NOT doing what it did before'" : {
},
"Blue, pattern breaking" : {
"comment" : "An accessibility label for a hollow circle with blue color.",
"isCommentAutoGenerated" : true
},
"BONUS" : {
"comment" : "The text displayed in the center of the bonus zone.",
"localizations" : {
@ -1443,6 +1530,9 @@
"Cards flip automatically" : {
"comment" : "Help text for the \"Auto Reveal\" reveal style.",
"isCommentAutoGenerated" : true
},
"Casinos display these boards — experienced players study them closely." : {
},
"Change table limits and display options" : {
"comment" : "Welcome screen feature description for customizing settings.",
@ -1625,6 +1715,29 @@
}
}
},
"Cockroach Pig" : {
"comment" : "Name of the road type that shows the finest pattern detection, looking 3 columns back.",
"isCommentAutoGenerated" : true
},
"Color Meaning" : {
"comment" : "A heading displayed in the color legend of a derived road type popover.",
"isCommentAutoGenerated" : true
},
"Compares current streak to 2 columns back." : {
"comment" : "Tooltip text for the \"Small Road\" road type in the Road Map selector.",
"isCommentAutoGenerated" : true
},
"Compares current streak to 3 columns back." : {
},
"Compares current streak to previous. Red = repeating pattern." : {
"comment" : "Tooltip description for the Big Eye Boy road type.",
"isCommentAutoGenerated" : true
},
"Compares current streak to the previous column." : {
"comment" : "Description of a feature in the Road Info Popover related to identifying if the shoe is predictable or random.",
"isCommentAutoGenerated" : true
},
"Customize Settings" : {
"comment" : "Welcome screen feature title for settings customization.",
"localizations" : {
@ -1738,6 +1851,12 @@
}
}
}
},
"Derived Road" : {
},
"Derived Roads" : {
},
"DISPLAY" : {
"comment" : "Section header for display settings.",
@ -1968,6 +2087,10 @@
}
}
},
"Finest pattern detection. Looks 3 columns back." : {
"comment" : "Short description for a road type that compares the current streak to one from three columns ago.",
"isCommentAutoGenerated" : true
},
"Game history" : {
"comment" : "The accessibility label for the road map view, describing it as a display of game results.",
"localizations" : {
@ -2224,6 +2347,10 @@
}
}
},
"Helps identify if the shoe is predictable or random." : {
"comment" : "Explanation of the Big Eye Boy road map feature.",
"isCommentAutoGenerated" : true
},
"HISTORY" : {
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
"localizations" : {
@ -2432,6 +2559,10 @@
}
}
},
"Identifies medium-term pattern trends." : {
"comment" : "Description of a road map when the selected road type is the Small Road.",
"isCommentAutoGenerated" : true
},
"If either hand totals 8 or 9 with two cards, it's a 'Natural'." : {
"comment" : "Description of the 'Natural' hand in baccarat, explaining when it occurs and its significance.",
"localizations" : {
@ -2547,6 +2678,10 @@
}
}
},
"Information about %@" : {
"comment" : "The accessibility label for the info button, describing what it does.",
"isCommentAutoGenerated" : true
},
"Last Synced" : {
"localizations" : {
"en" : {
@ -2636,6 +2771,13 @@
}
}
}
},
"Like Big Eye Boy, but looks 2 columns back." : {
"comment" : "Description of a derived road type that looks 2 columns back.",
"isCommentAutoGenerated" : true
},
"Long columns = hot streak. Short columns = choppy shoe." : {
},
"Lost" : {
"comment" : "Labels for the outcome circles in the \"Win/Loss/Push\" section.",
@ -2659,6 +2801,9 @@
}
}
}
},
"Lots of red = predictable shoe. Lots of blue = random shoe." : {
},
"Main Bets" : {
"comment" : "Title of a rule page in the \"Rules\" help view, describing the main bets available in baccarat.",
@ -2933,6 +3078,10 @@
}
}
},
"Not enough data" : {
"comment" : "A message displayed when there is not enough data to show a derived road.",
"isCommentAutoGenerated" : true
},
"Objective" : {
"comment" : "Title of a rule page in the \"Rules\" help view, describing the objective of the game.",
"localizations" : {
@ -3236,6 +3385,14 @@
}
}
},
"Pattern breaking" : {
"comment" : "Text describing the color meaning for a derived road where the pattern is breaking.",
"isCommentAutoGenerated" : true
},
"Pattern repeating" : {
"comment" : "Text describing the color meaning for a pattern-repeating bead on a derived road.",
"isCommentAutoGenerated" : true
},
"Player" : {
"localizations" : {
"en" : {
@ -3373,6 +3530,10 @@
}
}
},
"Player win" : {
"comment" : "Label for a result where the player wins.",
"isCommentAutoGenerated" : true
},
"Player Wins" : {
"comment" : "Label for the \"Player Wins\" stat in the statistics UI.",
"localizations" : {
@ -3556,6 +3717,15 @@
}
}
}
},
"Red circle = 'The shoe is doing what it did before'" : {
},
"Red, pattern repeating" : {
},
"Red/Blue = Banker/Player in basic roads." : {
},
"Reset Game" : {
"comment" : "A button label that resets the game balance and reshuffles the deck.",
@ -3672,11 +3842,25 @@
}
}
}
},
"Results fill top to bottom, then left to right." : {
"comment" : "Description of how the Bead Road displays results.",
"isCommentAutoGenerated" : true
},
"Results stack vertically when the same side wins." : {
},
"REVEAL STYLE" : {
"comment" : "The title of the section in the settings view related to the reveal style.",
"isCommentAutoGenerated" : true
},
"Road Maps" : {
},
"ROAD MAPS" : {
"comment" : "Title of the \"Road Maps\" section in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Road maps show game history and trends" : {
"comment" : "Welcome screen feature description for pattern tracking.",
"localizations" : {
@ -3699,6 +3883,9 @@
}
}
}
},
"Road maps track patterns in the shoe to help spot trends." : {
},
"Roulette" : {
"comment" : "The name of a roulette game.",
@ -4115,6 +4302,17 @@
}
}
}
},
"Shows all results in a simple grid." : {
"comment" : "Explanation point for the Bead Road popover.",
"isCommentAutoGenerated" : true
},
"Shows streaks. New column when winner changes." : {
"comment" : "Short description for tooltips on the \"Big Road\" road type.",
"isCommentAutoGenerated" : true
},
"Shows the most subtle pattern variations." : {
},
"Side bet on Player or Banker winning by a margin." : {
"comment" : "Title for a side bet where the player bets on which hand wins by a margin (e.g., Banker by 9 points).",
@ -4183,6 +4381,17 @@
}
}
}
},
"Similar to Big Eye Boy, but looks further back." : {
"comment" : "Description of the Small Road road type.",
"isCommentAutoGenerated" : true
},
"Simple grid showing all results in order." : {
"comment" : "Tooltip description for the Bead Road.",
"isCommentAutoGenerated" : true
},
"Small Road" : {
},
"SOUND & HAPTICS" : {
"comment" : "Section header for sound and haptic settings.",
@ -4260,6 +4469,17 @@
}
}
}
},
"Starts after the first entry in column 2 of the Big Road." : {
"comment" : "Description of the starting point of the Big Eye Boy road map.",
"isCommentAutoGenerated" : true
},
"Starts after the first entry in column 3 of the Big Road." : {
"comment" : "Description of the starting point for the Small Road road map.",
"isCommentAutoGenerated" : true
},
"Starts after the first entry in column 4 of the Big Road." : {
},
"Statistics" : {
"localizations" : {
@ -4474,6 +4694,12 @@
"Tap Reveal" : {
"comment" : "Name of the icon representing the \"Tap Reveal\" card reveal style.",
"isCommentAutoGenerated" : true
},
"Tap the road selector to switch between different road types." : {
},
"The finest pattern detection in Baccarat." : {
},
"The hand closest to 9 wins" : {
"comment" : "Welcome screen feature description for betting explanation.",
@ -4544,6 +4770,13 @@
}
}
},
"The most important road map in Baccarat." : {
},
"The simplest road map to understand." : {
"comment" : "Explanation point for the Bead Road popover, describing its simplicity.",
"isCommentAutoGenerated" : true
},
"There's no skill involved — just enjoy the game!" : {
"comment" : "Tip for players on how to play baccarat without needing any skill.",
"localizations" : {
@ -4567,6 +4800,13 @@
}
}
},
"These are for advanced players — beginners can ignore them!" : {
},
"These are for advanced players. Beginners can ignore them!" : {
"comment" : "A tip displayed in the \"About This Road\" popover, explaining that players can ignore certain information if they are not interested.",
"isCommentAutoGenerated" : true
},
"These show how the same pattern works for other games" : {
"comment" : "A description below the section of the view that previews icons for other games.",
"extractionState" : "stale",
@ -4590,6 +4830,12 @@
}
}
}
},
"They compare current patterns to past patterns." : {
},
"They don't predict the future, but show what HAS happened." : {
},
"Third Card - Banker" : {
"comment" : "Content for a rule page in the \"Rules\" help view, detailing the action of the Banker based on the Player's third card.",
@ -4795,6 +5041,9 @@
}
}
}
},
"Ties are shown as a green line across the last circle." : {
},
"Time" : {
"comment" : "Label for the duration of a session in the statistics sheet.",

View File

@ -59,6 +59,40 @@ enum Design {
static let bettingButtonsContainerHeight: CGFloat = 70
}
// MARK: - Road Map Display
enum RoadMap {
/// Cell size for road map dots.
static let cellSize: CGFloat = 16
/// Larger cell size for Big Road display.
static let bigRoadCellSize: CGFloat = 24
/// Sidebar cell size (landscape iPad).
static let sidebarCellSize: CGFloat = 32
/// Spacing between cells.
static let cellSpacing: CGFloat = 2
/// Maximum rows before dragon tail wrapping.
static let maxRows: Int = 6
/// Stroke width for hollow circles in derived roads.
static let hollowStrokeWidth: CGFloat = 2
/// Height of the road map container.
static let containerHeight: CGFloat = 180
/// Width of the landscape sidebar.
static let sidebarWidth: CGFloat = 240
/// Size of tie indicator line.
static let tieIndicatorSize: CGFloat = 8
/// Size of pair/natural markers.
static let markerSize: CGFloat = 6
}
// MARK: - Card Deal Animation
enum DealAnimation {

View File

@ -128,6 +128,40 @@ struct RulesHelpView: View {
String(localized: "Use History to spot patterns and trends in the shoe.")
]
),
RulePage(
title: String(localized: "Road Maps"),
icon: "chart.bar.xaxis",
content: [
String(localized: "Road maps track patterns in the shoe to help spot trends."),
String(localized: "Casinos display these boards — experienced players study them closely."),
String(localized: "They don't predict the future, but show what HAS happened."),
String(localized: "Red/Blue = Banker/Player in basic roads."),
String(localized: "Tap the road selector to switch between different road types.")
]
),
RulePage(
title: String(localized: "Big Road"),
icon: "circle.grid.3x3",
content: [
String(localized: "The most important road map in Baccarat."),
String(localized: "Results stack vertically when the same side wins."),
String(localized: "A new column starts when the winner changes."),
String(localized: "Ties are shown as a green line across the last circle."),
String(localized: "Long columns = hot streak. Short columns = choppy shoe.")
]
),
RulePage(
title: String(localized: "Derived Roads"),
icon: "waveform.path",
content: [
String(localized: "Big Eye Boy, Small Road, and Cockroach Pig analyze the Big Road."),
String(localized: "They compare current patterns to past patterns."),
String(localized: "Red circle = 'The shoe is doing what it did before'"),
String(localized: "Blue circle = 'The shoe is NOT doing what it did before'"),
String(localized: "Lots of red = predictable shoe. Lots of blue = random shoe."),
String(localized: "These are for advanced players — beginners can ignore them!")
]
),
RulePage(
title: String(localized: "Strategy Tips"),
icon: "lightbulb.fill",

View File

@ -14,6 +14,7 @@ struct StatisticsSheetView: View {
@Environment(\.dismiss) private var dismiss
@State private var selectedTab: StatisticsTab = .current
@State private var selectedSession: BaccaratSession?
@State private var selectedRoadType: RoadType = .big
var body: some View {
SheetContainerView(
@ -88,9 +89,11 @@ struct StatisticsSheetView: View {
// Session stats
sessionStatsSection(session: session)
// Road displays for current session
bigRoadSection
roadMapSection
// Road maps section with selector
roadMapsSection(results: state.roundHistory)
// Simple history section (horizontal dots)
historySection(results: state.roundHistory)
} else {
NoActiveSessionView()
}
@ -318,28 +321,45 @@ struct StatisticsSheetView: View {
}
}
// MARK: - Road Sections
// MARK: - Road Maps Section
private var bigRoadSection: some View {
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
if state.roundHistory.isEmpty {
@ViewBuilder
private func roadMapsSection(results: [RoundResult]) -> some View {
SheetSection(title: String(localized: "ROAD MAPS"), icon: "chart.bar.xaxis") {
if results.isEmpty {
emptyRoadPlaceholder
} else {
BigRoadView(results: state.roundHistory)
.frame(height: Size.bigRoadHeight)
RoadMapContainerView(
results: results,
selectedRoad: $selectedRoadType,
showSelector: true,
cellSize: Size.bigRoadCellSize,
showInfoButton: true
)
.frame(height: Size.roadMapsHeight)
}
}
}
private var roadMapSection: some View {
private var emptyRoadPlaceholder: some View {
Text(String(localized: "No rounds played yet"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xLarge)
}
// MARK: - History Section
@ViewBuilder
private func historySection(results: [RoundResult]) -> some View {
SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") {
if state.roundHistory.isEmpty {
if results.isEmpty {
emptyRoadPlaceholder
} else {
// Horizontal display matching what's shown during gameplay
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(state.roundHistory) { result in
ForEach(results) { result in
RoadDot(
result: result.result,
dotSize: Size.roadDotSize,
@ -355,14 +375,6 @@ struct StatisticsSheetView: View {
}
}
private var emptyRoadPlaceholder: some View {
Text(String(localized: "No rounds played yet"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xLarge)
}
// MARK: - Helpers
private func styleDisplayName(for rawValue: String) -> String {
@ -412,6 +424,7 @@ private struct SessionDetailView: View {
@Environment(\.dismiss) private var dismiss
@State private var showDeleteConfirmation = false
@State private var selectedRoadType: RoadType = .big
var body: some View {
SheetContainerView(
@ -499,14 +512,20 @@ private struct SessionDetailView: View {
)
}
// Big Road section (Baccarat-specific)
// Road Maps section (Baccarat-specific)
if !roundHistory.isEmpty {
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
BigRoadView(results: roundHistory)
.frame(height: Size.bigRoadHeight)
SheetSection(title: String(localized: "ROAD MAPS"), icon: "chart.bar.xaxis") {
RoadMapContainerView(
results: roundHistory,
selectedRoad: $selectedRoadType,
showSelector: true,
cellSize: Size.bigRoadCellSize,
showInfoButton: true
)
.frame(height: Size.roadMapsHeight)
}
// History road section
// Simple history section (horizontal dots)
SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xSmall) {
@ -550,124 +569,8 @@ private struct SessionDetailView: View {
}
}
// MARK: - Big Road View
private struct BigRoadView: View {
let results: [RoundResult]
private let maxRows = 6
private let cellSize: CGFloat = Size.bigRoadCellSize
/// Convert results into columns for Big Road display.
private var columns: [[RoundResult]] {
var cols: [[RoundResult]] = []
var currentCol: [RoundResult] = []
var lastResult: GameResult?
for result in results {
let currentResult = result.result
if currentResult == .tie {
// Ties don't start new columns
if !currentCol.isEmpty {
currentCol.append(result)
} else if !cols.isEmpty {
cols[cols.count - 1].append(result)
} else {
currentCol.append(result)
}
} else if lastResult == nil || currentResult == lastResult {
currentCol.append(result)
lastResult = currentResult
} else {
if !currentCol.isEmpty {
cols.append(currentCol)
}
currentCol = [result]
lastResult = currentResult
}
}
if !currentCol.isEmpty {
cols.append(currentCol)
}
return cols
}
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xxSmall) {
ForEach(Array(columns.enumerated()), id: \.offset) { _, column in
VStack(spacing: Design.Spacing.xxSmall) {
ForEach(Array(column.prefix(maxRows).enumerated()), id: \.offset) { _, result in
BigRoadCell(result: result)
}
if column.count > maxRows {
Text("+\(column.count - maxRows)")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer(minLength: 0)
}
}
}
.padding(Design.Spacing.small)
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
)
.scrollIndicators(.hidden)
}
}
private struct BigRoadCell: View {
let result: RoundResult
private let cellSize: CGFloat = Size.bigRoadCellSize
private var color: Color {
switch result.result {
case .playerWins: return .blue
case .bankerWins: return .red
case .tie: return .green
}
}
var body: some View {
ZStack {
Circle()
.stroke(color, lineWidth: Design.LineWidth.medium)
.frame(width: cellSize, height: cellSize)
if result.hasPair {
Circle()
.fill(Color.yellow)
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
.offset(y: cellSize * 0.3)
}
if result.isNatural {
Circle()
.fill(Color.white)
.frame(width: cellSize * 0.25, height: cellSize * 0.25)
.offset(y: -cellSize * 0.3)
}
if result.result == .tie {
Rectangle()
.fill(color)
.frame(width: cellSize * 0.8, height: Design.LineWidth.medium)
.rotationEffect(.degrees(-45))
}
}
}
}
// MARK: - Local Size Constants
// Note: BigRoadView is now a shared component in Views/Table/BigRoadView.swift
private enum Size {
static let outcomeCircleSize: CGFloat = 48
@ -678,6 +581,7 @@ private enum Size {
static let bigRoadCellSize: CGFloat = 24
static let winIndicatorSize: CGFloat = 24
static let roadDotSize: CGFloat = 28
static let roadMapsHeight: CGFloat = 220
}
// MARK: - Preview

View File

@ -0,0 +1,228 @@
//
// BeadRoadView.swift
// Baccarat
//
// The Bead Road (also called "Bead Plate" or "Cube Road") shows all results in a simple grid.
// Results fill top to bottom, then left to right.
//
import SwiftUI
import CasinoKit
/// Displays the Bead Road - a simple grid of all results in order.
/// Results fill columns top-to-bottom, then move right.
struct BeadRoadView: View {
/// The round results to display.
let results: [RoundResult]
/// Cell size for the dots.
var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize
/// Maximum number of rows before wrapping.
var maxRows: Int = Design.RoadMap.maxRows
// MARK: - Computed Properties
/// Results organized into columns.
private var columns: [[RoundResult]] {
guard !results.isEmpty else { return [] }
var cols: [[RoundResult]] = []
var currentCol: [RoundResult] = []
for result in results {
currentCol.append(result)
if currentCol.count >= maxRows {
cols.append(currentCol)
currentCol = []
}
}
// Add remaining results as final column
if !currentCol.isEmpty {
cols.append(currentCol)
}
return cols
}
// MARK: - Body
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) {
ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in
columnView(for: column, index: columnIndex)
}
// Invisible anchor for auto-scroll
Color.clear
.frame(width: 1, height: 1)
.id("end")
}
.padding(Design.Spacing.small)
}
.onChange(of: results.count) {
withAnimation {
proxy.scrollTo("end", anchor: .trailing)
}
}
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityLabel)
}
// MARK: - Private Views
@ViewBuilder
private func columnView(for column: [RoundResult], index: Int) -> some View {
VStack(spacing: Design.RoadMap.cellSpacing) {
ForEach(Array(column.enumerated()), id: \.offset) { rowIndex, result in
BeadRoadCell(
result: result,
size: cellSize
)
.id("\(index)-\(rowIndex)")
}
// Fill remaining space for consistent column height
if column.count < maxRows {
ForEach(0..<(maxRows - column.count), id: \.self) { _ in
Color.clear
.frame(width: cellSize, height: cellSize)
}
}
}
}
// MARK: - Accessibility
private var accessibilityLabel: String {
if results.isEmpty {
return String(localized: "Bead Road: No results yet")
}
let playerWins = results.filter { $0.result == .playerWins }.count
let bankerWins = results.filter { $0.result == .bankerWins }.count
let ties = results.filter { $0.result == .tie }.count
return String(localized: "Bead Road: \(results.count) results. Player \(playerWins), Banker \(bankerWins), Ties \(ties)")
}
}
// MARK: - Bead Road Cell
/// A single cell in the Bead Road display.
/// Shows filled circles with P/B/T labels.
struct BeadRoadCell: View {
let result: RoundResult
var size: CGFloat = Design.RoadMap.bigRoadCellSize
private var color: Color {
result.result.color
}
private var label: String {
switch result.result {
case .playerWins: return "P"
case .bankerWins: return "B"
case .tie: return "T"
}
}
private var markerSize: CGFloat {
size * 0.25
}
var body: some View {
ZStack {
// Filled circle
Circle()
.fill(color)
.frame(width: size, height: size)
// Border
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
.frame(width: size, height: size)
// Label
Text(label)
.font(.system(size: size * 0.45, weight: .bold))
.foregroundStyle(.white)
// Pair marker (bottom-left)
if result.hasPair {
Circle()
.fill(Color.yellow)
.frame(width: markerSize, height: markerSize)
.overlay(
Circle()
.strokeBorder(Color.white, lineWidth: Design.LineWidth.thin)
)
.offset(x: -size * 0.35, y: size * 0.35)
}
// Natural marker (top-right star)
if result.isNatural {
Image(systemName: "star.fill")
.font(.system(size: markerSize))
.foregroundStyle(.yellow)
.offset(x: size * 0.35, y: -size * 0.35)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(cellAccessibilityLabel)
}
private var cellAccessibilityLabel: String {
var label: String
switch result.result {
case .playerWins: label = String(localized: "Player win")
case .bankerWins: label = String(localized: "Banker win")
case .tie: label = String(localized: "Tie")
}
if result.isNatural {
label += ", natural"
}
if result.hasPair {
label += ", pair"
}
return label
}
}
// MARK: - Preview
#Preview("Bead Road") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
BeadRoadView(
results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true),
RoundResult(result: .tie, playerValue: 6, bankerValue: 6),
RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8)
]
)
.frame(height: Design.RoadMap.containerHeight)
.padding()
}
}

View File

@ -0,0 +1,209 @@
//
// BigRoadView.swift
// Baccarat
//
// The Big Road is the primary road map in Baccarat.
// Results stack vertically when the same side wins; a new column starts when the winner changes.
//
import SwiftUI
import CasinoKit
/// Displays the Big Road - the primary Baccarat road map.
/// Shows streaks as vertical columns that start a new column when the winner changes.
struct BigRoadView: View {
/// The round results to display.
let results: [RoundResult]
/// Optional cell size override.
var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize
/// Whether to show natural and pair markers.
var showMarkers: Bool = true
// MARK: - Computed Properties
private var entries: [BigRoadEntry] {
RoadMapBuilder.buildBigRoad(from: results)
}
private var columns: [BigRoadColumn] {
RoadMapBuilder.buildBigRoadColumns(from: entries)
}
// MARK: - Body
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) {
ForEach(columns) { column in
columnView(for: column)
}
// Invisible anchor for auto-scroll
Color.clear
.frame(width: 1, height: 1)
.id("end")
}
.padding(Design.Spacing.small)
}
.onChange(of: results.count) {
withAnimation {
proxy.scrollTo("end", anchor: .trailing)
}
}
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityLabel)
}
// MARK: - Private Views
@ViewBuilder
private func columnView(for column: BigRoadColumn) -> some View {
VStack(spacing: Design.RoadMap.cellSpacing) {
ForEach(column.entries) { entry in
BigRoadCell(
entry: entry,
size: cellSize,
showMarkers: showMarkers
)
}
// Fill remaining space for consistent column height
if column.entries.count < Design.RoadMap.maxRows {
ForEach(0..<(Design.RoadMap.maxRows - column.entries.count), id: \.self) { _ in
Color.clear
.frame(width: cellSize, height: cellSize)
}
}
}
}
// MARK: - Accessibility
private var accessibilityLabel: String {
if results.isEmpty {
return String(localized: "Big Road: No results yet")
}
let playerWins = results.filter { $0.result == .playerWins }.count
let bankerWins = results.filter { $0.result == .bankerWins }.count
let ties = results.filter { $0.result == .tie }.count
return String(localized: "Big Road: \(results.count) results. Player \(playerWins), Banker \(bankerWins), Ties \(ties)")
}
}
// MARK: - Big Road Cell
/// A single cell in the Big Road display.
struct BigRoadCell: View {
let entry: BigRoadEntry
var size: CGFloat = Design.RoadMap.bigRoadCellSize
var showMarkers: Bool = true
private var markerSize: CGFloat {
size * 0.25
}
var body: some View {
ZStack {
// Main circle (hollow)
Circle()
.stroke(entry.color, lineWidth: Design.RoadMap.hollowStrokeWidth)
.frame(width: size, height: size)
// Tie indicator (green diagonal line)
if entry.tieCount > 0 {
tieIndicator
}
// Pair marker (bottom-left)
if showMarkers && entry.hasPair {
Circle()
.fill(Color.yellow)
.frame(width: markerSize, height: markerSize)
.offset(x: -size * 0.35, y: size * 0.35)
}
// Natural marker (top-right star)
if showMarkers && entry.isNatural {
Image(systemName: "star.fill")
.font(.system(size: markerSize))
.foregroundStyle(.yellow)
.offset(x: size * 0.35, y: -size * 0.35)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(cellAccessibilityLabel)
}
@ViewBuilder
private var tieIndicator: some View {
if entry.tieCount == 1 {
// Single tie: diagonal green line
Path { path in
path.move(to: CGPoint(x: size * 0.25, y: size * 0.75))
path.addLine(to: CGPoint(x: size * 0.75, y: size * 0.25))
}
.stroke(Color.green, lineWidth: Design.LineWidth.medium)
.frame(width: size, height: size)
} else {
// Multiple ties: show count
Text("\(entry.tieCount)")
.font(.system(size: size * 0.4, weight: .bold))
.foregroundStyle(.green)
}
}
private var cellAccessibilityLabel: String {
var label = entry.result == .playerWins ?
String(localized: "Player win") : String(localized: "Banker win")
if entry.tieCount > 0 {
label += ", \(entry.tieCount) \(entry.tieCount == 1 ? "tie" : "ties")"
}
if entry.isNatural {
label += ", natural"
}
if entry.hasPair {
label += ", pair"
}
return label
}
}
// MARK: - Preview
#Preview("Big Road") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
BigRoadView(
results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true),
RoundResult(result: .tie, playerValue: 6, bankerValue: 6),
RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8)
]
)
.frame(height: Design.RoadMap.containerHeight)
.padding()
}
}

View File

@ -0,0 +1,258 @@
//
// DerivedRoadView.swift
// Baccarat
//
// Displays derived roads: Big Eye Boy, Small Road, and Cockroach Pig.
// These roads use hollow circles: red = pattern repeating, blue = pattern breaking.
//
import SwiftUI
import CasinoKit
/// Displays a derived road (Big Eye Boy, Small Road, or Cockroach Pig).
/// Uses hollow circles where red indicates pattern repeating and blue indicates pattern breaking.
struct DerivedRoadView: View {
/// The road type being displayed.
let roadType: RoadType
/// The round results to analyze.
let results: [RoundResult]
/// Cell size for the circles.
var cellSize: CGFloat = Design.RoadMap.cellSize
// MARK: - Computed Properties
private var entries: [DerivedRoadEntry] {
let bigRoad = RoadMapBuilder.buildBigRoad(from: results)
let columns = RoadMapBuilder.buildBigRoadColumns(from: bigRoad)
return RoadMapBuilder.buildDerivedRoad(from: columns, roadType: roadType)
}
private var columns: [[DerivedRoadEntry]] {
guard !entries.isEmpty else { return [] }
var cols: [Int: [DerivedRoadEntry]] = [:]
for entry in entries {
cols[entry.column, default: []].append(entry)
}
// Convert to sorted array
let maxColumn = entries.map(\.column).max() ?? 0
var result: [[DerivedRoadEntry]] = []
for col in 0...maxColumn {
result.append(cols[col] ?? [])
}
return result
}
// MARK: - Body
var body: some View {
if entries.isEmpty {
emptyState
} else {
roadContent
}
}
@ViewBuilder
private var roadContent: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: Design.RoadMap.cellSpacing) {
ForEach(Array(columns.enumerated()), id: \.offset) { columnIndex, column in
columnView(for: column, index: columnIndex)
}
// Invisible anchor for auto-scroll
Color.clear
.frame(width: 1, height: 1)
.id("end")
}
.padding(Design.Spacing.small)
}
.onChange(of: results.count) {
withAnimation {
proxy.scrollTo("end", anchor: .trailing)
}
}
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityLabel)
}
@ViewBuilder
private var emptyState: some View {
VStack(spacing: Design.Spacing.small) {
Image(systemName: roadType.icon)
.font(.system(size: Design.IconSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(String(localized: "Not enough data"))
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(roadType.shortDescription)
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(Design.Opacity.light))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
)
}
// MARK: - Private Views
@ViewBuilder
private func columnView(for column: [DerivedRoadEntry], index: Int) -> some View {
VStack(spacing: Design.RoadMap.cellSpacing) {
ForEach(column) { entry in
DerivedRoadCell(
entry: entry,
size: cellSize
)
}
// Fill remaining space for consistent column height
if column.count < Design.RoadMap.maxRows {
ForEach(0..<(Design.RoadMap.maxRows - column.count), id: \.self) { _ in
Color.clear
.frame(width: cellSize, height: cellSize)
}
}
}
}
// MARK: - Accessibility
private var accessibilityLabel: String {
if entries.isEmpty {
return "\(roadType.displayName): Not enough data yet"
}
let redCount = entries.filter(\.isRed).count
let blueCount = entries.count - redCount
return "\(roadType.displayName): \(entries.count) entries. \(redCount) red (pattern), \(blueCount) blue (break)"
}
}
// MARK: - Derived Road Cell
/// A single cell in a derived road display.
/// Shows hollow circles with red or blue color.
struct DerivedRoadCell: View {
let entry: DerivedRoadEntry
var size: CGFloat = Design.RoadMap.cellSize
var body: some View {
Circle()
.stroke(entry.color, lineWidth: Design.RoadMap.hollowStrokeWidth)
.frame(width: size, height: size)
.accessibilityElement(children: .ignore)
.accessibilityLabel(entry.isRed ?
String(localized: "Red, pattern repeating") :
String(localized: "Blue, pattern breaking"))
}
}
// MARK: - Previews
#Preview("Big Eye Boy") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.medium) {
Text("Big Eye Boy")
.foregroundStyle(.white)
DerivedRoadView(
roadType: .bigEyeBoy,
results: sampleResults
)
.frame(height: Design.RoadMap.containerHeight)
}
.padding()
}
}
#Preview("Small Road") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.medium) {
Text("Small Road")
.foregroundStyle(.white)
DerivedRoadView(
roadType: .smallRoad,
results: sampleResults
)
.frame(height: Design.RoadMap.containerHeight)
}
.padding()
}
}
#Preview("Cockroach Pig") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.medium) {
Text("Cockroach Pig")
.foregroundStyle(.white)
DerivedRoadView(
roadType: .cockroachPig,
results: sampleResults
)
.frame(height: Design.RoadMap.containerHeight)
}
.padding()
}
}
#Preview("Empty State") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
DerivedRoadView(
roadType: .bigEyeBoy,
results: []
)
.frame(height: Design.RoadMap.containerHeight)
.padding()
}
}
// Sample results for previews
private let sampleResults: [RoundResult] = [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4),
RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 7),
RoundResult(result: .playerWins, playerValue: 6, bankerValue: 3),
RoundResult(result: .playerWins, playerValue: 5, bankerValue: 4),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 1),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 9)
]

View File

@ -0,0 +1,348 @@
//
// RoadMapContainerView.swift
// Baccarat
//
// Container view for displaying road maps with a tabbed interface.
// Allows switching between Bead Road, Big Road, and derived roads.
//
import SwiftUI
import CasinoKit
/// Container view for road map displays with road type selection.
struct RoadMapContainerView: View {
/// The round results to display.
let results: [RoundResult]
/// The currently selected road type.
@Binding var selectedRoad: RoadType
/// Whether to show the road selector tabs.
var showSelector: Bool = true
/// Cell size for road displays.
var cellSize: CGFloat = Design.RoadMap.bigRoadCellSize
/// Whether to show the info button.
var showInfoButton: Bool = true
// MARK: - State
@State private var showRoadInfo = false
// MARK: - Body
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
if showSelector {
headerWithSelector
}
roadContent
}
}
// MARK: - Header
@ViewBuilder
private var headerWithSelector: some View {
HStack(spacing: Design.Spacing.small) {
// Road type picker
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(RoadType.allCases) { roadType in
roadTypeButton(for: roadType)
}
}
}
Spacer(minLength: 0)
// Info button
if showInfoButton {
Button {
showRoadInfo = true
} label: {
Image(systemName: "info.circle")
.font(.system(size: Design.IconSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.accessibilityLabel(String(localized: "Information about \(selectedRoad.displayName)"))
}
}
.sheet(isPresented: $showRoadInfo) {
RoadInfoSheet(roadType: selectedRoad)
}
}
@ViewBuilder
private func roadTypeButton(for roadType: RoadType) -> some View {
Button {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
selectedRoad = roadType
}
} label: {
HStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: roadType.icon)
.font(.system(size: Design.BaseFontSize.xSmall))
Text(roadType.displayName)
.font(.system(size: Design.BaseFontSize.xSmall, weight: .medium))
}
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(selectedRoad == roadType ?
Color.white.opacity(Design.Opacity.light) :
Color.clear)
)
.foregroundStyle(selectedRoad == roadType ? .white : .white.opacity(Design.Opacity.medium))
}
.buttonStyle(.plain)
.accessibilityLabel("\(roadType.displayName), \(selectedRoad == roadType ? "selected" : "not selected")")
}
// MARK: - Road Content
@ViewBuilder
private var roadContent: some View {
switch selectedRoad {
case .bead:
BeadRoadView(results: results, cellSize: cellSize)
case .big:
BigRoadView(results: results, cellSize: cellSize)
case .bigEyeBoy, .smallRoad, .cockroachPig:
DerivedRoadView(roadType: selectedRoad, results: results, cellSize: cellSize)
}
}
}
// MARK: - Road Info Sheet
/// Sheet view explaining the selected road type with casino-themed styling.
struct RoadInfoSheet: View {
let roadType: RoadType
@Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .title) private var iconSize: CGFloat = Design.BaseFontSize.display
@ScaledMetric(relativeTo: .title) private var titleSize: CGFloat = Design.BaseFontSize.title
@ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = Design.BaseFontSize.body
var body: some View {
NavigationStack {
ZStack {
Color.Sheet.background
.ignoresSafeArea()
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
// Large centered icon (like RulesHelpView)
Image(systemName: roadType.icon)
.font(.system(size: iconSize))
.foregroundStyle(Color.Sheet.accent)
.padding(.top, Design.Spacing.xxLarge)
// Title
Text(roadType.displayName)
.font(.system(size: titleSize, weight: .bold))
.foregroundStyle(.white)
// Subtitle for derived roads
if roadType.isDerived {
Text(String(localized: "Derived Road"))
.font(.subheadline)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
// Bullet points (styled like RulesHelpView)
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
ForEach(explanationPoints, id: \.self) { point in
HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) {
Text("")
.foregroundStyle(Color.Sheet.accent)
Text(point)
.font(.system(size: bodySize))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
}
}
}
.padding(.horizontal, Design.Spacing.xxLarge)
// Color legend for derived roads
if roadType.isDerived {
colorLegendSection
}
// Beginner tip for derived roads
if roadType.isDerived {
beginnerTipSection
}
Spacer()
}
}
}
.navigationTitle(String(localized: "About This Road"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(String(localized: "Done")) {
dismiss()
}
.bold()
.foregroundStyle(Color.Sheet.accent)
}
}
.toolbarBackground(Color.Sheet.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
}
}
// MARK: - Color Legend Section
private var colorLegendSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(String(localized: "Color Meaning"))
.font(.system(size: Design.BaseFontSize.body, weight: .semibold))
.foregroundStyle(.white)
HStack(spacing: Design.Spacing.large) {
legendItem(color: .red, text: String(localized: "Pattern repeating"))
legendItem(color: .blue, text: String(localized: "Pattern breaking"))
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.Sheet.sectionFill)
)
.padding(.horizontal, Design.Spacing.large)
}
// MARK: - Beginner Tip Section
private var beginnerTipSection: some View {
HStack(alignment: .top, spacing: Design.Spacing.medium) {
Image(systemName: "lightbulb.fill")
.font(.system(size: Design.IconSize.medium))
.foregroundStyle(Color.Sheet.accent)
Text(String(localized: "These are for advanced players. Beginners can ignore them!"))
.font(.callout)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.Sheet.accent.opacity(Design.Opacity.hint))
)
.padding(.horizontal, Design.Spacing.large)
}
// MARK: - Helper Views
@ViewBuilder
private func legendItem(color: Color, text: String) -> some View {
HStack(spacing: Design.Spacing.xSmall) {
Circle()
.stroke(color, lineWidth: Design.RoadMap.hollowStrokeWidth)
.frame(width: Design.RoadMap.cellSize, height: Design.RoadMap.cellSize)
Text(text)
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
}
// MARK: - Data
private var explanationPoints: [String] {
switch roadType {
case .bead:
return [
String(localized: "Shows all results in a simple grid."),
String(localized: "Results fill top to bottom, then left to right."),
String(localized: "Blue = Player win, Red = Banker win, Green = Tie."),
String(localized: "The simplest road map to understand.")
]
case .big:
return [
String(localized: "The most important road map in Baccarat."),
String(localized: "Results stack vertically when the same side wins."),
String(localized: "A new column starts when the winner changes."),
String(localized: "Long columns = hot streak. Short columns = choppy shoe."),
String(localized: "Ties are shown as a green line across the last circle.")
]
case .bigEyeBoy:
return [
String(localized: "Analyzes patterns from the Big Road."),
String(localized: "Compares current streak to the previous column."),
String(localized: "Helps identify if the shoe is predictable or random."),
String(localized: "Starts after the first entry in column 2 of the Big Road.")
]
case .smallRoad:
return [
String(localized: "Similar to Big Eye Boy, but looks further back."),
String(localized: "Compares current streak to 2 columns back."),
String(localized: "Identifies medium-term pattern trends."),
String(localized: "Starts after the first entry in column 3 of the Big Road.")
]
case .cockroachPig:
return [
String(localized: "The finest pattern detection in Baccarat."),
String(localized: "Compares current streak to 3 columns back."),
String(localized: "Shows the most subtle pattern variations."),
String(localized: "Starts after the first entry in column 4 of the Big Road.")
]
}
}
}
// MARK: - Previews
#Preview("Road Map Container") {
struct PreviewWrapper: View {
@State private var selectedRoad: RoadType = .big
var body: some View {
ZStack {
Color.Table.preview
.ignoresSafeArea()
RoadMapContainerView(
results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6)
],
selectedRoad: $selectedRoad
)
.frame(height: 220)
.padding()
}
}
}
return PreviewWrapper()
}
#Preview("Road Info Sheet") {
RoadInfoSheet(roadType: .bigEyeBoy)
}

View File

@ -0,0 +1,222 @@
//
// TrendBadgeView.swift
// Baccarat
//
// Displays streak and trend indicators near betting zones.
//
import SwiftUI
import CasinoKit
/// Displays a streak indicator badge.
/// Shows "B×5" or "P×3" format for streaks.
struct TrendBadgeView: View {
/// The current streak type (nil if no streak).
let streakType: GameResult?
/// The streak count.
let streakCount: Int
/// Minimum streak to display.
var minimumStreak: Int = 2
/// Whether to animate for hot streaks.
var animateHotStreak: Bool = true
/// Threshold for "hot" streak animation.
var hotStreakThreshold: Int = 4
// MARK: - State
@State private var isPulsing = false
// MARK: - Scaled Metrics
@ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.small
@ScaledMetric(relativeTo: .caption) private var badgeHeight: CGFloat = 24
@ScaledMetric(relativeTo: .caption) private var horizontalPadding: CGFloat = Design.Spacing.small
// MARK: - Computed Properties
private var shouldDisplay: Bool {
streakType != nil && streakType != .tie && streakCount >= minimumStreak
}
private var isHotStreak: Bool {
streakCount >= hotStreakThreshold
}
private var badgeColor: Color {
guard let type = streakType else { return .clear }
return type == .bankerWins ? .red : .blue
}
private var badgeText: String {
guard let type = streakType else { return "" }
let letter = type == .bankerWins ? "B" : "P"
return "\(letter)×\(streakCount)"
}
// MARK: - Body
var body: some View {
if shouldDisplay {
badgeContent
.onAppear {
if animateHotStreak && isHotStreak {
startPulsing()
}
}
.onChange(of: streakCount) {
if animateHotStreak && isHotStreak {
startPulsing()
} else {
isPulsing = false
}
}
}
}
@ViewBuilder
private var badgeContent: some View {
HStack(spacing: Design.Spacing.xxSmall) {
// Streak icon
Image(systemName: "flame.fill")
.font(.system(size: fontSize))
.foregroundStyle(isHotStreak ? .yellow : .white.opacity(Design.Opacity.strong))
// Streak text
Text(badgeText)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, horizontalPadding)
.frame(height: badgeHeight)
.background(
Capsule()
.fill(badgeColor.opacity(isHotStreak ? 0.9 : 0.7))
)
.overlay(
Capsule()
.stroke(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
)
.scaleEffect(isPulsing ? 1.05 : 1.0)
.shadow(color: isHotStreak ? badgeColor.opacity(0.5) : .clear, radius: isPulsing ? 8 : 4)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
// MARK: - Animation
private func startPulsing() {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
// MARK: - Accessibility
private var accessibilityLabel: String {
guard let type = streakType else { return "" }
let sideName = type == .bankerWins ?
String(localized: "Banker") : String(localized: "Player")
return String(localized: "\(sideName) streak of \(streakCount)")
}
}
// MARK: - Compact Trend Summary
/// A compact trend summary for the top bar.
struct CompactTrendView: View {
/// The current streak type (nil if no streak).
let streakType: GameResult?
/// The streak count.
let streakCount: Int
/// Minimum streak to display.
var minimumStreak: Int = 2
@ScaledMetric(relativeTo: .caption2) private var fontSize: CGFloat = Design.BaseFontSize.xSmall
private var shouldDisplay: Bool {
streakType != nil && streakType != .tie && streakCount >= minimumStreak
}
private var textColor: Color {
guard let type = streakType else { return .white }
return type == .bankerWins ? .red : .blue
}
private var displayText: String {
guard let type = streakType else { return "" }
let letter = type == .bankerWins ? "B" : "P"
return "\(letter)×\(streakCount)"
}
var body: some View {
if shouldDisplay {
Text(displayText)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(textColor)
.accessibilityLabel(accessibilityLabel)
}
}
private var accessibilityLabel: String {
guard let type = streakType else { return "" }
let sideName = type == .bankerWins ?
String(localized: "Banker") : String(localized: "Player")
return String(localized: "\(sideName) streak of \(streakCount)")
}
}
// MARK: - Previews
#Preview("Trend Badge - Banker Streak") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.large) {
TrendBadgeView(
streakType: .bankerWins,
streakCount: 3
)
TrendBadgeView(
streakType: .bankerWins,
streakCount: 5
)
TrendBadgeView(
streakType: .playerWins,
streakCount: 4
)
TrendBadgeView(
streakType: .playerWins,
streakCount: 7
)
}
}
}
#Preview("Compact Trend") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
HStack(spacing: Design.Spacing.large) {
CompactTrendView(
streakType: .bankerWins,
streakCount: 4
)
CompactTrendView(
streakType: .playerWins,
streakCount: 3
)
}
}
}