292 lines
11 KiB
Swift
292 lines
11 KiB
Swift
import SwiftUI
|
||
|
||
struct GameBoardView: View {
|
||
@ObservedObject var controller: GameController
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Group {
|
||
if let snap = controller.snapshot {
|
||
if controller.isRoundOver {
|
||
roundOver(snap)
|
||
} else {
|
||
playingBoard(snap)
|
||
}
|
||
} else {
|
||
waitingView
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
|
||
shareBar
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var shareBar: some View {
|
||
if controller.shareMode == .systemShare, let url = controller.joinURL {
|
||
if controller.snapshot == nil {
|
||
ShareLink(item: url) {
|
||
Text("Share")
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
} else {
|
||
Button { controller.tapNewGame() } label: {
|
||
Text("Leave")
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
} else {
|
||
Button { controller.tapExit() } label: {
|
||
Text("Exit")
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
|
||
private var waitingView: some View {
|
||
VStack(spacing: 16) {
|
||
if let id = controller.gameID {
|
||
Text("Game #\(id)")
|
||
.font(.title.weight(.bold))
|
||
}
|
||
ProgressView()
|
||
Text(controller.statusMessage ?? "Waiting for players…")
|
||
.foregroundStyle(.secondary)
|
||
if let error = controller.errorMessage {
|
||
Text(error)
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
Button("New Game Instead") { controller.tapNewGame() }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding(20)
|
||
}
|
||
|
||
private func playingBoard(_ snap: GolfAPISnapshot) -> some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
header(snap)
|
||
|
||
if let error = controller.errorMessage {
|
||
Text(error)
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
}
|
||
|
||
if controller.isEliminated {
|
||
Text("You are eliminated.")
|
||
.font(.headline)
|
||
.foregroundStyle(.orange)
|
||
}
|
||
|
||
sourcesRow(snap)
|
||
|
||
if let me = controller.myPlayer {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(controller.isMyTurn ? "Your hand — tap a card, then deck or discard" : "Your hand (\(me.user))")
|
||
.font(.headline)
|
||
HandGridView(
|
||
cards: handCards(for: me, cardNumber: snap.rules.cardNumber),
|
||
interactive: controller.isMyTurn && !controller.isBusy,
|
||
selectedSlot: controller.selectedSlot,
|
||
compact: false,
|
||
onSelect: { controller.selectSlot($0) }
|
||
)
|
||
Text("Total \(me.points) · showing \(me.currentGamePoints)")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} else {
|
||
Text("You are not in this game’s player list yet.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
opponentsSection(snap)
|
||
}
|
||
.padding(16)
|
||
}
|
||
}
|
||
|
||
private func header(_ snap: GolfAPISnapshot) -> some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(controller.gameID.map { "Game #\($0)" } ?? "Golf")
|
||
.font(.title2.weight(.bold))
|
||
Text(controller.isMyTurn ? "Your turn" : turnLabel(snap))
|
||
.font(.subheadline)
|
||
.foregroundStyle(controller.isMyTurn ? Color.accentColor : .secondary)
|
||
}
|
||
Spacer()
|
||
Text("Out at \(snap.rules.pointsToEnd)")
|
||
.font(.caption.weight(.semibold))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
||
}
|
||
}
|
||
|
||
private func turnLabel(_ snap: GolfAPISnapshot) -> String {
|
||
if let current = snap.players.first(where: { $0.orderID == snap.currentPlayer }) {
|
||
return "\(current.user)’s turn"
|
||
}
|
||
return "Waiting"
|
||
}
|
||
|
||
private func sourcesRow(_ snap: GolfAPISnapshot) -> some View {
|
||
HStack(spacing: 20) {
|
||
sourceButton(
|
||
title: "Deck",
|
||
subtitle: "\(snap.deckSize)",
|
||
selected: controller.selectedSource == .deck,
|
||
enabled: controller.isMyTurn && !controller.isBusy
|
||
) { controller.selectSource(.deck) }
|
||
|
||
sourceButton(
|
||
title: "Discard",
|
||
subtitle: snap.discardTop.map { CardDisplay.label(for: UInt8(clamping: $0)) } ?? "—",
|
||
selected: controller.selectedSource == .discard,
|
||
enabled: controller.isMyTurn && !controller.isBusy
|
||
) { controller.selectSource(.discard) }
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private func sourceButton(
|
||
title: String,
|
||
subtitle: String,
|
||
selected: Bool,
|
||
enabled: Bool,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
VStack(spacing: 6) {
|
||
Text(title).font(.caption.weight(.semibold))
|
||
Text(subtitle).font(.system(.title3, design: .rounded).weight(.bold))
|
||
}
|
||
.frame(width: 88, height: 72)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(selected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.strokeBorder(selected ? Color.accentColor : .clear, lineWidth: 2)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
.opacity(enabled ? 1 : 0.45)
|
||
}
|
||
|
||
private func opponentsSection(_ snap: GolfAPISnapshot) -> some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Table").font(.headline)
|
||
ForEach(snap.players) { player in
|
||
if player.user != controller.username {
|
||
opponentRow(player: player, cardNumber: snap.rules.cardNumber)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func opponentRow(player: GolfAPISnapshot.Player, cardNumber: Int) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text(player.lastMode == "eliminated" ? "\(player.user) (out)" : player.user)
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer()
|
||
Text("\(player.points) pts")
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
HandGridView(
|
||
cards: handCards(for: player, cardNumber: cardNumber),
|
||
interactive: false,
|
||
selectedSlot: nil,
|
||
compact: true,
|
||
onSelect: nil
|
||
)
|
||
}
|
||
.padding(10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(Color.secondary.opacity(0.08))
|
||
)
|
||
}
|
||
|
||
private func roundOver(_ snap: GolfAPISnapshot) -> some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text("Round over")
|
||
.font(.title.weight(.bold))
|
||
Text("All cards revealed. Continue when ready.")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
|
||
ForEach(snap.players) { player in
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text(player.user == controller.username ? "You (\(player.user))" : player.user)
|
||
.font(.headline)
|
||
if player.multiplier > 1 {
|
||
Text("×\(player.multiplier)")
|
||
.font(.caption.weight(.bold))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Capsule().fill(Color.orange.opacity(0.25)))
|
||
}
|
||
Spacer()
|
||
Text("\(player.currentGamePoints) this hole · \(player.points) total")
|
||
.font(.caption.monospacedDigit())
|
||
}
|
||
HandGridView(
|
||
cards: handCards(for: player, cardNumber: snap.rules.cardNumber),
|
||
interactive: false,
|
||
selectedSlot: nil,
|
||
compact: true,
|
||
onSelect: nil
|
||
)
|
||
}
|
||
}
|
||
|
||
Button { controller.continueRound() } label: {
|
||
Text(controller.isBusy ? "Updating…" : "Continue")
|
||
.font(.headline)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(controller.isBusy)
|
||
}
|
||
.padding(16)
|
||
}
|
||
}
|
||
|
||
private func handCards(for player: GolfAPISnapshot.Player, cardNumber: Int) -> [HandCard] {
|
||
var byPlacement: [Int: Int] = [:]
|
||
for card in player.cards {
|
||
byPlacement[card.cardPlacement] = card.card
|
||
}
|
||
return (1...cardNumber).map { placement in
|
||
if let value = byPlacement[placement] {
|
||
return HandCard(value: UInt8(clamping: value), faceUp: true)
|
||
}
|
||
return HandCard(value: 0, faceUp: false)
|
||
}
|
||
}
|
||
}
|