crossmate

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

commit a631522fb2a7be0fdd1ccafe2b4f33ef63c0e2b4
parent 1d007f7432898632dc45c6a7d12513c84ec29076
Author: Michael Camilleri <[email protected]>
Date:   Mon, 18 May 2026 13:20:16 +0900

Spread the player palette and keep collaborators off similar hues

This commit drops brown and teal, indigo and a rose pink added, giving nine colours
ordered by hue (also the Change Colour picker order). Pink uses a custom tint
rather than SwiftUI's .pink, which sits near 350° and reads as a second red.

Each PlayerColor now carries an intended `hue` in degrees — hand-assigned, not
derived from `tint`, so it stays a pure value (resolving a Color to RGB needs a
trait environment). stableColor's probe now requires a candidate to be more
than similarHueThreshold (90°, sized so a reserved blue also rules out green at
≈85°) from every reserved hue, not merely absent from the reserved set. Since
PlayerRoster threads each assigned friend's colour into `taken`, friends are
spaced apart from each other this way too, not only from the local user.

The probe degrades in two pure steps when the palette can't satisfy the
spacing: relax to exact-match avoidance (the old behaviour), then reuse the
hashed base colour — so every device still derives the same colour with no
persistence or sync. There is no migration: friend colours shift once on this
build (authorID is hashed over palette.count, now 7→9) and anyone who had brown
selected falls back to blue, since color(for:) already defaults unknown ids.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Models/PlayerColor.swift | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
1 file changed, 63 insertions(+), 19 deletions(-)

diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -8,6 +8,13 @@ struct PlayerColor: Sendable, Identifiable, Hashable { let name: String let tint: Color + /// The colour's *intended* position on the hue wheel, in degrees `0..<360`. + /// Hand-assigned rather than derived from `tint` so it stays a pure value + /// (resolving a SwiftUI `Color` to RGB needs a trait environment). Used + /// only by `stableColor` to keep a participant clear of colours that are + /// perceptually similar to one already taken — not just exact matches. + let hue: Double + /// Opacity used when this player's cell is the active selection. var selectedOpacity: Double { 0.55 } @@ -23,20 +30,33 @@ struct PlayerColor: Sendable, Identifiable, Hashable { } extension PlayerColor { - static let red = PlayerColor(id: "red", name: "Red", tint: .red) - static let orange = PlayerColor(id: "orange", name: "Orange", tint: .orange) - static let yellow = PlayerColor(id: "yellow", name: "Yellow", tint: .yellow) - static let green = PlayerColor(id: "green", name: "Green", tint: .green) - static let blue = PlayerColor(id: "blue", name: "Blue", tint: .blue) - static let purple = PlayerColor(id: "purple", name: "Purple", tint: .purple) - static let brown = PlayerColor( - id: "brown", - name: "Brown", - tint: Color(red: 0.76, green: 0.60, blue: 0.42) + static let red = PlayerColor(id: "red", name: "Red", tint: .red, hue: 0) + static let orange = PlayerColor(id: "orange", name: "Orange", tint: .orange, hue: 30) + static let yellow = PlayerColor(id: "yellow", name: "Yellow", tint: .yellow, hue: 50) + static let green = PlayerColor(id: "green", name: "Green", tint: .green, hue: 130) + static let teal = PlayerColor(id: "teal", name: "Teal", tint: .teal, hue: 185) + static let blue = PlayerColor(id: "blue", name: "Blue", tint: .blue, hue: 215) + static let indigo = PlayerColor(id: "indigo", name: "Indigo", tint: .indigo, hue: 250) + static let purple = PlayerColor(id: "purple", name: "Purple", tint: .purple, hue: 285) + /// A rose pink at ~330°. SwiftUI's system pink sits near 350° and reads as + /// a second red, so a custom tint keeps it distinct from `red`. + static let pink = PlayerColor( + id: "pink", + name: "Pink", + tint: Color(red: 0.92, green: 0.32, blue: 0.62), + hue: 330 ) - /// Order in which colours appear in any picker UI. - static let palette: [PlayerColor] = [.red, .orange, .yellow, .green, .blue, .purple, .brown] + /// Order in which colours appear in any picker UI — sorted by hue. + static let palette: [PlayerColor] = [ + .red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple, .pink, + ] + + /// Two colours whose hues are within this many degrees of each other are + /// treated as perceptually similar by `stableColor`, so a participant is + /// kept clear of them — not only exact matches. Sized so a reserved blue + /// also rules out green (≈85° away), per the desired behaviour. + static let similarHueThreshold: Double = 90 /// Look up a colour by its stored identifier, falling back to blue. static func color(for id: String) -> PlayerColor { @@ -54,21 +74,45 @@ extension PlayerColor { return Int(hash % UInt32(count)) } + /// Circular distance between two hues, in degrees `0...180`. + static func hueDistance(_ a: Double, _ b: Double) -> Double { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + return min(d, 360 - d) + } + /// Deterministic highlight colour for a participant: hash `authorID` into - /// the palette, then linear-probe forward past any colour already in - /// `reserved`. Being a pure function of its inputs, every device and every - /// game derives the same colour for a friend with no persistence or sync — - /// the friend stays one colour across games, and changing the local user's - /// preferred colour (passed in `reserved`) deterministically bumps any - /// friend that collided with it. Falls back to the base colour only when - /// the palette is exhausted (8+ participants). + /// the palette, then linear-probe forward for a colour that is not just + /// absent from `reserved` but also more than `similarHueThreshold` degrees + /// away from every reserved colour's hue — so a participant never lands on + /// a colour that looks like one already taken (a reserved blue also rules + /// out green, etc.). Being a pure function of its inputs, every device and + /// every game derives the same colour for a friend with no persistence or + /// sync — the friend stays one colour across games, and changing the local + /// user's preferred colour (passed in `reserved`) deterministically bumps + /// any friend that now collides or clashes with it. + /// + /// Degrades in two steps when the palette can't satisfy the spacing: first + /// relax to avoiding only the exact reserved colours (the old behaviour), + /// then, if every colour is reserved, reuse the hashed base colour. Both + /// fallbacks stay pure, so the guarantee above still holds. static func stableColor(forAuthorID authorID: String, reserved: Set<String>) -> PlayerColor { let n = palette.count let base = stableIndex(for: authorID, count: n) + let reservedHues = palette.filter { reserved.contains($0.id) }.map(\.hue) + + // Pass 1: clear of every reserved hue (distance 0 covers exact matches). + for step in 0..<n { + let candidate = palette[(base + step) % n] + if reservedHues.allSatisfy({ hueDistance($0, candidate.hue) > similarHueThreshold }) { + return candidate + } + } + // Pass 2: palette too tight for hue spacing — avoid exact matches only. for step in 0..<n { let candidate = palette[(base + step) % n] if !reserved.contains(candidate.id) { return candidate } } + // Pass 3: everything reserved — reuse the hashed base colour. return palette[base] } }