Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
100eb83422
commit
2d07147fa7
282
Baccarat/Baccarat/Engine/RoadMapBuilder.swift
Normal file
282
Baccarat/Baccarat/Engine/RoadMapBuilder.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
230
Baccarat/Baccarat/Models/RoadMapModels.swift
Normal file
230
Baccarat/Baccarat/Models/RoadMapModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
228
Baccarat/Baccarat/Views/Table/BeadRoadView.swift
Normal file
228
Baccarat/Baccarat/Views/Table/BeadRoadView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
209
Baccarat/Baccarat/Views/Table/BigRoadView.swift
Normal file
209
Baccarat/Baccarat/Views/Table/BigRoadView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
258
Baccarat/Baccarat/Views/Table/DerivedRoadView.swift
Normal file
258
Baccarat/Baccarat/Views/Table/DerivedRoadView.swift
Normal 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)
|
||||
]
|
||||
348
Baccarat/Baccarat/Views/Table/RoadMapContainerView.swift
Normal file
348
Baccarat/Baccarat/Views/Table/RoadMapContainerView.swift
Normal 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)
|
||||
}
|
||||
222
Baccarat/Baccarat/Views/Table/TrendBadgeView.swift
Normal file
222
Baccarat/Baccarat/Views/Table/TrendBadgeView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user