diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b13300e..681cdf4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ export const meadow: Wallpaper = { id: "meadow", // what /wallpaper takes: lowercase letters, digits and dashes name: "Meadow", description: "Rolling hills with a tree under drifting clouds", + collection: "Landscapes", // the menu's group; see Collections and fixed looks activity: { calm: "…", lively: "…", teeming: "…" }, // what each level shows, for the menu scrim: { day: [top, middle, bottom], sunset: […], night: […] }, // see Scrim create: (settings) => new Meadow(settings), // activity and time, and optionally season and weather @@ -240,6 +241,17 @@ single `node()`s into its coarse grid, then `apply()` it once after the things i areas and dims itself behind the middle of the screen; `firelight()` there gives a slow flicker. Keep the area behind text no brighter or busier than before, and keep flicker slow. +## Collections and fixed looks + +`collection` puts a wallpaper in one of the menu's groups: `Landscapes`, `Akira`, `Classics`, or a name of your own +for a new group. The menu's Collection row picks one, and shuffle stays within it. Without one, a wallpaper joins +Landscapes, or Classics if it has a fixed look. + +A wallpaper that only makes sense at one time of day, such as an old screensaver, sets `fixedTime: "night"` (or `day` +or `sunset`). It then always gets that time with no season or weather, whatever the settings, the menu hides its time, +season and weather rows, auto's crossfades pass it by, and the README and dev tools show it at that one time (`screenshots/.png`). +`scrim` still needs all three times, though only the fixed one is used. + ## Seasons and weather `settings.season` is spring, summer, autumn or winter, or missing for the classic look. Change what the season would @@ -355,6 +367,8 @@ bun dev/gif.ts all # re-renders the README's GIFs - `check.ts` runs the real engine on a mock 160x45 screen. "cells change per frame" is flicker: lower is calmer. `--quiet` prints only failures, and it fails on flicker above `--max-flicker` (150) and, if given, on frames slower than `--max-ms`; CI runs `--quiet --max-ms=60`. Dev renders seed `Math.random`, so they are reproducible. + Wallpapers whose whole view moves now and then (the maze's walking camera, listed in `LONG`) play for two minutes, + so their flicker is averaged over moving and still stretches alike. Errors thrown in `step` or `render` show up here; inside OpenCode they are only logged to `WALLPAPER_DEBUG`. `--verify-dirty` plays each combination twice, refitting every cell (`WALLPAPER_DIRTY=0`) and then only what changed, with text edits, redraws, agent events and a crossfade mixed in, and fails on any cell that differs. @@ -379,8 +393,8 @@ bun dev/gif.ts all # re-renders the README's GIFs 1. Put it in `wallpapers/.ts` as a named export, and add it to `wallpapers/index.ts`. 2. Run `bun run typecheck` and `bun dev/check.ts `, and look at `dev/snap.ts` and `dev/preview.ts` output at every time of day. -3. Add it to the README's wallpaper list, gallery and GIF grid. Add its best-looking time of day to `BEST` in - `dev/gif.ts`. CI publishes its screenshots once it's merged; a maintainer adds its GIF (see below). +3. Add it to its collection's line in the README's wallpaper list and to that collection's gallery. Unless it has a + fixed time, add its best-looking time of day to `BEST` in `dev/gif.ts`. CI publishes its screenshots once it's merged; a maintainer adds its GIF (see below). ## README images diff --git a/README.md b/README.md index 3a5dcf8..713e7d4 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Type `/wallpaper` to open the menu and pick a wallpaper; none shows until you do ![The wallpaper menu](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/menu.png) +- **Collection:** Landscapes, Akira, Classics or all: which wallpapers the menu and shuffle pick from. - **Activity:** calm, lively or teeming: how much moves behind your work. -- **Time:** day, sunset or night, or auto to follow your clock. -- **Season and weather:** follow the calendar, or the real weather where you are (`/wallpaper location Berlin`). +- **Time, season and weather:** for Landscapes. Time follows your clock or stays fixed, seasons follow the calendar, + and weather can be the real weather where you are (`/wallpaper location Berlin`). Akira and Classics keep one look. - **Brightness, shuffle, per project and power:** subtle to vivid, a new wallpaper every hour, one per project, and slowing down when idle. @@ -34,8 +35,9 @@ an error. With mouse support, clicking empty background nudges the scene, and ea ## Wallpapers -`ocean` · `desert` · `jungle` · `space` · `farm` · `tundra` · `beach` · `city` · `zen` · `prehistoric` · `rider` · -`megacity` · `dome` · `haunted-house` · `flowerbox` +- **Landscapes:** `ocean` · `desert` · `jungle` · `space` · `farm` · `tundra` · `beach` · `city` · `zen` · `prehistoric` · `haunted-house` +- **Akira:** `rider` · `megacity` · `dome` +- **Classics:** `flowerbox` · `maze` · `pipes` · `mystify` · `flying-opencode` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -61,7 +63,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Gallery -At teeming, played back at 3x speed: +At teeming; the GIFs play at 3x speed. + +### Landscapes | | | | --- | --- | @@ -70,9 +74,7 @@ At teeming, played back at 3x speed: | ![Farm in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/farm.gif) | ![Tundra in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/tundra.gif) | | ![Beach in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/beach.gif) | ![City in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/city.gif) | | ![Zen garden in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/zen.gif) | ![Prehistoric in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/prehistoric.gif) | -| ![Rider in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider.gif) | ![Megacity in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity.gif) | -| ![Dome in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome.gif) | ![Haunted house in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house.gif) | -| ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | | +| ![Haunted house in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house.gif) | | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -86,11 +88,25 @@ At teeming, played back at 3x speed: | City | ![City by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/city.png) | ![City at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/city-sunset.png) | ![City at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/city-night.png) | | Zen garden | ![Zen garden by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/zen.png) | ![Zen garden at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/zen-sunset.png) | ![Zen garden at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/zen-night.png) | | Prehistoric | ![Prehistoric by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/prehistoric.png) | ![Prehistoric at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/prehistoric-sunset.png) | ![Prehistoric at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/prehistoric-night.png) | -| Rider | ![Rider by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider.png) | ![Rider at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider-sunset.png) | ![Rider at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider-night.png) | -| Megacity | ![Megacity by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity.png) | ![Megacity at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity-sunset.png) | ![Megacity at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity-night.png) | -| Dome | ![Dome by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome.png) | ![Dome at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome-sunset.png) | ![Dome at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome-night.png) | | Haunted house | ![Haunted house by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house.png) | ![Haunted house at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house-sunset.png) | ![Haunted house at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house-night.png) | -| Flower Box | ![Flower Box by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.png) | ![Flower Box at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox-sunset.png) | ![Flower Box at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox-night.png) | + +### Akira + +| | Still | In motion | +| --- | --- | --- | +| Rider | ![Rider](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider.png) | ![Rider in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/rider.gif) | +| Megacity | ![Megacity](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity.png) | ![Megacity in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/megacity.gif) | +| Dome | ![Dome](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome.png) | ![Dome in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/dome.gif) | + +### Classics + +| | Still | In motion | +| --- | --- | --- | +| Flower Box | ![Flower Box](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.png) | ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | +| 3D Maze | ![3D Maze](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.png) | ![3D Maze in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.gif) | +| 3D Pipes | ![3D Pipes](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.png) | ![3D Pipes in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.gif) | +| Mystify | ![Mystify](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.png) | ![Mystify in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.gif) | +| Flying OpenCode | ![Flying OpenCode](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.png) | ![Flying OpenCode in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.gif) | ## License diff --git a/dev/check.ts b/dev/check.ts index 4108246..75bbdf3 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -32,11 +32,17 @@ const cursor = { x: 10, y: 40, visible: true } let failed = 0 let slowest = { ms: -1, name: "" } let busiest = { flicker: -1, name: "" } -const frames = 240 -const runs = (ids.length ? wallpapers.filter((w) => ids.includes(w.id)) : wallpapers).flatMap((w) => ACTIVITIES.flatMap((activity) => TIMES.map((time) => ({ wallpaper: w, activity, time })))) +// Wallpapers whose whole view moves now and then, such as the maze's walking camera, change hundreds of cells a frame +// while moving and almost none while still, so 16 seconds say more about where the run lands than about the scene. +// They play for two minutes instead, held to the same flicker limit; --verify-dirty, which keeps every frame of its +// shorter scripted run, checks only their cells. +const LONG = ["maze"] +const runs = (ids.length ? wallpapers.filter((w) => ids.includes(w.id)) : wallpapers).flatMap((w) => ACTIVITIES.flatMap((activity) => (w.fixedTime ? [w.fixedTime] : TIMES).map((time) => ({ wallpaper: w, activity, time })))) for (const { wallpaper, activity, time } of runs) { - const reference = verify ? play(wallpaper, activity, time, "0").recording : undefined - const result = play(wallpaper, activity, time, "1", reference) + const long = LONG.includes(wallpaper.id) + const frames = long && !verify ? 1800 : 240 + const reference = verify ? play(wallpaper, activity, time, "0", frames).recording : undefined + const result = play(wallpaper, activity, time, "1", frames, reference) const errors = (await Bun.file(debug).exists()) ? (await Bun.file(debug).text()).split("\n").filter((l) => l.startsWith("error:")) : [] rmSync(debug, { force: true }) const name = `${wallpaper.id} ${activity} ${time}` @@ -44,7 +50,7 @@ for (const { wallpaper, activity, time } of runs) { if (result.flicker > busiest.flicker) busiest = { flicker: result.flicker, name } const problems = [ errors.length && `${errors.length} errors`, - result.flicker > maxFlicker && `flicker over ${maxFlicker}`, + result.flicker > maxFlicker && !(long && verify) && `flicker over ${maxFlicker}`, result.ms > maxMs && `slower than ${maxMs} ms`, result.mismatches && `${result.mismatches} cells differ from a full refit, first ${result.first}`, result.uncovered && `${result.uncovered} pixels drawn outside the touched boxes, first ${result.where}`, @@ -59,7 +65,7 @@ if (failed) process.exit(1) // Plays one combination, with WALLPAPER_DIRTY set to dirty. Under --verify-dirty it records every frame, and compares // each against the reference recording when given. -function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: string, reference?: Uint16Array[]) { +function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: string, frames: number, reference?: Uint16Array[]) { process.env.WALLPAPER_DIRTY = dirty // A fresh engine and the same random stream per combination, so a run doesn't depend on which others ran before it. const screen = mockScreen() @@ -123,7 +129,9 @@ function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activ if (f === 150) engine.react("error") if (f === 200) engine.react("idle") if (f === 180) engine.start(wallpaper, { activity, time, season, weather, brightness: "vivid" }) - if (f === 205) engine.start(wallpaper, { activity, time: time === "night" ? "day" : "night", season, weather, brightness: "vivid" }) + if (f === 205 && !wallpaper.fixedTime) engine.start(wallpaper, { activity, time: time === "night" ? "day" : "night", season, weather, brightness: "vivid" }) + // A wallpaper with a fixed time ignores a new time, so it crossfades to another activity instead. + if (f === 205 && wallpaper.fixedTime) engine.start(wallpaper, { activity: activity === "calm" ? "teeming" : "calm", time, season, weather, brightness: "vivid" }) } // A wallpaper whose scenes check, after every render, that each pixel that differs from the cached background lies in diff --git a/dev/gif.ts b/dev/gif.ts index 23c24a8..d815653 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -14,8 +14,8 @@ const SPEED = 3 const FPS = 12.5 const START = 45 const FADE = 1.5 -// The time of day each wallpaper looks best at in the README; others use day. -const BEST: Record = { ocean: "day", desert: "sunset", jungle: "day", space: "sunset", farm: "sunset", tundra: "night", beach: "sunset", city: "night", zen: "sunset", prehistoric: "sunset", rider: "day", megacity: "night", dome: "night", "haunted-house": "night", flowerbox: "night" } +// The time of day each wallpaper with times of day looks best at in the README; others use day. +const BEST: Record = { ocean: "day", desert: "sunset", jungle: "day", space: "sunset", farm: "sunset", tundra: "night", beach: "sunset", city: "night", zen: "sunset", prehistoric: "sunset", "haunted-house": "night" } // Wallpapers in the hero GIF at the top of the README, each shown for HERO_SECONDS. const HERO = ["beach", "tundra", "jungle", "desert", "space"] const HERO_SECONDS = 2 @@ -31,22 +31,26 @@ mkdirSync("dev/out", { recursive: true }) if (args[0] === "all") { assets() for (const wallpaper of WALLPAPERS) { - const settings = { activity: "teeming" as const, time: BEST[wallpaper.id] ?? "day" } + const settings = { activity: "teeming" as const, time: best(wallpaper) } await write(`screenshots/${wallpaper.id}.gif`, [{ wallpaper, settings, seconds: 8 }], 480, 268, FADE) } const hero = HERO.flatMap((id) => WALLPAPERS.filter((w) => w.id === id)) - await write("screenshots/hero.gif", hero.map((wallpaper) => ({ wallpaper, settings: { activity: "teeming", time: BEST[wallpaper.id] ?? "day" }, seconds: HERO_SECONDS })), 720, 400, 0) + await write("screenshots/hero.gif", hero.map((wallpaper) => ({ wallpaper, settings: { activity: "teeming", time: best(wallpaper) }, seconds: HERO_SECONDS })), 720, 400, 0) } if (args[0] !== "all") { const wallpaper = wallpapers.find((w) => w.id === args[0]) if (!wallpaper) throw new Error(`usage: bun dev/gif.ts w.id).join("|")}> [activity] [time] [WxH] [seconds]`) const activity = ACTIVITIES.find((a) => args.includes(a)) ?? "teeming" - const time = TIMES.find((t) => args.includes(t)) ?? BEST[wallpaper.id] ?? "day" + const time = wallpaper.fixedTime ?? TIMES.find((t) => args.includes(t)) ?? best(wallpaper) const size = (args.find((arg) => /^\d+x\d+$/.test(arg)) ?? "480x268").split("x").map(Number) const seconds = Number(args.find((arg) => /^[\d.]+$/.test(arg)) ?? 8) await write(`dev/out/${wallpaper.id}-${activity}-${time}.gif`, [{ wallpaper, settings: { activity, time }, seconds }], size[0], size[1], FADE) } +function best(wallpaper: Wallpaper) { + return wallpaper.fixedTime ?? BEST[wallpaper.id] ?? "day" +} + async function write(path: string, shots: Shot[], W: number, H: number, fadeSeconds: number) { const start = Bun.nanoseconds() const fade = Math.round(fadeSeconds * FPS) diff --git a/dev/poke.ts b/dev/poke.ts index 19ffaf4..3e1d908 100644 --- a/dev/poke.ts +++ b/dev/poke.ts @@ -7,10 +7,10 @@ import { wallpapers } from "./wallpapers" const [id, ...rest] = process.argv.slice(2) const activity = ACTIVITIES.find((a) => rest.includes(a)) ?? "calm" -const time = TIMES.find((t) => rest.includes(t)) ?? "day" const [fx, fy, ...after] = rest.filter((arg) => /^[\d.]+$/.test(arg)).map(Number) const wallpaper = wallpapers.find((w) => w.id === id) if (!wallpaper || fy === undefined) throw new Error(`usage: bun dev/poke.ts <${wallpapers.map((w) => w.id).join("|")}> [activity] [time] [seconds...]`) +const time = wallpaper.fixedTime ?? TIMES.find((t) => rest.includes(t)) ?? "day" mkdirSync("dev/out", { recursive: true }) const scene = wallpaper.create({ activity, time }) scene.resize(720, 400) diff --git a/dev/preview.ts b/dev/preview.ts index 6d93b18..f7cd048 100644 --- a/dev/preview.ts +++ b/dev/preview.ts @@ -10,7 +10,6 @@ import { wallpapers } from "./wallpapers" const [id, ...rest] = process.argv.slice(2) const activity = ACTIVITIES.find((a) => rest.includes(a)) ?? "calm" -const time = TIMES.find((t) => rest.includes(t)) ?? "day" const season = SEASONS.find((s) => rest.includes(s)) const weather = WEATHERS.find((w) => rest.includes(w)) const layout = (["home", "session"] as const).find((l) => rest.includes(l)) @@ -19,6 +18,7 @@ const [w, h] = size ? size.split("x").map(Number) : [W, H] const seconds = rest.find((arg) => /^[\d.]+$/.test(arg)) ?? "20" const wallpaper = wallpapers.find((w) => w.id === id) if (!wallpaper) throw new Error(`usage: bun dev/preview.ts <${wallpapers.map((w) => w.id).join("|")}> [seconds]`) +const time = wallpaper.fixedTime ?? TIMES.find((t) => rest.includes(t)) ?? "day" process.env.WALLPAPER_OCTANTS ??= "1" mkdirSync("dev/out", { recursive: true }) const screen = mockScreen({ W: w, H: h }) diff --git a/dev/screenshots.ts b/dev/screenshots.ts index cd377cf..d1446a6 100644 --- a/dev/screenshots.ts +++ b/dev/screenshots.ts @@ -1,7 +1,7 @@ -// Re-renders the README gallery (teeming at 45 s via dev/snap.ts) and fails if any screenshot differs from the -// published one on the assets branch (see dev/assets.ts) beyond a tiny tolerance, copying the stale ones' fresh renders -// to dev/out/screenshots/; --update writes the fresh renders into screenshots/ instead. Naming wallpapers limits it to -// them: bun dev/screenshots.ts [--update] [wallpaper...] +// Re-renders the README gallery (teeming at 45 s via dev/snap.ts, one shot for a wallpaper with a fixed time) and +// fails if any screenshot differs from the published one on the assets branch (see dev/assets.ts) beyond a tiny +// tolerance, copying the stale ones' fresh renders to dev/out/screenshots/; --update writes the fresh renders into +// screenshots/ instead. Naming wallpapers limits it to them: bun dev/screenshots.ts [--update] [wallpaper...] import { availableParallelism } from "node:os" import { TIMES } from "../src/engine" import { WALLPAPERS } from "../wallpapers" @@ -12,7 +12,7 @@ const update = process.argv.includes("--update") const ids = process.argv.slice(2).filter((arg) => !arg.startsWith("--")) const unknown = ids.filter((id) => !WALLPAPERS.some((w) => w.id === id)) if (unknown.length) throw new Error(`unknown wallpaper ${unknown.join(", ")}`) -const shots = (ids.length ? WALLPAPERS.filter((w) => ids.includes(w.id)) : WALLPAPERS).flatMap((w) => TIMES.map((time) => ({ id: w.id, time, file: `screenshots/${w.id}${time === "day" ? "" : `-${time}`}.png` }))) +const shots = (ids.length ? WALLPAPERS.filter((w) => ids.includes(w.id)) : WALLPAPERS).flatMap((w) => (w.fixedTime ? [w.fixedTime] : TIMES).map((time) => ({ id: w.id, time, file: `screenshots/${w.id}${time === "day" || w.fixedTime ? "" : `-${time}`}.png` }))) assets() const queue = [...shots] const stale: string[] = [] diff --git a/dev/snap.ts b/dev/snap.ts index d939e6a..82ddc5e 100644 --- a/dev/snap.ts +++ b/dev/snap.ts @@ -9,12 +9,13 @@ import { wallpapers } from "./wallpapers" const [id, ...rest] = process.argv.slice(2) const activity = ACTIVITIES.find((a) => rest.includes(a)) ?? "calm" -const time = TIMES.find((t) => rest.includes(t)) ?? "day" -const season = SEASONS.find((s) => rest.includes(s)) const weather = WEATHERS.find((w) => rest.includes(w)) const times = rest.filter((arg) => /^[\d.]+$/.test(arg)) const wallpaper = wallpapers.find((w) => w.id === id) if (!wallpaper) throw new Error(`usage: bun dev/snap.ts <${wallpapers.map((w) => w.id).join("|")}> [seconds...]`) +// A wallpaper with a fixed time renders only that, without seasons. +const time = wallpaper.fixedTime ?? TIMES.find((t) => rest.includes(t)) ?? "day" +const season = wallpaper.fixedTime ? undefined : SEASONS.find((s) => rest.includes(s)) mkdirSync("dev/out", { recursive: true }) const scene = wallpaper.create({ activity, time, season, weather }) scene.resize(720, 400) diff --git a/src/canvas.ts b/src/canvas.ts index 28efcc5..155786a 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -292,6 +292,21 @@ export abstract class Canvas { this.restored = true } + // For scenes that build up their background over time: after changing `background` outside layout(), call this so + // the next finish() works the still frame out again (and the engine refits every cell once). + protected rebake() { + this.stillExposure = NaN + } + + // Makes what hdr holds now the background and still frame, for scenes whose whole view changes for a while and then + // holds still, such as a walking camera that pauses: once kept, frames that restore() and draw only small things + // cost as little as in any other scene. The next finish() tone-maps and refits everything once. + protected hold() { + this.background = this.hdr.slice() + this.still = this.background + this.stillExposure = NaN + } + // Marks pixels a scene's own loop writes during render(), x0..x1 by y0..y1 inclusive. protected touch(x0: number, y0: number, x1: number, y1: number) { addBox(this.touched, x0, y0, x1, y1) diff --git a/src/engine.tsx b/src/engine.tsx index d25c8dc..7468470 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -6,7 +6,7 @@ import { watchEgg } from "./egg" import { lerp, smoothstep, type RGB } from "./math" import { OCTANTS } from "./octants" import { FIRST, prepareEdges, refineEdge } from "./smooth" -import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type TextMap, type Wallpaper } from "./wallpaper" +import { AGENT_EVENTS, settingsFor, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type TextMap, type Wallpaper } from "./wallpaper" export { ACTIVITIES, @@ -16,6 +16,8 @@ export { SEASONS, TIMES, WEATHERS, + collectionOf, + settingsFor, type Activity, type AgentEvent, type Brightness, @@ -722,7 +724,8 @@ function createLayer(renderer: Context["renderer"], draw: (buffer: OptimizedBuff export interface Engine { readonly wallpaper: Wallpaper | undefined - // While running, the old scene crossfades into the new one over `fade` seconds. + // While running, the old scene crossfades into the new one over `fade` seconds. A wallpaper with a fixed time gets + // it in place of settings' time and season (see settingsFor). start(wallpaper: Wallpaper, settings: Settings, fade?: number): void stop(): void react(event: AgentEvent): void @@ -1318,7 +1321,8 @@ export function createEngine( get wallpaper() { return wallpaper }, - start(next, chosen, seconds = FADE) { + start(next, requested, seconds = FADE) { + const chosen = settingsFor(next, requested) const shows = (w?: Wallpaper, s?: Settings) => w === next && s?.activity === chosen.activity && s.time === chosen.time && s.season === chosen.season && s.weather === chosen.weather // Only brightness changed: restyle the running scene. if (shows(wallpaper, settings)) { diff --git a/src/load.ts b/src/load.ts index 546085d..d0262c3 100644 --- a/src/load.ts +++ b/src/load.ts @@ -52,6 +52,8 @@ function problem(value: unknown, taken: Set) { if (typeof w.create !== "function") return `${w.id}: create must be a function` const activity = w.activity as Record | undefined if (!ACTIVITIES.every((a) => typeof activity?.[a] === "string")) return `${w.id}: activity needs a description for each of ${ACTIVITIES.join(", ")}` + if (w.collection !== undefined && (typeof w.collection !== "string" || !w.collection.trim())) return `${w.id}: collection must be a name` + if (w.fixedTime !== undefined && !TIMES.some((t) => t === w.fixedTime)) return `${w.id}: fixedTime must be one of ${TIMES.join(", ")}` const scrim = w.scrim as Record | undefined const isScrim = (s: unknown) => Array.isArray(s) && s.length === 3 && s.every((c) => Array.isArray(c) && c.length === 3 && c.every((v) => typeof v === "number")) if (!TIMES.every((t) => isScrim(scrim?.[t]))) return `${w.id}: scrim needs three RGB colors for each of ${TIMES.join(", ")}` diff --git a/src/wallpaper.ts b/src/wallpaper.ts index f31fc43..86a0d37 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -49,6 +49,12 @@ export interface Wallpaper { readonly id: string readonly name: string readonly description: string + // The menu's group for it, such as "Landscapes" or "Classics". Defaults to Classics for a wallpaper with a fixed + // time and Landscapes otherwise. + readonly collection?: string + // For a wallpaper with one look: the time of day it always renders at, whatever the time setting, without seasons + // or weather. The menu hides its time, season and weather rows. + readonly fixedTime?: Time // For each time of day, colors (0-255) that text scrims fade the scene toward at the top, middle and bottom of the // screen. Darker, more saturated versions of the scene's own colors at those depths read better than black. readonly scrim: Readonly> @@ -59,6 +65,15 @@ export interface Wallpaper { export type Scrim = readonly [RGB, RGB, RGB] +export function collectionOf(wallpaper: Wallpaper) { + return wallpaper.collection ?? (wallpaper.fixedTime ? "Classics" : "Landscapes") +} + +// The settings a wallpaper renders with: one with a fixed time ignores the time, season and weather settings. +export function settingsFor(wallpaper: Wallpaper, settings: Settings): Settings { + return wallpaper.fixedTime ? { ...settings, time: wallpaper.fixedTime, season: undefined, weather: undefined } : settings +} + export interface Settings { readonly activity: Activity readonly time: Time diff --git a/tui.tsx b/tui.tsx index eb412cc..ab08bf6 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1,7 +1,7 @@ import { Plugin } from "@opencode/plugin/tui" import { RGBA, TextAttributes } from "@opentui/core" import { For, createEffect, createSignal, onCleanup } from "solid-js" -import { ACTIVITIES, BRIGHTNESSES, POWERS, SEASONS, TIMES, WEATHERS, createEngine, parseEvents, type Activity, type AgentEvent, type Brightness, type Power, type Season, type Time, type Weather } from "./src/engine" +import { ACTIVITIES, BRIGHTNESSES, POWERS, SEASONS, TIMES, WEATHERS, collectionOf, createEngine, parseEvents, type Activity, type AgentEvent, type Brightness, type Power, type Season, type Time, type Weather } from "./src/engine" import { REFRESH, coordinatesOf, forecast, type Forecast } from "./src/forecast" import { loadWallpapers } from "./src/load" import { WALLPAPERS } from "./wallpapers" @@ -15,6 +15,7 @@ export default Plugin.define({ const external = await loadWallpapers(context.options.wallpapers, WALLPAPERS) if (external.skipped.length) context.ui.toast.show({ message: `Skipped wallpapers:\n${external.skipped.join("\n")}`, variant: "error" }) const wallpapers = [...WALLPAPERS, ...external.wallpapers] + const collections = [...new Set(wallpapers.map(collectionOf))] const engine = createEngine(context, { dump: process.env.WALLPAPER_DUMP, dumpFade: process.env.WALLPAPER_DUMP_FADE }) // Environment overrides for development; they win over the stored choice. const pinned = process.env.WALLPAPER @@ -37,25 +38,28 @@ export default Plugin.define({ else draft.wallpaper = id draft.changed = { ...draft.changed, [scope(draft)]: Date.now() } } - // A project without its own change time yet counts from the global one. + // A project without its own change time or collection yet counts from the global one. const due = (state: Stored) => Date.now() - (state.changed?.[scope(state)] ?? state.changed?.[""] ?? 0) >= HOUR - const other = (id: string) => { - const others = wallpapers.filter((w) => w.id !== id) - return others[Math.floor(Math.random() * others.length)].id + const collection = (state: Stored) => collections.find((c) => c === (state.collections?.[scope(state)] ?? state.collections?.[""])) ?? "all" + const inCollection = (state: Stored) => wallpapers.filter((w) => collection(state) === "all" || collectionOf(w) === collection(state)) + // Shuffle stays within the chosen collection, and keeps the wallpaper when it is the collection's only one. + const other = (state: Stored, id: string) => { + const others = inCollection(state).filter((w) => w.id !== id) + return others.length ? others[Math.floor(Math.random() * others.length)].id : id } // Switches to a different wallpaper once an hour has passed. The check repeats under the storage lock, so several // running OpenCodes shuffle once between them. const shuffle = () => update((draft) => { const id = current(draft) - if (id && due(draft)) choose(draft, other(id)) + if (id && due(draft)) choose(draft, other(draft, id)) }) // Every-session shuffle runs once per OpenCode start; memory storage outlives plugin hot reloads. It picks before // the first frame and shows the pick while it saves, so the previous wallpaper never appears. const [session, mark] = context.storage.memory("session", { initial: { shuffled: false } }) const [picked, setPicked] = createSignal() if (!session.shuffled && shuffleSetting() === "session" && current(stored)) { - const id = other(current(stored)) + const id = other(stored, current(stored)) setPicked(id) void update((draft) => choose(draft, id)).finally(() => setPicked(undefined)) } @@ -63,6 +67,8 @@ export default Plugin.define({ draft.shuffled = true }) const shown = () => picked() ?? current(stored) + // Wallpapers with one look ignore the time and season settings, so the menu hides those rows for them. + const fixed = () => Boolean(wallpapers.find((w) => w.id === shown())?.fixedTime) // Auto follows the local clock, checked once a minute. WALLPAPER_CLOCK=HH:MM starts the clock there, for trying // auto's transitions. const [hour, minute] = (process.env.WALLPAPER_CLOCK ?? "").split(":").map(Number) @@ -120,6 +126,17 @@ export default Plugin.define({ await update((draft) => choose(draft, "")) return { message: "Wallpaper off" } } + if (arg.startsWith("collection ")) { + const name = arg.slice("collection ".length).trim() + const chosen = name === "all" ? "all" : collections.find((c) => c.toLowerCase() === name) + if (!chosen) return { message: `No collection "${name}". Available: all, ${collections.map((c) => c.toLowerCase()).join(", ")}`, error: true } + await update((draft) => { + draft.collections = { ...draft.collections, [scope(draft)]: chosen } + const members = inCollection(draft) + if (current(draft) && !members.some((w) => w.id === current(draft))) choose(draft, members[0].id) + }) + return { message: chosen === "all" ? "Wallpapers from every collection" : `Wallpaper collection: ${chosen}` } + } const level = ACTIVITIES.find((a) => a === arg) if (level) { await update((draft) => { @@ -172,7 +189,11 @@ export default Plugin.define({ } const wallpaper = wallpapers.find((w) => w.id === arg) if (!wallpaper) return { message: `No wallpaper "${arg}". Available: ${wallpapers.map((w) => w.id).join(", ")}`, error: true } - await update((draft) => choose(draft, arg)) + // A wallpaper from outside the chosen collection switches to its collection. + await update((draft) => { + if (!inCollection(draft).includes(wallpaper)) draft.collections = { ...draft.collections, [scope(draft)]: collectionOf(wallpaper) } + choose(draft, arg) + }) return { message: `${wallpaper.name} wallpaper on` } } const run = async (input?: string) => { @@ -184,9 +205,23 @@ export default Plugin.define({ // The picker works like OpenCode's settings: a row per setting with its value on the right. Enter or the arrow // keys change the highlighted row in place, and the dialog stays open until Escape. const rows: Row[] = [ + { + title: "Collection", + values: () => ["all", ...collections].map((c) => `collection ${c}`), + value: () => `collection ${collection(stored)}`, + label: (v) => (v === "collection all" ? "All" : v.slice(11)), + describe: (v) => + v === "collection all" + ? "Every wallpaper" + : wallpapers + .filter((w) => collectionOf(w) === v.slice(11)) + .map((w) => w.name) + .join(", "), + set: (v) => apply(v.toLowerCase()), + }, { title: "Wallpaper", - values: () => ["off", ...wallpapers.map((w) => w.id)], + values: () => ["off", ...inCollection(stored).map((w) => w.id)], value: () => shown() || "off", label: (v) => wallpapers.find((w) => w.id === v)?.name ?? "Off", describe: (v) => wallpapers.find((w) => w.id === v)?.description ?? "No wallpaper", @@ -202,6 +237,7 @@ export default Plugin.define({ }, { title: "Time of day", + hidden: fixed, values: () => ["auto", ...TIMES], value: timeSetting, label: (v) => (v === "auto" ? `Auto (${timeAt(now())})` : capitalize(v)), @@ -210,6 +246,7 @@ export default Plugin.define({ }, { title: "Season", + hidden: fixed, values: () => ["season auto", ...SEASONS.map((s) => `season ${s}`)], value: () => `season ${seasonSetting()}`, label: (v) => (v === "season auto" ? `Auto (${seasonAt(now(), south())})` : capitalize(v.slice(7))), @@ -221,6 +258,7 @@ export default Plugin.define({ }, { title: "Weather", + hidden: fixed, values: () => ["off", "local", "clear", "rain", "snow", "fog"].map((w) => `weather ${w}`), value: () => `weather ${weatherSetting()}`, label: (v) => (v === "weather local" ? `Local (${location() ? (local()?.weather ?? "checking") : "no location"})` : capitalize(v.slice(8))), @@ -288,11 +326,11 @@ export default Plugin.define({ ] const Picker = () => { const theme = context.theme.surface("dialog") - const [selected, setSelected] = createSignal(0) + const visible = () => rows.filter((row) => !row.hidden?.()) + const [selected, setSelected] = createSignal(rows[0]) let saving = false - const change = async (index: number, direction: number) => { + const change = async (row: Row, direction: number) => { if (saving) return - const row = rows[index] const values = row.values() const next = values[(values.indexOf(row.value()) + direction + values.length) % values.length] saving = true @@ -301,7 +339,8 @@ export default Plugin.define({ }) } const move = (direction: number) => { - setSelected((i) => (i + direction + rows.length) % rows.length) + const list = visible() + setSelected(list[(list.indexOf(selected()) + direction + list.length) % list.length]) } context.keymap.layer(() => ({ mode: "modal", @@ -326,9 +365,9 @@ export default Plugin.define({ - - {(row, index) => { - const active = () => selected() === index() + + {(row) => { + const active = () => selected() === row const fg = () => (active() ? theme.text.action.primary.focused : theme.text.base) return ( { - setSelected(index()) - void change(index(), 1) + setSelected(row) + void change(row, 1) }} > @@ -352,7 +391,7 @@ export default Plugin.define({ - {rows[selected()].describe(rows[selected()].value())} + {selected().describe(selected().value())} @@ -423,7 +462,7 @@ export default Plugin.define({ id: "wallpapers.choose", title: "Choose wallpaper", description: - "Animated scene behind the UI. Args: wallpaper id, off, calm, lively, teeming, day, sunset, night, auto, a season, a weather, location , subtle, normal, vivid, saver, smooth", + "Animated scene behind the UI. Args: wallpaper id, off, collection , calm, lively, teeming, day, sunset, night, auto, a season, a weather, location , subtle, normal, vivid, saver, smooth", group: "Wallpapers", palette: true, slash: { name: "wallpaper", arguments: true }, @@ -511,6 +550,8 @@ interface Stored { projects?: Record // When the wallpaper last changed, by scope, for hourly shuffle. changed?: Record + // The collection the menu and shuffle pick from, by scope; missing is all. + collections?: Record } // A setting in the picker: its values in order, how to show each, a line explaining the current one, and how to save one. @@ -521,4 +562,5 @@ interface Row { label: (value: string) => string describe: (value: string) => string set: (value: string) => Promise + hidden?: () => boolean } diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index 2aac1cf..7394cdf 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -900,6 +900,7 @@ export const beach: Wallpaper = { id: "beach", name: "Beach", description: "Waves washing up a sandy beach between swaying palms, with a sailboat on the horizon now and then", + collection: "Landscapes", activity: { calm: "Sea, waves, sand and palm trees", lively: "Adds leaping dolphins and seagulls", diff --git a/wallpapers/city.ts b/wallpapers/city.ts index 6fbd8ea..9b5bfb4 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -1113,6 +1113,7 @@ export const city: Wallpaper = { id: "city", name: "City", description: "A skyline over a river, traffic on an elevated highway, and a blimp drifting over now and then", + collection: "Landscapes", activity: { calm: "Skyline, river and light traffic on the highway", lively: "Adds a train crossing the bridge and more traffic", diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 9260791..ccf1e18 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -1038,6 +1038,7 @@ export const desert: Wallpaper = { id: "desert", name: "Desert", description: "Mesas and dunes with a saguaro, a cow skull, and a tumbleweed now and then", + collection: "Landscapes", activity: { calm: "Mesas, a saguaro and a skull", lively: "Adds a soaring eagle (an owl at night) and a rattlesnake", diff --git a/wallpapers/dome.ts b/wallpapers/dome.ts index aaddfba..61ac83c 100644 --- a/wallpapers/dome.ts +++ b/wallpapers/dome.ts @@ -751,6 +751,8 @@ export const dome: Wallpaper = { id: "dome", name: "Dome", description: "A vast black energy dome over a ruined city, its foot blazing with light under a ring of clouds, with a gunship crossing now and then", + collection: "Akira", + fixedTime: "night", activity: { calm: "The dome, its slowly breathing light and the creeping cloud ring", lively: "Light pulses out along the streets, rubble floats up and the sky's ripples spread", diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index a73f579..8a7ff51 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -1089,6 +1089,7 @@ export const farm: Wallpaper = { id: "farm", name: "Farm", description: "Rolling fields with a red barn, a turning windmill and swaying corn, and a tractor now and then", + collection: "Landscapes", activity: { calm: "Barn, windmill, fields and corn", lively: "Adds grazing cows and pecking chickens", diff --git a/wallpapers/flowerbox.ts b/wallpapers/flowerbox.ts index 49683a2..5f287bd 100644 --- a/wallpapers/flowerbox.ts +++ b/wallpapers/flowerbox.ts @@ -691,6 +691,8 @@ export const flowerbox: Wallpaper = { id: "flowerbox", name: "Flower Box", description: "The old 3D screensaver: a glossy box of colored patches spinning and bouncing, morphing into balls, a star and a pillow", + collection: "Classics", + fixedTime: "night", activity: { calm: "One object drifting and turning slowly, morphing between a cube, a cluster of balls and a pillow", lively: "A little quicker, through the full morph cycle with the spiky star", diff --git a/wallpapers/flying-opencode.ts b/wallpapers/flying-opencode.ts new file mode 100644 index 0000000..331e46e --- /dev/null +++ b/wallpapers/flying-opencode.ts @@ -0,0 +1,372 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { TAU, clamp, lerp, rand, smoothstep, type RGB } from "../src/math" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" + +// The scene runs slower than real time, which keeps it calm behind text. +const TIME_SCALE = 0.35 +// The OpenCode mark on its grid of blocks: 4 across and 5 down, a ring one block thick around a 2x3 counter whose top +// row is open and whose lower two rows hold the fill. BLOCK is a block's size in world units, where a unit at depth 1 +// spans the screen's height. +const BLOCK = 0.03 +// Marks are born this deep and fade in over the far part of the trip; ones nearer than NEAR have left the screen. +const FAR = 7 +const NEAR = 0.3 +// The egg's mark is this many times the size of the others. +const GIANT = 3.2 +// A finished task's warp surge lasts this many scene seconds. +const SURGE = 3.5 +// Clicked marks are at most this many at once. +const EXTRAS = 40 + +// Everything that changes with the time of day. Each mark's ring takes one of the colors; its counter fill is a darker +// shade of it. Night has no white or gray marks: the text on top is white. +interface Look { + sky: [number, RGB][] + colors: RGB[] + // The fill's share of the ring color. + fill: number +} + +const LOOKS: Record = { + // The classic: black, with bright red, green, cyan, magenta, gold and blue marks and a pale ice one for white. + day: { + sky: [ + [0, [0.002, 0.002, 0.003]], + [1, [0.001, 0.001, 0.002]], + ], + colors: [ + [1, 0.08, 0.06], + [0.1, 0.85, 0.12], + [0.06, 0.78, 1], + [0.95, 0.12, 0.85], + [1, 0.78, 0.08], + [0.2, 0.36, 1], + [0.72, 0.84, 0.95], + ], + fill: 0.32, + }, + // A deep plum fading to black, the marks amber, coral, rose, gold, violet, magenta and peach. + sunset: { + sky: [ + [0, [0.045, 0.008, 0.04]], + [0.6, [0.02, 0.004, 0.018]], + [1, [0.006, 0.001, 0.006]], + ], + colors: [ + [1, 0.45, 0.04], + [1, 0.18, 0.12], + [1, 0.16, 0.45], + [1, 0.72, 0.12], + [0.68, 0.18, 0.95], + [0.95, 0.12, 0.75], + [1, 0.56, 0.32], + ], + fill: 0.3, + }, + // Navy-black, the marks cobalt, teal, violet, aqua, magenta, green and a sky blue where day has white. + night: { + sky: [ + [0, [0.003, 0.005, 0.022]], + [1, [0.0008, 0.0012, 0.006]], + ], + colors: [ + [0.16, 0.3, 1], + [0.04, 0.72, 0.66], + [0.5, 0.22, 1], + [0.05, 0.75, 1], + [0.82, 0.12, 0.78], + [0.12, 0.8, 0.36], + [0.32, 0.52, 1], + ], + fill: 0.3, + }, +} + +// How many marks fly at once on a 16:9 screen, how fast they come, in world units per scene second, and how fast they +// turn, in radians per scene second. +const STREAM: Record = { + calm: { count: 7, speed: 0.22, spin: 0.4 }, + lively: { count: 20, speed: 0.6, spin: 0.65 }, + teeming: { count: 30, speed: 0.85, spin: 0.65 }, +} + +// A flying mark: its middle in world units (x right, y down, z away from the camera), a sideways push from a click, +// its turn about its vertical axis and how fast that turns, its color, its size relative to the others, and whether it +// is an extra (a click's or the egg's) that goes away instead of coming back from the far end. +interface Mark { + x: number + y: number + z: number + vx: number + vy: number + turn: number + spin: number + color: RGB + size: number + extra: boolean +} + +class FlyingOpenCode extends Canvas { + private readonly activity: Activity + private readonly look: Look + private marks: Mark[] = [] + private surge = 0 + private egg = eggWait() * TIME_SCALE + private readonly shade = new Float32Array(6) + + constructor(settings: Settings) { + super() + this.supersample = 1 + this.activity = settings.activity + this.look = LOOKS[settings.time] + } + + // A new mark at depth z, somewhere on screen that it leaves past an edge before it reaches NEAR, rather than flying + // into the camera. + private spawn(z: number): Mark { + const A = this.A + const margin = BLOCK * 3 + let x = 0 + let y = 0 + for (let i = 0; i < 20; i++) { + x = rand(-A / 2, A / 2) * z + y = rand(-0.5, 0.5) * z + if (Math.abs(x) > (A / 2) * NEAR + margin || Math.abs(y) > 0.5 * NEAR + margin) break + } + const colors = this.look.colors + return { + x, + y, + z, + vx: 0, + vy: 0, + turn: rand(0, TAU), + spin: STREAM[this.activity].spin * rand(0.7, 1.3) * (Math.random() < 0.5 ? -1 : 1), + color: colors[Math.floor(Math.random() * colors.length)], + size: 1, + extra: false, + } + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.stepGloom(dt) + this.surge = Math.max(0, this.surge - dt) + // After an error the stream slows; a finished task sends it into a warp-speed surge for a moment. + const speed = STREAM[this.activity].speed * (1 - 0.65 * this.gloom) * (1 + 5 * this.warp()) + const A = this.A + const decay = Math.exp(-dt / 1.5) + for (const [i, m] of this.marks.entries()) { + m.z -= speed * dt + m.x += m.vx * dt + m.y += m.vy * dt + m.vx *= decay + m.vy *= decay + m.turn += m.spin * dt * (1 + this.warp()) + const reach = (BLOCK * 3 * m.size) / Math.max(m.z, 0.01) + const gone = m.z < (m.size > 1 ? 0.12 : NEAR * 0.5) || Math.abs(m.x / m.z) > A / 2 + reach || Math.abs(m.y / m.z) > 0.5 + reach + if (!gone) continue + if (!m.extra) this.marks[i] = this.spawn(FAR) + if (m.extra) m.size = 0 + } + this.marks = this.marks.filter((m) => m.size > 0) + this.egg -= dt + if (this.egg > 0) return + // The egg: one giant mark flies straight out of the middle at the camera. + this.egg = eggWait(true) * TIME_SCALE + this.marks.push({ ...this.spawn(FAR), x: 0, y: 0, spin: 0.35, size: GIANT, extra: true }) + } + + // How far into the surge's rush the stream is, 0 to 1. + private warp() { + return smoothstep(0, 0.8, this.surge) * smoothstep(SURGE, SURGE - 0.6, this.surge) + } + + render() { + this.restore() + const { W, H, look, shade } = this + const order = [...this.marks].sort((a, b) => b.z - a.z) + const dim = 1 - 0.55 * this.gloom + const warp = this.warp() + const streak = Math.max(this.activity === "teeming" ? 0.35 : 0, warp) + const reach = lerp(1.4, 5, warp) + const speed = STREAM[this.activity].speed * (1 - 0.65 * this.gloom) * (1 + 5 * warp) + for (const m of order) { + // Far marks fade in out of the dark; the egg's fades away just before it reaches the camera. + const fade = smoothstep(FAR, FAR * 0.55, m.z) * (m.size > 1 ? smoothstep(0.12, 0.4, m.z) : 1) * dim + if (fade <= 0.002) continue + const c = m.color + const f = look.fill + // Facing the camera it catches the light; edge-on it dims a little, as the old flying logos did. + const facing = Math.abs(Math.cos(m.turn)) + const lit = fade * (0.7 + 0.35 * facing + 0.3 * facing ** 24) + shade.set([c[0] * lit, c[1] * lit, c[2] * lit, c[0] * f * lit, c[1] * f * lit, c[2] * f * lit]) + const b = BLOCK * m.size + const tall = (b * 5 * H) / m.z + // In a rush, and gently in teeming, the nearest marks leave a short streak back toward the middle. + if (streak > 0 && m.z < reach) this.streak(m, streak * smoothstep(reach, reach * 0.4, m.z) * fade, speed) + if (tall < 2) { + // Under two pixels tall the shape can't show: spread its light over the pixels it covers instead. + const area = (b * H) / m.z + const k = area * area * (0.25 + 0.75 * facing) / 20 + this.splat(W / 2 + (m.x / m.z) * H, H / 2 + (m.y / m.z) * H, shade[0] * 14 * k + shade[3] * 4 * k, shade[1] * 14 * k + shade[4] * 4 * k, shade[2] * 14 * k + shade[5] * 4 * k) + continue + } + const box = drawMark(this.hdr, W, H, m.x, m.y, m.z, Math.cos(m.turn), Math.sin(m.turn), b, shade, tall < 16 ? 4 : 3) + if (box) this.touch(box[0], box[1], box[2], box[3]) + } + this.finish() + } + + // A soft streak from the mark back to where it was a moment ago, in its ring color. + private streak(m: Mark, k: number, speed: number) { + const { W, H } = this + const back = m.z + speed * 0.25 + const x0 = W / 2 + (m.x / m.z) * H + const y0 = H / 2 + (m.y / m.z) * H + const x1 = W / 2 + (m.x / back) * H + const y1 = H / 2 + (m.y / back) * H + const n = Math.min(80, Math.ceil(Math.hypot(x1 - x0, y1 - y0))) + const c = m.color + for (let i = 1; i <= n; i++) { + const t = i / (n + 1) + const w = k * 0.5 * (1 - t) + this.splat(lerp(x0, x1, t), lerp(y0, y1, t), c[0] * w, c[1] * w, c[2] * w) + } + } + + // A finished task: the stream rushes past at warp speed for a moment. + protected override visit() { + this.surge = SURGE + } + + // A click: a burst of marks flies out from under the pointer. + poke(x: number, y: number) { + const room = EXTRAS - this.marks.filter((m) => m.extra).length + for (let i = 0; i < Math.min(10, room); i++) { + const z = rand(2.2, 2.8) + const a = rand(0, TAU) + const push = rand(0.25, 0.55) + this.marks.push({ ...this.spawn(z), x: (x - this.A / 2) * z, y: (y - 0.5) * z, vx: Math.cos(a) * push, vy: Math.sin(a) * push, extra: true }) + } + } + + protected override layout() { + const { W, H, hdr } = this + // Fill the stream once the screen's shape is known, as densely on a wide screen as on a 16:9 one. + const count = Math.round(STREAM[this.activity].count * clamp(this.A / (16 / 9), 0.6, 1.6)) + const stream = this.marks.filter((m) => !m.extra) + this.marks = [...stream.slice(0, count), ...Array.from({ length: Math.max(0, count - stream.length) }, () => this.spawn(rand(NEAR * 2, FAR))), ...this.marks.filter((m) => m.extra)] + const stops = this.look.sky + for (let y = 0; y < H; y++) { + const v = (y + 0.5) / H + const i = stops.findIndex(([at]) => at >= v) + const [a, from] = stops[Math.max(0, i - 1)] + const [b, to] = stops[i < 0 ? stops.length - 1 : i] + const k = b > a ? smoothstep(a, b, v) : 1 + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3 + hdr[o] = lerp(from[0], to[0], k) + hdr[o + 1] = lerp(from[1], to[1], k) + hdr[o + 2] = lerp(from[2], to[2], k) + } + } + } +} + +// Draws a mark centered at (mx, my, mz) in world units, turned about its vertical axis by the angle with cosine c and +// sine s, its blocks b units across, in true perspective: each of the ss x ss subsamples per pixel casts a ray onto the +// mark's plane and looks up which part of the mark it lands on. shade holds the ring's color and the fill's. Returns +// the pixel box drawn, or undefined when it's off screen. +function drawMark(hdr: Float32Array, W: number, H: number, mx: number, my: number, mz: number, c: number, s: number, b: number, shade: Float32Array, ss: number) { + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const u of [-2 * b, 2 * b]) + for (const v of [-2.5 * b, 2.5 * b]) { + const z = mz + u * s + if (z <= 0.01) return + const px = W / 2 + ((mx + u * c) / z) * H + const py = H / 2 + ((my + v) / z) * H + x0 = Math.min(x0, px) + y0 = Math.min(y0, py) + x1 = Math.max(x1, px) + y1 = Math.max(y1, py) + } + const bx0 = Math.max(0, Math.floor(x0)) + const by0 = Math.max(0, Math.floor(y0)) + const bx1 = Math.min(W - 1, Math.floor(x1)) + const by1 = Math.min(H - 1, Math.floor(y1)) + if (bx1 < bx0 || by1 < by0) return + // The plane's normal is (-s, 0, c); a ray (dx, dy, 1) meets it at t = (n . m) / (n . d). + const nm = -s * mx + c * mz + const inv = 1 / b + const step = 1 / ss + const share = 1 / (ss * ss) + for (let y = by0; y <= by1; y++) + for (let x = bx0; x <= bx1; x++) { + let r = 0 + let g = 0 + let bl = 0 + let n = 0 + for (let j = 0; j < ss; j++) { + const dy = (y + (j + 0.5) * step - H / 2) / H + for (let i = 0; i < ss; i++) { + const dx = (x + (i + 0.5) * step - W / 2) / H + const nd = c - s * dx + if (Math.abs(nd) < 1e-6) continue + const t = nm / nd + const U = (c * (t * dx - mx) + s * (t - mz)) * inv + const V = (t * dy - my) * inv + if (U < -2 || U > 2 || V < -2.5 || V > 2.5) continue + const counter = U > -1 && U < 1 && V > -1.5 && V < 1.5 + if (counter && V < -0.5) continue + const k = counter ? 3 : 0 + r += shade[k] + g += shade[k + 1] + bl += shade[k + 2] + n++ + } + } + if (!n) continue + const cov = n * share + const o = (y * W + x) * 3 + hdr[o] += (r / n - hdr[o]) * cov + hdr[o + 1] += (g / n - hdr[o + 1]) * cov + hdr[o + 2] += (bl / n - hdr[o + 2]) * cov + } + return [bx0, by0, bx1, by1] +} + +export const flyingOpenCode: Wallpaper = { + id: "flying-opencode", + name: "Flying OpenCode", + description: "The old flying-logos screensaver with OpenCode marks: colored marks stream out of the dark toward you, flapping as they fly", + collection: "Classics", + fixedTime: "night", + activity: { + calm: "A few marks drifting slowly out of the middle", + lively: "The classic stream of colored marks flying past", + teeming: "A denser, quicker stream, the nearest marks trailing faint warp streaks", + }, + scrim: { + day: [ + [4, 4, 6], + [3, 3, 5], + [2, 2, 3], + ], + sunset: [ + [30, 7, 26], + [18, 4, 16], + [6, 1, 6], + ], + night: [ + [2, 4, 16], + [1, 3, 11], + [0, 1, 5], + ], + }, + create: (settings) => new FlyingOpenCode(settings), +} diff --git a/wallpapers/haunted-house.ts b/wallpapers/haunted-house.ts index 7207c3b..c00fed4 100644 --- a/wallpapers/haunted-house.ts +++ b/wallpapers/haunted-house.ts @@ -1860,6 +1860,7 @@ export const hauntedHouse: Wallpaper = { id: "haunted-house", name: "Haunted House", description: "A crooked Victorian haunted house on a rise above a foggy graveyard under a huge moon, with a witch flying by now and then", + collection: "Landscapes", activity: { calm: "The candle and jack-o'-lanterns flicker, fog creeps, a bat or two flits and the swing sways", lively: "Adds bats pouring from the turret, ghosts drifting by and a ghoul peeking out from behind a gravestone", diff --git a/wallpapers/index.ts b/wallpapers/index.ts index 3ef2110..c8aff9e 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -4,14 +4,18 @@ import { desert } from "./desert" import { dome } from "./dome" import { farm } from "./farm" import { flowerbox } from "./flowerbox" +import { flyingOpenCode } from "./flying-opencode" import { hauntedHouse } from "./haunted-house" import { jungle } from "./jungle" +import { maze } from "./maze" import { megacity } from "./megacity" +import { mystify } from "./mystify" import { ocean } from "./ocean" +import { pipes } from "./pipes" import { prehistoric } from "./prehistoric" import { rider } from "./rider" import { space } from "./space" import { tundra } from "./tundra" import { zen } from "./zen" -export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, rider, megacity, dome, hauntedHouse, flowerbox] +export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, hauntedHouse, rider, megacity, dome, flowerbox, maze, pipes, mystify, flyingOpenCode] diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 39254ca..c4c6d97 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -799,6 +799,7 @@ export const jungle: Wallpaper = { id: "jungle", name: "Jungle", description: "A misty rainforest with a waterfall, light through the canopy, and a toucan flying by now and then", + collection: "Landscapes", activity: { calm: "Trees, vines, a waterfall and swaying leaves", lively: "Adds a swinging spider monkey and blue morpho butterflies", diff --git a/wallpapers/maze.ts b/wallpapers/maze.ts new file mode 100644 index 0000000..042a6f8 --- /dev/null +++ b/wallpapers/maze.ts @@ -0,0 +1,929 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { TAU, clamp, lerp, rand, smoothstep, type RGB } from "../src/math" +import { touch } from "../src/touched" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" + +// The scene runs slower than real time, which keeps it calm behind text. +const TIME_SCALE = 0.35 +// Cells along each side of the maze. Walls are thin, on the cell edges, WALL cells high, and the eye is halfway up. +const MAZE = 12 +const WALL = 0.8 +const EYE = WALL / 2 +// Bricks: four courses per wall, their length, and the mortar between them, all in cells. +const COURSE = WALL / 4 +const BRICK = 0.4 +const MORTAR = 0.035 +// Ceiling tiles per cell and the width of the seams between them, as a fraction of a tile. +const TILES = 3 +const SEAM = 0.07 +// Headings in the maze's grid: east, south, west, north (y runs south). +const DX = [1, 0, -1, 0] +const DY = [0, 1, 0, -1] +// Per screen column, what its ray hit (see castColumns). +const COL = 12 +// The flip lasts this many scene seconds before the view rights itself. +const FLIPPED = 25 + +// Everything that changes with the time of day. +interface Look { + brick: RGB + mortar: RGB + ceiling: RGB + seam: RGB + floor: RGB + // What far corridors fade into, and how quickly (per cell; 0 never). + fog: RGB + haze: number + // The camera's lamp: how quickly its light falls off with distance (0 lights everything evenly) and the light left + // where it doesn't reach. + lamp: number + ambient: number + // How much darker walls running east-west are than those running north-south, for the old flat shading. + side: number + // The floating polyhedra, the flip one, the smiley's face and its features. + solid: RGB + flip: RGB + face: RGB + ink: RGB +} + +const LOOKS: Record = { + // The classic: deep red bricks in white mortar, a pale stippled ceiling and a mustard floor, evenly lit. + day: { + brick: [0.3, 0.012, 0.01], + mortar: [0.62, 0.6, 0.58], + ceiling: [0.5, 0.5, 0.52], + seam: [0.3, 0.3, 0.33], + floor: [0.46, 0.25, 0.014], + fog: [0.3, 0.27, 0.25], + haze: 0.03, + lamp: 0, + ambient: 1, + side: 0.78, + solid: [0.11, 0.11, 0.12], + flip: [0.5, 0.03, 0.3], + face: [0.62, 0.48, 0.02], + ink: [0.03, 0.02, 0.01], + }, + // Low amber light, the corridors fading into a warm haze. + sunset: { + brick: [0.3, 0.025, 0.006], + mortar: [0.6, 0.38, 0.2], + ceiling: [0.5, 0.34, 0.2], + seam: [0.3, 0.17, 0.08], + floor: [0.5, 0.2, 0.01], + fog: [0.3, 0.1, 0.025], + haze: 0.42, + lamp: 0.12, + ambient: 0.4, + side: 0.74, + solid: [0.06, 0.035, 0.03], + flip: [0.5, 0.04, 0.24], + face: [0.66, 0.42, 0.02], + ink: [0.04, 0.012, 0.005], + }, + // Dark corridors in the camera's lamp, fading into deep blue-black: pale cobalt ceiling and mortar, no white. + night: { + brick: [0.26, 0.01, 0.026], + mortar: [0.16, 0.21, 0.54], + ceiling: [0.17, 0.23, 0.58], + seam: [0.06, 0.08, 0.28], + floor: [0.36, 0.17, 0.012], + fog: [0.002, 0.004, 0.02], + haze: 0.2, + lamp: 1.4, + ambient: 0.04, + side: 0.72, + solid: [0.03, 0.06, 0.24], + flip: [0.4, 0.02, 0.32], + face: [0.6, 0.4, 0.02], + ink: [0.02, 0.006, 0.04], + }, +} + +// How the camera walks, in scene seconds: top speed in cells per second, how long a quarter turn takes, the rest after +// a run and the pause looking down the new corridor after a turn. +const PACE: Record = { + calm: { speed: 0.3, turn: 2.4, rest: 0.4, look: 18 }, + lively: { speed: 0.9, turn: 1, rest: 0.3, look: 5.5 }, + teeming: { speed: 1.2, turn: 0.8, rest: 0.25, look: 4.5 }, +} + +// A spinning polyhedron floating in a corridor: its cell, spin angles and rates, how grown it is, and whether it is +// the one that flips the view. +interface Solid { + cx: number + cy: number + a: number + b: number + ra: number + rb: number + grow: number + dying: boolean + flip: boolean +} + +// Unit polyhedra: vertices and outward triangles, found as the triples every other vertex lies behind. +const PHI = (1 + Math.sqrt(5)) / 2 +const ICOSAHEDRON = hull([-1, 1].flatMap((s) => [-1, 1].flatMap((t) => [[0, s, t * PHI], [s, t * PHI, 0], [t * PHI, 0, s]]))) +const OCTAHEDRON = hull([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]) + +class Maze extends Canvas { + private time = 0 + private readonly activity: Activity + private readonly look: Look + private readonly calmPace: { speed: number; turn: number; rest: number; look: number } + // Heading for the exit after a finished task, at a brisker pace. + private seeking = false + // Walls on the cell edges: vertical ones at x = i between cells i - 1 and i, horizontal ones at y = j. + private readonly vwall = new Uint8Array((MAZE + 1) * MAZE) + private readonly hwall = new Uint8Array(MAZE * (MAZE + 1)) + // The exit, a dead end, and the wall with the smiley on it (vertical edges first, then horizontal ones). + private exit = 0 + private smile = -1 + // The way to the exit once the agent is done: the heading to take from each cell, or -1. + private readonly route = new Int8Array(MAZE * MAZE).fill(-1) + // The wandering choice made for each cell and heading of arrival, or -1 (see choose). + private readonly picks = new Int8Array(MAZE * MAZE * 4).fill(-1) + // The camera: its cell, heading, position and view angle, and what it is doing. + private cx = 0 + private cy = 0 + private heading = 0 + private x = 0.5 + private y = 0.5 + private angle = 0 + private mode: "pause" | "turn" | "run" | "cheer" = "pause" + // Whether the camera has just turned where it stands, so it walks on rather than choosing again. + private turned = true + private t = 0 + private dur = 1 + private from = 0 + private to = 0 + private goal = 0 + private s = 0 + private v = 0 + // A side a click asked to turn toward, -1 or +1. + private nudge = 0 + // Fades out and back in around a new maze. + private fade = 1 + private solids: Solid[] = [] + // The flip: where the view is between upright (0) and upside down (1), where it is heading, and how long it stays. + private flip = 0 + private flipTo = 0 + private flipLeft = 0 + private egg = eggWait() * TIME_SCALE + // Per column, what its ray hit; per row, how far the floor or ceiling it shows is. + private cols = new Float32Array(0) + private rows = new Float32Array(0) + private readonly params = new Float32Array(32) + // The view last cast: position, angle, flip and light. Whether it has been kept as the still background. + private readonly last = new Float64Array(5).fill(NaN) + private kept = false + + constructor(settings: Settings) { + super() + this.supersample = 1 + this.activity = settings.activity + this.look = LOOKS[settings.time] + this.calmPace = PACE[settings.activity] + this.build() + this.dur = rand(0.5, 2) + if (this.activity !== "teeming") return + this.spawn(false) + this.spawn(false) + for (const o of this.solids) o.grow = 1 + } + + // A new perfect maze, grown from a random cell: mostly carving on from the newest cell, which gives long corridors, + // and now and then from an older one, which gives side branches. The camera starts there, facing an opening, and the + // exit is the dead end farthest from it. + private build() { + this.vwall.fill(1) + this.hwall.fill(1) + const seen = new Uint8Array(MAZE * MAZE) + const start = Math.floor(Math.random() * MAZE * MAZE) + const stack = [start] + seen[start] = 1 + while (stack.length) { + const k = Math.random() < 0.7 ? stack.length - 1 : Math.floor(Math.random() * stack.length) + const c = stack[k] + const x = c % MAZE + const y = Math.floor(c / MAZE) + const ways = [0, 1, 2, 3].filter((h) => { + const nx = x + DX[h] + const ny = y + DY[h] + return nx >= 0 && ny >= 0 && nx < MAZE && ny < MAZE && !seen[ny * MAZE + nx] + }) + if (!ways.length) { + stack.splice(k, 1) + continue + } + const h = ways[Math.floor(Math.random() * ways.length)] + this.setWall(x, y, h, 0) + const n = (y + DY[h]) * MAZE + x + DX[h] + seen[n] = 1 + stack.push(n) + } + const far = this.distances(start) + this.placeExit(far.reduce((best, d, i) => (d > far[best] ? i : best), 0)) + this.cx = start % MAZE + this.cy = Math.floor(start / MAZE) + this.heading = [0, 1, 2, 3].find((h) => this.open(this.cx, this.cy, h)) ?? 0 + this.angle = (this.heading * TAU) / 4 + this.x = this.cx + 0.5 + this.y = this.cy + 0.5 + this.route.fill(-1) + this.picks.fill(-1) + this.solids = [] + this.turned = true + this.mode = "pause" + this.t = 0 + this.dur = this.pace.look + this.seeking = false + } + + // Makes the dead end c the exit, with the smiley on the wall facing its opening. + private placeExit(c: number) { + const x = c % MAZE + const y = Math.floor(c / MAZE) + this.exit = c + this.smile = this.edge(x, y, (([0, 1, 2, 3].find((h) => this.open(x, y, h)) ?? 0) + 2) % 4) + } + + // Steps from cell `from` to every cell. + private distances(from: number) { + const d = new Int32Array(MAZE * MAZE).fill(-1) + d[from] = 0 + const queue = [from] + for (let i = 0; i < queue.length; i++) { + const c = queue[i] + for (let h = 0; h < 4; h++) { + if (!this.open(c % MAZE, Math.floor(c / MAZE), h)) continue + const n = c + DX[h] + DY[h] * MAZE + if (d[n] >= 0) continue + d[n] = d[c] + 1 + queue.push(n) + } + } + return d + } + + // The index of the wall on the side h of cell (x, y): vertical edges first, then horizontal ones. + private edge(x: number, y: number, h: number) { + if (h === 0) return y * (MAZE + 1) + x + 1 + if (h === 2) return y * (MAZE + 1) + x + return (MAZE + 1) * MAZE + (h === 1 ? y + 1 : y) * MAZE + x + } + + private setWall(x: number, y: number, h: number, on: number) { + const e = this.edge(x, y, h) + if (e < (MAZE + 1) * MAZE) this.vwall[e] = on + else this.hwall[e - (MAZE + 1) * MAZE] = on + } + + private open(x: number, y: number, h: number) { + const e = this.edge(x, y, h) + return e < (MAZE + 1) * MAZE ? !this.vwall[e] : !this.hwall[e - (MAZE + 1) * MAZE] + } + + // The heading to take on from cell (x, y) arriving with heading h: the way to the exit once the agent is done, a + // side a click asked for, or else a wander (see pick). Picks are kept until the camera has walked through the cell, + // so looking ahead and walking agree. + private choose(x: number, y: number, h: number) { + const c = y * MAZE + x + if (c === this.exit) return (h + 2) % 4 + if (this.route[c] >= 0) return this.route[c] + if (this.nudge && this.open(x, y, (h + this.nudge + 4) % 4)) return (h + this.nudge + 4) % 4 + if (this.picks[c * 4 + h] < 0) this.picks[c * 4 + h] = this.pick(x, y, h) + return this.picks[c * 4 + h] + } + + // A wandering choice that never turns back before a dead end: mostly toward the bigger part of the maze, so short + // dead ends are seldom entered, and more often straight on than around a corner. + private pick(x: number, y: number, h: number) { + const ways = [h, (h + 1) % 4, (h + 3) % 4].filter((k) => this.open(x, y, k)) + if (!ways.length) return (h + 2) % 4 + const weights = ways.map((k) => this.reach(x + DX[k], y + DY[k], y * MAZE + x) * (k === h ? 2.5 : 1)) + let r = Math.random() * weights.reduce((a, b) => a + b, 0) + return ways.find((_, i) => (r -= weights[i]) < 0) ?? ways[0] + } + + // How many cells lie beyond cell (x, y) when coming from cell `from`. + private reach(x: number, y: number, from: number) { + const seen = new Uint8Array(MAZE * MAZE) + seen[from] = 1 + seen[y * MAZE + x] = 1 + const queue = [y * MAZE + x] + for (let i = 0; i < queue.length; i++) + for (let h = 0; h < 4; h++) { + const c = queue[i] + if (!this.open(c % MAZE, Math.floor(c / MAZE), h) || seen[c + DX[h] + DY[h] * MAZE]) continue + seen[c + DX[h] + DY[h] * MAZE] = 1 + queue.push(c + DX[h] + DY[h] * MAZE) + } + return queue.length + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.time += dt + this.stepGloom(dt) + this.walk(dt) + this.stepSolids(dt) + this.stepFlip(dt) + } + + private get pace() { + const p = this.calmPace + return this.seeking ? { speed: p.speed * 1.8, turn: p.turn * 0.6, rest: p.rest, look: Math.min(p.look, 1) } : p + } + + private walk(dt: number) { + const pace = this.pace + this.t += dt + if (this.mode === "cheer") { + // At the exit: smile back for a while, then fade out into a new maze and back in. + this.fade = smoothstep(this.dur, this.dur - 1.2, this.t) + if (this.t < this.dur) return + this.build() + this.t = -1.2 + return + } + this.fade = Math.min(1, this.fade + dt / 1.2) + if (this.mode === "turn") { + this.angle = lerp(this.from, this.to, smoothstep(0, 1, this.t / this.dur)) + if (this.t < this.dur) return + this.angle = (this.heading * TAU) / 4 + this.turned = true + this.rest(pace.look) + return + } + if (this.mode === "run") { + const accel = pace.speed * 0.8 + const left = this.goal - this.s + this.v = Math.min(this.v + accel * dt, pace.speed, Math.sqrt(2 * accel * Math.max(0, left)) + 0.02) + this.s = Math.min(this.goal, this.s + this.v * dt) + this.x = this.cx + 0.5 + DX[this.heading] * this.s + this.y = this.cy + 0.5 + DY[this.heading] * this.s + if (this.s < this.goal) return + for (let k = 1; k < this.goal; k++) { + const c = (this.cy + DY[this.heading] * k) * MAZE + this.cx + DX[this.heading] * k + this.picks.fill(-1, c * 4, c * 4 + 4) + } + this.cx += DX[this.heading] * this.goal + this.cy += DY[this.heading] * this.goal + this.s = 0 + this.v = 0 + this.turned = false + this.rest(pace.rest) + return + } + if (this.t < this.dur) return + this.decide() + } + + private rest(seconds: number) { + this.mode = "pause" + this.t = 0 + this.dur = seconds + } + + private decide() { + const pace = this.pace + if (this.cy * MAZE + this.cx === this.exit) { + this.mode = "cheer" + this.t = 0 + this.dur = 6 + this.route.fill(-1) + this.seeking = false + return + } + const c = this.cy * MAZE + this.cx + const straight = this.turned && this.route[c] < 0 && !(this.nudge && this.open(this.cx, this.cy, (this.heading + this.nudge + 4) % 4)) + const h = straight ? this.heading : this.choose(this.cx, this.cy, this.heading) + const turn = (h - this.heading + 4) % 4 + this.picks.fill(-1, c * 4, c * 4 + 4) + if (turn) { + if (turn === 1 || turn === 3) this.nudge = 0 + this.mode = "turn" + this.t = 0 + this.from = this.angle + this.to = this.angle + (turn === 3 ? -1 : turn) * (TAU / 4) + this.dur = pace.turn * (turn === 2 ? 1.6 : 1) + this.heading = h + return + } + // Run on until the next cell where the way bends. + let n = 1 + while (n < MAZE && this.choose(this.cx + DX[h] * n, this.cy + DY[h] * n, h) === h && (this.cy + DY[h] * n) * MAZE + this.cx + DX[h] * n !== this.exit) n++ + this.mode = "run" + this.goal = n + this.s = 0 + this.v = 0 + } + + // Teeming keeps a few polyhedra floating along the way ahead; the camera passes through them and they vanish. + private stepSolids(dt: number) { + for (const o of this.solids) { + o.a += o.ra * dt + o.b += o.rb * dt + o.grow = o.dying ? o.grow - dt * 3 : Math.min(1, o.grow + dt * 1.2) + if (o.dying || Math.hypot(this.x - o.cx - 0.5, this.y - o.cy - 0.5) > 0.3) continue + o.dying = true + if (o.flip) this.flipTo = 1 - this.flipTo + if (o.flip) this.flipLeft = FLIPPED + } + this.solids = this.solids.filter((o) => o.grow > 0) + this.egg -= dt + if (this.egg <= 0 && this.flipLeft <= 0 && this.flipTo === 0 && !this.solids.some((o) => o.flip)) { + this.egg = Infinity + this.spawn(true) + } + if (this.activity !== "teeming" || this.mode === "cheer") return + if (this.solids.filter((o) => !o.dying).length < 3 && Math.random() < dt * 0.6) this.spawn(false) + } + + // A polyhedron where the camera will see it: down the corridor it faces, or a few cells along its way. A click's + // comes in at the nearest free cell in view. + private spawn(flip: boolean, near = false) { + const h = this.heading + const sight: [number, number][] = [] + for (let x = Math.floor(this.x), y = Math.floor(this.y); sight.length < MAZE && this.open(x, y, h); x += DX[h], y += DY[h]) sight.push([x + DX[h], y + DY[h]]) + const free = (cells: [number, number][]) => cells.filter(([x, y]) => !this.solids.some((o) => o.cx === x && o.cy === y)) + const view = free(sight.filter(([x, y]) => Math.hypot(x + 0.5 - this.x, y + 0.5 - this.y) > 0.9)) + const pool = view.length && (near || Math.random() < 0.7) ? view : free(this.ahead(12).slice(2)) + const cell = pool[near ? 0 : Math.floor(Math.random() * Math.min(pool.length, 6))] + if (!cell) return + this.solids.push({ cx: cell[0], cy: cell[1], a: rand(0, TAU), b: rand(0, TAU), ra: rand(0.5, 0.8), rb: rand(0.3, 0.5), grow: 0, dying: false, flip }) + } + + // The next n cells the camera will walk through. + private ahead(n: number) { + let x = this.cx + let y = this.cy + let h = this.heading + const cells: [number, number][] = [] + for (let i = 0; i < n; i++) { + if (i || !(this.turned || this.mode !== "pause")) h = this.choose(x, y, h) + x += DX[h] + y += DY[h] + if (x < 0 || y < 0 || x >= MAZE || y >= MAZE) break + cells.push([x, y]) + if (y * MAZE + x === this.exit) break + } + return cells + } + + private stepFlip(dt: number) { + this.flip = this.flipTo > this.flip ? Math.min(this.flipTo, this.flip + dt / 1.5) : Math.max(this.flipTo, this.flip - dt / 1.5) + if (this.flipLeft <= 0) return + this.flipLeft -= dt + // It rights itself while the camera stands looking down a corridor. + if (this.flipLeft > 0 || this.mode !== "pause") return + this.flipTo = 0 + this.egg = eggWait(true) * TIME_SCALE + } + + // A finished task: the camera heads briskly for the exit, which turns out to be a dead end a few turns away. + protected override visit() { + if (this.mode === "cheer") return + const here = this.distances(this.cy * MAZE + this.cx) + const ends = here.map((d, c) => (d >= 4 && [0, 1, 2, 3].filter((h) => this.open(c % MAZE, Math.floor(c / MAZE), h)).length === 1 ? c : -1)).filter((c) => c >= 0) + if (ends.length) this.placeExit(ends.reduce((best, c) => (Math.abs(here[c] - 8) < Math.abs(here[best] - 8) ? c : best))) + this.seeking = true + const to = this.distances(this.exit) + for (let c = 0; c < MAZE * MAZE; c++) { + if (c === this.exit) continue + const x = c % MAZE + const y = Math.floor(c / MAZE) + this.route[c] = [0, 1, 2, 3].find((h) => this.open(x, y, h) && to[c + DX[h] + DY[h] * MAZE] === to[c] - 1) ?? -1 + } + if (this.mode === "pause") this.t = this.dur + if (this.mode === "run") this.replan() + } + + // A click on the left or right of the screen turns the camera that way where it can; elsewhere, or where there's no + // turning, a polyhedron appears down the corridor. + poke(x: number) { + const side = x < this.A / 3 ? -1 : x > (this.A * 2) / 3 ? 1 : 0 + const h = this.heading + const turn = (h + side + 4) % 4 + let can = false + for (let k = 0; side && k < MAZE && !can; k++) { + const cx = this.cx + DX[h] * k + const cy = this.cy + DY[h] * k + can = this.open(cx, cy, turn) + if (!this.open(cx, cy, h)) break + } + if (!can || this.mode === "turn" || this.mode === "cheer") { + if (this.solids.length < 6) this.spawn(false, true) + return + } + this.nudge = side + if (this.mode === "pause" && this.open(this.cx, this.cy, turn)) this.t = this.dur + if (this.mode === "run") this.replan() + } + + // Cuts the current run short where the way now bends. + private replan() { + const h = this.heading + for (let k = Math.ceil(this.s + 0.05); k < this.goal; k++) { + if (this.choose(this.cx + DX[h] * k, this.cy + DY[h] * k, h) === h) continue + this.goal = k + return + } + } + + protected override layout() { + this.cols = new Float32Array(this.W * COL) + this.rows = new Float32Array(this.H) + this.last.fill(NaN) + this.kept = false + } + + render() { + const { W, H, look } = this + // A flicker and dimming after an error, in coarse steps so the view isn't cast again every frame. + const dip = this.gloom > 0.2 && hashStep(Math.floor(this.time / 0.12)) < 0.1 * this.gloom ? 0.55 : 1 + const light = Math.round((1 - 0.45 * this.gloom) * dip * this.fade * 32) / 32 + const roll = Math.cos(this.flip * Math.PI) + const view = [this.x, this.y, this.angle, roll, light] + const moved = view.some((v, i) => v !== this.last[i]) + const f = Math.max(0.62 * H, 0.36 * W) + const fy = f * (Math.abs(roll) < 0.02 ? 0.02 * Math.sign(roll || 1) : roll) + const dirX = Math.cos(this.angle) + const dirY = Math.sin(this.angle) + if (moved || !this.kept) { + this.restore(undefined) + this.last.set(view) + const p = this.params + p.set([...look.brick, ...look.mortar, ...look.ceiling, ...look.seam, ...look.floor, ...look.fog, look.haze, look.lamp, look.ambient, light, ...look.face, ...look.ink, look.side, this.cells ? 9 : 2]) + castColumns(this.vwall, this.hwall, this.smile, this.x, this.y, dirX, dirY, -dirY * (W / 2 / f), dirX * (W / 2 / f), W, this.cols) + shadeView(this.hdr, W, H, this.cols, this.rows, f, fy, this.x, this.y, p) + touch(this.hdr, 0, 0, W - 1, H - 1) + // Once the camera holds still, its view becomes the background and only the polyhedra are drawn over it. + if (!moved) this.hold() + this.kept = !moved + } + if (this.kept) this.restore() + for (const o of this.solids) this.drawSolid(o, f, fy, dirX, dirY, light) + this.finish(2) + } + + private drawSolid(o: Solid, f: number, fy: number, dirX: number, dirY: number, light: number) { + const { W, H, look } = this + const ox = o.cx + 0.5 + const oy = o.cy + 0.5 + const depth = (ox - this.x) * dirX + (oy - this.y) * dirY + if (depth < 0.25 || o.grow <= 0) return + const lat = (ox - this.x) * -dirY + (oy - this.y) * dirX + if (Math.abs(lat / depth) * f > W / 2 + f * 0.3 / depth) return + const shape = o.flip ? OCTAHEDRON : ICOSAHEDRON + const r = 0.16 * smoothstep(0, 1, o.grow) * (o.flip ? 1.2 : 1) + const ca = Math.cos(o.a) + const sa = Math.sin(o.a) + const cb = Math.cos(o.b) + const sb = Math.sin(o.b) + // Spun about the vertical, then tipped about x; then into world space and onto the screen. + const pts = shape.verts.map(([vx, vy, vz]) => { + const x1 = vx * ca - vy * sa + const y1 = vx * sa + vy * ca + const y2 = y1 * cb - vz * sb + const z2 = y1 * sb + vz * cb + const wx = ox + x1 * r + const wy = oy + y2 * r + const wz = EYE + z2 * r + const d = (wx - this.x) * dirX + (wy - this.y) * dirY + const l = (wx - this.x) * -dirY + (wy - this.y) * dirX + return [wx, wy, wz, W / 2 + (f * l) / d, H / 2 - (fy * (wz - EYE)) / d, d] + }) + // Lit from behind the camera's left shoulder, then dimmed and fogged with distance like the walls. + const lx = -dirX * 0.6 + dirY * 0.4 + const ly = -dirY * 0.6 - dirX * 0.4 + const lz = 0.7 + const ll = Math.hypot(lx, ly, lz) + const dist = Math.hypot(ox - this.x, oy - this.y) + const reach = (look.lamp ? look.ambient + (1 - look.ambient) / (1 + dist * dist * look.lamp) : 1) * light + const fogk = veil(dist, look.haze) + const base = o.flip ? look.flip : look.solid + const faces = shape.faces + .map(([a, b, c]) => { + const [ax, ay, az] = pts[a] + const [bx, by, bz] = pts[b] + const [qx, qy, qz] = pts[c] + const ux = bx - ax + const uy = by - ay + const uz = bz - az + const vx = qx - ax + const vy = qy - ay + const vz = qz - az + const nx = uy * vz - uz * vy + const ny = uz * vx - ux * vz + const nz = ux * vy - uy * vx + const toward = (this.x - ax) * nx + (this.y - ay) * ny + (EYE - az) * nz + return { a, b, c, nx, ny, nz, toward, d: pts[a][5] + pts[b][5] + pts[c][5] } + }) + .filter((t) => t.toward > 0) + .sort((p, q) => q.d - p.d) + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const t of faces) { + const n = Math.hypot(t.nx, t.ny, t.nz) || 1 + const k = (0.3 + 1.1 * Math.max(0, (t.nx * lx + t.ny * ly + t.nz * lz) / (n * ll))) * reach * (1 - fogk) + const fog = look.fog + const pa = pts[t.a] + const pb = pts[t.b] + const pc = pts[t.c] + fillTriangle(this.hdr, W, H, this.cols, depth, pa[3], pa[4], pb[3], pb[4], pc[3], pc[4], base[0] * k + fog[0] * fogk * light, base[1] * k + fog[1] * fogk * light, base[2] * k + fog[2] * fogk * light) + x0 = Math.min(x0, pa[3], pb[3], pc[3]) + y0 = Math.min(y0, pa[4], pb[4], pc[4]) + x1 = Math.max(x1, pa[3], pb[3], pc[3]) + y1 = Math.max(y1, pa[4], pb[4], pc[4]) + } + if (faces.length) this.touch(Math.floor(x0) - 2, Math.floor(y0) - 2, Math.ceil(x1) + 2, Math.ceil(y1) + 2) + } +} + +// A stable 0..1 value per step index. +function hashStep(n: number) { + const s = Math.sin(n * 91.7 + 17.3) * 43758.5453 + return s - Math.floor(s) +} + +// The convex hull of a few points as outward triangles. +function hull(verts: number[][]) { + const faces: [number, number, number][] = [] + const n = verts.length + for (let a = 0; a < n; a++) + for (let b = a + 1; b < n; b++) + for (let c = b + 1; c < n; c++) { + const [ax, ay, az] = verts[a] + const u = [verts[b][0] - ax, verts[b][1] - ay, verts[b][2] - az] + const v = [verts[c][0] - ax, verts[c][1] - ay, verts[c][2] - az] + const nx = u[1] * v[2] - u[2] * v[1] + const ny = u[2] * v[0] - u[0] * v[2] + const nz = u[0] * v[1] - u[1] * v[0] + const sides = verts.filter((_, i) => i !== a && i !== b && i !== c).map((p) => (p[0] - ax) * nx + (p[1] - ay) * ny + (p[2] - az) * nz) + if (sides.every((s) => s < -1e-6)) faces.push([a, b, c]) + if (sides.every((s) => s > 1e-6)) faces.push([a, c, b]) + } + const scale = Math.hypot(verts[0][0], verts[0][1], verts[0][2]) + return { verts: verts.map((p) => p.map((k) => k / scale)), faces } +} + +// The share of [a, b] where the repeating pattern of period 1 is "on" for its first m: a box-filtered line, so mortar +// and seams thin into an even tint with distance instead of shimmering. +function lines(a: number, b: number, m: number) { + const fa = Math.floor(a) + const fb = Math.floor(b) + return (fb * m + Math.min(b - fb, m) - fa * m - Math.min(a - fa, m)) / (b - a) +} + +// Casts one ray per screen column through the maze's thin walls, a grid walk from cell edge to cell edge. Per column +// into cols: the perpendicular distance, the wall's top and bottom are filled in by shadeView; its position along the +// wall in cells, whether it runs east-west, whether it is the smiley wall, how squarely the ray meets it, and the ray. +function castColumns(vwall: Uint8Array, hwall: Uint8Array, smile: number, px: number, py: number, dirX: number, dirY: number, planeX: number, planeY: number, W: number, cols: Float32Array) { + for (let x = 0; x < W; x++) { + const cam = (2 * (x + 0.5)) / W - 1 + const rx = dirX + planeX * cam + const ry = dirY + planeY * cam + let mx = Math.floor(px) + let my = Math.floor(py) + const ddx = Math.abs(1 / rx) + const ddy = Math.abs(1 / ry) + const sx = rx < 0 ? -1 : 1 + const sy = ry < 0 ? -1 : 1 + let tx = (rx < 0 ? px - mx : mx + 1 - px) * ddx + let ty = (ry < 0 ? py - my : my + 1 - py) * ddy + let dist = 0 + let side = 0 + let edge = -1 + for (let i = 0; i < 4 * MAZE; i++) { + if (tx < ty) { + const e = my * (MAZE + 1) + mx + (sx > 0 ? 1 : 0) + if (my < 0 || my >= MAZE || vwall[e]) { + dist = tx + side = 0 + edge = e + break + } + mx += sx + tx += ddx + continue + } + const e = (my + (sy > 0 ? 1 : 0)) * MAZE + mx + if (mx < 0 || mx >= MAZE || hwall[e]) { + dist = ty + side = 1 + edge = (MAZE + 1) * MAZE + e + break + } + my += sy + ty += ddy + } + const len = Math.hypot(rx, ry) + const o = x * COL + cols[o] = Math.max(dist, 1e-3) + cols[o + 3] = side ? px + rx * dist : py + ry * dist + cols[o + 4] = side + cols[o + 5] = edge === smile ? 1 : 0 + cols[o + 6] = Math.max(0.12, Math.abs(side ? ry : rx) / len) + cols[o + 7] = len + cols[o + 8] = rx + cols[o + 9] = ry + } +} + +// Shades every pixel: brick walls with mortar, the tiled ceiling and the plain floor, lit and fogged by distance. +// fy is the vertical focal length, negative when the view is flipped; p holds the look (see Maze.render). +function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, rows: Float32Array, f: number, fy: number, px: number, py: number, p: Float32Array) { + const horizon = H / 2 + const haze = p[18] + const lamp = p[19] + const ambient = p[20] + const light = p[21] + const side = p[28] + for (let x = 0; x < W; x++) { + const o = x * COL + const d = cols[o] + const a = horizon + (fy * (EYE - WALL)) / d + const b = horizon + (fy * EYE) / d + cols[o + 1] = Math.min(a, b) + cols[o + 2] = Math.max(a, b) + cols[o + 10] = lit(d * cols[o + 7], lamp, ambient) * light + cols[o + 11] = veil(d * cols[o + 7], haze) + } + for (let y = 0; y < H; y++) { + const t = (y + 0.5 - horizon) / fy + rows[y] = t > 1e-4 ? EYE / t : t < -1e-4 ? (WALL - EYE) / -t : 1e9 + } + const mh = MORTAR / COURSE + const ml = MORTAR / BRICK + for (let y = 0; y < H; y++) { + const rd = rows[y] + const floor = (y + 0.5 - horizon) / fy > 0 + // How much ceiling one pixel covers, in tiles, across and along the view. + const step = (rd * rd) / (Math.abs(fy) * (WALL - EYE)) + for (let x = 0; x < W; x++) { + const o = x * COL + const d = cols[o] + const cover = Math.min(y + 1, cols[o + 2]) - Math.max(y, cols[o + 1]) + const cov = cover < 0 ? 0 : cover > 1 ? 1 : cover + let r = 0 + let g = 0 + let bl = 0 + if (cov > 0) { + const z = clamp(EYE - ((y + 0.5 - horizon) * d) / fy, 0, WALL) + const wz = d / Math.abs(fy) / COURSE + const wu = d / f / cols[o + 6] / BRICK + const sv = z / COURSE + mh / 2 + const course = Math.floor(z / COURSE) + const su = cols[o + 3] / BRICK + (course & 1) * 0.5 + ml / 2 + const ch = lines(sv - wz / 2, sv + wz / 2, mh) + const cv = lines(su - wu / 2, su + wu / 2, ml) + // Where courses get small the mortar softens into an even tint: crisp bricks up close, calm far walls. + const crisp = clamp((1 / wz - p[29]) / p[29], 0, 1) + const mortar = lerp(1 - (1 - mh) * (1 - ml), 1 - (1 - ch) * (1 - cv), crisp) + // Each brick a little lighter or darker, and shaded toward its bottom, both fading out where bricks get small. + const detail = clamp(1.5 - 2 * Math.max(wz, wu), 0, 1) + const tone = 1 + detail * (0.24 * cellNoise(Math.floor(su), course) - 0.12 + 0.16 * (z / COURSE - course) - 0.08) + const k = (1 - mortar) * tone + const s = cols[o + 4] ? side : 1 + r = (p[0] * k + p[3] * mortar) * s + g = (p[1] * k + p[4] * mortar) * s + bl = (p[2] * k + p[5] * mortar) * s + if (cols[o + 5]) { + // The smiley at the exit: a gold face with dark eyes and grin, centered on the wall's cell. + const u = cols[o + 3] + const lu = u - Math.floor(u) - 0.5 + const lz = z - WALL / 2 + const aa = Math.max(wz * COURSE, wu * BRICK, 0.004) + const rr = Math.hypot(lu, lz) + const face = clamp((0.27 - rr) / aa + 0.5, 0, 1) + const eye = Math.max(clamp((0.045 - Math.hypot(Math.abs(lu) - 0.09, lz - 0.07)) / aa + 0.5, 0, 1), 0) + const grin = clamp((0.035 - Math.abs(rr - 0.16)) / aa + 0.5, 0, 1) * clamp(-(lz + 0.04) / aa + 0.5, 0, 1) + const rim = clamp((0.018 - Math.abs(rr - 0.27)) / aa + 0.5, 0, 1) + const ink = Math.max(eye, grin, rim) + r = lerp(lerp(r, p[22], face), p[25], ink) + g = lerp(lerp(g, p[23], face), p[26], ink) + bl = lerp(lerp(bl, p[24], face), p[27], ink) + } + const k2 = cols[o + 10] + const fog = cols[o + 11] + r = (r * k2 * (1 - fog) + p[15] * fog * light) * cov + g = (g * k2 * (1 - fog) + p[16] * fog * light) * cov + bl = (bl * k2 * (1 - fog) + p[17] * fog * light) * cov + } + if (cov < 1) { + let fr = p[12] + let fg = p[13] + let fb = p[14] + if (!floor) { + // Ceiling tiles: thin darker seams in a square grid, box-filtered by how much ceiling the pixel spans. + const wx = (px + rd * cols[o + 8]) * TILES + const wy = (py + rd * cols[o + 9]) * TILES + const w = Math.max((rd * cols[o + 7]) / f, step) * TILES + const seam = 1 - (1 - lines(wx - w / 2 + SEAM / 2, wx + w / 2 + SEAM / 2, SEAM)) * (1 - lines(wy - w / 2 + SEAM / 2, wy + w / 2 + SEAM / 2, SEAM)) + // A faint stipple per tile, fading out where tiles get small. + const tone = 1 + clamp(1 - w * 1.5, 0, 1) * 0.1 * (cellNoise(Math.floor(wx), Math.floor(wy)) - 0.5) + fr = lerp(p[6] * tone, p[9], seam) + fg = lerp(p[7] * tone, p[10], seam) + fb = lerp(p[8] * tone, p[11], seam) + } + const dd = rd * cols[o + 7] + const k2 = lit(dd, lamp, ambient) * light + const fog = veil(dd, haze) + const c = 1 - cov + r += (fr * k2 * (1 - fog) + p[15] * fog * light) * c + g += (fg * k2 * (1 - fog) + p[16] * fog * light) * c + bl += (fb * k2 * (1 - fog) + p[17] * fog * light) * c + } + const i = (y * W + x) * 3 + hdr[i] = r + hdr[i + 1] = g + hdr[i + 2] = bl + } + } +} + +// How much of the fog shows at a distance: none up close, approaching all of it far away. +function veil(dist: number, haze: number) { + return (dist * haze) / (1 + dist * haze) +} + +// A stable 0..1 value per pair of integers, for brick and tile tones. +function cellNoise(a: number, b: number) { + let h = Math.imul(a, 374761393) + Math.imul(b, 668265263) + h = Math.imul(h ^ (h >>> 13), 1274126177) + return ((h ^ (h >>> 16)) >>> 0) / 4294967296 +} + +// The camera lamp's light at a distance: falling off from full, down to the ambient light; even when there's no lamp. +function lit(dist: number, lamp: number, ambient: number) { + return lamp ? ambient + (1 - ambient) / (1 + dist * dist * lamp) : 1 +} + +// A flat, anti-aliased triangle, drawn only in columns whose wall is farther than depth. +function fillTriangle(hdr: Float32Array, W: number, H: number, cols: Float32Array, depth: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number, r: number, g: number, b: number) { + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) + if (Math.abs(area) < 1e-6) return + const s = area > 0 ? 1 : -1 + const lab = Math.hypot(bx - ax, by - ay) || 1 + const lbc = Math.hypot(cx - bx, cy - by) || 1 + const lca = Math.hypot(ax - cx, ay - cy) || 1 + const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx) - 1)) + const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx) + 1)) + const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy) - 1)) + const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by, cy) + 1)) + for (let x = x0; x <= x1; x++) { + if (cols[x * COL] < depth) continue + const qx = x + 0.5 + for (let y = y0; y <= y1; y++) { + const qy = y + 0.5 + const e0 = (s * ((bx - ax) * (qy - ay) - (by - ay) * (qx - ax))) / lab + const e1 = (s * ((cx - bx) * (qy - by) - (cy - by) * (qx - bx))) / lbc + const e2 = (s * ((ax - cx) * (qy - cy) - (ay - cy) * (qx - cx))) / lca + // Half a pixel wider than the edge, so neighboring faces meet without a seam. + const cov = clamp(Math.min(e0, e1, e2) + 0.75, 0, 1) + if (cov <= 0) continue + const i = (y * W + x) * 3 + hdr[i] += (r - hdr[i]) * cov + hdr[i + 1] += (g - hdr[i + 1]) * cov + hdr[i + 2] += (b - hdr[i + 2]) * cov + } + } +} + +export const maze: Wallpaper = { + id: "maze", + name: "3D Maze", + description: "The old 3D maze screensaver: gliding through red brick corridors under a tiled ceiling, turning at every junction", + collection: "Classics", + fixedTime: "day", + activity: { + calm: "Long looks down each corridor between slow walks and turns", + lively: "Gliding down the corridors, pausing to look along each new one", + teeming: "A little quicker, with spinning polyhedra floating in the corridors", + }, + scrim: { + day: [ + [44, 42, 46], + [52, 12, 10], + [52, 34, 6], + ], + sunset: [ + [46, 28, 16], + [50, 14, 6], + [44, 22, 4], + ], + night: [ + [6, 8, 26], + [10, 4, 16], + [8, 6, 8], + ], + }, + create: (settings) => new Maze(settings), +} diff --git a/wallpapers/megacity.ts b/wallpapers/megacity.ts index eff646d..67e6e29 100644 --- a/wallpapers/megacity.ts +++ b/wallpapers/megacity.ts @@ -1279,6 +1279,8 @@ export const megacity: Wallpaper = { id: "megacity", name: "Megacity", description: "A canyon of towers over a neon avenue, where motorbikes ride off into the haze trailing red light", + collection: "Akira", + fixedTime: "night", activity: { calm: "The glowing city, windows coming and going, and a distant bike now and then", lively: "Adds bikes riding up the avenue with their light trails, and searchlights sweeping the sky", diff --git a/wallpapers/mystify.ts b/wallpapers/mystify.ts new file mode 100644 index 0000000..b9a5c05 --- /dev/null +++ b/wallpapers/mystify.ts @@ -0,0 +1,446 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { TAU, clamp, hsv, lerp, rand, smoothstep, type RGB } from "../src/math" +import { touch } from "../src/touched" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" + +// The scene runs slower than real time, which keeps it calm behind text. +const TIME_SCALE = 0.35 +// Corners per polygon: each moves on its own and bounces off the screen's edges. +const CORNERS = 4 +// The egg, the old Bezier screensaver: the edges bow into curves for this many scene seconds. +const BEZIER = 14 +// Segments per corner while the edges are curved. +const CURVE = 8 + +// Everything that changes with the time of day. Hues are fractions of the color wheel; day drifts all the way round, +// sunset and night swing back and forth between `from` and `to`. Never white or gray: the text on top is white. +interface Look { + sky: [number, RGB][] + from: number + to: number + cycle: boolean + saturation: number + value: number +} + +const LOOKS: Record = { + // The classic black desktop with bright, saturated lines. + day: { + sky: [ + [0, [0.003, 0.003, 0.004]], + [1, [0.001, 0.001, 0.002]], + ], + from: 0, + to: 1, + cycle: true, + saturation: 0.95, + value: 1, + }, + // Deep plum, the lines amber, rose and gold. + sunset: { + sky: [ + [0, [0.04, 0.008, 0.034]], + [0.6, [0.02, 0.004, 0.018]], + [1, [0.006, 0.001, 0.006]], + ], + from: -0.08, + to: 0.12, + cycle: false, + saturation: 1, + value: 0.95, + }, + // Navy-black, the lines teal, cobalt and violet. + night: { + sky: [ + [0, [0.003, 0.006, 0.022]], + [1, [0.0005, 0.001, 0.005]], + ], + from: 0.47, + to: 0.77, + cycle: false, + saturation: 0.95, + value: 1, + }, +} + +// Per activity: polygons, their corners' speed in screen heights per scene second, echoes in each stack, and the +// distance between echoes at a corner's usual speed. Every line moves a little each frame and the terminal redraws +// the cells it crosses; the lines reach across the screen, so the corners move slowly and the echoes stack closely. +const CAST: Record = { + calm: { count: 1, speed: 0.012, echoes: 6, spacing: 0.026 }, + lively: { count: 2, speed: 0.014, echoes: 6, spacing: 0.026 }, + teeming: { count: 3, speed: 0.016, echoes: 7, spacing: 0.026 }, +} +// History samples per echo gap; echoes read between samples, so the stack glides. +const SAMPLES = 4 + +// A polygon: its corners unfolded (x, y per corner; the screen position folds them back between the screen's edges, +// so bouncing is exact), their velocities and the decaying push of a click, a ring of past corner positions for the +// echoes, its place on the color wheel, and a life for the copies a finished task brings. +interface Poly { + u: Float64Array + v: Float64Array + kick: Float64Array + ring: Float64Array + head: number + hue: number + life: number + age: number +} + +const VALUES = CORNERS * 2 + +class Mystify extends Canvas { + private readonly look: Look + private readonly cast: (typeof CAST)[Activity] + // Scene seconds between echoes and between history samples, the scene clock and the next sample's time. + private readonly gap: number + private readonly sample: number + private time = 0 + private next = 0 + private polys: Poly[] = [] + private flash = 0 + private placed = false + private bezier = { wait: eggWait() * TIME_SCALE, t: -1 } + private readonly prev = new Float64Array(VALUES) + private readonly at = new Float64Array(CORNERS * 2) + private readonly mids = new Float64Array(CORNERS * 2) + + constructor(settings: Settings) { + super() + this.supersample = 1 + this.look = LOOKS[settings.time] + this.cast = CAST[settings.activity] + this.gap = this.cast.spacing / this.cast.speed + this.sample = this.gap / SAMPLES + this.polys = Array.from({ length: this.cast.count }, (_, i) => this.poly(i)) + } + + private poly(i: number, life = Infinity): Poly { + const f: Poly = { + u: new Float64Array(VALUES), + v: new Float64Array(VALUES), + kick: new Float64Array(VALUES), + ring: new Float64Array((this.cast.echoes * SAMPLES + 2) * CORNERS * 2), + head: 0, + hue: [0.2, 0.7, 0.45][i % 3] + rand(-0.03, 0.03), + life, + age: 0, + } + // One corner in each quarter of the screen, so every polygon starts out spanning it (x is a fraction of the width + // until layout() knows it). + const turn = Math.floor(rand(0, CORNERS)) + for (let c = 0; c < CORNERS; c++) { + const heading = rand(0, TAU) + const speed = this.cast.speed * rand(0.7, 1.3) + const q = (c + turn) % CORNERS + f.u[c * 2] = (q === 1 || q === 2 ? 0.5 : 0) + rand(0.04, 0.46) + f.u[c * 2 + 1] = (q >= 2 ? 0.5 : 0) + rand(0.04, 0.46) + f.v[c * 2] = Math.cos(heading) * speed + f.v[c * 2 + 1] = Math.sin(heading) * speed + } + return f + } + + // Fills a polygon's history as if it had always moved at its present velocity. + private rewind(f: Poly) { + const n = f.ring.length / (CORNERS * 2) + f.head = 0 + for (let s = 0; s < n; s++) { + const back = s * this.sample + (this.time - this.next + this.sample) + for (let k = 0; k < VALUES; k++) this.prev[k] = f.u[k] - f.v[k] * back + this.corners(this.prev, f.ring, s * CORNERS * 2) + } + } + + // Screen positions (in screen heights) of the corners for the unfolded values u, into out at o. + private corners(u: Float64Array, out: Float64Array, o: number) { + for (let k = 0; k < VALUES; k++) out[o + k] = fold(u[k], k & 1 ? 1 : this.A) + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + const start = this.time + this.time += dt + this.stepGloom(dt) + this.flash = Math.max(0, this.flash - dt) + // After an error the lines slow. + const pace = 1 - 0.6 * this.gloom + const decay = Math.exp(-dt / 2) + for (const f of this.polys) { + f.age += dt + f.hue += (dt / 60) * (1 + 6 * smoothstep(0, 1, this.flash)) + for (let k = 0; k < VALUES; k++) { + f.u[k] += (f.v[k] + f.kick[k]) * dt * pace + f.kick[k] *= decay + } + } + // Samples at exact intervals, worked out between the positions before and after this step. + for (; this.next <= this.time; this.next += this.sample) { + const back = this.time - this.next + for (const f of this.polys) { + const n = f.ring.length / (CORNERS * 2) + f.head = (f.head + n - 1) % n + for (let k = 0; k < VALUES; k++) this.prev[k] = f.u[k] - (f.v[k] + f.kick[k]) * back * pace + this.corners(this.prev, f.ring, f.head * CORNERS * 2) + } + } + this.polys = this.polys.filter((f) => f.age < f.life) + this.stepBezier(dt) + } + + render() { + this.restore() + const { H, look } = this + // On terminal cells the lines are wider and soft-edged: a crisp line gliding across a cell's sub-pixels keeps + // flipping its glyph, while a soft one mostly shifts colors. + const width = this.cells ? 0.8 : Math.max(0.5, 0.0024 * H) + const soft = this.cells ? 3.5 : 1 + const curve = this.bezier.t < 0 ? 0 : smoothstep(0, 2, this.bezier.t) * smoothstep(BEZIER, BEZIER - 2, this.bezier.t) + const flash = smoothstep(0, 1, this.flash) + const dim = 1 - 0.5 * this.gloom + const echoes = this.cast.echoes + // How far the newest sample is behind the present, in samples. + const lag = clamp(1 - (this.next - this.time) / this.sample, 0, 1) + for (const f of this.polys) { + const grow = f.life === Infinity ? 1 : smoothstep(0, 1, f.age) * smoothstep(f.life, f.life - 2, f.age) + if (grow <= 0.01) continue + const n = f.ring.length / (CORNERS * 2) + // Oldest first, so newer echoes draw over older ones; echo e is e gaps behind the present. + for (let e = echoes - 1; e >= 0; e--) { + if (e === 0) this.corners(f.u, this.at, 0) + if (e > 0) { + const s = e * SAMPLES - lag + const i = Math.floor(s) + const t = s - i + const a = ((f.head + i) % n) * CORNERS * 2 + const b = ((f.head + i + 1) % n) * CORNERS * 2 + for (let k = 0; k < CORNERS * 2; k++) this.at[k] = lerp(f.ring[a + k], f.ring[b + k], t) + } + for (let k = 0; k < CORNERS * 2; k++) this.at[k] *= H + const hue = f.hue - e * 0.01 + const base = look.cycle ? hsv(hue, look.saturation, look.value) : hsv(lerp(look.from, look.to, 0.5 - 0.5 * Math.cos(hue * TAU)), look.saturation, look.value) + const rainbow = hsv(f.hue * 3 + e * 0.09, 0.9, look.value * 1.1) + const a = grow * dim * (1 - (0.6 * e) / echoes) + const color: RGB = [lerp(base[0], rainbow[0], flash) * a, lerp(base[1], rainbow[1], flash) * a, lerp(base[2], rainbow[2], flash) * a] + this.outline(width, soft, color, curve) + } + } + this.finish() + } + + // Draws the polygon in this.at, its edges bowed toward a closed curve by `curve` (0 straight). + private outline(width: number, soft: number, color: RGB, curve: number) { + const { hdr, W, H, mids } = this + const points = this.at + if (curve <= 0.01) { + for (let c = 0; c < CORNERS; c++) { + const d = ((c + 1) % CORNERS) * 2 + stroke(hdr, W, H, points[c * 2], points[c * 2 + 1], points[d], points[d + 1], width, soft, color[0], color[1], color[2]) + } + return + } + for (let c = 0; c < CORNERS; c++) { + const d = ((c + 1) % CORNERS) * 2 + mids[c * 2] = (points[c * 2] + points[d]) / 2 + mids[c * 2 + 1] = (points[c * 2 + 1] + points[d + 1]) / 2 + } + // Around each corner, from the middle of the edge before to the middle of the edge after: straight through the + // corner, or a quadratic curve that only bends toward it. + for (let c = 0; c < CORNERS; c++) { + const m0 = ((c + CORNERS - 1) % CORNERS) * 2 + const m1 = c * 2 + let x0 = mids[m0] + let y0 = mids[m0 + 1] + for (let s = 1; s <= CURVE; s++) { + const t = s / CURVE + const sx = t < 0.5 ? lerp(mids[m0], points[c * 2], t * 2) : lerp(points[c * 2], mids[m1], t * 2 - 1) + const sy = t < 0.5 ? lerp(mids[m0 + 1], points[c * 2 + 1], t * 2) : lerp(points[c * 2 + 1], mids[m1 + 1], t * 2 - 1) + const bx = (1 - t) * (1 - t) * mids[m0] + 2 * t * (1 - t) * points[c * 2] + t * t * mids[m1] + const by = (1 - t) * (1 - t) * mids[m0 + 1] + 2 * t * (1 - t) * points[c * 2 + 1] + t * t * mids[m1 + 1] + const x1 = lerp(sx, bx, curve) + const y1 = lerp(sy, by, curve) + stroke(hdr, W, H, x0, y0, x1, y1, width, soft, color[0], color[1], color[2]) + x0 = x1 + y0 = y1 + } + } + } + + // A finished task: the colors race through the rainbow and each polygon splits off a copy for a while. + protected override visit() { + this.flash = 3 + if (this.polys.length > this.cast.count * 2) return + for (const parent of this.polys.slice(0, this.cast.count)) { + const f = this.poly(this.polys.length, 7) + f.u.set(parent.u) + for (let k = 0; k < VALUES; k++) f.v[k] = k & 1 ? parent.v[k] : -parent.v[k] + f.hue = parent.hue + 0.5 + this.rewind(f) + this.polys.push(f) + } + } + + // A click flings the nearest corner away from it. + poke(x: number, y: number) { + let best: Poly | undefined + let corner = 0 + let least = Infinity + for (const f of this.polys) { + this.corners(f.u, this.at, 0) + for (let c = 0; c < CORNERS; c++) { + const d = Math.hypot(this.at[c * 2] - x, this.at[c * 2 + 1] - y) + if (d >= least) continue + least = d + best = f + corner = c + } + } + if (!best) return + this.corners(best.u, this.at, 0) + const dx = this.at[corner * 2] - x + const dy = this.at[corner * 2 + 1] - y + const l = Math.hypot(dx, dy) || 1 + // Screen directions map to unfolded ones through the fold's slope where the corner is. + const k = corner * 2 + const sx = slope(best.u[k], this.A) + const sy = slope(best.u[k + 1], 1) + const speed = Math.hypot(best.v[k], best.v[k + 1]) + best.v[k] = (sx * dx * speed) / l + best.v[k + 1] = (sy * dy * speed) / l + best.kick[k] = (sx * dx * speed * 5) / l + best.kick[k + 1] = (sy * dy * speed * 5) / l + } + + protected override layout() { + const { W, H, hdr } = this + // Corners start spread across the width once the screen's shape is known. + if (!this.placed) + for (const f of this.polys) { + for (let c = 0; c < CORNERS; c++) f.u[c * 2] *= this.A + this.rewind(f) + } + this.placed = true + const stops = this.look.sky + for (let y = 0; y < H; y++) { + const v = (y + 0.5) / H + const i = stops.findIndex(([at]) => at >= v) + const [a, from] = stops[Math.max(0, i - 1)] + const [b, to] = stops[i < 0 ? stops.length - 1 : i] + const k = b > a ? smoothstep(a, b, v) : 1 + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3 + hdr[o] = lerp(from[0], to[0], k) + hdr[o + 1] = lerp(from[1], to[1], k) + hdr[o + 2] = lerp(from[2], to[2], k) + } + } + } + + private stepBezier(dt: number) { + const b = this.bezier + if (b.t < 0) { + b.wait -= dt + if (b.wait <= 0) b.t = 0 + return + } + b.t += dt + if (b.t < BEZIER) return + b.t = -1 + b.wait = eggWait(true) * TIME_SCALE + } +} + +// Folds an unfolded coordinate back between 0 and span, as if it had bounced off both ends. +function fold(u: number, span: number) { + const m = (((u % (2 * span)) + 2 * span) % (2 * span)) + return m < span ? m : 2 * span - m +} + +// Which way the folded coordinate moves as u grows: 1 or -1. +function slope(u: number, span: number) { + return (((u % (2 * span)) + 2 * span) % (2 * span)) < span ? 1 : -1 +} + +// An anti-aliased straight line from (ax, ay) to (bx, by) in pixels, `width` pixels from its middle to the middle of +// an edge that fades over `soft` pixels, mixed over the scene. Marks what it draws in short runs along the line, so only the blocks it crosses +// are worked out again. +function stroke(hdr: Float32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, width: number, soft: number, r: number, g: number, b: number) { + const dx = bx - ax + const dy = by - ay + const len2 = dx * dx + dy * dy + if (len2 < 1e-6) return + const reach = width + soft + const steep = Math.abs(dy) > Math.abs(dx) + // Walk the longer axis; across it, cover the band where the line is within reach. + const m0 = steep ? ay : ax + const m1 = steep ? by : bx + const n0 = steep ? ax : ay + const n1 = steep ? bx : by + const across = reach * Math.sqrt(len2) / Math.abs(m1 - m0) + const lo = Math.max(0, Math.floor(Math.min(m0, m1) - reach)) + const hi = Math.min((steep ? H : W) - 1, Math.ceil(Math.max(m0, m1) + reach)) + const limit = (steep ? W : H) - 1 + const inv = 1 / len2 + let run = lo + let runLo = Infinity + let runHi = -Infinity + for (let m = lo; m <= hi; m++) { + const t = clamp((m + 0.5 - m0) / (m1 - m0), 0, 1) + const c = n0 + (n1 - n0) * t - 0.5 + const a0 = Math.max(0, Math.floor(c - across)) + const a1 = Math.min(limit, Math.ceil(c + across)) + for (let n = a0; n <= a1; n++) { + const px = (steep ? n : m) + 0.5 + const py = (steep ? m : n) + 0.5 + const s = clamp(((px - ax) * dx + (py - ay) * dy) * inv, 0, 1) + const ex = ax + dx * s - px + const ey = ay + dy * s - py + const cover = clamp((width + soft / 2 - Math.sqrt(ex * ex + ey * ey)) / soft, 0, 1) + if (cover <= 0) continue + const o = ((steep ? m : n) * W + (steep ? n : m)) * 3 + hdr[o] += (r - hdr[o]) * cover + hdr[o + 1] += (g - hdr[o + 1]) * cover + hdr[o + 2] += (b - hdr[o + 2]) * cover + } + runLo = Math.min(runLo, a0) + runHi = Math.max(runHi, a1) + if (m - run < 7 && m < hi) continue + if (runHi >= runLo) touch(hdr, steep ? runLo : run, steep ? run : runLo, steep ? runHi : m, steep ? m : runHi) + run = m + 1 + runLo = Infinity + runHi = -Infinity + } +} + +export const mystify: Wallpaper = { + id: "mystify", + name: "Mystify", + description: "The old screensaver: bright polygons whose corners glide and bounce off the edges, trailing stacks of echoes", + collection: "Classics", + fixedTime: "day", + activity: { + calm: "One polygon drifting slowly", + lively: "The classic pair, in two colors", + teeming: "Three polygons, a little quicker, with longer trails", + }, + scrim: { + day: [ + [4, 4, 6], + [3, 3, 5], + [2, 2, 4], + ], + sunset: [ + [30, 8, 26], + [20, 5, 18], + [8, 2, 8], + ], + night: [ + [2, 4, 16], + [1, 3, 12], + [0, 1, 5], + ], + }, + create: (settings) => new Mystify(settings), +} diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index d37c9c5..cd478a8 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -1562,6 +1562,7 @@ export const ocean: Wallpaper = { id: "ocean", name: "Ocean", description: "Deep water, god rays, marine snow, and a whale that drifts past now and then", + collection: "Landscapes", activity: { calm: "Water, light and the whale", lively: "Adds a fish and an octopus", diff --git a/wallpapers/pipes.ts b/wallpapers/pipes.ts new file mode 100644 index 0000000..de5d110 --- /dev/null +++ b/wallpapers/pipes.ts @@ -0,0 +1,751 @@ +import { Canvas } from "../src/canvas" +import { eggWait } from "../src/egg" +import { clamp, lerp, rand, smoothstep, type RGB } from "../src/math" +import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" + +// The scene runs slower than real time, which keeps it calm behind text. +const TIME_SCALE = 0.35 +// The grid the pipes grow through, in cells: GY tall, GZ deep, and as wide as the screen's shape asks, up to GXM. The +// camera sits at the origin looking down +z at the grid's near face, Z0 cells away, which just overfills the screen. +const GY = 8 +const GZ = 12 +const GXM = 40 +const Z0 = 5 +const FOCAL = (0.5 * Z0) / (GY * 0.45) +// Pipe, ball joint and bend radii in cells. +const PIPE = 0.15 +const BALL = 0.23 +const BEND = 0.5 +// Subsamples per pixel along each axis in the pipe buffer, for anti-aliased edges. +const SS = 2 +// Pending pipe pixels are tracked in blocks this many pixels square, and folded into the background now and then. +const BLOCK = 8 +const BAKE_FRAMES = 45 +// The share of the grid filled, and the scene seconds, after which the pipes fade out and start again. +const FULL = 0.45 +const FADE = 1 +// Direction vectors for +x, -x, +y, -y, +z, -z (y down, z away from the camera). +const DX = [1, -1, 0, 0, 0, 0] +const DY = [0, 0, 1, -1, 0, 0] +const DZ = [0, 0, 0, 0, 1, -1] +const CYL = 0 +const BALLS = 1 +const ARC = 2 + +// The classic colors: teal, green, orange, yellow, gray, red, blue and violet. +const COLORS: RGB[] = [ + [0.2, 0.75, 0.75], + [0.12, 0.58, 0.08], + [0.95, 0.34, 0.04], + [1, 0.7, 0.04], + [0.52, 0.52, 0.55], + [0.85, 0.06, 0.05], + [0.09, 0.25, 0.95], + [0.5, 0.1, 0.8], +] +// The teal and green come up most often, as in the original. +const PICKS = [0, 0, 0, 1, 1, 1, 2, 3, 4, 5, 6, 7] + +interface Look { + // The background, top to bottom. + sky: [RGB, RGB] + // The key light's color, the ambient share, the glint's color and how much of the pipe's color it takes, a soft + // fill on the rims, how far the far end fades toward the background, and a tint for the pipe colors. + key: RGB + ambient: number + glint: RGB + glintTint: number + fill: RGB + fog: number + tint: RGB +} + +const LOOKS: Record = { + // The classic black screen with bright pipes under a strong white light. + day: { + sky: [ + [0.002, 0.002, 0.003], + [0.001, 0.001, 0.0015], + ], + key: [1.1, 1.08, 1.02], + ambient: 0.14, + glint: [1, 0.98, 0.94], + glintTint: 0.15, + fill: [0.02, 0.03, 0.04], + fog: 0.4, + tint: [1, 1, 1], + }, + // A deep plum glowing to embers at the bottom, under a low, warm light. + sunset: { + sky: [ + [0.06, 0.012, 0.05], + [0.085, 0.022, 0.006], + ], + key: [1.15, 0.82, 0.58], + ambient: 0.14, + glint: [1, 0.62, 0.3], + glintTint: 0.45, + fill: [0.3, 0.05, 0.18], + fog: 0.45, + tint: [1.05, 0.95, 0.88], + }, + // Navy to black, each pipe's glint its own color edged with cobalt: never white. + night: { + sky: [ + [0.003, 0.006, 0.024], + [0.0006, 0.0012, 0.005], + ], + key: [0.62, 0.72, 1], + ambient: 0.09, + glint: [0.16, 0.36, 1.05], + glintTint: 0.65, + fill: [0.02, 0.1, 0.3], + fog: 0.5, + tint: [0.78, 0.84, 1], + }, +} + +// A piece of pipe as it grows: a cylinder from (x, y, z) along direction d, ext cells long; a ball (or one of the +// teapot's ellipsoids) at (x, y, z), squashed by ky; or a bend through a cell from the face it entered by (dir e2) to +// the one it leaves by (opposite e1), around (x, y, z), ext radians along. drawn is how much is in the pipe buffer. +interface Prim { + kind: number + x: number + y: number + z: number + d: number + e1: number + e2: number + r: number + ky: number + ext: number + full: number + drawn: number + color: RGB +} + +// A pipe working through its current cell (i, j, k): entered heading dIn (-1 where it starts), leaving heading dOut +// (-1 where it ends), u of the way through. pieces are that cell's prims. +interface Pipe { + i: number + j: number + k: number + dIn: number + dOut: number + u: number + bend: boolean + length: number + max: number + color: RGB + pieces: Prim[] + done: boolean +} + +class Pipes extends Canvas { + private time = 0 + private readonly activity: Activity + private readonly look: Look + private readonly speed: number + private readonly count: number + private readonly bends: number + private readonly limit: number + private pipes: Pipe[] = [] + private waits: number[] = [] + private prims: Prim[] = [] + private fresh: Prim[] = [] + private readonly grid = new Uint8Array(GXM * GY * GZ) + private filled = 0 + private gx = GY * 2 + private cycle = 0 + private fade = -1 + private burst = 0 + private egg = eggWait() * TIME_SCALE + private teapot = false + private grown = false + // The pipe buffer: subsample colors and depths, the background per subsample row, and pending blocks. + private samples = new Float32Array(0) + private depth = new Float32Array(0) + private rows = new Float32Array(0) + private base = new Float32Array(0) + private pending = new Uint8Array(0) + private pendingCount = 0 + private sinceBake = 0 + private readonly shade = new Float32Array(16) + private readonly box = new Float32Array(4) + private readonly spine = new Float32Array(9) + + constructor(settings: Settings) { + super() + this.supersample = 1 + this.activity = settings.activity + this.look = LOOKS[settings.time] + this.speed = { calm: 0.8, lively: 1.5, teeming: 2.8 }[this.activity] + this.count = { calm: 1, lively: 3, teeming: 6 }[this.activity] + this.bends = { calm: 0, lively: 0.1, teeming: 0.35 }[this.activity] + this.limit = { calm: 300, lively: 200, teeming: 130 }[this.activity] + this.waits = Array.from({ length: this.count }, (_, i) => (i === 0 ? 0 : rand(1, 4) * i)) + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.time += dt + this.stepGloom(dt) + if (!this.grown) return + this.egg -= dt + if (this.egg <= 0 && !this.teapot) this.teapot = true + if (this.fade >= 0) { + this.fade += dt / FADE + if (this.fade >= 1) this.clear() + return + } + this.cycle += dt + this.burst = Math.max(0, this.burst - dt) + this.grow(dt * (1 - 0.9 * this.gloom) * (1 + 4 * smoothstep(0, 1, this.burst))) + if (this.filled > FULL * this.gx * GY * GZ || this.cycle > this.limit) this.fade = 0 + } + + render() { + this.restore() + const { W, H } = this + for (const p of this.fresh) this.draw(p) + this.fresh = this.fresh.filter((p) => p.drawn < p.full) + this.sinceBake++ + if (this.pendingCount && (this.sinceBake >= BAKE_FRAMES || this.fade >= 0)) { + resolve(this.samples, this.background, W, H, 0, 0, W - 1, H - 1) + this.pending.fill(0) + this.pendingCount = 0 + this.sinceBake = 0 + this.hdr.set(this.background) + this.rebake() + } + const bw = Math.ceil(W / BLOCK) + const bh = Math.ceil(H / BLOCK) + for (let by = 0; by < bh; by++) + for (let bx = 0; bx < bw; bx++) { + if (!this.pending[by * bw + bx]) continue + let end = bx + while (end + 1 < bw && this.pending[by * bw + end + 1]) end++ + const x0 = bx * BLOCK + const y0 = by * BLOCK + const x1 = Math.min(W, (end + 1) * BLOCK) - 1 + const y1 = Math.min(H, (by + 1) * BLOCK) - 1 + resolve(this.samples, this.hdr, W, H, x0, y0, x1, y1) + this.touch(x0, y0, x1, y1) + bx = end + } + if (this.fade >= 0) { + dim(this.hdr, this.base, smoothstep(0, 1, this.fade)) + this.touch(0, 0, W - 1, H - 1) + } + this.finish() + } + + // A finished task: every growing pipe races ahead for a moment, and a couple more join in. + protected override visit() { + if (this.fade >= 0) return + this.burst = 3 + for (let n = 0; n < 2 && this.pipes.length < this.count + 3; n++) this.sprout() + } + + // A click sprouts a new pipe at the nearest free cell under the pointer. + poke(x: number, y: number) { + if (this.fade >= 0 || this.pipes.length >= this.count + 4) return + const rx = (x - this.A / 2) / FOCAL + const ry = (y - 0.5) / FOCAL + for (let k = 1; k < GZ; k++) { + const z = Z0 + k + const i = Math.round(rx * z + (GXM - 1) / 2) + const j = Math.round(ry * z + (GY - 1) / 2) + if (!this.inside(i, j, k) || this.grid[this.cell(i, j, k)]) continue + this.start(i, j, k) + return + } + } + + protected override layout() { + const { W, H, look } = this + this.gx = Math.min(GXM, Math.max(4, Math.round(this.A * GY))) + // Grow the scene a while before the first frame, so it never opens on an empty screen. + if (!this.grown) { + this.grown = true + for (let t = { calm: 100, lively: 30, teeming: 14 }[this.activity]; t > 0; t -= 0.05) this.grow(0.05) + } + for (let y = 0; y < H; y++) { + const k = smoothstep(0, 1, (y + 0.5) / H) + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3 + this.hdr[o] = lerp(look.sky[0][0], look.sky[1][0], k) + this.hdr[o + 1] = lerp(look.sky[0][1], look.sky[1][1], k) + this.hdr[o + 2] = lerp(look.sky[0][2], look.sky[1][2], k) + } + } + this.base = this.hdr.slice() + this.rows = new Float32Array(H * SS * 3) + for (let y = 0; y < H * SS; y++) { + const k = smoothstep(0, 1, (y + 0.5) / (H * SS)) + for (let c = 0; c < 3; c++) this.rows[y * 3 + c] = lerp(look.sky[0][c], look.sky[1][c], k) + } + this.samples = new Float32Array(W * SS * H * SS * 3) + this.depth = new Float32Array(W * SS * H * SS) + this.pending = new Uint8Array(Math.ceil(W / BLOCK) * Math.ceil(H / BLOCK)) + this.pendingCount = 0 + this.wipe() + for (const p of this.prims) { + p.drawn = 0 + this.draw(p) + } + this.pending.fill(0) + this.pendingCount = 0 + resolve(this.samples, this.hdr, W, H, 0, 0, W - 1, H - 1) + } + + // Empties the pipe buffer to the background. + private wipe() { + const n = this.depth.length + const SW = this.W * SS + this.depth.fill(Infinity) + for (let s = 0; s < n; s++) { + const r = Math.floor(s / SW) * 3 + this.samples[s * 3] = this.rows[r] + this.samples[s * 3 + 1] = this.rows[r + 1] + this.samples[s * 3 + 2] = this.rows[r + 2] + } + } + + // The fade is over: empty the grid and start again. + private clear() { + this.fade = -1 + this.cycle = 0 + this.pipes = [] + this.prims = [] + this.fresh = [] + this.grid.fill(0) + this.filled = 0 + this.waits = Array.from({ length: this.count }, (_, i) => rand(0.2, 0.6) + i * rand(1, 3)) + this.wipe() + this.pending.fill(0) + this.pendingCount = 0 + this.background.set(this.base) + this.rebake() + } + + private grow(dt: number) { + for (const p of this.pipes) this.advance(p, dt * this.speed) + const live = this.pipes.filter((p) => !p.done) + for (let n = this.pipes.length - live.length; n > 0; n--) this.waits.push(rand(0.5, 3)) + this.pipes = live + this.waits = this.waits.map((w) => w - dt).sort((a, b) => a - b) + while (this.waits.length && this.waits[0] <= 0) { + this.waits.shift() + if (this.pipes.length < this.count) this.sprout() + } + } + + private advance(p: Pipe, du: number) { + p.u += du + for (;;) { + this.extend(p) + if (p.dOut < 0 && p.u >= 0.5) { + p.done = true + return + } + if (p.u < 1) return + p.u -= 1 + p.i += DX[p.dOut] + p.j += DY[p.dOut] + p.k += DZ[p.dOut] + p.dIn = p.dOut + this.enter(p) + } + } + + // Sets the extents of the pieces in the pipe's cell from how far through it the pipe is. + private extend(p: Pipe) { + const u = Math.min(p.u, 1) + if (p.bend) { + p.pieces[0].ext = u * (Math.PI / 2) + return + } + for (const piece of p.pieces) { + if (piece.kind === BALLS) piece.ext = u >= 0.5 ? 1 : 0 + if (piece.kind !== CYL) continue + // The half leading in from the face, then the half leading out from the middle. + piece.ext = piece.full === 1 ? u : piece === p.pieces[0] && p.dIn >= 0 ? Math.min(u, 0.5) : Math.max(0, u - 0.5) + } + } + + // The pipe has reached a new cell: choose where it leaves by and lay out the pieces through it. + private enter(p: Pipe) { + p.length++ + p.dOut = p.length >= p.max ? -1 : this.choose(p) + if (p.dOut >= 0) this.claim(p.i + DX[p.dOut], p.j + DY[p.dOut], p.k + DZ[p.dOut]) + const [x, y, z] = this.center(p.i, p.j, p.k) + p.pieces = [] + p.bend = false + if (p.dIn >= 0 && p.dOut === p.dIn) { + p.pieces.push(this.prim(CYL, x - DX[p.dIn] * 0.5, y - DY[p.dIn] * 0.5, z - DZ[p.dIn] * 0.5, p.dIn, PIPE, 1, p.color)) + return + } + if (p.dIn >= 0 && p.dOut >= 0 && Math.random() < this.bends) { + p.bend = true + // The bend's axis sits on the corner between the two faces. + const a = this.prim(ARC, x - DX[p.dIn] * BEND + DX[p.dOut] * BEND, y - DY[p.dIn] * BEND + DY[p.dOut] * BEND, z - DZ[p.dIn] * BEND + DZ[p.dOut] * BEND, 0, PIPE, Math.PI / 2, p.color) + a.e1 = p.dOut ^ 1 + a.e2 = p.dIn + p.pieces.push(a) + return + } + if (p.dIn >= 0) p.pieces.push(this.prim(CYL, x - DX[p.dIn] * 0.5, y - DY[p.dIn] * 0.5, z - DZ[p.dIn] * 0.5, p.dIn, PIPE, 0.5, p.color)) + // Only at a near joint, where it's big enough to see. + const teapot = this.teapot && p.dIn >= 0 && p.dOut >= 0 && p.k < GZ / 2 + if (teapot) { + this.teapot = false + this.egg = eggWait(true) * TIME_SCALE + for (const t of TEAPOT) p.pieces.push(this.prim(BALLS, x + t[0] * 1.4, y + t[1] * 1.4, z + t[2] * 1.4, 0, t[3] * 1.4, 1, p.color, t[4])) + } + if (!teapot) p.pieces.push(this.prim(BALLS, x, y, z, 0, BALL, 1, p.color)) + if (p.dOut >= 0) p.pieces.push(this.prim(CYL, x, y, z, p.dOut, PIPE, 0.5, p.color)) + } + + private prim(kind: number, x: number, y: number, z: number, d: number, r: number, full: number, color: RGB, ky = 1): Prim { + const prim = { kind, x, y, z, d, e1: 0, e2: 0, r, ky, ext: 0, full, drawn: 0, color } + this.prims.push(prim) + this.fresh.push(prim) + return prim + } + + // Mostly straight on; otherwise any free way but back. + private choose(p: Pipe) { + const free = [0, 1, 2, 3, 4, 5].filter((d) => { + const i = p.i + DX[d] + const j = p.j + DY[d] + const k = p.k + DZ[d] + return this.inside(i, j, k) && !this.grid[this.cell(i, j, k)] + }) + if (!free.length) return -1 + if (p.dIn >= 0 && free.includes(p.dIn) && Math.random() < 0.6) return p.dIn + const turns = free.filter((d) => d !== p.dIn) + const pick = turns.length ? turns : free + return pick[Math.floor(Math.random() * pick.length)] + } + + // A new pipe at a random free cell. + private sprout() { + for (let n = 0; n < 60; n++) { + const lo = Math.floor((GXM - this.gx) / 2) + const i = lo + Math.floor(Math.random() * this.gx) + const j = Math.floor(Math.random() * GY) + // Nearer cells more often, so the screen's edges get the big pipes and its middle stays quieter. + const k = Math.floor(Math.random() ** 1.6 * GZ) + if (this.grid[this.cell(i, j, k)]) continue + this.start(i, j, k) + return + } + } + + private start(i: number, j: number, k: number) { + const color = this.paint(COLORS[PICKS[Math.floor(Math.random() * PICKS.length)]]) + const p: Pipe = { i, j, k, dIn: -1, dOut: -1, u: 0.5, bend: false, length: 0, max: Math.floor(rand(18, 50)), color, pieces: [], done: false } + this.claim(i, j, k) + this.enter(p) + this.pipes.push(p) + } + + private paint(c: RGB): RGB { + const t = this.look.tint + // Night has no gray: that pipe turns slate blue. + const g = this.look === LOOKS.night && c === COLORS[4] ? ([0.16, 0.24, 0.55] as RGB) : c + return [g[0] * t[0], g[1] * t[1], g[2] * t[2]] + } + + private claim(i: number, j: number, k: number) { + this.grid[this.cell(i, j, k)] = 1 + this.filled++ + } + + private inside(i: number, j: number, k: number) { + const lo = Math.floor((GXM - this.gx) / 2) + return i >= lo && i < lo + this.gx && j >= 0 && j < GY && k >= 0 && k < GZ + } + + private cell(i: number, j: number, k: number) { + return (k * GY + j) * GXM + i + } + + private center(i: number, j: number, k: number) { + return [i - (GXM - 1) / 2, j - (GY - 1) / 2, Z0 + k] + } + + // Draws what a piece has grown since it was last drawn into the pipe buffer, and marks the blocks it changed. + private draw(p: Prim) { + if (p.ext <= p.drawn) return + const look = this.look + const gloom = 1 - 0.4 * this.gloom + const s = this.shade + s.set([look.key[0] * gloom, look.key[1] * gloom, look.key[2] * gloom, look.ambient, look.glint[0] * gloom, look.glint[1] * gloom, look.glint[2] * gloom, look.glintTint, look.fill[0], look.fill[1], look.fill[2], look.fog]) + const SW = this.W * SS + const SH = this.H * SS + const f = FOCAL * SH + const box = this.box + box[0] = Infinity + box[1] = Infinity + box[2] = -Infinity + box[3] = -Infinity + if (p.kind === BALLS) ellipsoid(this.samples, this.depth, this.rows, SW, SH, f, s, p.x, p.y, p.z, p.r, p.ky, p.color, box, undefined) + if (p.kind === CYL) { + const a = p.drawn + const b = p.ext + const axis = p.d >> 1 + const sign = p.d & 1 ? -1 : 1 + const at = axis === 0 ? p.x : axis === 1 ? p.y : p.z + // Back over the last bit drawn, so the joint between frames is seamless. + const s0 = at + sign * Math.max(0, a - 0.02) + const s1 = at + sign * b + cylinder(this.samples, this.depth, this.rows, SW, SH, f, s, axis, p.x, p.y, p.z, Math.min(s0, s1), Math.max(s0, s1), p.r, p.color, box) + } + if (p.kind === ARC) { + const step = 0.06 + // The spine the balls are strung along, so they shade as one smooth tube. + const spine = this.spine + spine.set([p.x, p.y, p.z, DX[p.e1], DY[p.e1], DZ[p.e1], DX[p.e2], DY[p.e2], DZ[p.e2]]) + for (let t = Math.floor(p.drawn / step) * step; t < p.ext + step; t += step) { + const th = Math.min(t, p.ext) + const c = Math.cos(th) * BEND + const n = Math.sin(th) * BEND + ellipsoid(this.samples, this.depth, this.rows, SW, SH, f, s, p.x + DX[p.e1] * c + DX[p.e2] * n, p.y + DY[p.e1] * c + DY[p.e2] * n, p.z + DZ[p.e1] * c + DZ[p.e2] * n, p.r, 1, p.color, box, spine) + if (th >= p.ext) break + } + } + p.drawn = p.kind === BALLS ? 1 : p.ext + if (box[2] < box[0]) return + const bw = Math.ceil(this.W / BLOCK) + const x0 = Math.max(0, Math.floor(box[0] / SS / BLOCK)) + const y0 = Math.max(0, Math.floor(box[1] / SS / BLOCK)) + const x1 = Math.min(bw - 1, Math.floor(box[2] / SS / BLOCK)) + const y1 = Math.min(Math.ceil(this.H / BLOCK) - 1, Math.floor(box[3] / SS / BLOCK)) + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) { + if (this.pending[y * bw + x]) continue + this.pending[y * bw + x] = 1 + this.pendingCount++ + } + } +} + +// The easter egg's teapot, as ellipsoids around the joint [x, y, z, radius, squash]: body, lid, knob, a spout curving +// up to the right and a handle on the left. +const TEAPOT: number[][] = [ + [0, 0.02, 0, 0.3, 0.7], + [0, -0.17, 0, 0.17, 0.4], + [0, -0.25, 0, 0.05, 1], + ...Array.from({ length: 16 }, (_, n) => { + const t = n / 15 + return [0.22 + 0.24 * t, 0.06 - 0.26 * t * t - 0.04 * t, 0, 0.075 - 0.035 * t, 1] + }), + ...Array.from({ length: 24 }, (_, n) => { + const a = -Math.PI / 2 + (Math.PI * n) / 23 + return [-0.28 - 0.13 * Math.cos(a), 0.12 * Math.sin(a), 0, 0.042, 1] + }), +] + +// Shades one subsample of a pipe hit at depth z with normal n, seen along d: the key light from the upper left and +// front, a glint, a soft rim fill, and the far end fading toward the background. +function light(samples: Float32Array, rows: Float32Array, s: number, row: number, nx: number, ny: number, nz: number, dx: number, dy: number, z: number, color: RGB, shade: Float32Array) { + const lx = -0.48 + const ly = -0.66 + const lz = -0.58 + const vl = 1 / Math.hypot(dx, dy, 1) + const vx = -dx * vl + const vy = -dy * vl + const vz = -vl + const diffuse = Math.max(0, nx * lx + ny * ly + nz * lz) + const hx = lx + vx + const hy = ly + vy + const hz = lz + vz + const hl = 1 / Math.hypot(hx, hy, hz) + const sp = Math.max(0, (nx * hx + ny * hy + nz * hz) * hl) + const sp2 = sp * sp + const sp8 = sp2 * sp2 * sp2 * sp2 + const spec = sp8 * sp8 * sp8 * sp8 * 1.1 + sp8 * 0.12 + const facing = nx * vx + ny * vy + nz * vz + const rim = (1 - Math.max(0, facing)) ** 3 + const fog = shade[11] * smoothstep(Z0, Z0 + GZ, z) + const o = s * 3 + for (let c = 0; c < 3; c++) { + const glint = lerp(shade[4 + c], shade[4 + c] * color[c] * 1.6, shade[7]) + const lit = color[c] * (shade[3] + shade[c] * diffuse) + glint * spec + shade[8 + c] * rim + samples[o + c] = lerp(lit, rows[row * 3 + c], fog) + } +} + +// Ray-casts an ellipsoid (radius r, its height squashed by ky) centered at (cx, cy, cz) into the pipe buffer with a +// depth test, growing box to the subsamples it covers. A ball strung along a bend's spine (its center and the two +// directions spanning its quarter circle) takes its normal from the spine instead, so the bend shades smoothly. +function ellipsoid(samples: Float32Array, depth: Float32Array, rows: Float32Array, SW: number, SH: number, f: number, shade: Float32Array, cx: number, cy: number, cz: number, r: number, ky: number, color: RGB, box: Float32Array, spine: Float32Array | undefined) { + const ry = r * ky + const near = cz - r + if (near <= 0.1) return + const x0 = Math.max(0, Math.floor(SW / 2 + (f * (cx - r)) / (cx - r < 0 ? near : cz + r))) + const x1 = Math.min(SW - 1, Math.ceil(SW / 2 + (f * (cx + r)) / (cx + r > 0 ? near : cz + r))) + const y0 = Math.max(0, Math.floor(SH / 2 + (f * (cy - ry)) / (cy - ry < 0 ? near : cz + r))) + const y1 = Math.min(SH - 1, Math.ceil(SH / 2 + (f * (cy + ry)) / (cy + ry > 0 ? near : cz + r))) + if (x1 < x0 || y1 < y0) return + const iy = 1 / ky + const ccy = cy * iy + const cc = cx * cx + ccy * ccy + cz * cz - r * r + let hit = false + for (let y = y0; y <= y1; y++) { + const dy = (y + 0.5 - SH / 2) / f + const dys = dy * iy + for (let x = x0; x <= x1; x++) { + const dx = (x + 0.5 - SW / 2) / f + const a = dx * dx + dys * dys + 1 + const b = dx * cx + dys * ccy + cz + const disc = b * b - a * cc + if (disc < 0) continue + const t = (b - Math.sqrt(disc)) / a + const s = y * SW + x + if (t >= depth[s]) continue + depth[s] = t + hit = true + let nx = t * dx - cx + let ny = (t * dy - cy) * iy * iy + let nz = t - cz + if (spine) { + const px = t * dx - spine[0] + const py = t * dy - spine[1] + const pz = t - spine[2] + const phi = clamp(Math.atan2(px * spine[6] + py * spine[7] + pz * spine[8], px * spine[3] + py * spine[4] + pz * spine[5]), 0, Math.PI / 2) + const c = Math.cos(phi) * BEND + const n = Math.sin(phi) * BEND + nx = px - spine[3] * c - spine[6] * n + ny = py - spine[4] * c - spine[7] * n + nz = pz - spine[5] * c - spine[8] * n + } + const nl = 1 / Math.hypot(nx, ny, nz) + light(samples, rows, s, y, nx * nl, ny * nl, nz * nl, dx, dy, t, color, shade) + } + } + if (!hit) return + box[0] = Math.min(box[0], x0) + box[1] = Math.min(box[1], y0) + box[2] = Math.max(box[2], x1) + box[3] = Math.max(box[3], y1) +} + +// Ray-casts a capped cylinder of radius r along axis (0 x, 1 y, 2 z) from a to b, its center line through (cx, cy, cz). +function cylinder(samples: Float32Array, depth: Float32Array, rows: Float32Array, SW: number, SH: number, f: number, shade: Float32Array, axis: number, cx: number, cy: number, cz: number, a: number, b: number, r: number, color: RGB, box: Float32Array) { + const lx0 = axis === 0 ? a : cx - r + const lx1 = axis === 0 ? b : cx + r + const ly0 = axis === 1 ? a : cy - r + const ly1 = axis === 1 ? b : cy + r + const lz0 = axis === 2 ? a : cz - r + const lz1 = axis === 2 ? b : cz + r + if (lz0 <= 0.1) return + const x0 = Math.max(0, Math.floor(SW / 2 + (f * lx0) / (lx0 < 0 ? lz0 : lz1))) + const x1 = Math.min(SW - 1, Math.ceil(SW / 2 + (f * lx1) / (lx1 > 0 ? lz0 : lz1))) + const y0 = Math.max(0, Math.floor(SH / 2 + (f * ly0) / (ly0 < 0 ? lz0 : lz1))) + const y1 = Math.min(SH - 1, Math.ceil(SH / 2 + (f * ly1) / (ly1 > 0 ? lz0 : lz1))) + if (x1 < x0 || y1 < y0) return + // The two axes across the cylinder, p and q, and its center line on them. + const cp = axis === 0 ? cy : cx + const cq = axis === 2 ? cy : cz + let hit = false + for (let y = y0; y <= y1; y++) { + const dy = (y + 0.5 - SH / 2) / f + for (let x = x0; x <= x1; x++) { + const dx = (x + 0.5 - SW / 2) / f + const dp = axis === 0 ? dy : dx + const dq = axis === 2 ? dy : 1 + const dk = axis === 0 ? dx : axis === 1 ? dy : 1 + const qa = dp * dp + dq * dq + const qb = dp * cp + dq * cq + const disc = qb * qb - qa * (cp * cp + cq * cq - r * r) + if (disc < 0 || qa < 1e-9) continue + const root = Math.sqrt(disc) + const t0 = (qb - root) / qa + const t1 = (qb + root) / qa + // Where the ray is between the caps. + const ta = dk === 0 ? (a <= 0 && b >= 0 ? -Infinity : Infinity) : Math.min(a / dk, b / dk) + const tb = dk === 0 ? (a <= 0 && b >= 0 ? Infinity : -Infinity) : Math.max(a / dk, b / dk) + const t = Math.max(t0, ta) + if (t > Math.min(t1, tb) || t <= 0) continue + const s = y * SW + x + if (t >= depth[s]) continue + depth[s] = t + hit = true + const side = t0 >= ta + const np = side ? (t * dp - cp) / r : 0 + const nq = side ? (t * dq - cq) / r : 0 + const nk = side ? 0 : dk > 0 ? -1 : 1 + const nx = axis === 0 ? nk : np + const ny = axis === 0 ? np : axis === 1 ? nk : nq + const nz = axis === 2 ? nk : nq + light(samples, rows, s, y, nx, ny, nz, dx, dy, t, color, shade) + } + } + if (!hit) return + box[0] = Math.min(box[0], x0) + box[1] = Math.min(box[1], y0) + box[2] = Math.max(box[2], x1) + box[3] = Math.max(box[3], y1) +} + +// Averages the pipe buffer's subsamples into pixels x0..x1, y0..y1 of out. +function resolve(samples: Float32Array, out: Float32Array, W: number, H: number, x0: number, y0: number, x1: number, y1: number) { + const SW = W * SS + const k = 1 / (SS * SS) + for (let y = y0; y <= Math.min(y1, H - 1); y++) + for (let x = x0; x <= Math.min(x1, W - 1); x++) { + let r = 0 + let g = 0 + let b = 0 + for (let j = 0; j < SS; j++) + for (let i = 0; i < SS; i++) { + const s = ((y * SS + j) * SW + x * SS + i) * 3 + r += samples[s] + g += samples[s + 1] + b += samples[s + 2] + } + const o = (y * W + x) * 3 + out[o] = r * k + out[o + 1] = g * k + out[o + 2] = b * k + } +} + +// Fades the whole frame toward the empty background by k. +function dim(hdr: Float32Array, base: Float32Array, k: number) { + for (let i = 0; i < hdr.length; i++) hdr[i] += (base[i] - hdr[i]) * k +} + +export const pipes: Wallpaper = { + id: "pipes", + name: "3D Pipes", + description: "The old 3D screensaver: glossy pipes growing through the dark one length at a time, turning at ball joints", + collection: "Classics", + fixedTime: "day", + activity: { + calm: "One pipe growing slowly through the grid, over a long fill", + lively: "Two or three pipes growing at once", + teeming: "Five pipes, quicker, some sweeping round in smooth bends", + }, + scrim: { + day: [ + [6, 6, 8], + [4, 4, 6], + [3, 3, 4], + ], + sunset: [ + [30, 6, 24], + [20, 5, 14], + [24, 8, 3], + ], + night: [ + [2, 4, 16], + [1, 3, 11], + [0, 1, 5], + ], + }, + create: (settings) => new Pipes(settings), +} diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index afe2e3c..bf51163 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -1224,6 +1224,7 @@ export const prehistoric: Wallpaper = { id: "prehistoric", name: "Prehistoric", description: "A smoking volcano over ferns and cycads, with a T. rex stomping by now and then", + collection: "Landscapes", activity: { calm: "Volcano, smoke, ferns and cycads", lively: "Adds a browsing sauropod and circling pterosaurs", diff --git a/wallpapers/rider.ts b/wallpapers/rider.ts index 9f12048..1a74e12 100644 --- a/wallpapers/rider.ts +++ b/wallpapers/rider.ts @@ -925,6 +925,8 @@ export const rider: Wallpaper = { id: "rider", name: "Rider", description: "A red capsule bike on pale cracked concrete, its rider walking up to it, with another of the gang speeding by now and then", + collection: "Akira", + fixedTime: "day", activity: { calm: "The bike, the rider standing by it, and drifting dust", lively: "He walks slowly up to the bike and back, and paper and pebbles skitter in the gusts", diff --git a/wallpapers/space.ts b/wallpapers/space.ts index 67b9a97..6214285 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -765,6 +765,7 @@ export const space: Wallpaper = { id: "space", name: "Space", description: "A ringed planet turning slowly below a nebula and drifting stars, with a comet now and then", + collection: "Landscapes", activity: { calm: "The planet, its rings, a moon and the stars", lively: "Adds an orbiting space station and a satellite", diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index 282d5dc..17aa42e 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -816,6 +816,7 @@ export const tundra: Wallpaper = { id: "tundra", name: "Tundra", description: "Snowy peaks, a frozen lake, igloos and a snowman under falling snow, with a snowy owl now and then", + collection: "Landscapes", activity: { calm: "Peaks, igloos, a snowman and falling snow", lively: "Adds waddling penguins and an arctic fox", diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index b2125df..da1bed4 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -1310,6 +1310,7 @@ export const zen: Wallpaper = { id: "zen", name: "Zen garden", description: "A koi pond under a red bridge, raked gravel, stone lanterns and falling cherry petals, with a heron now and then", + collection: "Landscapes", activity: { calm: "Garden, pond ripples, petals and a bamboo fountain", lively: "Adds koi circling under the lily pads",