working ios game

This commit is contained in:
2026-05-01 09:21:43 -04:00
commit 89b2e779f5
17 changed files with 2892 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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!")
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct ShikakuApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,209 @@
import SwiftUI
enum DrawingMode: String, CaseIterable, Identifiable {
case draw
case erase
var id: String {
rawValue
}
var title: String {
switch self {
case .draw:
return "Draw"
case .erase:
return "Erase"
}
}
var symbolName: String {
switch self {
case .draw:
return "rectangle"
case .erase:
return "eraser"
}
}
}
struct ShikakuGridView: View {
@ObservedObject var game: ShikakuGame
let mode: DrawingMode
let side: CGFloat
@State private var dragStart: GridPoint?
@State private var dragEnd: GridPoint?
@State private var lastErasedCell: GridPoint?
var body: some View {
Canvas { context, _ in
drawBoard(in: CGRect(x: 0, y: 0, width: side, height: side), context: &context)
}
.frame(width: side, height: side)
.contentShape(Rectangle())
.gesture(boardGesture(side: side))
.accessibilityLabel("Shikaku grid")
.accessibilityValue("\(game.size) by \(game.size)")
}
private func boardGesture(side: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
guard let cell = cell(at: value.location, side: side) else {
return
}
switch mode {
case .draw:
if dragStart == nil {
dragStart = cell
}
dragEnd = cell
case .erase:
guard lastErasedCell != cell else {
return
}
game.removeRect(at: cell)
lastErasedCell = cell
dragStart = nil
dragEnd = nil
}
}
.onEnded { value in
defer {
dragStart = nil
dragEnd = nil
lastErasedCell = nil
}
guard mode == .draw,
let start = dragStart,
let end = cell(at: value.location, side: side) ?? dragEnd else {
return
}
game.placeRect(
PlayerRect(
startRow: start.row,
startCol: start.col,
endRow: end.row,
endCol: end.col
)
)
}
}
private func drawBoard(in rect: CGRect, context: inout GraphicsContext) {
let cellSide = rect.width / CGFloat(game.size)
let boardRect = CGRect(origin: .zero, size: rect.size)
context.fill(Path(boardRect), with: .color(ShikakuTheme.paper))
for (index, playerRect) in game.playerRects.enumerated() {
draw(playerRect, index: index, cellSide: cellSide, context: &context)
}
if let start = dragStart, let end = dragEnd, mode == .draw {
let preview = PlayerRect(startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col)
drawPreview(preview, cellSide: cellSide, context: &context)
}
drawGridLines(cellSide: cellSide, boardRect: boardRect, context: &context)
drawClues(cellSide: cellSide, context: &context)
}
private func draw(_ rect: PlayerRect, index: Int, cellSide: CGFloat, context: inout GraphicsContext) {
let pathRect = cgRect(for: rect.bounds, cellSide: cellSide).insetBy(dx: 2, dy: 2)
let path = Path(roundedRect: pathRect, cornerRadius: 6)
let hasError = game.errorRectIDs.contains(rect.id)
if hasError {
context.fill(path, with: .color(ShikakuTheme.errorFill))
context.stroke(path, with: .color(ShikakuTheme.errorStroke), lineWidth: 2.5)
} else {
let colorIndex = index % ShikakuTheme.rectFills.count
context.fill(path, with: .color(ShikakuTheme.rectFills[colorIndex]))
context.stroke(path, with: .color(ShikakuTheme.rectStrokes[colorIndex]), lineWidth: 2.5)
}
}
private func drawPreview(_ rect: PlayerRect, cellSide: CGFloat, context: inout GraphicsContext) {
let pathRect = cgRect(for: rect.bounds, cellSide: cellSide).insetBy(dx: 2, dy: 2)
let path = Path(roundedRect: pathRect, cornerRadius: 6)
let isInvalid = rect.area == 1
context.fill(path, with: .color(isInvalid ? ShikakuTheme.errorFill : ShikakuTheme.dragFill))
context.stroke(path, with: .color(isInvalid ? ShikakuTheme.errorStroke : ShikakuTheme.dragStroke), lineWidth: 2)
let fontSize = max(10, min(26, cellSide * 0.38))
let center = CGPoint(x: pathRect.midX, y: pathRect.midY)
let label = Text("\(rect.area)")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
context.draw(
label.foregroundStyle(Color.black.opacity(0.55)),
at: CGPoint(x: center.x + 1, y: center.y + 1),
anchor: .center
)
context.draw(
label.foregroundStyle(Color.white.opacity(0.95)),
at: center,
anchor: .center
)
}
private func drawGridLines(cellSide: CGFloat, boardRect: CGRect, context: inout GraphicsContext) {
var gridPath = Path()
for index in 0...game.size {
let offset = CGFloat(index) * cellSide
gridPath.move(to: CGPoint(x: offset, y: 0))
gridPath.addLine(to: CGPoint(x: offset, y: boardRect.maxY))
gridPath.move(to: CGPoint(x: 0, y: offset))
gridPath.addLine(to: CGPoint(x: boardRect.maxX, y: offset))
}
context.stroke(gridPath, with: .color(ShikakuTheme.gridLine), lineWidth: 0.8)
context.stroke(Path(boardRect), with: .color(ShikakuTheme.gridBorder), lineWidth: 3)
}
private func drawClues(cellSide: CGFloat, context: inout GraphicsContext) {
let fontSize = max(14, min(24, cellSide * 0.36))
for (cell, value) in game.puzzle.clues {
let point = CGPoint(
x: (CGFloat(cell.col) + 0.5) * cellSide,
y: (CGFloat(cell.row) + 0.5) * cellSide
)
let text = Text("\(value)")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(ShikakuTheme.clue)
context.draw(text, at: point, anchor: .center)
}
}
private func cgRect(for bounds: CellBounds, cellSide: CGFloat) -> CGRect {
CGRect(
x: CGFloat(bounds.startCol) * cellSide,
y: CGFloat(bounds.startRow) * cellSide,
width: CGFloat(bounds.width) * cellSide,
height: CGFloat(bounds.height) * cellSide
)
}
private func cell(at location: CGPoint, side: CGFloat) -> GridPoint? {
guard location.x >= 0,
location.y >= 0,
location.x <= side,
location.y <= side else {
return nil
}
let cellSide = side / CGFloat(game.size)
let row = min(Int(location.y / cellSide), game.size - 1)
let col = min(Int(location.x / cellSide), game.size - 1)
return GridPoint(row: row, col: col)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
enum ShikakuTheme {
static let background = Color(red: 0.11, green: 0.10, blue: 0.13)
static let panel = Color(red: 0.16, green: 0.15, blue: 0.18)
static let paper = Color(red: 0.95, green: 0.93, blue: 0.88)
static let gridLine = Color(red: 0.55, green: 0.52, blue: 0.48)
static let gridBorder = Color(red: 0.20, green: 0.18, blue: 0.22)
static let clue = Color(red: 0.10, green: 0.10, blue: 0.12)
static let mutedText = Color(red: 0.67, green: 0.62, blue: 0.56)
static let primaryText = Color(red: 0.91, green: 0.89, blue: 0.84)
static let success = Color(red: 0.34, green: 0.78, blue: 0.48)
static let error = Color(red: 0.88, green: 0.28, blue: 0.28)
static let accent = Color(red: 0.49, green: 0.38, blue: 0.72)
static let dragFill = Color(red: 0.50, green: 0.70, blue: 0.95).opacity(0.30)
static let dragStroke = Color(red: 0.30, green: 0.55, blue: 0.90).opacity(0.90)
static let errorFill = Color(red: 0.90, green: 0.25, blue: 0.25).opacity(0.30)
static let errorStroke = Color(red: 0.80, green: 0.10, blue: 0.10).opacity(0.90)
static let rectFills: [Color] = [
Color(red: 0.36, green: 0.61, blue: 0.84).opacity(0.45),
Color(red: 0.42, green: 0.78, blue: 0.60).opacity(0.45),
Color(red: 0.90, green: 0.55, blue: 0.35).opacity(0.45),
Color(red: 0.76, green: 0.45, blue: 0.82).opacity(0.45),
Color(red: 0.88, green: 0.76, blue: 0.30).opacity(0.45),
Color(red: 0.45, green: 0.82, blue: 0.85).opacity(0.45),
Color(red: 0.90, green: 0.65, blue: 0.80).opacity(0.45),
Color(red: 0.55, green: 0.72, blue: 0.35).opacity(0.45)
]
static let rectStrokes: [Color] = [
Color(red: 0.20, green: 0.42, blue: 0.70),
Color(red: 0.22, green: 0.60, blue: 0.40),
Color(red: 0.75, green: 0.35, blue: 0.12),
Color(red: 0.56, green: 0.25, blue: 0.65),
Color(red: 0.70, green: 0.58, blue: 0.08),
Color(red: 0.22, green: 0.62, blue: 0.68),
Color(red: 0.80, green: 0.45, blue: 0.65),
Color(red: 0.35, green: 0.54, blue: 0.18)
]
}