Advertisement

← poker-tutor

Sources/PokerUI/TableDiagram.swift

7.5 KB Download file
import SwiftUI
import PokerCore

/// Where the seats sit, as pure arithmetic.
///
/// Separated from the drawing because it is the part that can be wrong in a way nobody notices
/// until a nine-seat ring is drawn on a phone: dots overlapping each other, or the outer ones
/// hanging off the edge of the space the diagram claimed. A test checks both, at every table size
/// the app deals, which is not something a SwiftUI view can be asked.
public struct SeatRing: Sendable {
    /// The whole picture is this wide, and nearly as tall: nine seats on a ring only stop
    /// crowding each other at the top and the bottom once the ring is close to round.
    public let side: CGFloat
    public let count: Int

    public init(side: CGFloat, count: Int) {
        self.side = side
        self.count = min(9, max(2, count))
    }

    public var height: CGFloat { side * 0.90 }
    /// A seat counter, and the space it needs once the highlight ring is round it.
    public var dot: CGFloat { side * 0.15 }
    public var footprint: CGFloat { dot * 1.3 }

    /// Seat 1 sits at the top and the numbers run clockwise from there, which puts the dealer —
    /// always the last number — immediately anticlockwise of the small blind, exactly where it
    /// sits at a real table.
    public func point(_ index: Int) -> CGPoint {
        let angle = -Double.pi / 2 + 2 * Double.pi * Double(index) / Double(count)
        return CGPoint(x: cos(angle) * side * 0.36, y: sin(angle) * height * 0.33)
    }

    public var points: [CGPoint] { (0..<count).map(point) }
}

/// A small picture of the table: every seat as a numbered dot in a ring, the dealer button and the
/// two blinds marked, and one seat lit up.
///
/// The whole reason it exists is that "UTG+1" is a fact about a table nobody can see. A ring with
/// the seats numbered on it answers the question the name was standing in for — where am I sitting,
/// and how many people are behind me — without anyone having to be taught the word first.
///
/// Drawn entirely with shapes, so it costs nothing to ship and grows with the text-size setting
/// rather than pixelating. It reads the same in both palettes: the only colours in it are the
/// palette's own.
struct TableDiagram: View {
    /// How many were dealt in. Two through nine, which is every table this app deals.
    let players: Int
    /// The seat to light up, if any.
    var highlight: Position?
    /// The width the whole picture is drawn at before the text-size setting is applied.
    var base: CGFloat = 148

    private var count: Int { min(9, max(2, players)) }

    /// The seats in numbered order, which is also the order they sit in: the small blind first and
    /// the button last. Sorted by the number rather than by the naming, because heads-up the two
    /// disagree — see `Position.number(inTableOf:)`.
    private var seats: [Position] {
        Position.names(forPlayerCount: count)
            .sorted { ($0.number(inTableOf: count) ?? 0) < ($1.number(inTableOf: count) ?? 0) }
    }

    var body: some View {
        let ring = SeatRing(side: Theme.size(base), count: count)
        return ZStack {
            Ellipse()
                .fill(Theme.feltInner.opacity(0.5))
                .overlay(Ellipse().stroke(Theme.rail, lineWidth: max(2, ring.side * 0.026)))
                .frame(width: ring.side * 0.48, height: ring.height * 0.30)

            ForEach(Array(seats.enumerated()), id: \.offset) { index, seat in
                dot(seat, ring: ring)
                    .offset(x: ring.point(index).x, y: ring.point(index).y)
            }
        }
        .frame(width: ring.side, height: ring.height)
        .oneAccessibleElement()
        .accessibilityLabel(Text(accessibilityText))
    }

    /// One seat: a numbered counter, with the dealer button or a blind's chip clipped to the
    /// corner of it. Both marks are the shapes they are at a real table — a "D" disc for the
    /// dealer, a chip for money that is already in — so the picture needs no key to read.
    private func dot(_ seat: Position, ring: SeatRing) -> some View {
        let lit = seat == highlight
        let side = ring.side
        let diameter = ring.dot
        let mark = diameter * 0.52
        return ZStack {
            Circle()
                .fill(lit ? Theme.accent : Theme.panelRaised)
                .frame(width: diameter, height: diameter)
            Circle()
                .stroke(lit ? Theme.accent : Color.white.opacity(0.14),
                        lineWidth: lit ? max(2, side * 0.02) : 1)
                .frame(width: diameter * 1.3, height: diameter * 1.3)
            Text("\(seat.number(inTableOf: count) ?? 0)")
                .numeric(side * 0.078, capped: true)
                .foregroundStyle(lit ? Theme.background : Theme.text)

            if seat == .button {
                ZStack {
                    Circle().fill(Theme.gold)
                    Text("D").label(side * 0.05, capped: true).foregroundStyle(Theme.background)
                }
                .frame(width: mark, height: mark)
                .offset(x: diameter * 0.46, y: diameter * 0.46)
            } else if seat.isBlind {
                Circle()
                    .fill(Theme.gold)
                    .overlay(Circle().stroke(Theme.background.opacity(0.6), lineWidth: 1))
                    .frame(width: mark * 0.72, height: mark * 0.72)
                    .offset(x: diameter * 0.46, y: diameter * 0.46)
            }
        }
    }

    private var accessibilityText: String {
        guard let highlight, let number = highlight.number(inTableOf: count) else {
            return "A \(count) seat table."
        }
        return "A \(count) seat table with seat \(number), the \(highlight.fullName), marked."
    }
}

/// The picture with the words that go under it: what the highlighted seat is, and what its number
/// actually buys you.
struct SeatExplainer: View {
    let players: Int
    let seat: Position?
    @Environment(\.metrics) var metrics
    @State var showing: GlossaryTerm?

    var body: some View {
        let layout = metrics.compact
        return VStack(alignment: .leading, spacing: 10) {
            if layout {
                TableDiagram(players: players, highlight: seat)
                words
            } else {
                HStack(alignment: .top, spacing: 16) {
                    TableDiagram(players: players, highlight: seat)
                    words
                }
            }
            DefinitionSlot(showing: $showing)
        }
    }

    /// The traditional name is the one part of this that has to be learned rather than read, so it
    /// is the part that can be tapped.
    private var words: some View {
        VStack(alignment: .leading, spacing: 5) {
            if let seat, let number = seat.number(inTableOf: players) {
                HStack(alignment: .firstTextBaseline, spacing: 5) {
                    Text("Seat \(number) of \(players) —").heading().foregroundStyle(Theme.text)
                    TermChip(seat.fullName, showing: $showing, size: 13, colour: Theme.text)
                }
                GlossaryText(seat.actOrderNote(inTableOf: players), showing: $showing)
            }
            GlossaryText(Copy.seatsPayBeforeCards,
                         size: 11, colour: Theme.dim.opacity(0.85), showing: $showing)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}