working ios game
This commit is contained in:
327
ios/Shikaku/ContentView.swift
Normal file
327
ios/Shikaku/ContentView.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user