Files
shikaku/ios/Shikaku/Core/ShikakuGame.swift
2026-05-01 09:21:43 -04:00

155 lines
4.4 KiB
Swift

import Foundation
enum GameStatus: Equatable {
case playing
case solved
case error
}
@MainActor
final class ShikakuGame: ObservableObject {
static let supportedSizes = [5, 7, 10, 15, 20, 25]
@Published private(set) var puzzle: ShikakuPuzzle
@Published private(set) var playerRects: [PlayerRect] = []
@Published private(set) var errorRectIDs: Set<PlayerRect.ID> = []
@Published private(set) var message = "Ready"
@Published private(set) var status: GameStatus = .playing
private var startDate = Date()
private var solvedElapsed: TimeInterval?
var size: Int {
puzzle.size
}
var currentSeed: UInt64 {
puzzle.seed
}
init(size: Int = 5) {
self.puzzle = Self.makePuzzle(size: size)
self.startDate = Date()
}
func newPuzzle(size requestedSize: Int? = nil, seed requestedSeed: UInt64? = nil) {
let nextSize = requestedSize ?? size
puzzle = Self.makePuzzle(size: nextSize, seed: requestedSeed)
playerRects = []
errorRectIDs = []
message = "Ready"
status = .playing
startDate = Date()
solvedElapsed = nil
}
func clear() {
playerRects = []
errorRectIDs = []
message = "Cleared"
status = .playing
if let solvedElapsed {
startDate = Date().addingTimeInterval(-solvedElapsed)
}
solvedElapsed = nil
}
func placeRect(_ rect: PlayerRect) {
guard rect.area > 1 else {
errorRectIDs = []
status = .playing
message = "Drag over at least 2 cells"
return
}
let newCells = Set(rect.cells)
playerRects.removeAll { !Set($0.cells).isDisjoint(with: newCells) }
playerRects.append(rect)
errorRectIDs = []
status = .playing
runAutoCheck()
}
func removeRect(at cell: GridPoint) {
let oldCount = playerRects.count
playerRects.removeAll { $0.contains(cell) }
if playerRects.count != oldCount {
errorRectIDs = []
status = .playing
message = "Ready"
solvedElapsed = nil
}
}
func checkSolution() {
let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues)
if result.isSolved {
markSolved()
errorRectIDs = []
message = "Solved in \(formattedElapsed())"
} else {
status = .error
errorRectIDs = invalidRectIDs()
message = result.message
}
}
func formattedElapsed(at date: Date = Date()) -> String {
let elapsed = solvedElapsed ?? max(0, date.timeIntervalSince(startDate))
let minutes = Int(elapsed) / 60
let seconds = Int(elapsed) % 60
return String(format: "%d:%02d", minutes, seconds)
}
private func runAutoCheck() {
guard status != .solved else {
return
}
let result = ShikakuValidation.verify(size: size, playerRects: playerRects, clues: puzzle.clues)
if result.isSolved {
markSolved()
message = "Solved in \(formattedElapsed())"
} else {
status = .playing
message = "Ready"
}
}
private func markSolved() {
if solvedElapsed == nil {
solvedElapsed = Date().timeIntervalSince(startDate)
}
status = .solved
}
private func invalidRectIDs() -> Set<PlayerRect.ID> {
Set(playerRects.compactMap { rect in
let clueValues = rect.cells.compactMap { puzzle.clues[$0] }
if clueValues.count != 1 || clueValues.first != rect.area {
return rect.id
}
return nil
})
}
private static func makePuzzle(size: Int, seed requestedSeed: UInt64? = nil) -> ShikakuPuzzle {
do {
return try ShikakuGenerator.generate(size: size, seed: requestedSeed)
} catch {
assertionFailure("Falling back after puzzle generation error: \(error)")
return fallbackPuzzle(size: size)
}
}
private static func fallbackPuzzle(size: Int) -> ShikakuPuzzle {
let rect = SolutionRect(row: 0, col: 0, height: size, width: size)
let clue = GridPoint(row: size / 2, col: size / 2)
return ShikakuPuzzle(size: size, clues: [clue: rect.area], solution: [rect], seed: 0)
}
}