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 / scene.gleam
scene.gleam 12.0K
1/// What a frame is made of.2///3/// Everything that is *art* is composed once, in greyscale, into a4/// low-resolution buffer and shaded once. Nothing is drawn "in colour" and then5/// converted: the greyscale value *is* the intended coverage of paper, authored6/// linearly, so a tone of 128 really does put half the dots down.7///8/// Because error diffusion depends on the whole image, shifting anything by a9/// pixel re-flows the pattern downstream of it. So the one thing that moves —10/// the block under your thumb — is shaded once into a sprite and carried, while11/// the rest of the scene is composed without it. The static picture is12/// therefore identical frame to frame, and the sliding block keeps its own13/// texture instead of boiling.14///15/// Type is not art and does not go through any of this. It is drawn on a second16/// canvas at full device resolution in flat ink. Shading type turns it to17/// gravel, which is fine for a title and unreadable for anything else.18///19/// This module is the boundary. A painter builds a list of these and every20/// decision — which screen, which ruling, which colour, what tone — is already21/// made by the time one is constructed. What crosses into the renderer is a22/// description, not a policy: the pipeline downstream knows about thresholds23/// and pixels and knows nothing about themes, families or rungs.24import gleam/float25import gleam/int26import gleam/list27import klotski/layout.{type Rect}28import klotski/theme.{type Rgb}2930/// Tones are coverage of paper, 0..255.31///32/// Retuned for Atkinson, which throws away a quarter of its error and so runs33/// highlights clean to white and shadows to solid black. Anything past about34/// 225 renders as bare paper and anything under about 35 as solid ink, so all35/// the useful texture lives in the middle and the steps have to sit there.36pub const paper_tone = 2553738pub const rule_tone = 03940/// Coverage per piece size. Only three quarters, a half and a quarter are used:41/// Atkinson resolves those to even textures, while the eighths between them42/// come out visibly streaked, and an eighth is too sparse for a hue to register43/// at all. Tall and wide share a half and are told apart by colour and by their44/// own shape.45pub fn tone_of(class: Int) -> Int {46 case class {47 1 -> 19248 2 -> 12849 3 -> 12850 4 -> 6451 _ -> 052 }53}5455/// The two faces, and the pair is the whole hierarchy.56///57/// Tiny5 has one weight, so a title is not a heavier caption — it is a58/// different kind of letter. That is a stronger distinction than a weight59/// anyway: a bitmap face at fourteen pixels cannot hold a bold convincingly,60/// and the two it does hold end up looking like the same word printed twice.61pub type Face {62 /// Tiny5: the display face, for anything the picture announces.63 Display64 /// DM Mono: everything that is read rather than announced.65 Text66}6768pub type Align {69 Start70 Center71 End72}7374/// A screen, resolved: either diffusion or a threshold table at a ruling.75///76/// `key` is what the sprite cache is keyed on, so the renderer never has to77/// compare two tables to know they are the same press.78pub type Screen {79 Screen(80 key: String,81 diffuse: Bool,82 table: List(Int),83 overprint: List(Int),84 cell: Int,85 )86}8788pub type BlockStyle {89 BlockStyle(90 /// The margin between the block and its cell.91 gap: Int,92 /// The hard offset shadow, which is what QuickDraw did.93 drop: Int,94 /// Coverage of paper, before the screen.95 tone: Int,96 shadow: Bool,97 /// A doubled outline, for the piece under a thumb.98 thick: Bool,99 /// The mark on the goal block: the one piece whose identity matters.100 device: Bool,101 screen: Screen,102 /// The colour the block is reprinted in once the picture is thresholded,103 /// and the second plate an overprint splits its ink onto.104 ink: Result(Rgb, Nil),105 plate: Result(Rgb, Nil),106 )107}108109pub type Op {110 /// A greyscale rectangle, in layout units.111 Fill(x: Int, y: Int, w: Int, h: Int, tone: Int)112 /// A block, drawn at (x, y) but *cut* at the phase of (rest_x, rest_y).113 ///114 /// They are the same everywhere except under a thumb or mid-slide, and115 /// keeping them apart is what stops the ink crawling: the screen is ruled116 /// across the picture rather than restarted at each block, so a sprite has to117 /// be cut at the phase of the place it belongs to. Taking that from the118 /// animated position instead would make the ink a function of how the piece119 /// got there, and a board drawn without animation would not match the same120 /// board drawn with it.121 Block(122 x: Int,123 y: Int,124 w: Int,125 h: Int,126 class: Int,127 rest_x: Int,128 rest_y: Int,129 style: BlockStyle,130 )131 /// Type, painted after the threshold at full device resolution. `max_w` of132 /// zero is no cap; anything else shrinks the face until the tracked line133 /// fits.134 Label(135 text: String,136 x: Float,137 y: Float,138 size: Float,139 face: Face,140 track: Float,141 align: Align,142 alpha: Float,143 max_w: Float,144 )145 /// The one thing on screen that is not one bit.146 ///147 /// A solved board used to be marked with an inked seal, which sank straight148 /// into the board's own texture — ink on ink. The trophy is painted after the149 /// threshold, at full resolution and in gold, so it sits clearly on top of150 /// the shading instead of competing with it.151 Trophy(x: Float, y: Float, size: Float)152 /// The perfect solver's line. Gold and painted after the threshold for the153 /// same reason the trophy is: drawn into the scene it was ink on ink, and the154 /// bars of your own descent covered the very line they are meant to be read155 /// against.156 GoldLine(x1: Float, y1: Float, x2: Float, y2: Float)157 /// The maker's mark: a delta in a ring, carried where a Macintosh carried the158 /// Apple. Painted after the threshold because it is a *mark* — two flat159 /// colours and no texture at all. Shading it would put the house pattern160 /// through a shape whose whole job is to be constant, and at fourteen pixels161 /// a screen of any ruling turns a ring into four dots.162 DevLogo(x: Float, y: Float, size: Float, ink: Rgb)163 /// The arrowhead under the aperture, pointing out of the tray, in the goal164 /// block's colour. Painted after the threshold for the reason the maker's mark165 /// is: it is a flat mark whose job is to be the same shape every time, and a166 /// screen put through something this small returns gravel. `w` is its width in167 /// units and is odd, which makes its height exactly `(w + 1) / 2` — it is a168 /// stack of runs, each two units narrower than the one above.169 Exit(x: Int, y: Int, w: Int, ink: Rgb)170 /// Sprites and type are composited *after* the threshold, so they do not171 /// respect anything drawn over them in the scene — a scrolled list ran172 /// straight through the menu bar. Anything queued while a clip is set carries173 /// it, and honours it at paint time.174 Clip(rect: Rect)175 /// Release the clip.176 Unclip177}178179/// Everything the renderer needs that is not an op: the picture's size, the two180/// values it is thresholded to, the screen the frame is cut on, the ground it181/// is lying on, and whichever struck inversion is in force.182pub type Frame {183 Frame(184 scale: Float,185 vw: Int,186 vh: Int,187 off_x: Int,188 off_y: Int,189 density: Int,190 ink: Rgb,191 paper: Rgb,192 gold: Rgb,193 screen: Screen,194 tile: List(Int),195 dot: Rgb,196 field: Rgb,197 /// A single struck frame of inversion, which the undo spends.198 flash: Bool,199 /// How far the solve's inverting wipe has crossed, in layout units, or a200 /// negative number when there is none.201 wipe: Float,202 ops: List(Op),203 )204}205206// ------------------------------------------------------------------ helpers207//208// The composites below expand to primitive fills here rather than in the209// renderer, so that what a window is made of stays a fact about the design210// language and not a special case in the pixel loop.211212/// Tiny5 is a bitmap face on an eight-pixel em, so sizes are snapped to whole213/// even pixels to keep its stems even.214pub fn snap(size: Float) -> Float {215 float.max(2.0, int.to_float(float.round(size /. 2.0) * 2))216}217218/// A one-unit rule. Every rule in this app is one unit wide, which is why the219/// upscale has to be whole.220pub fn rule(x: Int, y: Int, w: Int, h: Int) -> Op {221 Fill(x, y, w, h, rule_tone)222}223224/// A window: paper, with a hard rule around it and a second one inset. Straight225/// out of QuickDraw.226pub fn plate(r: Rect) -> List(Op) {227 let inset = 3228 [229 Fill(r.x, r.y, r.w, r.h, paper_tone),230 rule(r.x, r.y, r.w, 1),231 rule(r.x, r.y + r.h - 1, r.w, 1),232 rule(r.x, r.y, 1, r.h),233 rule(r.x + r.w - 1, r.y, 1, r.h),234 rule(r.x + inset, r.y + inset, r.w - 2 * inset, 1),235 rule(r.x + inset, r.y + r.h - inset - 1, r.w - 2 * inset, 1),236 rule(r.x + inset, r.y + inset, 1, r.h - 2 * inset),237 rule(r.x + r.w - inset - 1, r.y + inset, 1, r.h - 2 * inset),238 ]239}240241/// A panel, and the menu bar. The desktop carries a pattern, so nothing small242/// is ever set on it — on a Macintosh, text lives inside a window. This is what243/// keeps a textured ground and legible labels from being a trade.244pub fn panel(r: Rect) -> List(Op) {245 [246 Fill(r.x, r.y, r.w, r.h, paper_tone),247 rule(r.x, r.y, r.w, 1),248 rule(r.x, r.y + r.h - 1, r.w, 1),249 rule(r.x, r.y, 1, r.h),250 rule(r.x + r.w - 1, r.y, 1, r.h),251 ]252}253254pub fn menu_bar(vw: Int, bar_h: Int) -> List(Op) {255 [Fill(0, 0, vw, bar_h, paper_tone), rule(0, bar_h, vw, 1)]256}257258/// The tray the blocks sit in: paper, ruled just outside its own edge, so the259/// rule does not eat a pixel of the playing area.260pub fn tray(r: Rect) -> List(Op) {261 [262 Fill(r.x, r.y, r.w, r.h, paper_tone),263 rule(r.x - 1, r.y - 1, r.w + 2, 1),264 rule(r.x - 1, r.y + r.h, r.w + 2, 1),265 rule(r.x - 1, r.y - 1, 1, r.h + 2),266 rule(r.x + r.w, r.y - 1, 1, r.h + 2),267 ]268}269270/// The way out.271///272/// The tray's rule reaches the two cells the goal block leaves by and turns273/// down, so the gap is a doorway with jambs rather than an absence. A rule that274/// merely stops reads as a rule that ran out; a rule that turns a right angle275/// reads as a wall that has been built up to an opening on purpose. Between the276/// jambs, an arrowhead pointing out of the tray, printed in the goal block's own277/// colour — which is the one thing on the board that says which piece the door278/// is for, and it says it without a word.279///280/// It was three ticks under the rule before, evenly spaced across the gap. Three281/// marks of one size have to be read as a group and then interpreted; the middle282/// one carried the whole meaning and looked exactly like the two that only283/// marked a boundary.284///285/// `room` is what there is between the tray's rule and the name set under the286/// plate. The figure is fitted into it rather than sized off the cell alone,287/// because the space below the tray is the one part of the plate that does not288/// grow with the board.289pub fn aperture(290 board: Rect,291 cell: Int,292 goal_x: Int,293 tray_bottom: Int,294 room: Int,295 ink: Result(Rgb, Nil),296) -> List(Op) {297 let x = board.x + goal_x * cell298 let w = cell * 2299300 // The head is a stack of runs two units narrower each time, so its height301 // follows from its width and only one of the two is a decision. The jambs go302 // a unit past it: a frame that stops level with what it frames stops being a303 // frame.304 let high =305 int.max(306 2,307 int.min({ room - 3 } / 2, float.round(int.to_float(cell) *. 0.26)),308 )309 let head = int.min(high * 2 - 1, w - 6)310 let deep = high + 2311312 let mark = case ink {313 Ok(colour) -> [314 Exit(x: x + w / 2 - head / 2, y: tray_bottom + 2, w: head, ink: colour),315 ]316 // No tints in this theme, so the door is read from its frame alone.317 Error(_) -> []318 }319320 list.flatten([321 [322 Fill(x, tray_bottom, w, 1, paper_tone),323 rule(x - 1, tray_bottom, 1, deep),324 rule(x + w, tray_bottom, 1, deep),325 ],326 mark,327 ])328}329330/// A plain label, at its natural size.331pub fn label(332 text: String,333 x: Float,334 y: Float,335 size: Float,336 face: Face,337 align: Align,338) -> Op {339 Label(text:, x:, y:, size:, face:, track: 0.0, align:, alpha: 1.0, max_w: 0.0)340}