328 lines
10 KiB
Swift
328 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var game = ShikakuGame(size: 5)
|
|
@State private var mode: DrawingMode = .draw
|
|
@State private var zoom: CGFloat = 1.0
|
|
@State private var pinchStartZoom: CGFloat?
|
|
@State private var isShowingSeedSheet = false
|
|
@State private var seedInput = ""
|
|
@State private var seedError: String?
|
|
|
|
private let zoomMinimum: CGFloat = 0.5
|
|
private let zoomMaximum: CGFloat = 3.0
|
|
private let zoomStep: CGFloat = 0.15
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
header
|
|
sizePicker
|
|
modePicker
|
|
boardViewport
|
|
zoomControls
|
|
controls
|
|
statusText
|
|
}
|
|
.padding(.top, 18)
|
|
.padding(.bottom, 20)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(ShikakuTheme.background.ignoresSafeArea())
|
|
.sheet(isPresented: $isShowingSeedSheet) {
|
|
seedSheet
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text("SHIKAKU")
|
|
.font(.system(size: 24, weight: .black, design: .rounded))
|
|
.foregroundStyle(ShikakuTheme.primaryText)
|
|
|
|
Spacer()
|
|
|
|
TimelineView(.periodic(from: .now, by: 1)) { timeline in
|
|
Text(game.formattedElapsed(at: timeline.date))
|
|
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(ShikakuTheme.mutedText)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var sizePicker: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(ShikakuGame.supportedSizes, id: \.self) { size in
|
|
Button {
|
|
guard size != game.size else {
|
|
return
|
|
}
|
|
|
|
game.newPuzzle(size: size)
|
|
mode = .draw
|
|
resetZoom()
|
|
} label: {
|
|
Text("\(size)x\(size)")
|
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.frame(minWidth: 56)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(size == game.size ? ShikakuTheme.accent : ShikakuTheme.mutedText)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|
|
|
|
private var modePicker: some View {
|
|
Picker("Mode", selection: $mode) {
|
|
ForEach(DrawingMode.allCases) { mode in
|
|
Label(mode.title, systemImage: mode.symbolName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var boardViewport: some View {
|
|
GeometryReader { proxy in
|
|
let currentBoardSide = boardSide
|
|
let contentWidth = max(currentBoardSide + 32, proxy.size.width)
|
|
let contentHeight = max(currentBoardSide + 32, proxy.size.height)
|
|
|
|
ScrollView([.horizontal, .vertical]) {
|
|
ZStack {
|
|
ShikakuGridView(game: game, mode: mode, side: currentBoardSide)
|
|
}
|
|
.frame(width: contentWidth, height: contentHeight, alignment: .center)
|
|
}
|
|
.scrollIndicators(.visible)
|
|
.background(ShikakuTheme.panel)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.simultaneousGesture(pinchZoomGesture)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
|
|
private var zoomControls: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
updateZoom(by: -zoomStep)
|
|
} label: {
|
|
Image(systemName: "minus.magnifyingglass")
|
|
.frame(width: 34, height: 34)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(zoom <= zoomMinimum)
|
|
|
|
Text("\(Int((zoom * 100).rounded()))%")
|
|
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(ShikakuTheme.mutedText)
|
|
.monospacedDigit()
|
|
.frame(width: 58)
|
|
|
|
Button {
|
|
updateZoom(by: zoomStep)
|
|
} label: {
|
|
Image(systemName: "plus.magnifyingglass")
|
|
.frame(width: 34, height: 34)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(zoom >= zoomMaximum)
|
|
|
|
Button {
|
|
resetZoom()
|
|
} label: {
|
|
Label("100%", systemImage: "arrow.counterclockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(ShikakuTheme.mutedText)
|
|
}
|
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var controls: some View {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
|
Button {
|
|
game.newPuzzle()
|
|
mode = .draw
|
|
} label: {
|
|
Label("New", systemImage: "arrow.clockwise")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(ShikakuTheme.accent)
|
|
|
|
Button {
|
|
game.checkSolution()
|
|
} label: {
|
|
Label("Check", systemImage: "checkmark.circle")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(ShikakuTheme.primaryText)
|
|
|
|
Button(role: .destructive) {
|
|
game.clear()
|
|
mode = .draw
|
|
} label: {
|
|
Label("Clear", systemImage: "trash")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button {
|
|
seedInput = ""
|
|
seedError = nil
|
|
isShowingSeedSheet = true
|
|
} label: {
|
|
Label("Seed", systemImage: "number")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(ShikakuTheme.primaryText)
|
|
}
|
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var statusText: some View {
|
|
Text(game.message)
|
|
.font(.system(size: 14, weight: .medium, design: .rounded))
|
|
.foregroundStyle(statusColor)
|
|
.frame(height: 20)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch game.status {
|
|
case .playing:
|
|
return ShikakuTheme.mutedText
|
|
case .solved:
|
|
return ShikakuTheme.success
|
|
case .error:
|
|
return ShikakuTheme.error
|
|
}
|
|
}
|
|
|
|
private var seedSheet: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("CURRENT SEED")
|
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
|
.foregroundStyle(ShikakuTheme.mutedText)
|
|
|
|
Text("\(game.currentSeed)")
|
|
.font(.system(size: 22, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(ShikakuTheme.primaryText)
|
|
.textSelection(.enabled)
|
|
|
|
Divider()
|
|
|
|
TextField("e.g. 123456789", text: $seedInput)
|
|
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
|
.keyboardType(.numbersAndPunctuation)
|
|
.submitLabel(.go)
|
|
.onSubmit(startPuzzleFromSeed)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
if let seedError {
|
|
Text(seedError)
|
|
.font(.system(size: 13, weight: .medium, design: .rounded))
|
|
.foregroundStyle(ShikakuTheme.error)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
HStack {
|
|
Button("Cancel") {
|
|
isShowingSeedSheet = false
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
startPuzzleFromSeed()
|
|
} label: {
|
|
Label("Start Puzzle", systemImage: "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(ShikakuTheme.accent)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.presentationDetents([.height(320)])
|
|
.presentationDragIndicator(.visible)
|
|
.background(ShikakuTheme.background)
|
|
}
|
|
|
|
private var boardSide: CGFloat {
|
|
CGFloat(game.size) * baseCellSide(for: game.size) * zoom
|
|
}
|
|
|
|
private var pinchZoomGesture: some Gesture {
|
|
MagnificationGesture()
|
|
.onChanged { value in
|
|
if pinchStartZoom == nil {
|
|
pinchStartZoom = zoom
|
|
}
|
|
|
|
zoom = clampedZoom((pinchStartZoom ?? zoom) * value)
|
|
}
|
|
.onEnded { _ in
|
|
pinchStartZoom = nil
|
|
}
|
|
}
|
|
|
|
private func baseCellSide(for size: Int) -> CGFloat {
|
|
switch size {
|
|
case 5:
|
|
return 56
|
|
case 7:
|
|
return 52
|
|
case 10:
|
|
return 46
|
|
case 15:
|
|
return 36
|
|
case 20:
|
|
return 28
|
|
case 25:
|
|
return 24
|
|
default:
|
|
return 40
|
|
}
|
|
}
|
|
|
|
private func updateZoom(by delta: CGFloat) {
|
|
zoom = clampedZoom(zoom + delta)
|
|
}
|
|
|
|
private func resetZoom() {
|
|
zoom = 1.0
|
|
pinchStartZoom = nil
|
|
}
|
|
|
|
private func clampedZoom(_ value: CGFloat) -> CGFloat {
|
|
min(zoomMaximum, max(zoomMinimum, value))
|
|
}
|
|
|
|
private func startPuzzleFromSeed() {
|
|
let trimmed = seedInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let seed = UInt64(trimmed) else {
|
|
seedError = "Enter a whole-number seed."
|
|
return
|
|
}
|
|
|
|
game.newPuzzle(seed: seed)
|
|
mode = .draw
|
|
resetZoom()
|
|
isShowingSeedSheet = false
|
|
}
|
|
}
|