crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }