clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

main
klot.ski / src / klotski / theme.gleam
theme.gleam 14.5K
1/// Themes, and the look of a board.2///3/// This is the only place any colour is written down. A theme owns:4///5/// ink, paper the two values everything is thresholded to6/// pieces one colour per piece size, reprinted after the threshold7/// gold the one thing on screen that is not one bit8/// backdrops which stencils the desktop may be drawn with9/// grounds which colour pairs that stencil may be printed in10/// shading which screen shapes the blocks may be drawn with11/// rulings how coarse those screens may be12///13/// The last four are pools, not values: a board draws one of each, seeded on14/// its family and rung, so a ladder walks through combinations nobody authored.15///16/// Shape and ruling are separate pools rather than one list because they are17/// separate choices — a printer picks a dot and then picks how big it is, and18/// holding one while changing the other is most of the difference between19/// newsprint and a magazine. Folding them together would mean writing out every20/// product by hand, which is exactly the authoring this file exists to avoid.21///22/// Seven backdrops, six grounds and thirty-two block looks is 1344 per theme23/// out of about twenty written-down numbers, which is the entire reason for24/// doing it this way rather than drawing 1344 backdrops.25///26/// Piece colours are deliberately *not* in a pool. The 2x2 is Cao Cao, and a27/// goal block that changes colour between rungs stops being a thing you28/// recognise and starts being a thing you have to re-read.29import gleam/float30import gleam/int31import gleam/list32import gleam/result33import klotski/ground.{type Backdrop}34import klotski/prng35import klotski/shading.{type Pattern, type Ruling}3637pub type Rgb {38 Rgb(r: Int, g: Int, b: Int)39}4041/// A colour pair the desktop stencil is printed in: the field behind, the dot42/// on top.43pub type Ground {44 Ground(name: String, field: Rgb, dot: Rgb)45}4647pub type Theme {48 Theme(49 id: String,50 name: String,51 ink: Rgb,52 paper: Rgb,53 /// Indexed by piece class: 1 single, 2 upright, 3 recumbent, 4 goal.54 pieces: List(Rgb),55 gold: Rgb,56 backdrops: List(Backdrop),57 grounds: List(Ground),58 shading: List(Pattern),59 rulings: List(Ruling),60 )61}6263/// The look of one board: a backdrop stencil, the colour pair it is printed in,64/// a shading screen for the blocks and the ruling that screen is cut at.65pub type Combo {66 Combo(backdrop: Backdrop, ground: Ground, shading: Pattern, ruling: Ruling)67}6869/// The piece colours.70///71/// The dither is untouched by these: a block is still a threshold between its72/// own tone and the paper, so it keeps exactly the texture it had. Only the ink73/// it is printed in changes — which is what colour QuickDraw did, and the74/// reason this reads as an early colour Macintosh rather than as a departure75/// from the look.76///77/// The big block is red because it always has been: Klotski's 2x2 is Cao Cao in78/// Huarong Dao and the red donkey in L'Ane Rouge. The others take the Apple79/// rainbow's blue, green and orange.80fn quickdraw_pieces() -> List(Rgb) {81 [82 Rgb(0xDE, 0x8A, 0x2E),83 // 1x1 small orange84 Rgb(0x3B, 0x82, 0xC4),85 // 1x2 tall blue86 Rgb(0x4E, 0x9E, 0x5C),87 // 2x1 wide green88 Rgb(0xD1, 0x4A, 0x42),89 // 2x2 the big block, red90 ]91}9293pub fn all() -> List(Theme) {94 [macintosh(), paper(), night(), amoled()]95}9697pub fn by_id(id: String) -> Theme {98 all() |> list.find(fn(theme) { theme.id == id }) |> result.unwrap(macintosh())99}100101/// The Macintosh was black on white, so this is too. None of these are pure102/// black on pure white: the paper is always tinted and the ink always has a103/// cast, which is most of why a 1-bit image reads as a printed thing rather104/// than a screenshot. Swapping the two entries is all "night" is.105fn macintosh() -> Theme {106 Theme(107 id: "macintosh",108 name: "MACINTOSH",109 ink: Rgb(0x2B, 0x2B, 0x22),110 paper: Rgb(0xE8, 0xF4, 0xF2),111 pieces: quickdraw_pieces(),112 gold: Rgb(0xC8, 0x95, 0x26),113 backdrops: ground.all(),114 shading: shading.patterns(),115 rulings: shading.rulings(),116 // Papers around the theme's own, each with an ink that has been pulled117 // toward it. A ground whose dot is the same neutral as every other118 // ground's reads as one backdrop with the lights changed; matching the119 // cast is what makes them separate places.120 grounds: [121 Ground("MINT", Rgb(0xE8, 0xF4, 0xF2), Rgb(0x2B, 0x2B, 0x22)),122 Ground("SLATE", Rgb(0xD8, 0xE2, 0xE6), Rgb(0x27, 0x31, 0x3A)),123 Ground("BONE", Rgb(0xEC, 0xE7, 0xDA), Rgb(0x3A, 0x33, 0x2A)),124 Ground("SKY", Rgb(0xDC, 0xE8, 0xF4), Rgb(0x24, 0x38, 0x4C)),125 Ground("MOSS", Rgb(0xDC, 0xE8, 0xD8), Rgb(0x2A, 0x3A, 0x2C)),126 Ground("ROSE", Rgb(0xF2, 0xE2, 0xE2), Rgb(0x42, 0x2C, 0x2E)),127 ],128 )129}130131fn paper() -> Theme {132 Theme(133 id: "paper",134 name: "PAPER",135 ink: Rgb(0x2E, 0x30, 0x37),136 paper: Rgb(0xEB, 0xE5, 0xCE),137 pieces: quickdraw_pieces(),138 gold: Rgb(0xC8, 0x95, 0x26),139 backdrops: ground.all(),140 shading: shading.patterns(),141 rulings: shading.rulings(),142 grounds: [143 Ground("CREAM", Rgb(0xEB, 0xE5, 0xCE), Rgb(0x2E, 0x30, 0x37)),144 Ground("LINEN", Rgb(0xE4, 0xDC, 0xC4), Rgb(0x3A, 0x34, 0x2A)),145 Ground("SAND", Rgb(0xEA, 0xDC, 0xBA), Rgb(0x46, 0x39, 0x2A)),146 Ground("OLIVE", Rgb(0xDF, 0xE2, 0xC2), Rgb(0x33, 0x38, 0x2A)),147 Ground("CLAY", Rgb(0xEC, 0xD8, 0xC4), Rgb(0x4A, 0x34, 0x2A)),148 Ground("ASH", Rgb(0xDE, 0xDD, 0xD4), Rgb(0x32, 0x34, 0x3A)),149 ],150 )151}152153fn night() -> Theme {154 Theme(155 id: "night",156 name: "NIGHT",157 ink: Rgb(0xDC, 0xE4, 0xE2),158 paper: Rgb(0x1C, 0x20, 0x28),159 pieces: quickdraw_pieces(),160 gold: Rgb(0xC8, 0x95, 0x26),161 backdrops: ground.all(),162 shading: shading.patterns(),163 rulings: shading.rulings(),164 // Every ground but the first sits *below* the theme's paper rather than165 // beside it. On a dark theme the panels are the lit thing; a ground at the166 // same value as the paper differing only in hue reads as a printing fault167 // rather than as a desk the windows are lying on.168 grounds: [169 Ground("NIGHT", Rgb(0x1C, 0x20, 0x28), Rgb(0xDC, 0xE4, 0xE2)),170 Ground("INDIGO", Rgb(0x0F, 0x14, 0x24), Rgb(0xBC, 0xC8, 0xE8)),171 Ground("FOREST", Rgb(0x0C, 0x1A, 0x14), Rgb(0xC4, 0xE4, 0xD2)),172 Ground("PLUM", Rgb(0x19, 0x0E, 0x22), Rgb(0xDE, 0xC8, 0xE8)),173 Ground("COAL", Rgb(0x10, 0x10, 0x13), Rgb(0xD8, 0xD8, 0xD2)),174 Ground("STEEL", Rgb(0x0E, 0x1A, 0x26), Rgb(0xBC, 0xD4, 0xE6)),175 ],176 )177}178179/// The exception, argued rather than assumed.180///181/// Every other theme obeys the rule above — tinted paper, ink with a cast —182/// because that is what stops a 1-bit picture reading as a screenshot. This one183/// breaks it on purpose: on an OLED panel a black pixel is a pixel that is off,184/// and #1C2028 is not off. The tint that buys warmth on a backlit screen costs185/// the one thing the display can do that no other can.186///187/// What replaces the tint is the ground. Every backdrop here is pure black with188/// a neon stencil over it, at one pixel in sixteen or fewer, so the colour189/// arrives as a field of light on true black instead of as a wash across it.190///191/// The piece colours are the theme's own for the same reason the others are192/// not: the Apple rainbow was chosen against paper and goes muddy against193/// black. These sit at the coverages the blocks actually use — the small block194/// is only a quarter ink, so it has to be the brightest of the four to register195/// at all, and the goal block is three quarters, so it can afford to be the196/// most saturated.197fn amoled() -> Theme {198 Theme(199 id: "amoled",200 name: "AMOLED",201 ink: Rgb(0xDC, 0xF2, 0xFF),202 paper: Rgb(0x00, 0x00, 0x00),203 pieces: [204 Rgb(0xFF, 0xB0, 0x1A),205 Rgb(0x22, 0xD3, 0xEE),206 Rgb(0x34, 0xE8, 0x9E),207 Rgb(0xFF, 0x2E, 0x63),208 ],209 // The printed gold disappears against black.210 gold: Rgb(0xFF, 0xD2, 0x4A),211 backdrops: ground.all(),212 shading: shading.patterns(),213 rulings: shading.rulings(),214 grounds: [215 Ground("CYAN", Rgb(0, 0, 0), Rgb(0x22, 0xD3, 0xEE)),216 Ground("MAGENTA", Rgb(0, 0, 0), Rgb(0xFF, 0x2E, 0x63)),217 Ground("SPRING", Rgb(0, 0, 0), Rgb(0x34, 0xE8, 0x9E)),218 Ground("AMBER", Rgb(0, 0, 0), Rgb(0xFF, 0xB0, 0x1A)),219 Ground("VIOLET", Rgb(0, 0, 0), Rgb(0xA8, 0x55, 0xF7)),220 Ground("ICE", Rgb(0, 0, 0), Rgb(0x6E, 0xA8, 0xFF)),221 ],222 )223}224225/// The colour a piece class is reprinted in. Class 1..4; anything else is the226/// empty class, which is never reprinted.227pub fn piece_colour(theme: Theme, class: Int) -> Result(Rgb, Nil) {228 case class >= 1 && class <= 4 {229 False -> Error(Nil)230 True -> theme.pieces |> list.drop(class - 1) |> list.first231 }232}233234/// The second plate.235///236/// Driven to the primary the colour is already mostly made of, with the other237/// two channels pulled back: the same hue, printed at full strength. Laid over238/// the flat plate on a rotated screen it reads as a colour separation rather239/// than as a different piece, which is the whole requirement — the 2x2 has to240/// stay the red one.241pub fn plate_two(colour: Rgb) -> Rgb {242 let peak = int.max(colour.r, int.max(colour.g, colour.b))243 let drive = fn(value) {244 case value == peak {245 True -> 255246 False -> float.round(int.to_float(value) *. 0.55)247 }248 }249 Rgb(drive(colour.r), drive(colour.g), drive(colour.b))250}251252/// Rec. 709 luma, 0..255.253pub fn luma(colour: Rgb) -> Float {254 0.2126255 *. int.to_float(colour.r)256 +. 0.7152257 *. int.to_float(colour.g)258 +. 0.0722259 *. int.to_float(colour.b)260}261262/// Plain RGB distance. Luma answers whether a dot is visible on its field;263/// distance answers whether a field is distinguishable from the paper of the264/// panels lying on it — which on a dark theme is mostly a matter of hue at265/// near-identical brightness, and so is invisible to luma alone.266pub fn distance(a: Rgb, b: Rgb) -> Float {267 let square = fn(x: Int, y: Int) {268 let d = int.to_float(x - y)269 d *. d270 }271 let sum = square(a.r, b.r) +. square(a.g, b.g) +. square(a.b, b.b)272 case float.square_root(sum) {273 Ok(value) -> value274 Error(_) -> 0.0275 }276}277278/// Which of these is a dark screen is not a flag anybody has to remember to279/// set: it is what the paper is.280pub fn is_dark(theme: Theme) -> Bool {281 luma(theme.paper) <. 128.0282}283284/// AUTO, and what it resolves to.285///286/// One pair rather than a rule over the whole table: PAPER is a warmer version287/// of the same light idea and AMOLED is a deliberate extreme, and neither is288/// what someone means when they have simply told their phone they prefer dark.289/// They mean the house look, in the dark, which is exactly the pair the tables290/// were written as.291pub fn resolve(id: String, dark: Bool) -> String {292 case id {293 "auto" ->294 case dark {295 True -> "night"296 False -> "macintosh"297 }298 other ->299 case list.any(all(), fn(theme) { theme.id == other }) {300 True -> other301 False -> "macintosh"302 }303 }304}305306/// What the screens that are not a board use.307///308/// The ladder varies; the atlas, the record and the title do not — they are309/// chrome, and chrome that shifts under you every time you open it is not310/// chrome. It is also the only sensible answer for a screen with no rung to311/// seed from.312pub fn house_combo(theme: Theme) -> Combo {313 Combo(314 backdrop: ground.Lattice,315 ground: theme.grounds316 |> list.first317 |> result.unwrap(Ground("MINT", theme.paper, theme.ink)),318 shading: shading.Atkinson,319 ruling: shading.Fine,320 )321}322323/// The look of one board, seeded on family and rung — the same pair that324/// decides which board it is, so a rung always looks the way it looked. Mixing325/// the theme into the hash means the same rung is a different combination under326/// a different theme rather than the same arrangement recoloured.327pub fn combo_for(theme: Theme, family_id: String, rung: Int) -> Combo {328 let rng = prng.seeded_at(family_id <> "|" <> theme.id, rung * 8191)329 let #(backdrop, rng) = prng.pick(rng, theme.backdrops, ground.Lattice)330 let #(ground_pair, rng) =331 prng.pick(rng, theme.grounds, Ground("MINT", theme.paper, theme.ink))332 let #(pattern, rng) = prng.pick(rng, theme.shading, shading.Atkinson)333 // Drawn unconditionally so the stream stays in step whichever shape came up,334 // then normalised: the shapes that take no ruling always report FINE rather335 // than a frequency they quietly ignore, so the sprite cache key agrees with336 // what is actually on screen.337 let #(ruling, _) = prng.pick(rng, theme.rulings, shading.Fine)338 Combo(339 backdrop: backdrop,340 ground: ground_pair,341 shading: pattern,342 ruling: shading.effective_ruling(pattern, ruling),343 )344}345346/// Only the shape pins. The ruling is the press this board was printed on, not347/// a preference: it is chosen with the shape it belongs to, and letting someone348/// hold one still while the other rolled produced pairings neither the seed nor349/// the person actually asked for.350pub fn apply_pin(combo: Combo, pinned: Result(Pattern, Nil)) -> Combo {351 case pinned {352 Error(_) -> combo353 Ok(pattern) ->354 Combo(355 ..combo,356 shading: pattern,357 ruling: shading.effective_ruling(pattern, combo.ruling),358 )359 }360}361362/// The maker's mark's ink, seeded on the day rather than on the board — it does363/// not belong to a board. It is the same mark on every screen it appears on,364/// all day, and it moves on tomorrow. The theme is mixed into the hash so365/// switching theme is not merely the same choice recoloured.366pub fn logo_ink(theme: Theme, day: Int) -> Rgb {367 let rng = prng.seeded_at("dev-logo|" <> theme.id, day * 2657)368 let #(colour, _) = prng.pick(rng, theme.pieces, Rgb(0xD1, 0x4A, 0x42))369 colour370}371372/// Every pool name must resolve to something that exists, and a theme naming373/// only unruled shapes would carry a ruling pool that can never be reached —374/// which is a table lying about what it does. Catching this here rather than at375/// the point of use means a mistake in a theme is a failed check rather than a376/// screen that silently falls back to the house lattice.377pub fn validate() -> List(String) {378 list.flat_map(all(), fn(theme) {379 let empties = case380 theme.backdrops == []381 || theme.grounds == []382 || theme.shading == []383 || theme.rulings == []384 {385 True -> [theme.id <> ": empty pool"]386 False -> []387 }388 let unruled = case list.any(theme.shading, shading.is_ruled) {389 True -> []390 False -> [theme.id <> ": no ruled shading"]391 }392 let pieces = case list.length(theme.pieces) == 4 {393 True -> []394 False -> [theme.id <> ": wrong number of piece colours"]395 }396 list.flatten([empties, unruled, pieces])397 })398}