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:
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]
}
}