From cfbc4ddb19fb0a645603d76f2ac2eb51d4cc2139 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 08:43:30 -0700 Subject: [PATCH 01/15] Add 3D Pipes wallpaper --- src/canvas.ts | 6 + wallpapers/index.ts | 3 +- wallpapers/pipes.ts | 748 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 wallpapers/pipes.ts diff --git a/src/canvas.ts b/src/canvas.ts index 28efcc5..7d77348 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -292,6 +292,12 @@ 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 + } + // 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/wallpapers/index.ts b/wallpapers/index.ts index 3ef2110..8349f76 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -8,10 +8,11 @@ import { hauntedHouse } from "./haunted-house" import { jungle } from "./jungle" import { megacity } from "./megacity" 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, rider, megacity, dome, hauntedHouse, flowerbox, pipes] diff --git a/wallpapers/pipes.ts b/wallpapers/pipes.ts new file mode 100644 index 0000000..07392bd --- /dev/null +++ b/wallpapers/pipes.ts @@ -0,0 +1,748 @@ +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.17, 0.6, 0.6], + [0.1, 0.45, 0.07], + [0.8, 0.28, 0.03], + [0.85, 0.58, 0.03], + [0.42, 0.42, 0.44], + [0.7, 0.05, 0.04], + [0.07, 0.2, 0.8], + [0.42, 0.08, 0.66], +] +// 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.5, + 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.1, 0.72, 0.45], + ambient: 0.12, + glint: [1, 0.6, 0.28], + glintTint: 0.45, + fill: [0.25, 0.04, 0.16], + fog: 0.55, + tint: [1, 0.85, 0.75], + }, + // 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.6, + 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: 5 }[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: 50, lively: 25, 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.5, 2) + 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) + const k = Math.floor(Math.random() * 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", + 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), +} From 4fbdafe823e17d4f571342de768cc2c7260d26d4 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 08:51:41 -0700 Subject: [PATCH 02/15] Tune 3D Pipes and add it to the README and GIF list --- README.md | 5 +++-- dev/gif.ts | 2 +- wallpapers/pipes.ts | 41 +++++++++++++++++++++-------------------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3a5dcf8..6e6bac8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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` +`megacity` · `dome` · `haunted-house` · `flowerbox` · `pipes` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -72,7 +72,7 @@ At teeming, played back at 3x speed: | ![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) | | +| ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | ![3D Pipes in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.gif) | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -91,6 +91,7 @@ At teeming, played back at 3x speed: | 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) | +| 3D Pipes | ![3D Pipes by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.png) | ![3D Pipes at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes-sunset.png) | ![3D Pipes at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes-night.png) | ## License diff --git a/dev/gif.ts b/dev/gif.ts index 23c24a8..38c9150 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -15,7 +15,7 @@ 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" } +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", pipes: "day" } // 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 diff --git a/wallpapers/pipes.ts b/wallpapers/pipes.ts index 07392bd..d0ed47e 100644 --- a/wallpapers/pipes.ts +++ b/wallpapers/pipes.ts @@ -34,14 +34,14 @@ const ARC = 2 // The classic colors: teal, green, orange, yellow, gray, red, blue and violet. const COLORS: RGB[] = [ - [0.17, 0.6, 0.6], - [0.1, 0.45, 0.07], - [0.8, 0.28, 0.03], - [0.85, 0.58, 0.03], - [0.42, 0.42, 0.44], - [0.7, 0.05, 0.04], - [0.07, 0.2, 0.8], - [0.42, 0.08, 0.66], + [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] @@ -72,7 +72,7 @@ const LOOKS: Record = { glint: [1, 0.98, 0.94], glintTint: 0.15, fill: [0.02, 0.03, 0.04], - fog: 0.5, + fog: 0.4, tint: [1, 1, 1], }, // A deep plum glowing to embers at the bottom, under a low, warm light. @@ -81,13 +81,13 @@ const LOOKS: Record = { [0.06, 0.012, 0.05], [0.085, 0.022, 0.006], ], - key: [1.1, 0.72, 0.45], - ambient: 0.12, - glint: [1, 0.6, 0.28], + key: [1.15, 0.82, 0.58], + ambient: 0.14, + glint: [1, 0.62, 0.3], glintTint: 0.45, - fill: [0.25, 0.04, 0.16], - fog: 0.55, - tint: [1, 0.85, 0.75], + 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: { @@ -100,7 +100,7 @@ const LOOKS: Record = { glint: [0.16, 0.36, 1.05], glintTint: 0.65, fill: [0.02, 0.1, 0.3], - fog: 0.6, + fog: 0.5, tint: [0.78, 0.84, 1], }, } @@ -180,7 +180,7 @@ class Pipes extends Canvas { 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: 5 }[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)) @@ -268,7 +268,7 @@ class Pipes extends Canvas { // 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: 50, lively: 25, teeming: 14 }[this.activity]; t > 0; t -= 0.05) this.grow(0.05) + 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) @@ -321,7 +321,7 @@ class Pipes extends Canvas { this.fresh = [] this.grid.fill(0) this.filled = 0 - this.waits = Array.from({ length: this.count }, (_, i) => rand(0.5, 2) + i * rand(1, 3)) + 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 @@ -435,7 +435,8 @@ class Pipes extends Canvas { const lo = Math.floor((GXM - this.gx) / 2) const i = lo + Math.floor(Math.random() * this.gx) const j = Math.floor(Math.random() * GY) - const k = Math.floor(Math.random() * GZ) + // 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 From b581feac4ed1b11d32f21909685b7e6306a054f0 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 08:58:48 -0700 Subject: [PATCH 03/15] Add the Mystify wallpaper --- README.md | 5 +- dev/gif.ts | 2 +- wallpapers/index.ts | 3 +- wallpapers/mystify.ts | 422 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 wallpapers/mystify.ts diff --git a/README.md b/README.md index 3a5dcf8..183ab06 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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` +`megacity` · `dome` · `haunted-house` · `flowerbox` · `mystify` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -72,7 +72,7 @@ At teeming, played back at 3x speed: | ![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) | | +| ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | ![Mystify in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.gif) | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -91,6 +91,7 @@ At teeming, played back at 3x speed: | 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) | +| Mystify | ![Mystify by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.png) | ![Mystify at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify-sunset.png) | ![Mystify at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify-night.png) | ## License diff --git a/dev/gif.ts b/dev/gif.ts index 23c24a8..dbdb114 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -15,7 +15,7 @@ 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" } +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", mystify: "day" } // 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 diff --git a/wallpapers/index.ts b/wallpapers/index.ts index 3ef2110..8233a68 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -7,6 +7,7 @@ import { flowerbox } from "./flowerbox" import { hauntedHouse } from "./haunted-house" import { jungle } from "./jungle" import { megacity } from "./megacity" +import { mystify } from "./mystify" import { ocean } from "./ocean" import { prehistoric } from "./prehistoric" import { rider } from "./rider" @@ -14,4 +15,4 @@ 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, rider, megacity, dome, hauntedHouse, flowerbox, mystify] diff --git a/wallpapers/mystify.ts b/wallpapers/mystify.ts new file mode 100644 index 0000000..ad6a54a --- /dev/null +++ b/wallpapers/mystify.ts @@ -0,0 +1,422 @@ +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 trail, and the +// distance between echoes at a corner's usual speed. Each new echo changes the cells along its lines, so the speed over +// the spacing sets how much the terminal redraws. +const CAST: Record = { + calm: { count: 1, speed: 0.032, echoes: 5, spacing: 0.032 }, + lively: { count: 2, speed: 0.045, echoes: 6, spacing: 0.032 }, + teeming: { count: 3, speed: 0.052, echoes: 8, spacing: 0.036 }, +} +// Floats per echo: the corners' unfolded positions, then its hue and how curved its edges are. +const ECHO = CORNERS * 2 + 2 + +// A polygon: its corners' positions unfolded (x, y per corner; the screen position folds them back between the edges, +// so bouncing is exact), their velocities and the decaying push of a click, its place on the color wheel, and a life +// for the copies a finished task brings. Like the original, the trail is a ring of still copies: every gap a new one +// fades in at the head and the oldest fades out, so lines only change shape where a copy comes or goes, which keeps +// the terminal's cells steady. +interface Poly { + u: Float64Array + v: Float64Array + kick: Float64Array + echoes: Float64Array + head: number + since: number + hue: number + life: number + age: number +} + +class Mystify extends Canvas { + private readonly look: Look + private readonly cast: (typeof CAST)[Activity] + // Scene seconds between echoes. + private readonly gap: number + private polys: Poly[] = [] + private flash = 0 + private placed = false + private bezier = { wait: eggWait() * TIME_SCALE, t: -1 } + 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.polys = Array.from({ length: this.cast.count }, (_, i) => this.poly(i)) + } + + private poly(i: number, life = Infinity): Poly { + const p: Poly = { + u: new Float64Array(CORNERS * 2), + v: new Float64Array(CORNERS * 2), + kick: new Float64Array(CORNERS * 2), + echoes: new Float64Array((this.cast.echoes + 1) * ECHO), + head: 0, + // Polygons take their new copies at different moments. + since: ((i % this.cast.count) / this.cast.count) * this.gap, + 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. + 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 + p.u[c * 2] = (q === 1 || q === 2 ? 0.5 : 0) + rand(0.04, 0.46) + p.u[c * 2 + 1] = (q >= 2 ? 0.5 : 0) + rand(0.04, 0.46) + p.v[c * 2] = Math.cos(heading) * speed + p.v[c * 2 + 1] = Math.sin(heading) * speed + } + this.rewind(p) + return p + } + + // Fills a polygon's trail as if it had always moved at its present velocity. + private rewind(p: Poly) { + const n = this.cast.echoes + 1 + p.head = 0 + for (let e = 0; e < n; e++) { + const o = e * ECHO + for (let k = 0; k < CORNERS * 2; k++) p.echoes[o + k] = p.u[k] - p.v[k] * (e * this.gap + p.since) + p.echoes[o + CORNERS * 2] = p.hue + p.echoes[o + CORNERS * 2 + 1] = 0 + } + } + + step(dt: number) { + dt = clamp(dt, 0, 0.1) * TIME_SCALE + this.stepGloom(dt) + this.flash = Math.max(0, this.flash - dt) + // After an error the lines slow; a finished task hurries them for a moment. + const pace = (1 - 0.6 * this.gloom) * (1 + 1.5 * smoothstep(0, 1, this.flash)) + const decay = Math.exp(-dt / 2) + const curve = this.bezier.t < 0 ? 0 : smoothstep(0, 2, this.bezier.t) * smoothstep(BEZIER, BEZIER - 2, this.bezier.t) + const n = this.cast.echoes + 1 + for (const p of this.polys) { + p.age += dt + p.hue += (dt / 60) * (1 + 6 * smoothstep(0, 1, this.flash)) + for (let k = 0; k < CORNERS * 2; k++) { + p.u[k] += (p.v[k] + p.kick[k]) * dt * pace + p.kick[k] *= decay + } + p.since += dt * pace + if (p.since < this.gap) continue + p.since -= this.gap + p.head = (p.head + n - 1) % n + const o = p.head * ECHO + p.echoes.set(p.u, o) + p.echoes[o + CORNERS * 2] = p.hue + p.echoes[o + CORNERS * 2 + 1] = curve + } + this.polys = this.polys.filter((p) => p.age < p.life) + this.stepBezier(dt) + } + + render() { + this.restore() + const { H, look } = this + const width = this.cells ? 0.6 : Math.max(0.5, 0.0024 * H) + const flash = smoothstep(0, 1, this.flash) + const dim = 1 - 0.5 * this.gloom + const n = this.cast.echoes + 1 + for (const p of this.polys) { + const grow = p.life === Infinity ? 1 : smoothstep(0, 1, p.age) * smoothstep(p.life, p.life - 2, p.age) + if (grow <= 0.01) continue + // Oldest first, so newer copies draw over older ones. Age is in gaps: a copy fades in and out quickly, so each + // cell it crosses changes about once as it comes and once as it goes. + for (let e = n - 1; e >= 0; e--) { + const age = e + p.since / this.gap + const a = grow * dim * smoothstep(0, 0.15, age) * (1 - smoothstep(n - 0.3, n, age)) * (1 - (0.5 * age) / n) + if (a <= 0.01) continue + const o = ((p.head + e) % n) * ECHO + for (let k = 0; k < CORNERS * 2; k++) this.at[k] = fold(p.echoes[o + k], k & 1 ? 1 : this.A) * H + const hue = p.echoes[o + CORNERS * 2] + 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(p.hue * 3 + e * 0.09, 0.9, look.value * 1.1) + 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, color, p.echoes[o + CORNERS * 2 + 1]) + } + } + this.finish() + } + + // Draws the polygon in this.at, its edges bowed toward a closed curve by `curve` (0 straight). + private outline(width: 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, 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, 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 p = this.poly(this.polys.length, 11) + p.u.set(parent.u) + for (let k = 0; k < CORNERS * 2; k++) p.v[k] = k & 1 ? parent.v[k] : -parent.v[k] + p.hue = parent.hue + 0.5 + this.rewind(p) + this.polys.push(p) + } + } + + // 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 p of this.polys) + for (let c = 0; c < CORNERS; c++) { + const d = Math.hypot(fold(p.u[c * 2], this.A) - x, fold(p.u[c * 2 + 1], 1) - y) + if (d >= least) continue + least = d + best = p + corner = c + } + if (!best) return + const px = fold(best.u[corner * 2], this.A) + const py = fold(best.u[corner * 2 + 1], 1) + const dx = px - x + const dy = py - y + const l = Math.hypot(dx, dy) || 1 + // Screen directions map to unfolded ones through the fold's slope where the corner is. + const sx = slope(best.u[corner * 2], this.A) + const sy = slope(best.u[corner * 2 + 1], 1) + const speed = Math.hypot(best.v[corner * 2], best.v[corner * 2 + 1]) + best.v[corner * 2] = (sx * dx * speed) / l + best.v[corner * 2 + 1] = (sy * dy * speed) / l + best.kick[corner * 2] = (sx * dx * speed * 5) / l + best.kick[corner * 2 + 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 p of this.polys) { + for (let c = 0; c < CORNERS; c++) p.u[c * 2] *= this.A + this.rewind(p) + } + 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 edge of +// full cover, 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, 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 + 1 + 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 + 0.5 - Math.sqrt(ex * ex + ey * ey), 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: thin bright polygons bouncing off the edges, trailing fans of echoes, slowly changing color", + activity: { + calm: "One polygon drifting slowly with a short trail", + 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), +} From c15f4d5e45b3b6270da11610df748a87674e8644 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:04:27 -0700 Subject: [PATCH 04/15] Add Flying OpenCode wallpaper --- wallpapers/flying-opencode.ts | 368 ++++++++++++++++++++++++++++++++++ wallpapers/index.ts | 3 +- 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 wallpapers/flying-opencode.ts diff --git a/wallpapers/flying-opencode.ts b/wallpapers/flying-opencode.ts new file mode 100644 index 0000000..0e1fe69 --- /dev/null +++ b/wallpapers/flying-opencode.ts @@ -0,0 +1,368 @@ +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 and a pale 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.48, 0.06], + [1, 0.24, 0.16], + [1, 0.2, 0.5], + [1, 0.74, 0.22], + [0.72, 0.2, 0.95], + [1, 0.66, 0.46], + ], + fill: 0.3, + }, + // Navy-black, the marks cobalt, teal, violet, cyan, magenta, green and a pale cobalt 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.06, 0.58, 1], + [0.82, 0.12, 0.78], + [0.12, 0.8, 0.36], + [0.45, 0.58, 1], + ], + fill: 0.3, + }, +} + +// How many marks fly at once and how fast they come, in world units per scene second. +const STREAM: Record = { + calm: { count: 8, speed: 0.28 }, + lively: { count: 20, speed: 0.6 }, + teeming: { count: 30, speed: 0.85 }, +} + +// 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 placed = false + 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: rand(0.4, 0.9) * (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.6 + 0.4 * facing + 0.35 * 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. + if (!this.placed) this.marks = Array.from({ length: STREAM[this.activity].count }, () => this.spawn(rand(NEAR * 2, FAR))) + 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) + } + } + } +} + +// 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", + 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/index.ts b/wallpapers/index.ts index 3ef2110..d9d114e 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -4,6 +4,7 @@ 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 { megacity } from "./megacity" @@ -14,4 +15,4 @@ 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, rider, megacity, dome, hauntedHouse, flowerbox, flyingOpenCode] From b78b1accd5410db4972818203fb0eacc44a012ea Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:06:34 -0700 Subject: [PATCH 05/15] Mystify: no hurry on a finished task, shorter-lived copies --- wallpapers/mystify.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wallpapers/mystify.ts b/wallpapers/mystify.ts index ad6a54a..c82f986 100644 --- a/wallpapers/mystify.ts +++ b/wallpapers/mystify.ts @@ -157,8 +157,8 @@ class Mystify extends Canvas { dt = clamp(dt, 0, 0.1) * TIME_SCALE this.stepGloom(dt) this.flash = Math.max(0, this.flash - dt) - // After an error the lines slow; a finished task hurries them for a moment. - const pace = (1 - 0.6 * this.gloom) * (1 + 1.5 * smoothstep(0, 1, this.flash)) + // After an error the lines slow. + const pace = 1 - 0.6 * this.gloom const decay = Math.exp(-dt / 2) const curve = this.bezier.t < 0 ? 0 : smoothstep(0, 2, this.bezier.t) * smoothstep(BEZIER, BEZIER - 2, this.bezier.t) const n = this.cast.echoes + 1 @@ -253,7 +253,7 @@ class Mystify extends Canvas { this.flash = 3 if (this.polys.length > this.cast.count * 2) return for (const parent of this.polys.slice(0, this.cast.count)) { - const p = this.poly(this.polys.length, 11) + const p = this.poly(this.polys.length, 7) p.u.set(parent.u) for (let k = 0; k < CORNERS * 2; k++) p.v[k] = k & 1 ? parent.v[k] : -parent.v[k] p.hue = parent.hue + 0.5 From 561f66a0cf7754a9449f13253b6120a2269e4691 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:07:05 -0700 Subject: [PATCH 06/15] Add the 3D Maze wallpaper --- src/canvas.ts | 9 + wallpapers/index.ts | 3 +- wallpapers/maze.ts | 915 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 926 insertions(+), 1 deletion(-) create mode 100644 wallpapers/maze.ts diff --git a/src/canvas.ts b/src/canvas.ts index 28efcc5..47ea39a 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -292,6 +292,15 @@ export abstract class Canvas { this.restored = true } + // 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/wallpapers/index.ts b/wallpapers/index.ts index 3ef2110..944b44e 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -6,6 +6,7 @@ import { farm } from "./farm" import { flowerbox } from "./flowerbox" import { hauntedHouse } from "./haunted-house" import { jungle } from "./jungle" +import { maze } from "./maze" import { megacity } from "./megacity" import { ocean } from "./ocean" import { prehistoric } from "./prehistoric" @@ -14,4 +15,4 @@ 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, rider, megacity, dome, hauntedHouse, flowerbox, maze] diff --git a/wallpapers/maze.ts b/wallpapers/maze.ts new file mode 100644 index 0000000..ffc0b3f --- /dev/null +++ b/wallpapers/maze.ts @@ -0,0 +1,915 @@ +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 = 10 +// 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.09, 0.09, 0.1], + 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.32, + lamp: 0.12, + ambient: 0.4, + side: 0.74, + solid: [0.12, 0.05, 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.15, + 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.5, turn: 2, rest: 0.4, look: 18 }, + lively: { speed: 0.9, turn: 1.4, rest: 0.3, look: 6 }, + teeming: { speed: 1.2, turn: 1.1, 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 * 0.8) + 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.slice(1)) + const pool = view.length && (near || Math.random() < 0.6) ? 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 + if (this.flipLeft > 0) 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.42 * 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.13 * 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 = 1 - Math.exp(-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.map((p, i) => (i === a || i === b || i === c ? 0 : (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) + } + 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 id = Math.floor(su) * 7.1 + course * 13.7 + const n = Math.sin(id * 12.9898) * 43758.5453 + const tone = 1 + detail * (0.24 * (n - Math.floor(n)) - 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 = lit(d * cols[o + 7], lamp, ambient) * light + const fog = 1 - Math.exp(-d * cols[o + 7] * haze) + 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 n = Math.sin(Math.floor(wx) * 12.9898 + Math.floor(wy) * 78.233) * 43758.5453 + const tone = 1 + clamp(1 - w * 1.5, 0, 1) * 0.1 * (n - Math.floor(n) - 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 = 1 - Math.exp(-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 + } + } +} + +// 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", + activity: { + calm: "Long looks down each corridor between slow walks and turns", + lively: "A steady glide through the maze, pausing briefly at the turns", + 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), +} From 8714ad01a94f07f7e833f3a0f15ab2e3ffe12b41 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:16:57 -0700 Subject: [PATCH 07/15] Tune Flying OpenCode and add it to the README and GIFs --- README.md | 5 ++-- dev/gif.ts | 2 +- wallpapers/flying-opencode.ts | 44 ++++++++++++++++++----------------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3a5dcf8..dd91fc6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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` +`megacity` · `dome` · `haunted-house` · `flowerbox` · `flying-opencode` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -72,7 +72,7 @@ At teeming, played back at 3x speed: | ![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) | | +| ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | ![Flying OpenCode in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.gif) | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -91,6 +91,7 @@ At teeming, played back at 3x speed: | 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) | +| Flying OpenCode | ![Flying OpenCode by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.png) | ![Flying OpenCode at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode-sunset.png) | ![Flying OpenCode at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode-night.png) | ## License diff --git a/dev/gif.ts b/dev/gif.ts index 23c24a8..2ec891d 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -15,7 +15,7 @@ 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" } +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", "flying-opencode": "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 diff --git a/wallpapers/flying-opencode.ts b/wallpapers/flying-opencode.ts index 0e1fe69..55f23e0 100644 --- a/wallpapers/flying-opencode.ts +++ b/wallpapers/flying-opencode.ts @@ -46,7 +46,7 @@ const LOOKS: Record = { ], fill: 0.32, }, - // A deep plum fading to black, the marks amber, coral, rose, gold, violet and a pale peach. + // 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]], @@ -54,16 +54,17 @@ const LOOKS: Record = { [1, [0.006, 0.001, 0.006]], ], colors: [ - [1, 0.48, 0.06], - [1, 0.24, 0.16], - [1, 0.2, 0.5], - [1, 0.74, 0.22], - [0.72, 0.2, 0.95], - [1, 0.66, 0.46], + [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, cyan, magenta, green and a pale cobalt where day has white. + // 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]], @@ -73,20 +74,21 @@ const LOOKS: Record = { [0.16, 0.3, 1], [0.04, 0.72, 0.66], [0.5, 0.22, 1], - [0.06, 0.58, 1], + [0.05, 0.75, 1], [0.82, 0.12, 0.78], [0.12, 0.8, 0.36], - [0.45, 0.58, 1], + [0.32, 0.52, 1], ], fill: 0.3, }, } -// How many marks fly at once and how fast they come, in world units per scene second. -const STREAM: Record = { - calm: { count: 8, speed: 0.28 }, - lively: { count: 20, speed: 0.6 }, - teeming: { count: 30, speed: 0.85 }, +// 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, @@ -109,7 +111,6 @@ class FlyingOpenCode extends Canvas { private readonly activity: Activity private readonly look: Look private marks: Mark[] = [] - private placed = false private surge = 0 private egg = eggWait() * TIME_SCALE private readonly shade = new Float32Array(6) @@ -141,7 +142,7 @@ class FlyingOpenCode extends Canvas { vx: 0, vy: 0, turn: rand(0, TAU), - spin: rand(0.4, 0.9) * (Math.random() < 0.5 ? -1 : 1), + 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, @@ -199,7 +200,7 @@ class FlyingOpenCode extends Canvas { 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.6 + 0.4 * facing + 0.35 * facing ** 24) + 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 @@ -253,9 +254,10 @@ class FlyingOpenCode extends Canvas { protected override layout() { const { W, H, hdr } = this - // Fill the stream once the screen's shape is known. - if (!this.placed) this.marks = Array.from({ length: STREAM[this.activity].count }, () => this.spawn(rand(NEAR * 2, FAR))) - this.placed = true + // 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 From a7bdb1bdc5108a07e1a77e9f9ef8f4e8316ebe08 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:27:10 -0700 Subject: [PATCH 08/15] Maze: wandering walk, visible polyhedra, cheaper shading, long check play --- dev/check.ts | 12 +++++++---- wallpapers/maze.ts | 52 ++++++++++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/dev/check.ts b/dev/check.ts index 4108246..3204d99 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -32,11 +32,15 @@ const cursor = { x: 10, y: 40, visible: true } let failed = 0 let slowest = { ms: -1, name: "" } let busiest = { flicker: -1, name: "" } -const frames = 240 +// 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 (not under --verify-dirty), held to the same flicker limit. +const LONG = ["maze"] const runs = (ids.length ? wallpapers.filter((w) => ids.includes(w.id)) : wallpapers).flatMap((w) => ACTIVITIES.flatMap((activity) => 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 frames = LONG.includes(wallpaper.id) && !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}` @@ -59,7 +63,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() diff --git a/wallpapers/maze.ts b/wallpapers/maze.ts index ffc0b3f..7d703cb 100644 --- a/wallpapers/maze.ts +++ b/wallpapers/maze.ts @@ -21,7 +21,7 @@ const SEAM = 0.07 const DX = [1, 0, -1, 0] const DY = [0, 1, 0, -1] // Per screen column, what its ray hit (see castColumns). -const COL = 10 +const COL = 12 // The flip lasts this many scene seconds before the view rights itself. const FLIPPED = 25 @@ -61,7 +61,7 @@ const LOOKS: Record = { lamp: 0, ambient: 1, side: 0.78, - solid: [0.09, 0.09, 0.1], + 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], @@ -74,7 +74,7 @@ const LOOKS: Record = { seam: [0.3, 0.17, 0.08], floor: [0.5, 0.2, 0.01], fog: [0.3, 0.1, 0.025], - haze: 0.32, + haze: 0.42, lamp: 0.12, ambient: 0.4, side: 0.74, @@ -91,7 +91,7 @@ const LOOKS: Record = { seam: [0.06, 0.08, 0.28], floor: [0.36, 0.17, 0.012], fog: [0.002, 0.004, 0.02], - haze: 0.15, + haze: 0.2, lamp: 1.4, ambient: 0.04, side: 0.72, @@ -428,7 +428,7 @@ class Maze extends Canvas { 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 * 0.8) + 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 @@ -451,8 +451,8 @@ class Maze extends Canvas { 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.slice(1)) - const pool = view.length && (near || Math.random() < 0.6) ? view : free(this.ahead(12).slice(2)) + 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 }) @@ -479,7 +479,8 @@ class Maze extends Canvas { 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 - if (this.flipLeft > 0) return + // 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 } @@ -549,7 +550,7 @@ class Maze extends Canvas { 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.42 * W) + 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) @@ -579,7 +580,7 @@ class Maze extends Canvas { 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.13 * smoothstep(0, 1, o.grow) * (o.flip ? 1.2 : 1) + 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) @@ -604,7 +605,7 @@ class Maze extends Canvas { 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 = 1 - Math.exp(-dist * look.haze) + const fogk = veil(dist, look.haze) const base = o.flip ? look.flip : look.solid const faces = shape.faces .map(([a, b, c]) => { @@ -665,7 +666,7 @@ function hull(verts: number[][]) { 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.map((p, i) => (i === a || i === b || i === c ? 0 : (p[0] - ax) * nx + (p[1] - ay) * ny + (p[2] - az) * nz)) + 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]) } @@ -752,6 +753,8 @@ function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, 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 @@ -786,9 +789,7 @@ function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, 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 id = Math.floor(su) * 7.1 + course * 13.7 - const n = Math.sin(id * 12.9898) * 43758.5453 - const tone = 1 + detail * (0.24 * (n - Math.floor(n)) - 0.12 + 0.16 * (z / COURSE - course) - 0.08) + 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 @@ -810,8 +811,8 @@ function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, g = lerp(lerp(g, p[23], face), p[26], ink) bl = lerp(lerp(bl, p[24], face), p[27], ink) } - const k2 = lit(d * cols[o + 7], lamp, ambient) * light - const fog = 1 - Math.exp(-d * cols[o + 7] * haze) + 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 @@ -827,15 +828,14 @@ function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, 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 n = Math.sin(Math.floor(wx) * 12.9898 + Math.floor(wy) * 78.233) * 43758.5453 - const tone = 1 + clamp(1 - w * 1.5, 0, 1) * 0.1 * (n - Math.floor(n) - 0.5) + 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 = 1 - Math.exp(-dd * haze) + 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 @@ -849,6 +849,18 @@ function shadeView(hdr: Float32Array, W: number, H: number, cols: Float32Array, } } +// 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 From 665c0d3e8dd2a76d17f2d51fac967599cc7f509b Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:31:41 -0700 Subject: [PATCH 09/15] Document the 3D Maze wallpaper; quicker turns --- CONTRIBUTING.md | 2 ++ README.md | 5 +++-- dev/check.ts | 8 +++++--- dev/gif.ts | 2 +- wallpapers/maze.ts | 6 +++--- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b13300e..00963a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -355,6 +355,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. diff --git a/README.md b/README.md index 3a5dcf8..5904563 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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` +`megacity` · `dome` · `haunted-house` · `flowerbox` · `maze` | Spring | Summer | Autumn | Winter | | --- | --- | --- | --- | @@ -72,7 +72,7 @@ At teeming, played back at 3x speed: | ![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) | | +| ![Flower Box in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flowerbox.gif) | ![3D Maze in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.gif) | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -91,6 +91,7 @@ At teeming, played back at 3x speed: | 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) | +| 3D Maze | ![3D Maze by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.png) | ![3D Maze at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze-sunset.png) | ![3D Maze at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze-night.png) | ## License diff --git a/dev/check.ts b/dev/check.ts index 3204d99..daa7829 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -34,11 +34,13 @@ let slowest = { ms: -1, name: "" } let busiest = { flicker: -1, name: "" } // 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 (not under --verify-dirty), held to the same flicker limit. +// 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) => TIMES.map((time) => ({ wallpaper: w, activity, time })))) for (const { wallpaper, activity, time } of runs) { - const frames = LONG.includes(wallpaper.id) && !verify ? 1800 : 240 + 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:")) : [] @@ -48,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}`, diff --git a/dev/gif.ts b/dev/gif.ts index 23c24a8..b9713e3 100644 --- a/dev/gif.ts +++ b/dev/gif.ts @@ -15,7 +15,7 @@ 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" } +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", maze: "day" } // 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 diff --git a/wallpapers/maze.ts b/wallpapers/maze.ts index 7d703cb..10d3ed7 100644 --- a/wallpapers/maze.ts +++ b/wallpapers/maze.ts @@ -105,9 +105,9 @@ const LOOKS: Record = { // 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.5, turn: 2, rest: 0.4, look: 18 }, - lively: { speed: 0.9, turn: 1.4, rest: 0.3, look: 6 }, - teeming: { speed: 1.2, turn: 1.1, rest: 0.25, look: 4.5 }, + calm: { speed: 0.3, turn: 2.4, rest: 0.4, look: 18 }, + lively: { speed: 0.9, turn: 1, rest: 0.3, look: 5 }, + teeming: { speed: 1.2, turn: 0.8, rest: 0.25, look: 4 }, } // A spinning polyhedron floating in a corridor: its cell, spin angles and rates, how grown it is, and whether it is From 21ce9dc721b24e9c6bb3bc29609de1016c12be00 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:41:56 -0700 Subject: [PATCH 10/15] Maze: longer looks, darker sunset polyhedra --- wallpapers/maze.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wallpapers/maze.ts b/wallpapers/maze.ts index 10d3ed7..e18024e 100644 --- a/wallpapers/maze.ts +++ b/wallpapers/maze.ts @@ -78,7 +78,7 @@ const LOOKS: Record = { lamp: 0.12, ambient: 0.4, side: 0.74, - solid: [0.12, 0.05, 0.03], + 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], @@ -106,8 +106,8 @@ const LOOKS: Record = { // 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 }, - teeming: { speed: 1.2, turn: 0.8, rest: 0.25, look: 4 }, + 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 @@ -903,7 +903,7 @@ export const maze: Wallpaper = { description: "The old 3D maze screensaver: gliding through red brick corridors under a tiled ceiling, turning at every junction", activity: { calm: "Long looks down each corridor between slow walks and turns", - lively: "A steady glide through the maze, pausing briefly at the 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: { From 1c6826563d51585a78d9f0c6d5f57c0605986826 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:53:46 -0700 Subject: [PATCH 11/15] Group wallpapers into collections; give Akira and Classics one fixed look --- dev/check.ts | 6 ++- dev/gif.ts | 14 +++--- dev/poke.ts | 2 +- dev/preview.ts | 2 +- dev/screenshots.ts | 10 ++--- dev/snap.ts | 5 ++- src/engine.tsx | 10 +++-- src/load.ts | 2 + src/wallpaper.ts | 15 +++++++ tui.tsx | 81 ++++++++++++++++++++++++++--------- wallpapers/beach.ts | 1 + wallpapers/city.ts | 1 + wallpapers/desert.ts | 1 + wallpapers/dome.ts | 2 + wallpapers/farm.ts | 1 + wallpapers/flowerbox.ts | 2 + wallpapers/flying-opencode.ts | 2 + wallpapers/haunted-house.ts | 1 + wallpapers/index.ts | 2 +- wallpapers/jungle.ts | 1 + wallpapers/maze.ts | 2 + wallpapers/megacity.ts | 2 + wallpapers/mystify.ts | 2 + wallpapers/ocean.ts | 1 + wallpapers/pipes.ts | 2 + wallpapers/prehistoric.ts | 1 + wallpapers/rider.ts | 2 + wallpapers/space.ts | 1 + wallpapers/tundra.ts | 1 + wallpapers/zen.ts | 1 + 30 files changed, 136 insertions(+), 40 deletions(-) diff --git a/dev/check.ts b/dev/check.ts index daa7829..75bbdf3 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -37,7 +37,7 @@ let busiest = { flicker: -1, name: "" } // 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) => TIMES.map((time) => ({ wallpaper: w, activity, time })))) +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 long = LONG.includes(wallpaper.id) const frames = long && !verify ? 1800 : 240 @@ -129,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 4e1d38f..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", pipes: "day", mystify: "day", "flying-opencode": "night", maze: "day" } +// 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/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..75812db 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, and without + // seasons. The menu hides its time and season 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 and season settings. +export function settingsFor(wallpaper: Wallpaper, settings: Settings): Settings { + return wallpaper.fixedTime ? { ...settings, time: wallpaper.fixedTime, season: undefined } : settings +} + export interface Settings { readonly activity: Activity readonly time: Time diff --git a/tui.tsx b/tui.tsx index eb412cc..1c9a0b5 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))), @@ -288,11 +325,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 +338,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 +364,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 +390,7 @@ export default Plugin.define({ - {rows[selected()].describe(rows[selected()].value())} + {selected().describe(selected().value())} @@ -423,7 +461,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 +549,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 +561,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 index 55f23e0..331e46e 100644 --- a/wallpapers/flying-opencode.ts +++ b/wallpapers/flying-opencode.ts @@ -344,6 +344,8 @@ 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", 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 812e810..ab24886 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -18,4 +18,4 @@ 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, pipes, mystify, flyingOpenCode, maze] +export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, hauntedHouse, rider, megacity, dome, flowerbox, pipes, mystify, flyingOpenCode, maze] 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 index e18024e..042a6f8 100644 --- a/wallpapers/maze.ts +++ b/wallpapers/maze.ts @@ -901,6 +901,8 @@ 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", 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 index c82f986..92420bd 100644 --- a/wallpapers/mystify.ts +++ b/wallpapers/mystify.ts @@ -396,6 +396,8 @@ export const mystify: Wallpaper = { id: "mystify", name: "Mystify", description: "The old screensaver: thin bright polygons bouncing off the edges, trailing fans of echoes, slowly changing color", + collection: "Classics", + fixedTime: "day", activity: { calm: "One polygon drifting slowly with a short trail", lively: "The classic pair, in two colors", 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 index d0ed47e..de5d110 100644 --- a/wallpapers/pipes.ts +++ b/wallpapers/pipes.ts @@ -723,6 +723,8 @@ 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", 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", From 34d13b9b87b50ab9ca515505819a19b83a799885 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 09:55:18 -0700 Subject: [PATCH 12/15] Arrange the README gallery and docs by collection --- CONTRIBUTING.md | 16 ++++++++++++++-- README.md | 44 +++++++++++++++++++++++++++----------------- wallpapers/index.ts | 2 +- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00963a1..034d98e 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 and no season, whatever the settings, the menu hides its time and season +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 @@ -381,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 6c3a106..b3396b0 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ 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. +- **Time:** day, sunset or night, or auto to follow your clock. Akira and Classics keep one look. - **Season and weather:** follow the calendar, or the real weather where you are (`/wallpaper location Berlin`). - **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` · `pipes` · `mystify` · `flying-opencode` · `maze` +- **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,11 +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) | ![3D Pipes in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.gif) | -| ![Mystify in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.gif) | ![Flying OpenCode in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.gif) | -| ![3D Maze in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.gif) | | +| ![Haunted house in motion](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/haunted-house.gif) | | | | Day | Sunset | Night | | --- | --- | --- | --- | @@ -88,15 +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) | -| 3D Pipes | ![3D Pipes by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes.png) | ![3D Pipes at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes-sunset.png) | ![3D Pipes at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/pipes-night.png) | -| Mystify | ![Mystify by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify.png) | ![Mystify at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify-sunset.png) | ![Mystify at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/mystify-night.png) | -| Flying OpenCode | ![Flying OpenCode by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode.png) | ![Flying OpenCode at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode-sunset.png) | ![Flying OpenCode at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/flying-opencode-night.png) | -| 3D Maze | ![3D Maze by day](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze.png) | ![3D Maze at sunset](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze-sunset.png) | ![3D Maze at night](https://raw.githubusercontent.com/vaprdev/opencode-wallpapers/assets/maze-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/wallpapers/index.ts b/wallpapers/index.ts index ab24886..c8aff9e 100644 --- a/wallpapers/index.ts +++ b/wallpapers/index.ts @@ -18,4 +18,4 @@ 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, hauntedHouse, rider, megacity, dome, flowerbox, pipes, mystify, flyingOpenCode, maze] +export const WALLPAPERS = [ocean, desert, jungle, space, farm, tundra, beach, city, zen, prehistoric, hauntedHouse, rider, megacity, dome, flowerbox, maze, pipes, mystify, flyingOpenCode] From 3c9f56ce2a18cf5ffe17193a10ba2d943f7b4bb9 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 10:38:30 -0700 Subject: [PATCH 13/15] Mystify: compact figures that glide every frame, with a continuous echo trail --- wallpapers/mystify.ts | 283 ++++++++++++++++++++++++------------------ 1 file changed, 161 insertions(+), 122 deletions(-) diff --git a/wallpapers/mystify.ts b/wallpapers/mystify.ts index 92420bd..86f1e93 100644 --- a/wallpapers/mystify.ts +++ b/wallpapers/mystify.ts @@ -6,7 +6,7 @@ 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. +// Corners per polygon: each bounces on its own within its figure's box. const CORNERS = 4 // The egg, the old Bezier screensaver: the edges bow into curves for this many scene seconds. const BEZIER = 14 @@ -64,43 +64,50 @@ const LOOKS: Record = { }, } -// Per activity: polygons, their corners' speed in screen heights per scene second, echoes in each trail, and the -// distance between echoes at a corner's usual speed. Each new echo changes the cells along its lines, so the speed over -// the spacing sets how much the terminal redraws. -const CAST: Record = { - calm: { count: 1, speed: 0.032, echoes: 5, spacing: 0.032 }, - lively: { count: 2, speed: 0.045, echoes: 6, spacing: 0.032 }, - teeming: { count: 3, speed: 0.052, echoes: 8, spacing: 0.036 }, +// Per activity: figures, each one's size (the box its corners bounce in, in screen heights), how fast the figure drifts +// and its corners bounce within it (screen heights per scene second), echoes in each trail, 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, so size, speed, echoes and their spacing (lines further apart flip more cells) set how much it redraws. +const CAST: Record = { + calm: { count: 1, size: 0.38, drift: 0.016, wobble: 0.016, echoes: 4, spacing: 0.038 }, + lively: { count: 2, size: 0.34, drift: 0.02, wobble: 0.019, echoes: 4, spacing: 0.03 }, + teeming: { count: 3, size: 0.3, drift: 0.024, wobble: 0.022, echoes: 5, spacing: 0.03 }, } -// Floats per echo: the corners' unfolded positions, then its hue and how curved its edges are. -const ECHO = CORNERS * 2 + 2 +// History samples per echo gap; echoes read between samples, so the fan glides. +const SAMPLES = 4 +// The share of its figure's box each corner bounces across, from its own corner of the box. +const REACH = 0.55 -// A polygon: its corners' positions unfolded (x, y per corner; the screen position folds them back between the edges, -// so bouncing is exact), their velocities and the decaying push of a click, its place on the color wheel, and a life -// for the copies a finished task brings. Like the original, the trail is a ring of still copies: every gap a new one -// fades in at the head and the oldest fades out, so lines only change shape where a copy comes or goes, which keeps -// the terminal's cells steady. -interface Poly { +// A figure: its middle and its corners within their box, unfolded (the screen position folds them back between the +// 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 Figure { u: Float64Array v: Float64Array kick: Float64Array - echoes: Float64Array + ring: Float64Array head: number - since: number hue: number life: number age: number } +// Unfolded values per figure: the middle (x, y), then each corner's offset within the box (x, y). +const VALUES = 2 + CORNERS * 2 + class Mystify extends Canvas { private readonly look: Look private readonly cast: (typeof CAST)[Activity] - // Scene seconds between echoes. + // Scene seconds between echoes and between history samples, the scene clock and the next sample's time. private readonly gap: number - private polys: Poly[] = [] + private readonly sample: number + private time = 0 + private next = 0 + private figures: Figure[] = [] 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) @@ -109,115 +116,142 @@ class Mystify extends Canvas { this.supersample = 1 this.look = LOOKS[settings.time] this.cast = CAST[settings.activity] - this.gap = this.cast.spacing / this.cast.speed - this.polys = Array.from({ length: this.cast.count }, (_, i) => this.poly(i)) + this.gap = this.cast.spacing / (this.cast.drift + this.cast.wobble) + this.sample = this.gap / SAMPLES + this.figures = Array.from({ length: this.cast.count }, (_, i) => this.figure(i)) } - private poly(i: number, life = Infinity): Poly { - const p: Poly = { - u: new Float64Array(CORNERS * 2), - v: new Float64Array(CORNERS * 2), - kick: new Float64Array(CORNERS * 2), - echoes: new Float64Array((this.cast.echoes + 1) * ECHO), + private figure(i: number, life = Infinity): Figure { + const { size, drift, wobble } = this.cast + const f: Figure = { + u: new Float64Array(VALUES), + v: new Float64Array(VALUES), + kick: new Float64Array(VALUES), + ring: new Float64Array((this.cast.echoes * SAMPLES + 2) * CORNERS * 2), head: 0, - // Polygons take their new copies at different moments. - since: ((i % this.cast.count) / this.cast.count) * this.gap, 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. - const turn = Math.floor(rand(0, CORNERS)) + // Figures start spread along the screen (u[0] is a fraction of the open width until layout() knows it). + const heading = rand(0, TAU) + f.u[0] = (i + rand(0.2, 0.8)) / this.cast.count + f.u[1] = rand(0, 1) + f.v[0] = Math.cos(heading) * drift * rand(0.8, 1.2) + f.v[1] = Math.sin(heading) * drift * rand(0.8, 1.2) 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 - p.u[c * 2] = (q === 1 || q === 2 ? 0.5 : 0) + rand(0.04, 0.46) - p.u[c * 2 + 1] = (q >= 2 ? 0.5 : 0) + rand(0.04, 0.46) - p.v[c * 2] = Math.cos(heading) * speed - p.v[c * 2 + 1] = Math.sin(heading) * speed + const turn = rand(0, TAU) + const speed = wobble * rand(0.7, 1.3) + f.u[2 + c * 2] = rand(0, REACH * size) + f.u[3 + c * 2] = rand(0, REACH * size) + f.v[2 + c * 2] = Math.cos(turn) * speed + f.v[3 + c * 2] = Math.sin(turn) * speed } - this.rewind(p) - return p + return f } - // Fills a polygon's trail as if it had always moved at its present velocity. - private rewind(p: Poly) { - const n = this.cast.echoes + 1 - p.head = 0 - for (let e = 0; e < n; e++) { - const o = e * ECHO - for (let k = 0; k < CORNERS * 2; k++) p.echoes[o + k] = p.u[k] - p.v[k] * (e * this.gap + p.since) - p.echoes[o + CORNERS * 2] = p.hue - p.echoes[o + CORNERS * 2 + 1] = 0 + // Fills a figure's history as if it had always moved at its present velocity. + private rewind(f: Figure) { + 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) { + const size = this.cast.size + const x = size / 2 + fold(u[0], Math.max(0.01, this.A - size)) + const y = size / 2 + fold(u[1], 1 - size) + // Each corner bounces within its own corner of the box, so the shape stays open as it morphs. + for (let c = 0; c < CORNERS; c++) { + out[o + c * 2] = x - size / 2 + (c === 1 || c === 2 ? 1 - REACH : 0) * size + fold(u[2 + c * 2], REACH * size) + out[o + c * 2 + 1] = y - size / 2 + (c >= 2 ? 1 - REACH : 0) * size + fold(u[3 + c * 2], REACH * size) } } 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) - const curve = this.bezier.t < 0 ? 0 : smoothstep(0, 2, this.bezier.t) * smoothstep(BEZIER, BEZIER - 2, this.bezier.t) - const n = this.cast.echoes + 1 - for (const p of this.polys) { - p.age += dt - p.hue += (dt / 60) * (1 + 6 * smoothstep(0, 1, this.flash)) - for (let k = 0; k < CORNERS * 2; k++) { - p.u[k] += (p.v[k] + p.kick[k]) * dt * pace - p.kick[k] *= decay + for (const f of this.figures) { + 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.figures) { + 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) } - p.since += dt * pace - if (p.since < this.gap) continue - p.since -= this.gap - p.head = (p.head + n - 1) % n - const o = p.head * ECHO - p.echoes.set(p.u, o) - p.echoes[o + CORNERS * 2] = p.hue - p.echoes[o + CORNERS * 2 + 1] = curve } - this.polys = this.polys.filter((p) => p.age < p.life) + this.figures = this.figures.filter((f) => f.age < f.life) this.stepBezier(dt) } render() { this.restore() const { H, look } = this - const width = this.cells ? 0.6 : Math.max(0.5, 0.0024 * H) + // 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 n = this.cast.echoes + 1 - for (const p of this.polys) { - const grow = p.life === Infinity ? 1 : smoothstep(0, 1, p.age) * smoothstep(p.life, p.life - 2, p.age) + 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.figures) { + const grow = f.life === Infinity ? 1 : smoothstep(0, 1, f.age) * smoothstep(f.life, f.life - 2, f.age) if (grow <= 0.01) continue - // Oldest first, so newer copies draw over older ones. Age is in gaps: a copy fades in and out quickly, so each - // cell it crosses changes about once as it comes and once as it goes. - for (let e = n - 1; e >= 0; e--) { - const age = e + p.since / this.gap - const a = grow * dim * smoothstep(0, 0.15, age) * (1 - smoothstep(n - 0.3, n, age)) * (1 - (0.5 * age) / n) - if (a <= 0.01) continue - const o = ((p.head + e) % n) * ECHO - for (let k = 0; k < CORNERS * 2; k++) this.at[k] = fold(p.echoes[o + k], k & 1 ? 1 : this.A) * H - const hue = p.echoes[o + CORNERS * 2] + 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(p.hue * 3 + e * 0.09, 0.9, look.value * 1.1) + 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, color, p.echoes[o + CORNERS * 2 + 1]) + 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, color: RGB, curve: number) { + 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, color[0], color[1], color[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 } @@ -241,63 +275,68 @@ class Mystify extends Canvas { 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, color[0], color[1], color[2]) + 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. + // A finished task: the colors race through the rainbow and each figure 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 p = this.poly(this.polys.length, 7) - p.u.set(parent.u) - for (let k = 0; k < CORNERS * 2; k++) p.v[k] = k & 1 ? parent.v[k] : -parent.v[k] - p.hue = parent.hue + 0.5 - this.rewind(p) - this.polys.push(p) + if (this.figures.length > this.cast.count * 2) return + for (const parent of this.figures.slice(0, this.cast.count)) { + const f = this.figure(this.figures.length, 7) + f.u.set(parent.u) + f.v.set(parent.v) + f.v[0] = -parent.v[0] + f.v[1] = -parent.v[1] + f.hue = parent.hue + 0.5 + this.rewind(f) + this.figures.push(f) } } - // A click flings the nearest corner away from it. + // A click flings the nearest corner away from it, and nudges its figure the same way. poke(x: number, y: number) { - let best: Poly | undefined + const size = this.cast.size + let best: Figure | undefined let corner = 0 let least = Infinity - for (const p of this.polys) + for (const f of this.figures) { + this.corners(f.u, this.at, 0) for (let c = 0; c < CORNERS; c++) { - const d = Math.hypot(fold(p.u[c * 2], this.A) - x, fold(p.u[c * 2 + 1], 1) - y) + const d = Math.hypot(this.at[c * 2] - x, this.at[c * 2 + 1] - y) if (d >= least) continue least = d - best = p + best = f corner = c } + } if (!best) return - const px = fold(best.u[corner * 2], this.A) - const py = fold(best.u[corner * 2 + 1], 1) - const dx = px - x - const dy = py - y + 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 sx = slope(best.u[corner * 2], this.A) - const sy = slope(best.u[corner * 2 + 1], 1) - const speed = Math.hypot(best.v[corner * 2], best.v[corner * 2 + 1]) - best.v[corner * 2] = (sx * dx * speed) / l - best.v[corner * 2 + 1] = (sy * dy * speed) / l - best.kick[corner * 2] = (sx * dx * speed * 5) / l - best.kick[corner * 2 + 1] = (sy * dy * speed * 5) / l + // Screen directions map to unfolded ones through each fold's slope. + const k = 2 + corner * 2 + const speed = Math.hypot(best.v[k], best.v[k + 1]) + best.v[k] = (slope(best.u[k], REACH * size) * dx * speed) / l + best.v[k + 1] = (slope(best.u[k + 1], REACH * size) * dy * speed) / l + best.kick[k] = (slope(best.u[k], REACH * size) * dx * speed * 4) / l + best.kick[k + 1] = (slope(best.u[k + 1], REACH * size) * dy * speed * 4) / l + best.kick[0] += (slope(best.u[0], Math.max(0.01, this.A - size)) * dx * this.cast.drift * 3) / l + best.kick[1] += (slope(best.u[1], 1 - size) * dy * this.cast.drift * 3) / l } protected override layout() { const { W, H, hdr } = this - // Corners start spread across the width once the screen's shape is known. + // Figures start spread across the width once the screen's shape is known. if (!this.placed) - for (const p of this.polys) { - for (let c = 0; c < CORNERS; c++) p.u[c * 2] *= this.A - this.rewind(p) + for (const f of this.figures) { + f.u[0] *= Math.max(0.01, this.A - this.cast.size) + this.rewind(f) } this.placed = true const stops = this.look.sky @@ -341,15 +380,15 @@ 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 edge of -// full cover, mixed over the scene. Marks what it draws in short runs along the line, so only the blocks it crosses +// 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, r: number, g: number, b: number) { +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 + 1 + 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 @@ -375,7 +414,7 @@ function stroke(hdr: Float32Array, W: number, H: number, ax: number, ay: number, 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 + 0.5 - Math.sqrt(ex * ex + ey * ey), 0, 1) + 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 @@ -395,11 +434,11 @@ function stroke(hdr: Float32Array, W: number, H: number, ax: number, ay: number, export const mystify: Wallpaper = { id: "mystify", name: "Mystify", - description: "The old screensaver: thin bright polygons bouncing off the edges, trailing fans of echoes, slowly changing color", + description: "The old screensaver: bright polygons gliding and bouncing, morphing as they go, trailing fans of echoes", collection: "Classics", fixedTime: "day", activity: { - calm: "One polygon drifting slowly with a short trail", + calm: "One polygon drifting slowly", lively: "The classic pair, in two colors", teeming: "Three polygons, a little quicker, with longer trails", }, From 39365564a3ca7da7a243ce46bea603c8443321ae Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 10:41:52 -0700 Subject: [PATCH 14/15] Keep time of day, season and weather to Landscapes Fixed-look wallpapers (Akira and Classics) now also ignore the weather setting, and the menu hides the weather row for them, like time of day and season. --- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- src/wallpaper.ts | 8 ++++---- tui.tsx | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 034d98e..681cdf4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -248,8 +248,8 @@ for a new group. The menu's Collection row picks one, and shuffle stays within i 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 and no season, whatever the settings, the menu hides its time and season -rows, auto's crossfades pass it by, and the README and dev tools show it at that one time (`screenshots/.png`). +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 diff --git a/README.md b/README.md index b3396b0..713e7d4 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Type `/wallpaper` to open the menu and pick a wallpaper; none shows until you do - **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. Akira and Classics keep one look. -- **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. diff --git a/src/wallpaper.ts b/src/wallpaper.ts index 75812db..86a0d37 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -52,8 +52,8 @@ export interface Wallpaper { // 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, and without - // seasons. The menu hides its time and season rows. + // 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. @@ -69,9 +69,9 @@ 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 and season settings. +// 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 } : settings + return wallpaper.fixedTime ? { ...settings, time: wallpaper.fixedTime, season: undefined, weather: undefined } : settings } export interface Settings { diff --git a/tui.tsx b/tui.tsx index 1c9a0b5..ab08bf6 100644 --- a/tui.tsx +++ b/tui.tsx @@ -258,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))), From 72ff09922637d2302879c8e7f978f00c0473d8a5 Mon Sep 17 00:00:00 2001 From: vprdev Date: Mon, 12 Oct 2026 10:57:59 -0700 Subject: [PATCH 15/15] Mystify: full-screen polygons bouncing off the edges, gliding with stacked echoes --- wallpapers/mystify.ts | 131 ++++++++++++++++++------------------------ 1 file changed, 57 insertions(+), 74 deletions(-) diff --git a/wallpapers/mystify.ts b/wallpapers/mystify.ts index 86f1e93..b9a5c05 100644 --- a/wallpapers/mystify.ts +++ b/wallpapers/mystify.ts @@ -6,7 +6,7 @@ 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 bounces on its own within its figure's box. +// 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 @@ -64,24 +64,21 @@ const LOOKS: Record = { }, } -// Per activity: figures, each one's size (the box its corners bounce in, in screen heights), how fast the figure drifts -// and its corners bounce within it (screen heights per scene second), echoes in each trail, 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, so size, speed, echoes and their spacing (lines further apart flip more cells) set how much it redraws. -const CAST: Record = { - calm: { count: 1, size: 0.38, drift: 0.016, wobble: 0.016, echoes: 4, spacing: 0.038 }, - lively: { count: 2, size: 0.34, drift: 0.02, wobble: 0.019, echoes: 4, spacing: 0.03 }, - teeming: { count: 3, size: 0.3, drift: 0.024, wobble: 0.022, echoes: 5, spacing: 0.03 }, +// 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 fan glides. +// History samples per echo gap; echoes read between samples, so the stack glides. const SAMPLES = 4 -// The share of its figure's box each corner bounces across, from its own corner of the box. -const REACH = 0.55 -// A figure: its middle and its corners within their box, unfolded (the screen position folds them back between the -// 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 Figure { +// 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 @@ -92,8 +89,7 @@ interface Figure { age: number } -// Unfolded values per figure: the middle (x, y), then each corner's offset within the box (x, y). -const VALUES = 2 + CORNERS * 2 +const VALUES = CORNERS * 2 class Mystify extends Canvas { private readonly look: Look @@ -103,7 +99,7 @@ class Mystify extends Canvas { private readonly sample: number private time = 0 private next = 0 - private figures: Figure[] = [] + private polys: Poly[] = [] private flash = 0 private placed = false private bezier = { wait: eggWait() * TIME_SCALE, t: -1 } @@ -116,14 +112,13 @@ class Mystify extends Canvas { this.supersample = 1 this.look = LOOKS[settings.time] this.cast = CAST[settings.activity] - this.gap = this.cast.spacing / (this.cast.drift + this.cast.wobble) + this.gap = this.cast.spacing / this.cast.speed this.sample = this.gap / SAMPLES - this.figures = Array.from({ length: this.cast.count }, (_, i) => this.figure(i)) + this.polys = Array.from({ length: this.cast.count }, (_, i) => this.poly(i)) } - private figure(i: number, life = Infinity): Figure { - const { size, drift, wobble } = this.cast - const f: Figure = { + private poly(i: number, life = Infinity): Poly { + const f: Poly = { u: new Float64Array(VALUES), v: new Float64Array(VALUES), kick: new Float64Array(VALUES), @@ -133,25 +128,23 @@ class Mystify extends Canvas { life, age: 0, } - // Figures start spread along the screen (u[0] is a fraction of the open width until layout() knows it). - const heading = rand(0, TAU) - f.u[0] = (i + rand(0.2, 0.8)) / this.cast.count - f.u[1] = rand(0, 1) - f.v[0] = Math.cos(heading) * drift * rand(0.8, 1.2) - f.v[1] = Math.sin(heading) * drift * rand(0.8, 1.2) + // 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 turn = rand(0, TAU) - const speed = wobble * rand(0.7, 1.3) - f.u[2 + c * 2] = rand(0, REACH * size) - f.u[3 + c * 2] = rand(0, REACH * size) - f.v[2 + c * 2] = Math.cos(turn) * speed - f.v[3 + c * 2] = Math.sin(turn) * speed + 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 figure's history as if it had always moved at its present velocity. - private rewind(f: Figure) { + // 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++) { @@ -163,14 +156,7 @@ class Mystify extends Canvas { // 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) { - const size = this.cast.size - const x = size / 2 + fold(u[0], Math.max(0.01, this.A - size)) - const y = size / 2 + fold(u[1], 1 - size) - // Each corner bounces within its own corner of the box, so the shape stays open as it morphs. - for (let c = 0; c < CORNERS; c++) { - out[o + c * 2] = x - size / 2 + (c === 1 || c === 2 ? 1 - REACH : 0) * size + fold(u[2 + c * 2], REACH * size) - out[o + c * 2 + 1] = y - size / 2 + (c >= 2 ? 1 - REACH : 0) * size + fold(u[3 + c * 2], REACH * size) - } + for (let k = 0; k < VALUES; k++) out[o + k] = fold(u[k], k & 1 ? 1 : this.A) } step(dt: number) { @@ -182,7 +168,7 @@ class Mystify extends Canvas { // After an error the lines slow. const pace = 1 - 0.6 * this.gloom const decay = Math.exp(-dt / 2) - for (const f of this.figures) { + 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++) { @@ -193,14 +179,14 @@ class Mystify extends Canvas { // 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.figures) { + 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.figures = this.figures.filter((f) => f.age < f.life) + this.polys = this.polys.filter((f) => f.age < f.life) this.stepBezier(dt) } @@ -217,7 +203,7 @@ class Mystify extends Canvas { 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.figures) { + 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) @@ -282,29 +268,26 @@ class Mystify extends Canvas { } } - // A finished task: the colors race through the rainbow and each figure splits off a copy for a while. + // 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.figures.length > this.cast.count * 2) return - for (const parent of this.figures.slice(0, this.cast.count)) { - const f = this.figure(this.figures.length, 7) + 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) - f.v.set(parent.v) - f.v[0] = -parent.v[0] - f.v[1] = -parent.v[1] + 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.figures.push(f) + this.polys.push(f) } } - // A click flings the nearest corner away from it, and nudges its figure the same way. + // A click flings the nearest corner away from it. poke(x: number, y: number) { - const size = this.cast.size - let best: Figure | undefined + let best: Poly | undefined let corner = 0 let least = Infinity - for (const f of this.figures) { + 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) @@ -319,23 +302,23 @@ class Mystify extends Canvas { 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 each fold's slope. - const k = 2 + corner * 2 + // 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] = (slope(best.u[k], REACH * size) * dx * speed) / l - best.v[k + 1] = (slope(best.u[k + 1], REACH * size) * dy * speed) / l - best.kick[k] = (slope(best.u[k], REACH * size) * dx * speed * 4) / l - best.kick[k + 1] = (slope(best.u[k + 1], REACH * size) * dy * speed * 4) / l - best.kick[0] += (slope(best.u[0], Math.max(0.01, this.A - size)) * dx * this.cast.drift * 3) / l - best.kick[1] += (slope(best.u[1], 1 - size) * dy * this.cast.drift * 3) / l + 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 - // Figures start spread across the width once the screen's shape is known. + // Corners start spread across the width once the screen's shape is known. if (!this.placed) - for (const f of this.figures) { - f.u[0] *= Math.max(0.01, this.A - this.cast.size) + 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 @@ -434,7 +417,7 @@ function stroke(hdr: Float32Array, W: number, H: number, ax: number, ay: number, export const mystify: Wallpaper = { id: "mystify", name: "Mystify", - description: "The old screensaver: bright polygons gliding and bouncing, morphing as they go, trailing fans of echoes", + description: "The old screensaver: bright polygons whose corners glide and bounce off the edges, trailing stacks of echoes", collection: "Classics", fixedTime: "day", activity: {