working ios game
This commit is contained in:
232
ios/Shikaku/Core/PuzzleGenerator.swift
Normal file
232
ios/Shikaku/Core/PuzzleGenerator.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
|
||||
enum PuzzleGenerationError: Error, LocalizedError {
|
||||
case unsupportedSize(Int)
|
||||
case failed(size: Int, attempts: Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedSize(let size):
|
||||
return "Grid size \(size) is not supported."
|
||||
case .failed(let size, let attempts):
|
||||
return "Puzzle generation failed for \(size)x\(size) after \(attempts) attempts."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SeededRandomNumberGenerator: RandomNumberGenerator {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
self.state = seed == 0 ? 0x9E3779B97F4A7C15 : seed
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E3779B97F4A7C15
|
||||
var z = state
|
||||
z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
|
||||
z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
|
||||
return z ^ (z >> 31)
|
||||
}
|
||||
|
||||
mutating func randomDouble() -> Double {
|
||||
let value = next() >> 11
|
||||
return Double(value) / Double(1 << 53)
|
||||
}
|
||||
|
||||
mutating func weightedLowIndex(count: Int) -> Int {
|
||||
guard count > 1 else {
|
||||
return 0
|
||||
}
|
||||
|
||||
// The Python version sampled a beta distribution to favor smaller rectangles.
|
||||
// This power curve keeps the same bias without pulling in a statistics library.
|
||||
let fraction = pow(randomDouble(), 2.2)
|
||||
return min(Int(fraction * Double(count)), count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
enum ShikakuGenerator {
|
||||
static let maximumAttempts = 200
|
||||
private static let maximumRandomSeed: UInt64 = 2_147_483_648
|
||||
|
||||
static func generate(size: Int, seed requestedSeed: UInt64? = nil) throws -> ShikakuPuzzle {
|
||||
guard size > 1 else {
|
||||
throw PuzzleGenerationError.unsupportedSize(size)
|
||||
}
|
||||
|
||||
let seed = requestedSeed ?? UInt64.random(in: 0...maximumRandomSeed)
|
||||
var rng = SeededRandomNumberGenerator(seed: seed)
|
||||
|
||||
for _ in 0..<maximumAttempts {
|
||||
if let puzzle = attempt(size: size, seed: seed, rng: &rng) {
|
||||
return puzzle
|
||||
}
|
||||
}
|
||||
|
||||
throw PuzzleGenerationError.failed(size: size, attempts: maximumAttempts)
|
||||
}
|
||||
|
||||
private static func attempt(size: Int, seed: UInt64, rng: inout SeededRandomNumberGenerator) -> ShikakuPuzzle? {
|
||||
var grid = Array(repeating: Array(repeating: -1, count: size), count: size)
|
||||
var rects: [SolutionRect] = []
|
||||
var uncovered = (0..<size).flatMap { row in
|
||||
(0..<size).map { col in GridPoint(row: row, col: col) }
|
||||
}
|
||||
|
||||
uncovered.shuffle(using: &rng)
|
||||
|
||||
while !uncovered.isEmpty {
|
||||
let start = uncovered[0]
|
||||
|
||||
if grid[start.row][start.col] != -1 {
|
||||
uncovered.removeFirst()
|
||||
continue
|
||||
}
|
||||
|
||||
var candidates: [SolutionRect] = []
|
||||
|
||||
for height in 1...(size - start.row) {
|
||||
for width in 1...(size - start.col) {
|
||||
if height == 1 && width == 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
for rowOffset in 0..<height {
|
||||
for colOffset in 0..<width {
|
||||
let row0 = start.row - rowOffset
|
||||
let col0 = start.col - colOffset
|
||||
let row1 = row0 + height - 1
|
||||
let col1 = col0 + width - 1
|
||||
|
||||
guard row0 >= 0, col0 >= 0, row1 < size, col1 < size else {
|
||||
continue
|
||||
}
|
||||
|
||||
if rectangleIsUncovered(row0: row0, col0: col0, row1: row1, col1: col1, grid: grid) {
|
||||
candidates.append(SolutionRect(row: row0, col: col0, height: height, width: width))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.isEmpty {
|
||||
guard absorbIsolatedCell(start, size: size, grid: &grid, rects: &rects, uncovered: &uncovered, rng: &rng) else {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let keyedCandidates = candidates.map { rect in
|
||||
(rect: rect, tieBreak: rng.randomDouble())
|
||||
}
|
||||
let sortedCandidates = keyedCandidates
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.rect.area == rhs.rect.area {
|
||||
return lhs.tieBreak < rhs.tieBreak
|
||||
}
|
||||
return lhs.rect.area < rhs.rect.area
|
||||
}
|
||||
.map(\.rect)
|
||||
|
||||
let chosen = sortedCandidates[rng.weightedLowIndex(count: sortedCandidates.count)]
|
||||
let rectID = rects.count
|
||||
|
||||
for cell in chosen.cells {
|
||||
grid[cell.row][cell.col] = rectID
|
||||
}
|
||||
|
||||
rects.append(chosen)
|
||||
uncovered.removeAll { grid[$0.row][$0.col] != -1 }
|
||||
}
|
||||
|
||||
guard (0..<size).allSatisfy({ row in (0..<size).allSatisfy { grid[row][$0] != -1 } }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var clues: [GridPoint: Int] = [:]
|
||||
for rect in rects {
|
||||
guard let clueCell = rect.cells.randomElement(using: &rng) else {
|
||||
return nil
|
||||
}
|
||||
clues[clueCell] = rect.area
|
||||
}
|
||||
|
||||
return ShikakuPuzzle(size: size, clues: clues, solution: rects, seed: seed)
|
||||
}
|
||||
|
||||
private static func rectangleIsUncovered(
|
||||
row0: Int,
|
||||
col0: Int,
|
||||
row1: Int,
|
||||
col1: Int,
|
||||
grid: [[Int]]
|
||||
) -> Bool {
|
||||
for row in row0...row1 {
|
||||
for col in col0...col1 where grid[row][col] != -1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func absorbIsolatedCell(
|
||||
_ cell: GridPoint,
|
||||
size: Int,
|
||||
grid: inout [[Int]],
|
||||
rects: inout [SolutionRect],
|
||||
uncovered: inout [GridPoint],
|
||||
rng: inout SeededRandomNumberGenerator
|
||||
) -> Bool {
|
||||
var neighbors = [
|
||||
GridPoint(row: cell.row - 1, col: cell.col),
|
||||
GridPoint(row: cell.row + 1, col: cell.col),
|
||||
GridPoint(row: cell.row, col: cell.col - 1),
|
||||
GridPoint(row: cell.row, col: cell.col + 1)
|
||||
]
|
||||
neighbors.shuffle(using: &rng)
|
||||
|
||||
for neighbor in neighbors {
|
||||
guard (0..<size).contains(neighbor.row),
|
||||
(0..<size).contains(neighbor.col),
|
||||
grid[neighbor.row][neighbor.col] != -1 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let rectID = grid[neighbor.row][neighbor.col]
|
||||
let old = rects[rectID]
|
||||
let oldBounds = old.bounds
|
||||
let newRow0 = min(oldBounds.startRow, cell.row)
|
||||
let newCol0 = min(oldBounds.startCol, cell.col)
|
||||
let newRow1 = max(oldBounds.endRow, cell.row)
|
||||
let newCol1 = max(oldBounds.endCol, cell.col)
|
||||
|
||||
let expandedCells = CellBounds(
|
||||
startRow: newRow0,
|
||||
startCol: newCol0,
|
||||
endRow: newRow1,
|
||||
endCol: newCol1
|
||||
).cells
|
||||
|
||||
guard expandedCells.allSatisfy({ grid[$0.row][$0.col] == -1 || grid[$0.row][$0.col] == rectID }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for expandedCell in expandedCells {
|
||||
grid[expandedCell.row][expandedCell.col] = rectID
|
||||
}
|
||||
|
||||
rects[rectID] = SolutionRect(
|
||||
row: newRow0,
|
||||
col: newCol0,
|
||||
height: newRow1 - newRow0 + 1,
|
||||
width: newCol1 - newCol0 + 1
|
||||
)
|
||||
uncovered.removeAll { grid[$0.row][$0.col] != -1 }
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
154
ios/Shikaku/Core/ShikakuGame.swift
Normal file
154
ios/Shikaku/Core/ShikakuGame.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
152
ios/Shikaku/Core/ShikakuModels.swift
Normal file
152
ios/Shikaku/Core/ShikakuModels.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
|
||||
struct GridPoint: Hashable, Sendable {
|
||||
let row: Int
|
||||
let col: Int
|
||||
}
|
||||
|
||||
struct CellBounds: Hashable, Sendable {
|
||||
let startRow: Int
|
||||
let startCol: Int
|
||||
let endRow: Int
|
||||
let endCol: Int
|
||||
|
||||
var height: Int {
|
||||
endRow - startRow + 1
|
||||
}
|
||||
|
||||
var width: Int {
|
||||
endCol - startCol + 1
|
||||
}
|
||||
|
||||
var area: Int {
|
||||
height * width
|
||||
}
|
||||
|
||||
func contains(_ point: GridPoint) -> Bool {
|
||||
startRow <= point.row && point.row <= endRow && startCol <= point.col && point.col <= endCol
|
||||
}
|
||||
|
||||
var cells: [GridPoint] {
|
||||
var result: [GridPoint] = []
|
||||
result.reserveCapacity(area)
|
||||
for row in startRow...endRow {
|
||||
for col in startCol...endCol {
|
||||
result.append(GridPoint(row: row, col: col))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct SolutionRect: Hashable, Sendable {
|
||||
let row: Int
|
||||
let col: Int
|
||||
let height: Int
|
||||
let width: Int
|
||||
|
||||
var area: Int {
|
||||
height * width
|
||||
}
|
||||
|
||||
var bounds: CellBounds {
|
||||
CellBounds(startRow: row, startCol: col, endRow: row + height - 1, endCol: col + width - 1)
|
||||
}
|
||||
|
||||
var cells: [GridPoint] {
|
||||
bounds.cells
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerRect: Identifiable, Hashable, Sendable {
|
||||
let id: UUID
|
||||
let startRow: Int
|
||||
let startCol: Int
|
||||
let endRow: Int
|
||||
let endCol: Int
|
||||
|
||||
init(id: UUID = UUID(), startRow: Int, startCol: Int, endRow: Int, endCol: Int) {
|
||||
self.id = id
|
||||
self.startRow = startRow
|
||||
self.startCol = startCol
|
||||
self.endRow = endRow
|
||||
self.endCol = endCol
|
||||
}
|
||||
|
||||
var bounds: CellBounds {
|
||||
CellBounds(
|
||||
startRow: min(startRow, endRow),
|
||||
startCol: min(startCol, endCol),
|
||||
endRow: max(startRow, endRow),
|
||||
endCol: max(startCol, endCol)
|
||||
)
|
||||
}
|
||||
|
||||
var area: Int {
|
||||
bounds.area
|
||||
}
|
||||
|
||||
var cells: [GridPoint] {
|
||||
bounds.cells
|
||||
}
|
||||
|
||||
func contains(_ point: GridPoint) -> Bool {
|
||||
bounds.contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
struct ShikakuPuzzle: Sendable {
|
||||
let size: Int
|
||||
let clues: [GridPoint: Int]
|
||||
let solution: [SolutionRect]
|
||||
let seed: UInt64
|
||||
}
|
||||
|
||||
struct ValidationResult: Equatable, Sendable {
|
||||
let isSolved: Bool
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum ShikakuValidation {
|
||||
static func verify(size: Int, playerRects: [PlayerRect], clues: [GridPoint: Int]) -> ValidationResult {
|
||||
var coverage: [GridPoint: Int] = [:]
|
||||
|
||||
for (index, rect) in playerRects.enumerated() {
|
||||
for cell in rect.cells {
|
||||
guard (0..<size).contains(cell.row), (0..<size).contains(cell.col) else {
|
||||
return ValidationResult(isSolved: false, message: "A rectangle extends outside the grid")
|
||||
}
|
||||
|
||||
if coverage[cell] != nil {
|
||||
return ValidationResult(isSolved: false, message: "Cells overlap between two rectangles")
|
||||
}
|
||||
|
||||
coverage[cell] = index
|
||||
}
|
||||
}
|
||||
|
||||
let totalCells = size * size
|
||||
if coverage.count != totalCells {
|
||||
return ValidationResult(isSolved: false, message: "Not all cells are covered (\(coverage.count)/\(totalCells))")
|
||||
}
|
||||
|
||||
for rect in playerRects {
|
||||
let clueValues = rect.cells.compactMap { clues[$0] }
|
||||
|
||||
if clueValues.isEmpty {
|
||||
return ValidationResult(isSolved: false, message: "A rectangle contains no clue number")
|
||||
}
|
||||
|
||||
if clueValues.count > 1 {
|
||||
return ValidationResult(isSolved: false, message: "A rectangle contains more than one clue number")
|
||||
}
|
||||
|
||||
let clueValue = clueValues[0]
|
||||
if rect.area != clueValue {
|
||||
return ValidationResult(isSolved: false, message: "A rectangle contains \(clueValue) but has \(rect.area) cells")
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationResult(isSolved: true, message: "Solved!")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user