GridView.swift (22763B)
1 import SwiftUI 2 3 struct GridView: View { 4 @Bindable var session: PlayerSession 5 let roster: PlayerRoster 6 let showsSharedAnnotations: Bool 7 /// Whether to render peers' live cursor tracks. Off for a solved puzzle — 8 /// the game is no longer a live session, so the other player's cursor is 9 /// dropped while author tints stay to colour the finished grid. 10 var showsPeerCursors: Bool = true 11 /// The finish-banner replay frame to render. When non-nil (the user has 12 /// scrubbed back from the end), the grid renders this reconstructed history 13 /// instead of the live `Game`: each touched cell shows its after-state, 14 /// blanks elsewhere. Live selection, word highlight, and taps are 15 /// suppressed, and the playhead cell is tinted in the acting author's 16 /// colour. `nil` (the default, or a scrubber at rest) leaves normal play. 17 var replayFrame: ReplayFrame? = nil 18 19 private let spacing: CGFloat = 1 20 21 var body: some View { 22 let width = session.puzzle.width 23 let height = session.puzzle.height 24 let replayCells = replayFrame?.cells 25 let isReplaying = replayFrame != nil 26 let replayCursor = replayFrame?.cursor 27 // Once the puzzle is solved, rebus squares show their canonical fill 28 // (e.g. a Schrödinger square reads "MTWA" rather than the "WA" the 29 // solver typed for the down answer). Purely a render-time substitution 30 // — the stored entry, its authorship, and marks are left untouched. 31 let showsCanonicalRebus = !isReplaying && session.game.completionState == .solved 32 // Peer cursor tints are rendered in a separate Canvas layer (see 33 // `RemoteCursorTints`) so this 441-cell grid no longer re-evaluates on 34 // every peer cursor move — only on local selection and letter changes. 35 let showsRemoteTints = showsSharedAnnotations && showsPeerCursors && !isReplaying 36 // Author colours are shown for shared live play and for any replay 37 // (the rewind reads as coloured per author, matching the scoreboard). 38 let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying) 39 ? Dictionary( 40 uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) } 41 ) 42 : [:] 43 // Colour for the replay playhead: the acting author's selection fill, 44 // so a rewound move reads in that player's colour (the local fill for 45 // our own moves). `nil` outside replay leaves the local cursor as-is. 46 let playheadTint: Color? = replayFrame?.cursorAuthorID 47 .flatMap { id in roster.entries.first { $0.authorID == id } } 48 .map { $0.color.selectionFill } 49 let patternPalette = CrossRefPattern.allCases 50 let cellGroups = session.puzzle.cellGroups 51 // Layered back to front: black (shows through the inter-cell gaps and 52 // behind blocks) -> white cell backdrop -> peer cursor tints -> local 53 // cursor tints -> cells. Keep these as siblings in one layout so 54 // iPad-sized proposals cannot give the backing Canvases a different 55 // drawing rect than the cells. The cursor layers each read their own 56 // selection state in their own body, so a cursor move repaints only a 57 // lightweight Canvas and never invalidates the cell `ForEach`. 58 PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) { 59 GridBackdrop(puzzle: session.puzzle, spacing: spacing) 60 if showsRemoteTints { 61 RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing) 62 } 63 LocalCursorTints( 64 session: session, 65 spacing: spacing, 66 isReplaying: isReplaying, 67 replayCursor: replayCursor, 68 replayPlayheadTint: playheadTint 69 ) 70 if !isReplaying { 71 RecentChangeBorders(session: session, roster: roster, spacing: spacing) 72 } 73 PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { 74 ForEach(0..<(width * height), id: \.self) { index in 75 let r = index / width 76 let c = index % width 77 let pos = GridPosition(row: r, col: c) 78 let cell = session.puzzle.cells[r][c] 79 let square = session.game.squares[r][c] 80 // During replay the cell's letter/mark/author come from the 81 // reconstructed history, not the live square. 82 let replayCell = replayCells?[pos] 83 let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry 84 let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry 85 let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark 86 let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID 87 CellView( 88 cell: cell, 89 entry: displayEntry, 90 mark: mark, 91 crossRefPattern: cellGroups[pos].map { 92 patternPalette[$0 % patternPalette.count] 93 }, 94 gridRow: r, 95 gridCol: c, 96 gridSpacing: spacing, 97 authorTint: entry.isEmpty 98 ? nil 99 : letterAuthorID.flatMap { authorTintByID[$0] } 100 ) 101 .equatable() 102 .onTapGesture { 103 guard !isReplaying else { return } 104 session.select(row: r, col: c) 105 } 106 } 107 } 108 } 109 } 110 111 /// The canonical fill to display for a rebus square on a solved puzzle, or 112 /// `nil` to fall back to the stored entry. Only multi-character solutions 113 /// (rebus/Schrödinger squares) differ from what the solver typed; ordinary 114 /// single-letter cells return `nil` and render their entry unchanged. 115 private func canonicalRebusFill(for cell: Puzzle.Cell, when isSolved: Bool) -> String? { 116 guard isSolved, let solution = cell.solution, solution.count > 1 else { return nil } 117 return solution 118 } 119 120 } 121 122 // MARK: - Layout 123 124 private enum PuzzleGridMetrics { 125 static func cellSize( 126 for proposal: ProposedViewSize, 127 columns: Int, 128 rows: Int, 129 spacing: CGFloat 130 ) -> CGFloat { 131 cellSize( 132 availableWidth: proposal.width, 133 availableHeight: proposal.height, 134 columns: columns, 135 rows: rows, 136 spacing: spacing 137 ) 138 } 139 140 static func cellSize( 141 availableWidth: CGFloat?, 142 availableHeight: CGFloat?, 143 columns: Int, 144 rows: Int, 145 spacing: CGFloat 146 ) -> CGFloat { 147 let cols = CGFloat(columns) 148 let rs = CGFloat(rows) 149 let width = availableWidth ?? .infinity 150 let height = availableHeight ?? .infinity 151 let widthBased = width.isFinite 152 ? (width - spacing * (cols + 1)) / cols 153 : .infinity 154 let heightBased = height.isFinite 155 ? (height - spacing * (rs + 1)) / rs 156 : .infinity 157 return max(0, min(widthBased, heightBased)) 158 } 159 } 160 161 /// Lays out puzzle cells in a `rows × columns` grid with a uniform square 162 /// cell size. The cell size is chosen to fit the proposed container, with 163 /// `spacing` points between every cell and around the outer edge (the black 164 /// grid border shows through the gaps). The laid-out grid reports its tight 165 /// size via `sizeThatFits`, so the parent view sizes us to exactly the grid 166 /// dimensions — no `.aspectRatio` modifier needed. 167 private struct PuzzleGridLayout: Layout { 168 let columns: Int 169 let rows: Int 170 let spacing: CGFloat 171 172 func sizeThatFits( 173 proposal: ProposedViewSize, 174 subviews: Subviews, 175 cache: inout () 176 ) -> CGSize { 177 let cellSize = PuzzleGridMetrics.cellSize( 178 for: proposal, 179 columns: columns, 180 rows: rows, 181 spacing: spacing 182 ) 183 let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 184 let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 185 return CGSize(width: width, height: height) 186 } 187 188 func placeSubviews( 189 in bounds: CGRect, 190 proposal: ProposedViewSize, 191 subviews: Subviews, 192 cache: inout () 193 ) { 194 let geometry = PuzzleGridGeometry( 195 size: bounds.size, 196 columns: columns, 197 rows: rows, 198 spacing: spacing 199 ) 200 let cellProposal = ProposedViewSize( 201 width: geometry.cellSize, 202 height: geometry.cellSize 203 ) 204 for (index, subview) in subviews.enumerated() { 205 let rect = geometry.cellRect(row: index / columns, col: index % columns) 206 subview.place( 207 at: CGPoint(x: bounds.minX + rect.minX, y: bounds.minY + rect.minY), 208 anchor: .topLeading, 209 proposal: cellProposal 210 ) 211 } 212 } 213 } 214 215 /// Stacks the grid's backing layers and cell layout into one measured surface. 216 /// Using `.background` for the Canvases can let SwiftUI hand those layers a 217 /// different size from the custom cell layout on iPad, which makes their 218 /// derived rects drift. This layout makes the Canvases draw from the same 219 /// surface size and places the cell grid at the exact rect derived from it. 220 private struct PuzzleGridLayerLayout: Layout { 221 let columns: Int 222 let rows: Int 223 let spacing: CGFloat 224 225 func sizeThatFits( 226 proposal: ProposedViewSize, 227 subviews: Subviews, 228 cache: inout () 229 ) -> CGSize { 230 let cellSize = PuzzleGridMetrics.cellSize( 231 for: proposal, 232 columns: columns, 233 rows: rows, 234 spacing: spacing 235 ) 236 let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 237 let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 238 return CGSize(width: width, height: height) 239 } 240 241 func placeSubviews( 242 in bounds: CGRect, 243 proposal: ProposedViewSize, 244 subviews: Subviews, 245 cache: inout () 246 ) { 247 guard let cellGrid = subviews.last else { return } 248 let geometry = PuzzleGridGeometry( 249 size: bounds.size, 250 columns: columns, 251 rows: rows, 252 spacing: spacing 253 ) 254 let layerProposal = ProposedViewSize(width: bounds.width, height: bounds.height) 255 for subview in subviews.dropLast() { 256 subview.place( 257 at: CGPoint(x: bounds.minX, y: bounds.minY), 258 anchor: .topLeading, 259 proposal: layerProposal 260 ) 261 } 262 // The view builder must emit backing layers first and the cell grid 263 // last; the cell grid is the only subview placed on the tight grid rect. 264 let cellGridRect = geometry.gridRect.offsetBy(dx: bounds.minX, dy: bounds.minY) 265 cellGrid.place( 266 at: cellGridRect.origin, 267 anchor: .topLeading, 268 proposal: ProposedViewSize(width: cellGridRect.width, height: cellGridRect.height) 269 ) 270 } 271 } 272 273 /// Shared cell geometry for the puzzle grid: given a container `size`, computes 274 /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then 275 /// hands back the frame of any `(row, col)`. `PuzzleGridLayout` and the backing 276 /// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all 277 /// derive their cell rects from this, so the layers stay pixel-aligned with the 278 /// cells above them. 279 private struct PuzzleGridGeometry { 280 let cellSize: CGFloat 281 let spacing: CGFloat 282 let gridSize: CGSize 283 private let originX: CGFloat 284 private let originY: CGFloat 285 286 init(size: CGSize, columns: Int, rows: Int, spacing: CGFloat) { 287 let cellSize = PuzzleGridMetrics.cellSize( 288 for: ProposedViewSize(width: size.width, height: size.height), 289 columns: columns, 290 rows: rows, 291 spacing: spacing 292 ) 293 let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 294 let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 295 self.cellSize = cellSize 296 self.spacing = spacing 297 self.gridSize = CGSize(width: gridWidth, height: gridHeight) 298 self.originX = (size.width - gridWidth) / 2 299 self.originY = (size.height - gridHeight) / 2 300 } 301 302 var gridRect: CGRect { 303 CGRect(origin: CGPoint(x: originX, y: originY), size: gridSize) 304 } 305 306 func cellRect(row: Int, col: Int) -> CGRect { 307 CGRect( 308 x: originX + spacing + CGFloat(col) * (cellSize + spacing), 309 y: originY + spacing + CGFloat(row) * (cellSize + spacing), 310 width: cellSize, 311 height: cellSize 312 ) 313 } 314 } 315 316 // MARK: - Backing layers 317 318 /// The opaque white cell backdrop, drawn behind the (clear-based) cells and 319 /// beneath the peer cursor tints. Depends only on the puzzle's block layout, so 320 /// it is static for the life of the game and never repaints during play. Blocks 321 /// are skipped, leaving the container's black to show through. 322 private struct GridBackdrop: View { 323 let puzzle: Puzzle 324 let spacing: CGFloat 325 326 var body: some View { 327 Canvas { context, size in 328 let geometry = PuzzleGridGeometry( 329 size: size, 330 columns: puzzle.width, 331 rows: puzzle.height, 332 spacing: spacing 333 ) 334 context.fill(Path(geometry.gridRect), with: .color(.black)) 335 for r in 0..<puzzle.height { 336 for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock { 337 context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white)) 338 } 339 } 340 } 341 .allowsHitTesting(false) 342 } 343 } 344 345 /// Every present peer's selected answer, filled with that peer's selection 346 /// colour so the track reads as a coloured run; the exact focused square is 347 /// intentionally local-only. This is the only layer that observes the 348 /// high-frequency `roster.remoteSelections` stream, so a peer cursor move 349 /// repaints just this Canvas — drawing a handful of word cells — instead of 350 /// re-evaluating the cell grid above. 351 private struct RemoteCursorTints: View { 352 let roster: PlayerRoster 353 let puzzle: Puzzle 354 let spacing: CGFloat 355 356 var body: some View { 357 let tints = remoteTrackTints() 358 Canvas { context, size in 359 guard !tints.isEmpty else { return } 360 let geometry = PuzzleGridGeometry( 361 size: size, 362 columns: puzzle.width, 363 rows: puzzle.height, 364 spacing: spacing 365 ) 366 for (pos, color) in tints { 367 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) 368 } 369 } 370 .allowsHitTesting(false) 371 } 372 373 /// Resolves each peer's cursor track to a per-cell fill colour, latest 374 /// write winning where two peers' words overlap a cell. 375 private func remoteTrackTints() -> [GridPosition: Color] { 376 var tint: [GridPosition: (Date, Color)] = [:] 377 for (_, sel) in roster.remoteSelections { 378 for cell in puzzle.wordCells( 379 atRow: sel.row, col: sel.col, direction: sel.direction 380 ) { 381 let pos = GridPosition(row: cell.row, col: cell.col) 382 if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true { 383 tint[pos] = (sel.updatedAt, sel.color.selectionFill) 384 } 385 } 386 } 387 return tint.mapValues { $0.1 } 388 } 389 } 390 391 /// The local player's own cursor: the focused square (selection fill), the rest 392 /// of the focused word (highlight fill), and a border on cross-referenced cells 393 /// related to the focus. Like `RemoteCursorTints`, this is the only view that 394 /// reads `session`'s selection, so a local cursor move repaints just this Canvas 395 /// — a handful of cells — instead of re-evaluating the 441-cell grid above. In 396 /// replay it draws only the single playhead cell in the acting author's colour; 397 /// the live selection is suppressed. Sits above `RemoteCursorTints` so the local 398 /// cursor reads over a peer's track where they overlap. 399 private struct LocalCursorTints: View { 400 let session: PlayerSession 401 let spacing: CGFloat 402 let isReplaying: Bool 403 let replayCursor: GridPosition? 404 let replayPlayheadTint: Color? 405 406 @Environment(PlayerPreferences.self) private var preferences 407 408 var body: some View { 409 let fills = cellFills() 410 let borders = isReplaying ? [] : relatedBorderCells() 411 let borderColor = preferences.color.highlightFill 412 Canvas { context, size in 413 guard !fills.isEmpty || !borders.isEmpty else { return } 414 let geometry = PuzzleGridGeometry( 415 size: size, 416 columns: session.puzzle.width, 417 rows: session.puzzle.height, 418 spacing: spacing 419 ) 420 for (pos, color) in fills { 421 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) 422 } 423 for pos in borders { 424 // Inset by half the line width so the stroke sits inside the 425 // cell, matching the in-cell `strokeBorder` it replaces. 426 let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5) 427 context.stroke(Path(rect), with: .color(borderColor), lineWidth: 3) 428 } 429 } 430 .allowsHitTesting(false) 431 } 432 433 /// The selection/highlight fills. In replay this is just the playhead cell in 434 /// the acting author's colour; live, it's the focused word in the highlight 435 /// fill with the focused square overridden to the stronger selection fill. 436 private func cellFills() -> [GridPosition: Color] { 437 if isReplaying { 438 guard let replayCursor, let replayPlayheadTint else { return [:] } 439 return [replayCursor: replayPlayheadTint] 440 } 441 let color = preferences.color 442 var fills: [GridPosition: Color] = [:] 443 for cell in session.puzzle.wordCells( 444 atRow: session.selectedRow, 445 col: session.selectedCol, 446 direction: session.direction 447 ) { 448 fills[GridPosition(row: cell.row, col: cell.col)] = color.highlightFill 449 } 450 fills[GridPosition(row: session.selectedRow, col: session.selectedCol)] = color.selectionFill 451 return fills 452 } 453 454 private func relatedBorderCells() -> [GridPosition] { 455 Array(session.puzzle.relatedCells( 456 atRow: session.selectedRow, 457 col: session.selectedCol, 458 direction: session.direction 459 )) 460 } 461 } 462 463 /// Fading author-coloured borders on cells a peer filled or cleared since this 464 /// player last viewed the puzzle. The set is captured once on the open arm beat 465 /// (`PlayerSession.recentChanges`, populated by `PuzzleView`); this layer reveals 466 /// it and fades it out when the player's first interaction clears the set. Like 467 /// the cursor-tint layers it reads only its own slice of `session`, so it is the 468 /// only view that repaints when the change set changes. Drawn over the cursor 469 /// tints but behind the cells, matching `LocalCursorTints`' related-cell borders. 470 private struct RecentChangeBorders: View { 471 let session: PlayerSession 472 let roster: PlayerRoster 473 let spacing: CGFloat 474 475 /// The resolved strokes currently drawn. Held locally so they stay on 476 /// screen through the fade-out after `recentChanges` is cleared. 477 @State private var shown: [GridPosition: Color] = [:] 478 @State private var visible = false 479 480 var body: some View { 481 Canvas { context, size in 482 guard !shown.isEmpty else { return } 483 let geometry = PuzzleGridGeometry( 484 size: size, 485 columns: session.puzzle.width, 486 rows: session.puzzle.height, 487 spacing: spacing 488 ) 489 for (pos, color) in shown { 490 let rect = geometry.cellRect(row: pos.row, col: pos.col) 491 let path = Path(rect.insetBy(dx: 1.5, dy: 1.5)) 492 context.stroke( 493 path, 494 with: .color(color.opacity(0.10)), 495 lineWidth: 6 496 ) 497 context.stroke( 498 path, 499 with: .color(color.opacity(0.30)), 500 lineWidth: 2 501 ) 502 } 503 } 504 .opacity(visible ? 1 : 0) 505 .animation(.easeOut(duration: 0.45), value: visible) 506 .allowsHitTesting(false) 507 .onAppear { apply(session.recentChanges) } 508 .onChange(of: session.recentChanges) { _, changes in apply(changes) } 509 } 510 511 private func apply(_ changes: [GridPosition: String]) { 512 if changes.isEmpty { 513 // Fade out, then drop the strokes once the animation has finished so 514 // they remain visible while the opacity animates down. 515 visible = false 516 Task { 517 try? await Task.sleep(for: .milliseconds(500)) 518 if session.recentChanges.isEmpty { shown = [:] } 519 } 520 } else { 521 shown = resolve(changes) 522 visible = true 523 } 524 } 525 526 /// Maps each changed cell to its writer's colour, dropping out-of-bounds or 527 /// block positions defensively. A writer no longer in the roster falls back 528 /// to a neutral border rather than being skipped. 529 private func resolve(_ changes: [GridPosition: String]) -> [GridPosition: Color] { 530 let colorByAuthor = Dictionary( 531 roster.entries.map { ($0.authorID, $0.color.tint) }, 532 uniquingKeysWith: { first, _ in first } 533 ) 534 let width = session.puzzle.width 535 let height = session.puzzle.height 536 var result: [GridPosition: Color] = [:] 537 for (pos, authorID) in changes { 538 guard pos.row >= 0, pos.row < height, pos.col >= 0, pos.col < width else { continue } 539 guard !session.puzzle.cells[pos.row][pos.col].isBlock else { continue } 540 result[pos] = colorByAuthor[authorID] ?? Color.secondary 541 } 542 return result 543 } 544 }