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 / browser_ffi.mjs
browser_ffi.mjs 13.1K
1// The platform, and nothing else.2//3// Every function here is a capability with no Gleam expression: a key-value4// store, an oscillator, a vibration motor, a media query, a frame callback, a5// worker. None of them decides anything — the tones are a scale that was chosen6// upstream, the seeds the worker runs under were drawn upstream, and the theme7// this writes to the document was resolved upstream.89import { toList } from "../gleam.mjs";1011const KEY = "klotski.save";1213// ------------------------------------------------------------------ storage1415export function load() {16 try {17 return localStorage.getItem(KEY) || "";18 } catch (_) {19 return "";20 }21}2223export function save(value) {24 try {25 localStorage.setItem(KEY, value);26 } catch (_) {27 // Private mode, quota, evicted — play on regardless.28 }29}3031export function requestPersistence() {32 if (navigator.storage && navigator.storage.persist) {33 navigator.storage.persist().catch(() => {});34 }35}3637// -------------------------------------------------------------------- sound38//39// Notes, not foley.40//41// Monument Valley's rule: interaction sounds should be musical, because music42// is the most abstract sound there is and abstraction is what a geometric puzzle43// wants. Every cell a block travels sounds one degree of a minor pentatonic, so44// a long slide plays a rising figure and solving a board plays a line rather45// than a rattle. Synthesised rather than sampled — it keeps the install tiny,46// and a synthetic timbre is the audio equivalent of one bit.4748// A minor pentatonic degrees, and G3: low enough to stay soft.49const PENTATONIC = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22];50const ROOT = 196;5152let audio = null;5354export function unlockAudio() {55 if (audio) {56 if (audio.state === "suspended") audio.resume();57 return;58 }59 const Context = window.AudioContext || window.webkitAudioContext;60 if (!Context) return;61 audio = new Context();62 if (audio.state === "suspended") audio.resume();63 const buffer = audio.createBuffer(1, 1, 22050);64 const source = audio.createBufferSource();65 source.buffer = buffer;66 source.connect(audio.destination);67 source.start(0);68}6970function tone(frequency, duration, volume, type = "triangle", delay = 0) {71 if (!audio) return;72 const t0 = audio.currentTime + delay;73 const gain = audio.createGain();74 const osc = audio.createOscillator();75 // A hair of detune: wood, not glass.76 const detuned = audio.createOscillator();77 const detunedGain = audio.createGain();78 osc.type = detuned.type = type;79 osc.frequency.value = frequency;80 detuned.frequency.value = frequency * 1.006;81 detunedGain.gain.value = 0.38;82 gain.gain.setValueAtTime(0.0001, t0);83 // An 8ms attack transient.84 gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.008);85 gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration);86 osc.connect(gain);87 detuned.connect(detunedGain);88 detunedGain.connect(gain);89 gain.connect(audio.destination);90 osc.start(t0);91 detuned.start(t0);92 osc.stop(t0 + duration + 0.02);93 detuned.stop(t0 + duration + 0.02);94}9596export function steps(count, enabled) {97 if (!enabled || !audio) return;98 for (let i = 0; i < count; i++) {99 const semitone = PENTATONIC[Math.min(PENTATONIC.length - 1, i)];100 tone(ROOT * Math.pow(2, semitone / 12), 0.26, 0.15, "triangle", i * 0.055);101 }102}103104export function cue(name, enabled) {105 if (!enabled || !audio) return;106 if (name === "page") {107 tone(330, 0.09, 0.045, "sine");108 return;109 }110 if (name === "thud") {111 // A blocked move: damped, pitchless, close to silence.112 const t0 = audio.currentTime;113 const osc = audio.createOscillator();114 const gain = audio.createGain();115 osc.type = "sine";116 osc.frequency.setValueAtTime(150, t0);117 osc.frequency.exponentialRampToValueAtTime(58, t0 + 0.09);118 gain.gain.setValueAtTime(0.15, t0);119 gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);120 osc.connect(gain);121 gain.connect(audio.destination);122 osc.start(t0);123 osc.stop(t0 + 0.14);124 return;125 }126 if (name === "solve") {127 // The figure resolves to the tonic, with a bell over it.128 [0, 3, 5, 7, 12].forEach((semitone, i) =>129 tone(ROOT * Math.pow(2, semitone / 12), 0.5, 0.12, "triangle", i * 0.085),130 );131 tone(ROOT * 4, 1.6, 0.06, "sine", 0.42);132 }133}134135export function buzz(pattern, enabled) {136 if (!enabled || !navigator.vibrate) return;137 try {138 navigator.vibrate(Array.from(pattern));139 } catch (_) {140 // Some browsers refuse without a gesture; there is nothing to recover.141 }142}143144// ------------------------------------------------------------------- system145146const dark = query("(prefers-color-scheme: dark)");147const still = query("(prefers-reduced-motion: reduce)");148149function query(media) {150 return typeof window !== "undefined" && window.matchMedia151 ? window.matchMedia(media)152 : { matches: false, addEventListener() {} };153}154155export function prefersDark() {156 return dark.matches;157}158159export function prefersStill() {160 return still.matches;161}162163export function watchSystem(dispatch, message) {164 const fire = () => dispatch(message);165 dark.addEventListener("change", fire);166 still.addEventListener("change", fire);167}168169export function watchViewport(dispatch, message) {170 const fire = () => dispatch(message);171 window.addEventListener("resize", fire);172 // A rotation reports its new size a beat after it says it happened.173 window.addEventListener("orientationchange", () => setTimeout(fire, 150));174 // The board owns every gesture, and a browser that pinches or double-taps175 // mid-drag has taken one.176 document.addEventListener("gesturestart", (e) => e.preventDefault());177 document.addEventListener("dblclick", (e) => e.preventDefault());178 document.addEventListener("contextmenu", (e) => {179 if (e.target instanceof HTMLCanvasElement) e.preventDefault();180 });181}182183export function watchKeys(dispatch, toMessage) {184 window.addEventListener("keydown", (event) => {185 dispatch(toMessage(event.key));186 });187}188189export function watchFrame(dispatch, toMessage) {190 const loop = (now) => {191 requestAnimationFrame(loop);192 dispatch(toMessage(now));193 };194 requestAnimationFrame(loop);195}196197export function now() {198 return Date.now();199}200201export function today() {202 return new Date().toISOString().slice(0, 10);203}204205export function dayOfYear() {206 const date = new Date();207 const start = Date.UTC(date.getFullYear(), 0, 1);208 const today = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());209 return Math.floor((today - start) / 86400000) + 1;210}211212export function setThemeColour(ink, paper, ground) {213 const root = document.documentElement.style;214 root.setProperty("--ink", ink);215 root.setProperty("--paper", paper);216 root.setProperty("--ground", ground);217 const meta = document.querySelector('meta[name="theme-color"]');218 if (meta) meta.setAttribute("content", paper);219}220221export function loadFonts(dispatch, message) {222 const faces = [223 document.fonts.load("32px Tiny5"),224 document.fonts.load("16px DMMono"),225 ];226 Promise.all(faces)227 .catch(() => {})228 .then(() => dispatch(message));229}230231// ------------------------------------------------------------- installation232233let installPrompt = null;234235export function watchInstall(dispatch, message) {236 window.addEventListener("beforeinstallprompt", (event) => {237 event.preventDefault();238 installPrompt = event;239 dispatch(message);240 });241}242243export async function promptInstall(dispatch, message) {244 const prompt = installPrompt;245 installPrompt = null;246 if (prompt) {247 try {248 prompt.prompt();249 await prompt.userChoice;250 } catch (_) {251 // Dismissed, or unsupported. Either way there is nothing to report.252 }253 }254 dispatch(message);255}256257// A new version is never applied while somebody is playing.258//259// The worker that installs it waits, and the app calls `applyUpdate` at the one260// moment a reload costs nothing: between finishing with one board and opening261// the next. PROXX does the same thing for the same reason. Reloading on the spot262// is what a document editor does, because it has nowhere better; a puzzle has a263// seam and should use it.264export function registerServiceWorker() {265 if (!("serviceWorker" in navigator)) return;266 window.addEventListener("load", () => {267 navigator.serviceWorker.register("/sw.js").catch(() => {});268 });269}270271/// Take a waiting version, if there is one. Nothing happens when there is not.272///273/// The registration is asked at the moment of use rather than watched from the274/// moment of registration. Watching looked tidier and lost the race: a browser275/// checks for a new worker on its own during a navigation, so the install can276/// finish before the page has attached a listener for it, and the update then277/// sits there forever because the event that announced it has already gone.278/// There is nothing to miss if you never try to remember.279///280/// A plain reload would not do it either: during a navigation the old page lives281/// until the response arrives, so the old worker is still controlling and the282/// waiting one stays waiting. It has to be told to skip, and the reload has to283/// wait until it has actually taken over.284const RESUME = "klotski.resume";285286export function applyUpdate(resume) {287 if (!("serviceWorker" in navigator)) return;288 navigator.serviceWorker289 .getRegistration()290 .then((registration) => {291 const worker = registration && registration.waiting;292 if (!worker) return;293 // What the player was in the middle of asking for, kept across the reload294 // so it can be handed straight back to them. Without this the reload lands295 // on the title window and the tap that triggered it is simply gone —296 // which is not "the wait and nothing else", it is being sent to the back297 // of the queue for pressing the button. Session storage, so it cannot298 // outlive the tab and strand somebody on a board they did not pick.299 try {300 sessionStorage.setItem(RESUME, resume);301 } catch (_) {}302 navigator.serviceWorker.addEventListener(303 "controllerchange",304 () => window.location.reload(),305 { once: true },306 );307 worker.postMessage("SKIP_WAITING");308 })309 .catch(() => {});310}311312/// What the app was opening when it reloaded to take an update, once. Reading it313/// clears it, so a later reload starts from the title window like any other.314export function takeResume() {315 try {316 const value = sessionStorage.getItem(RESUME);317 sessionStorage.removeItem(RESUME);318 return value ?? "";319 } catch (_) {320 return "";321 }322}323324// ------------------------------------------------------------- the surveyor325326// The build substitutes the worker's fingerprinted URL for `__SOLVER_WORKER__`327// before it hashes this bundle, so a bundle can only ever reach the worker it328// was built against. That matters more here than it would for a missing file:329// the two speak a protocol of their own, so an old bundle meeting a new worker330// is not an error, it is a conversation at cross purposes.331//332// The `typeof` guard is what makes the same source run unbundled, where nothing333// has been substituted and the file is served under its own name.334const SOLVER_WORKER =335 typeof __SOLVER_WORKER__ === "string" ? __SOLVER_WORKER__ : "/solver-worker.js";336337let solver = null;338339function worker() {340 if (solver) return solver;341 solver = new Worker(SOLVER_WORKER);342 return solver;343}344345export function watchSolver(dispatch, sweeping, surveyed, puzzled, measured, probed) {346 worker().addEventListener("message", ({ data }) => {347 if (data.type === "probed") {348 dispatch(probed(data.familyId, toList(data.distances)));349 return;350 }351 if (data.type === "sweeping") {352 dispatch(sweeping(data.familyId, data.count, data.total, data.phase));353 } else if (data.type === "surveyed") {354 dispatch(surveyed(data.familyId, data.ecc, data.count));355 } else if (data.type === "puzzled") {356 dispatch(puzzled(data.familyId, data.rung, toList(data.candidates)));357 } else if (data.type === "measured") {358 dispatch(measured(data.familyId, data.seq, toList(data.distances)));359 }360 });361}362363export function survey(familyId, width, height, goalX, goalY, grid) {364 worker().postMessage({365 type: "survey",366 familyId,367 width,368 height,369 goalX,370 goalY,371 grid: Array.from(grid),372 });373}374375export function requestPuzzle(376 familyId,377 rung,378 want,379 ceiling,380 window,381 offset,382 seed,383 stray,384 rollouts,385 goalX,386 goalY,387) {388 worker().postMessage({389 type: "puzzle",390 familyId,391 rung,392 want,393 ceiling,394 window,395 offset,396 seed,397 stray,398 rollouts,399 goalX,400 goalY,401 });402}403404export function probe(familyId, states) {405 worker().postMessage({ type: "probe", familyId, states: Array.from(states) });406}407408// The handle a harness drives the game through — and the one anybody inspecting409// a phone over chrome://inspect reaches for. It dispatches into the app's own410// input path rather than reaching past it, so what a screenshot shows is what a411// run does.412export function exposeHarness(dispatch, solve) {413 window.klotski = Object.assign(window.klotski || {}, {414 solve: () => {415 dispatch(solve);416 return "walking";417 },418 });419}420421export function requestDistances(familyId, states, seq) {422 worker().postMessage({423 type: "distances",424 familyId,425 states: Array.from(states),426 seq,427 });428}