PlayerColor.swift (11717B)
1 import SwiftUI 2 3 /// A player's chosen highlight colour. Each player picks one of these so that 4 /// — once collaborative play is wired up — both players' selections can be 5 /// shown side-by-side without ambiguity. 6 struct PlayerColor: Sendable, Identifiable, Hashable { 7 let id: String 8 let name: String 9 let tint: Color 10 11 /// The colour's *intended* position on the hue wheel, in degrees `0..<360`. 12 /// Hand-assigned rather than derived from `tint` so it stays a pure value 13 /// (resolving a SwiftUI `Color` to RGB needs a trait environment). Used 14 /// only by `assignedColor` to keep a participant clear of colours that are 15 /// perceptually similar to one already taken — not just exact matches. 16 let hue: Double 17 18 /// Opacity used when this player's cell is the active selection. 19 var selectedOpacity: Double { 0.70 } 20 21 /// Opacity used for the rest of this player's active word. 22 var highlightedOpacity: Double { 0.40 } 23 24 /// Opacity used for faint author-attribution tinting. 25 static let authorTintOpacity = 0.16 26 27 /// Fill for UI tied to this player's active selection (the selected cell). 28 var selectionFill: Color { tint.opacity(selectedOpacity) } 29 30 /// Fill for UI tied to this player's passive highlight — the rest of the 31 /// active word. 32 var highlightFill: Color { tint.opacity(highlightedOpacity) } 33 34 /// Fill for UI tied to faint author-attribution tinting. 35 var authorTintFill: Color { tint.opacity(Self.authorTintOpacity) } 36 37 /// Background wash for the clue bar. The clue bar composites over the 38 /// system background, so unlike the always-white grid cells the wash's 39 /// perceived colour depends on the appearance: at the faint author-tint 40 /// opacity over a near-black Dark Mode background the hue reads as black. 41 /// Raise the opacity in Dark Mode so the player's colour stays visible. 42 func clueBarFill(dark: Bool) -> Color { 43 tint.opacity(dark ? 0.20 : Self.authorTintOpacity) 44 } 45 } 46 47 extension PlayerColor { 48 static let red = PlayerColor(id: "red", name: "Red", tint: .red, hue: 0) 49 static let orange = PlayerColor(id: "orange", name: "Orange", tint: .orange, hue: 30) 50 static let yellow = PlayerColor(id: "yellow", name: "Yellow", tint: .yellow, hue: 50) 51 static let green = PlayerColor(id: "green", name: "Green", tint: .green, hue: 130) 52 static let teal = PlayerColor(id: "teal", name: "Teal", tint: .teal, hue: 185) 53 static let blue = PlayerColor(id: "blue", name: "Blue", tint: .blue, hue: 215) 54 static let indigo = PlayerColor(id: "indigo", name: "Indigo", tint: .indigo, hue: 250) 55 static let purple = PlayerColor(id: "purple", name: "Purple", tint: .purple, hue: 285) 56 /// A rose pink at ~330°. SwiftUI's system pink sits near 350° and reads as 57 /// a second red, so a custom tint keeps it distinct from `red`. 58 static let pink = PlayerColor( 59 id: "pink", 60 name: "Pink", 61 tint: Color(red: 0.92, green: 0.32, blue: 0.62), 62 hue: 330 63 ) 64 65 /// Order in which colours appear in any picker UI — sorted by hue. 66 static let palette: [PlayerColor] = [ 67 .red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple, .pink, 68 ] 69 70 /// Two colours whose hues are within this many degrees of each other are 71 /// treated as perceptually similar by `assignedColor`, so a participant is 72 /// kept clear of them — not only exact matches. Sized so a reserved blue 73 /// also rules out green (≈85° away), per the desired behaviour. 74 static let similarHueThreshold: Double = 90 75 76 /// Hand-curated companion palettes keyed by the *anchor* — the local 77 /// user's colour. Each anchor maps to a few ordered *options*; `gameID` 78 /// selects one, so a game's colours still vary game to game, while every 79 /// option is vetted to look good. The first `n` ids of an option cover an 80 /// `(n + 1)`-player game (anchor + `n` companions), so the ordering is 81 /// chosen to keep the 2- and 3-player prefixes well spaced too. 82 /// 83 /// Spacing was tuned on the *desaturated filled-cell wash* (tint at 84 /// `authorTintOpacity` over white) — where colours are hardest to tell 85 /// apart — using perceptual (OKLab ΔE) distance, not raw hue. That is why 86 /// pairs that look distinct at full saturation but collapse when washed 87 /// out (e.g. red/pink) never share an option. Built for up to four 88 /// players; larger games fall back to `assignedColor` for the surplus. 89 static let companionTable: [String: [[String]]] = [ 90 "red": [["blue", "yellow", "green"], ["green", "indigo", "orange"], ["yellow", "teal", "indigo"]], 91 "orange": [["blue", "pink", "green"], ["green", "indigo", "red"], ["teal", "purple", "red"]], 92 "yellow": [["red", "blue", "green"], ["red", "teal", "indigo"], ["green", "indigo", "pink"]], 93 "green": [["red", "blue", "yellow"], ["blue", "pink", "orange"], ["red", "indigo", "orange"]], 94 "teal": [["red", "orange", "indigo"], ["red", "yellow", "purple"], ["yellow", "pink", "indigo"]], 95 "blue": [["red", "yellow", "green"], ["green", "pink", "orange"], ["red", "orange", "purple"]], 96 "indigo": [["red", "yellow", "green"], ["red", "orange", "teal"], ["orange", "green", "pink"]], 97 "purple": [["orange", "green", "red"], ["yellow", "teal", "red"], ["yellow", "green", "blue"]], 98 "pink": [["yellow", "blue", "green"], ["orange", "green", "indigo"], ["yellow", "teal", "indigo"]], 99 ] 100 101 /// Look up a colour by its stored identifier, falling back to blue. 102 static func color(for id: String) -> PlayerColor { 103 palette.first { $0.id == id } ?? .blue 104 } 105 106 /// Colours for the `authorIDs` collaborators in `gameID`, given the local 107 /// user's `anchor` colour. `authorIDs` is taken in the caller's stable 108 /// (sorted) order, and the returned array is aligned to it. 109 /// 110 /// Picks one curated option from `companionTable` for the anchor — seeded 111 /// by `gameID`, so the set varies between games — then rotates the handout 112 /// order by a second game-derived offset. That keeps each game's companion 113 /// set vetted while letting one-collaborator games use any colour in the 114 /// selected option instead of always taking the first slot. Any slots a 115 /// game needs beyond the option's length (or an anchor with no table 116 /// entry, which shouldn't happen for palette colours) fall back to the 117 /// legacy hue-spacing `assignedColor`, threaded so the surplus colours 118 /// stay distinct from the anchor and each other. 119 static func assignedCompanions( 120 forSortedAuthorIDs authorIDs: [String], 121 inGame gameID: UUID, 122 anchor: PlayerColor 123 ) -> [PlayerColor] { 124 let count = authorIDs.count 125 guard count > 0 else { return [] } 126 127 var colors: [PlayerColor] = [] 128 if let options = companionTable[anchor.id], !options.isEmpty { 129 let gameSeed = gameID.uuidString 130 let option = options[stableIndex(for: "\(gameSeed)-option", count: options.count)] 131 let offset = stableIndex(for: "\(gameSeed)-offset", count: option.count) 132 let rotatedOption = option.indices.map { option[($0 + offset) % option.count] } 133 colors = rotatedOption.prefix(count).map { color(for: $0) } 134 } 135 136 if colors.count < count { 137 var taken = Set(colors.map(\.id)) 138 taken.insert(anchor.id) 139 for authorID in authorIDs[colors.count..<count] { 140 let color = assignedColor(forAuthorID: authorID, inGame: gameID, reserved: taken) 141 taken.insert(color.id) 142 colors.append(color) 143 } 144 } 145 return colors 146 } 147 148 /// FNV-1a hash of `value` reduced into `0..<count`. A pure function, so 149 /// every process and device derives the same index for the same input. 150 /// Shared by avatar selection and stable colour assignment. 151 static func stableIndex(for value: String, count: Int) -> Int { 152 guard count > 0 else { return 0 } 153 let hash = value.utf8.reduce(UInt32(2_166_136_261)) { partial, byte in 154 (partial ^ UInt32(byte)) &* 16_777_619 155 } 156 return Int(hash % UInt32(count)) 157 } 158 159 /// Circular distance between two hues, in degrees `0...180`. 160 static func hueDistance(_ a: Double, _ b: Double) -> Double { 161 let d = abs(a - b).truncatingRemainder(dividingBy: 360) 162 return min(d, 360 - d) 163 } 164 165 /// Highlight colour assigned to a collaborator *within one game*: filter the 166 /// palette down to the colours that are not just absent from `reserved` but 167 /// also more than `similarHueThreshold` degrees away from every reserved 168 /// colour's hue — so a collaborator never lands on a colour that looks like 169 /// one already taken (a reserved blue also rules out green, etc.) — then 170 /// hash `authorID` together with `gameID` to pick one of the survivors. 171 /// 172 /// Picking by hashing *into the eligible set* (rather than hashing to a base 173 /// index and linear-probing forward through the full palette) keeps the 174 /// choice uniform across the colours that are actually allowed. Probing 175 /// forward biases heavily toward whichever eligible colour sits just past 176 /// the largest contiguous block of rejected ones: e.g. against a reserved 177 /// blue the spacing rule rejects green…purple as a block, so a forward probe 178 /// landed on pink ~67% of the time. Hashing into the survivors spreads it 179 /// evenly instead. 180 /// 181 /// Folding `gameID` into the seed makes the colour deliberately *unstable* 182 /// across games: the same friend reads as a different colour in each game, 183 /// so a colour is felt as "whoever is in this grid" rather than a fixed 184 /// badge for a person. Callers thread the colours already handed out in the 185 /// game through `reserved`, so collaborators within a single game stay as 186 /// distinct as the palette allows. The local user is never assigned this 187 /// way — their colour is their stable preference, passed in via `reserved`. 188 /// 189 /// Degrades in two steps when the palette can't satisfy the spacing: first 190 /// relax to avoiding only the exact reserved colours, then, if every colour 191 /// is reserved, reuse the hashed base colour. 192 static func assignedColor( 193 forAuthorID authorID: String, 194 inGame gameID: UUID, 195 reserved: Set<String> 196 ) -> PlayerColor { 197 let seed = "\(gameID.uuidString)-\(authorID)" 198 let reservedHues = palette.filter { reserved.contains($0.id) }.map(\.hue) 199 200 // Pass 1: clear of every reserved hue (distance 0 covers exact matches). 201 let spaced = palette.filter { candidate in 202 reservedHues.allSatisfy { hueDistance($0, candidate.hue) > similarHueThreshold } 203 } 204 if let pick = pick(from: spaced, seed: seed) { return pick } 205 206 // Pass 2: palette too tight for hue spacing — avoid exact matches only. 207 let free = palette.filter { !reserved.contains($0.id) } 208 if let pick = pick(from: free, seed: seed) { return pick } 209 210 // Pass 3: everything reserved — reuse the hashed base colour. 211 return palette[stableIndex(for: seed, count: palette.count)] 212 } 213 214 /// Deterministically pick one colour from `candidates` by hashing `seed` 215 /// into the set. Returns `nil` when there is nothing to choose from. 216 private static func pick(from candidates: [PlayerColor], seed: String) -> PlayerColor? { 217 guard !candidates.isEmpty else { return nil } 218 return candidates[stableIndex(for: seed, count: candidates.count)] 219 } 220 }