GridView.swift (40632B)
1 import SwiftUI 2 3 struct GridView: View { 4 @Bindable var session: PlayerSession 5 let roster: PlayerRoster 6 /// Owns the reveal confirmation alert (presented by `PuzzleView`); the 7 /// grid's VoiceOver Reveal Square action requests through it so a reveal 8 /// is never applied without the standard confirmation. 9 let revealConfirmation: RevealConfirmation 10 let showsSharedAnnotations: Bool 11 /// Whether to render peers' live cursor tracks. Off for a solved puzzle — 12 /// the game is no longer a live session, so the other player's cursor is 13 /// dropped while author tints stay to colour the finished grid. 14 var showsPeerCursors: Bool = true 15 /// The finish-banner replay frame to render. When non-nil (the user has 16 /// scrubbed back from the end), the grid renders this reconstructed history 17 /// instead of the live `Game`: each touched cell shows its after-state, 18 /// blanks elsewhere. Live selection, word highlight, and taps are 19 /// suppressed, and the playhead cell is tinted in the acting author's 20 /// colour. `nil` (the default, or a scrubber at rest) leaves normal play. 21 var replayFrame: ReplayFrame? = nil 22 23 private let spacing: CGFloat = 1 24 25 /// The grid container's resolved size, captured so the single tap recogniser 26 /// can map a tap location back to a cell via `PuzzleGridGeometry`. 27 @State private var gridSize: CGSize = .zero 28 29 /// Decoded `data=` decoration tiles, keyed by payload. Resolved once per 30 /// puzzle rather than in `body`, which re-runs on every keystroke. 31 @State private var decorationImages: [String: Image] = [:] 32 33 var body: some View { 34 let width = session.puzzle.width 35 let height = session.puzzle.height 36 let replayCells = replayFrame?.cells 37 let isReplaying = replayFrame != nil 38 let replayCursor = replayFrame?.cursor 39 // Once the puzzle is solved, rebus squares show their canonical fill 40 // (e.g. a Schrödinger square reads "MTWA" rather than the "WA" the 41 // solver typed for the down answer). Purely a render-time substitution 42 // — the stored entry, its authorship, and marks are left untouched. 43 let showsCanonicalRebus = !isReplaying && session.game.completionState == .solved 44 // Peer cursor tints are rendered in a separate Canvas layer (see 45 // `RemoteCursorTints`) so this 441-cell grid no longer re-evaluates on 46 // every peer cursor move — only on local selection and letter changes. 47 let showsRemoteTints = showsSharedAnnotations && showsPeerCursors && !isReplaying 48 // Author colours are shown for shared live play and for any replay 49 // (the rewind reads as coloured per author, matching the scoreboard). 50 let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying) 51 ? Dictionary( 52 roster.entries.map { ($0.authorID, $0.color.tint) }, 53 uniquingKeysWith: { first, _ in first } 54 ) 55 : [:] 56 // Colour for the replay playhead: the acting author's selection fill, 57 // so a rewound move reads in that player's colour (the local fill for 58 // our own moves). `nil` outside replay leaves the local cursor as-is. 59 let playheadTint: Color? = replayFrame?.cursorAuthorID 60 .flatMap { id in roster.entries.first { $0.authorID == id } } 61 .map { $0.color.selectionFill } 62 let patternPalette = CrossRefPattern.allCases 63 let cellGroups = session.puzzle.cellGroups 64 // Flatten the grid to a plain draw model read here in `body` (so it 65 // tracks the observable game state), then hand it to a single `Canvas` 66 // that paints all cell content in one pass — replacing the former 67 // 441-view `ForEach`, which dominated first-render cost. Block cells 68 // carry no content; they're skipped at draw time but kept in the array 69 // so indices map directly to positions. 70 let cellModel: [CellDraw] = (0..<(width * height)).map { index in 71 let r = index / width 72 let c = index % width 73 let cell = session.puzzle.cells[r][c] 74 let visibleDecorations = session.puzzle.decorations[GridPosition(row: r, col: c)]? 75 .filter { $0.isVisible(solved: showsCanonicalRebus) } ?? [] 76 guard !cell.isBlock else { 77 return CellDraw(row: r, col: c, isBlock: true, special: nil, 78 crossRef: nil, entry: "", isPencil: false, 79 triangle: nil, authorTint: nil, 80 decorations: visibleDecorations) 81 } 82 let pos = GridPosition(row: r, col: c) 83 let square = session.game.squares[r][c] 84 // During replay the cell's letter/mark/author come from the 85 // reconstructed history, not the live square. 86 let replayCell = replayCells?[pos] 87 let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry 88 let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry 89 let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark 90 let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID 91 let triangle: TriangleKind? = mark.isRevealed 92 ? .revealed 93 : (mark.isCheckedWrong ? .wrong : nil) 94 return CellDraw( 95 row: r, 96 col: c, 97 isBlock: false, 98 special: cell.special, 99 crossRef: cellGroups[pos].map { patternPalette[$0 % patternPalette.count] }, 100 entry: displayEntry, 101 isPencil: mark.isPencil, 102 triangle: triangle, 103 authorTint: entry.isEmpty ? nil : letterAuthorID.flatMap { authorTintByID[$0] }, 104 decorations: visibleDecorations 105 ) 106 } 107 // Layered back to front: black (shows through the inter-cell gaps and 108 // behind blocks) -> white cell backdrop -> author tints -> peer cursor 109 // tints -> local cursor tints -> cells. Keep these as siblings in one 110 // layout so iPad-sized proposals cannot give the backing Canvases a 111 // different drawing rect than the cells. The cursor layers each read 112 // their own selection state in their own body, so a cursor move 113 // repaints only a lightweight Canvas and never invalidates the cell 114 // `ForEach`. Author tints sit below the local cursor so the focused 115 // square's opaque fill can mask the tint (see `LocalCursorTints`). 116 PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) { 117 GridBackdrop(puzzle: session.puzzle, spacing: spacing) 118 AuthorTintsLayer(cells: cellModel, columns: width, rows: height, spacing: spacing) 119 if showsRemoteTints { 120 RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing) 121 } 122 LocalCursorTints( 123 session: session, 124 spacing: spacing, 125 isReplaying: isReplaying, 126 replayCursor: replayCursor, 127 replayPlayheadTint: playheadTint 128 ) 129 if !isReplaying { 130 RecentChangeBorders(session: session, roster: roster, spacing: spacing) 131 } 132 PuzzleCellsLayer( 133 cells: cellModel, 134 columns: width, 135 rows: height, 136 spacing: spacing, 137 decorationImages: decorationImages 138 ) 139 } 140 // One tap recogniser for the whole grid instead of 441 per-cell ones: 141 // with uniform cells the tapped cell is pure arithmetic on the tap 142 // location (see `PuzzleGridGeometry.cell(at:)`). `select` already guards 143 // blocks and out-of-range, so any in-grid tap is safe to forward. 144 .contentShape(Rectangle()) 145 .onTapGesture(coordinateSpace: .local) { location in 146 guard !isReplaying, gridSize != .zero else { return } 147 let geometry = PuzzleGridGeometry( 148 size: gridSize, columns: width, rows: height, spacing: spacing 149 ) 150 if let (r, c) = geometry.cell(at: location) { 151 session.select(row: r, col: c) 152 } 153 } 154 .onGeometryChange(for: CGSize.self) { $0.size } action: { gridSize = $0 } 155 // Decode once per puzzle. Keyed on the game so switching puzzles inside 156 // one grid view re-resolves rather than drawing the previous puzzle's 157 // tiles; a puzzle with no `data=` decorations resolves to an empty map 158 // and costs a single dictionary walk. 159 .task(id: session.mutator.gameID) { 160 decorationImages = DecorationImages.decode(session.puzzle.decorations) 161 } 162 // Synthetic VoiceOver elements over the Canvas layers; built only 163 // while VoiceOver is running (see GridAccessibility.swift). 164 .puzzleGridAccessibility( 165 session: session, 166 roster: roster, 167 revealConfirmation: revealConfirmation, 168 showsSharedAnnotations: showsSharedAnnotations, 169 isReplaying: isReplaying, 170 gridSize: gridSize, 171 spacing: spacing 172 ) 173 } 174 175 /// The canonical fill to display for a rebus square on a solved puzzle, or 176 /// `nil` to fall back to the stored entry. Only multi-character solutions 177 /// (rebus/Schrödinger squares) differ from what the solver typed; ordinary 178 /// single-letter cells return `nil` and render their entry unchanged. 179 private func canonicalRebusFill(for cell: Puzzle.Cell, when isSolved: Bool) -> String? { 180 guard isSolved, let solution = cell.solution, solution.count > 1 else { return nil } 181 return solution 182 } 183 184 } 185 186 // MARK: - Cell content 187 188 /// Which corner-triangle marker a cell carries, if any. Mirrors the former 189 /// `CellView.cornerTriangleColor`: revealed cells are yellow, checked-wrong red. 190 private enum TriangleKind { 191 case revealed 192 case wrong 193 } 194 195 /// A flat, value-type snapshot of everything one cell draws. Built in 196 /// `GridView.body` (so it tracks observable state) and rendered by 197 /// `PuzzleCellsLayer`. Replaces the per-cell `CellView`. 198 private struct CellDraw { 199 let row: Int 200 let col: Int 201 let isBlock: Bool 202 let special: Puzzle.Special? 203 let crossRef: CrossRefPattern? 204 let entry: String 205 let isPencil: Bool 206 let triangle: TriangleKind? 207 /// The author's base tint, if this cell carries an entry in a shared game. 208 /// `PlayerColor.authorTintOpacity` is applied at draw time (in 209 /// `AuthorTintsLayer`, not `PuzzleCellsLayer`), matching the former 210 /// `CellView` background. 211 let authorTint: Color? 212 /// Decoration layers currently visible on this cell, in paint order. Already 213 /// filtered by phase, so the draw path never has to know whether the puzzle 214 /// is solved. Unlike every other field here this is also populated for 215 /// blocks, which is the whole point — an after-solve reveal routinely lands 216 /// on black squares. 217 let decorations: [Puzzle.Decoration] 218 } 219 220 /// The faint author-attribution washes, split out of `PuzzleCellsLayer` so they 221 /// can sit *below* the cursor tints: `LocalCursorTints` paints the focused 222 /// square opaquely, masking the author tint on that one cell so the cursor 223 /// keeps a single fixed colour whether or not the square is filled. Reads the 224 /// same `CellDraw` model as the cells layer, so it repaints on letter changes 225 /// and never on cursor moves. 226 private struct AuthorTintsLayer: View { 227 let cells: [CellDraw] 228 let columns: Int 229 let rows: Int 230 let spacing: CGFloat 231 232 var body: some View { 233 Canvas { context, size in 234 let geometry = PuzzleGridGeometry( 235 size: size, columns: columns, rows: rows, spacing: spacing 236 ) 237 guard geometry.cellSize > 0 else { return } 238 for cell in cells { 239 guard let tint = cell.authorTint else { continue } 240 context.fill( 241 Path(geometry.cellRect(row: cell.row, col: cell.col)), 242 with: .color(tint.opacity(PlayerColor.authorTintOpacity)) 243 ) 244 } 245 } 246 .allowsHitTesting(false) 247 } 248 } 249 250 /// Draws all cell content (shaded/circled specials, cross-ref hatching, 251 /// letters, corner triangles) in a single `Canvas`. This is the layer that 252 /// previously cost ~441 SwiftUI view subtrees on first render; as one 253 /// immediate-mode draw pass it builds in roughly constant time. Z-order within 254 /// each cell matches the former `CellView`: shaded → cross-ref → circle → 255 /// letter → corner triangle. Author tints are the exception — they render in 256 /// `AuthorTintsLayer` beneath the cursor layers so the focused square's opaque 257 /// selection fill can mask them. 258 private struct PuzzleCellsLayer: View { 259 let cells: [CellDraw] 260 let columns: Int 261 let rows: Int 262 let spacing: CGFloat 263 /// Decoded `data=` tiles, keyed by payload (see `DecorationImages`). 264 var decorationImages: [String: Image] = [:] 265 @Environment(\.colorScheme) private var colorScheme 266 267 /// Base size before the per-cell fit-scale; mirrors the former 268 /// `CellView` letter font (`.system(size: 34, weight: .semibold, 269 /// design: .rounded)`). 270 private let baseFontSize: CGFloat = 34 271 272 var body: some View { 273 Canvas { context, size in 274 let geometry = PuzzleGridGeometry( 275 size: size, columns: columns, rows: rows, spacing: spacing 276 ) 277 guard geometry.cellSize > 0 else { return } 278 279 // Resolved letters are cached per (entry, pencil) for this draw: a 280 // full grid has ~26 distinct glyphs, so almost every cell is a hit 281 // and `resolve`/`measure` runs a couple of dozen times, not 441. 282 var glyphCache: [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:] 283 var decorationTextCache: 284 [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:] 285 // Resolved tiles are cached for this draw the same way glyphs are: 286 // a grid art puzzle repeats a handful of motifs across many cells. 287 var imageCache: [String: GraphicsContext.ResolvedImage] = [:] 288 289 // Decorations run over every cell, blocks included — an after-solve 290 // reveal routinely lands on black squares, which the content loop 291 // below skips entirely. 292 for cell in cells where !cell.decorations.isEmpty { 293 drawDecorations( 294 cell, 295 in: geometry.cellRect(row: cell.row, col: cell.col), 296 textCache: &decorationTextCache, 297 cache: &imageCache, 298 context: context 299 ) 300 } 301 302 for cell in cells where !cell.isBlock { 303 let rect = geometry.cellRect(row: cell.row, col: cell.col) 304 305 if cell.special == .shaded { 306 context.fill(Path(rect), with: .color(.black.opacity(0.22))) 307 } 308 if let crossRef = cell.crossRef { 309 drawCrossRef(crossRef, in: rect, row: cell.row, col: cell.col, context: context) 310 } 311 if cell.special == .circled { 312 context.stroke( 313 Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)), 314 with: .color(.black.opacity(0.55)), 315 lineWidth: 1 316 ) 317 } 318 if !cell.entry.isEmpty { 319 drawLetter(cell, in: rect, cache: &glyphCache, context: context) 320 } 321 if let triangle = cell.triangle { 322 let side = min(rect.width, rect.height) * 0.3 323 var path = Path() 324 path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) 325 path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) 326 path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) 327 path.closeSubpath() 328 context.fill(path, with: .color(triangle == .revealed ? .yellow : .red)) 329 } 330 } 331 } 332 } 333 334 /// Paints a cell's decoration layers in definition order — the format's 335 /// stacking rule is that a repeated design character's lines paint in the 336 /// order they were written. 337 /// 338 /// Two kinds are deliberately not drawn here. A `before`-phase `mark` is 339 /// already folded into `Cell.special` by `Puzzle.init`, so drawing it again 340 /// would double-stroke every circle; only an `after` mark needs handling. 341 /// And `fg-*` colours belong to the entry glyph, so they're applied in 342 /// `drawLetter` rather than as a layer of their own. 343 private func drawDecorations( 344 _ cell: CellDraw, 345 in rect: CGRect, 346 textCache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)], 347 cache: inout [String: GraphicsContext.ResolvedImage], 348 context: GraphicsContext 349 ) { 350 for decoration in cell.decorations { 351 switch decoration.content { 352 case .mark(let special) where decoration.phase == .after: 353 switch special { 354 case .shaded: 355 context.fill(Path(rect), with: .color(.black.opacity(0.22))) 356 case .circled: 357 context.stroke( 358 Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)), 359 with: .color(.black.opacity(0.55)), 360 lineWidth: 1 361 ) 362 } 363 case .mark: 364 continue 365 case .color(.background, let light, let dark): 366 guard let color = Puzzle.Decoration.Content.color( 367 light: light, dark: dark, for: colorScheme 368 ) else { 369 continue 370 } 371 context.fill(Path(rect), with: .color(color)) 372 case .color: 373 continue 374 case .text(let value): 375 let key = "\(value)|\(cell.isBlock)" 376 let resolved: GraphicsContext.ResolvedText 377 let natural: CGSize 378 if let hit = textCache[key] { 379 resolved = hit.text 380 natural = hit.size 381 } else { 382 let text = context.resolve( 383 Text(value) 384 .font( 385 .system( 386 size: baseFontSize * 0.8, 387 weight: .semibold, 388 design: .rounded 389 ) 390 ) 391 .foregroundStyle(cell.isBlock ? Color.white : Color.black) 392 ) 393 let measured = text.measure( 394 in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity) 395 ) 396 textCache[key] = (text, measured) 397 resolved = text 398 natural = measured 399 } 400 guard natural.width > 0, natural.height > 0 else { continue } 401 let availableWidth = rect.width - 4 402 let scale = min(1, availableWidth / natural.width, rect.height / natural.height) 403 context.drawLayer { layer in 404 layer.translateBy(x: rect.midX, y: rect.midY) 405 layer.scaleBy(x: scale, y: scale) 406 layer.draw(resolved, at: .zero, anchor: .center) 407 } 408 case .data(_, _, let payload): 409 guard let image = decorationImages[payload] else { continue } 410 let resolved: GraphicsContext.ResolvedImage 411 if let hit = cache[payload] { 412 resolved = hit 413 } else { 414 resolved = context.resolve(image) 415 cache[payload] = resolved 416 } 417 context.draw(resolved, in: rect) 418 } 419 } 420 } 421 422 /// The entry colour override from an `fg-*` decoration, if one applies. 423 private func foregroundOverride(for cell: CellDraw) -> Color? { 424 for decoration in cell.decorations { 425 guard case .color(.foreground, let light, let dark) = decoration.content, 426 let color = Puzzle.Decoration.Content.color( 427 light: light, dark: dark, for: colorScheme 428 ) else { 429 continue 430 } 431 return color 432 } 433 return nil 434 } 435 436 /// Draws one entry centred in its cell, scaled down to fit exactly as the 437 /// former `.minimumScaleFactor(0.1)` did: the largest size ≤ base that fits 438 /// the cell minus the 2pt horizontal padding. Single letters never scale (a 439 /// 4-char rebus does), and the per-cell scale is pure arithmetic on one 440 /// cached measurement. 441 private func drawLetter( 442 _ cell: CellDraw, 443 in rect: CGRect, 444 cache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)], 445 context: GraphicsContext 446 ) { 447 let foreground = foregroundOverride(for: cell) 448 // The colour joins the cache key: two cells can share an entry and 449 // differ only in an `fg-*` decoration, and caching on the entry alone 450 // would paint the second in the first one's colour. 451 let key = "\(cell.entry)|\(cell.isPencil)|\(foreground.map(String.init(describing:)) ?? "")" 452 let resolved: GraphicsContext.ResolvedText 453 let natural: CGSize 454 if let hit = cache[key] { 455 resolved = hit.text 456 natural = hit.size 457 } else { 458 let color: Color = foreground ?? (cell.isPencil ? .black.opacity(0.5) : .black) 459 let text = context.resolve( 460 Text(cell.entry) 461 .font(.system(size: baseFontSize, weight: .semibold, design: .rounded)) 462 .foregroundStyle(color) 463 ) 464 let measured = text.measure(in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity)) 465 cache[key] = (text, measured) 466 resolved = text 467 natural = measured 468 } 469 guard natural.width > 0, natural.height > 0 else { return } 470 let availableWidth = rect.width - 4 // matches .padding(.horizontal, 2) 471 let scale = min(1, availableWidth / natural.width, rect.height / natural.height) 472 context.drawLayer { layer in 473 layer.translateBy(x: rect.midX, y: rect.midY) 474 layer.scaleBy(x: scale, y: scale) 475 layer.draw(resolved, at: .zero, anchor: .center) 476 } 477 } 478 479 /// Strokes the passive cross-reference hatching for one cell, reusing the 480 /// `CrossRefLines` lattice so lines stay continuous across cell borders. 481 /// The path is generated in cell-local space, then clipped to the cell and 482 /// translated into place. 483 private func drawCrossRef( 484 _ pattern: CrossRefPattern, 485 in rect: CGRect, 486 row: Int, 487 col: Int, 488 context: GraphicsContext 489 ) { 490 let ink = Color.black.opacity(0.20) 491 let localRect = CGRect(x: 0, y: 0, width: rect.width, height: rect.height) 492 func stroke(_ slope: CrossRefLines.Slope) { 493 let path = CrossRefLines(slope: slope, row: row, col: col, spacing: spacing) 494 .path(in: localRect) 495 var layer = context 496 layer.clip(to: Path(rect)) 497 layer.translateBy(x: rect.minX, y: rect.minY) 498 layer.stroke(path, with: .color(ink), lineWidth: 1) 499 } 500 switch pattern { 501 case .diagonalDown: stroke(.down) 502 case .diagonalUp: stroke(.up) 503 case .crosshatch: stroke(.down); stroke(.up) 504 case .horizontal: stroke(.horizontal) 505 case .vertical: stroke(.vertical) 506 } 507 } 508 } 509 510 // MARK: - Layout 511 512 private enum PuzzleGridMetrics { 513 static func cellSize( 514 for proposal: ProposedViewSize, 515 columns: Int, 516 rows: Int, 517 spacing: CGFloat 518 ) -> CGFloat { 519 cellSize( 520 availableWidth: proposal.width, 521 availableHeight: proposal.height, 522 columns: columns, 523 rows: rows, 524 spacing: spacing 525 ) 526 } 527 528 static func cellSize( 529 availableWidth: CGFloat?, 530 availableHeight: CGFloat?, 531 columns: Int, 532 rows: Int, 533 spacing: CGFloat 534 ) -> CGFloat { 535 let cols = CGFloat(columns) 536 let rs = CGFloat(rows) 537 let width = availableWidth ?? .infinity 538 let height = availableHeight ?? .infinity 539 let widthBased = width.isFinite 540 ? (width - spacing * (cols + 1)) / cols 541 : .infinity 542 let heightBased = height.isFinite 543 ? (height - spacing * (rs + 1)) / rs 544 : .infinity 545 return max(0, min(widthBased, heightBased)) 546 } 547 } 548 549 /// Stacks the grid's backing layers and cell layout into one measured surface. 550 /// Using `.background` for the Canvases can let SwiftUI hand those layers a 551 /// different size from the custom cell layout on iPad, which makes their 552 /// derived rects drift. This layout makes the Canvases draw from the same 553 /// surface size and places the cell grid at the exact rect derived from it. 554 private struct PuzzleGridLayerLayout: Layout { 555 let columns: Int 556 let rows: Int 557 let spacing: CGFloat 558 559 func sizeThatFits( 560 proposal: ProposedViewSize, 561 subviews: Subviews, 562 cache: inout () 563 ) -> CGSize { 564 let cellSize = PuzzleGridMetrics.cellSize( 565 for: proposal, 566 columns: columns, 567 rows: rows, 568 spacing: spacing 569 ) 570 let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 571 let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 572 return CGSize(width: width, height: height) 573 } 574 575 func placeSubviews( 576 in bounds: CGRect, 577 proposal: ProposedViewSize, 578 subviews: Subviews, 579 cache: inout () 580 ) { 581 guard let cellGrid = subviews.last else { return } 582 let geometry = PuzzleGridGeometry( 583 size: bounds.size, 584 columns: columns, 585 rows: rows, 586 spacing: spacing 587 ) 588 let layerProposal = ProposedViewSize(width: bounds.width, height: bounds.height) 589 for subview in subviews.dropLast() { 590 subview.place( 591 at: CGPoint(x: bounds.minX, y: bounds.minY), 592 anchor: .topLeading, 593 proposal: layerProposal 594 ) 595 } 596 // The view builder must emit backing layers first and the cell grid 597 // last; the cell grid is the only subview placed on the tight grid rect. 598 let cellGridRect = geometry.gridRect.offsetBy(dx: bounds.minX, dy: bounds.minY) 599 cellGrid.place( 600 at: cellGridRect.origin, 601 anchor: .topLeading, 602 proposal: ProposedViewSize(width: cellGridRect.width, height: cellGridRect.height) 603 ) 604 } 605 } 606 607 /// Shared cell geometry for the puzzle grid: given a container `size`, computes 608 /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then 609 /// hands back the frame of any `(row, col)`. `PuzzleCellsLayer` and the backing 610 /// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all 611 /// derive their cell rects from this, so every layer stays pixel-aligned; it 612 /// also inverts the mapping (`cell(at:)`) for the grid's single tap recogniser. 613 /// Internal (not private) so `GridAccessibility` can frame its synthetic 614 /// VoiceOver elements on the same lattice. 615 struct PuzzleGridGeometry { 616 let cellSize: CGFloat 617 let spacing: CGFloat 618 let gridSize: CGSize 619 private let columns: Int 620 private let rows: Int 621 private let originX: CGFloat 622 private let originY: CGFloat 623 624 init(size: CGSize, columns: Int, rows: Int, spacing: CGFloat) { 625 let cellSize = PuzzleGridMetrics.cellSize( 626 for: ProposedViewSize(width: size.width, height: size.height), 627 columns: columns, 628 rows: rows, 629 spacing: spacing 630 ) 631 let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 632 let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 633 self.cellSize = cellSize 634 self.spacing = spacing 635 self.columns = columns 636 self.rows = rows 637 self.gridSize = CGSize(width: gridWidth, height: gridHeight) 638 self.originX = (size.width - gridWidth) / 2 639 self.originY = (size.height - gridHeight) / 2 640 } 641 642 var gridRect: CGRect { 643 CGRect(origin: CGPoint(x: originX, y: originY), size: gridSize) 644 } 645 646 func cellRect(row: Int, col: Int) -> CGRect { 647 CGRect( 648 x: originX + spacing + CGFloat(col) * (cellSize + spacing), 649 y: originY + spacing + CGFloat(row) * (cellSize + spacing), 650 width: cellSize, 651 height: cellSize 652 ) 653 } 654 655 /// Inverse of `cellRect`: maps a tap point (in the grid's coordinate space) 656 /// to the cell it falls in. Returns `nil` for points outside the grid (the 657 /// centring margin / black border). The uniform lattice makes this pure 658 /// arithmetic — the reason a single grid-wide tap recogniser can replace a 659 /// per-cell one. Points landing in an inter-cell gap clamp to the nearest 660 /// cell so the whole grid surface is tappable. 661 func cell(at point: CGPoint) -> (row: Int, col: Int)? { 662 guard gridRect.contains(point) else { return nil } 663 let stride = cellSize + spacing 664 guard stride > 0 else { return nil } 665 let col = min(max(Int((point.x - originX - spacing) / stride), 0), columns - 1) 666 let row = min(max(Int((point.y - originY - spacing) / stride), 0), rows - 1) 667 return (row, col) 668 } 669 } 670 671 // MARK: - Backing layers 672 673 /// The opaque white cell backdrop, drawn behind the (clear-based) cells and 674 /// beneath the peer cursor tints. Depends only on the puzzle's block layout, so 675 /// it is static for the life of the game and never repaints during play. Blocks 676 /// are skipped, leaving the container's black to show through. 677 private struct GridBackdrop: View { 678 let puzzle: Puzzle 679 let spacing: CGFloat 680 681 var body: some View { 682 Canvas { context, size in 683 let geometry = PuzzleGridGeometry( 684 size: size, 685 columns: puzzle.width, 686 rows: puzzle.height, 687 spacing: spacing 688 ) 689 context.fill(Path(geometry.gridRect), with: .color(.black)) 690 for r in 0..<puzzle.height { 691 for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock { 692 context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white)) 693 } 694 } 695 } 696 .allowsHitTesting(false) 697 } 698 } 699 700 /// Every present peer's selected answer, filled with that peer's selection 701 /// colour so the track reads as a coloured run; the exact focused square is 702 /// intentionally local-only. This is the only layer that observes the 703 /// high-frequency `roster.remoteSelections` stream, so a peer cursor move 704 /// repaints just this Canvas — drawing a handful of word cells — instead of 705 /// re-evaluating the cell grid above. 706 private struct RemoteCursorTints: View { 707 let roster: PlayerRoster 708 let puzzle: Puzzle 709 let spacing: CGFloat 710 711 var body: some View { 712 let tints = remoteTrackTints() 713 Canvas { context, size in 714 guard !tints.isEmpty else { return } 715 let geometry = PuzzleGridGeometry( 716 size: size, 717 columns: puzzle.width, 718 rows: puzzle.height, 719 spacing: spacing 720 ) 721 for (pos, color) in tints { 722 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) 723 } 724 } 725 .allowsHitTesting(false) 726 } 727 728 /// Resolves each peer's cursor track to a per-cell fill colour, latest 729 /// write winning where two peers' words overlap a cell. 730 private func remoteTrackTints() -> [GridPosition: Color] { 731 var tint: [GridPosition: (Date, Color)] = [:] 732 for (_, sel) in roster.remoteSelections { 733 for cell in puzzle.wordCells( 734 atRow: sel.row, col: sel.col, direction: sel.direction 735 ) { 736 let pos = GridPosition(row: cell.row, col: cell.col) 737 if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true { 738 tint[pos] = (sel.updatedAt, sel.color.selectionFill) 739 } 740 } 741 } 742 return tint.mapValues { $0.1 } 743 } 744 } 745 746 /// The local player's own cursor: the focused square (selection fill), the rest 747 /// of the focused word (highlight fill), and a border on cross-referenced cells 748 /// related to the focus. Like `RemoteCursorTints`, this is the only view that 749 /// reads `session`'s selection, so a local cursor move repaints just this Canvas 750 /// — a handful of cells — instead of re-evaluating the 441-cell grid above. In 751 /// replay it draws only the single playhead cell in the acting author's colour; 752 /// the live selection is suppressed. Sits above `RemoteCursorTints` so the local 753 /// cursor reads over a peer's track where they overlap. 754 private struct LocalCursorTints: View { 755 let session: PlayerSession 756 let spacing: CGFloat 757 let isReplaying: Bool 758 let replayCursor: GridPosition? 759 let replayPlayheadTint: Color? 760 761 @Environment(PlayerPreferences.self) private var preferences 762 763 var body: some View { 764 let fills = cellFills() 765 let borders = isReplaying ? [] : relatedBorderCells() 766 let borderColor = preferences.color.highlightFill 767 // The focused square is repainted from white before its selection fill 768 // so the author tint in the layer beneath never stacks with it: the 769 // cursor keeps one fixed colour whether or not the square is filled, 770 // while the rest of the word still blends with author tints. Live play 771 // only — the replay playhead keeps the plain translucent fill. 772 let selectedCell: GridPosition? = isReplaying 773 ? nil 774 : GridPosition(row: session.selectedRow, col: session.selectedCol) 775 Canvas { context, size in 776 guard !fills.isEmpty || !borders.isEmpty else { return } 777 let geometry = PuzzleGridGeometry( 778 size: size, 779 columns: session.puzzle.width, 780 rows: session.puzzle.height, 781 spacing: spacing 782 ) 783 if let pos = selectedCell { 784 context.fill( 785 Path(geometry.cellRect(row: pos.row, col: pos.col)), 786 with: .color(.white) 787 ) 788 } 789 for (pos, color) in fills { 790 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) 791 } 792 for pos in borders { 793 // Inset by half the line width so the stroke sits inside the 794 // cell, matching the in-cell `strokeBorder` it replaces. 795 let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5) 796 context.stroke(Path(rect), with: .color(borderColor), lineWidth: 3) 797 } 798 } 799 .allowsHitTesting(false) 800 } 801 802 /// The selection/highlight fills. In replay this is just the playhead cell in 803 /// the acting author's colour; live, it's the focused word in the highlight 804 /// fill with the focused square overridden to the stronger selection fill. 805 private func cellFills() -> [GridPosition: Color] { 806 if isReplaying { 807 guard let replayCursor, let replayPlayheadTint else { return [:] } 808 return [replayCursor: replayPlayheadTint] 809 } 810 let color = preferences.color 811 var fills: [GridPosition: Color] = [:] 812 for cell in session.puzzle.wordCells( 813 atRow: session.selectedRow, 814 col: session.selectedCol, 815 direction: session.direction 816 ) { 817 fills[GridPosition(row: cell.row, col: cell.col)] = color.highlightFill 818 } 819 fills[GridPosition(row: session.selectedRow, col: session.selectedCol)] = color.selectionFill 820 return fills 821 } 822 823 private func relatedBorderCells() -> [GridPosition] { 824 Array(session.puzzle.relatedCells( 825 atRow: session.selectedRow, 826 col: session.selectedCol, 827 direction: session.direction 828 )) 829 } 830 } 831 832 /// Fading author-coloured borders on cells a peer filled or cleared since this 833 /// player last viewed the puzzle. The set is captured once on the open arm beat 834 /// (`PlayerSession.recentChanges`, populated by `PuzzleView`); this layer reveals 835 /// it and fades it out when the player's first interaction clears the set. Like 836 /// the cursor-tint layers it reads only its own slice of `session`, so it is the 837 /// only view that repaints when the change set changes. Drawn over the cursor 838 /// tints but behind the cells, matching `LocalCursorTints`' related-cell borders. 839 private struct RecentChangeBorders: View { 840 let session: PlayerSession 841 let roster: PlayerRoster 842 let spacing: CGFloat 843 844 /// The resolved strokes currently drawn. Held locally so they stay on 845 /// screen through the fade-out after `recentChanges` is cleared. 846 @State private var shown: [GridPosition: Color] = [:] 847 @State private var visible = false 848 849 var body: some View { 850 Canvas { context, size in 851 guard !shown.isEmpty else { return } 852 let geometry = PuzzleGridGeometry( 853 size: size, 854 columns: session.puzzle.width, 855 rows: session.puzzle.height, 856 spacing: spacing 857 ) 858 for (pos, color) in shown { 859 let rect = geometry.cellRect(row: pos.row, col: pos.col) 860 let path = Path(rect.insetBy(dx: 1.5, dy: 1.5)) 861 context.stroke( 862 path, 863 with: .color(color.opacity(0.10)), 864 lineWidth: 6 865 ) 866 context.stroke( 867 path, 868 with: .color(color.opacity(0.30)), 869 lineWidth: 2 870 ) 871 } 872 } 873 .opacity(visible ? 1 : 0) 874 .animation(.easeOut(duration: 0.45), value: visible) 875 .allowsHitTesting(false) 876 .onAppear { apply(session.recentChanges) } 877 .onChange(of: session.recentChanges) { _, changes in apply(changes) } 878 } 879 880 private func apply(_ changes: [GridPosition: String]) { 881 if changes.isEmpty { 882 // Fade out, then drop the strokes once the animation has finished so 883 // they remain visible while the opacity animates down. 884 visible = false 885 Task { 886 try? await Task.sleep(for: .milliseconds(500)) 887 if session.recentChanges.isEmpty { shown = [:] } 888 } 889 } else { 890 shown = resolve(changes) 891 visible = true 892 } 893 } 894 895 /// Maps each changed cell to its writer's colour, dropping out-of-bounds or 896 /// block positions defensively. A writer no longer in the roster falls back 897 /// to a neutral border rather than being skipped. 898 private func resolve(_ changes: [GridPosition: String]) -> [GridPosition: Color] { 899 let colorByAuthor = Dictionary( 900 roster.entries.map { ($0.authorID, $0.color.tint) }, 901 uniquingKeysWith: { first, _ in first } 902 ) 903 let width = session.puzzle.width 904 let height = session.puzzle.height 905 var result: [GridPosition: Color] = [:] 906 for (pos, authorID) in changes { 907 guard pos.row >= 0, pos.row < height, pos.col >= 0, pos.col < width else { continue } 908 guard !session.puzzle.cells[pos.row][pos.col].isBlock else { continue } 909 result[pos] = colorByAuthor[authorID] ?? Color.secondary 910 } 911 return result 912 } 913 }