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