commit 28968fea513baec6bad506f7101e36a00a601ee6
parent 79801038b2b43fd684747bf9d1818c34bad71ba3
Author: Michael Camilleri <[email protected]>
Date: Fri, 26 Jun 2026 09:22:57 +0900
Pick collaborator colours that stay distinct when washed out
In a shared puzzle every collaborator's letters carry a faint tint of
that player's colour, so the colours have to stay apart when rendered
that way. The previous assignment maximised distance around the hue
wheel, but hue separation barely survives the low-opacity wash a
filled cell uses — with the local user on blue, the others could land
on red, purple and pink and read almost identically.
This commit assigns the colours from a curated companionTable keyed by
the local user's (anchor) colour. Each anchor maps to a few ordered
options whose sets were chosen on the desaturated filled-cell wash
using perceptual (OKLab) distance rather than raw hue, so pairs that
only diverge at full saturation never share a set. assignedCompanions
picks one option per game — seeded by the game id, so a set still
varies between games — and remoteParticipants uses it in place of the
per-player hue-spacing pick, which now only fills games larger than
four players.
The per-cell attribution opacity also rises from 0.10 to 0.16, since
at 0.10 even well-separated colours wash out to near-white before
their hue can register.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 160 insertions(+), 24 deletions(-)
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -65,25 +65,24 @@ enum ParticipantSummaries {
authorIDs.remove(localAuthorID)
}
- // Hand each collaborator a colour that is as distinct as the palette
- // allows: walk them in sorted-authorID order, seed `taken` with the
- // local user's (stable) colour, and thread each assignment back in so
- // the next collaborator avoids it. The colour is derived from authorID
- // *and* `gameID`, so it is distinct within the game but deliberately
- // differs from game to game — a colour reads as "who is in this grid",
- // never a permanent badge for a person.
- var taken: Set<String> = [localColor.id]
+ // Hand the collaborators a curated, perceptually-spaced colour set
+ // anchored on the local user's (stable) colour: walk them in
+ // sorted-authorID order so the assignment is stable, and let
+ // `assignedCompanions` pick one game-specific option from the table.
+ // The set is derived from `gameID`, so it is distinct within the game
+ // but deliberately differs from game to game — a colour reads as "who
+ // is in this grid", never a permanent badge for a person.
+ let sortedAuthorIDs = authorIDs.sorted()
+ let colors = PlayerColor.assignedCompanions(
+ forSortedAuthorIDs: sortedAuthorIDs,
+ inGame: gameID,
+ anchor: localColor
+ )
var summaries: [GameParticipantSummary] = []
- for authorID in authorIDs.sorted() {
+ for (authorID, color) in zip(sortedAuthorIDs, colors) {
let name = nicknamesByAuthor[authorID]
?? namesByAuthor[authorID]
?? "Waiting for player..."
- let color = PlayerColor.assignedColor(
- forAuthorID: authorID,
- inGame: gameID,
- reserved: taken
- )
- taken.insert(color.id)
summaries.append(GameParticipantSummary(
authorID: authorID,
name: name,
diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift
@@ -22,7 +22,7 @@ struct PlayerColor: Sendable, Identifiable, Hashable {
var highlightedOpacity: Double { 0.40 }
/// Opacity used for faint author-attribution tinting.
- static let authorTintOpacity = 0.10
+ static let authorTintOpacity = 0.16
/// Fill for UI tied to this player's active selection (the selected cell).
var selectionFill: Color { tint.opacity(selectedOpacity) }
@@ -36,7 +36,7 @@ struct PlayerColor: Sendable, Identifiable, Hashable {
/// Background wash for the clue bar. The clue bar composites over the
/// system background, so unlike the always-white grid cells the wash's
- /// perceived colour depends on the appearance: at the 10% author-tint
+ /// perceived colour depends on the appearance: at the faint author-tint
/// opacity over a near-black Dark Mode background the hue reads as black.
/// Raise the opacity in Dark Mode so the player's colour stays visible.
func clueBarFill(dark: Bool) -> Color {
@@ -73,11 +73,72 @@ extension PlayerColor {
/// also rules out green (≈85° away), per the desired behaviour.
static let similarHueThreshold: Double = 90
+ /// Hand-curated companion palettes keyed by the *anchor* — the local
+ /// user's colour. Each anchor maps to a few ordered *options*; `gameID`
+ /// selects one, so a game's colours still vary game to game, while every
+ /// option is vetted to look good. The first `n` ids of an option cover an
+ /// `(n + 1)`-player game (anchor + `n` companions), so the ordering is
+ /// chosen to keep the 2- and 3-player prefixes well spaced too.
+ ///
+ /// Spacing was tuned on the *desaturated filled-cell wash* (tint at
+ /// `authorTintOpacity` over white) — where colours are hardest to tell
+ /// apart — using perceptual (OKLab ΔE) distance, not raw hue. That is why
+ /// pairs that look distinct at full saturation but collapse when washed
+ /// out (e.g. red/pink) never share an option. Built for up to four
+ /// players; larger games fall back to `assignedColor` for the surplus.
+ static let companionTable: [String: [[String]]] = [
+ "red": [["blue", "yellow", "green"], ["green", "indigo", "orange"], ["yellow", "teal", "indigo"]],
+ "orange": [["blue", "pink", "green"], ["green", "indigo", "red"], ["teal", "purple", "red"]],
+ "yellow": [["red", "blue", "green"], ["red", "teal", "indigo"], ["green", "indigo", "pink"]],
+ "green": [["red", "blue", "yellow"], ["blue", "pink", "orange"], ["red", "indigo", "orange"]],
+ "teal": [["red", "orange", "indigo"], ["red", "yellow", "purple"], ["yellow", "pink", "indigo"]],
+ "blue": [["red", "yellow", "green"], ["green", "pink", "orange"], ["red", "orange", "purple"]],
+ "indigo": [["red", "yellow", "green"], ["red", "orange", "teal"], ["orange", "green", "pink"]],
+ "purple": [["orange", "green", "red"], ["yellow", "teal", "red"], ["yellow", "green", "blue"]],
+ "pink": [["yellow", "blue", "green"], ["orange", "green", "indigo"], ["yellow", "teal", "indigo"]],
+ ]
+
/// Look up a colour by its stored identifier, falling back to blue.
static func color(for id: String) -> PlayerColor {
palette.first { $0.id == id } ?? .blue
}
+ /// Colours for the `authorIDs` collaborators in `gameID`, given the local
+ /// user's `anchor` colour. `authorIDs` is taken in the caller's stable
+ /// (sorted) order, and the returned array is aligned to it.
+ ///
+ /// Picks one curated option from `companionTable` for the anchor — seeded
+ /// by `gameID`, so the set varies between games — and hands its companions
+ /// out in order. Any slots a game needs beyond the option's length (or an
+ /// anchor with no table entry, which shouldn't happen for palette colours)
+ /// fall back to the legacy hue-spacing `assignedColor`, threaded so the
+ /// surplus colours stay distinct from the anchor and each other.
+ static func assignedCompanions(
+ forSortedAuthorIDs authorIDs: [String],
+ inGame gameID: UUID,
+ anchor: PlayerColor
+ ) -> [PlayerColor] {
+ let count = authorIDs.count
+ guard count > 0 else { return [] }
+
+ var colors: [PlayerColor] = []
+ if let options = companionTable[anchor.id], !options.isEmpty {
+ let option = options[stableIndex(for: gameID.uuidString, count: options.count)]
+ colors = option.prefix(count).map { color(for: $0) }
+ }
+
+ if colors.count < count {
+ var taken = Set(colors.map(\.id))
+ taken.insert(anchor.id)
+ for authorID in authorIDs[colors.count..<count] {
+ let color = assignedColor(forAuthorID: authorID, inGame: gameID, reserved: taken)
+ taken.insert(color.id)
+ colors.append(color)
+ }
+ }
+ return colors
+ }
+
/// FNV-1a hash of `value` reduced into `0..<count`. A pure function, so
/// every process and device derives the same index for the same input.
/// Shared by avatar selection and stable colour assignment.
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -30,6 +30,11 @@ enum DemoSeed {
D3. Down 3 ~ CEH
"""
+ /// A bundled 15×15 used for the one demo game seeded with letters already
+ /// filled in, so the faint filled-cell author-attribution tints can be
+ /// compared in a realistic grid rather than a 3×3 toy.
+ private static let filledPuzzleResource = "cm-starter-0001"
+
/// The local user's authorID in demo mode, injected into `AuthorIdentity`
/// so the seeded crossmates classify as remote players. Kept distinct from
/// every crossmate id below.
@@ -61,15 +66,35 @@ enum DemoSeed {
title: "Tuesday Mini",
participants: ["_demo-alice", "_demo-bob"],
puzzle: puzzle,
+ source: puzzleSource,
in: ctx
)
seedGame(
title: "Sunday Giant",
participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
puzzle: puzzle,
+ source: puzzleSource,
in: ctx
)
+ // A full 15×15 with letters already filled in, attributed across all
+ // four players, so the desaturated filled-cell tints can be eyeballed
+ // in a real grid. Reuse the catalog the new-game picker uses; it
+ // resolves the bundled `.xd` for us and reads its source on demand.
+ if let entry = PuzzleCatalog.source(matchingResourceID: filledPuzzleResource, title: nil),
+ let bigSource = try? entry.loadSource(),
+ let bigXD = try? XD.parse(bigSource) {
+ let bigPuzzle = Puzzle(xd: bigXD)
+ let game = seedGame(
+ title: entry.title,
+ participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
+ puzzle: bigPuzzle,
+ source: bigSource,
+ in: ctx
+ )
+ seedFilledLetters(in: game, puzzle: bigPuzzle, in: ctx)
+ }
+
try? ctx.save()
}
@@ -91,16 +116,18 @@ enum DemoSeed {
friend.pairKey = "demo-pair-\(crossmate.id)"
}
+ @discardableResult
private static func seedGame(
title: String,
participants: [String],
puzzle: Puzzle,
+ source: String,
in ctx: NSManagedObjectContext
- ) {
+ ) -> GameEntity {
let game = GameEntity(context: ctx)
game.id = UUID()
game.title = title
- game.puzzleSource = puzzleSource
+ game.puzzleSource = source
game.createdAt = Date()
game.updatedAt = Date()
// A non-nil share record name is what marks the game as shared, which is
@@ -116,6 +143,55 @@ enum DemoSeed {
player.ckRecordName = "demo-player-\(title)-\(authorID)"
player.updatedAt = Date()
}
+ return game
+ }
+
+ /// Fills a realistic share of `game`'s grid with correct letters, handing
+ /// each cell to one of the four demo players (you + the three crossmates)
+ /// in small diagonal patches and leaving scattered gaps so the puzzle reads
+ /// as in-progress. One `MovesEntity` per author carries that author's cells,
+ /// exactly as a real co-solve would, so `GridStateMerger` rebuilds the
+ /// attributed grid — and each filled cell renders that player's faint
+ /// attribution tint.
+ private static func seedFilledLetters(
+ in game: GameEntity,
+ puzzle: Puzzle,
+ in ctx: NSManagedObjectContext
+ ) {
+ let authors = [localAuthorID] + crossmates.map(\.id)
+ let now = Date()
+ var cellsByAuthor: [String: [GridPosition: TimestampedCell]] = [:]
+
+ for r in 0..<puzzle.height {
+ for c in 0..<puzzle.width {
+ let cell = puzzle.cells[r][c]
+ guard !cell.isBlock,
+ let solution = cell.solution,
+ !solution.isEmpty,
+ !solution.allSatisfy(\.isWhitespace)
+ else { continue }
+ // Leave roughly one cell in seven blank for an in-progress look.
+ if (r * 5 + c) % 7 == 0 { continue }
+ let author = authors[((r / 2) + (c / 2)) % authors.count]
+ cellsByAuthor[author, default: [:]][GridPosition(row: r, col: c)] =
+ TimestampedCell(
+ letter: solution.uppercased(),
+ mark: .none,
+ updatedAt: now,
+ authorID: author
+ )
+ }
+ }
+
+ for (author, cells) in cellsByAuthor {
+ let entity = MovesEntity(context: ctx)
+ entity.game = game
+ entity.authorID = author
+ entity.deviceID = "demo-device-\(author)"
+ entity.ckRecordName = "demo-moves-\(game.id?.uuidString ?? "")-\(author)"
+ entity.cells = (try? MovesCodec.encode(cells)) ?? Data()
+ entity.updatedAt = now
+ }
}
}
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -146,12 +146,12 @@ struct PlayerRosterTests {
// than acting as a fixed per-person badge.
func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? {
let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id
- let expected = PlayerColor.assignedColor(
- forAuthorID: "_B",
+ let expected = PlayerColor.assignedCompanions(
+ forSortedAuthorIDs: ["_B"],
inGame: gameID,
- reserved: [localColorID]
- )
- #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected.id)
+ anchor: PlayerColor.color(for: localColorID)
+ ).first
+ #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id)
return roster.entries.first { $0.authorID == "_B" }?.color.id
}