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.gleam
klotski.gleam 68.9K
1/// Klotski.2///3/// Flow, input and chrome. The screens are paced rather than instant: a title4/// that has to be touched, a sweep you watch happen, a card naming the set and5/// its optimal length, the board rising into place, and a record when it is6/// done. None of it is long, but none of it cuts.7///8/// Sweeps happen when a family is first opened rather than at boot. Eight9/// families is a second and a half of breadth-first search, and making someone10/// wait through all of it to play one board is the wrong trade — especially when11/// they may only ever open two of them.12///13/// Nothing in this app moves smoothly: motion is quantised so the shading never14/// lands out of phase, which means most ticks of a clock produce the same15/// picture as the last one. So the loop keeps both — the clock, and the stepped16/// value the painters actually draw with — and asks for a picture when a stepped17/// value moves rather than when time passes. Composing a screen is not cheap and18/// cannot be: every pixel is drawn, read back, thresholded, tinted and written19/// out, because diffusion depends on the whole image. Doing that five times for20/// the same picture is what a phone feels as a stutter.21import gleam/dynamic.{type Dynamic}22import gleam/dynamic/decode23import gleam/float24import gleam/int25import gleam/list26import gleam/option.{type Option, None, Some}27import gleam/result28import klotski/browser29import klotski/difficulty30import klotski/domain.{type Direction, type Piece, Down, Left, Right, Up}31import klotski/families.{type Family}32import klotski/ground33import klotski/layout.{type BoardLayout, type Layout, Split}34import klotski/paint35import klotski/prefs.{type Preference}36import klotski/prng37import klotski/progress38import klotski/render39import klotski/scene.{type Screen as Shade}40import klotski/shading.{type Pattern}41import klotski/store.{type Save, Best, Full, OffHints}42import klotski/theme.{type Combo, type Theme}43import klotski/walk44import lustre45import lustre/attribute46import lustre/effect.{type Effect}47import lustre/element.{type Element}48import lustre/element/html49import lustre/event5051// ------------------------------------------------------------------- model5253pub type Screen {54 Boot55 Sweeping56 Atlas57 Card58 Playing59 Won60 Record61}6263pub type Axis {64 Horizontal65 Vertical66}6768pub type Drag {69 Drag(70 piece: Int,71 origin_x: Float,72 origin_y: Float,73 axis: Option(Axis),74 offset: Int,75 reach_positive: Int,76 reach_negative: Int,77 )78}7980/// A block sliding to where it was sent.81///82/// Under reduced motion it does not slide: it is already there. The move itself83/// is not the animation — the board changes either way, the sound plays either84/// way — so the still path arrives at the same position without the tween in85/// between. Which is only true because a block's ink is cut at its resting86/// square rather than at wherever it happens to be passing.87pub type Anim {88 Anim(89 piece: Int,90 axis: Axis,91 from: Float,92 to: Float,93 current: Int,94 elapsed: Float,95 duration: Float,96 then: Option(Pending),97 )98}99100pub type Pending {101 Pending(piece: Int, direction: Direction, cells: Int)102}103104pub type Step {105 Step(piece: Int, direction: Direction, cells: Int)106}107108pub type Sweep {109 Sweep(family: String, count: Int, total: Int, phase: Int)110}111112pub type Surveyed {113 Surveyed(family: String, ecc: Int, count: Int)114}115116/// The board a rung serves, and what the walker found in it.117pub type Puzzle {118 Puzzle(family: String, rung: Int, grid: List(Int), optimal: Int, score: Float)119}120121pub type Model {122 Model(123 screen: Screen,124 save: Save,125 system: Preference,126 layout: Layout,127 board: BoardLayout,128 family: Family,129 rung: Int,130 surveys: List(Surveyed),131 sweep: Option(Sweep),132 puzzle: Option(Puzzle),133 /// The rung a family will next serve, cached so the atlas can show what the134 /// next board costs without re-walking it every frame.135 previews: List(Puzzle),136 pieces: List(Piece),137 start_pieces: List(Piece),138 steps: Int,139 history: List(Step),140 /// Distance to the goal after each step, starting with the distance before141 /// any — the descent, and what the record screen draws.142 trace: List(Int),143 /// How many measurements are still in flight. A run is only finished when144 /// the last of them has landed.145 awaiting: Int,146 started_at: Int,147 drag: Option(Drag),148 anim: Option(Anim),149 scroll: Option(#(Float, Int, Float)),150 atlas_scroll: Int,151 // The clocks, and beside each the stepped value the painters draw with.152 last_tick: Float,153 shake: Float,154 shake_off: Int,155 shake_piece: Option(Int),156 card_t: Float,157 card_step: Int,158 rise_t: Float,159 rise: Int,160 flash: Float,161 wipe: Float,162 /// When the solve may be written down: after the wipe, and never before the163 /// last measurement has landed.164 finishing: Option(Float),165 elapsed: Float,166 settings_open: Bool,167 /// Which pull-down is open, if any. One field rather than a flag per menu:168 /// two menus open at once is not a state this sheet has, and two booleans is169 /// a way of writing it down that permits it.170 menu_open: Menu,171 install_available: Bool,172 last_frame: String,173 /// The moves a harness probe is waiting on an answer for, in the order they174 /// were asked about, and whether a walk is still under way.175 probing: List(#(Int, Direction)),176 walking: Bool,177 )178}179180/// Which pull-down is open. A named thing rather than a flag each, so that two181/// menus open at once is a state this sheet cannot get into.182pub type Menu {183 NoMenu184 ThemeMenu185 BlocksMenu186}187188pub type Message {189 Ticked(Float)190 ViewportChanged191 SystemChanged192 FontsReady193 PointerDown(Float, Float)194 PointerMoved(Float, Float)195 PointerUp(Float, Float)196 PointerCancelled197 KeyPressed(String)198 FamilyOpened(Int)199 Swept(String, Int, Int, Int)200 SurveyFinished(String, Int, Int)201 PuzzleOffered(String, Int, List(Dynamic))202 Measured(String, Int, List(Int))203 Probed(String, List(Int))204 HarnessWalked205 UndoPressed206 ResetPressed207 BackPressed208 StatsPressed209 NextPressed210 SettingsOpened211 SettingsClosed212 MenuToggled(Menu)213 /// A click that landed on the sheet and not on an open pull-down, which is214 /// how a menu is dismissed without being answered. Anywhere is outside: the215 /// sheet covers the screen, and a menu you have to aim at to close is a menu216 /// that has taken the screen hostage.217 MenuDismissed218 /// A click inside an open pull-down. It carries no change of its own — being219 /// caught before it reaches the sheet is the whole of what it does.220 MenuHeld221 PaletteChosen(String)222 ShadingChosen(Result(Pattern, Nil))223 HintsChosen(store.Hints)224 DensityChosen(store.Density)225 SoundToggled226 HapticsToggled227 InstallOffered228 InstallPressed229 InstallFinished230}231232pub fn main() {233 let app = lustre.application(init, update, view)234 let assert Ok(_) = lustre.start(app, "#app", browser.load())235 Nil236}237238fn init(raw: String) -> #(Model, Effect(Message)) {239 let save = store.decode(raw)240 let system = browser.preference()241 let family = families.at(0)242 let l =243 layout.choose(244 layout.Box(0, 0, 400, 800),245 render.device_pixel_ratio(),246 // The ladder of upscales is cut for the standard density on purpose. See247 // `upscales`: reading the setting here made a shading control relay out248 // the app.249 store.density_units(store.Standard),250 )251 let model =252 Model(253 screen: Boot,254 save:,255 system:,256 layout: l,257 board: layout.board_layout(l, family),258 family:,259 rung: 1,260 surveys: [],261 sweep: None,262 puzzle: None,263 previews: [],264 pieces: [],265 start_pieces: [],266 steps: 0,267 history: [],268 trace: [],269 awaiting: 0,270 started_at: 0,271 drag: None,272 anim: None,273 scroll: None,274 atlas_scroll: 0,275 last_tick: 0.0,276 shake: 0.0,277 shake_off: 0,278 shake_piece: None,279 card_t: 0.0,280 card_step: 0,281 rise_t: 1.0,282 rise: 0,283 flash: 0.0,284 wipe: -1.0,285 finishing: None,286 elapsed: 0.0,287 settings_open: False,288 menu_open: NoMenu,289 install_available: False,290 last_frame: "",291 probing: [],292 walking: False,293 )294 let boot =295 effect.batch([296 browser.watch_frame(Ticked),297 browser.watch_viewport(ViewportChanged),298 browser.watch_system(SystemChanged),299 browser.watch_keys(KeyPressed),300 browser.watch_install(InstallOffered),301 browser.watch_solver(302 Swept,303 SurveyFinished,304 PuzzleOffered,305 Measured,306 Probed,307 ),308 browser.expose_harness(HarnessWalked),309 render.watch_pointer(310 PointerDown,311 PointerMoved,312 PointerUp,313 PointerCancelled,314 ),315 browser.load_fonts(FontsReady),316 browser.register_service_worker(),317 browser.request_persistence(),318 ])319 // A reload that was taken to install a new version hands back what the player320 // had just asked for, so the update costs them the wait and genuinely nothing321 // else. Any other launch starts at the title window, which is the only thing322 // an empty resume can mean.323 let resume =324 families.all()325 |> list.find(fn(family) { family.id == browser.take_resume() })326 case resume {327 Ok(family) -> {328 let #(model, opened) = open_family(model, family)329 #(model, effect.batch([boot, opened]))330 }331 Error(_) -> #(model, boot)332 }333}334335// ------------------------------------------------------------------ update336337fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {338 case message {339 Ticked(now) -> tick(model, now)340 ViewportChanged | FontsReady -> {341 // Painted from the *new* layout, not the one that has just been replaced:342 // the canvas takes its backing store from the frame it is handed, and its343 // CSS size from the view, so a frame drawn at the old size is a picture344 // stretched to fit the new one.345 let model = relayout(model)346 #(model, repaint(model))347 }348 SystemChanged -> {349 let model = Model(..model, system: browser.preference())350 // The look is resolved from the theme, and AUTO is not a theme: a phone351 // that goes dark at sunset has changed which one is in force, so the352 // sprite cache is no longer describing this palette.353 #(model, effect.batch([render.clear_sprites(), repaint(model)]))354 }355356 PointerDown(x, y) -> pointer_down(model, x, y)357 PointerMoved(x, y) -> pointer_moved(model, x, y)358 PointerUp(x, y) -> pointer_up(model, x, y)359 PointerCancelled -> #(360 Model(..model, drag: None, scroll: None),361 effect.none(),362 )363 KeyPressed(key) -> key_pressed(model, key)364365 FamilyOpened(index) -> open_family(model, families.at(index))366 Swept(family, count, total, phase) -> #(367 Model(..model, sweep: Some(Sweep(family, count, total, phase))),368 effect.none(),369 )370 SurveyFinished(family, ecc, count) ->371 survey_finished(model, family, ecc, count)372 PuzzleOffered(family, rung, candidates) ->373 puzzle_offered(model, family, rung, candidates)374 Measured(family, seq, distances) -> measured(model, family, seq, distances)375 HarnessWalked -> harness_step(model)376 Probed(family, distances) -> harness_took(model, family, distances)377378 UndoPressed -> undo(model)379 ResetPressed -> begin_play(model)380 BackPressed -> go(Model(..model, settings_open: False), Atlas)381 StatsPressed -> go(model, Record)382 NextPressed -> open_rung(model, model.family, rung_of(model, model.family))383 SettingsOpened -> #(384 Model(..model, settings_open: True, menu_open: NoMenu),385 effect.none(),386 )387 SettingsClosed -> #(388 Model(..model, settings_open: False, menu_open: NoMenu),389 effect.none(),390 )391 MenuDismissed -> #(Model(..model, menu_open: NoMenu), effect.none())392 MenuHeld -> #(model, effect.none())393 // Opening one closes the other, which is what a single field buys.394 MenuToggled(menu) -> #(395 Model(..model, menu_open: case model.menu_open == menu {396 True -> NoMenu397 False -> menu398 }),399 effect.none(),400 )401402 PaletteChosen(palette) -> {403 let model =404 Model(405 ..model,406 save: store.Save(..model.save, palette:),407 menu_open: NoMenu,408 )409 #(410 model,411 effect.batch([persist(model), render.clear_sprites(), repaint(model)]),412 )413 }414 ShadingChosen(pattern) -> {415 let model =416 Model(417 ..model,418 save: store.Save(..model.save, shading: pattern),419 menu_open: NoMenu,420 )421 // The screen is baked into the sprites.422 #(423 model,424 effect.batch([persist(model), render.clear_sprites(), repaint(model)]),425 )426 }427 HintsChosen(hints) -> {428 let model = Model(..model, save: store.Save(..model.save, hints:))429 #(model, effect.batch([persist(model), repaint(model)]))430 }431 DensityChosen(density) -> {432 // Re-cuts every pixel the app owns: the backing stores are sized in buffer433 // pixels and the sprite cache is baked at the old ones.434 let model =435 relayout(Model(..model, save: store.Save(..model.save, density:)))436 #(437 model,438 effect.batch([persist(model), render.clear_sprites(), repaint(model)]),439 )440 }441 SoundToggled -> {442 let model =443 Model(..model, save: store.Save(..model.save, sound: !model.save.sound))444 #(model, persist(model))445 }446 HapticsToggled -> {447 let model =448 Model(449 ..model,450 save: store.Save(..model.save, haptics: !model.save.haptics),451 )452 #(model, persist(model))453 }454455 InstallOffered -> #(Model(..model, install_available: True), effect.none())456 InstallPressed -> #(457 Model(..model, install_available: False),458 browser.prompt_install(InstallFinished),459 )460 InstallFinished -> #(model, effect.none())461 }462}463464// -------------------------------------------------------------------- clock465466fn tick(model: Model, now: Float) -> #(Model, Effect(Message)) {467 let dt = case model.last_tick {468 0.0 -> 16.0469 last -> float.min(48.0, float.max(0.0, now -. last))470 }471 let model = Model(..model, last_tick: now, elapsed: model.elapsed +. dt)472473 let #(model, arrived) = advance_anim(model, dt)474 let model =475 Model(476 ..model,477 shake: float.max(0.0, model.shake -. dt *. 0.05),478 rise_t: float.min(1.0, model.rise_t +. dt /. 300.0),479 flash: float.max(0.0, model.flash -. dt),480 wipe: case model.wipe >=. 0.0 {481 True -> {482 let next = model.wipe +. dt *. 0.9483 case next >. int.to_float(int.max(model.layout.vw, model.layout.vh)) {484 True -> -1.0485 False -> next486 }487 }488 False -> -1.0489 },490 card_t: case model.screen == Card {491 True -> model.card_t +. dt492 False -> model.card_t493 },494 )495496 // The stepped form of each clock, worked out here rather than in the painter:497 // the loop has to know what the picture will be before it decides whether to498 // draw one, and two places rounding the same clock their own way is how they499 // drift apart.500 let model =501 Model(502 ..model,503 rise: case model.rise_t <. 1.0 {504 True -> float.round({ 1.0 -. model.rise_t } *. 12.0 /. 2.0) * 2505 False -> 0506 },507 shake_off: case model.shake >. 0.0 {508 True -> float.round(sine(model.shake *. 0.9) *. 2.0)509 False -> 0510 },511 // The card's title arrives in five steps, or has already arrived. The card512 // itself keeps its pause either way: it is a screen naming the board, not513 // an effect, and cutting it would be cutting information rather than514 // motion.515 card_step: case model.system.still {516 True -> 4517 False ->518 int.min(519 4,520 float.truncate(float.min(1.0, model.card_t /. 280.0) *. 5.0),521 )522 },523 )524525 // The card times out into the board; a solve waits for the wipe *and* for the526 // last measurement, so the record it writes down is the run that happened.527 let #(model, staged) = case model.screen, model.finishing {528 Card, _ if model.card_t >. 1300.0 -> {529 let #(m, e) = begin_play(model)530 #(m, e)531 }532 _, Some(deadline) if model.elapsed >=. deadline && model.awaiting == 0 ->533 finish(model)534 _, _ -> #(model, effect.none())535 }536537 let key = frame_key(model)538 case key == model.last_frame {539 True -> #(model, effect.batch([arrived, staged]))540 False -> #(541 Model(..model, last_frame: key),542 effect.batch([arrived, staged, paint(model)]),543 )544 }545}546547/// Everything the next picture will be made of, as one value to compare against548/// the last one. The clocks advance every frame; the picture does not.549fn frame_key(model: Model) -> String {550 let anim = case model.anim {551 Some(a) -> axis_id(a.axis) <> int.to_string(a.current)552 None -> ""553 }554 let drag = case model.drag {555 Some(d) -> int.to_string(d.piece) <> ":" <> int.to_string(d.offset)556 None -> ""557 }558 let sweep = case model.sweep {559 Some(s) -> s.family <> int.to_string(s.count) <> int.to_string(s.phase)560 None -> ""561 }562 [563 screen_id(model.screen),564 int.to_string(model.rise),565 int.to_string(model.shake_off),566 anim,567 drag,568 int.to_string(model.card_step),569 case model.card_t >. 240.0 {570 True -> "1"571 False -> "0"572 },573 case model.flash >. 0.0 {574 True -> "1"575 False -> "0"576 },577 case model.wipe >=. 0.0 {578 True -> int.to_string(float.round(model.wipe))579 False -> "-"580 },581 sweep,582 int.to_string(model.atlas_scroll),583 int.to_string(model.steps),584 int.to_string(list.length(model.trace)),585 int.to_string(list.length(model.previews)),586 ]587 |> list.fold("", fn(acc, part) { acc <> "|" <> part })588}589590fn advance_anim(model: Model, dt: Float) -> #(Model, Effect(Message)) {591 case model.anim {592 None -> #(model, effect.none())593 Some(a) -> {594 let elapsed = a.elapsed +. dt595 let k = float.min(1.0, elapsed /. a.duration)596 let eased = 1.0 -. cube(1.0 -. k)597 // Quantised to the shading period, so the pattern never lands out of598 // phase.599 let current =600 float.round(601 { a.from +. { a.to -. a.from } *. eased }602 /. int.to_float(shading.period),603 )604 * shading.period605 case k >=. 1.0 {606 False -> #(607 Model(..model, anim: Some(Anim(..a, elapsed:, current:))),608 effect.none(),609 )610 True ->611 case a.then {612 None -> #(Model(..model, anim: None), effect.none())613 Some(p) ->614 perform_move(615 Model(..model, anim: None),616 p.piece,617 p.direction,618 p.cells,619 )620 }621 }622 }623 }624}625626/// One through `count`, which is the shape every "for each cell travelled" loop627/// in here wants.628fn counting(count: Int) -> List(Int) {629 int.range(from: 1, to: count + 1, with: [], run: fn(items, n) { [n, ..items] })630 |> list.reverse631}632633fn cube(v: Float) -> Float {634 v *. v *. v635}636637@external(javascript, "./klotski_ffi.mjs", "sine")638fn sine(v: Float) -> Float639640// --------------------------------------------------------------------- flow641642fn go(model: Model, screen: Screen) -> #(Model, Effect(Message)) {643 let model = Model(..model, screen:, settings_open: False, menu_open: NoMenu)644 #(model, effect.batch([page(model), refresh_previews(model), paint(model)]))645}646647fn rung_of(model: Model, family: Family) -> Int {648 store.record_for(model.save, family.id).rung649}650651fn survey_of(model: Model, id: String) -> Option(Surveyed) {652 model.surveys653 |> list.find(fn(s) { s.family == id })654 |> option.from_result655}656657/// Open a family at whichever rung it has reached, sweeping it first if new.658fn open_family(model: Model, family: Family) -> #(Model, Effect(Message)) {659 let model = Model(..model, family:, rung: rung_of(model, family))660 case survey_of(model, family.id) {661 Some(_) -> #(662 Model(..model, settings_open: False),663 effect.batch([page(model), ask_for_puzzle(model, family, model.rung)]),664 )665 None -> {666 let model =667 Model(668 ..model,669 screen: Sweeping,670 settings_open: False,671 sweep: Some(Sweep(family.id, 1, 1, 1)),672 )673 #(674 model,675 effect.batch([676 page(model),677 browser.survey(678 family.id,679 family.width,680 family.height,681 family.goal_x,682 family.goal_y,683 families.grid(family),684 ),685 paint(model),686 ]),687 )688 }689 }690}691692fn survey_finished(693 model: Model,694 id: String,695 ecc: Int,696 count: Int,697) -> #(Model, Effect(Message)) {698 let surveys = [699 Surveyed(id, ecc, count),700 ..list.filter(model.surveys, fn(s) { s.family != id })701 ]702 let model = Model(..model, surveys:, sweep: None)703 case model.family.id == id && model.screen == Sweeping {704 True -> #(model, ask_for_puzzle(model, model.family, model.rung))705 False -> #(model, refresh_previews(model))706 }707}708709/// Which candidates a rung may be drawn from, and under what seeds.710///711/// The whole of the schedule is here: the depth the ladder asks for, whether it712/// has run out of depth, how wide the window of candidates is, where in the band713/// it starts, and the stream the walker is run under. The worker is handed those714/// numbers and hands back measurements.715fn ask_for_puzzle(model: Model, family: Family, rung: Int) -> Effect(Message) {716 case survey_of(model, family.id) {717 None -> effect.none()718 Some(sv) -> {719 let want = progress.depth_for_rung(rung, sv.ecc)720 let ceiling = want >= sv.ecc - 1721 let #(seed, _) =722 prng.next_int(prng.seeded_at(family.id, rung * 101), 1_000_000)723 browser.request_puzzle(724 family_id: family.id,725 rung:,726 want:,727 ceiling:,728 // Past the ceiling the band is the same every time, so the window729 // narrows: weighing twelve overlapping candidates and keeping the best730 // simply re-elects the same winner rung after rung.731 window: case ceiling {732 True -> progress.ceiling_pool733 False -> progress.pool734 },735 offset: prng.hash_string(family.id)736 + rung737 * case ceiling {738 True -> 5739 False -> 7740 },741 seed:,742 stray: difficulty.stray,743 rollouts: difficulty.rollouts,744 goal_x: family.goal_x,745 goal_y: family.goal_y,746 )747 }748 }749}750751type Candidate {752 Candidate(753 grid: List(Int),754 optimal: Int,755 wander: Float,756 retreats: Int,757 branch: Float,758 )759}760761fn candidate_decoder() -> decode.Decoder(Candidate) {762 use grid <- decode.field("grid", decode.list(decode.int))763 use optimal <- decode.field("optimal", decode.int)764 use wander <- decode.field("wander", decode.float)765 use retreats <- decode.field("retreats", decode.int)766 use branch <- decode.field("branch", decode.float)767 decode.success(Candidate(grid:, optimal:, wander:, retreats:, branch:))768}769770/// Keep the candidate whose state space punishes a strayed walker hardest.771fn puzzle_offered(772 model: Model,773 id: String,774 rung: Int,775 candidates: List(Dynamic),776) -> #(Model, Effect(Message)) {777 let scored =778 candidates779 |> list.filter_map(fn(raw) {780 decode.run(raw, candidate_decoder())781 |> result.map(fn(c) {782 Puzzle(783 family: id,784 rung:,785 grid: c.grid,786 optimal: c.optimal,787 score: difficulty.score(788 optimal: c.optimal,789 wander: c.wander,790 retreats: c.retreats,791 branch: c.branch,792 ),793 )794 })795 |> result.replace_error(Nil)796 })797 let best = hardest(scored, None)798799 case best {800 None -> #(model, effect.none())801 Some(puzzle) -> {802 let previews = [803 puzzle,804 ..list.filter(model.previews, fn(p) { p.family != id })805 ]806 let model = Model(..model, previews:)807 // A puzzle asked for because a family was opened becomes the card; one808 // asked for to fill an atlas row is only ever a number on a card.809 case model.family.id == id && model.rung == rung && wants_card(model) {810 False -> #(model, paint(model))811 True -> {812 let model =813 Model(..model, puzzle: Some(puzzle), card_t: 0.0, card_step: 0)814 go(model, Card)815 }816 }817 }818 }819}820821fn hardest(candidates: List(Puzzle), best: Option(Puzzle)) -> Option(Puzzle) {822 case candidates, best {823 [], _ -> best824 [next, ..rest], Some(held) ->825 case next.score >. held.score {826 True -> hardest(rest, Some(next))827 False -> hardest(rest, best)828 }829 [next, ..rest], None -> hardest(rest, Some(next))830 }831}832833fn wants_card(model: Model) -> Bool {834 case model.screen {835 Sweeping -> True836 Atlas -> True837 Won -> True838 _ -> False839 }840}841842fn open_rung(843 model: Model,844 family: Family,845 rung: Int,846) -> #(Model, Effect(Message)) {847 let model = Model(..model, family:, rung:, puzzle: None)848 #(model, ask_for_puzzle(model, family, rung))849}850851/// A family's atlas row shows what its next board costs, which means asking for852/// it. Only for families that have been swept, and only when what is cached is853/// for a rung that is no longer next.854fn refresh_previews(model: Model) -> Effect(Message) {855 case model.screen {856 Atlas ->857 families.all()858 |> list.filter_map(fn(family) {859 let rung = rung_of(model, family)860 let known =861 model.previews862 |> list.find(fn(p) { p.family == family.id && p.rung == rung })863 case survey_of(model, family.id), known {864 Some(_), Error(_) -> Ok(ask_for_puzzle(model, family, rung))865 _, _ -> Error(Nil)866 }867 })868 |> effect.batch869 _ -> effect.none()870 }871}872873fn begin_play(model: Model) -> #(Model, Effect(Message)) {874 case model.puzzle {875 None -> #(model, effect.none())876 Some(puzzle) -> {877 let board = families.board(model.family)878 let pieces = domain.pieces_from_grid(board, puzzle.grid)879 let model =880 Model(881 ..model,882 screen: Playing,883 settings_open: False,884 walking: False,885 pieces:,886 start_pieces: pieces,887 steps: 0,888 history: [],889 trace: [puzzle.optimal],890 awaiting: 0,891 drag: None,892 anim: None,893 shake: 0.0,894 shake_piece: None,895 finishing: None,896 // The board falls into place, unless the system asked for stillness —897 // then it is simply in place, which is where the fall was always going.898 rise_t: case model.system.still {899 True -> 1.0900 False -> 0.0901 },902 started_at: browser.now(),903 board: layout.board_layout(model.layout, model.family),904 )905 // The one place a board starts, whichever way it was reached, and so the906 // one place a waiting version is allowed in: the record is written, the907 // last board is finished with, and a reload costs a player the wait and908 // nothing else. Almost every time there is nothing waiting and this does909 // nothing at all.910 #(911 model,912 effect.batch([913 browser.apply_update(model.family.id),914 page(model),915 paint(model),916 ]),917 )918 }919 }920}921922/// Write the solve down.923///924/// Waited for on purpose: the shape of a run is only knowable once every step's925/// distance has been measured, and the trace is a session thing while the log is926/// a capped ring buffer, so there is no later moment to recover it from.927fn finish(model: Model) -> #(Model, Effect(Message)) {928 let stats = walk.trace_stats(model.trace)929 let optimal = case model.puzzle {930 Some(p) -> p.optimal931 None -> stats.optimal932 }933 let score = case model.puzzle {934 Some(p) -> p.score935 None -> 1.6936 }937 let #(save, gain) =938 store.record(939 save: model.save,940 family_id: model.family.id,941 rung: model.rung,942 optimal:,943 steps: model.steps,944 ms: int.max(0, browser.now() - model.started_at),945 score:,946 detours: stats.detours,947 worst_detour: stats.worst_detour,948 now: browser.now(),949 today: browser.today(),950 )951 let model =952 Model(..model, save:, screen: Won, finishing: None, walking: False)953 #(954 model,955 effect.batch([956 browser.persist(store.encode(save)),957 browser.buzz(958 case gain.levelled {959 True -> [24, 40, 90, 40, 120]960 False -> [24, 40, 90]961 },962 model.save.haptics,963 ),964 paint(model),965 ]),966 )967}968969// -------------------------------------------------------------------- moves970971fn perform_move(972 model: Model,973 piece_id: Int,974 direction: Direction,975 cells: Int,976) -> #(Model, Effect(Message)) {977 let board = families.board(model.family)978 let #(pieces, moved) =979 domain.move_many(board, model.pieces, piece_id, direction, cells)980 case moved {981 0 -> refuse(model, piece_id)982 _ -> {983 // Every configuration the move passed through, so the trace is measured984 // rather than inferred: a unit move changes the distance by exactly -1, 0985 // or +1, and a slide of four cells is four of those.986 let states =987 counting(moved)988 |> list.map(fn(n) {989 let #(at, _) =990 domain.move_many(board, model.pieces, piece_id, direction, n)991 domain.encode_state(board, at)992 })993 let seq = list.length(model.trace)994 let model =995 Model(996 ..model,997 pieces:,998 steps: model.steps + moved,999 history: [Step(piece_id, direction, moved), ..model.history],1000 drag: None,1001 awaiting: model.awaiting + 1,1002 )1003 let solved = domain.is_solved(board, pieces)1004 let sound =1005 effect.batch([1006 browser.steps(moved, model.save.sound),1007 browser.buzz([8], model.save.haptics),1008 ])1009 let measure = browser.request_distances(model.family.id, states, seq)1010 case solved {1011 False -> #(model, effect.batch([sound, measure, paint(model)]))1012 True -> {1013 // The inverting wipe, spent once per solve — and skipped entirely1014 // under reduced motion, along with the pause it exists to fill. It is1015 // the whole screen turning over; there is no gentler version of it1016 // that is still it.1017 let still = model.system.still1018 let model =1019 Model(1020 ..model,1021 wipe: case still {1022 True -> -1.01023 False -> 0.01024 },1025 finishing: Some(1026 model.elapsed1027 +. case still {1028 True -> 0.01029 False -> 330.01030 },1031 ),1032 )1033 #(1034 model,1035 effect.batch([1036 sound,1037 measure,1038 browser.cue("solve", model.save.sound),1039 paint(model),1040 ]),1041 )1042 }1043 }1044 }1045 }1046}10471048fn refuse(model: Model, piece_id: Int) -> #(Model, Effect(Message)) {1049 // The jolt that says a piece has nowhere to go. Under reduced motion the thud1050 // and the buzz say it on their own; they were never movement.1051 let model = case model.system.still {1052 True -> Model(..model, drag: None)1053 False ->1054 Model(..model, drag: None, shake: 12.0, shake_piece: Some(piece_id))1055 }1056 #(1057 model,1058 effect.batch([1059 browser.cue("thud", model.save.sound),1060 browser.buzz([10, 26, 10], model.save.haptics),1061 paint(model),1062 ]),1063 )1064}10651066fn undo(model: Model) -> #(Model, Effect(Message)) {1067 case model.history {1068 [] -> #(model, effect.none())1069 [Step(piece, direction, cells), ..rest] -> {1070 let pieces =1071 counting(cells)1072 |> list.fold(model.pieces, fn(pieces, _) {1073 domain.move_unchecked(pieces, piece, domain.opposite(direction))1074 })1075 let keep = int.max(1, list.length(model.trace) - cells)1076 let model =1077 Model(1078 ..model,1079 pieces:,1080 steps: int.max(0, model.steps - cells),1081 history: rest,1082 trace: list.take(model.trace, keep),1083 drag: None,1084 // A single struck frame of inversion. Not drawn under reduced motion:1085 // a full-screen flash is the one thing on this list that can actually1086 // hurt someone, and the undo is already announced by the board and the1087 // tone.1088 flash: case model.system.still {1089 True -> 0.01090 False -> 55.01091 },1092 )1093 #(1094 model,1095 effect.batch([1096 browser.cue("page", model.save.sound),1097 browser.buzz([6], model.save.haptics),1098 paint(model),1099 ]),1100 )1101 }1102 }1103}11041105fn measured(1106 model: Model,1107 id: String,1108 seq: Int,1109 distances: List(Int),1110) -> #(Model, Effect(Message)) {1111 let awaiting = int.max(0, model.awaiting - 1)1112 // An answer that no longer describes the run — an undo landed while it was in1113 // flight — is dropped rather than spliced in at the wrong place.1114 case id == model.family.id && list.length(model.trace) == seq {1115 False -> #(Model(..model, awaiting:), effect.none())1116 True -> {1117 let model =1118 Model(1119 ..model,1120 awaiting:,1121 trace: list.append(1122 model.trace,1123 list.filter(distances, fn(d) { d >= 0 }),1124 ),1125 )1126 #(model, effect.batch([keep_walking(model), paint(model)]))1127 }1128 }1129}11301131// ------------------------------------------------------------------ harness1132//1133// Walk the board down the distance field to the goal.1134//1135// A debugging aid rather than a feature — but a useful one, since it reaches the1136// record screen without anyone having to solve a hundred-step board by hand, and1137// it is how a screenshot of a real run gets taken.1138//1139// It plays through the game's own input path: the trace, the history, the sounds1140// and the record all happen exactly as they would under a thumb, which is the1141// whole point. A walk that had its own way of moving a block would be a second1142// implementation nobody looks at.11431144fn harness_step(model: Model) -> #(Model, Effect(Message)) {1145 // Only ever one question in flight, and only once the last step's own distance1146 // has landed: the next choice is made against the board that actually exists,1147 // not against the one the trace has not caught up with yet.1148 case model.screen, model.probing, model.awaiting {1149 Playing, [], 0 -> {1150 let board = families.board(model.family)1151 let moves =1152 model.pieces1153 |> list.flat_map(fn(piece) {1154 domain.move_options(board, model.pieces, piece.id)1155 |> list.map(fn(direction) { #(piece.id, direction) })1156 })1157 let states =1158 list.map(moves, fn(move) {1159 let #(piece, direction) = move1160 let #(after, _) =1161 domain.move_many(board, model.pieces, piece, direction, 1)1162 domain.encode_state(board, after)1163 })1164 #(1165 Model(..model, probing: moves, walking: True),1166 browser.probe(model.family.id, states),1167 )1168 }1169 Playing, _, _ -> #(Model(..model, walking: True), effect.none())1170 _, _, _ -> #(model, effect.none())1171 }1172}11731174fn harness_took(1175 model: Model,1176 id: String,1177 distances: List(Int),1178) -> #(Model, Effect(Message)) {1179 let here = current_distance(model)1180 let downhill =1181 list.zip(model.probing, distances)1182 |> list.find(fn(pair) { pair.1 == here - 1 })1183 let model = Model(..model, probing: [])1184 case id == model.family.id, downhill {1185 True, Ok(#(#(piece, direction), _)) ->1186 perform_move(model, piece, direction, 1)1187 // Nothing downhill from here means the board is solved, or the walk was1188 // asked for on a board the survey no longer describes.1189 _, _ -> #(Model(..model, walking: False), effect.none())1190 }1191}11921193/// The step that keeps a walk going, taken when the last one has been measured.1194fn keep_walking(model: Model) -> Effect(Message) {1195 case model.walking && model.screen == Playing && model.awaiting == 0 {1196 True -> effect.from(fn(dispatch) { dispatch(HarnessWalked) })1197 False -> effect.none()1198 }1199}12001201// -------------------------------------------------------------------- input12021203fn piece_at(model: Model, x: Float, y: Float) -> Option(Piece) {1204 let b = model.board1205 let cell = b.cell1206 let cx =1207 float.truncate({ x -. int.to_float(b.board.x) } /. int.to_float(cell))1208 let cy =1209 float.truncate(1210 { y -. int.to_float(b.board.y - model.rise) } /. int.to_float(cell),1211 )1212 case1213 cx < 0 || cy < 0 || cx >= model.family.width || cy >= model.family.height1214 {1215 True -> None1216 False ->1217 model.pieces1218 |> list.find(fn(p) {1219 cx >= p.x && cx < p.x + p.width && cy >= p.y && cy < p.y + p.height1220 })1221 |> option.from_result1222 }1223}12241225fn pointer_down(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1226 case model.screen {1227 Boot -> {1228 let #(m, e) = go(model, Atlas)1229 #(m, effect.batch([browser.unlock_audio(), e]))1230 }1231 Card -> begin_play(model)1232 Atlas -> #(1233 Model(..model, scroll: Some(#(y, model.atlas_scroll, 0.0))),1234 effect.none(),1235 )1236 Playing ->1237 case model.anim {1238 Some(_) -> #(model, effect.none())1239 None ->1240 case piece_at(model, x, y) {1241 None -> #(model, effect.none())1242 Some(piece) -> #(1243 Model(..model, drag: Some(Drag(piece.id, x, y, None, 0, 0, 0))),1244 paint(model),1245 )1246 }1247 }1248 _ -> #(model, effect.none())1249 }1250}12511252fn pointer_moved(1253 model: Model,1254 x: Float,1255 y: Float,1256) -> #(Model, Effect(Message)) {1257 case model.scroll, model.drag {1258 Some(#(origin, from, moved)), _ -> {1259 let dy = y -. origin1260 let model =1261 Model(1262 ..model,1263 scroll: Some(#(1264 origin,1265 from,1266 float.max(moved, float.absolute_value(dy)),1267 )),1268 atlas_scroll: clamp_scroll(model, from + float.round(dy)),1269 )1270 #(model, paint(model))1271 }1272 None, Some(drag) -> {1273 let dx = x -. drag.origin_x1274 let dy = y -. drag.origin_y1275 case drag.axis {1276 None ->1277 case1278 float.absolute_value(dx) <. 3.0 && float.absolute_value(dy) <. 3.01279 {1280 True -> #(model, effect.none())1281 False -> begin_axis(model, drag, dx, dy)1282 }1283 Some(axis) -> {1284 let raw = case axis {1285 Horizontal -> dx1286 Vertical -> dy1287 }1288 let clamped =1289 float.max(1290 int.to_float(drag.reach_negative),1291 float.min(int.to_float(drag.reach_positive), raw),1292 )1293 let offset =1294 float.round(clamped /. int.to_float(shading.period))1295 * shading.period1296 #(Model(..model, drag: Some(Drag(..drag, offset:))), paint(model))1297 }1298 }1299 }1300 None, None -> #(model, effect.none())1301 }1302}13031304fn begin_axis(1305 model: Model,1306 drag: Drag,1307 dx: Float,1308 dy: Float,1309) -> #(Model, Effect(Message)) {1310 let axis = case float.absolute_value(dx) >. float.absolute_value(dy) {1311 True -> Horizontal1312 False -> Vertical1313 }1314 let board = families.board(model.family)1315 let #(forward, backward) = case axis {1316 Horizontal -> #(Right, Left)1317 Vertical -> #(Down, Up)1318 }1319 let cell = model.board.cell1320 let positive =1321 domain.free_cells(board, model.pieces, drag.piece, forward) * cell1322 let negative =1323 -domain.free_cells(board, model.pieces, drag.piece, backward) * cell1324 case positive == 0 && negative == 0 {1325 True -> refuse(model, drag.piece)1326 False -> #(1327 Model(1328 ..model,1329 drag: Some(1330 Drag(1331 ..drag,1332 axis: Some(axis),1333 reach_positive: positive,1334 reach_negative: negative,1335 ),1336 ),1337 ),1338 paint(model),1339 )1340 }1341}13421343fn clamp_scroll(model: Model, y: Int) -> Int {1344 let g = paint.atlas_geometry(model.layout, families.count())1345 let over =1346 paint.atlas_content_height(g) - { model.layout.vh - model.layout.bar_h - 6 }1347 case over <= 0 {1348 True -> 01349 False -> int.max(-over, int.min(0, y))1350 }1351}13521353fn pointer_up(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1354 case model.scroll {1355 // A short drag is a tap. Anything longer was someone scrolling the list.1356 Some(#(_, _, moved)) -> {1357 let model = Model(..model, scroll: None)1358 case moved <. 5.0 {1359 False -> #(model, effect.none())1360 True -> atlas_tap(model, x, y)1361 }1362 }1363 None ->1364 case model.drag {1365 None -> #(model, effect.none())1366 Some(drag) -> end_drag(Model(..model, drag: None), drag)1367 }1368 }1369}13701371fn end_drag(model: Model, drag: Drag) -> #(Model, Effect(Message)) {1372 let board = families.board(model.family)1373 let cell = model.board.cell1374 case drag.axis {1375 // A tap, not a drag. If there is exactly one way out, take it — the1376 // affordance that makes the board feel like it wants to be solved.1377 None ->1378 case domain.move_options(board, model.pieces, drag.piece) {1379 [only] -> {1380 let #(dx, dy) = domain.direction_delta(only)1381 animate(1382 model,1383 drag.piece,1384 case dx {1385 0 -> Vertical1386 _ -> Horizontal1387 },1388 0.0,1389 int.to_float({ dx + dy } * cell),1390 Some(Pending(drag.piece, only, 1)),1391 )1392 }1393 [] -> refuse(model, drag.piece)1394 _ -> #(model, paint(model))1395 }1396 Some(axis) -> {1397 let cells = float.round(int.to_float(drag.offset) /. int.to_float(cell))1398 case cells {1399 0 ->1400 animate(model, drag.piece, axis, int.to_float(drag.offset), 0.0, None)1401 _ -> {1402 let direction = case axis, cells > 0 {1403 Horizontal, True -> Right1404 Horizontal, False -> Left1405 Vertical, True -> Down1406 Vertical, False -> Up1407 }1408 animate(1409 model,1410 drag.piece,1411 axis,1412 int.to_float(drag.offset),1413 int.to_float(cells * cell),1414 Some(Pending(drag.piece, direction, int.absolute_value(cells))),1415 )1416 }1417 }1418 }1419 }1420}14211422fn animate(1423 model: Model,1424 piece: Int,1425 axis: Axis,1426 from: Float,1427 to: Float,1428 then: Option(Pending),1429) -> #(Model, Effect(Message)) {1430 case model.system.still {1431 True ->1432 case then {1433 None -> #(model, paint(model))1434 Some(p) -> perform_move(model, p.piece, p.direction, p.cells)1435 }1436 False -> #(1437 Model(1438 ..model,1439 anim: Some(Anim(1440 piece:,1441 axis:,1442 from:,1443 to:,1444 current: float.round(from),1445 elapsed: 0.0,1446 duration: float.max(80.0, float.absolute_value(to -. from) *. 3.0),1447 then:,1448 )),1449 ),1450 paint(model),1451 )1452 }1453}14541455fn atlas_tap(model: Model, x: Float, y: Float) -> #(Model, Effect(Message)) {1456 let g = paint.atlas_geometry(model.layout, families.count())1457 let hit =1458 families.all()1459 |> list.index_map(fn(family, index) { #(family, index) })1460 |> list.find(fn(entry) {1461 let r = paint.family_row_rect(g, entry.1, model.atlas_scroll)1462 x >=. int.to_float(r.x)1463 && x <=. int.to_float(r.x + r.w)1464 && y >=. int.to_float(r.y)1465 && y <=. int.to_float(r.y + r.h)1466 })1467 case hit {1468 Error(_) -> #(model, effect.none())1469 Ok(#(family, _)) -> open_family(model, family)1470 }1471}14721473fn key_pressed(model: Model, key: String) -> #(Model, Effect(Message)) {1474 case key, model.screen {1475 "Escape", _ ->1476 case model.menu_open != NoMenu, model.settings_open {1477 True, _ -> #(Model(..model, menu_open: NoMenu), effect.none())1478 False, True -> #(Model(..model, settings_open: False), effect.none())1479 False, False -> #(model, effect.none())1480 }1481 _, Playing ->1482 case model.anim, key_direction(key) {1483 None, Some(direction) -> arrow_move(model, direction)1484 _, _ -> #(model, effect.none())1485 }1486 _, _ -> #(model, effect.none())1487 }1488}14891490fn key_direction(key: String) -> Option(Direction) {1491 case key {1492 "ArrowUp" -> Some(Up)1493 "ArrowDown" -> Some(Down)1494 "ArrowLeft" -> Some(Left)1495 "ArrowRight" -> Some(Right)1496 _ -> None1497 }1498}14991500/// Arrow keys move the goal block, which is the piece a keyboard has any1501/// business naming: it is the one with an identity, and every other piece is1502/// interchangeable with something the same shape.1503fn arrow_move(model: Model, direction: Direction) -> #(Model, Effect(Message)) {1504 let board = families.board(model.family)1505 let goal = model.pieces |> list.find(fn(p) { p.shape == domain.Goal })1506 case goal {1507 Error(_) -> #(model, effect.none())1508 Ok(piece) ->1509 case domain.can_move(board, model.pieces, piece.id, direction) {1510 False -> refuse(model, piece.id)1511 True -> {1512 let #(dx, dy) = domain.direction_delta(direction)1513 let cell = model.board.cell1514 animate(1515 model,1516 piece.id,1517 case dx {1518 0 -> Vertical1519 _ -> Horizontal1520 },1521 0.0,1522 int.to_float({ dx + dy } * cell),1523 Some(Pending(piece.id, direction, 1)),1524 )1525 }1526 }1527 }1528}15291530// ------------------------------------------------------------------- effects15311532fn persist(model: Model) -> Effect(Message) {1533 browser.persist(store.encode(model.save))1534}15351536fn page(model: Model) -> Effect(Message) {1537 browser.cue("page", model.save.sound)1538}15391540fn relayout(model: Model) -> Model {1541 let #(x, y, w, h) = render.safe_box()1542 let l =1543 layout.choose(1544 layout.Box(x, y, w, h),1545 render.device_pixel_ratio(),1546 store.density_units(store.Standard),1547 )1548 Model(..model, layout: l, board: layout.board_layout(l, model.family))1549}15501551fn repaint(model: Model) -> Effect(Message) {1552 paint(model)1553}15541555// ------------------------------------------------------------------ the look15561557fn active_theme(model: Model) -> Theme {1558 theme.by_id(theme.resolve(model.save.palette, model.system.dark))1559}15601561/// The look of the board on screen: drawn from the theme's pools and seeded on1562/// the family and rung, but only where there is a board. The atlas, the record1563/// and the title are chrome and keep the house style; a rung is also the only1564/// thing there is to seed from, and those screens do not have one.1565///1566/// The record screen counts as the board: it is the same plate, the same family1567/// and the same rung, arrived at through a wipe. Handing it back to the house1568/// lattice snapped the backdrop the instant the puzzle was solved, which read as1569/// the reward taking the world away.1570fn combo_for(model: Model, t: Theme) -> Combo {1571 let on_board = case model.screen {1572 Card | Playing | Won -> True1573 _ -> False1574 }1575 let base = case on_board {1576 True -> theme.combo_for(t, model.family.id, model.rung)1577 False -> theme.house_combo(t)1578 }1579 theme.apply_pin(base, model.save.shading)1580}15811582fn frame_screen(model: Model, combo: Combo) -> Shade {1583 let density = store.density_units(model.save.density)1584 let cell = int.max(1, model.board.cell)1585 // Stepped down to what the smallest piece on this board can carry *and* to1586 // what stays in register with the grid it is printed on: the screen runs1587 // across the whole picture, so a cell that is not a whole number of periods1588 // wide would put every piece on a different part of the pattern from its1589 // neighbour.1590 let ruling =1591 shading.fit_ruling(combo.ruling, { cell - 6 } * density, cell * density)1592 paint.screen_for(combo.shading, ruling)1593}15941595fn paint(model: Model) -> Effect(Message) {1596 let t = active_theme(model)1597 let combo = combo_for(model, t)1598 let screen = frame_screen(model, combo)1599 let ops = ops_for(model, t, screen, combo)1600 effect.batch([1601 browser.set_theme_colour(1602 rgb_css(t.ink),1603 rgb_css(t.paper),1604 rgb_css(combo.ground.field),1605 ),1606 render.paint(scene.Frame(1607 scale: model.layout.scale,1608 vw: model.layout.vw,1609 vh: model.layout.vh,1610 off_x: model.layout.off_x,1611 off_y: model.layout.off_y,1612 density: store.density_units(model.save.density),1613 ink: t.ink,1614 paper: t.paper,1615 gold: t.gold,1616 screen:,1617 tile: ground.tile(combo.backdrop),1618 dot: combo.ground.dot,1619 field: combo.ground.field,1620 flash: model.flash >. 0.0,1621 wipe: model.wipe,1622 ops:,1623 )),1624 ])1625}16261627fn rgb_css(colour: theme.Rgb) -> String {1628 "rgb("1629 <> int.to_string(colour.r)1630 <> ","1631 <> int.to_string(colour.g)1632 <> ","1633 <> int.to_string(colour.b)1634 <> ")"1635}16361637fn ops_for(1638 model: Model,1639 t: Theme,1640 screen: Shade,1641 _combo: Combo,1642) -> List(scene.Op) {1643 case model.screen {1644 Boot -> paint.boot(model.layout, families.at(0), screen, t)1645 Sweeping -> {1646 let sweep = model.sweep |> option.unwrap(Sweep(model.family.id, 0, 1, 1))1647 paint.survey(1648 model.layout,1649 model.family.label,1650 sweep.count,1651 sweep.phase,1652 int.to_float(sweep.count) /. int.to_float(int.max(1, sweep.total)),1653 )1654 }1655 Atlas ->1656 paint.atlas(1657 model.layout,1658 atlas_rows(model, t),1659 model.atlas_scroll,1660 t,1661 theme.logo_ink(t, browser.day_of_year()),1662 store.density_units(model.save.density),1663 )1664 Card ->1665 paint.card(1666 model.layout,1667 model.family.label,1668 model.rung,1669 optimal(model),1670 model.card_step,1671 model.card_t >. 240.0,1672 )1673 Playing ->1674 paint.play(1675 model.layout,1676 model.board,1677 model.family,1678 placed(model),1679 screen,1680 t,1681 model.rise,1682 model.save.hints,1683 current_distance(model),1684 gauge_reference(model),1685 )1686 Won -> paint.won(model.layout, model.board, model.family, model.trace)1687 Record -> {1688 let records =1689 families.all()1690 |> list.map(fn(family) {1691 #(family, store.record_for(model.save, family.id))1692 })1693 paint.stats(1694 model.layout,1695 model.save.totals,1696 progress.level_progress(model.save.totals.xp),1697 model.save.days.streak,1698 records,1699 model.save.log,1700 list.length(model.surveys),1701 list.fold(model.surveys, 0, fn(n, s) { n + s.count }),1702 )1703 }1704 }1705}17061707fn optimal(model: Model) -> Int {1708 case model.puzzle {1709 Some(p) -> p.optimal1710 None -> 01711 }1712}17131714fn current_distance(model: Model) -> Int {1715 model.trace |> list.last |> result.unwrap(optimal(model))1716}17171718/// FULL scales the gauge to the distance you started at. BEST scales it to the1719/// furthest out you have ever been on this board instead: the number is gone, so1720/// the gauge is all there is, and anchoring it to your own worst point makes it1721/// a reading of your progress rather than a disguised step count.1722fn gauge_reference(model: Model) -> Int {1723 let start = model.trace |> list.first |> result.unwrap(optimal(model))1724 case model.save.hints {1725 Best -> list.fold(model.trace, start, int.max)1726 _ -> start1727 }1728}17291730fn placed(model: Model) -> List(paint.Placed) {1731 let b = model.board1732 let cell = b.cell1733 let bx = b.board.x1734 let by = b.board.y - model.rise1735 list.map(model.pieces, fn(piece) {1736 let #(dx, dy) = piece_offset(model, piece.id)1737 let jolt = case model.shake_piece == Some(piece.id) {1738 True -> model.shake_off1739 False -> 01740 }1741 paint.Placed(1742 // The offset moves the block; it does not move the screen the block is cut1743 // from. A piece under a thumb carries its own ink across the board.1744 x: bx + piece.x * cell + dx + jolt,1745 y: by + piece.y * cell + dy,1746 w: piece.width * cell,1747 h: piece.height * cell,1748 class: domain.shape_class(piece.shape),1749 rest_x: bx + piece.x * cell,1750 rest_y: by + piece.y * cell,1751 thick: case model.drag {1752 Some(d) -> d.piece == piece.id1753 None -> False1754 },1755 )1756 })1757}17581759fn piece_offset(model: Model, id: Int) -> #(Int, Int) {1760 case model.drag {1761 Some(Drag(piece, _, _, Some(axis), offset, _, _)) if piece == id ->1762 case axis {1763 Horizontal -> #(offset, 0)1764 Vertical -> #(0, offset)1765 }1766 _ ->1767 case model.anim {1768 Some(a) if a.piece == id ->1769 case a.axis {1770 Horizontal -> #(a.current, 0)1771 Vertical -> #(0, a.current)1772 }1773 _ -> #(0, 0)1774 }1775 }1776}17771778fn atlas_rows(model: Model, t: Theme) -> List(paint.Row) {1779 list.map(families.all(), fn(family) {1780 let rung = rung_of(model, family)1781 let survey = survey_of(model, family.id)1782 let preview =1783 model.previews1784 |> list.find(fn(p) { p.family == family.id && p.rung == rung })1785 |> option.from_result1786 let ecc = case survey {1787 Some(s) -> s.ecc1788 None -> 01789 }1790 let combo =1791 theme.apply_pin(theme.combo_for(t, family.id, rung), model.save.shading)1792 paint.Row(1793 family:,1794 rung:,1795 grid: case preview {1796 Some(p) -> p.grid1797 None -> families.grid(family)1798 },1799 surveyed: option.is_some(survey) && option.is_some(preview),1800 optimal: case preview {1801 Some(p) -> p.optimal1802 None -> 01803 },1804 grade: case preview {1805 Some(p) -> difficulty.grade(p.score)1806 None -> 11807 },1808 ecc:,1809 at_ceiling: progress.at_ceiling(rung, ecc),1810 depth: progress.depth_for_rung(rung, ecc),1811 look: #(combo.shading, combo.ruling),1812 )1813 })1814}18151816// --------------------------------------------------------------------- view1817//1818// Crisp mono chrome, laid over the shaded canvas. Every serif on screen is drawn1819// into the canvas instead, so it gets thresholded; anything here is a control,1820// and a control has to be legible before it is atmospheric.1821//1822// A zone is a band of DOM laid over the picture, in the picture's own units. It1823// used to span the picture, because the picture was the composition; now the1824// composition is a column somewhere inside it, so a zone is told which box it1825// belongs to and only the menu bar still spans the screen.18261827fn view(model: Model) -> Element(Message) {1828 let l = model.layout1829 html.div(1830 [1831 attribute.id("stage"),1832 attribute.attribute("data-mode", case l.mode == Split {1833 True -> "split"1834 False -> "stack"1835 }),1836 attribute.styles([1837 #("left", px(int.to_float(l.off_x))),1838 #("top", px(int.to_float(l.off_y))),1839 #("width", px(int.to_float(l.vw) *. l.scale)),1840 #("height", px(int.to_float(l.vh) *. l.scale)),1841 #("--s", float.to_string(l.scale)),1842 ]),1843 ],1844 [1845 // The picture is shaded at low resolution; type rides above it at full1846 // device resolution so it never meets the threshold.1847 html.canvas([1848 attribute.id("board"),1849 attribute.styles([1850 #("width", px(int.to_float(l.vw) *. l.scale)),1851 #("height", px(int.to_float(l.vh) *. l.scale)),1852 ]),1853 ]),1854 html.canvas([1855 attribute.id("type"),1856 attribute.styles([1857 #("width", px(int.to_float(l.vw) *. l.scale)),1858 #("height", px(int.to_float(l.vh) *. l.scale)),1859 ]),1860 ]),1861 html.div([attribute.id("ui")], chrome(model)),1862 ],1863 )1864}18651866fn px(value: Float) -> String {1867 float.to_string(value) <> "px"1868}18691870fn chrome(model: Model) -> List(Element(Message)) {1871 let l = model.layout1872 let b = model.board1873 let playing = model.screen == Playing1874 let won = model.screen == Won1875 let has_bar = case model.screen {1876 Atlas | Playing | Won | Record -> True1877 _ -> False1878 }18791880 let panel = layout.Column(b.panel.x, b.panel.w, 0.0)1881 let column = layout.Column(l.col.x, l.col.w, 0.0)18821883 [1884 case has_bar {1885 True -> menu_zone(model)1886 False -> element.none()1887 },1888 // The zone is the drawn scale, so the two end labels land on its two ends1889 // without a padding that has to be kept in step with it by hand.1890 case playing && model.save.hints != OffHints {1891 True ->1892 zone(1893 model,1894 "gauge",1895 b.gauge.y + 6 - gauge_rise(model),1896 14,1897 layout.Column(b.gauge.x, b.gauge.w, 0.0),1898 [1899 html.span([attribute.class("lbl")], [1900 // The gauge's left end stops being the start once BEST anchors it1901 // to the furthest out you have been, and a scale labelled with the1902 // wrong end is worse than no label.1903 html.text(case model.save.hints {1904 Best -> "DEEPEST"1905 _ -> "START"1906 }),1907 ]),1908 html.span([attribute.class("spacer")], []),1909 html.span([attribute.class("lbl")], [html.text("SOLVED")]),1910 ],1911 )1912 False -> element.none()1913 },1914 case playing {1915 True ->1916 zone(model, "hud", b.hud_y, 30, panel, [1917 stat(int.to_string(model.steps), "STEPS"),1918 case model.save.hints {1919 Full -> stat(int.to_string(current_distance(model)), "TO GOAL")1920 _ -> element.none()1921 },1922 stat(int.to_string(optimal(model)), "OPTIMAL"),1923 ])1924 False -> element.none()1925 },1926 case won {1927 True -> record_zone(model, b, panel)1928 False -> element.none()1929 },1930 case playing || won {1931 True ->1932 zone(model, "acts", b.panel.actions_y, 34, panel, case playing {1933 True -> [1934 html.button(1935 [1936 event.on_click(UndoPressed),1937 attribute.disabled(list.is_empty(model.history)),1938 ],1939 [html.text("UNDO")],1940 ),1941 html.button(1942 [attribute.class("ghost"), event.on_click(ResetPressed)],1943 [html.text("RESET")],1944 ),1945 ]1946 False -> [1947 html.button([event.on_click(NextPressed)], [html.text("NEXT")]),1948 html.button(1949 [attribute.class("ghost"), event.on_click(BackPressed)],1950 [html.text("PUZZLES")],1951 ),1952 ]1953 })1954 False -> element.none()1955 },1956 case model.screen == Boot {1957 True -> {1958 // Held clear of the window's own bottom rule: the fraction alone puts1959 // the box through it once the window is short enough.1960 let w = l.boot_win1961 zone(1962 model,1963 "gate",1964 int.min(w.y + float.round(int.to_float(w.h) *. 0.85), w.y + w.h - 24),1965 20,1966 column,1967 [1968 html.span([attribute.class("val beat")], [html.text("TAP TO BEGIN")]),1969 ],1970 )1971 }1972 False -> element.none()1973 },1974 case model.settings_open {1975 True -> sheet(model)1976 False -> element.none()1977 },1978 ]1979}19801981fn gauge_rise(model: Model) -> Int {1982 case model.layout.mode == Split {1983 True -> 01984 False -> model.rise1985 }1986}19871988fn zone(1989 model: Model,1990 id: String,1991 top: Int,1992 height: Int,1993 box: layout.Column,1994 children: List(Element(Message)),1995) -> Element(Message) {1996 let s = model.layout.scale1997 html.div(1998 [1999 attribute.id(id),2000 attribute.class("zone"),2001 attribute.styles([2002 #("top", px(int.to_float(top) *. s)),2003 #("height", px(int.to_float(height) *. s)),2004 #("left", px(int.to_float(box.x) *. s)),2005 #("width", px(int.to_float(box.w) *. s)),2006 ]),2007 ],2008 children,2009 )2010}20112012fn menu_zone(model: Model) -> Element(Message) {2013 let l = model.layout2014 let s = l.scale2015 let on_atlas = model.screen == Atlas2016 html.div(2017 [2018 attribute.id("top"),2019 attribute.class("zone"),2020 attribute.styles([2021 #("top", px(1.0 *. s)),2022 #("height", px(int.to_float(l.bar_h - 2) *. s)),2023 #("left", "0"),2024 #("width", "100%"),2025 ]),2026 ],2027 [2028 case on_atlas {2029 True -> html.span([attribute.class("spacer")], [])2030 False ->2031 html.button([attribute.class("ghost"), event.on_click(BackPressed)], [2032 html.text("BACK"),2033 ])2034 },2035 html.span([attribute.class("spacer")], []),2036 case on_atlas && model.install_available {2037 True ->2038 html.button([event.on_click(InstallPressed)], [html.text("INSTALL")])2039 False -> element.none()2040 },2041 // Boxed icons, the way a system of this vintage drew a toolbar: a hard2042 // rectangle with a glyph struck inside it, rather than a word. Drawn as2043 // inline SVG on integer coordinates with crispEdges, so the strokes land2044 // on whole pixels like everything else, and in currentColor so they invert2045 // with the box when it is pressed. The canvas has no SVG anywhere and2046 // should not; this is DOM chrome, which is exactly where a vector glyph2047 // belongs.2048 case on_atlas {2049 True ->2050 icon_button("Record", StatsPressed, [2051 #(0.0, 6.0, 2.0, 3.0),2052 #(3.5, 3.0, 2.0, 6.0),2053 #(7.0, 0.0, 2.0, 9.0),2054 ])2055 False -> element.none()2056 },2057 case on_atlas {2058 True ->2059 icon_button("Settings", SettingsOpened, [2060 #(0.0, 2.0, 9.0, 1.0),2061 #(2.0, 0.0, 2.0, 5.0),2062 #(0.0, 6.0, 9.0, 1.0),2063 #(5.0, 4.0, 2.0, 5.0),2064 ])2065 False -> element.none()2066 },2067 ],2068 )2069}20702071fn icon_button(2072 label: String,2073 message: Message,2074 bars: List(#(Float, Float, Float, Float)),2075) -> Element(Message) {2076 html.button(2077 [2078 attribute.class("iconbtn"),2079 attribute.attribute("aria-label", label),2080 attribute.title(label),2081 event.on_click(message),2082 ],2083 [2084 element.namespaced(2085 "http://www.w3.org/2000/svg",2086 "svg",2087 [2088 attribute.attribute("viewBox", "0 0 9 9"),2089 attribute.attribute("aria-hidden", "true"),2090 attribute.attribute("focusable", "false"),2091 ],2092 list.map(bars, fn(bar) {2093 let #(x, y, w, h) = bar2094 element.namespaced(2095 "http://www.w3.org/2000/svg",2096 "rect",2097 [2098 attribute.attribute("x", float.to_string(x)),2099 attribute.attribute("y", float.to_string(y)),2100 attribute.attribute("width", float.to_string(w)),2101 attribute.attribute("height", float.to_string(h)),2102 ],2103 [],2104 )2105 }),2106 ),2107 ],2108 )2109}21102111/// What the run was made of, which is what the chart above it is drawing.2112///2113/// A detour is one excursion above the closest you had already been — one bump2114/// on the ridge — and DEEPEST is how far back the worst of them put you. Neither2115/// can be worked out from the two numbers beside them, which is the test every2116/// one of these has to pass: a step back costs two steps, so "wrong moves" is2117/// just the gap between steps and optimal, halved, and printing it would be2118/// printing the same fact twice.2119fn record_zone(2120 model: Model,2121 b: BoardLayout,2122 panel: layout.Column,2123) -> Element(Message) {2124 let stats = walk.trace_stats(model.trace)2125 zone(model, "record", b.record_y, 34, panel, [2126 stat(int.to_string(model.steps), "YOUR STEPS"),2127 stat(int.to_string(optimal(model)), "OPTIMAL"),2128 stat(int.to_string(stats.detours), "DETOURS"),2129 stat(int.to_string(stats.worst_detour), "DEEPEST"),2130 ])2131}21322133fn stat(value: String, label: String) -> Element(Message) {2134 html.div([attribute.class("stat")], [2135 html.span([attribute.class("val")], [html.text(value)]),2136 html.span([attribute.class("lbl")], [html.text(label)]),2137 ])2138}21392140// ------------------------------------------------------------------- sheet21412142fn sheet(model: Model) -> Element(Message) {2143 html.div(2144 [2145 attribute.id("sheet"),2146 attribute.class("open"),2147 attribute.attribute("role", "dialog"),2148 attribute.attribute("aria-modal", "true"),2149 attribute.attribute("aria-label", "Settings"),2150 event.on_click(MenuDismissed),2151 ],2152 [2153 // The dim behind an open pull-down. Rendered before everything so it is2154 // behind everything, and inside the sheet so a click on it is a click on2155 // the sheet, which is already what closes a menu.2156 case model.menu_open == NoMenu {2157 True -> element.none()2158 False -> html.div([attribute.class("scrim")], [])2159 },2160 // The three that change how the picture looks come first and together:2161 // what it is printed in, what the blocks are printed with, and how finely.2162 // Reading them apart, with the hints and the sound in between, made three2163 // settings about one thing look like three unrelated ones.2164 //2165 // Both of the first two are pull-downs. The blocks always were — nine2166 // answers wrapped into three ragged lines and read as a wall — and the2167 // theme now is too, because it is the same question: one of a closed list,2168 // or AUTO. A row of buttons said "these are all live at once", which is2169 // the one thing a setting with a single answer is not. The ruling is not2170 // here at all; it is not a preference.2171 pull_down(2172 "THEME",2173 case model.save.palette {2174 "auto" -> "AUTO"2175 id -> theme.by_id(id).name2176 },2177 model.menu_open == ThemeMenu,2178 MenuToggled(ThemeMenu),2179 #(model.save.palette == "auto", PaletteChosen("auto")),2180 list.map(theme.all(), fn(t) {2181 #(t.name, model.save.palette == t.id, PaletteChosen(t.id))2182 }),2183 ),2184 pull_down(2185 "BLOCKS",2186 case model.save.shading {2187 Ok(pattern) -> shading.pattern_label(pattern)2188 Error(_) -> "AUTO"2189 },2190 model.menu_open == BlocksMenu,2191 MenuToggled(BlocksMenu),2192 #(model.save.shading == Error(Nil), ShadingChosen(Error(Nil))),2193 list.map(shading.patterns(), fn(pattern) {2194 #(2195 shading.pattern_label(pattern),2196 model.save.shading == Ok(pattern),2197 ShadingChosen(Ok(pattern)),2198 )2199 }),2200 ),2201 group("INTENSITY", [2202 setting(2203 store.density_label(store.Low),2204 model.save.density == store.Low,2205 DensityChosen(store.Low),2206 ),2207 setting(2208 store.density_label(store.Standard),2209 model.save.density == store.Standard,2210 DensityChosen(store.Standard),2211 ),2212 ]),2213 group("HINTS", [2214 setting("FULL", model.save.hints == Full, HintsChosen(Full)),2215 setting("BEST", model.save.hints == Best, HintsChosen(Best)),2216 setting("OFF", model.save.hints == OffHints, HintsChosen(OffHints)),2217 ]),2218 group("SOUND", [2219 setting("TONES", model.save.sound, SoundToggled),2220 setting("HAPTICS", model.save.haptics, HapticsToggled),2221 ]),2222 html.div([attribute.class("credit")], [2223 html.text("FONTS BY STEFAN SCHMIDT & COLOPHON FOUNDRY"),2224 ]),2225 html.button([event.on_click(SettingsClosed)], [html.text("CLOSE")]),2226 ],2227 )2228}22292230/// A pull-down, and both of the sheet's menus are one of these.2231///2232/// Built out of real elements rather than a `<select>`: a native menu is the one2233/// control the platform insists on drawing in its own house style, and it would2234/// be the only rounded, shaded, system-blue thing on screen. So this is the2235/// QuickDraw menu instead — a hard box, a hairline frame, inversion for the2236/// highlight, and a solid drop shadow.2237///2238/// AUTO is always first and always ruled off from what follows, because it is a2239/// different kind of answer: everything below it names a thing, and AUTO says2240/// let something else decide.2241///2242/// The click that opens it, and every click inside it, is stopped here. The2243/// sheet behind is listening for everything else, so a click anywhere off the2244/// menu closes it.2245fn pull_down(2246 label: String,2247 title: String,2248 open: Bool,2249 toggle: Message,2250 automatic: #(Bool, Message),2251 options: List(#(String, Bool, Message)),2252) -> Element(Message) {2253 let #(auto_on, auto_message) = automatic2254 html.div([attribute.class("group")], [2255 html.div([attribute.class("grouplbl")], [html.text(label)]),2256 html.div(2257 [2258 attribute.class(case open {2259 True -> "menu open"2260 False -> "menu"2261 }),2262 event.on_click(MenuHeld) |> event.stop_propagation,2263 ],2264 [2265 html.button(2266 [2267 attribute.class("menutitle"),2268 attribute.attribute("aria-haspopup", "listbox"),2269 attribute.attribute("aria-expanded", case open {2270 True -> "true"2271 False -> "false"2272 }),2273 event.on_click(toggle),2274 ],2275 [html.text(title)],2276 ),2277 html.div(2278 [attribute.class("menulist"), attribute.attribute("role", "listbox")],2279 [2280 html.button(2281 [2282 attribute.attribute("data-auto", ""),2283 attribute.class(armed(auto_on)),2284 event.on_click(auto_message),2285 ],2286 [html.text("AUTO")],2287 ),2288 ..list.map(options, fn(option) {2289 let #(name, on, message) = option2290 html.button(2291 [attribute.class(armed(on)), event.on_click(message)],2292 [html.text(name)],2293 )2294 })2295 ],2296 ),2297 ],2298 ),2299 ])2300}23012302fn group(label: String, controls: List(Element(Message))) -> Element(Message) {2303 html.div([attribute.class("group")], [2304 html.div([attribute.class("grouplbl")], [html.text(label)]),2305 html.div([attribute.class("row")], controls),2306 ])2307}23082309fn setting(label: String, on: Bool, message: Message) -> Element(Message) {2310 html.button([attribute.class(armed(on)), event.on_click(message)], [2311 html.text(label),2312 ])2313}23142315fn armed(on: Bool) -> String {2316 case on {2317 True -> "armed"2318 False -> ""2319 }2320}23212322fn screen_id(screen: Screen) -> String {2323 case screen {2324 Boot -> "boot"2325 Sweeping -> "survey"2326 Atlas -> "atlas"2327 Card -> "card"2328 Playing -> "play"2329 Won -> "won"2330 Record -> "stats"2331 }2332}23332334fn axis_id(axis: Axis) -> String {2335 case axis {2336 Horizontal -> "x"2337 Vertical -> "y"2338 }2339}