use bevy::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};
pub const IDLE_ROW: Color = Color::srgb(0.66, 0.54, 0.17);
pub const SELECTED_ROW: Color = Color::srgb(0.95, 0.9, 0.6);
pub const CARD_BG: Color = Color::srgb(0.07, 0.08, 0.11);
pub const LEGEND_SAND: Color = Color::srgb(0.84, 0.77, 0.62);
pub const GOLD: Color = Color::srgb(0.96, 0.83, 0.35);
pub const PARCHMENT: Color = Color::srgb(0.92, 0.89, 0.78);
pub const CARD_FILL: Color = Color::srgba(0.04, 0.07, 0.12, 0.95);
pub const CARD_EDGE: Color = Color::srgba(0.96, 0.83, 0.35, 0.30);
pub const SCROLL_TRACK: Color = Color::srgba(0.96, 0.83, 0.35, 0.10);
pub const SCROLL_THUMB: Color = Color::srgba(0.96, 0.83, 0.35, 0.55);
pub const PILL_FILL: Color = Color::srgba(0.05, 0.07, 0.11, 0.92);
pub const FOAM_LINE: Color = Color::srgba(0.62, 0.82, 0.92, 0.16);
pub const HEADING_ICON: Color = Color::srgba(0.96, 0.83, 0.35, 0.85);
pub const INK_TIDE: Color = Color::srgb(0.55, 0.8, 0.95);
pub const INK_LURE: Color = Color::srgb(0.75, 0.6, 0.95);
pub const INK_SURGE: Color = Color::srgb(0.5, 0.9, 0.7);
pub const INK_RAID: Color = Color::srgb(0.96, 0.45, 0.38);
pub const HEADER_FILL: Color = Color::srgb(0.08, 0.09, 0.12);
pub const TOAST_FILL: Color = Color::srgb(0.1, 0.12, 0.16);
pub const CARD_SHADOW: Color = Color::srgba(0.0, 0.05, 0.12, 0.45);
pub const PICKED_WASH: Color = Color::srgba(0.96, 0.83, 0.35, 0.16);
pub const HAIRLINE: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
pub const UNLIT_RING: Color = Color::srgba(1.0, 1.0, 1.0, 0.22);
pub const BAR_TRACK: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
pub const CHIP_NAME_ALPHA: f32 = 0.92;
pub const HUD_INK: Color = Color::WHITE;
pub const DARK_INK: Color = Color::srgb(0.06, 0.05, 0.04);
pub const TEXT_CONTRAST: f32 = 4.5;
pub const TITLE_INK: Color = Color::srgb(0.99, 0.85, 0.36);
pub const TITLE_SHADOW: Color = Color::srgba(0.05, 0.10, 0.20, 0.55);
pub const SIGN_FILL: Color = Color::srgba(0.05, 0.09, 0.14, 0.96);
pub const CLOCK_CALM: Color = Color::srgb(0.95, 0.93, 0.84);
pub const CLOCK_RED: Color = Color::srgb(0.96, 0.25, 0.18);
pub const CLOCK_RED_BRIGHT: Color = Color::srgb(1.0, 0.55, 0.25);
pub const INK_ONE_POST: Color = Color::srgb(0.52, 0.82, 0.55);
pub const INK_THREE_POSTS: Color = Color::srgb(0.96, 0.62, 0.30);
pub const TILE_CLEARED_INK: Color = Color::srgb(1.0, 0.96, 0.86);
pub const TILE_OPEN_FILL: Color = Color::srgba(0.95, 0.93, 0.84, 0.12);
pub const TILE_LOCKED_FILL: Color = Color::srgba(0.06, 0.08, 0.11, 0.45);
pub const TILE_LOCKED_INK: Color = Color::srgba(0.95, 0.93, 0.84, 0.22);
pub const MEDAL_GOLD: Color = Color::srgb(0.95, 0.78, 0.25);
pub const MEDAL_SILVER: Color = Color::srgb(0.76, 0.79, 0.83);
pub const MEDAL_BRONZE: Color = Color::srgb(0.75, 0.52, 0.33);
pub const MEDAL_DRIFTWOOD: Color = Color::srgb(0.42, 0.44, 0.5);
pub const MEDAL_DIGIT: Color = Color::srgb(0.12, 0.11, 0.09);
pub const GULL_INK: Color = Color::srgba(0.85, 0.88, 0.92, 0.9);
static COLORBLIND: AtomicBool = AtomicBool::new(false);
pub fn set_colorblind(on: bool) {
COLORBLIND.store(on, Ordering::Relaxed);
}
pub fn player_color(player: u8) -> Color {
if COLORBLIND.load(Ordering::Relaxed) {
safe_color(player)
} else {
classic_color(player)
}
}
pub fn seat_paint(seat: u8) -> Color {
player_color(seat).lighter(0.08)
}
pub fn seat_text(seat: u8) -> Color {
use std::sync::OnceLock;
const SEATS: usize = crate::sim::MAX_PLAYERS;
static KEPT: [[OnceLock<Color>; SEATS]; 2] = [const { [const { OnceLock::new() }; SEATS] }; 2];
let colorblind = COLORBLIND.load(Ordering::Relaxed);
let work = || {
text_on_dark(match colorblind {
true => safe_color(seat),
false => classic_color(seat),
})
};
match KEPT[usize::from(colorblind)].get(usize::from(seat)) {
Some(kept) => *kept.get_or_init(work),
None => work(),
}
}
fn text_on_dark(color: Color) -> Color {
let reads = |ink: Color| {
[CARD_BG, CARD_FILL.with_alpha(1.0)]
.iter()
.all(|&fill| contrast(ink, fill) >= TEXT_CONTRAST)
};
let lch = Oklcha::from(color);
let mut lightness = lch.lightness;
let mut ink = srgb_of(lch);
while !reads(ink) && lightness < 1.0 {
lightness = (lightness + 0.005).min(1.0);
ink = in_gamut(lch.with_lightness(lightness));
}
ink
}
pub fn seat_chip(seat: u8) -> Color {
chips(COLORBLIND.load(Ordering::Relaxed), seat).0
}
fn chips(colorblind: bool, seat: u8) -> (Color, Color) {
use std::sync::OnceLock;
type Pair = OnceLock<(Color, Color)>;
const SEATS: usize = crate::sim::MAX_PLAYERS;
static KEPT: [[Pair; SEATS]; 2] = [const { [const { OnceLock::new() }; SEATS] }; 2];
let work = || {
let color = match colorblind {
true => safe_color(seat),
false => classic_color(seat),
};
(trailing_chip(color), legible(color))
};
match KEPT[usize::from(colorblind)].get(usize::from(seat)) {
Some(kept) => *kept.get_or_init(work),
None => work(),
}
}
fn trailing_chip(color: Color) -> Color {
legible(chip_shade(color))
}
pub fn leader_chip(seat: u8) -> Color {
chips(COLORBLIND.load(Ordering::Relaxed), seat).1
}
fn chip_shade(color: Color) -> Color {
let lch = Oklcha::from(color);
in_gamut(lch.with_lightness(lch.lightness * CHIP_SHADE))
}
fn in_gamut(lch: Oklcha) -> Color {
let fits = |lch: Oklcha| {
let rgb = Color::from(lch).to_srgba();
[rgb.red, rgb.green, rgb.blue]
.iter()
.all(|c| (-1e-4..=1.0 + 1e-4).contains(c))
};
if fits(lch) {
return srgb_of(lch);
}
let (mut inside, mut outside) = (0.0, lch.chroma);
for _ in 0..24 {
let mid = (inside + outside) / 2.0;
match fits(lch.with_chroma(mid)) {
true => inside = mid,
false => outside = mid,
}
}
srgb_of(lch.with_chroma(inside))
}
fn srgb_of(lch: Oklcha) -> Color {
let rgb = Color::from(lch).to_srgba();
Color::srgb(
rgb.red.clamp(0.0, 1.0),
rgb.green.clamp(0.0, 1.0),
rgb.blue.clamp(0.0, 1.0),
)
}
pub fn ink_on(fill: Color) -> Color {
match contrast(DARK_INK, fill) > contrast(Color::WHITE, fill) {
true => DARK_INK,
false => Color::WHITE,
}
}
pub fn name_ink_on(fill: Color) -> Color {
ink_on(fill).with_alpha(CHIP_NAME_ALPHA)
}
pub fn legible(color: Color) -> Color {
let fill: Color = color.to_srgba().with_alpha(1.0).into();
let reads = |fill: Color| contrast(name_ink_on(fill), fill) >= TEXT_CONTRAST;
if reads(fill) {
return fill;
}
let lch = Oklcha::from(fill);
let moved = |toward: f32, ink: Color| {
let mut lightness = lch.lightness;
loop {
lightness = (lightness + toward).clamp(0.0, 1.0);
let nudged = in_gamut(lch.with_lightness(lightness));
let done = contrast(ink.with_alpha(CHIP_NAME_ALPHA), nudged) >= TEXT_CONTRAST;
if done || lightness <= 0.0 || lightness >= 1.0 {
return (lightness, nudged);
}
}
};
let (darker, under_white) = moved(-0.005, Color::WHITE);
let (lighter, under_dark) = moved(0.005, DARK_INK);
match lch.lightness - darker <= lighter - lch.lightness {
true => under_white,
false => under_dark,
}
}
pub fn contrast(ink: Color, fill: Color) -> f32 {
let luminance = |c: LinearRgba| 0.2126 * c.red + 0.7152 * c.green + 0.0722 * c.blue;
let ratio = |x: f32, y: f32| (x.max(y) + 0.05) / (x.min(y) + 0.05);
let a = ink.alpha();
let under = luminance(fill.to_linear());
let (over, base) = (ink.to_linear(), fill.to_linear());
let on_light = LinearRgba::rgb(
over.red * a + base.red * (1.0 - a),
over.green * a + base.green * (1.0 - a),
over.blue * a + base.blue * (1.0 - a),
);
let (over, base) = (ink.to_srgba(), fill.to_srgba());
let on_screen = Srgba::rgb(
over.red * a + base.red * (1.0 - a),
over.green * a + base.green * (1.0 - a),
over.blue * a + base.blue * (1.0 - a),
);
ratio(luminance(on_light), under).min(ratio(luminance(on_screen.into()), under))
}
const CHIP_SHADE: f32 = 0.78;
pub(crate) fn classic_color(player: u8) -> Color {
match player {
0 => Color::srgb(0.84, 0.27, 0.27), 1 => Color::srgb(0.25, 0.47, 0.79), 2 => Color::srgb(0.30, 0.69, 0.31), 3 => Color::srgb(0.89, 0.73, 0.23), 4 => Color::srgb(0.70, 0.40, 0.82), _ => Color::srgb(0.95, 0.55, 0.20), }
}
fn safe_color(player: u8) -> Color {
match player {
0 => Color::srgb(0.80, 0.40, 0.00), 1 => Color::srgb(0.35, 0.70, 0.90), 2 => Color::srgb(0.95, 0.90, 0.25), 3 => Color::srgb(0.80, 0.60, 0.70), 4 => Color::srgb(0.00, 0.45, 0.70), _ => Color::srgb(0.35, 0.35, 0.38), }
}
#[cfg(test)]
mod tests {
use super::*;
fn every_seat() -> Vec<(bool, u8, Color)> {
[
(false, classic_color as fn(u8) -> Color),
(true, safe_color),
]
.into_iter()
.flat_map(|(colorblind, palette)| {
(0..crate::sim::MAX_PLAYERS as u8).map(move |seat| (colorblind, seat, palette(seat)))
})
.collect()
}
fn hue_gap(a: f32, b: f32) -> f32 {
let gap = (a - b).rem_euclid(360.0);
gap.min(360.0 - gap)
}
#[test]
fn a_trailing_chip_keeps_its_colour() {
for (_, seat, full) in every_seat() {
{
let rgb = trailing_chip(full).to_srgba();
assert!(
[rgb.red, rgb.green, rgb.blue]
.iter()
.all(|c| (0.0..=1.0).contains(c)),
"seat {seat}: {rgb:?} is off the screen"
);
let (full, shown) = (Oklcha::from(full), Oklcha::from(Color::from(rgb)));
assert!(shown.lightness > 0.35, "seat {seat}: {shown:?}");
let ratio = shown.lightness / full.lightness;
assert!(
(0.68..=0.86).contains(&ratio),
"seat {seat} keeps {ratio} of its lightness"
);
if full.chroma > 0.03 {
assert!(
hue_gap(shown.hue, full.hue) < 2.0,
"seat {seat} keeps its hue: {} for {}",
shown.hue,
full.hue
);
}
}
}
}
#[test]
fn every_chip_reads() {
for (colorblind, seat, color) in every_seat() {
{
for fill in [trailing_chip(color), legible(color)] {
let name = contrast(name_ink_on(fill), fill);
let score = contrast(ink_on(fill), fill);
assert!(
name >= TEXT_CONTRAST && score >= name,
"colour-blind {colorblind}, seat {seat} on {:?}: name {name:.2}, score {score:.2}",
fill.to_srgba()
);
}
}
}
}
#[test]
fn any_colour_made_legible_reads() {
for r in 0..=8 {
for g in 0..=8 {
for b in 0..=8 {
let color = Color::srgb(r as f32 / 8.0, g as f32 / 8.0, b as f32 / 8.0);
let fill = legible(color);
let seen = contrast(name_ink_on(fill), fill);
assert!(seen >= TEXT_CONTRAST, "{color:?}: {seen:.2}");
}
}
}
}
#[test]
fn a_leader_chip_stays_its_colour() {
for (_, seat, full) in every_seat() {
{
let (full, chip) = (Oklcha::from(full), Oklcha::from(legible(full)));
assert!(
(chip.lightness - full.lightness).abs() < 0.1,
"seat {seat}: {full:?} became {chip:?}"
);
}
}
}
#[test]
fn contrast_is_measured_as_wcag_measures_it() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.0).abs() < 1e-3);
assert!((contrast(Color::WHITE, Color::WHITE) - 1.0).abs() < 1e-6);
let grey = Color::srgb(0.4627, 0.4627, 0.4627);
assert!((contrast(Color::WHITE, grey) - 4.54).abs() < 0.02);
let half = contrast(Color::WHITE.with_alpha(0.5), Color::BLACK);
assert!(half > 1.0 && half < 21.0, "{half}");
assert_eq!(ink_on(Color::srgb(0.95, 0.9, 0.25)), DARK_INK);
assert_eq!(ink_on(Color::srgb(0.1, 0.2, 0.5)), Color::WHITE);
}
#[test]
fn kept_chips_are_the_searched_ones() {
for seat in 0..=crate::sim::MAX_PLAYERS as u8 {
for (colorblind, color) in [(false, classic_color(seat)), (true, safe_color(seat))] {
let want = (trailing_chip(color), legible(color));
assert_eq!(chips(colorblind, seat), want, "seat {seat}");
assert_eq!(chips(colorblind, seat), want, "seat {seat}, kept");
}
}
}
#[test]
fn player_colors_are_distinct() {
for palette in [classic_color, safe_color] {
let colors: Vec<_> = (0..crate::sim::MAX_PLAYERS as u8).map(palette).collect();
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
assert_ne!(colors[i], colors[j], "seats {i} and {j}");
}
}
}
}
fn dichromat_view(color: Color) -> (f32, f32) {
let c = color.to_srgba();
let brightness = 0.35 * c.red + 0.55 * c.green + 0.10 * c.blue;
let blue_yellow = c.blue - (c.red + c.green) / 2.0;
(brightness, blue_yellow)
}
#[test]
fn the_safe_palette_survives_color_blind_vision() {
let separation = |palette: fn(u8) -> Color| {
let mut worst = f32::INFINITY;
for a in 0..crate::sim::MAX_PLAYERS as u8 {
for b in (a + 1)..crate::sim::MAX_PLAYERS as u8 {
let (ba, ya) = dichromat_view(palette(a));
let (bb, yb) = dichromat_view(palette(b));
worst = worst.min((ba - bb).abs().max((ya - yb).abs()));
}
}
worst
};
let safe = separation(safe_color);
assert!(safe > 0.2, "closest safe pair is only {safe} apart");
assert!(
separation(classic_color) < safe,
"the accessible palette must beat the default it replaces"
);
}
#[test]
fn seat_text_reads_on_the_dark_cards() {
for palette in [classic_color, safe_color] {
for seat in 0..crate::sim::MAX_PLAYERS as u8 {
let color = palette(seat);
let ink = text_on_dark(color);
for fill in [CARD_BG, CARD_FILL.with_alpha(1.0)] {
let ratio = contrast(ink, fill);
assert!(
ratio >= TEXT_CONTRAST,
"seat {seat}: {ratio:.2} on {fill:?}"
);
}
let (was, now) = (Oklcha::from(color), Oklcha::from(ink));
assert!(now.lightness >= was.lightness - 1e-3, "seat {seat}");
if was.chroma > 0.05 {
let turn = (now.hue - was.hue).abs();
assert!(turn.min(360.0 - turn) < 6.0, "seat {seat} turned {turn}");
}
}
}
}
}