diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c067710..7299816 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,14 @@ when idle at 5 fps, never more than 0.1 s at a time), then `render()`, and reads `src/canvas.ts`) and you only write `step` and `render`. Brightness is applied by the engine, so scenes ignore it. The grid is small: a 160x45 terminal is 320x180 sub-pixels in character mode (2x4 per cell), and 480x270 in pixel mode. -`bun dev/snap.ts` renders at 720x400. Anything thinner than about 2 sub-pixels flickers or vanishes, so size things as -fractions of the height `H`. Most scenes use x from 0 to `A` (the aspect ratio, `W / H`) and y from 0 to 1, and multiply -by `H` to get pixels. +`bun dev/snap.ts` renders at 720x400. Size things as fractions of the height `H`. Most scenes use x from 0 to `A` (the +aspect ratio, `W / H`) and y from 0 to 1, and multiply by `H` to get pixels. + +In character mode each cell shows only two colors, so anything thinner than about 2 sub-pixels, and any texture finer +than a cell, turns into speckle and shimmers as it moves. `Canvas` sets `this.cells` then (not in snaps or pixel mode), +so a scene can draw bolder there: `thick(radius, min)` gives a stroke radius in pixels that never drops below `min` on +cells, `blade(spine, widths, color)` draws a frond's leaflets as one scalloped blade, and textures can coarsen or drop +out. Judge it with `bun dev/preview.ts`, and with `WALLPAPER_OCTANTS=0` for quadrant terminals. ### step and render @@ -97,7 +102,9 @@ This is the biggest single saving: a full-screen gradient with noise costs more | `polygon(points, color, alpha?)` | An anti-aliased polygon in a flat color, for straight-edged things like buildings and boats | | `shape(parts, lighting, light?)` | Rounded parts joined into one lit form, for creatures, people and plants | -For per-pixel effects such as water, sand or fog, loop over `hdr` directly. +For per-pixel effects such as water, sand or fog, loop over `hdr` directly. `reflect()` in `src/water.ts` mirrors +other rows of the scene into water rows with drifting ripples, a tint and a per-pixel weight; call it each frame over +only the water's rows (as the beach and the prehistoric lake do), or once in `layout()` for still water such as ice. ### Shapes @@ -132,12 +139,42 @@ details), and values in between soften it. of height), `core` and `glow` colors, `near` and `wide` glow strengths, and `moon: true` for a cratered moon. - `makeStars(count, top)` and `paintStars(hdr, W, H, stars, time, top, brightness)` draw slowly twinkling stars that fade toward `top`. Stars are tinted from `STAR_TINTS`, never white. -- `makeClouds(count, puffy, y0, y1)`, `driftClouds(clouds, A, dt)` and `paintClouds(hdr, W, H, clouds, top, bottom, - alpha, puffy)` give puffy cumulus or thin streaks, shaded from `top` to `bottom`. +- `makeClouds(count, kind, y0, y1)`, `driftClouds(clouds, A, dt)` and `paintClouds(hdr, W, H, clouds, top, bottom, + alpha, orb)` give clouds of one kind: `cumulus`, `towering` (standing on y, for a horizon), `cirrus`, `wisp`, + `stratus` or `ash`. They are lit from the orb: the brighter of `top` and `bottom` faces it, and clouds near it are + backlit with glowing edges. Pass no orb under an overcast deck to light them evenly from above. Each cloud's shape + is built once per screen size, so drawing them each frame is cheap. Heaped kinds read best by day and at night; + thin streaks (`cirrus`, `stratus`) catch a low sunset sun better. +- `makeShadows(count, y0, y1)` and `paintShadows(hdr, W, H, shadows, ground, orb)` drift soft cloud shadows across + the ground below `ground` under a high sun; `paintHaze(hdr, W, H, y0, y1, color, alpha)` paints a static haze band. + +`src/grade.ts` gives daytime depth in `layout()`: `haze(hdr, sky, W, H, y0, y1, far, near?, top?)` fades what was +painted toward a copy of the sky taken right after `paintSky` (call it after each layer so farther ones fade more), and +`mottle(hdr, W, H, horizon, bottom, size, warm, cool)` lays broad warm and cool patches over the ground in perspective. +Setting `this.frame` (0 to 1) in the constructor darkens the bottom corners to frame a bright foreground. `src/math.ts` has `TAU`, `lerp`, `clamp`, `smoothstep`, `rand`, stable hashes (`hash`, `hash2`, `hashString`), smooth noise (`noise1`, `noise2`, `fbm1`, `fbm2`), `hsv` and the `RGB` type. +### Shadows + +`src/shadow.ts` grounds things standing on the ground. Make one `Shade` with `sunShade(time, look.orb, +weather.covered)`, then call `groundShadow(hdr, W, H, shade, x, y, w, h, tip?, lift?)` with the ground point under a +thing, its width and height in pixels, before drawing it: static things in `layout()`, moving ones each frame. It draws a +soft contact shadow and a cast shadow away from the light: short by day, long and spreading from the low sun at sunset, +faint at night, contact only under cloud. `lift` (pixels off the ground) fades the contact shadow and slides the cast one +away, for hops and lifts. Shadows multiply the ground toward a sky-tinted color, so they never gray it, and where +they overlap the ground darkens only as much as the deepest of them, not stacking into dark patches. + +### Creatures + +`src/creature.ts` animates walkers. Pose a creature in body coordinates with `body(x, y, S, turn)` (u forward, v +down, w across), and keep a `Gait` per walker: `stepGait(g, distance / stride, dt, face)` advances the step cycle with +the distance walked, so planted feet don't slide, eases between standing and walking, turns through a three-quarter +view instead of flipping, and rings a springy `lag` for tails and ears to trail. `quadruped()` draws four jointed +legs in diagonal pairs, `limb()` any two-segment limb with a bending joint, `stride()` and `bob()` one foot's step and +the body's rise, and `chain()` a tapered tail or neck along a curve. + ## Time of day Each wallpaper keeps everything that changes with the time of day in one `LOOKS: Record`: sky stops, the @@ -158,6 +195,12 @@ sunset scenes by about a fifth and night scenes barely, so night keeps its color Night must not use white or gray highlights, because OpenCode's text is white. Use saturated, tinted colors instead: cobalt and teal moonlight, a gold moon, amber lamps and windows, tinted stars, glowing plankton or fireflies. +Give night clear light structure so things stay readable: a lighter band at the horizon (or, underwater, toward the +surface) for silhouettes to stand against, a strong rim `light` from the moon's side, and a few local pools of warm or +glowing light. `lightPool()` in `src/light.ts` lights an ellipse in the light's own color, so ground and creatures +passing through it pick it up; draw it each frame after the creatures it should light. `campfire()` and +`fireflyLight()` there are ready-made pools. Keep the area behind text no brighter or busier than before. + ## Seasons and weather `settings.season` is spring, summer, autumn or winter, or missing for the classic look. Change what the season would @@ -169,6 +212,15 @@ make one `WeatherLayer` (from `src/weather.ts`) with the weather, time of day an Skip stars when `covered` is set, and color clouds with `scud` when it is. Passing `light` as the last argument gives a seasonal shower or flurry without the cloud deck, as the farm does in winter. `wallpapers/farm.ts` shows all of it. +## Wind + +Every outdoor scene shares one wind from `src/wind.ts`, so a gust rolls across the whole screen from left to right +every minute or so and then dies away. `gust(x, t)` is how hard it blows at x (screen heights) and scene time t, from 0 +to 1: add it to anything that drifts, such as petals, snow or dust. `sway(x, t, phase, rate)` replaces a plant's own +`Math.sin(t * rate + phase)`: the same rocking in calm air, plus a lean downwind as a gust passes. For plants painted +once in `layout()`, a `SwayLayer` bends them without redrawing: call `begin()` before painting them, `end()` after +with how far their tips move and a weight from roots to tips, and `draw()` right after copying the background. + ## Scrim Text gets a soft scrim that fades the scene toward a dark color around it. `scrim` gives that color (0 to 255) for @@ -176,6 +228,20 @@ each time of day at the top, middle and bottom of the screen. Darker, more satur at those depths read better than black: dark sky blue at the top and dark green over grass by day, deep navy at night. Check that text stays readable with `bun dev/preview.ts` at every time of day. +## Composition + +OpenCode's text sits in the middle of the screen: the home screen has a centered logo and a 76-column prompt across +the middle third of the height, and a session has a column of messages, a prompt at the bottom and, from 120 columns, +a sidebar on the right. Put the things people should see (a barn, a volcano, a waterfall) toward the left and right +edges, in the top band or along the bottom, measured in screen heights from the nearest edge (`A - 0.3`, not `0.8 * A`) +so they stay clear on wide and narrow terminals alike. Keep the middle band to calm scenery such as sky, water or open +field. `bun dev/preview.ts home 100x30` shows a real layout at any size. + +The engine also tells scenes where text actually is, about once a second. `this.openSpot(x0, x1, y0, y1)` picks a +random point in a box, the least covered of a few tries, for a creature's next stop or a visitor's spot, and +`this.openRow(y0, y1)` picks a height for something crossing the whole screen. Both are plain random picks until the +engine has sent its map, and in `dev/snap.ts`. + ## Activity - **calm** is scenery with something rare now and then: water, wind, clouds, one creature passing every minute or so. @@ -232,7 +298,7 @@ a lot with machine load, so compare against an existing wallpaper measured back bun run typecheck bun dev/check.ts [id...] [season] [weather] # every activity and time: errors, ms/frame, flicker bun dev/snap.ts [activity] [time] [season] [weather] [seconds...] # scene PNGs in dev/out/, and ms per render -bun dev/preview.ts [activity] [time] [season] [weather] [seconds] # terminal cells behind text, as a PNG +bun dev/preview.ts [activity] [time] [season] [weather] [home|session] [WxH] [seconds] # terminal cells behind text, as a PNG bun dev/pixel-check.ts [id] [activity] [time] # real-pixel mode against a mock kitty terminal bun dev/poke.ts [activity] [time] [seconds...] # click at a point (fractions of the screen) bun dev/fade.ts :day :night 50 # a frame halfway through a crossfade, and its cost @@ -247,7 +313,8 @@ bun dev/gif.ts all # re-renders the README's GIFs than `--max-ms`; CI runs `--quiet --max-ms=60`. Dev renders seed `Math.random`, so they are reproducible. Errors thrown in `step` or `render` show up here; inside OpenCode they are only logged to `WALLPAPER_DEBUG`. - `snap.ts` renders the scene directly, before terminal conversion, for judging the art. -- `preview.ts` shows what the terminal will show, scrim included. +- `preview.ts` shows what the terminal will show, scrim included. `home` and `session` lay the text out like OpenCode's + home screen or a busy session, and a size such as `240x60` or `100x30` replaces 160x45. - `dev/pty.ts [seconds] [keys...]` runs a real OpenCode in a pseudo-terminal and types each key argument, pausing 1.5 s after each; `\r` is Enter and `\e` is Escape. `dev/pty-kitty.ts [id] [seconds]` does the same as a kitty terminal in pixel mode. Run them with a throwaway profile (fresh `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, @@ -272,9 +339,10 @@ bun dev/gif.ts all # re-renders the README's GIFs ## Your own package -A wallpaper package imports the API from `opencode-wallpapers/api`, which has `Canvas`, `cap`, `ell`, the sky, -weather, flock, egg and math helpers, and the `Wallpaper` types, with no OpenCode or OpenTUI dependency. It isn't on -npm yet, so build a clone of this repository and depend on it by path: +A wallpaper package imports the API from `opencode-wallpapers/api`, which has `Canvas`, `cap`, `ell`, `blade`, the +sky, grade, shadow, light, water, creature, wind, weather, flock, egg and math helpers, and the `Wallpaper` types, +with no OpenCode or OpenTUI dependency. It isn't on npm yet, so build a clone of this repository and depend on it by +path: ```sh (cd ~/code/opencode-wallpapers && bun install && bun run build) diff --git a/README.md b/README.md index 8d3c228..04f04d6 100644 --- a/README.md +++ b/README.md @@ -72,25 +72,27 @@ Every wallpaper has a day, sunset and night look. By default the time of day fol sunset from 6pm (and at dawn, 6 to 7am), night from 8pm, fading from one to the next over 45 seconds. Pick one in the menu to keep it fixed. -- **Ocean:** sunlit water by day, golden light at sunset, and at night dark moonlit water with glowing plankton - and, in teeming, a diver's torch. -- **Desert:** a high sun with sunlit rock and shadows by day, backlit silhouettes at sunset, and a moonlit night with - stars and the Milky Way, where an owl and bats take over the sky. +- **Ocean:** sunlit water by day, golden light at sunset, and at night moonlit blue water with dark silhouettes, + glowing plankton and, in teeming, a diver's torch. +- **Desert:** a high sun with sunlit rock and shadows by day, silhouettes and long shadows in a hot ochre dust haze at + sunset, and a moonlit night with stars, the Milky Way and a campfire, where an owl and bats take over the sky. - **Jungle:** sunlit greens by day, warm backlit mist at sunset, and a moonlit night lit by fireflies, where the jaguar's eyes glow. - **Space:** the planet's sunlit side by day, an orbital sunrise with the star flaring at its edge at sunset, and its night side with city lights and aurora under a bright Milky Way. -- **Farm:** sunny fields by day, a backlit barn at sunset, and a moonlit night with glowing windows and fireflies. +- **Farm:** sunny fields under drifting cloud shadows by day, golden-hour light and long shadows over warm grass at + sunset, and a moonlit night with glowing windows, an open barn door and fireflies. - **Tundra:** crisp blue-white snow by day, pink alpenglow at sunset, and deep blue snow under a green and violet aurora at night, with warm light from the igloos. -- **Beach:** turquoise water by day, a glittering path under the setting sun, and a moon path at night with glowing - bioluminescent waves. -- **City:** glass towers in the sun by day, backlit towers with the first lit windows at sunset, and at night windows, - lamps and neon in saturated colors mirrored in the river, with rain and a wet street in teeming. -- **Zen garden:** a sunlit pond mirroring the sky by day, the setting sun glittering in it at sunset, and at night - amber lanterns, fireflies and the gold moon reflected in dark blue water. -- **Prehistoric:** a hazy blue sky over glowing lava by day, backlit dinosaurs at sunset, and a moonlit night where - red and orange lava runs down the volcano, lights its smoke and shimmers on the lake. +- **Beach:** turquoise water mirroring the sky by day, a pastel pink and teal sunset over a pink-lit sea, and a moon + path at night with glowing bioluminescent waves and a bonfire on the sand. +- **City:** glass towers in the sun by day, a smoggy amber and magenta sunset dropping between the towers, and at + night windows, lamps and neon in saturated colors mirrored in the river, with rain and a wet street in teeming. +- **Zen garden:** a sunlit pond mirroring the sky by day, a soft peach and lavender sunset glittering in it, and at + night amber lanterns, fireflies and the gold moon reflected in dark blue water. +- **Prehistoric:** a hazy blue sky over glowing lava by day, dinosaurs under an ashy red sky with the sun dimmed by + smoke at sunset, and a moonlit night where red and orange lava runs down the volcano, lights its smoke and shimmers + on the lake. ## Seasons and weather @@ -181,7 +183,9 @@ The plugin post-processes each frame OpenCode draws. Cells painted with OpenCode by the scene, while colored backgrounds such as diffs and selections are left alone. Text keeps a soft scrim that fades the scene toward a dark tint around it. Scenes render in HDR with bloom and tone mapping, then are fitted to two colors per character cell. While one look fades into another, both scenes render and blend, scrim included, so only -transitions cost extra. +transitions cost extra. Scenes keep their landmarks toward the edges, and the engine tells them where text sits, so +creatures and visitors stop where they can be seen. Outdoor scenes share one wind: now and then a gust rolls across +the screen, bending plants and carrying petals, snow and dust. ## Make a wallpaper diff --git a/dev/check.ts b/dev/check.ts index 2e702d5..18e0272 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -12,7 +12,7 @@ mkdirSync("dev/out", { recursive: true }) const debug = `${process.cwd()}/dev/out/check.log` rmSync(debug, { force: true }) process.env.WALLPAPER_DEBUG = debug -process.env.WALLPAPER_OCTANTS = "1" +process.env.WALLPAPER_OCTANTS ??= "1" const args = parseArgs({ allowPositionals: true, options: { quiet: { type: "boolean" }, "max-ms": { type: "string" }, "max-flicker": { type: "string", default: "150" } } }) const maxMs = Number(args.values["max-ms"] ?? Infinity) diff --git a/dev/mock.ts b/dev/mock.ts index cf294bb..bd72d79 100644 --- a/dev/mock.ts +++ b/dev/mock.ts @@ -1,28 +1,30 @@ -// A mock 160x45 OpenCode screen driving the real engine, for headless checks. +// A mock OpenCode screen (160x45 unless sized) driving the real engine, for headless checks. import { createEngine } from "../src/engine" export const W = 160 export const H = 45 export const FPS = 15 -export function mockScreen(options: { kitty?: boolean } = {}) { +export function mockScreen(options: { kitty?: boolean; W?: number; H?: number } = {}) { + const w = options.W ?? W + const h = options.H ?? H // The engine advances on wall-clock time; each performance.now() call moves one engine frame forward. let clock = 0 performance.now = () => (clock += 1000 / FPS) - const buffers = { char: new Uint32Array(W * H), fg: new Uint16Array(W * H * 4), bg: new Uint16Array(W * H * 4), attributes: new Uint32Array(W * H) } + const buffers = { char: new Uint32Array(w * h), fg: new Uint16Array(w * h * 4), bg: new Uint16Array(w * h * 4), attributes: new Uint32Array(w * h) } let post: ((buffer: unknown) => void) | undefined let layer: ((buffer: unknown) => void) | undefined const renderer = { addPostProcessFn: (fn: typeof post) => (post = fn), removePostProcessFn: () => (post = undefined), requestRender() {}, - getCursorState: () => ({ x: 10, y: 40, visible: true }), + getCursorState: () => ({ x: 10, y: h - 5, visible: true }), on() {}, off() {}, stdin: { on() {}, off() {} }, kittyImageTransport: "raw", root: {} as { onMouse?: unknown }, - ...(options.kitty ? { resolution: { width: W * 8, height: H * 16 }, capabilities: { kitty_graphics: true } } : {}), + ...(options.kitty ? { resolution: { width: w * 8, height: h * 16 }, capabilities: { kitty_graphics: true } } : {}), } const engine = createEngine({ renderer, data: { listen: () => () => {} } } as never, { layer: (_renderer, draw) => ((layer = draw as typeof layer), { dispose() {} }) }) return { @@ -31,10 +33,10 @@ export function mockScreen(options: { kitty?: boolean } = {}) { renderer, // OpenCode's render pass: the back layer places its image first, then the UI paints every cell, then post-processing. frame(paint: (x: number, y: number) => number = () => 32, buffer: Record = {}) { - const buf = { width: W, height: H, buffers, ...buffer } + const buf = { width: w, height: h, buffers, ...buffer } layer?.(buf) - for (let i = 0; i < W * H; i++) { - buffers.char[i] = paint(i % W, (i / W) | 0) + for (let i = 0; i < w * h; i++) { + buffers.char[i] = paint(i % w, (i / w) | 0) buffers.bg.set([10, 10, 12, 255], i * 4) buffers.fg.set([200, 200, 200, 255], i * 4) } @@ -45,3 +47,34 @@ export function mockScreen(options: { kitty?: boolean } = {}) { // Some text-like cells so the scrim and text handling get exercised. export const text = (x: number, y: number) => (y % 4 === 1 && x > 40 && x < 120 && x % 9 !== 0 ? 65 : 32) + +// Where text sits on OpenCode's home screen, measured in a real one: a centered logo, a 76-column prompt below it and +// the version in the bottom-right corner. +export function home(w: number, h: number) { + const c = Math.floor(h / 2) + const left = Math.floor(w / 2) - 37 + const spans: [number, number, number][] = [ + ...[0, 1, 2, 3].map((r): [number, number, number] => [c - 6 + r, left + 18, left + 58]), + [c - 1, left + 20, left + 56], + [c + 2, left + 3, left + 58], + [c + 4, left + 3, left + 32], + [c + 6, left + 42, left + 75], + ...[1, 2, 3, 4, 5].map((r): [number, number, number] => [c + r, left, left + 1]), + [h - 2, w - 17, w - 2], + ] + return (x: number, y: number) => (spans.some((s) => s[0] === y && x >= s[1] && x < s[2]) ? 65 : 32) +} + +// A session: a column of messages above the prompt, a status line, and from 120 columns a sidebar on the right. +export function session(w: number, h: number) { + const right = w >= 120 ? w - 42 : w - 2 + return (x: number, y: number) => { + if (y === h - 2) return x >= 2 && x < w - 2 && x % 23 < 15 ? 65 : 32 + if (x >= right + 2 && x < w - 2) return (y >= 2 && y < 4) || (y >= 6 && y < 15 && y % 3 === 0 && x < w - 16 - ((y * 7) % 12)) ? 65 : 32 + if (x < 2 || x >= right) return 32 + if (y >= h - 7 && y < h - 3) return y === h - 6 || (y === h - 4 && x < 30) ? 65 : 32 + if (y < 1 || y >= h - 8 || y % 6 === 5) return 32 + // Paragraphs of prose: every line fills the column except each paragraph's last. + return y % 6 !== 4 || x < 2 + (right - 2) * (0.3 + ((y * 37) % 50) / 100) ? 65 : 32 + } +} diff --git a/dev/preview.ts b/dev/preview.ts index 2835d1b..6d93b18 100644 --- a/dev/preview.ts +++ b/dev/preview.ts @@ -1,9 +1,11 @@ -// Renders what a wallpaper looks like in the terminal (octant cells behind some text) on a mock 160x45 screen, to a PNG: -// bun dev/preview.ts [calm|lively|teeming] [day|sunset|night] [spring|summer|autumn|winter] [clear|overcast|rain|snow|fog] [seconds] +// Renders what a wallpaper looks like in the terminal (octant cells behind some text) on a mock screen, to a PNG. home +// and session lay the text out like OpenCode's home screen or a session; a size such as 240x60 replaces 160x45: +// bun dev/preview.ts [calm|lively|teeming] [day|sunset|night] [spring|summer|autumn|winter] [clear|overcast|rain|snow|fog] [home|session] [WxH] [seconds] +import "./seed" import { mkdirSync } from "node:fs" import { ACTIVITIES, SEASONS, TIMES, WEATHERS } from "../src/engine" import { cellsToPng } from "./cells" -import { FPS, H, W, mockScreen, text } from "./mock" +import { FPS, H, W, home, mockScreen, session, text } from "./mock" import { wallpapers } from "./wallpapers" const [id, ...rest] = process.argv.slice(2) @@ -11,15 +13,19 @@ 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)) +const size = rest.find((arg) => /^\d+x\d+$/.test(arg)) +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]`) -process.env.WALLPAPER_OCTANTS = "1" +process.env.WALLPAPER_OCTANTS ??= "1" mkdirSync("dev/out", { recursive: true }) -const screen = mockScreen() +const screen = mockScreen({ W: w, H: h }) +const paint = layout === "home" ? home(w, h) : layout === "session" ? session(w, h) : text screen.engine.start(wallpaper, { activity, time, season, weather }) -for (let f = 0; f < Number(seconds) * FPS; f++) screen.frame(text) +for (let f = 0; f < Number(seconds) * FPS; f++) screen.frame(paint) screen.engine.stop() -const path = `dev/out/${[wallpaper.id, activity, time, season, weather, "preview", `${seconds}s`].filter(Boolean).join("-")}.png` -await Bun.write(path, cellsToPng({ W, H, ...screen.buffers })) +const path = `dev/out/${[wallpaper.id, activity, time, season, weather, layout, size, "preview", `${seconds}s`].filter(Boolean).join("-")}.png` +await Bun.write(path, cellsToPng({ W: w, H: h, ...screen.buffers })) console.log(path) diff --git a/screenshots/beach-night.png b/screenshots/beach-night.png index ed1f7a8..684ba4f 100644 Binary files a/screenshots/beach-night.png and b/screenshots/beach-night.png differ diff --git a/screenshots/beach-sunset.png b/screenshots/beach-sunset.png index 79b4587..e4890bc 100644 Binary files a/screenshots/beach-sunset.png and b/screenshots/beach-sunset.png differ diff --git a/screenshots/beach.gif b/screenshots/beach.gif index 0a8bb85..85ad9f7 100644 Binary files a/screenshots/beach.gif and b/screenshots/beach.gif differ diff --git a/screenshots/beach.png b/screenshots/beach.png index 61c392a..cd0d9e0 100644 Binary files a/screenshots/beach.png and b/screenshots/beach.png differ diff --git a/screenshots/city-night.png b/screenshots/city-night.png index 59d6a64..4d3368c 100644 Binary files a/screenshots/city-night.png and b/screenshots/city-night.png differ diff --git a/screenshots/city-sunset.png b/screenshots/city-sunset.png index e1b23ab..27109a4 100644 Binary files a/screenshots/city-sunset.png and b/screenshots/city-sunset.png differ diff --git a/screenshots/city.gif b/screenshots/city.gif index 0f9d2b8..53f4706 100644 Binary files a/screenshots/city.gif and b/screenshots/city.gif differ diff --git a/screenshots/city.png b/screenshots/city.png index 9c74f3c..767ba96 100644 Binary files a/screenshots/city.png and b/screenshots/city.png differ diff --git a/screenshots/desert-night.png b/screenshots/desert-night.png index 0224136..d32413c 100644 Binary files a/screenshots/desert-night.png and b/screenshots/desert-night.png differ diff --git a/screenshots/desert-sunset.png b/screenshots/desert-sunset.png index 4f35606..5666ddc 100644 Binary files a/screenshots/desert-sunset.png and b/screenshots/desert-sunset.png differ diff --git a/screenshots/desert.gif b/screenshots/desert.gif index 7f0015c..438193e 100644 Binary files a/screenshots/desert.gif and b/screenshots/desert.gif differ diff --git a/screenshots/desert.png b/screenshots/desert.png index dca5d68..bd3790d 100644 Binary files a/screenshots/desert.png and b/screenshots/desert.png differ diff --git a/screenshots/farm-autumn.png b/screenshots/farm-autumn.png index 78752bb..6d6b724 100644 Binary files a/screenshots/farm-autumn.png and b/screenshots/farm-autumn.png differ diff --git a/screenshots/farm-night.png b/screenshots/farm-night.png index 0ee886c..c978745 100644 Binary files a/screenshots/farm-night.png and b/screenshots/farm-night.png differ diff --git a/screenshots/farm-rain.png b/screenshots/farm-rain.png index 9e5a73c..1a3579b 100644 Binary files a/screenshots/farm-rain.png and b/screenshots/farm-rain.png differ diff --git a/screenshots/farm-spring.png b/screenshots/farm-spring.png index 7e22e0d..675c71f 100644 Binary files a/screenshots/farm-spring.png and b/screenshots/farm-spring.png differ diff --git a/screenshots/farm-summer.png b/screenshots/farm-summer.png index 3aca4b9..8cdcb9b 100644 Binary files a/screenshots/farm-summer.png and b/screenshots/farm-summer.png differ diff --git a/screenshots/farm-sunset.png b/screenshots/farm-sunset.png index 57ebc6a..fc14c04 100644 Binary files a/screenshots/farm-sunset.png and b/screenshots/farm-sunset.png differ diff --git a/screenshots/farm-winter.png b/screenshots/farm-winter.png index 6a475c7..ee5330d 100644 Binary files a/screenshots/farm-winter.png and b/screenshots/farm-winter.png differ diff --git a/screenshots/farm.gif b/screenshots/farm.gif index 0bc34ad..726fd2c 100644 Binary files a/screenshots/farm.gif and b/screenshots/farm.gif differ diff --git a/screenshots/farm.png b/screenshots/farm.png index 3aca4b9..8cdcb9b 100644 Binary files a/screenshots/farm.png and b/screenshots/farm.png differ diff --git a/screenshots/hero.gif b/screenshots/hero.gif index 72e2dae..b125ef3 100644 Binary files a/screenshots/hero.gif and b/screenshots/hero.gif differ diff --git a/screenshots/jungle-night.png b/screenshots/jungle-night.png index cf6940f..820df74 100644 Binary files a/screenshots/jungle-night.png and b/screenshots/jungle-night.png differ diff --git a/screenshots/jungle-sunset.png b/screenshots/jungle-sunset.png index cb8708b..2804163 100644 Binary files a/screenshots/jungle-sunset.png and b/screenshots/jungle-sunset.png differ diff --git a/screenshots/jungle.gif b/screenshots/jungle.gif index 6e9c9c4..6b5e380 100644 Binary files a/screenshots/jungle.gif and b/screenshots/jungle.gif differ diff --git a/screenshots/jungle.png b/screenshots/jungle.png index cd667f2..eb4b152 100644 Binary files a/screenshots/jungle.png and b/screenshots/jungle.png differ diff --git a/screenshots/menu.png b/screenshots/menu.png index 0e397cf..087eeed 100644 Binary files a/screenshots/menu.png and b/screenshots/menu.png differ diff --git a/screenshots/ocean-night.png b/screenshots/ocean-night.png index 1c1d95c..9e16974 100644 Binary files a/screenshots/ocean-night.png and b/screenshots/ocean-night.png differ diff --git a/screenshots/ocean-sunset.png b/screenshots/ocean-sunset.png index eb3018b..d489d2b 100644 Binary files a/screenshots/ocean-sunset.png and b/screenshots/ocean-sunset.png differ diff --git a/screenshots/ocean.gif b/screenshots/ocean.gif index 351af65..615a1c5 100644 Binary files a/screenshots/ocean.gif and b/screenshots/ocean.gif differ diff --git a/screenshots/ocean.png b/screenshots/ocean.png index 2ff460f..33bb17d 100644 Binary files a/screenshots/ocean.png and b/screenshots/ocean.png differ diff --git a/screenshots/prehistoric-night.png b/screenshots/prehistoric-night.png index 9e399fd..d6796c3 100644 Binary files a/screenshots/prehistoric-night.png and b/screenshots/prehistoric-night.png differ diff --git a/screenshots/prehistoric-sunset.png b/screenshots/prehistoric-sunset.png index 7519528..57afc57 100644 Binary files a/screenshots/prehistoric-sunset.png and b/screenshots/prehistoric-sunset.png differ diff --git a/screenshots/prehistoric.gif b/screenshots/prehistoric.gif index 84912c3..f456052 100644 Binary files a/screenshots/prehistoric.gif and b/screenshots/prehistoric.gif differ diff --git a/screenshots/prehistoric.png b/screenshots/prehistoric.png index 37b8cc0..6e97316 100644 Binary files a/screenshots/prehistoric.png and b/screenshots/prehistoric.png differ diff --git a/screenshots/space-night.png b/screenshots/space-night.png index 8ad2468..75e36fe 100644 Binary files a/screenshots/space-night.png and b/screenshots/space-night.png differ diff --git a/screenshots/space-sunset.png b/screenshots/space-sunset.png index bfac356..6eaba81 100644 Binary files a/screenshots/space-sunset.png and b/screenshots/space-sunset.png differ diff --git a/screenshots/space.gif b/screenshots/space.gif index 18d63b4..57d87c1 100644 Binary files a/screenshots/space.gif and b/screenshots/space.gif differ diff --git a/screenshots/space.png b/screenshots/space.png index d7c6d30..55d2b37 100644 Binary files a/screenshots/space.png and b/screenshots/space.png differ diff --git a/screenshots/tundra-night.png b/screenshots/tundra-night.png index a8e399a..7421128 100644 Binary files a/screenshots/tundra-night.png and b/screenshots/tundra-night.png differ diff --git a/screenshots/tundra-sunset.png b/screenshots/tundra-sunset.png index 5fd2af9..4352270 100644 Binary files a/screenshots/tundra-sunset.png and b/screenshots/tundra-sunset.png differ diff --git a/screenshots/tundra.gif b/screenshots/tundra.gif index 394bbb6..9f19ce5 100644 Binary files a/screenshots/tundra.gif and b/screenshots/tundra.gif differ diff --git a/screenshots/tundra.png b/screenshots/tundra.png index 8102589..c7dc987 100644 Binary files a/screenshots/tundra.png and b/screenshots/tundra.png differ diff --git a/screenshots/zen-night.png b/screenshots/zen-night.png index 9499712..9e19c59 100644 Binary files a/screenshots/zen-night.png and b/screenshots/zen-night.png differ diff --git a/screenshots/zen-sunset.png b/screenshots/zen-sunset.png index 41f149d..4b135ef 100644 Binary files a/screenshots/zen-sunset.png and b/screenshots/zen-sunset.png differ diff --git a/screenshots/zen.gif b/screenshots/zen.gif index acc8fa1..96a6165 100644 Binary files a/screenshots/zen.gif and b/screenshots/zen.gif differ diff --git a/screenshots/zen.png b/screenshots/zen.png index 56295df..f83c8b7 100644 Binary files a/screenshots/zen.png and b/screenshots/zen.png differ diff --git a/src/api.ts b/src/api.ts index 1815efa..c02de66 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,9 +1,13 @@ // The public API for wallpapers, imported as "opencode-wallpapers/api". Nothing here depends on OpenCode or OpenTUI. -export { Canvas, cap, ell, type Lighting, type Part } from "./canvas" +export { Canvas, blade, cap, ell, type Lighting, type Part } from "./canvas" +export { bob, body, chain, gait, joint, limb, quadruped, stepGait, stride, view, type Body, type Gait } from "./creature" export { eggWait } from "./egg" export { bird, flyAway, startle, type Flier } from "./flock" +export { haze, mottle } from "./grade" +export { campfire, fireflyLight, lightPool } from "./light" export { TAU, clamp, fbm1, fbm2, hash, hash2, hashString, hsv, lerp, noise1, noise2, rand, smoothstep, type RGB } from "./math" -export { STAR_TINTS, driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "./sky" +export { fillShadow, groundShadow, sunShade, type Shade } from "./shadow" +export { STAR_TINTS, driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintHaze, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type CloudKind, type Orb, type Star } from "./sky" export { ACTIVITIES, AGENT_EVENTS, @@ -17,8 +21,11 @@ export { type Scrim, type Season, type Settings, + type TextMap, type Time, type Wallpaper, type Weather, } from "./wallpaper" +export { reflect } from "./water" export { WeatherLayer } from "./weather" +export { SwayLayer, gust, sway } from "./wind" diff --git a/src/canvas.ts b/src/canvas.ts index bd4e824..070bdf0 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -1,5 +1,6 @@ -import type { AgentEvent } from "./wallpaper" -import { clamp, type RGB } from "./math" +import type { AgentEvent, TextMap } from "./wallpaper" +import { clamp, rand, smoothstep, type RGB } from "./math" +import { clearShadows, keepShadows } from "./shadow" // Bloom is computed at 1/BLOOM resolution, then again at half that for the wide halo. const BLOOM = 4 @@ -20,6 +21,17 @@ export function ell(cx: number, cy: number, rx: number, ry: number, angle: numbe return { kind: "ellipse", cx, cy, rx, ry, angle, color, ribs } } +// A leafy blade along spine (points in pixels), widths[i] wide either side at each point: overlapping ellipses whose +// scalloped edges suggest leaflets. Fronds draw this on terminal cells (see Canvas.cells) instead of single leaflets, +// which would turn into speckle there. +export function blade(spine: readonly (readonly [number, number])[], widths: readonly number[], color: RGB): Part[] { + return spine.slice(1).map(([bx, by], i) => { + const [ax, ay] = spine[i] + const half = Math.hypot(bx - ax, by - ay) * 0.75 + return ell((ax + bx) / 2, (ay + by) / 2, Math.max(half, 0.5), Math.max(0.5, (widths[i] + widths[i + 1]) / 2), Math.atan2(by - ay, bx - ax), color) + }) +} + // How shape() lights a shape. rim: backlit from a point (pixels), so only the outline facing it glows. front: lit from // a direction (x right, y down, z toward the viewer), shading each part as a rounded form. export type Lighting = { style: "rim"; color: RGB; x: number; y: number } | { style: "front"; dir: readonly [number, number, number] } @@ -31,6 +43,10 @@ export abstract class Canvas { W = 0 H = 0 A = 1 + // Set when each pixel is a sub-pixel of a terminal character cell (2 across, 4 down) and each cell shows only two + // colors. Strokes under about two pixels across and texture finer than a cell turn into noise there, so scenes draw + // bolder when it's set: thicker strokes (see thick()), solid blades for leaflets, coarser texture. + protected cells = false hdr = new Float32Array(0) pixels = new Uint8Array(0) @@ -56,6 +72,9 @@ export abstract class Canvas { // clouds by it. protected gloom = 0 private gloomy = false + // Extra darkening toward the bottom corners (0 to 1), folded into the vignette: set it in the constructor to frame + // a bright daytime foreground. + protected frame = 0 abstract step(dt: number): void abstract render(): void @@ -72,15 +91,67 @@ export abstract class Canvas { this.gloom = clamp(this.gloom + (this.gloomy ? dt : -dt) * 0.3, 0, 1) } + // Where OpenCode's text sits; unknown until the engine says, and in dev/snap.ts. + protected text: TextMap | undefined + + textMap(map: TextMap) { + this.text = map + } + + // How hidden by text the point (x, y) in screen heights is, from 0 (open) to 1. + protected covered(x: number, y: number) { + const t = this.text + if (!t) return 0 + return t.cover[clamp(Math.floor(y * t.rows), 0, t.rows - 1) * t.cols + clamp(Math.floor((x / this.A) * t.cols), 0, t.cols - 1)] + } + + // A random point in the box from (x0, y0) to (x1, y1) in screen heights, the most open of a few tries once the + // engine has said where text is: creatures wander toward open ground and visitors come on where they'll be seen. + protected openSpot(x0: number, x1: number, y0: number, y1: number): [number, number] { + let best: [number, number] = [rand(x0, x1), rand(y0, y1)] + let least = this.covered(best[0], best[1]) + for (let i = 0; i < 5 && least > 0.05; i++) { + const spot: [number, number] = [rand(x0, x1), rand(y0, y1)] + const c = this.covered(spot[0], spot[1]) + if (c >= least) continue + best = spot + least = c + } + return best + } + + // A height between y0 and y1 for a visitor crossing the whole screen: the least covered row of a few tries. + protected openRow(y0: number, y1: number) { + let best = rand(y0, y1) + const t = this.text + if (!t) return best + const cover = (y: number) => { + const row = clamp(Math.floor(y * t.rows), 0, t.rows - 1) * t.cols + let sum = 0 + for (let c = 0; c < t.cols; c++) sum += t.cover[row + c] + return sum + } + let least = cover(best) + for (let i = 0; i < 4; i++) { + const y = rand(y0, y1) + const c = cover(y) + if (c >= least) continue + best = y + least = c + } + return best + } + // Called after every size change, once the buffers match the new size. protected layout() {} - resize(W: number, H: number) { + resize(W: number, H: number, cells = false) { W = Math.max(64, Math.round(W)) H = Math.max(36, Math.round(H)) - if (W === this.W && H === this.H) return + if (W === this.W && H === this.H && cells === this.cells) return this.W = W this.H = H + this.cells = cells this.A = W / H this.hdr = new Float32Array(W * H * 3) this.pixels = new Uint8Array(W * H * 4) @@ -99,7 +170,8 @@ export abstract class Canvas { for (let x = 0; x < W; x++) { const dx = (x - W / 2) * invR const dy = (y - H / 2) * invR - this.vignette[y * W + x] = 1 - 0.62 * Math.pow(dx * dx + dy * dy, 1.25) + const corner = this.frame * smoothstep(0.45, 1, y / H) * (0.35 + 0.65 * (2 * Math.abs(x / W - 0.5)) ** 2) + this.vignette[y * W + x] = (1 - 0.62 * Math.pow(dx * dx + dy * dy, 1.25)) * (1 - corner) } // Bilinear lookup from full-resolution rows and columns into the bloom grid. this.rowA = new Int32Array(H) @@ -123,6 +195,13 @@ export abstract class Canvas { this.colF[x] = clamp(bx - i, 0, 1) } this.layout() + keepShadows(this.hdr) + } + + // A stroke radius in screen heights, in pixels; on terminal cells never under min, since thinner strokes break up + // into noise there and shimmer as they move. + protected thick(radius: number, min = 1) { + return this.cells ? Math.max(radius * this.H, min) : radius * this.H } // Adds light to one pixel. @@ -170,6 +249,7 @@ export abstract class Canvas { protected finish(exposure = 1.25) { bloom(this.hdr, this.W, this.H, this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.bloomUp) toneMap(this.hdr, this.pixels, this.W, this.H, this.bloomUp, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) + clearShadows(this.hdr) } } diff --git a/src/creature.ts b/src/creature.ts new file mode 100644 index 0000000..d7dee2e --- /dev/null +++ b/src/creature.ts @@ -0,0 +1,121 @@ +// Walking and follow-through for creatures drawn with Canvas.shape. Creatures are posed in their own body coordinates +// (u forward, v down, w toward their right side) and mapped to pixels by a view, so legs, joints and turns are worked +// out once here instead of in every scene. +import { cap, type Part } from "./canvas" +import { TAU, clamp, smoothstep, type RGB } from "./math" + +// A walking creature's animation state. phase counts strides: advance it by distance walked over stride length and +// planted feet stay put on the ground. go eases between standing (0) and walking (1), turn eases facing from 1 (right) +// to -1 (left) through a three-quarter view, and lag is a springy follow-through that overshoots as the creature +// starts, stops and turns, for tails, ears, manes and heads to trail by. +export interface Gait { + phase: number + go: number + turn: number + lag: number + lagV: number +} + +export function gait(face = 1, phase = 0): Gait { + return { phase, go: 0, turn: face, lag: 0, lagV: 0 } +} + +// Moves a gait on by `strides` (distance over stride length) in dt creature seconds, turning toward face (1 or -1) at +// `rate` (2 swings it right round in a creature second). +export function stepGait(g: Gait, strides: number, dt: number, face: number, rate = 3) { + g.phase += strides + g.go = clamp(g.go + (strides > 0 ? dt : -dt) * 2.5, 0, 1) + const before = g.turn + g.turn = face > g.turn ? Math.min(face, g.turn + dt * rate) : Math.max(face, g.turn - dt * rate) + // The spring chases the walking pace and is kicked by turning, then rings down. + const target = g.go + Math.abs(g.turn - before) / Math.max(dt, 1e-6) * 0.15 + g.lagV += ((target - g.lag) * 40 - g.lagV * 5) * dt + g.lag += g.lagV * dt +} + +// How a creature is seen at turn t: along scales its length (1 side-on facing right, -1 facing left) and across its +// width, which shows as it turns to face the viewer on the way round. +export function view(turn: number): [along: number, across: number] { + const a = ((1 - turn) * Math.PI) / 2 + return [Math.cos(a), Math.sin(a)] +} + +// One foot's place in the stride: how far it reaches ahead of its hip (1 at touchdown, -1 at lift-off) and how high it +// is lifted (0 on the ground). It is planted for the first `duty` of each cycle, sliding back exactly as fast as the +// body moves on, then swings forward. A creature whose stride (distance per cycle) is d keeps feet planted with a +// reach of d * duty / 2. +export function stride(phase: number, offset = 0, duty = 0.6): [reach: number, lift: number] { + const p = (((phase + offset) % 1) + 1) % 1 + if (p < duty) return [1 - (2 * p) / duty, 0] + const q = (p - duty) / (1 - duty) + return [-1 + 2 * smoothstep(0, 1, q), Math.sin(Math.PI * q)] +} + +// How high the body rides: 1 over each planted leg, 0 between, twice a stride. +export function bob(phase: number, duty = 0.6) { + return 0.5 + 0.5 * Math.cos(TAU * 2 * (phase - duty / 2)) +} + +// The middle joint of a two-segment limb of lengths a and b from (ax, ay) to (bx, by). bend 1 puts it on the side of +// the line's left normal, behind a leg hanging from a creature facing +x (a hock or a bird's ankle); -1 in front (a +// knee or elbow). Out of reach, the limb straightens toward the tip. +export function joint(ax: number, ay: number, bx: number, by: number, a: number, b: number, bend: number): [number, number] { + const dx = bx - ax + const dy = by - ay + const d = Math.hypot(dx, dy) || 1e-6 + const r = clamp(d, Math.abs(a - b) + 1e-6, a + b - 1e-6) + const along = (a * a - b * b + r * r) / (2 * r) + const h = Math.sqrt(Math.max(0, a * a - along * along)) * bend + return [ax + (dx / d) * along - (dy / d) * h, ay + (dy / d) * along + (dx / d) * h] +} + +// A tapered two-segment limb in body coordinates, mapped to pixels by P: root radius r0, joint r1, tip r2. +export function limb(P: (u: number, v: number) => [number, number], ax: number, ay: number, bx: number, by: number, a: number, b: number, bend: number, r0: number, r1: number, r2: number, color: RGB): Part[] { + const [jx, jy] = joint(ax, ay, bx, by, a, b, bend) + return [cap(...P(ax, ay), ...P(jx, jy), r0, r1, color), cap(...P(jx, jy), ...P(bx, by), r1, r2, color)] +} + +// Body coordinates to pixels: u along the creature, v down, w across it toward its right side, all in units of S pixels. +// along and across are its view (see view), and len(u, w) the on-screen half-length in pixels of a part u long and w +// wide, for ellipses that foreshorten as the creature turns. +export type Body = ((u: number, v: number, w?: number) => [number, number]) & { S: number; along: number; across: number; len: (u: number, w: number) => number } + +// A creature's body coordinates seen at turn (see view): x, y is where it stands in pixels and S its size in pixels. +export function body(x: number, y: number, S: number, turn: number): Body { + const [along, across] = view(turn) + const P = (u: number, v: number, w = 0): [number, number] => [x + (u * along - w * across) * S, y + v * S] + return Object.assign(P, { S, along, across, len: (u: number, w: number) => (u * Math.abs(along) + w * across) * S }) +} + +// A quadruped's legs on its far or near side, walking in diagonal pairs. Hips sit `fore` ahead of and `hind` behind +// the middle, `top` above the ground and `w` either side; a and b are the proportions of the upper and lower leg, +// reach and lift how far a foot travels (see stride), and r the leg's radii at the hip, joint and foot. Front knees +// bend forward and hocks back. paw adds a hoof or paw that long, in its own color. +export function quadruped(P: Body, g: Gait, far: boolean, o: { fore: number; hind: number; top: number; w: number; a: number; b: number; reach: number; lift: number; r: [number, number, number]; color: RGB; paw?: [number, RGB] }): Part[] { + const S = P.S + const [r0, r1, r2] = o.r + // Standing, legs are a little bent; a and b only set the proportions. + const k = ((o.top - r2) * 1.06) / (o.a + o.b) + const along = view(g.turn)[0] + const legs: [number, number, number][] = [ + [o.fore, o.w, 0], + [-o.hind, -o.w, 0], + [o.fore, -o.w, 0.5], + [-o.hind, o.w, 0.5], + ] + return legs + .filter(([, w]) => w * along < 0 === far) + .flatMap(([u, w, offset]) => { + const [reach, lift] = stride(g.phase, offset) + const fu = u + reach * o.reach * g.go + const fv = -lift * o.lift * g.go - r2 + const Q = (a: number, b: number) => P(a, b, w) + const parts = limb(Q, u, -o.top, fu, fv, o.a * k, o.b * k, u > 0 ? -1 : 1, r0 * S, r1 * S, r2 * S, o.color) + return o.paw ? [...parts, cap(...Q(fu, fv), ...Q(fu + o.paw[0], fv + r2 * 0.1), r2 * 1.15 * S, r2 * S, o.paw[1])] : parts + }) +} + +// A chain of tapered capsules through points along a curve, for tails and necks: path(t) for t 0..1 in pixels. +export function chain(path: (t: number) => [number, number], n: number, r0: number, r1: number, color: RGB): Part[] { + return Array.from({ length: n }, (_, i) => cap(...path(i / n), ...path((i + 1) / n), r0 + (r1 - r0) * (i / n), r0 + (r1 - r0) * ((i + 1) / n), color)) +} diff --git a/src/engine.tsx b/src/engine.tsx index 91a87a8..ca9fbfc 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -4,7 +4,7 @@ import { appendFileSync } from "node:fs" import { watchEgg } from "./egg" import { lerp, smoothstep, type RGB } from "./math" import { OCTANTS } from "./octants" -import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type Wallpaper } from "./wallpaper" +import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type TextMap, type Wallpaper } from "./wallpaper" export { ACTIVITIES, @@ -22,6 +22,7 @@ export { type Scrim, type Season, type Settings, + type TextMap, type Time, type Wallpaper, type Weather, @@ -73,6 +74,10 @@ const LEVELS: Record> = { const DESATURATE = { day: 0.22, sunset: 0.22, night: 0.04 } // Seconds a change of wallpaper, activity or time of day crossfades over by default. export const FADE = 1.2 +// The map of where text sits that scenes get (see Scene.textMap): its grid, and how many frames apart it is rebuilt. +const MAP_COLS = 16 +const MAP_ROWS = 9 +const MAP_EVERY = 15 // Crossfades two equally sized RGBA frames into out. function mix(a: Uint8Array, b: Uint8Array, out: Uint8Array, t: number) { @@ -259,8 +264,9 @@ const QUADRANTS = [ 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588, ] -// Picks the two-colour split of a cell's four sub-pixels with the least error and writes it as a quadrant block. -function writeQuadrant(q8: Float32Array, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { +// Picks the two-colour split of a cell's four sub-pixels with the least error and writes it as a quadrant block. Like +// writeOctant it keeps last frame's split unless the new one is clearly better. Returns the mask used. +function writeQuadrant(q8: Float32Array, previous: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { const q = quad4 for (let s = 0; s < 4; s++) { const top = ((s >> 1) * 4 + (s & 1)) * 3 @@ -268,68 +274,75 @@ function writeQuadrant(q8: Float32Array, char: Uint32Array, fg: Uint16Array, bg: } let bestMask = 0 let bestErr = Infinity - let fr = 0 - let fgr = 0 - let fb = 0 - let br = 0 - let bgg = 0 - let bb = 0 for (let m = 0; m < 8; m++) { - let ar = 0 - let ag = 0 - let ab = 0 - let an = 0 - let cr = 0 - let cg = 0 - let cb = 0 - let cn = 0 - for (let s = 0; s < 4; s++) { - if (m & (1 << s)) { - ar += q[s * 3] - ag += q[s * 3 + 1] - ab += q[s * 3 + 2] - an++ - } else { - cr += q[s * 3] - cg += q[s * 3 + 1] - cb += q[s * 3 + 2] - cn++ - } - } - if (an) ((ar /= an), (ag /= an), (ab /= an)) - if (cn) ((cr /= cn), (cg /= cn), (cb /= cn)) - let err = 0 - for (let s = 0; s < 4; s++) { - const on = m & (1 << s) - const dr = q[s * 3] - (on ? ar : cr) - const dg = q[s * 3 + 1] - (on ? ag : cg) - const db = q[s * 3 + 2] - (on ? ab : cb) - err += dr * dr + dg * dg + db * db - } - if (err < bestErr - 0.5) { - bestErr = err + evalQuad(q, m) + if (split.err < bestErr - 0.5) { + bestErr = split.err bestMask = m - fr = ar - fgr = ag - fb = ab - br = cr - bgg = cg - bb = cb } } - bg[o] = br - bg[o + 1] = bgg - bg[o + 2] = bb + let chosen = bestMask + if (previous >= 0 && previous !== bestMask) { + evalQuad(q, previous) + if (split.err <= bestErr * 1.35 + HYSTERESIS / 2) chosen = previous + } + evalQuad(q, chosen) + bg[o] = split.br + bg[o + 1] = split.bg + bg[o + 2] = split.bb bg[o + 3] = 255 - if (bestMask === 0) { + if (chosen === 0) { char[i] = 0x20 - return + return chosen } - char[i] = QUADRANTS[bestMask] - fg[o] = fr - fg[o + 1] = fgr - fg[o + 2] = fb + char[i] = QUADRANTS[chosen] + fg[o] = split.fr + fg[o + 1] = split.fg + fg[o + 2] = split.fb fg[o + 3] = 255 + return chosen +} + +// Like evalMask, for a cell's four quadrant sub-pixels. +function evalQuad(q: Float32Array, mask: number) { + let fr = 0 + let fg = 0 + let fb = 0 + let fn = 0 + let br = 0 + let bg = 0 + let bb = 0 + let bn = 0 + for (let s = 0; s < 4; s++) { + if (mask & (1 << s)) { + fr += q[s * 3] + fg += q[s * 3 + 1] + fb += q[s * 3 + 2] + fn++ + } else { + br += q[s * 3] + bg += q[s * 3 + 1] + bb += q[s * 3 + 2] + bn++ + } + } + if (fn) ((fr /= fn), (fg /= fn), (fb /= fn)) + if (bn) ((br /= bn), (bg /= bn), (bb /= bn)) + let err = 0 + for (let s = 0; s < 4; s++) { + const on = mask & (1 << s) + const dr = q[s * 3] - (on ? fr : br) + const dg = q[s * 3 + 1] - (on ? fg : bg) + const db = q[s * 3 + 2] - (on ? fb : bb) + err += dr * dr + dg * dg + db * db + } + split.fr = fr + split.fg = fg + split.fb = fb + split.br = br + split.bg = bg + split.bb = bb + split.err = err } function isBlock(char: number) { @@ -466,6 +479,10 @@ export function createEngine( let grid = new Float32Array(0) let gridBlur = new Float32Array(0) let light = false + const textMap: TextMap = { cols: MAP_COLS, rows: MAP_ROWS, cover: new Float32Array(MAP_COLS * MAP_ROWS) } + const mapCount = new Float32Array(MAP_COLS * MAP_ROWS) + let mapped = false + let mapFresh = false // Box-filters the scene down to the 2x4-per-cell octant grid, then applies an unsharp mask. const buildGrid = (px: Uint8Array, PW: number, PH: number, GW: number, GH: number) => { @@ -694,8 +711,8 @@ export function createEngine( const target = `${targetW}x${targetH}` const resized = target !== lastTarget lastTarget = target - scene.resize(targetW, targetH) - from?.scene.resize(targetW, targetH) + scene.resize(targetW, targetH, !pixels) + from?.scene.resize(targetW, targetH, !pixels) // The scene advances at most once per timer interval, and not at all while paused; redraws in between (typing, UI // updates) reuse the last frame. const now = performance.now() @@ -727,6 +744,11 @@ export function createEngine( // Blending in the previous frame fades at the same speed per second whatever the frame rate. const persistence = interval > 1000 / FPS ? PERSISTENCE ** (interval / (1000 / FPS)) : PERSISTENCE paint(buf, from ? mixed : scene.pixels, scene.W, scene.H, advanced, pixels, desaturate, shade, persistence, scrim) + if (mapFresh) { + mapFresh = false + scene.textMap?.(textMap) + from?.scene.textMap?.(textMap) + } if (options.dump && !dumped && frames > 120) { dumped = true write(buf, options.dump) @@ -816,6 +838,19 @@ export function createEngine( if (open.length !== cells) open = new Uint8Array(cells) openW = W for (let i = 0; i < cells; i++) open[i] = surface[i] && char[i] === SPACE && mask[((i / W) | 0) * 2 * W + (i % W)] < OPEN ? 1 : 0 + if (frames % MAP_EVERY === 1) { + const cover = textMap.cover + cover.fill(0) + mapCount.fill(0) + for (let i = 0; i < cells; i++) { + const g = Math.floor((((i / W) | 0) * MAP_ROWS) / H) * MAP_COLS + Math.floor(((i % W) * MAP_COLS) / W) + cover[g] += surface[i] ? Math.min(1, mask[((i / W) | 0) * 2 * W + (i % W)] * look.reach) : 1 + mapCount[g]++ + } + for (let g = 0; g < cover.length; g++) cover[g] /= Math.max(1, mapCount[g]) + mapped = true + mapFresh = true + } const cursor = renderer.getCursorState() const cursorX = cursor.visible ? cursor.x - 1 : -1 @@ -881,7 +916,7 @@ export function createEngine( } if (ch === SPACE && !atCursor(x, y)) { if (octants) previousMask[i] = writeOctant(q, order, previousMask[i], char, fg, bg, i, o) - else writeQuadrant(q, char, fg, bg, i, o) + else previousMask[i] = writeQuadrant(q, previousMask[i], char, fg, bg, i, o) applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue @@ -909,6 +944,7 @@ export function createEngine( const created = next.create(chosen) if (busy) created.react?.("busy") if (failed) created.react?.("error") + if (mapped) created.textMap?.(textMap) return created } diff --git a/src/grade.ts b/src/grade.ts new file mode 100644 index 0000000..064a00e --- /dev/null +++ b/src/grade.ts @@ -0,0 +1,37 @@ +// Daytime depth for static backgrounds, applied once in layout(): aerial haze and broad ground patches. Rows are +// fractions of the height. +import { fbm2, lerp, smoothstep, type RGB } from "./math" + +// Aerial perspective: whatever was painted between rows `top` and y1 fades toward the sky behind it, by `far` down to +// row y0 (the horizon), easing to `near` at y1. `sky` is a copy of hdr taken right after the sky (and weather) was +// painted, so open sky is unchanged and distant shapes melt into the exact color around them. Below the horizon the +// sky copy holds the horizon color, which is what distant ground fades to. Call it after each layer to haze the +// farther ones more. +export function haze(hdr: Float32Array, sky: Float32Array, W: number, H: number, y0: number, y1: number, far: number, near = 0, top = 0) { + for (let y = Math.max(0, Math.floor(top * H)); y < Math.min(H, Math.ceil(y1 * H)); y++) { + const k = lerp(far, near, smoothstep(y0, y1, (y + 0.5) / H)) + if (k <= 0) continue + for (let o = y * W * 3; o < (y + 1) * W * 3; o++) hdr[o] += (sky[o] - hdr[o]) * k + } +} + +// Gentle, broad tone patches on the ground from the horizon at y0 down to y1: low-frequency noise laid out in +// perspective (patches flatten and shrink toward the horizon, where they fade out), tinting toward `warm` on one side +// and `cool` on the other. Both are multipliers near 1; `size` is a patch's width at the bottom, in screen heights. +export function mottle(hdr: Float32Array, W: number, H: number, y0: number, y1: number, size: number, warm: RGB, cool: RGB) { + const span = 1 - y0 + 0.04 + for (let y = Math.max(0, Math.ceil(y0 * H)); y < Math.min(H, Math.ceil(y1 * H)); y++) { + const v = (y + 0.5) / H + const z = Math.min(6, span / (v - y0 + 0.04)) + const fade = smoothstep(y0, y0 + 0.08, v) + for (let x = 0; x < W; x++) { + const n = Math.max(-1, Math.min(1, (fbm2(((x / H) * z) / size, (z * span) / size, 3) - 0.5) * 3.2)) * fade + const t = n > 0 ? warm : cool + const a = Math.abs(n) + const o = (y * W + x) * 3 + hdr[o] *= 1 + (t[0] - 1) * a + hdr[o + 1] *= 1 + (t[1] - 1) * a + hdr[o + 2] *= 1 + (t[2] - 1) * a + } + } +} diff --git a/src/light.ts b/src/light.ts new file mode 100644 index 0000000..bf1c6b7 --- /dev/null +++ b/src/light.ts @@ -0,0 +1,60 @@ +import { cap, type Part } from "./canvas" +import type { RGB } from "./math" + +// A pool of light from a lantern, lava, a window or glowing plankton, falling off over an ellipse (pixels) around +// (cx, cy). lift lights what is already there in the light's own color, in proportion to its brightest channel, so lit +// ground and creatures keep their shapes and take on the light's hue; glow adds a little haze of the color itself. +export function lightPool(hdr: Float32Array, W: number, H: number, cx: number, cy: number, rx: number, ry: number, color: RGB, lift: number, glow: number) { + const x0 = Math.max(0, Math.floor(cx - rx)) + const x1 = Math.min(W - 1, Math.ceil(cx + rx)) + const y0 = Math.max(0, Math.floor(cy - ry)) + const y1 = Math.min(H - 1, Math.ceil(cy + ry)) + const lr = color[0] * lift + const lg = color[1] * lift + const lb = color[2] * lift + const gr = color[0] * glow + const gg = color[1] * glow + const gb = color[2] * glow + for (let y = y0; y <= y1; y++) { + const dy = (y + 0.5 - cy) / ry + for (let x = x0; x <= x1; x++) { + const dx = (x + 0.5 - cx) / rx + const d = 1 - dx * dx - dy * dy + if (d <= 0) continue + const f = d * d + const o = (y * W + x) * 3 + // Capped, so foam, lamps and other things already glowing don't wash out to white. + const m = Math.min(0.2, Math.max(hdr[o], hdr[o + 1], hdr[o + 2])) + hdr[o] += (m * lr + gr) * f + hdr[o + 1] += (m * lg + gg) * f + hdr[o + 2] += (m * lb + gb) * f + } + } +} + +// The little pool of yellow-green light a firefly at (x, y) throws on the leaves and grass around it, k its blink. +export function fireflyLight(hdr: Float32Array, W: number, H: number, x: number, y: number, k: number) { + lightPool(hdr, W, H, x, y, 0.045 * H, 0.045 * H, [0.75, 1, 0.25], 4 * k, 0.012 * k) +} + +// A small campfire standing at (x, gy) in pixels, H pixels to a screen height: crossed logs and tongues of flame, each +// tongue its own part list so the hot core draws over the outer flames (flat, with shape light 0). flicker, about 0.8 +// to 1, scales the light the fire throws. +export function campfire(x: number, gy: number, H: number, t: number) { + const flicker = 0.85 + 0.1 * Math.sin(t * 7.1) + 0.05 * Math.sin(t * 13.3 + 1) + const log: RGB = [0.05, 0.025, 0.02] + const logs = [cap(x - 0.03 * H, gy, x + 0.025 * H, gy - 0.008 * H, 0.006 * H, 0.005 * H, log), cap(x + 0.03 * H, gy, x - 0.02 * H, gy - 0.01 * H, 0.006 * H, 0.005 * H, log)] + const flames: Part[][] = ( + [ + [-0.008, 0.036, 0.009, false], + [0.008, 0.03, 0.008, false], + [0, 0.046, 0.011, false], + [0, 0.026, 0.006, true], + ] as const + ).map(([dx, h, r, core]) => { + const sway = Math.sin(t * 3 + dx * 400) * 0.003 * H + const height = h * H * (0.8 + 0.2 * Math.sin(t * 5.3 + dx * 900 + h * 100)) * flicker + return [cap(x + dx * H, gy - 0.004 * H, x + dx * H + sway, gy - height, r * H, 0.0015 * H, core ? [2.6, 1.5, 0.3] : [2, 0.6, 0.08])] + }) + return { flicker, logs, flames } +} diff --git a/src/shadow.ts b/src/shadow.ts new file mode 100644 index 0000000..edfffd1 --- /dev/null +++ b/src/shadow.ts @@ -0,0 +1,125 @@ +// Shadows on the ground: a soft contact shadow right under a thing, and the shadow it casts away from the light. +// Scenes make one Shade per layout with sunShade() and call groundShadow() under each thing that stands on the ground, +// before drawing it: static things in layout(), moving things each frame. +import { clamp, type RGB } from "./math" +import type { Time } from "./wallpaper" + +// The ground is seen at a low angle, so a round footprint looks this much shallower than it is wide. +const SQUASH = 0.38 + +// How a scene's light throws shadows. Shadows multiply the ground toward tint, a dark color tinted by the sky that +// fills them, so they deepen the ground's own hue rather than graying it. +export interface Shade { + tint: RGB + // Strength of the soft patch right under a thing, and of the shadow it casts. + contact: number + cast: number + // Cast length per unit of height. + length: number + // A low sun throws shadows away from (x, y), in fractions of the width and height; a high sun or moon throws them + // all along (x, y). + x: number + y: number + radiate: boolean +} + +// Short, soft shadows by day; long ones at sunset, spreading away from the low sun; at night faint contact shadows and +// a hint of moon shadow. Under cloud the light is diffuse, so only contact shadows remain. orb is the sun or moon, in +// fractions of the width and height. +export function sunShade(time: Time, orb: { x: number; y: number }, covered = false): Shade { + const away = orb.x < 0.5 ? 1 : -1 + const looks: Record = { + day: { tint: [0.36, 0.44, 0.64], contact: 0.75, cast: 0.6, length: 0.6, x: away, y: 0.45, radiate: false }, + sunset: { tint: [0.32, 0.22, 0.42], contact: 0.7, cast: 0.75, length: 2.4, x: orb.x, y: orb.y, radiate: true }, + night: { tint: [0.35, 0.45, 0.85], contact: 0.45, cast: 0.2, length: 0.6, x: away, y: 0.45, radiate: false }, + } + const shade = looks[time] + return { ...shade, contact: covered ? shade.contact * 0.8 : shade.contact, cast: covered ? 0 : shade.cast } +} + +// The shadows of a thing w pixels wide and h tall standing at (x, y), the ground under its middle. tip is the width +// of the far end of its cast shadow relative to the near end: under 1 for things that narrow upward, over 1 for a tree +// with a wide crown. lift is how far it is off the ground in pixels: the contact shadow spreads and fades, and the cast +// shadow slides away. +export function groundShadow(hdr: Float32Array, W: number, H: number, shade: Shade, x: number, y: number, w: number, h: number, tip = 0.7, lift = 0) { + const [tr, tg, tb] = shade.tint + const up = clamp(lift / Math.max(1, h * 0.5), 0, 1) + // Centered a little in front of the feet: the half behind them is mostly hidden by the thing itself. + const r = w * 0.6 * (1 + up * 0.6) + const cy = y + r * SQUASH * 0.3 + if (shade.contact > 0 && up < 1) fillShadow(hdr, W, H, x, cy, x, cy, r, r, tr, tg, tb, shade.contact * (1 - up) ** 2, 1, 0.6) + if (shade.cast <= 0) return + const dx = shade.radiate ? x - shade.x * W : shade.x + const dy = shade.radiate ? Math.max(0, y - shade.y * H) : shade.y + const d = Math.hypot(dx, dy) || 1 + // Toward the viewer the ground is foreshortened, so the down-screen part of a shadow is shorter. + const ux = dx / d + const uy = (dy / d) * 0.5 + const near = lift * shade.length + const far = near + h * shade.length + fillShadow(hdr, W, H, x + ux * near, y + uy * near, x + ux * far, y + uy * far, w * 0.4, w * 0.4 * tip, tr, tg, tb, shade.cast * (1 - up * 0.5), 0.3, 0.55) +} + +// How deep the shadow on each pixel of a frame already is, per hdr buffer, so overlapping shadows deepen only to the +// strongest of them instead of stacking into dark patches. fixed holds the shadows painted in layout(), which every +// frame starts from; Canvas calls keepShadows() after layout() and clearShadows() in finish(). +const depth = new WeakMap() + +export function keepShadows(hdr: Float32Array) { + const d = depth.get(hdr) + if (!d) return + d.fixed = d.amount.slice() + d.dirty = false +} + +export function clearShadows(hdr: Float32Array) { + const d = depth.get(hdr) + if (!d?.dirty) return + d.amount.set(d.fixed) + d.dirty = false +} + +// Darkens a soft tapered capsule lying on the ground toward (tr, tg, tb): from (ax, ay) radius r0 to (bx, by) radius +// r1, in pixels, measured with y stretched by 1/SQUASH. Strength a at the near end fades to a * fade at the far end. +// soft is the share of the radius that is penumbra at the near end (1 fades from the middle); it widens to 1 at the far +// end. +export function fillShadow(hdr: Float32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, r0: number, r1: number, tr: number, tg: number, tb: number, a: number, fade: number, soft: number) { + if (a <= 0) return + let d = depth.get(hdr) + if (!d || d.amount.length !== W * H) { + d = { amount: new Float32Array(W * H), fixed: new Float32Array(W * H), dirty: false } + depth.set(hdr, d) + } + d.dirty = true + const amount = d.amount + const R = Math.max(r0, r1) + const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - R)) + const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + R)) + const y0 = Math.max(0, Math.floor(Math.min(ay, by) - R * SQUASH)) + const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + R * SQUASH)) + const dx = bx - ax + const dy = (by - ay) / SQUASH + const len2 = dx * dx + dy * dy + for (let y = y0; y <= y1; y++) { + const oy = (y + 0.5 - ay) / SQUASH + for (let x = x0; x <= x1; x++) { + const ox = x + 0.5 - ax + const t = len2 > 0 ? clamp((ox * dx + oy * dy) / len2, 0, 1) : 0 + const r = r0 + (r1 - r0) * t + const e = Math.hypot(ox - dx * t, oy - dy * t) / r + if (e >= 1) continue + // The penumbra widens along the shadow, away from what casts it. + const k = clamp((1 - e) / (soft + (1 - soft) * t), 0, 1) + const s = a * k * k * (3 - 2 * k) * (1 + (fade - 1) * t) + const p = y * W + x + const m = amount[p] + if (s <= m) continue + amount[p] = s + // Takes the pixel from the shadow it already has to this deeper one. + const i = p * 3 + hdr[i] *= (1 - s * (1 - tr)) / (1 - m * (1 - tr)) + hdr[i + 1] *= (1 - s * (1 - tg)) / (1 - m * (1 - tg)) + hdr[i + 2] *= (1 - s * (1 - tb)) / (1 - m * (1 - tb)) + } + } +} diff --git a/src/sky.ts b/src/sky.ts index fa2185a..6771848 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,5 +1,5 @@ // Sky pieces shared by outdoor wallpapers: a depth-graded sky with a sun or moon, stars and drifting clouds. -import { TAU, fbm1, lerp, smoothstep, type RGB } from "./math" +import { TAU, clamp, fbm1, fbm2, lerp, rand, smoothstep, type RGB } from "./math" // The sun or moon: x as a fraction of the width, y and r as fractions of the height. export interface Orb { @@ -29,13 +29,6 @@ export interface Star { tint: RGB } -export interface Cloud { - x: number - y: number - speed: number - puffs: { dx: number; dy: number; rx: number; ry: number }[] -} - // Fills the whole canvas with a vertical gradient through stops ([height fraction, color]), adds a glow around the orb // and draws the orb itself. export function paintSky(hdr: Float32Array, W: number, H: number, stops: [number, RGB][], orb: Orb) { @@ -86,56 +79,324 @@ export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[ } } -// Puffy cumulus (rounded, lit from above) or thin streaks, scattered between y0 and y1. -export function makeClouds(count: number, puffy: boolean, y0: number, y1: number): Cloud[] { - return Array.from({ length: count }, () => ({ - x: Math.random() * 2, - y: y0 + Math.random() * (y1 - y0), - speed: 0.003 + Math.random() * 0.004, - puffs: puffy - ? Array.from({ length: 6 }, (_, i) => ({ dx: (i / 5 - 0.5) * 0.16 + (Math.random() - 0.5) * 0.03, dy: -Math.sin((i / 5) * Math.PI) * 0.02 + (Math.random() - 0.5) * 0.01, rx: 0.03 + Math.random() * 0.025, ry: 0.022 + Math.random() * 0.014 })) - : Array.from({ length: 5 }, () => ({ dx: (Math.random() - 0.5) * 0.22, dy: (Math.random() - 0.5) * 0.015, rx: 0.06 + Math.random() * 0.08, ry: 0.008 + Math.random() * 0.01 })), - })) +// Puffs heaped into rounded cumulus with flat bases, taller towering cumulus that stand on y, high cirrus streaks with +// hooked ends, soft wisps, low stratus layers, and billowing volcanic ash. +export type CloudKind = "cumulus" | "towering" | "cirrus" | "wisp" | "stratus" | "ash" + +export interface Cloud { + x: number + y: number + speed: number + kind: CloudKind | "shadow" + // Half the width in screen heights, so the cloud wraps around only once it is off screen. + w: number + // Puffs (x, y, radius r) or strands (x, y, half-length r, half-thickness s, tilt t) relative to the cloud's position, + // in screen heights, and the flat base puffs are cut off at. + parts: { x: number; y: number; r: number; s: number; t: number }[] + base: number + seed: number + sprite?: Sprite +} + +// A cloud's shape, built once per screen size: opacity, surface normal, thickness (0 at the edge, 1 deep inside) and +// how much its edge glows when backlit, per pixel. (x0, y0) is its top-left pixel relative to the cloud's position and +// cy its middle in screen heights. +interface Sprite { + H: number + w: number + h: number + x0: number + y0: number + cy: number + thin: number + a: Float32Array + nx: Float32Array + ny: Float32Array + nz: Float32Array + d: Float32Array + e: Float32Array +} + +// Opacity ramps over edge (field values) up to alpha; thin clouds are lit nearly flat; size is the rough half-width. +const KINDS: Record = { + cumulus: { size: [0.08, 0.13], speed: [0.003, 0.007], edge: [0.02, 0.28], alpha: 1, thin: 0 }, + towering: { size: [0.09, 0.14], speed: [0, 0], edge: [0.02, 0.28], alpha: 0.85, thin: 0 }, + ash: { size: [0.1, 0.16], speed: [0.004, 0.007], edge: [0, 0.6], alpha: 0.8, thin: 0 }, + cirrus: { size: [0.14, 0.24], speed: [0.005, 0.009], edge: [0.05, 0.8], alpha: 0.9, thin: 0.7 }, + wisp: { size: [0.12, 0.2], speed: [0.003, 0.006], edge: [0.04, 0.85], alpha: 0.75, thin: 0.6 }, + stratus: { size: [0.25, 0.45], speed: [0.002, 0.004], edge: [0.12, 0.75], alpha: 0.75, thin: 0.5 }, + shadow: { size: [0.2, 0.45], speed: [0.004, 0.01], edge: [0, 0.8], alpha: 1, thin: 0 }, +} + +export function makeClouds(count: number, kind: CloudKind, y0: number, y1: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const size = rand(KINDS[kind].size[0], KINDS[kind].size[1]) + const [parts, base] = shapeParts(kind, size) + return { x: ((i + Math.random() * 0.8) / count) * 2, y: y0 + Math.random() * (y1 - y0), speed: rand(KINDS[kind].speed[0], KINDS[kind].speed[1]), kind, w: halfWidth(parts), parts, base, seed: Math.random() * 100 } + }) +} + +// Soft shadows of clouds drifting across the ground between y0 and y1, flatter toward the horizon and bigger and +// faster nearer. +export function makeShadows(count: number, y0: number, y1: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const near = (i + Math.random()) / count + const w = lerp(KINDS.shadow.size[0], KINDS.shadow.size[1], near) + const parts = [{ x: 0, y: 0, r: w, s: w * (0.12 + 0.12 * near), t: 0 }] + return { x: Math.random() * 2, y: lerp(y0, y1, near), speed: lerp(KINDS.shadow.speed[0], KINDS.shadow.speed[1], near), kind: "shadow" as const, w, parts, base: Infinity, seed: Math.random() * 100 } + }) } export function driftClouds(clouds: Cloud[], A: number, dt: number) { for (const c of clouds) { c.x += c.speed * dt - if (c.x > A + 0.4) c.x = -0.4 + if (c.x > A + c.w) c.x = -c.w } } // Low, heavy clouds that gather after the agent fails: darker versions of the scene's own cloud colors, faded in by // gloom (0 to 1). They drift like the others, so the caller moves them with driftClouds. export function makeStorm(): Cloud[] { - return makeClouds(8, true, 0.04, 0.26) + return makeClouds(8, "cumulus", 0.04, 0.26) } export function paintStorm(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, gloom: number) { if (gloom <= 0) return - paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8, true) -} - -// Clouds shaded from top to bottom; puffy ones are nearly opaque, streaks are thin and soft. -export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, puffy: boolean) { - for (const c of clouds) - for (const p of c.puffs) { - const cx = (c.x + p.dx) * H - const cy = (c.y + p.dy) * H - const rx = p.rx * H - const ry = p.ry * H - for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) - for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { - const dx = (x + 0.5 - cx) / rx - const dy = (y + 0.5 - cy) / ry - const q = dx * dx + dy * dy - if (q >= 1) continue - const under = smoothstep(-1, 1, dy) - const o = (y * W + x) * 3 - const a = (puffy ? Math.min(1, (1 - q) * 3) : (1 - q) ** 1.5) * alpha - hdr[o] += (lerp(top[0], bottom[0], under) - hdr[o]) * a - hdr[o + 1] += (lerp(top[1], bottom[1], under) - hdr[o + 1]) * a - hdr[o + 2] += (lerp(top[2], bottom[2], under) - hdr[o + 2]) * a - } + paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8) +} + +// Clouds lit by the sun or moon: the brighter of top and bottom is the side facing the light, the darker the side away +// from it. Near the orb they are backlit, with darker cores and glowing edges (gold near the moon, the clouds' own tint +// elsewhere at night). Without an orb, under an overcast deck, they are lit evenly from above. +export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, orb?: Orb) { + const [lit, shade] = luma(top) >= luma(bottom) ? [top, bottom] : [bottom, top] + const low = !!orb && !orb.moon && orb.y > 0.3 + // How much light comes from the open sky above rather than from the orb, and how much every edge facing it glows. + const sky = !orb ? 1 : orb.moon ? 0.25 : low ? 0 : 0.35 + const edge = !orb ? 0 : orb.moon ? 0.6 : low ? 0.3 : 0.15 + const k = !orb ? 0 : orb.moon ? 1.6 : low ? 1.4 : 0.6 + for (const c of clouds) { + const s = spriteFor(c, H) + const dx = orb ? orb.x * (W / H) - c.x : 0 + const dy = orb ? orb.y - c.y - s.cy : -1 + const dist = Math.hypot(dx, dy) || 1 + const back = orb ? Math.exp(-dist / 0.3) : 0 + const glow = orb?.glow ?? lit + const m = orb?.moon ? back : 1 + const rim: RGB = [lerp(lit[0] * 2.5, glow[0] * k, m), lerp(lit[1] * 2.5, glow[1] * k, m), lerp(lit[2] * 2.5, glow[2] * k, m)] + drawCloud(hdr, W, H, s, c.x * H, c.y * H, lit, shade, rim, dx / dist, dy / dist, back, edge, sky, alpha) + } +} + +// Darkens the ground under each shadow, below ground (a fraction of the height), and only under a high sun: pass no orb +// under an overcast deck. +export function paintShadows(hdr: Float32Array, W: number, H: number, shadows: Cloud[], ground: number, orb?: Orb) { + if (!orb || orb.moon || orb.y > 0.3) return + for (const c of shadows) drawShadow(hdr, W, H, spriteFor(c, H), c.x * H, c.y * H, ground * H) +} + +// A static band of haze between y0 and y1, thickest low down, in long soft streaks; paint it into the background. +export function paintHaze(hdr: Float32Array, W: number, H: number, y0: number, y1: number, color: RGB, alpha: number) { + for (let y = Math.max(0, Math.floor(y0 * H)); y < Math.min(H, Math.ceil(y1 * H)); y++) { + const v = (y / H - y0) / (y1 - y0) + const band = smoothstep(0, 1, v) * (1 - smoothstep(0.92, 1, v) * 0.5) * alpha + for (let x = 0; x < W; x++) { + const a = Math.min(1, band * (0.55 + 0.9 * fbm2((x / H) * 2.5, (y / H) * 18, 3))) + const o = (y * W + x) * 3 + hdr[o] += (color[0] - hdr[o]) * a + hdr[o + 1] += (color[1] - hdr[o + 1]) * a + hdr[o + 2] += (color[2] - hdr[o + 2]) * a } + } +} + +function luma(c: RGB) { + return c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722 +} + +function shapeParts(kind: CloudKind, size: number): [Cloud["parts"], number] { + const part = (x: number, y: number, r: number, s = 0, t = 0) => ({ x, y, r, s, t }) + if (kind === "cumulus") { + // A row of puffs, biggest in the middle, heaped higher on top and cut off flat along the base. + const h = size * rand(0.6, 0.8) + const n = 5 + Math.floor(Math.random() * 3) + const row = Array.from({ length: n }, (_, i) => { + const f = (i / (n - 1)) * 2 - 1 + const r = h * (0.6 - 0.3 * Math.abs(f)) * rand(0.7, 1.2) + return part(f * size * 0.7 + rand(-0.06, 0.06) * size, h * 0.35 - r * rand(0.5, 0.95), r) + }) + const heap = Array.from({ length: 2 + Math.floor(Math.random() * 2) }, () => part(rand(-0.4, 0.4) * size, -h * rand(0.35, 0.55), h * rand(0.3, 0.42))) + return [[...row, ...heap], h * 0.35] + } + if (kind === "towering") { + // A broad foot on the horizon under a leaning column of puffs that narrows as it climbs. + const h = size * rand(1.4, 1.9) + const lean = rand(-0.25, 0.25) * size + const foot = Array.from({ length: 5 }, (_, i) => part(((i / 4) * 2 - 1) * size * 0.72 + rand(-0.05, 0.05) * size, -size * rand(0.1, 0.2), size * rand(0.26, 0.36))) + const column = Array.from({ length: 9 }, (_, i) => { + const f = (i + 1) / 9 + const r = size * (0.5 - 0.22 * f) * rand(0.8, 1.1) + return part(lean * f + rand(-0.3, 0.3) * size * (1 - 0.5 * f), -h * f * 0.75, r) + }) + return [[...foot, ...column], 0] + } + // Ash spreads out downwind in a long, low smear of small billows. + if (kind === "ash") return [Array.from({ length: 11 }, () => part(rand(-0.8, 0.8) * size, rand(-0.12, 0.12) * size, size * rand(0.16, 0.3))), Infinity] + if (kind === "cirrus") return [Array.from({ length: 3 + Math.floor(Math.random() * 2) }, () => part(rand(-0.35, 0.35) * size, rand(-0.03, 0.03), size * rand(0.45, 0.7), rand(0.007, 0.012), rand(-0.1, 0.02))), Infinity] + if (kind === "wisp") return [Array.from({ length: 2 + Math.floor(Math.random() * 2) }, () => part(rand(-0.3, 0.3) * size, rand(-0.012, 0.012), size * rand(0.55, 0.85), rand(0.007, 0.012), rand(-0.05, 0.05))), Infinity] + return [Array.from({ length: 3 }, () => part(rand(-0.25, 0.25) * size, rand(-0.01, 0.01), size * rand(0.65, 0.9), rand(0.013, 0.022))), Infinity] +} + +function halfWidth(parts: Cloud["parts"]) { + return parts.reduce((m, p) => Math.max(m, Math.abs(p.x) + p.r * 1.12), 0) +} + +function spriteFor(c: Cloud, H: number) { + if (c.sprite?.H !== H) c.sprite = buildSprite(c, H) + return c.sprite +} + +function buildSprite(c: Cloud, H: number): Sprite { + const kind = KINDS[c.kind] + const puffy = c.kind === "cumulus" || c.kind === "towering" || c.kind === "ash" + // Noise can push a puff's edge out a little past its radius. + const grow = puffy ? 1.12 : 1 + const x0 = Math.floor((-c.w - 0.01) * H) + const top = c.parts.reduce((m, p) => Math.min(m, p.y - (puffy ? p.r * grow : p.s * 4 + Math.abs(p.t) * p.r + (c.kind === "cirrus" ? 0.02 : 0))), 0) + const bottom = c.parts.reduce((m, p) => Math.max(m, Math.min(c.base, p.y + (puffy ? p.r * grow : p.s * 4 + Math.abs(p.t) * p.r))), 0) + const y0 = Math.floor(top * H) - 1 + const w = Math.ceil((c.w + 0.01) * H) - x0 + const h = Math.ceil(bottom * H) + 1 - y0 + const n = w * h + const field = new Float32Array(n) + const a = new Float32Array(n) + const d = new Float32Array(n) + const [lo, hi] = kind.edge + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + const u = (x0 + i + 0.5) / H + const v = (y0 + j + 0.5) / H + const f = shapeAt(c, u, v, puffy) + const k = j * w + i + field[k] = f + a[k] = smoothstep(lo, hi, f) * kind.alpha + d[k] = smoothstep(lo, 1, f) + } + // Normals from the blurred field, so each puff reads as a dome. + const r = Math.max(1, Math.round(H * 0.01)) + const blurred = boxBlur(boxBlur(field, w, h, r, 1, 0), w, h, r, 0, 1) + const nx = new Float32Array(n) + const ny = new Float32Array(n) + const nz = new Float32Array(n) + const e = new Float32Array(n) + const scale = H * 0.02 + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + const k = j * w + i + const gx = -(blurred[j * w + Math.min(w - 1, i + 1)] - blurred[j * w + Math.max(0, i - 1)]) * scale + const gy = -(blurred[Math.min(h - 1, j + 1) * w + i] - blurred[Math.max(0, j - 1) * w + i]) * scale + const l = Math.hypot(gx, gy, 1) + nx[k] = gx / l + ny[k] = gy / l + nz[k] = 1 / l + e[k] = (1 - d[k]) ** 2 * Math.sqrt(a[k]) + } + return { H, w, h, x0, y0, cy: (y0 + h / 2) / H, thin: kind.thin, a, nx, ny, nz, d, e } +} + +// The cloud's density at (u, v) relative to its position: the strongest puff or strand, broken up by noise at about the +// size of a character cell so edges fray softly instead of tracing ellipses. +function shapeAt(c: Cloud, u: number, v: number, puffy: boolean) { + let f = -1 + for (const p of c.parts) { + if (puffy) { + f = Math.max(f, 1 - ((u - p.x) ** 2 + (v - p.y) ** 2) / (p.r * p.r)) + continue + } + const along = (u - p.x) / p.r + if (along <= -1 || along >= 1) continue + // Cirrus curl up at the downwind end into hooks and fan out toward the other; stratus swell and thin along their + // length, with ragged ends. + const cirrus = c.kind === "cirrus" + const dv = v - p.y - p.t * (u - p.x) + (cirrus ? 0.02 * Math.max(0, along) ** 3 : 0) + const s = p.s * (cirrus ? 1.3 - 0.6 * along : 0.5 + 1.1 * fbm1(u * 6 + p.x * 50, 3)) + const ends = 1 - along * along + (c.kind === "stratus" ? (fbm1(v * 60 + p.y * 90, 2) - 0.45) * 0.8 : 0) + f = Math.max(f, ends * Math.exp(-((dv / s) ** 2))) + } + const seed = c.seed + // Fraying only near the edge keeps the insides solid. + const fray = (fbm2(u * 28 + seed, v * 28 + seed * 0.7, 3) - 0.44) * 2.4 * (1 - clamp(f, 0, 1)) + const base = c.base + (fbm1(u * 40 + seed, 3) - 0.5) * 0.008 + if (c.kind === "cumulus") return (f + fray) * smoothstep(base + 0.004, base - 0.01, v) + if (c.kind === "towering") return (f + fray * 0.8) * smoothstep(0.002, -0.008, v) + if (c.kind === "ash") return f + fray * 1.2 + // Fibers run along cirrus and wisps. + if (c.kind === "cirrus") return f * clamp((fbm2(u * 5 + seed, v * 70, 3) - 0.25) * 2.6, 0, 1.3) + if (c.kind === "wisp") return f * (0.55 + 0.9 * fbm2(u * 5 + seed, v * 45, 3)) + if (c.kind === "stratus") return f + fray * smoothstep(0, 0.2, f) + return f * (0.6 + 0.8 * fbm2(u * 6 + seed, v * 18, 2)) +} + +// A box blur of radius r along (dx, dy): (1, 0) for rows, (0, 1) for columns. +function boxBlur(src: Float32Array, w: number, h: number, r: number, dx: number, dy: number) { + const out = new Float32Array(src.length) + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + let sum = 0 + for (let t = -r; t <= r; t++) sum += src[clamp(j + t * dy, 0, h - 1) * w + clamp(i + t * dx, 0, w - 1)] + out[j * w + i] = sum / (2 * r + 1) + } + return out +} + +// One cloud's sprite at (x, y) in pixels, sliding smoothly between pixel columns. lx, ly point toward the orb; back is +// how far the cloud is backlit by it and edge how much every edge facing it glows anyway. +function drawCloud(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number, lit: RGB, shade: RGB, rim: RGB, lx: number, ly: number, back: number, edge: number, sky: number, alpha: number) { + const l = Math.hypot(lx, ly, 0.4) + const Lx = lx / l + const Ly = ly / l + const Lz = 0.4 / l + const px = x + s.x0 + const X0 = Math.floor(px) + const f = px - X0 + const Y0 = Math.round(y) + s.y0 + const sw = s.w + for (let j = Math.max(0, -Y0); j < Math.min(s.h, H - Y0); j++) + for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { + const k = j * sw + i + const a = ((i < sw ? s.a[k] : 0) * (1 - f) + (i > 0 ? s.a[k - 1] : 0) * f) * alpha + if (a < 0.003) continue + const q = i < sw ? k : k - 1 + const nx = s.nx[q] + const ny = s.ny[q] + const light = (Math.max(0, nx * Lx + ny * Ly + s.nz[q] * Lz) * (1 - sky) + (0.5 - 0.5 * ny) * sky) * (1 - 0.5 * back * s.d[q]) + const t = light + (1 - light) * s.thin + const g = (back + edge) * s.e[q] * Math.max(0, nx * lx + ny * ly) * alpha + const o = ((Y0 + j) * W + X0 + i) * 3 + hdr[o] += (shade[0] + (lit[0] - shade[0]) * t - hdr[o]) * a + rim[0] * g + hdr[o + 1] += (shade[1] + (lit[1] - shade[1]) * t - hdr[o + 1]) * a + rim[1] * g + hdr[o + 2] += (shade[2] + (lit[2] - shade[2]) * t - hdr[o + 2]) * a + rim[2] * g + } +} + +function drawShadow(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number, ground: number) { + const px = x + s.x0 + const X0 = Math.floor(px) + const f = px - X0 + const Y0 = Math.round(y) + s.y0 + const sw = s.w + const fade = H * 0.03 + for (let j = Math.max(0, -Y0, Math.floor(ground) - Y0); j < Math.min(s.h, H - Y0); j++) { + const row = clamp((Y0 + j - ground) / fade, 0, 1) * 0.38 + for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { + const k = j * sw + i + const a = ((i < sw ? s.a[k] : 0) * (1 - f) + (i > 0 ? s.a[k - 1] : 0) * f) * row + if (a < 0.003) continue + // Shade is lit by the blue sky, so it darkens reds most. + const o = ((Y0 + j) * W + X0 + i) * 3 + hdr[o] *= 1 - a + hdr[o + 1] *= 1 - a * 0.88 + hdr[o + 2] *= 1 - a * 0.62 + } + } } diff --git a/src/wallpaper.ts b/src/wallpaper.ts index f0fbdea..0332090 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -2,12 +2,13 @@ import type { RGB } from "./math" // A wallpaper's animation. Each frame the engine resizes the scene to the sub-pixel grid it needs, steps it by the // elapsed seconds, renders it, and reads `pixels` (RGBA, W x H; only resize may replace the array). Extending Canvas -// provides everything but step and render. +// provides everything but step and render. resize's cells is set when each pixel becomes one sub-pixel of a terminal +// character cell, fitted to two colors per cell, so the scene can draw bolder. export interface Scene { readonly W: number readonly H: number readonly pixels: Uint8Array - resize(W: number, H: number): void + resize(W: number, H: number, cells?: boolean): void step(dt: number): void render(): void // Optional: what the OpenCode agent is doing. Scenes bring on a rare visitor when a task is done and turn overcast @@ -16,6 +17,17 @@ export interface Scene { react?(event: AgentEvent): void // Optional reaction to a click on open background, in screen heights: x runs 0..W/H across and y 0..1 down. poke?(x: number, y: number): void + // Optional: where OpenCode's text sits, sent when the scene starts and about once a second after that, so creatures + // can keep to open ground and visitors can come on where they'll be seen. + textMap?(map: TextMap): void +} + +// A coarse grid over the whole screen, row-major: how much each cell is covered by text, its scrim or a panel, from 0 +// (open) to 1 (hidden). +export interface TextMap { + readonly cols: number + readonly rows: number + readonly cover: Float32Array } export const AGENT_EVENTS = ["busy", "idle", "done", "error"] as const diff --git a/src/water.ts b/src/water.ts new file mode 100644 index 0000000..65b6770 --- /dev/null +++ b/src/water.ts @@ -0,0 +1,33 @@ +import type { RGB } from "./math" + +let wave = new Float32Array(0) + +// Mirrors the scene into water rows y0 to y1. Row y shows row rows[y - y0] of the same buffer (outside those rows), +// tinted, wobbled by ripples of up to amp[y - y0] pixels that drift with time, and mixed in by weight (one value per +// pixel of those rows; 0 leaves a pixel alone). With still (those rows as the water was drawn), pixels painted over +// since are left alone, so whatever swims or stands in front of the water stays in front of its reflection. +export function reflect(hdr: Float32Array, W: number, H: number, y0: number, y1: number, rows: Float32Array, amp: Float32Array, weight: Float32Array, tint: RGB, time: number, still?: Float32Array) { + if (wave.length !== W) wave = new Float32Array(W) + for (let x = 0; x < W; x++) wave[x] = 2.4 * Math.sin((x / H) * 9 + time * 0.35) + 1.2 * Math.sin((x / H) * 23 - time * 0.6) + for (let y = Math.max(0, y0); y < Math.min(H, y1); y++) { + const r = y - y0 + const a = amp[r] + const phase = (y / H) * 150 + time * 1.2 + for (let x = 0; x < W; x++) { + const w = weight[r * W + x] + if (w <= 0) continue + const o = (y * W + x) * 3 + const s = (r * W + x) * 3 + if (still && (hdr[o] !== still[s] || hdr[o + 1] !== still[s + 1] || hdr[o + 2] !== still[s + 2])) continue + const p = phase + wave[x] + const sx = Math.min(W - 1.001, Math.max(0, x + a * Math.sin(p))) + const sy = Math.min(H - 1, Math.max(0, Math.round(rows[r] + a * 0.6 * Math.cos(p)))) + const x0 = sx | 0 + const f = sx - x0 + const m = (sy * W + x0) * 3 + hdr[o] += ((hdr[m] + (hdr[m + 3] - hdr[m]) * f) * tint[0] - hdr[o]) * w + hdr[o + 1] += ((hdr[m + 1] + (hdr[m + 4] - hdr[m + 1]) * f) * tint[1] - hdr[o + 1]) * w + hdr[o + 2] += ((hdr[m + 2] + (hdr[m + 5] - hdr[m + 2]) * f) * tint[2] - hdr[o + 2]) * w + } + } +} diff --git a/src/wind.ts b/src/wind.ts new file mode 100644 index 0000000..a9501a7 --- /dev/null +++ b/src/wind.ts @@ -0,0 +1,160 @@ +import { hash, smoothstep } from "./math" + +// One wind for the whole scene, so everything that sways moves together. Now and then a gust rolls across the screen +// from left to right and dies away; between gusts the air is calm. x is in screen heights from the left edge and t in +// scene seconds, so a scene passes its own time and every scene shares the same weather. + +// Scene seconds between chances of a gust, how fast one crosses the screen (screen heights per scene second), and how +// long it takes to build and to die away at any one spot. +const SLOT = 14 +const SPEED = 0.45 +const RISE = 1.1 +const FALL = 3.2 +// Lines the gusts up so one crosses in the first few seconds of a scene and another about 45 seconds in, inside the +// frames dev/check.ts measures and the README's GIFs. +const PHASE = 15.5 + +// How hard a gust is blowing at x: 0 in calm air, up to 1 at its height. +export function gust(x: number, t: number) { + const s = t - x / SPEED + PHASE + const n = Math.floor(s / SLOT) + // About a third of the chances pass without a gust, so they don't come like clockwork. + const strength = hash(n * 7.31 + 2.1) + if (strength < 0.3) return 0 + const u = s - n * SLOT - hash(n * 3.17 + 0.5) * (SLOT - RISE - FALL) + if (u <= 0 || u >= RISE + FALL) return 0 + return (u < RISE ? smoothstep(0, RISE, u) : 1 - smoothstep(RISE, RISE + FALL, u)) * (0.6 + 0.4 * (strength - 0.3) / 0.7) +} + +// How far a plant leans, in units of its own sway: the same gentle rocking as ever in calm air, then a lean downwind +// (positive x) with livelier rocking as a gust passes. phase keeps neighbors out of step and rate is how fast it rocks. +export function sway(x: number, t: number, phase = 0, rate = 0.7) { + const g = gust(x, t) + return Math.sin(t * rate + phase) * (1 + 0.4 * g) + g * (1.65 + 0.22 * Math.sin(t * rate * 2.7 + phase * 1.7)) +} + +// Plants painted once into a static background that still bend in the gusts: the pixels they cover shift downwind +// row by row, more toward the tips, over what was painted behind them. Call begin() in layout() before painting them, +// end() once they're painted, seal() at the end of layout() if anything was painted over them since, and draw() right +// after copying the background each frame. +export class SwayLayer { + private before = new Float32Array(0) + private x0 = 0 + private y0 = 0 + private w = 0 + private h = 0 + private reach = 0 + private bare = new Float32Array(0) + private layer = new Float32Array(0) + // 1 where the plants are, 2 where something since painted over them hides them, 0 elsewhere. + private cover = new Uint8Array(0) + private weight = new Float32Array(0) + private shift = new Float32Array(0) + + // dir -1 sends the gusts the other way, right to left, for a scene whose breeze already blows that way. + constructor(private readonly dir = 1) {} + + begin(hdr: Float32Array) { + this.before = hdr.slice() + } + + // reach is about how far the tips move in the strongest gust, in pixels; weight(x, y) in pixels is how much of that a pixel + // moves, from 0 at the roots to 1 at the tips. + end(hdr: Float32Array, W: number, H: number, reach: number, weight: (x: number, y: number) => number) { + const before = this.before + this.before = new Float32Array(0) + let x0 = W + let y0 = H + let x1 = -1 + let y1 = -1 + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3 + if (hdr[o] === before[o] && hdr[o + 1] === before[o + 1] && hdr[o + 2] === before[o + 2]) continue + if (weight(x, y) <= 0) continue + x0 = Math.min(x0, x) + x1 = Math.max(x1, x) + y0 = Math.min(y0, y) + y1 = Math.max(y1, y) + } + this.w = 0 + if (x1 < 0) return + const pad = Math.ceil(reach) + 1 + this.x0 = Math.max(0, x0 - pad) + this.y0 = y0 + this.w = Math.min(W - 1, x1 + pad) - this.x0 + 1 + this.h = y1 - y0 + 1 + this.reach = reach + const n = this.w * this.h + this.bare = new Float32Array(n * 3) + this.layer = new Float32Array(n * 3) + this.cover = new Uint8Array(n) + this.weight = new Float32Array(n) + this.shift = new Float32Array(this.w) + for (let y = 0; y < this.h; y++) + for (let x = 0; x < this.w; x++) { + const i = y * this.w + x + const o = ((this.y0 + y) * W + this.x0 + x) * 3 + for (let c = 0; c < 3; c++) { + this.bare[i * 3 + c] = before[o + c] + this.layer[i * 3 + c] = hdr[o + c] + } + this.cover[i] = hdr[o] !== before[o] || hdr[o + 1] !== before[o + 1] || hdr[o + 2] !== before[o + 2] ? 1 : 0 + this.weight[i] = Math.max(0, weight(this.x0 + x, this.y0 + y)) + } + } + + // Pixels painted since end() stay put over the plants. + seal(hdr: Float32Array, W: number) { + for (let y = 0; y < this.h; y++) + for (let x = 0; x < this.w; x++) { + const i = y * this.w + x + const o = ((this.y0 + y) * W + this.x0 + x) * 3 + if (hdr[o] !== this.layer[i * 3] || hdr[o + 1] !== this.layer[i * 3 + 1] || hdr[o + 2] !== this.layer[i * 3 + 2]) this.cover[i] = 2 + } + } + + // How far the pixel at (x, y) was carried downwind in the last draw(), for things hung on the plants. + offset(x: number, y: number) { + const i = Math.round(x) - this.x0 + const j = Math.round(y) - this.y0 + if (i < 0 || j < 0 || i >= this.w || j >= this.h) return 0 + return this.shift[i] * this.weight[j * this.w + i] + } + + draw(hdr: Float32Array, W: number, H: number, t: number) { + if (!this.w) return + let any = false + for (let x = 0; x < this.w; x++) { + const u = (this.dir > 0 ? this.x0 + x : W - this.x0 - x) / H + const g = gust(u, t) + // A little flutter on top of the lean, so the tips don't move as one stiff sheet. + this.shift[x] = this.dir * g * this.reach * (0.64 + 0.19 * Math.sin(t * 2.3 + u * 9)) + if (g > 0) any = true + } + if (any) shear(hdr, W, this.x0, this.y0, this.w, this.h, this.bare, this.layer, this.cover, this.weight, this.shift) + } +} + +// Redraws the layer's region with each pixel taken from `shift * weight` pixels upwind, over what lies behind it. +function shear(hdr: Float32Array, W: number, x0: number, y0: number, w: number, h: number, bare: Float32Array, layer: Float32Array, cover: Uint8Array, weight: Float32Array, shift: Float32Array) { + for (let y = 0; y < h; y++) { + const row = y * w + for (let x = 0; x < w; x++) { + const i = row + x + if (cover[i] === 2) continue + const d = shift[x] * weight[i] + if (d < 0.02 && d > -0.02) continue + const sx = x - d + const xa = Math.floor(sx) + const f = sx - xa + const a = xa >= 0 && cover[row + xa] ? 1 - f : 0 + const b = xa + 1 < w && xa + 1 >= 0 && cover[row + xa + 1] ? f : 0 + if (a + b === 0 && cover[i] === 0) continue + const o = ((y0 + y) * W + x0 + x) * 3 + const k = 1 - a - b + const ia = (row + xa) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] = bare[i * 3 + c] * k + (a ? layer[ia + c] * a : 0) + (b ? layer[ia + 3 + c] * b : 0) + } + } +} diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f977204..5c5c3b6 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -1,10 +1,16 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" +import { limb, stride } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" import { bird, flyAway, startle, type Flier } from "../src/flock" -import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { campfire, lightPool } from "../src/light" +import { TAU, clamp, fbm1, hash, hash2, lerp, noise1, rand, smoothstep, type RGB } from "../src/math" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" +import { sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -13,6 +19,9 @@ const CREATURE_SPEED = 0.25 const HORIZON = 0.5 // How long the message in a bottle stays, in scene seconds. const BOTTLE = 95 +// How long a ring on the water and a dolphin's glowing wake under it last, in scene seconds. +const RING = 4 +const WAKE = 7 // What changes with the time of day. Sea, sand and foam get their own colors; at night the foam glows with // bioluminescence instead of turning white. Objects and animals have one daytime color each, darkened by tint. @@ -22,11 +31,13 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } sea: [far: RGB, near: RGB] sand: [dry: RGB, wet: RGB] foam: RGB glitter: number + // The tint of what the sea mirrors. + mirror: RGB tint: RGB night: boolean } @@ -42,58 +53,62 @@ const LOOKS: Record = { ], orb: { x: 0.18, y: 0.14, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.68, 0.8], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.68, 0.8], alpha: 0.9 }, sea: [ - [0.12, 0.42, 0.62], - [0.08, 0.6, 0.62], + [0.04, 0.26, 0.56], + [0.03, 0.54, 0.6], ], sand: [ - [0.86, 0.76, 0.55], - [0.62, 0.52, 0.36], + [0.8, 0.65, 0.43], + [0.5, 0.4, 0.27], ], foam: [0.92, 0.96, 1], glitter: 0.15, + mirror: [0.82, 0.9, 0.95], tint: [1, 1, 1], night: false, }, sunset: { style: "rim", - light: [1, 0.5, 0.2], + // Pastel: a teal sky blushing pink toward the horizon, and a sea lit pink far out, teal near the shore. + light: [1, 0.6, 0.62], sky: [ - [0, [0.03, 0.02, 0.08]], - [0.22, [0.12, 0.04, 0.12]], - [0.38, [0.45, 0.14, 0.14]], - [HORIZON, [1, 0.5, 0.18]], + [0, [0.03, 0.1, 0.14]], + [0.2, [0.1, 0.24, 0.3]], + [0.36, [0.6, 0.32, 0.5]], + [HORIZON, [1, 0.5, 0.55]], ], - orb: { x: 0.55, y: 0.475, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, - stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + orb: { x: 0.55, y: 0.465, r: 0.05, core: [2.6, 1.4, 1.2], glow: [1, 0.5, 0.58], near: 0.45, wide: 0.22 }, + stars: 12, + clouds: { top: [0.2, 0.2, 0.3], bottom: [1, 0.5, 0.62], alpha: 0.8 }, sea: [ - [0.55, 0.25, 0.18], - [0.12, 0.07, 0.12], + [0.8, 0.36, 0.5], + [0.04, 0.24, 0.27], ], sand: [ - [0.4, 0.22, 0.15], - [0.25, 0.13, 0.1], + [0.4, 0.22, 0.27], + [0.2, 0.13, 0.19], ], - foam: [1, 0.62, 0.42], + foam: [1, 0.76, 0.8], glitter: 1, - tint: [0.22, 0.13, 0.1], + mirror: [0.9, 0.78, 0.72], + tint: [0.2, 0.16, 0.22], night: false, }, night: { style: "rim", - light: [0.2, 0.45, 1], + light: [0.3, 0.62, 1.5], sky: [ [0, [0.004, 0.008, 0.03]], - [0.3, [0.012, 0.02, 0.06]], - [HORIZON, [0.04, 0.06, 0.15]], + [0.25, [0.014, 0.024, 0.07]], + [0.4, [0.035, 0.055, 0.14]], + [HORIZON, [0.07, 0.1, 0.24]], ], orb: { x: 0.7, y: 0.16, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, sea: [ - [0.03, 0.06, 0.15], + [0.045, 0.085, 0.2], [0.01, 0.03, 0.07], ], sand: [ @@ -102,7 +117,8 @@ const LOOKS: Record = { ], foam: [0.1, 0.85, 1.1], glitter: 0.7, - tint: [0.025, 0.04, 0.09], + mirror: [0.75, 0.9, 1], + tint: [0.02, 0.032, 0.075], night: true, }, } @@ -155,6 +171,8 @@ interface Dolphin { y: number dir: number wait: number + // Scene seconds since it dived back in, while it swims on under the surface; negative once out of sight. + under: number } class Beach extends Canvas { @@ -163,14 +181,23 @@ class Beach extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] + // Towering cumulus far out on the sea horizon, painted into the background, and shadows of the near clouds. + private towers = makeClouds(3, "towering", HORIZON, HORIZON) + private shadows = makeShadows(3, HORIZON + 0.06, 0.95) private storm = makeStorm() private palms: Palm[] = [] private boat = { x: -9, dir: 1, next: 14 } private dolphins: Dolphin[] = [] private splashes: { x: number; y: number; vx: number; vy: number; age: number }[] = [] + // Rings spreading on the water where a dolphin broke the surface. + private rings: { x: number; y: number; age: number }[] = [] + // The sea's rows (from the horizon to the highest swash): which row each one mirrors, how far its ripples wobble the + // reflection, how much reflection each pixel takes this frame, and the rows as the sea was drawn. + private sea = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0), still: new Float32Array(0) } private crab = { x: 0.5, dir: 1, rest: 0, phase: 0 } private surfer = { x: -0.2, phase: 0 } private startled: Flier[] = [] @@ -190,10 +217,13 @@ class Beach extends Canvas { const look = offSeason && settings.time === "day" ? { ...LOOKS.day, ...OFF_SEASON } : LOOKS[settings.time] // With the sun or moon behind cloud, only a trace of the glitter path is left. this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.15 } : look + if (settings.time === "day") this.frame = 0.4 + this.shade = sunShade(settings.time, look.orb, this.weather.covered) if (this.season === "summer" && !this.look.night) this.swimmers = Array.from({ length: { calm: 1, lively: 3, teeming: 5 }[settings.activity] }, (_, i) => ({ x: 0.25 + i * 0.13 + hash(i) * 0.05, y: 0.69 + hash(i * 3.3) * 0.03, phase: i * 1.9 })) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.08, 0.3) - if (settings.activity !== "calm") this.dolphins = [0, 1].map((i) => ({ t: -1, x: 0, y: 0, dir: 1, wait: 4 + i * 9 })) + // Long thin streaks catch the low sun better than heaps of cumulus. + this.clouds = settings.time === "sunset" ? makeClouds(4, "cirrus", 0.08, 0.3) : makeClouds(4, "cumulus", 0.07, 0.24) + if (settings.activity !== "calm") this.dolphins = [0, 1].map((i) => ({ t: -1, x: 0, y: 0, dir: 1, wait: 4 + i * 9, under: -1 })) } step(dt: number) { @@ -202,6 +232,7 @@ class Beach extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) @@ -214,6 +245,8 @@ class Beach extends Canvas { s.y += s.vy * dt } this.splashes = this.splashes.filter((s) => s.age < 1.2) + for (const r of this.rings) r.age += dt + this.rings = this.rings.filter((r) => r.age < RING) this.startled = flyAway(this.startled, dt, this.A) const b = this.bottle if (b.t >= 0) b.t += dt @@ -227,10 +260,10 @@ class Beach extends Canvas { } if (this.activity === "teeming") { const c = this.crab - c.phase += cdt * 10 if (c.rest > 0) c.rest -= cdt else { c.x += c.dir * 0.04 * cdt + c.phase += (0.04 * cdt) / 0.02 if (Math.random() < cdt * 0.3) c.rest = rand(2, 5) if (c.x > 0.75 * this.A) c.dir = -1 if (c.x < 0.3 * this.A) c.dir = 1 @@ -250,9 +283,13 @@ class Beach extends Canvas { this.hdr.set(this.background) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) + const orb = this.weather.covered ? undefined : this.look.orb + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawSea() + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.01, orb) + this.sea.still.set(this.hdr.subarray(this.sea.y0 * this.W * 3, this.sea.y1 * this.W * 3)) + this.drawWakes() for (const s of this.swimmers) this.drawSwimmer(s) this.drawBoat() for (const d of this.dolphins) this.drawDolphin(d) @@ -262,6 +299,11 @@ class Beach extends Canvas { if (this.activity !== "calm" && !this.look.night) this.drawGulls() if (this.activity === "teeming") this.drawCrab() for (const b of this.startled) this.shape(bird(b, this.H, 0.03 * this.H, this.paint([0.75, 0.78, 0.82])), this.lighting, 0.5) + if (this.look.night) this.drawBonfire() + // Once the boat and birds are up: the sky and all of them mirrored in the open water. The palms stand on the sand, + // not at the water's edge, so they go in after it. + const s = this.sea + reflect(this.hdr, this.W, this.H, s.y0, s.y1, s.rows, s.amp, s.gloss, this.look.mirror, this.time, s.still) // Trunks are drawn each frame, over the sea, since they cross the water. for (const p of this.palms) { this.drawTrunk(p) @@ -282,6 +324,8 @@ class Beach extends Canvas { this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } paintSky(this.hdr, W, H, look.sky, look.orb) this.weather.cover(this.hdr, W, H) + const sky = this.hdr.slice() + if (!this.weather.covered) paintClouds(this.hdr, W, H, this.towers, look.clouds.top, look.clouds.bottom, look.clouds.alpha, look.orb) // A small island on the horizon. const ix = 0.16 * A this.polygon(Array.from({ length: 21 }, (_, i) => [(ix + Math.cos((i / 20) * Math.PI) * 0.08) * H, (HORIZON - Math.sin((i / 20) * Math.PI) * 0.025) * H] as const), this.paint([0.2, 0.35, 0.22])) @@ -300,16 +344,40 @@ class Beach extends Canvas { this.hdr[o + 1] = dry[1] * ripple this.hdr[o + 2] = dry[2] * ripple } + // By day the island fades into the sky, and the sand drifts between warm and cool. + if (look.style === "front") { + haze(this.hdr, sky, W, H, HORIZON, HORIZON + 0.01, 0.4) + mottle(this.hdr, W, H, HORIZON, 1, 0.6, [1.12, 1, 0.8], [0.72, 0.78, 0.92]) + } // Summer brings a crowd of umbrellas and sunbathers; out of season the beach is empty but for driftwood. const season = this.season if (season === "summer") for (const [i, [x, y, stripe]] of UMBRELLAS.slice(0, { calm: 2, lively: 4, teeming: 6 }[this.activity]).entries()) this.drawUmbrella(x * A, y, stripe, i % 3 === 2 ? undefined : SKIN[i % SKIN.length]) if (season === "autumn" || season === "winter") this.drawDriftwood(0.56 * A, 0.9) if (this.activity === "teeming" && season !== "autumn" && season !== "winter") this.drawBeachThings() + // The palms lean in from the edges, their crowns high and to the sides of the text in the middle. this.palms = [ - { base: [0.07 * A, 1.03], crown: [0.2 * A, 0.36], lean: 1 }, - { base: [0.94 * A, 1.0], crown: [0.85 * A, 0.44], lean: -1 }, + { base: [0.12, 1.03], crown: [0.35, 0.33], lean: 1 }, + { base: [A - 0.1, 1.0], crown: [A - 0.27, 0.39], lean: -1 }, ] this.background = this.hdr.slice() + // Each row of sea mirrors the sky about the horizon, stretched a little as rough water does, its ripples wobbling + // wider toward the shore. + const y0 = Math.floor(HORIZON * H) + const y1 = Math.min(H, Math.ceil(0.77 * H) + 3) + const n = y1 - y0 + this.sea = { + y0, + y1, + rows: Float32Array.from({ length: n }, (_, r) => y0 - 1 - r * 0.85), + amp: Float32Array.from({ length: n }, (_, r) => (0.3 + (r / (0.24 * H)) * 2.6) * (H / 180)), + gloss: new Float32Array(n * W), + still: new Float32Array(n * W * 3), + } + } + + private ground(x: number, y: number, w: number, h: number, tip?: number, lift?: number) { + const H = this.H + groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip, lift) } private paint(c: RGB): RGB { @@ -322,64 +390,82 @@ class Beach extends Canvas { return 0.74 + 0.01 * Math.sin(u * 2.3 + 1) + 0.012 * Math.sin(this.time * 0.5) } - // The sea: deepening toward the shore, with drifting wave streaks, a glittering path under the sun or moon, a wave - // rolling in now and then, wet sand above the waterline, and foam along its edge. + // The sea: deepening toward the shore, with highlights on the wave crests drifting in (brightest along the path under + // the sun or moon), a wave rolling in now and then, wet sand above the waterline, and foam along its edge. It notes + // how much each pixel mirrors the sky, more toward the horizon as on real water; the reflection goes in at the end of + // render(), once everything it mirrors is drawn. private drawSea() { - const { W, H, hdr, look } = this + const { W, H, hdr, look, sea } = this const t = this.time const [far, near] = look.sea const [, wet] = look.sand + const sky = look.sky[look.sky.length - 1][1] const ox = look.orb.x * W const roll = (t * 0.08) % 1 + const thin = H / 180 + sea.gloss.fill(0) for (let x = 0; x < W; x++) { const u = x / H const edge = this.shore(u) * H const reach = (0.74 + 0.01 * Math.sin(u * 2.3 + 1) + 0.014) * H const wave = lerp(0.62, edge / H - 0.005, roll) * H - for (let y = Math.floor(HORIZON * H); y < Math.min(H, reach + 2); y++) { + const broken = smoothstep(0.42, 0.6, fbm1(u * 6 + t * 0.1, 4)) + const bend = 1.4 * Math.sin(u * 2.7 + t * 0.05) + 0.7 * Math.sin(u * 7.3 - t * 0.08) + for (let y = sea.y0; y < Math.min(H, reach + 2); y++) { const o = (y * W + x) * 3 + const gi = (y - sea.y0) * W + x if (y > edge) { - // Wet sand the swash has just left. + // Wet sand the swash has just left, faintly glossy. const k = clamp((reach - y) / (reach - edge + 1), 0, 1) * 0.8 hdr[o] += (wet[0] - hdr[o]) * k hdr[o + 1] += (wet[1] - hdr[o + 1]) * k hdr[o + 2] += (wet[2] - hdr[o + 2]) * k + sea.gloss[gi] = k * 0.3 continue } - const depth = clamp((y / H - HORIZON) / (edge / H - HORIZON), 0, 1) - let r = lerp(far[0], near[0], depth) - let g = lerp(far[1], near[1], depth) - let b = lerp(far[2], near[2], depth) - const streak = Math.sin(u * (30 - depth * 18) + y * 0.9 + t * 0.6 + Math.sin(y * 0.37) * 3) - const shine = streak > 0.9 ? (streak - 0.9) * 2 * (0.4 + depth) : 0 - r += look.light[0] * shine * 0.25 - g += look.light[1] * shine * 0.25 - b += look.light[2] * shine * 0.25 - // The glitter path widens toward the shore. - const spread = (0.02 + depth * 0.12) * H - const along = Math.abs(x - ox) / spread - if (along < 1 && hash2(x, Math.floor(y * 0.5) + Math.floor(t * 3) * 131) > 0.86 + along * 0.1) { - const k = (1 - along) * look.glitter * 1.2 - r += look.light[0] * k - g += look.light[1] * k - b += look.light[2] * k + const d = y / H - HORIZON + const depth = clamp(d / (edge / H - HORIZON), 0, 1) + // Rows of swell rolling in toward the shore, closer together toward the horizon, though never closer than a + // terminal cell. + const phase = 51 * Math.log(d + 0.15) - t * 0.6 + bend + const n = phase / TAU - Math.round(phase / TAU) + const swell = 0.94 + 0.06 * Math.cos(phase) + // By day the far water pales into the haze along the horizon. + const mist = look.style === "front" ? (1 - depth) ** 4 * 0.5 : 0 + let r = lerp(lerp(far[0], near[0], depth) * swell, sky[0], mist) + let g = lerp(lerp(far[1], near[1], depth) * swell, sky[1], mist) + let b = lerp(lerp(far[2], near[2], depth) * swell, sky[2], mist) + // Each crest catches the light in dashes along its length, long enough to read in a terminal cell. + const across = Math.abs(n) * ((TAU * (d + 0.15) * H) / 51) + const crest = Math.max(0, 1 - across / ((0.7 + depth * 1.2) * thin)) * smoothstep(0.008, 0.03, d) + let shine = 0 + if (crest > 0) { + // Under the sun or moon the dashes crowd together into a glittering path. + const path = Math.max(0, 1 - Math.abs(x - ox) / ((0.03 + depth * 0.14) * H)) ** 1.5 + const dash = smoothstep(0.45 - path * 0.25, 0.72 - path * 0.25, noise1((u * 2.4) / (d + 0.02) + Math.round(phase / TAU) * 17.3, 5)) + const glint = crest * dash * 0.14 * (1 - path) + const sun = crest * dash * path * look.glitter * 1.6 + shine = glint + sun + r += look.light[0] * glint + look.orb.glow[0] * sun + g += look.light[1] * glint + look.orb.glow[1] * sun + b += look.light[2] * glint + look.orb.glow[2] * sun } // Foam: the incoming wave's crest and the edge of the water. - const broken = smoothstep(0.42, 0.6, fbm1(u * 6 + t * 0.1, 4)) - const crest = Math.max(0, 1 - Math.abs(y - wave) / 1.2) * (0.5 + 0.5 * Math.sin(u * 40 + t)) * (1 - roll) * broken + const breaker = Math.max(0, 1 - Math.abs(y - wave) / 1.2) * (0.5 + 0.5 * Math.sin(u * 40 + t)) * (1 - roll) * broken const lap = Math.max(0, 1 - (edge - y) / 2.2) * (0.7 + 0.3 * Math.sin(u * 30 - t * 2)) - const foam = Math.max(crest, lap) + const foam = Math.max(breaker, lap) r += (look.foam[0] - r) * foam g += (look.foam[1] - g) * foam b += (look.foam[2] - b) * foam hdr[o] = r hdr[o + 1] = g hdr[o + 2] = b + sea.gloss[gi] = (0.1 + 0.7 * Math.exp(-d * 12)) * (1 - foam) * (1 - Math.min(1, shine * 1.5)) } // At night the glowing foam lights the wet sand just beyond it. if (look.night) - for (let y = Math.floor(edge); y < Math.min(H, edge + 0.02 * H); y++) { - const k = Math.exp(-(y - edge) / 2.5) * 0.15 + for (let y = Math.floor(edge); y < Math.min(H, edge + 0.05 * H); y++) { + const k = Math.exp(-(y - edge) / (0.012 * H)) * 0.2 const o = (y * W + x) * 3 hdr[o] += look.foam[0] * k hdr[o + 1] += look.foam[1] * k @@ -388,6 +474,17 @@ class Beach extends Canvas { } } + // A bonfire on the sand at night, lighting the beach, the crab and the palm beside it. + private drawBonfire() { + const H = this.H + const x = 0.17 * this.A * H + const gy = 0.92 * H + const fire = campfire(x, gy, H * 1.2, this.time) + lightPool(this.hdr, this.W, H, x, gy - 0.03 * H, 0.36 * H, 0.13 * H, [1, 0.4, 0.07], 2.6 * fire.flicker, 0.04 * fire.flicker) + this.shape(fire.logs, { style: "rim", color: [1.4, 0.5, 0.1], x, y: gy - 0.03 * H }) + for (const flame of fire.flames) this.shape(flame, { style: "front", dir: [0, 0, 1] }, 0) + } + // A palm trunk curving from its base to the crown, ringed with old leaf scars, with coconuts at the top. private drawTrunk(p: Palm) { const H = this.H @@ -418,25 +515,32 @@ class Beach extends Canvas { const [cx, cy] = [p.crown[0] * H, p.crown[1] * H] for (let i = 0; i < 8; i++) { const base = -Math.PI / 2 + ((i / 7) * 2 - 1) * 1.6 + p.lean * 0.15 - const sway = Math.sin(this.time * 0.8 + i * 1.3 + p.lean) * 0.06 + // Turned whichever way carries the tip downwind. + const bend = sway(p.crown[0], this.time, i * 1.3 + p.lean, 0.8) * 0.06 * (Math.sin(base) > 0 ? -1 : 1) const length = (0.17 + hash(i * 3.1 + p.lean) * 0.05) * H const parts: Part[] = [] let [x, y] = [cx, cy] - let angle = base + sway + const spine: [number, number][] = [[x, y]] + const widths = [this.thick(0.005)] + let angle = base + bend for (let s = 0; s < 8; s++) { const q = s / 8 // Fronds pointing sideways droop more. angle += (Math.cos(base) > 0 ? 1 : -1) * 0.06 * (0.3 + Math.abs(Math.cos(base))) + 0.02 const nx = x + Math.cos(angle) * (length / 8) const ny = y + Math.sin(angle) * (length / 8) + q * q * 0.006 * H - parts.push(cap(x, y, nx, ny, lerp(0.005, 0.0015, q) * H, lerp(0.005, 0.0015, q + 0.125) * H, frond)) + parts.push(cap(x, y, nx, ny, this.thick(lerp(0.005, 0.0015, q)), this.thick(lerp(0.005, 0.0015, q + 0.125), 0.7), frond)) const leaf = (1 - q * 0.6) * 0.03 * H - for (const side of [-1, 1]) { - const la = angle + side * 1.1 + 0.4 - parts.push(cap(nx, ny, nx + Math.cos(la) * leaf, ny + Math.sin(la) * leaf, 0.0028 * H, 0.0008 * H, frond)) - } + spine.push([nx, ny]) + widths.push(leaf * 0.5) + if (!this.cells) + for (const side of [-1, 1]) { + const la = angle + side * 1.1 + 0.4 + parts.push(cap(nx, ny, nx + Math.cos(la) * leaf, ny + Math.sin(la) * leaf, 0.0028 * H, 0.0008 * H, frond)) + } ;[x, y] = [nx, ny] } + if (this.cells) parts.push(...blade(spine, widths, frond)) this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.7) } } @@ -453,6 +557,8 @@ class Beach extends Canvas { this.shape([cap(...P(-0.035, 0.06), ...P(0.045, 0.056), 0.009 * s * H, 0.006 * s * H, c), ell(...P(-0.048, 0.059), 0.01 * s * H, 0.009 * s * H, 0, c)], this.lighting, 0.6) this.shape([cap(...P(-0.012, 0.06), ...P(0.012, 0.059), 0.0095 * s * H, 0.0095 * s * H, this.paint(stripe))], this.lighting, 0.4) } + // The canopy shades whoever lies under it. + this.ground(ux, uy + 0.05 * s, 0.18 * s, 0.17 * s, 1.2) this.shape([cap(...P(0, 0.05), ...P(0, -0.12), 0.003 * s * H, 0.003 * s * H, this.paint([0.85, 0.85, 0.82]))], this.lighting, 0.4) for (let k = 0; k < 6; k++) { const a0 = Math.PI + (k / 6) * Math.PI @@ -466,6 +572,7 @@ class Beach extends Canvas { private drawDriftwood(x: number, y: number) { const H = this.H const wood = this.paint([0.62, 0.55, 0.45]) + this.ground(x, y + 0.006, 0.13, 0.02, 1) this.shape([cap((x - 0.07) * H, y * H, (x + 0.06) * H, (y - 0.008) * H, 0.009 * H, 0.006 * H, wood, 2), cap((x + 0.02) * H, (y - 0.005) * H, (x + 0.05) * H, (y - 0.03) * H, 0.004 * H, 0.002 * H, wood)], this.lighting, 0.7) } @@ -485,6 +592,8 @@ class Beach extends Canvas { if (this.season !== "summer") this.drawUmbrella(0.64 * A, 0.86, [0.85, 0.15, 0.12]) const [sx, sy] = [0.42 * A, 0.92] const sand = this.paint([0.78, 0.66, 0.42]) + this.ground(sx, sy, 0.1, 0.09, 0.5) + this.ground(0.53 * A, 0.95, 0.028, 0.032) const box = (x0: number, y0: number, x1: number, y1: number) => [[x0 * H, y0 * H], [x1 * H, y0 * H], [x1 * H, y1 * H], [x0 * H, y1 * H]] as const this.polygon(box(sx - 0.05, sy - 0.03, sx + 0.05, sy), sand) for (const [dx, h] of [[-0.04, 0.06], [0, 0.08], [0.04, 0.06]] as const) { @@ -525,17 +634,22 @@ class Beach extends Canvas { this.polygon([[x - s * 0.6, y - s * 0.12], [x + s * 0.6, y - s * 0.12], [x + s * 0.42, y + s * 0.05], [x - s * 0.42, y + s * 0.05]], this.paint([0.3, 0.2, 0.15])) this.polygon([[x - s * 0.05, y - s * 0.15], [x - s * 0.05, y - s * 1.1], [x + s * 0.55 * b.dir, y - s * 0.15]], this.paint([0.92, 0.9, 0.84])) this.polygon([[x - s * 0.12, y - s * 0.15], [x - s * 0.12, y - s * 0.85], [x - s * 0.5 * b.dir, y - s * 0.15]], this.paint([0.85, 0.82, 0.76])) + // At night a lantern hangs at the stern, lighting the sails and the water around the boat. + if (!this.look.night) return + const lx = x - s * 0.45 * b.dir + lightPool(this.hdr, this.W, H, lx, y, s * 1.4, s * 0.9, [1, 0.55, 0.15], 2.5, 0.03) + this.disc(lx, y - s * 0.25, Math.max(1, s * 0.07), 1.6, 0.85, 0.2, 1) } // Each dolphin waits underwater, then arcs out of the sea and splashes back in. private stepDolphin(d: Dolphin, dt: number, cdt: number) { + if (d.under >= 0) d.under = d.under + dt > WAKE ? -1 : d.under + dt if (d.t < 0) { d.wait -= cdt if (d.wait > 0) return d.t = 0 d.dir = Math.random() < 0.5 ? 1 : -1 - d.x = rand(0.3 * this.A, 0.7 * this.A) - d.y = rand(0.56, 0.66) + ;[d.x, d.y] = this.openSpot(0.2 * this.A, 0.8 * this.A, 0.56, 0.66) this.splash(d.x, d.y) return } @@ -544,11 +658,70 @@ class Beach extends Canvas { this.splash(d.x + d.dir * 0.16, d.y) d.t = -1 d.wait = rand(10, 25) + d.under = 0 } } private splash(x: number, y: number) { for (let i = 0; i < 10; i++) this.splashes.push({ x, y, vx: (Math.random() - 0.5) * 0.06, vy: -(0.03 + Math.random() * 0.05), age: 0 }) + this.rings.push({ x, y, age: 0 }) + } + + // Wakes in the water: rings where dolphins broke the surface, the dolphin's path as it swims on under it, and the + // surfer's trail. At night they glow with the plankton the foam glows with; by day they are faint foam. + private drawWakes() { + const { H, look } = this + for (const r of this.rings) { + const f = r.age / RING + this.ring(r.x * H, r.y * H, (0.012 + f * 0.05) * H, (1 - f) ** 2 * (look.night ? 0.5 : 0.2)) + } + for (const d of this.dolphins) { + if (d.under < 0) continue + // The wake leads off from where it dived back in, growing as it swims on and fading as it goes deeper. + const x0 = d.x + d.dir * 0.16 + const head = x0 + d.dir * (0.02 + 0.03 * d.under) + this.trail(head * H, x0 * H, d.y * H, (1 - d.under / WAKE) ** 1.5 * (look.night ? 0.7 : 0.15)) + } + if (this.activity === "teeming") { + const s = this.surfer + this.trail((s.x - 0.04) * H, (s.x - 0.3) * H, (0.65 + Math.sin(s.phase * 1.5) * 0.004) * H, look.night ? 0.8 : 0.35) + } + } + + // A ring spreading on the water, flattened by perspective. + private ring(cx: number, cy: number, R: number, k: number) { + const { W, H, hdr, look } = this + const glow = look.night + for (let y = Math.max(0, Math.floor(cy - R * 0.25 - 2)); y <= Math.min(H - 1, Math.ceil(cy + R * 0.25 + 2)); y++) + for (let x = Math.max(0, Math.floor(cx - R - 2)); x <= Math.min(W - 1, Math.ceil(cx + R + 2)); x++) { + const d = Math.hypot(x + 0.5 - cx, (y + 0.5 - cy) / 0.25) + const a = Math.max(0, 1 - Math.abs(d - R) / (1.6 * (H / 180) + 0.6)) * k + if (a <= 0) continue + const o = (y * W + x) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] += glow ? look.foam[c] * a : (look.foam[c] - hdr[o + c]) * a + } + } + + // A wake from head back to tail along row y, widening and fading toward the tail, sparkling where the water churns. + private trail(head: number, tail: number, cy: number, k: number) { + const { W, H, hdr, look } = this + const glow = look.night + const len = Math.abs(head - tail) + const w = 0.02 * H + for (let x = Math.max(0, Math.floor(Math.min(head, tail))); x <= Math.min(W - 1, Math.ceil(Math.max(head, tail))); x++) { + const f = Math.abs(x - head) / len + if (f > 1) continue + const half = (0.15 + 0.85 * f) * w + const fade = (1 - f) ** 1.5 * k + for (let y = Math.max(0, Math.floor(cy - half - 1)); y <= Math.min(H - 1, Math.ceil(cy + half + 1)); y++) { + // Brightest along the two arms of the wake, spreading apart behind the head. + const v = Math.abs(y + 0.5 - cy) / half + const a = Math.max(0, 1 - Math.abs(v - 0.7) * 2.2) * fade * (0.6 + 0.4 * hash2(x >> 1, y)) + if (a <= 0) continue + const o = (y * W + x) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] += glow ? look.foam[c] * a : (look.foam[c] - hdr[o + c]) * a + } + } } private drawSplashes() { @@ -559,6 +732,8 @@ class Beach extends Canvas { } } + // A bottlenose dolphin: a tapering body with a rounded melon and short beak, a swept dorsal fin and flukes. Its tail + // trails the turn of the leap, flexing down as it rises and up as it dives. private drawDolphin(d: Dolphin) { if (d.t < 0) return const H = this.H @@ -568,21 +743,26 @@ class Beach extends Canvas { const angle = Math.atan2(-Math.cos(Math.PI * d.t) * 0.08 * Math.PI, d.dir * 0.16) const ca = Math.cos(angle) const sa = Math.sin(angle) - const P = (u: number, v: number): [number, number] => [x + (u * ca - v * sa) * S, y + (u * sa + v * ca) * S] + const f = Math.sign(ca || 1) + const bend = 0.35 * Math.cos(Math.PI * d.t) * f + const P = (u: number, v: number): [number, number] => { + const w = v + bend * Math.min(0, u) ** 2 + return [x + (u * ca - w * sa) * S, y + (u * sa + w * ca) * S] + } const back = this.paint([0.42, 0.52, 0.62]) - this.shape( - [ - cap(...P(-0.4, 0), ...P(0.35, 0), 0.08 * S, 0.11 * S, back), - cap(...P(0.35, 0.02), ...P(0.52, 0.04), 0.05 * S, 0.025 * S, back), - cap(...P(0, -0.1), ...P(-0.1 * Math.sign(ca || 1), -0.24), 0.05 * S, 0.01 * S, back), - cap(...P(-0.4, 0), ...P(-0.55, -0.1), 0.04 * S, 0.02 * S, back), - cap(...P(-0.4, 0), ...P(-0.55, 0.1), 0.04 * S, 0.02 * S, back), - cap(...P(0.1, 0.06), ...P(-0.02, 0.18), 0.035 * S, 0.01 * S, back), - ], - this.lighting, - 0.7, + const spine: [number, number][] = [[0.52, 0.018], [0.42, 0.03], [0.36, 0.07], [0.26, 0.1], [0.08, 0.115], [-0.12, 0.1], [-0.3, 0.06], [-0.46, 0.026]] + const parts: Part[] = spine.slice(1).map(([u, r], i) => cap(...P(spine[i][0], i < 2 ? 0.02 : 0), ...P(u, i < 1 ? 0.02 : 0), spine[i][1] * S, r * S, back)) + const beat = Math.sin(d.t * TAU * 1.5) * 0.04 + parts.push( + cap(...P(-0.46, 0), ...P(-0.62, -0.05 + beat), 0.03 * S, 0.012 * S, back), + cap(...P(-0.46, 0), ...P(-0.6, 0.06 + beat), 0.03 * S, 0.012 * S, back), + cap(...P(0.02, -0.1), ...P(-0.06, -0.22), 0.055 * S, 0.012 * S, back), + cap(...P(-0.06, -0.22), ...P(-0.12, -0.24), 0.012 * S, 0.006 * S, back), + cap(...P(0.18, 0.07), ...P(0.06, 0.17), 0.035 * S, 0.01 * S, back), ) - this.shape([cap(...P(-0.25, 0.04), ...P(0.3, 0.05), 0.04 * S, 0.06 * S, this.paint([0.75, 0.78, 0.8]))], this.lighting, 0.5) + this.shape(parts, this.lighting, 0.7) + this.shape([cap(...P(-0.2, 0.06), ...P(0.3, 0.065), 0.03 * S, 0.045 * S, this.paint([0.68, 0.72, 0.76]))], this.lighting, 0.5) + this.add(...P(0.36, -0.01), 0.02, 0.02, 0.02) } // Gulls wheeling over the water, wings flexing. @@ -626,6 +806,7 @@ class Beach extends Canvas { const y = lerp(lerp(0.62, beached, come), 0.66, smoothstep(52, 62, t)) + Math.sin(this.time * 1.4) * 0.004 * clamp(afloat, 0, 1) const tilt = 0.35 + Math.sin(this.time * 1.1) * 0.25 * clamp(afloat, 0, 1) const S = 0.09 * H + if (afloat < 1) this.ground(x, y + 0.012, 0.07, 0.025, 1, clamp(afloat, 0, 1) * 0.03 * H) const ca = Math.cos(tilt) const sa = Math.sin(tilt) const P = (u: number, v: number): [number, number] => [x * H + (u * ca - v * sa) * S, y * H + (u * sa + v * ca) * S] @@ -642,27 +823,42 @@ class Beach extends Canvas { this.add(...P(-0.1, -0.1), this.look.light[0] * 0.5, this.look.light[1] * 0.5, this.look.light[2] * 0.5) } - // A red crab scuttling sideways across the sand, claws up, pausing now and then. + // A red crab scuttling sideways across the sand on jointed legs, four to a side stepping in two alternating sets, + // eyes up on stalks and claws raised; pausing, it waves a claw. private drawCrab() { const H = this.H const c = this.crab - const S = 0.05 * H - const x = c.x * H + const S = 0.065 * H + const going = c.rest > 0 ? 0 : 1 + const x = (c.x + Math.sin(c.phase * TAU * 2) * 0.006 * going) * H const y = 0.97 * H const red = this.paint([0.85, 0.25, 0.12]) - const step = c.rest > 0 ? 0 : Math.sin(c.phase) * 0.06 - const parts: Part[] = [ell(x, y - 0.25 * S, 0.4 * S, 0.22 * S, 0, red)] + const dark = this.paint([0.55, 0.13, 0.07]) + const wave = (1 - going) * Math.max(0, Math.sin(this.creatureTime * 2.5)) ** 2 + this.ground(c.x, 0.97, 0.07, 0.045) + const parts: Part[] = [] for (const side of [-1, 1]) - for (let k = 0; k < 3; k++) { - const kx = x + side * (0.25 + k * 0.1) * S - parts.push(cap(kx, y - 0.2 * S, kx + side * 0.25 * S, y + (k % 2 ? step : -step) * S, 0.035 * S, 0.025 * S, red)) + for (let k = 0; k < 4; k++) { + const [reach, lift] = stride(c.phase, ((k + (side > 0 ? 1 : 0)) % 2) * 0.5) + const hx = x + side * (0.2 + k * 0.05) * S + const fx = x + (side * (0.5 + k * 0.1) + reach * c.dir * 0.08 * going) * S + const fy = y - (lift * 0.08 * going + 0.02) * S + parts.push(...limb((u, v) => [u, v], hx, y - 0.25 * S, fx, fy, 0.22 * S, 0.28 * S, -side, 0.05 * S, 0.04 * S, 0.02 * S, k % 2 ? red : dark)) } + parts.push(ell(x, y - 0.3 * S, 0.38 * S, 0.2 * S, 0, red)) for (const side of [-1, 1]) { - parts.push(cap(x + side * 0.3 * S, y - 0.35 * S, x + side * 0.45 * S, y - 0.6 * S, 0.04 * S, 0.035 * S, red)) - parts.push(ell(x + side * 0.48 * S, y - 0.68 * S, 0.12 * S, 0.09 * S, side * 0.5, red)) + parts.push(cap(x + side * 0.08 * S, y - 0.42 * S, x + side * 0.1 * S, y - 0.56 * S, 0.025 * S, 0.02 * S, red)) + const lift = side > 0 ? wave * 0.25 : 0 + const [cx, cy] = [x + side * 0.5 * S, y - (0.66 + lift) * S] + parts.push( + cap(x + side * 0.28 * S, y - 0.34 * S, x + side * 0.4 * S, y - (0.5 + lift * 0.6) * S, 0.045 * S, 0.035 * S, red), + ell(cx, cy, 0.13 * S, 0.1 * S, side * 0.5, red), + cap(cx + side * 0.04 * S, cy - 0.06 * S, cx + side * 0.06 * S, cy - (0.2 + wave * 0.05) * S, 0.05 * S, 0.012 * S, red), + cap(cx + side * 0.09 * S, cy - 0.02 * S, cx + side * 0.16 * S, cy - 0.14 * S, 0.035 * S, 0.01 * S, red), + ) } this.shape(parts, this.lighting, 0.6) - for (const side of [-1, 1]) this.add(x + side * 0.1 * S, y - 0.45 * S, 0.02, 0.02, 0.02) + for (const side of [-1, 1]) this.disc(x + side * 0.1 * S, y - 0.58 * S, 0.03 * S, 0.02, 0.02, 0.02, 1) } // A surfer standing on a board, riding the swell along the shore and bobbing. @@ -707,9 +903,9 @@ export const beach: Wallpaper = { [44, 38, 26], ], sunset: [ - [30, 14, 26], - [36, 16, 22], - [14, 8, 8], + [8, 22, 28], + [36, 20, 30], + [16, 12, 14], ], night: [ [3, 6, 16], diff --git a/wallpapers/city.ts b/wallpapers/city.ts index c2fb6c8..e365800 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -1,10 +1,15 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { bob, limb, stride } from "../src/creature" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" +import { haze } from "../src/grade" +import { lightPool } from "../src/light" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintHaze, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -27,7 +32,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } // The far skyline's hazy color and the river from far to near. haze: RGB river: [far: RGB, near: RGB] @@ -54,11 +59,11 @@ const LOOKS: Record = { ], orb: { x: 0.14, y: 0.11, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, - haze: [0.55, 0.65, 0.78], + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, + haze: [0.48, 0.6, 0.8], river: [ - [0.2, 0.36, 0.48], - [0.08, 0.2, 0.3], + [0.12, 0.3, 0.48], + [0.05, 0.16, 0.3], ], reflect: 0.55, tint: [1, 1, 1], @@ -69,23 +74,24 @@ const LOOKS: Record = { }, sunset: { style: "rim", - light: [1, 0.5, 0.2], + // Smog: magenta overhead, thick amber over the rooftops, and a dull orange sun sinking between the towers. + light: [1, 0.55, 0.16], sky: [ - [0, [0.03, 0.02, 0.08]], - [0.25, [0.12, 0.04, 0.12]], - [0.45, [0.45, 0.14, 0.14]], - [BANK, [1, 0.5, 0.18]], + [0, [0.06, 0.012, 0.06]], + [0.2, [0.2, 0.035, 0.16]], + [0.38, [0.55, 0.14, 0.22]], + [BANK, [1, 0.56, 0.14]], ], - orb: { x: 0.7, y: 0.5, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, - stars: 20, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, - haze: [0.36, 0.14, 0.16], + orb: { x: 0.62, y: 0.4, r: 0.065, core: [2.6, 1.3, 0.35], glow: [1, 0.5, 0.12], near: 0.45, wide: 0.35 }, + stars: 0, + clouds: { top: [0.2, 0.04, 0.16], bottom: [0.95, 0.4, 0.3], alpha: 0.5 }, + haze: [0.6, 0.27, 0.12], river: [ - [0.4, 0.16, 0.14], - [0.1, 0.05, 0.09], + [0.62, 0.28, 0.12], + [0.14, 0.03, 0.1], ], reflect: 0.6, - tint: [0.2, 0.12, 0.11], + tint: [0.22, 0.1, 0.13], windows: [AMBER, GOLD], lit: 0.18, glow: 0.55, @@ -93,15 +99,16 @@ const LOOKS: Record = { }, night: { style: "rim", - light: [0.25, 0.3, 1], + light: [0.35, 0.42, 1.4], sky: [ [0, [0.004, 0.006, 0.03]], - [0.32, [0.014, 0.016, 0.06]], - [BANK, [0.1, 0.035, 0.14]], + [0.32, [0.016, 0.018, 0.07]], + [0.45, [0.05, 0.025, 0.12]], + [BANK, [0.15, 0.05, 0.2]], ], orb: { x: 0.82, y: 0.12, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 50, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.08, 0.05, 0.2], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.08, 0.05, 0.2], alpha: 0.4 }, haze: [0.035, 0.03, 0.08], river: [ [0.03, 0.025, 0.08], @@ -124,12 +131,12 @@ const DAYLIGHT = (() => { // Daytime colors. const FACADES: RGB[] = [ - [0.5, 0.56, 0.64], - [0.68, 0.62, 0.52], - [0.32, 0.46, 0.6], - [0.58, 0.42, 0.36], - [0.4, 0.52, 0.54], - [0.62, 0.62, 0.68], + [0.3, 0.42, 0.6], + [0.7, 0.55, 0.4], + [0.18, 0.33, 0.55], + [0.62, 0.36, 0.26], + [0.22, 0.43, 0.48], + [0.66, 0.6, 0.53], ] const PAINT: RGB[] = [ [0.75, 0.12, 0.1], @@ -140,6 +147,12 @@ const PAINT: RGB[] = [ [0.3, 0.55, 0.4], [0.55, 0.56, 0.6], ] +const HAIR: RGB[] = [ + [0.12, 0.08, 0.05], + [0.45, 0.28, 0.12], + [0.05, 0.05, 0.06], + [0.7, 0.55, 0.3], +] const NEON: RGB[] = [ [1.5, 0.2, 0.95], [0.2, 1.3, 1.25], @@ -148,6 +161,10 @@ const NEON: RGB[] = [ [1.4, 0.95, 0.2], ] const CONCRETE: RGB = [0.58, 0.56, 0.53] +const FLAGS: RGB[] = [ + [0.8, 0.12, 0.1], + [0.12, 0.32, 0.75], +] const STEEL: RGB = [0.62, 0.17, 0.11] const HEADLIGHT: RGB = [1.4, 0.95, 0.3] const TAILLIGHT: RGB = [1.4, 0.08, 0.06] @@ -188,8 +205,11 @@ interface Car { interface Walker { x: number dir: number + // Strides taken: one per step of both feet. phase: number + size: number coat: RGB + hair: RGB umbrella: RGB } @@ -215,6 +235,7 @@ class City extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) // The river with the skyline reflected in it, which each frame ripples sideways where `water` is set. private river = new Float32Array(0) @@ -248,13 +269,15 @@ class City extends Canvas { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 4, this.look.clouds.puffy, 0.04, 0.1) + this.clouds = makeClouds(2, "stratus", 0.05, 0.12) this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, BANK) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) this.wet = settings.weather ? settings.weather === "rain" || settings.weather === "snow" : this.look.night && settings.activity === "teeming" if (settings.activity !== "teeming") return const umbrellas: RGB[] = [[0.1, 0.55, 0.6], [0.6, 0.1, 0.4], [0.65, 0.35, 0.05], [0.3, 0.15, 0.6], [0.6, 0.1, 0.08]] - this.walkers = Array.from({ length: 5 }, (_, i) => ({ x: 0.15 + i * 0.37, dir: i % 2 ? -1 : 1, phase: Math.random() * TAU, coat: PAINT[(i * 3) % PAINT.length], umbrella: umbrellas[i] })) + this.walkers = Array.from({ length: 5 }, (_, i) => ({ x: 0.15 + i * 0.37, dir: i % 2 ? -1 : 1, phase: Math.random(), size: [1, 0.92, 1.06, 0.97, 1.03][i], coat: PAINT[(i * 3) % PAINT.length], hair: HAIR[i % HAIR.length], umbrella: umbrellas[i] })) if (this.look.night && !settings.weather) this.rain = Array.from({ length: 30 }, () => ({ x: Math.random() * 2, y: Math.random(), speed: rand(0.9, 1.3) })) } @@ -277,7 +300,7 @@ class City extends Canvas { if (b.x < -0.15) b.x = this.A + 0.15 } for (const w of this.walkers) { - w.phase += cdt * 6 + w.phase += (0.06 * cdt) / (0.07 * w.size) w.x += w.dir * 0.06 * cdt if (w.x > this.A + 0.05) w.x = -0.05 if (w.x < -0.05) w.x = this.A + 0.05 @@ -296,7 +319,7 @@ class City extends Canvas { this.hdr.set(this.background) if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4) const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] - paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, look.clouds.puffy) + paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, this.weather.covered ? undefined : look.orb) paintStorm(this.hdr, W, H, this.storm, look.clouds.top, look.clouds.bottom, this.gloom) if (this.hero.t >= 0) this.drawHero() this.drawBlimp() @@ -304,12 +327,17 @@ class City extends Canvas { this.drawLights() if (this.activity !== "calm") this.drawTrain() if (this.activity === "teeming") for (const b of this.boats) this.drawBoat(b) + this.drawPromenade() + for (const c of this.cars) this.ground(c.x, LANES[c.lane].y, (c.bus ? 0.08 : 0.04) * LANES[c.lane].scale, (c.bus ? 0.023 : 0.015) * LANES[c.lane].scale, 1) for (const c of this.cars) if (c.lane === 0) this.drawCar(c) for (const c of this.cars) if (c.lane === 1) this.drawCar(c) // The barrier sits in front of the near lane, so it is copied back over the cars. const a = Math.floor((ROAD + 0.012) * H) * W * 3 this.hdr.set(this.background.subarray(a, Math.ceil((ROAD + 0.026) * H) * W * 3), a) + for (const w of this.walkers) this.ground(w.x, 0.996, 0.02, 0.1, 0.8) for (const w of this.walkers) this.drawWalker(w) + // At night the lamps under the highway light the sidewalk and the people passing beneath them. + if (look.night) for (let i = 0; this.pillarX(i) - 0.21 < this.A + 0.1; i++) lightPool(this.hdr, W, H, (this.pillarX(i) - 0.21) * H, 0.96 * H, 0.14 * H, 0.08 * H, [1, 0.5, 0.12], 3, 0) for (const b of this.pigeons) this.shape(bird(b, H, 0.022 * H, this.paint([0.42, 0.44, 0.5])), this.lighting, 0.4) this.drawRain() this.weather.draw(this.hdr, W, H) @@ -335,17 +363,22 @@ class City extends Canvas { this.beacons = [] this.flickers = [] this.sign = undefined - // Hazy towers on the far side of the city, then the downtown skyline, tallest toward the middle. + // Hazy towers on the far side of the city, then the skyline, tallest in two districts toward the sides where text + // rarely covers them, and lower across the middle. for (let x = -0.03, i = 0; x < A + 0.03; i++) { const w = 0.035 + hash(i * 1.7 + 0.3) * 0.05 this.paintTower(this.tower(x, w, 0.06 + hash(i * 3.1 + 0.7) ** 1.5 * 0.17, i + 500, true)) x += w * (0.7 + hash(i * 5.3) * 0.4) } + // City haze glowing with the horizon's light over the far towers, which the downtown skyline stands in front of. + const horizon = look.sky[look.sky.length - 1][1] + paintHaze(this.hdr, W, H, BANK - 0.3, BANK, horizon, 0.7) const near: Tower[] = [] for (let x = -0.02, i = 0; x < A + 0.02; i++) { const w = 0.05 + hash(i * 2.3 + 0.1) * 0.055 - const downtown = 0.13 * Math.exp(-(((x + w / 2 - 0.55 * A) / 0.4) ** 2)) - near.push(this.tower(x, w, 0.08 + hash(i * 4.7 + 0.2) ** 1.6 * 0.23 + downtown, i, false)) + const c = x + w / 2 + const downtown = 0.16 * Math.max(Math.exp(-(((c - 0.36) / 0.26) ** 2)), Math.exp(-(((c - A + 0.36) / 0.26) ** 2))) + near.push(this.tower(x, w, 0.08 + hash(i * 4.7 + 0.2) ** 1.6 * (0.16 + downtown * 0.45) + downtown, i, false)) x += w + (hash(i * 6.1) - 0.35) * 0.03 } // The tallest two get a spire and an antenna. @@ -353,6 +386,8 @@ class City extends Canvas { tallest[0].crown = 3 tallest[1].crown = 2 for (const t of near) this.paintTower(t) + if (look === LOOKS.day) haze(this.hdr, sky, W, H, BANK, BANK + 0.01, 0.12) + paintHaze(this.hdr, W, H, BANK - 0.1, BANK, horizon, 0.25) this.visibleStars = this.stars.filter((s) => { const o = (Math.floor(s.y * H) * W + Math.floor(s.x * W)) * 3 return this.hdr[o] === sky[o] && this.hdr[o + 1] === sky[o + 1] && this.hdr[o + 2] === sky[o + 2] @@ -378,6 +413,11 @@ class City extends Canvas { for (let i = 0; i < 400; i++) this.stepTraffic(0.25) } + private ground(x: number, y: number, w: number, h: number, tip?: number) { + const H = this.H + groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip) + } + private paint(c: RGB): RGB { const t = this.look.tint return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] @@ -446,7 +486,8 @@ class City extends Canvas { const shade = day ? side * (0.9 + 0.15 * up) : 1 let r = body[0] * shade let g = body[1] * shade - let b = body[2] * shade + // The shaded side takes the blue of the sky. + let b = body[2] * shade * (side < 1 ? 1.25 : 1) const wu = u - 0.004 const wv = v - 0.006 const fu = wu / cw - Math.floor(wu / cw) @@ -457,9 +498,9 @@ class City extends Canvas { const row = Math.floor(wv / (t.windows === 2 ? ch * 1.5 : ch)) const h = hash2(t.seed * 13.1 + col * 1.7, row * 3.3 + k * 17) const glass = day ? 0.55 + 0.2 * up : 0.6 - r = lerp(r * glass, 0.45, day ? 0.25 : 0) - g = lerp(g * glass, 0.6, day ? 0.25 : 0) - b = lerp(b * glass, 0.78, day ? 0.25 : 0) + r = lerp(r * glass, 0.26, day ? 0.35 : 0) + g = lerp(g * glass, 0.46, day ? 0.35 : 0) + b = lerp(b * glass, 0.78, day ? 0.35 : 0) if (h < look.lit * (t.far ? 0.6 : 1)) { const h2 = hash(h * 91.7) const c = palette[h2 < 0.75 ? t.palette % palette.length : Math.floor(h2 * 997) % palette.length] @@ -623,26 +664,22 @@ class City extends Canvas { private paintHighway() { const { A, H } = this const edge = this.paint([0.45, 0.42, 0.38]) - this.polygon(this.rect(-0.01, QUAY, A + 0.01, ROAD), this.paint([0.5, 0.47, 0.42])) + this.polygon(this.rect(-0.01, QUAY, A + 0.01, ROAD), this.paint([0.52, 0.43, 0.34])) this.polygon(this.rect(-0.01, QUAY, A + 0.01, QUAY + 0.004), edge) const rail: Part[] = [cap(-1, (QUAY - 0.012) * H, A * H + 1, (QUAY - 0.012) * H, 0.0012 * H, 0.0012 * H, edge)] for (let x = 0.01; x < A; x += 0.03) rail.push(cap(x * H, QUAY * H, x * H, (QUAY - 0.012) * H, 0.001 * H, 0.001 * H, edge)) this.shape(rail, this.lighting, 0.3) - // Trees along the promenade, between the highway lamps. - const leaves = this.paint([0.22, 0.42, 0.18]) - for (let x = 0.29; x < A + 0.05; x += 0.34) { - const base = QUAY + 0.014 - this.shape([cap(x * H, base * H, x * H, (base - 0.022) * H, 0.002 * H, 0.0015 * H, this.paint([0.35, 0.25, 0.16]))], this.lighting, 0.4) - this.shape([ell(x * H, (base - 0.036) * H, 0.014 * H, 0.013 * H, 0, leaves), ell((x - 0.011) * H, (base - 0.026) * H, 0.011 * H, 0.009 * H, 0, leaves), ell((x + 0.011) * H, (base - 0.027) * H, 0.011 * H, 0.009 * H, 0, leaves)], this.lighting, 0.7) - } + // Shadows of the promenade trees, which are drawn each frame so they can sway. + for (let x = 0.29; x < A + 0.05; x += 0.34) this.ground(x, QUAY + 0.014, 0.03, 0.05, 1.3) // Shade under the deck, deepening toward it, then the street and its sidewalk. for (let y = UNDER; y < STREET; y += 0.005) this.polygon(this.rect(-0.01, y, A + 0.01, y + 0.0052), this.paint([0.12 + (y - UNDER) * 2, 0.12 + (y - UNDER) * 2, 0.15 + (y - UNDER) * 2])) this.polygon(this.rect(-0.01, STREET, A + 0.01, 0.965), this.paint([0.26, 0.26, 0.28])) for (let x = 0.02; x < A; x += 0.07) this.polygon(this.rect(x, 0.931, x + 0.032, 0.935), this.paint([0.8, 0.72, 0.35])) - this.polygon(this.rect(-0.01, 0.965, A + 0.01, 1.01), this.paint([0.48, 0.46, 0.43])) + this.polygon(this.rect(-0.01, 0.965, A + 0.01, 1.01), this.paint([0.44, 0.37, 0.31])) this.polygon(this.rect(-0.01, 0.965, A + 0.01, 0.969), this.paint([0.62, 0.6, 0.56])) for (let i = 0; this.pillarX(i) - 0.04 < A; i++) { const x = this.pillarX(i) + this.ground(x, STREET, 0.045, STREET - UNDER, 1) this.polygon([[(x - 0.028) * H, UNDER * H], [(x + 0.028) * H, UNDER * H], [(x + 0.02) * H, STREET * H], [(x - 0.02) * H, STREET * H]], this.paint(CONCRETE)) this.polygon([[(x + 0.012) * H, UNDER * H], [(x + 0.028) * H, UNDER * H], [(x + 0.02) * H, STREET * H], [(x + 0.008) * H, STREET * H]], this.paint([0.42, 0.41, 0.4])) } @@ -746,6 +783,39 @@ class City extends Canvas { if (this.sign && look.glow > 0 && hash(Math.floor(this.time * 0.4) + 3.7) > 0.15) this.neon(this.sign, true, true) } + // Trees along the promenade between the highway lamps, nodding in the wind, and two flags that hang slack in calm + // air and stream out when a gust comes through. + private drawPromenade() { + const { A, H } = this + const base = QUAY + 0.014 + const leaves = this.paint([0.22, 0.42, 0.18]) + for (let i = 0, x = 0.29; x < A + 0.05; i++, x += 0.34) { + const lean = sway(x, this.time, i * 1.7, 0.9) * 0.0015 + this.shape([cap(x * H, base * H, (x + lean * 0.5) * H, (base - 0.022) * H, 0.002 * H, 0.0015 * H, this.paint([0.35, 0.25, 0.16]))], this.lighting, 0.4) + this.shape([ell((x + lean) * H, (base - 0.036) * H, 0.014 * H, 0.013 * H, 0, leaves), ell((x - 0.011 + lean * 0.7) * H, (base - 0.026) * H, 0.011 * H, 0.009 * H, 0, leaves), ell((x + 0.011 + lean * 0.7) * H, (base - 0.027) * H, 0.011 * H, 0.009 * H, 0, leaves)], this.lighting, 0.7) + } + const pole = this.paint([0.62, 0.62, 0.64]) + for (const [i, x] of [0.205, 0.205 + 0.34 * Math.floor((A - 0.4) / 0.34)].entries()) { + const top = base - 0.075 + this.shape([cap(x * H, base * H, x * H, (top - 0.004) * H, 0.0013 * H, 0.001 * H, pole)], this.lighting, 0.4) + const g = gust(x, this.time) + // From hanging nearly straight down to flying level, rippling faster the harder it blows. + const droop = 1.25 * (1 - (0.3 + 0.7 * g)) + const L = 0.03 + const upper: [number, number][] = [] + const lower: [number, number][] = [] + for (let k = 0; k <= 6; k++) { + const s = (k / 6) * L + const wave = Math.sin(k * 1.1 - this.time * (3 + 5 * g) + i) * 0.0025 * (k / 6) + const px = x + s * Math.cos(droop) + wave * Math.sin(droop) + const py = top + s * Math.sin(droop) + wave * Math.cos(droop) + upper.push([px * H, py * H]) + lower.push([px * H, (py + 0.016) * H]) + } + this.polygon([...upper, ...lower.reverse()], this.paint(FLAGS[i])) + } + } + private stepTraffic(dt: number) { for (const c of this.cars) c.x += LANES[c.lane].dir * LANES[c.lane].speed * dt this.cars = this.cars.filter((c) => c.x > -0.15 && c.x < this.A + 0.15) @@ -946,24 +1016,37 @@ class City extends Canvas { if (lit) this.glow(this.hdr, X(L / 2), y - 0.006 * H, 0.004 * H, 0.004 * H, HEADLIGHT, 1.5 * look.glow) } - // People strolling along the sidewalk, under umbrellas when it rains. + // People strolling along the sidewalk, under umbrellas when it rains. Each step lands and holds, knees bending, + // arms swinging against the legs and the body rising over each stride. private drawWalker(w: Walker) { const { H, look } = this - const S = 0.1 * H - const x = w.x * H - const y = 0.996 * H - const P = (u: number, v: number): [number, number] => [x + u * w.dir * S, y + v * S] - const swing = Math.sin(w.phase) * 0.12 + const S = 0.1 * H * w.size + const rise = bob(w.phase) * 0.015 + const P = (u: number, v: number): [number, number] => [w.x * H + u * w.dir * S, 0.996 * H + (v - rise) * S] const coat = this.paint(w.coat) const legs = this.paint([0.15, 0.15, 0.2]) const skin = this.paint([0.8, 0.6, 0.48]) + const shoe = this.paint([0.08, 0.07, 0.07]) + const side = (s: number, far: boolean): Part[] => { + const [reach, lift] = stride(w.phase, s > 0 ? 0 : 0.5) + const fu = reach * 0.21 + const fv = -lift * 0.07 - 0.025 + rise + const umbrella = this.wet && !far + const hand: [number, number] = umbrella ? [0.12, -0.85] : [-reach * 0.13 + 0.02, -0.44] + const shade = (c: RGB): RGB => (far ? [c[0] * 0.7, c[1] * 0.7, c[2] * 0.7] : c) + return [ + ...limb(P, 0, -0.47, fu, fv, 0.24, 0.24, -1, 0.045 * S, 0.037 * S, 0.032 * S, shade(legs)), + cap(...P(fu - 0.02, fv), ...P(fu + 0.06, fv + 0.006), 0.028 * S, 0.024 * S, shoe), + ...limb(P, 0.01, -0.77, ...hand, 0.18, 0.18, umbrella ? -1 : 1, 0.033 * S, 0.028 * S, 0.025 * S, shade(coat)), + ] + } + this.shape(side(-1, true), this.lighting, 0.6) this.shape( [ - cap(...P(0, -0.45), ...P(-swing, 0), 0.04 * S, 0.035 * S, legs), - cap(...P(0, -0.45), ...P(swing, 0), 0.04 * S, 0.035 * S, legs), + ...side(1, false), cap(...P(0, -0.42), ...P(0.01, -0.8), 0.085 * S, 0.075 * S, coat), - cap(...P(0.01, -0.76), ...P(swing * 0.5 + (this.wet ? 0.12 : 0), this.wet ? -0.85 : -0.45), 0.03 * S, 0.026 * S, coat), - ell(...P(0.02, -0.89), 0.06 * S, 0.068 * S, 0, skin), + ell(...P(0.02, -0.89 + Math.cos(TAU * 2 * w.phase - 1) * 0.008), 0.06 * S, 0.068 * S, 0, skin), + ell(...P(0.005, -0.925 + Math.cos(TAU * 2 * w.phase - 1) * 0.008), 0.062 * S, 0.04 * S, -0.3 * w.dir, this.paint(w.hair)), ], this.lighting, 0.6, @@ -1007,9 +1090,9 @@ export const city: Wallpaper = { [26, 26, 32], ], sunset: [ - [30, 14, 26], - [34, 14, 22], - [14, 8, 12], + [28, 8, 26], + [38, 16, 12], + [16, 8, 12], ], night: [ [3, 4, 16], diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 570ddf3..5583a7e 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -1,10 +1,15 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { chain } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, fbm2, hash, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeStorm, paintStorm } from "../src/sky" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { campfire, lightPool } from "../src/light" +import { driftClouds, makeClouds, makeStorm, paintClouds, paintStorm, type Cloud } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -23,7 +28,7 @@ interface Look { orb: { x: number; y: number; r: number; core: RGB; glow: RGB; near: number; wide: number; moon: boolean } stars: number milkyWay: boolean - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } farMesa: RGB nearMesa: RGB floor: [RGB, RGB] @@ -62,15 +67,15 @@ const LOOKS: Record = { orb: { x: 0.78, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.15, moon: false }, stars: 0, milkyWay: false, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.55, 0.62, 0.75], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.55, 0.62, 0.75], alpha: 0.9 }, farMesa: [0.62, 0.36, 0.26], nearMesa: [0.62, 0.27, 0.13], floor: [ - [0.66, 0.52, 0.38], - [0.6, 0.42, 0.26], + [0.66, 0.5, 0.34], + [0.6, 0.4, 0.23], ], - backDune: [0.7, 0.5, 0.3], - frontDune: [0.76, 0.54, 0.3], + backDune: [0.72, 0.48, 0.26], + frontDune: [0.78, 0.52, 0.27], cactus: [0.16, 0.32, 0.12], bone: [0.86, 0.8, 0.68], horn: [0.72, 0.62, 0.45], @@ -95,26 +100,27 @@ const LOOKS: Record = { }, sunset: { style: "rim", - light: [1, 0.42, 0.14], + light: [1, 0.5, 0.16], + // Dust: a rust-brown sky thickening to a hot ochre haze along the horizon. sky: [ - [0, [0.012, 0.01, 0.04]], - [0.25, [0.05, 0.02, 0.085]], - [0.42, [0.2, 0.055, 0.12]], - [0.52, [0.55, 0.16, 0.12]], - [HORIZON, [0.95, 0.4, 0.14]], + [0, [0.03, 0.014, 0.012]], + [0.22, [0.09, 0.035, 0.022]], + [0.4, [0.3, 0.1, 0.04]], + [0.52, [0.66, 0.27, 0.08]], + [HORIZON, [0.95, 0.55, 0.2]], ], - orb: { x: 0.6, y: 0.53, r: 0.065, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25, moon: false }, - stars: 45, + orb: { x: 0.6, y: 0.565, r: 0.09, core: [2.6, 1.25, 0.42], glow: [1, 0.48, 0.14], near: 0.42, wide: 0.42, moon: false }, + stars: 15, milkyWay: false, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.75, 0.24, 0.2], alpha: 0.55 }, - farMesa: [0.07, 0.03, 0.06], - nearMesa: [0.07, 0.025, 0.04], + clouds: { top: [0.14, 0.05, 0.025], bottom: [0.8, 0.38, 0.12], alpha: 0.5 }, + farMesa: [0.32, 0.1, 0.05], + nearMesa: [0.13, 0.04, 0.02], floor: [ - [0.4, 0.14, 0.07], - [0.1, 0.04, 0.03], + [0.62, 0.28, 0.1], + [0.14, 0.06, 0.025], ], - backDune: [0.075, 0.03, 0.025], - frontDune: [0.04, 0.017, 0.014], + backDune: [0.2, 0.08, 0.035], + frontDune: [0.12, 0.048, 0.022], cactus: [0.02, 0.05, 0.025], bone: [0.62, 0.5, 0.4], horn: [0.48, 0.38, 0.26], @@ -139,25 +145,25 @@ const LOOKS: Record = { }, night: { style: "rim", - light: [0.18, 0.42, 1], + light: [0.28, 0.6, 1.45], sky: [ [0, [0.004, 0.006, 0.02]], - [0.3, [0.01, 0.018, 0.05]], - [0.5, [0.025, 0.04, 0.09]], - [HORIZON, [0.05, 0.07, 0.13]], + [0.3, [0.012, 0.02, 0.06]], + [0.5, [0.035, 0.055, 0.13]], + [HORIZON, [0.08, 0.11, 0.24]], ], orb: { x: 0.3, y: 0.16, r: 0.035, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 160, milkyWay: true, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.5 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.5 }, farMesa: [0.03, 0.04, 0.08], nearMesa: [0.012, 0.016, 0.035], floor: [ - [0.05, 0.06, 0.1], - [0.015, 0.018, 0.035], + [0.07, 0.09, 0.17], + [0.025, 0.03, 0.06], ], - backDune: [0.025, 0.03, 0.055], - frontDune: [0.012, 0.014, 0.028], + backDune: [0.035, 0.045, 0.095], + frontDune: [0.02, 0.025, 0.055], cactus: [0.01, 0.02, 0.02], bone: [0.1, 0.2, 0.42], horn: [0.08, 0.13, 0.28], @@ -175,7 +181,7 @@ const LOOKS: Record = { ], twig: [0.06, 0.09, 0.2], night: true, - plumage: [0.08, 0.075, 0.08], + plumage: [0.05, 0.06, 0.12], plumageLight: [0.08, 0.1, 0.22], flock: [0.02, 0.02, 0.03], dust: [0, 0, 0], @@ -214,13 +220,6 @@ interface Mesa { slope: number } -interface Cloud { - x: number - y: number - speed: number - puffs: { dx: number; dy: number; rx: number; ry: number }[] -} - interface Bird { angle: number speed: number @@ -245,6 +244,9 @@ interface Snake { interface Coyote { howl: number + // Where the head is between resting (0) and howling (1): it chases howl on a spring, so it lags and overshoots. + head: number + headV: number t: number next: number } @@ -263,7 +265,7 @@ class Desert extends Canvas { private dust = Array.from({ length: 60 }, () => ({ x: Math.random() * 4, y: 0.3 + Math.random() * 0.7, s: Math.random() })) private clouds: Cloud[] private storm = makeStorm() - private tumbleweed = { x: -9, dir: 1, next: 14, spin: 0, hop: 0 } + private tumbleweed = { x: -9, next: 14, spin: 0, hop: 0 } private twigs = buildTwigs() private bigBird: Bird | undefined private flock: Bird[] = [] @@ -278,26 +280,22 @@ class Desert extends Canvas { private readonly flowerLight: RGB private readonly dusting: RGB private readonly weather: WeatherLayer + private readonly shade: Shade constructor(settings: Settings) { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 this.season = settings.season ?? "summer" this.flowerLight = FLOWER_LIGHT[settings.time] this.dusting = DUSTING[settings.time] this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) const look = this.look const top = look.milkyWay ? 0.5 : 0.3 this.stars = Array.from({ length: look.stars }, () => ({ x: Math.random(), y: Math.random() * top, b: 0.4 + Math.random() * 0.6, phase: Math.random() * TAU, tint: STAR_TINTS[Math.floor(Math.random() * STAR_TINTS.length)] })) - this.clouds = Array.from({ length: look.clouds.puffy ? 4 : 5 }, () => ({ - x: Math.random() * 2, - y: look.clouds.puffy ? 0.1 + Math.random() * 0.2 : 0.12 + Math.random() * 0.24, - speed: 0.003 + Math.random() * 0.004, - puffs: look.clouds.puffy - ? Array.from({ length: 6 }, (_, i) => ({ dx: (i / 5 - 0.5) * 0.16 + (Math.random() - 0.5) * 0.03, dy: -Math.sin((i / 5) * Math.PI) * 0.02 + (Math.random() - 0.5) * 0.01, rx: 0.03 + Math.random() * 0.025, ry: 0.022 + Math.random() * 0.014 })) - : Array.from({ length: 5 }, () => ({ dx: (Math.random() - 0.5) * 0.22, dy: (Math.random() - 0.5) * 0.015, rx: 0.06 + Math.random() * 0.08, ry: 0.008 + Math.random() * 0.01 })), - })) + this.clouds = makeClouds(5, "cirrus", 0.08, 0.32) if (settings.activity !== "calm") { this.bigBird = { angle: 0, speed: 0.5, cx: 0.55, cy: 0.2, rx: 0.3, ry: 0.05, phase: 0 } this.snake = { x: 0.5, y: 0.9, vx: 0, vy: 0, wx: 0.5, wy: 0.9, wanderT: 0, phase: 0, trail: [] } @@ -306,7 +304,7 @@ class Desert extends Canvas { this.flock = look.night ? [0, 1.6, 3.1, 4.7].map((angle, i) => ({ angle, speed: 0.9 + i * 0.12, cx: 0.72, cy: 0.3, rx: 0.06 + i * 0.03, ry: 0.04, phase: i * 1.7 })) : [0, 2.1, 4.2].map((angle, i) => ({ angle, speed: 0.4 + i * 0.05, cx: 0.25, cy: 0.16, rx: 0.08 + i * 0.035, ry: 0.025, phase: i })) - this.coyote = { howl: 0, t: -1, next: 8 } + this.coyote = { howl: 0, head: 0, headV: 0, t: -1, next: 8 } } } @@ -314,14 +312,11 @@ class Desert extends Canvas { dt = clamp(dt, 0, 0.1) * TIME_SCALE this.time += dt for (const d of this.dust) { - d.x += (0.01 + d.s * 0.012) * dt + d.x += (0.01 + d.s * 0.012 + gust(d.x, this.time) * 0.06) * dt d.y += Math.sin(this.time * 0.5 + d.s * 30) * 0.002 * dt if (d.x > this.A) d.x -= this.A } - for (const c of this.clouds) { - c.x += c.speed * dt - if (c.x > this.A + 0.4) c.x = -0.4 - } + driftClouds(this.clouds, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.stepTumbleweed(dt) @@ -339,8 +334,11 @@ class Desert extends Canvas { render() { this.hdr.set(this.background) + // Heat haze over the sunlit floor, but not in winter or under cloud. + if (this.look.style === "front" && this.season !== "winter" && !this.weather.covered) shimmer(this.hdr, this.background, this.W, this.H, this.time) if (!this.weather.covered) this.drawStars() - this.drawClouds() + const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.weather.covered ? undefined : this.look.orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) for (const b of this.flock) this.look.night ? this.drawBat(b) : this.drawBird(b, 0.3, "vulture") if (this.bigBird) this.drawBird(this.bigBird, 0.8, this.look.night ? "owl" : "eagle") @@ -349,6 +347,7 @@ class Desert extends Canvas { if (this.roadrunner.t >= 0) this.drawRoadrunner() for (const b of this.startled) this.silhouette(bird(b, this.H, 0.022 * this.H, this.look.flock), 0.4) this.drawTumbleweed() + if (this.look.night) this.drawCampfire() this.drawDust() this.weather.draw(this.hdr, this.W, this.H) this.finish() @@ -365,6 +364,8 @@ class Desert extends Canvas { this.orbY = look.orb.y * H this.drawSky() this.weather.cover(this.hdr, W, H) + const sky = this.hdr.slice() + const day = look === LOOKS.day const far = mesas(A, 3, 7, [0.06, 0.16], [0.04, 0.09]) const near = mesas(A, 2, 19, [0.05, 0.12], [0.06, 0.13]) this.nearTop = new Float32Array(W) @@ -382,6 +383,8 @@ class Desert extends Canvas { }) this.drawFloor() this.fillBelow(this.nearTop, (x, y, d) => (y > (HORIZON + 0.02) * H ? undefined : this.surface(look.nearMesa, this.nearTop, x, d, 1.6, 0.8, true))) + // By day the mesas and the far floor fade toward the sky, layer by layer. + if (day) haze(this.hdr, sky, W, H, HORIZON, 0.72, 0.12) const backTop = new Float32Array(W) this.frontTop = new Float32Array(W) for (let x = 0; x < W; x++) { @@ -394,27 +397,29 @@ class Desert extends Canvas { const ripple = 0.9 + 0.1 * Math.sin(x * 0.5 + d * 0.9 + fbm1(x * 0.05, 3) * 4) return this.surface(scale(look.frontDune, ripple), this.frontTop, x, d, 2.5, 0.5, false) }) + if (day) mottle(this.hdr, W, H, HORIZON, 1, 0.6, [1.1, 0.98, 0.8], [0.72, 0.7, 0.86]) if (this.season === "spring") this.drawBloom() if (this.activity === "teeming") { - for (const fx of [0.47, 0.92]) { - const gy = backTop[Math.min(W - 1, Math.round(fx * W))] + 2 - this.shadow(fx * A * H, gy, 0.05 * H) - this.drawSaguaro(fx * A * H, gy, (fx < 0.5 ? 0.14 : 0.11) * H, 0.5) + for (const [x, h] of [[0.45, 0.14], [A - 0.14, 0.11]]) { + const gy = backTop[clamp(Math.round(x * H), 0, W - 1)] + 2 + this.shadow(x * H, gy, 0.25 * h * H, h * H) + this.drawSaguaro(x * H, gy, h * H, 0.5) } } - this.shadow(0.13 * A * H, this.ground(0.13), 0.12 * H) - this.drawSaguaro(0.13 * A * H, this.ground(0.13) + 3, 0.34 * H, 0) - this.shadow(0.8 * A * H, this.ground(0.8), 0.08 * H) - this.drawSkull(0.8 * A * H, this.ground(0.8), 0.07 * H) + // The big saguaro and the skull keep to the sides, clear of the text in the middle. + this.shadow(0.22 * H, this.ground(0.22 / A), 0.085 * H, 0.34 * H) + this.drawSaguaro(0.22 * H, this.ground(0.22 / A) + 3, 0.34 * H, 0) + this.shadow((A - 0.32) * H, this.ground(1 - 0.32 / A), 0.07 * H, 0.1 * H, 2.5) + this.drawSkull((A - 0.32) * H, this.ground(1 - 0.32 / A), 0.07 * H) if (this.activity === "teeming") { - this.shadow(0.33 * A * H, this.ground(0.33), 0.05 * H) + this.shadow(0.33 * A * H, this.ground(0.33), 0.09 * H, 0.16 * H, 1.1) this.drawPricklyPear(0.33 * A * H, this.ground(0.33) + 2, H) - this.shadow(0.62 * A * H, this.ground(0.62), 0.035 * H) + this.shadow(0.62 * A * H, this.ground(0.62), 0.06 * H, 0.07 * H, 0.8) this.drawBarrel(0.62 * A * H, this.ground(0.62) + 1, H) } - // The coyote sits on the tallest near mesa. - let best = 0 - for (let x = 1; x < W; x++) if (this.nearTop[x] < this.nearTop[best]) best = x + // The coyote sits on the tallest near mesa toward either side, where text rarely covers it, but not behind the saguaro. + let best = W - 1 - Math.round(W * 0.05) + for (let x = 0; x < W; x++) if (Math.abs(x / W - 0.5) > 0.3 && Math.abs(x / W - 0.5) < 0.47 && Math.abs(x / H - 0.22) > 0.1 && this.nearTop[x] < this.nearTop[best]) best = x this.coyoteX = best this.background = this.hdr.slice() } @@ -543,37 +548,23 @@ class Desert extends Canvas { } } - // By day, a soft shadow on the ground falling to the lower left of something standing at (x, gy). - private shadow(x: number, gy: number, length: number) { - if (this.look.style !== "front") return - const { W, H, hdr } = this - const cx = x - length * 0.55 - const rx = length * 0.7 - const ry = Math.max(1.5, length * 0.12) - for (let y = Math.max(0, Math.floor(gy - ry)); y <= Math.min(H - 1, Math.ceil(gy + ry)); y++) - for (let px = Math.max(0, Math.floor(cx - rx)); px <= Math.min(W - 1, Math.ceil(cx + rx)); px++) { - const q = ((px + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - gy) / ry) ** 2 - if (q >= 1) continue - const k = 1 - 0.45 * (1 - q) ** 0.7 - const o = (y * W + px) * 3 - hdr[o] *= k - hdr[o + 1] *= k - hdr[o + 2] *= k - } + // The shadows of something w pixels wide and h tall standing at (x, gy). + private shadow(x: number, gy: number, w: number, h: number, tip?: number, lift?: number) { + groundShadow(this.hdr, this.W, this.H, this.shade, x, gy, w, h, tip, lift) } + // The tumbleweed rolls with the wind, ambling in calm air and bowling along when a gust catches it. private stepTumbleweed(dt: number) { const t = this.tumbleweed if (t.x < -5) { t.next -= dt if (t.next > 0) return - t.dir = Math.random() < 0.5 ? 1 : -1 - t.x = t.dir > 0 ? -0.1 : this.A + 0.1 + t.x = -0.1 return } - const move = 0.06 * dt - t.x += t.dir * move - t.spin += (t.dir * move) / 0.035 + const move = (0.045 + 0.1 * gust(t.x, this.time)) * dt + t.x += move + t.spin += move / 0.035 t.hop += move / 0.05 if (t.x < -0.2 || t.x > this.A + 0.2) { t.x = -9 @@ -624,6 +615,8 @@ class Desert extends Canvas { } } c.howl = c.t < 0 ? 0 : smoothstep(0, 1.5, c.t) * (1 - smoothstep(4.5, 6, c.t)) + c.headV += ((c.howl - c.head) * 30 - c.headV * 3.5) * dt + c.head += c.headV * dt } } @@ -634,7 +627,7 @@ class Desert extends Canvas { s.wanderT -= dt if (s.wanderT <= 0 || Math.hypot(s.wx - s.x, s.wy - s.y) < 0.02) { s.wanderT = rand(6, 14) - s.wx = rand(0.3 * A, 0.7 * A) + s.wx = this.openSpot(0.3 * A, 0.7 * A, 0.9, 0.9)[0] s.wy = crest(s.wx) + rand(0.015, 0.06) } const dx = s.wx - s.x @@ -657,6 +650,17 @@ class Desert extends Canvas { this.shape(parts, lighting, light) } + // A small campfire on the dunes at night, its warm light falling on the sand and the saguaro beside it. + private drawCampfire() { + const H = this.H + const x = 0.21 * this.A * H + const gy = this.ground(0.21) + 0.01 * H + const fire = campfire(x, gy, H, this.time) + lightPool(this.hdr, this.W, H, x, gy - 0.03 * H, 0.32 * H, 0.14 * H, [1, 0.4, 0.07], 2.6 * fire.flicker, 0.04 * fire.flicker) + this.shape(fire.logs, { style: "rim", color: [1.4, 0.5, 0.1], x, y: gy - 0.03 * H }) + for (const flame of fire.flames) this.shape(flame, { style: "front", dir: [0, 0, 1] }, 0) + } + // A saguaro: a ribbed trunk with two upturned arms. haze fades distant ones toward the dunes behind them. private drawSaguaro(x: number, gy: number, h: number, haze: number) { const c = mix(this.look.cactus, this.look.backDune, haze * 0.6) @@ -732,34 +736,6 @@ class Desert extends Canvas { } } - // Clouds drifting slowly across: puffy and sunlit from above by day, thin streaks lit from below otherwise. - private drawClouds() { - const { H, W, hdr, look } = this - const { alpha, puffy } = look.clouds - const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] - for (const c of this.clouds) - for (const p of c.puffs) { - const cx = (c.x + p.dx) * H - const cy = (c.y + p.dy) * H - const rx = p.rx * H - const ry = p.ry * H - const warm = puffy ? 1 : 0.6 + 0.6 * Math.exp(-Math.abs(cx - this.orbX) / (0.5 * H)) - for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) - for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { - const dx = (x + 0.5 - cx) / rx - const dy = (y + 0.5 - cy) / ry - const q = dx * dx + dy * dy - if (q >= 1) continue - const under = smoothstep(-1, 1, dy) - const o = (y * W + x) * 3 - const a = (puffy ? Math.min(1, (1 - q) * 3) : (1 - q) ** 1.5) * alpha - hdr[o] += (lerp(top[0], bottom[0], under) * warm - hdr[o]) * a - hdr[o + 1] += (lerp(top[1], bottom[1], under) * warm - hdr[o + 1]) * a - hdr[o + 2] += (lerp(top[2], bottom[2], under) * warm - hdr[o + 2]) * a - } - } - } - // A soaring bird seen from the front, wings spread and banking as it circles. A bald eagle has a white head and // tail and fingered wingtips; a vulture is small and dark with lifted wingtips; an owl has broad rounded wings, a // round face with ear tufts, and glowing eyes. @@ -779,7 +755,8 @@ class Desert extends Canvas { const flex = 0.006 * Math.sin(b.phase * 1.3) const parts: Part[] = [] for (const side of [-1, 1]) { - const spine = (t: number): [number, number] => [side * (0.028 + 0.125 * t), -dihedral * t * t + flex * t * t] + // The wingtips lag the roll, bending against the way it is banking. + const spine = (t: number): [number, number] => [side * (0.028 + 0.125 * t), -dihedral * t * t + flex * t * t + side * 0.025 * Math.cos(b.angle) * t * t * t] for (let i = 0; i < 6; i++) parts.push(cap(...T(...spine(i / 6)), ...T(...spine((i + 1) / 6)), chord * (1 - 0.3 * (i / 6)) * S, chord * (1 - 0.3 * ((i + 1) / 6)) * S, main)) const [tu, tv] = spine(0.92) if (kind === "owl") { @@ -836,7 +813,8 @@ class Desert extends Canvas { this.silhouette(parts, 0.35) } - // A coyote sitting on top of a mesa, facing the sun or moon, that now and then lifts its head to howl. + // A coyote sitting on top of a mesa, facing the sun or moon, that now and then lifts its head to howl. The head + // swings up and settles back a little past rest, ears trailing it, and the brush of a tail sweeps now and then. private drawCoyote(c: Coyote) { const x = this.coyoteX + 0.5 const gy = this.nearTop[this.coyoteX] + 0.5 @@ -845,24 +823,27 @@ class Desert extends Canvas { const P = (u: number, v: number): [number, number] => [x + u * face * S, gy + v * S] const k = this.look.coyote // The head turns from looking ahead to pointing at the sky; R places points in the head's frame. - const a = lerp(-0.2, -1.2, c.howl) - const head = [0.21, -0.66] + const a = lerp(-0.15, -1.2, c.head) + const head = [0.2, -0.66] const R = (u: number, v: number): [number, number] => P(head[0] + u * Math.cos(a) - v * Math.sin(a), head[1] + u * Math.sin(a) + v * Math.cos(a)) + const ears = 0.4 * c.head + (c.howl - c.head) * 2 + const sweep = Math.sin(this.time * CREATURE_SPEED * 1.7) ** 3 * 0.04 + const tail = (t: number) => P(-0.18 - t * 0.3, -0.07 + t * 0.04 - Math.sin(Math.PI * t) * 0.04 + sweep * t) this.silhouette( [ - cap(...P(-0.17, -0.06), ...P(-0.3, -0.02), 0.06 * S, 0.055 * S, k), - cap(...P(-0.3, -0.02), ...P(-0.42, -0.01), 0.055 * S, 0.03 * S, k), - ell(...P(-0.05, -0.17), 0.17 * S, 0.17 * S, 0, k), - cap(...P(-0.02, -0.22), ...P(0.12, -0.48), 0.13 * S, 0.11 * S, k), - cap(...P(0.12, -0.45), ...P(0.16, -0.12), 0.08 * S, 0.055 * S, k), - cap(...P(0.15, -0.15), ...P(0.17, 0), 0.035 * S, 0.03 * S, k), - cap(...P(0.11, -0.15), ...P(0.12, 0), 0.03 * S, 0.028 * S, k), - ell(...P(0.04, -0.025), 0.1 * S, 0.03 * S, 0, k), - cap(...P(0.12, -0.5), ...P(head[0], head[1]), 0.07 * S, 0.06 * S, k), - ell(...R(0, 0), 0.08 * S, 0.062 * S, a * face, k), - cap(...R(0, 0), ...R(0.15, 0.01), 0.045 * S, 0.02 * S, k), - cap(...R(-0.025, -0.045), ...R(-0.055, -0.135), 0.026 * S, 0.004 * S, k), - cap(...R(0.005, -0.05), ...R(-0.015, -0.14), 0.024 * S, 0.004 * S, k), + ...chain(tail, 3, 0.05 * S, 0.065 * S, k), + ell(...tail(1), 0.06 * S, 0.04 * S, 0, k), + ell(...P(-0.08, -0.15), 0.16 * S, 0.15 * S, 0, k), + ell(...P(0.02, -0.03), 0.12 * S, 0.03 * S, 0, k), + cap(...P(-0.04, -0.2), ...P(0.1, -0.48), 0.14 * S, 0.105 * S, k), + cap(...P(0.12, -0.42), ...P(0.16, -0.03), 0.055 * S, 0.03 * S, k), + cap(...P(0.08, -0.42), ...P(0.11, -0.03), 0.05 * S, 0.028 * S, k), + ell(...P(0.18, -0.02), 0.045 * S, 0.022 * S, 0, k), + cap(...P(0.1, -0.48), ...P(head[0], head[1]), 0.085 * S, 0.06 * S, k), + ell(...R(0, 0), 0.075 * S, 0.058 * S, a * face, k), + cap(...R(0.02, 0.005), ...R(0.17, 0.02), 0.042 * S, 0.014 * S, k), + cap(...R(-0.025, -0.04), ...R(-0.05 - ears * 0.05, -0.15 + ears * 0.03), 0.03 * S, 0.004 * S, k), + cap(...R(0.01, -0.045), ...R(-0.005 - ears * 0.05, -0.15 + ears * 0.03), 0.027 * S, 0.004 * S, k), ], 1.3, ) @@ -920,6 +901,7 @@ class Desert extends Canvas { const x = r.x * H const gy = this.ground(r.x / this.A) + 1 const S = 0.14 * H + this.shadow(x - 0.05 * S, gy, 0.5 * S, 0.6 * S, 0.6) const P = (u: number, v: number): [number, number] => [x + u * S, gy + v * S] const bob = running ? 0 : Math.max(0, Math.sin((r.t - r.halt) * 12)) * 0.05 const tail = running ? -0.47 : -0.78 @@ -950,7 +932,7 @@ class Desert extends Canvas { const cx = t.x * H const hop = Math.abs(Math.sin(t.hop)) * 0.012 * H const gy = this.ground(t.x / this.A) - this.shadow(cx + R * 0.8, gy, R * (2 - hop / (0.012 * H))) + this.shadow(cx, gy, R * 2, R * 2, 1, hop) const cy = gy - R + 1 - hop const cs = Math.cos(t.spin) const sn = Math.sin(t.spin) @@ -978,6 +960,27 @@ class Desert extends Canvas { } } +// Heat haze: rows just above the hot floor at the horizon waver sideways by a fraction of a pixel, so the feet of the +// distant mesas swim. +function shimmer(hdr: Float32Array, background: Float32Array, W: number, H: number, t: number) { + for (let y = Math.floor((HORIZON - 0.05) * H); y < Math.ceil((HORIZON + 0.02) * H); y++) { + const v = y / H + const d = 0.0025 * H * Math.exp(-(((v - HORIZON + 0.008) / 0.02) ** 2)) * Math.sin(v * 520 + t * 5) * (0.6 + 0.4 * Math.sin(v * 90 - t * 1.7)) + if (d < 0.02 && d > -0.02) continue + const shift = Math.floor(d) + const f = d - shift + const row = y * W + for (let x = 0; x < W; x++) { + const a = (row + clamp(x - shift, 0, W - 1)) * 3 + const b = (row + clamp(x - shift - 1, 0, W - 1)) * 3 + const o = (row + x) * 3 + hdr[o] = background[a] * (1 - f) + background[b] * f + hdr[o + 1] = background[a + 1] * (1 - f) + background[b + 1] * f + hdr[o + 2] = background[a + 2] * (1 - f) + background[b + 2] * f + } + } +} + function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } @@ -1037,9 +1040,9 @@ export const desert: Wallpaper = { [44, 28, 16], ], sunset: [ - [14, 10, 30], - [40, 16, 24], - [16, 8, 8], + [24, 12, 8], + [40, 18, 10], + [16, 8, 6], ], night: [ [4, 6, 16], diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 868c944..0f9f5bc 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -1,9 +1,14 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { bob, body, chain, gait, limb, quadruped, stepGait, stride, type Gait } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" +import { fireflyLight, lightPool } from "../src/light" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -11,15 +16,16 @@ const TIME_SCALE = 0.35 const CREATURE_SPEED = 0.25 const HORIZON = 0.55 -// What changes with the time of day. Buildings, crops and animals have one daytime color each; tint darkens them into -// silhouettes at sunset and night, while the sky, hills and window light get their own values. +// What changes with the time of day. Buildings, crops and animals have one daytime color each; tint warms them in the +// golden-hour sunset and darkens them into silhouettes at night, while the sky, hills and window light get their own +// values. interface Look { style: "rim" | "front" light: RGB sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } hills: [far: RGB, near: RGB] tint: RGB windows: number @@ -37,10 +43,10 @@ const LOOKS: Record = { ], orb: { x: 0.82, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.9 }, hills: [ [0.42, 0.6, 0.38], - [0.3, 0.55, 0.16], + [0.25, 0.5, 0.12], ], tint: [1, 1, 1], windows: 0, @@ -48,40 +54,42 @@ const LOOKS: Record = { }, sunset: { style: "rim", - light: [1, 0.5, 0.2], + // Golden hour: the sun still clear of the hills, a honeyed sky and warm grass in long light. + light: [1, 0.66, 0.25], sky: [ - [0, [0.03, 0.02, 0.08]], - [0.25, [0.12, 0.04, 0.12]], - [0.42, [0.45, 0.14, 0.14]], - [HORIZON, [1, 0.5, 0.18]], + [0, [0.1, 0.08, 0.18]], + [0.18, [0.4, 0.2, 0.18]], + [0.36, [1, 0.52, 0.16]], + [HORIZON, [1, 0.74, 0.3]], ], - orb: { x: 0.28, y: 0.5, r: 0.06, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, - stars: 30, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + orb: { x: 0.2, y: 0.37, r: 0.042, core: [3.2, 2, 0.7], glow: [1, 0.6, 0.2], near: 0.55, wide: 0.35 }, + stars: 0, + clouds: { top: [1.3, 0.8, 0.35], bottom: [0.6, 0.3, 0.22], alpha: 0.8 }, hills: [ - [0.3, 0.12, 0.12], - [0.08, 0.04, 0.03], + [0.68, 0.42, 0.1], + [0.34, 0.24, 0.04], ], - tint: [0.22, 0.13, 0.1], - windows: 0.5, + tint: [0.46, 0.29, 0.13], + windows: 0.25, fireflies: false, }, night: { style: "rim", - light: [0.2, 0.45, 1], + light: [0.3, 0.62, 1.5], sky: [ [0, [0.004, 0.008, 0.03]], - [0.3, [0.012, 0.02, 0.06]], - [HORIZON, [0.04, 0.06, 0.15]], + [0.3, [0.014, 0.024, 0.07]], + [0.45, [0.035, 0.055, 0.14]], + [HORIZON, [0.07, 0.1, 0.24]], ], orb: { x: 0.74, y: 0.15, r: 0.032, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.45 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, hills: [ - [0.02, 0.035, 0.07], - [0.008, 0.016, 0.03], + [0.035, 0.06, 0.13], + [0.016, 0.03, 0.065], ], - tint: [0.025, 0.04, 0.08], + tint: [0.02, 0.032, 0.065], windows: 1, fireflies: true, }, @@ -107,19 +115,19 @@ const GROUND: Partial [0.56, 0.44, 0.17], ], sunset: [ - [0.34, 0.13, 0.09], - [0.1, 0.05, 0.025], + [0.62, 0.34, 0.1], + [0.42, 0.22, 0.05], ], }, - // Snow by day, rosy at sunset, and deep blue at night, never white. + // Snow by day, gold at sunset, and deep blue at night, never white. winter: { day: [ [0.74, 0.79, 0.88], [0.82, 0.86, 0.93], ], sunset: [ - [0.46, 0.25, 0.3], - [0.3, 0.16, 0.19], + [0.6, 0.4, 0.26], + [0.5, 0.34, 0.24], ], night: [ [0.035, 0.07, 0.18], @@ -142,8 +150,14 @@ const CROWN: Record = { autumn: [[0.85, 0.42, 0.1], [[0.95, 0.68, 0.15], [0.7, 0.16, 0.06], [0.55, 0.3, 0.08]]], winter: [[0, 0, 0], []], } -// Where the two trees stand, as fractions of the width. -const TREES = [0.42, 0.95] +// Daytime crops on the far fields, as multipliers of the far hill color. +const CROPS: RGB[] = [ + [1, 1, 1], + [0.66, 0.82, 0.62], + [1.1, 1.08, 0.8], + [1.45, 1.12, 0.45], + [1.2, 0.86, 0.55], +] const WOOD: RGB = [0.45, 0.3, 0.16] const BLACK: RGB = [0.05, 0.05, 0.05] @@ -161,6 +175,7 @@ interface Walker { // Time left standing with the head up, looking toward a click. look: number moving: boolean + g: Gait } class Farm extends Canvas { @@ -169,15 +184,17 @@ class Farm extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] + private shadows = makeShadows(3, HORIZON + 0.08, 0.95) private storm = makeStorm() private nearTop = new Float32Array(0) private cows: Walker[] = [] private chickens: Walker[] = [] private pigs: Walker[] = [] - private farmer = { x: 0.5, dir: 1, phase: 0, wave: -1, next: 20 } + private farmer = { x: 0.5, dir: 1, wave: -1, next: 20, g: gait() } private tractor = { x: -9, dir: 1, next: 14, wheel: 0 } private smoke: { x: number; y: number; age: number }[] = [] private fireflies: { x: number; y: number; vx: number; vy: number; phase: number }[] = [] @@ -188,23 +205,34 @@ class Farm extends Canvas { private readonly weather: WeatherLayer private hills: [far: RGB, near: RGB] private treeTops: [number, number][] = [] + // The barn and windmill stand toward the edges, in screen heights from the left, clear of the text in the middle. + private barnX = 0 + private millX = 0.25 // Blossom petals in spring and leaves in autumn, drifting down on the breeze. private leaves: { x: number; y: number; phase: number; color: RGB }[] = [] + // The back corn rows are painted once but still bend as gusts pass, and the open pasture lightens under them. + private corn = new SwayLayer() + private pasture = new Int32Array(0) + private gusts = new Float32Array(0) + private fan = 0 constructor(settings: Settings) { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 this.season = settings.season ?? "summer" this.hills = GROUND[this.season]?.[settings.time] ?? this.look.hills // Without a weather setting, winter brings a light flurry. this.weather = new WeatherLayer(settings.weather ?? (this.season === "winter" ? "snow" : "clear"), settings.time, HORIZON, !settings.weather) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) const drifting = { spring: 10, autumn: 22, summer: 0, winter: 0 }[this.season] * { calm: 1, lively: 1.3, teeming: 1.6 }[settings.activity] const colors: RGB[] = this.season === "spring" ? [[1, 0.78, 0.86], [0.95, 0.6, 0.72]] : [[0.9, 0.45, 0.1], [0.75, 0.2, 0.07], [0.95, 0.7, 0.18]] this.leaves = Array.from({ length: Math.round(drifting) }, (_, i) => ({ x: Math.random() * 2, y: 0.4 + Math.random() * 0.55, phase: Math.random() * TAU, color: colors[i % colors.length] })) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.08, 0.3) - const walker = (x: number, y: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, look: 0, moving: false }) + // Long thin streaks catch the low sun better than heaps of cumulus. + this.clouds = settings.time === "sunset" ? makeClouds(5, "cirrus", 0.06, 0.3) : makeClouds(5, "cumulus", 0.08, 0.3) + const walker = (x: number, y: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, look: 0, moving: false, g: gait(1, hash(x * 7 + y)) }) if (settings.activity !== "calm") { this.cows = [walker(0.9, 0.8), walker(1.3, 0.84), ...(settings.activity === "teeming" ? [walker(1.1, 0.78)] : [])] this.chickens = [walker(1.2, 0.69), walker(1.3, 0.7), walker(1.4, 0.685)] @@ -220,12 +248,14 @@ class Farm extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) + this.fan += dt * (0.8 + 1.6 * gust(this.millX, this.time)) for (const l of this.leaves) { l.y += (0.02 + Math.sin(this.time * 1.3 + l.phase) * 0.012) * dt - l.x += (0.025 + Math.sin(this.time * 0.7 + l.phase * 3) * 0.02) * dt + l.x += (0.025 + Math.sin(this.time * 0.7 + l.phase * 3) * 0.02 + gust(l.x, this.time) * 0.09) * dt if (l.y < 0.99 && l.x < this.A + 0.05) continue // Most come loose from one of the trees; some blow in from off to the left. const tree = this.treeTops[Math.floor(Math.random() * this.treeTops.length)] @@ -243,9 +273,9 @@ class Farm extends Canvas { } this.stepTractor(dt) const A = this.A - for (const c of this.cows) this.wander(c, cdt, [0.4 * A, 0.95 * A, 0.76, 0.87], 0.03, [6, 14]) - for (const c of this.chickens) this.wander(c, cdt, [0.62 * A, 0.84 * A, 0.672, 0.712], 0.025, [2, 5]) - for (const p of this.pigs) this.wander(p, cdt, [0.44 * A, 0.6 * A, 0.88, 0.94], 0.02, [5, 10]) + for (const c of this.cows) this.wander(c, cdt, [0.4 * A, 0.95 * A, 0.76, 0.87], 0.03, [6, 14], 0.055) + for (const c of this.chickens) this.wander(c, cdt, [this.barnX - 0.21, this.barnX + 0.18, 0.672, 0.712], 0.025, [2, 5], 0.025) + for (const p of this.pigs) this.wander(p, cdt, [0.44 * A, 0.6 * A, 0.88, 0.94], 0.02, [5, 10], 0.035) if (this.activity === "teeming") this.stepFarmer(cdt) this.stepEgg(dt, cdt) } @@ -261,10 +291,16 @@ class Farm extends Canvas { render() { this.hdr.set(this.background) + for (let x = 0; x < this.W; x++) this.gusts[x] = gust(x / this.H, this.time) + lightenGrass(this.hdr, this.W, this.H, this.pasture, this.gusts) + this.corn.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) + const orb = this.weather.covered ? undefined : this.look.orb + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.01, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + this.drawShadows() this.drawFan() this.drawTractor() for (const c of this.chickens) this.drawChicken(c) @@ -273,6 +309,8 @@ class Farm extends Canvas { for (const { w, kind } of herd) kind === "cow" ? this.drawCow(w) : this.drawPig(w) this.drawEgg() if (this.activity === "teeming") this.drawFarmer() + // At night the open barn door spills warm light across the yard and whatever is in it. + if (this.look.windows === 1) lightPool(this.hdr, this.W, this.H, this.barnX * this.H, 0.67 * this.H, 0.24 * this.H, 0.07 * this.H, [1, 0.55, 0.15], 3, 0.02) this.drawCornRow(3) this.drawFireflies() this.drawLeaves() @@ -296,7 +334,8 @@ class Farm extends Canvas { const season = this.season const golden = season === "autumn" ? 0.45 : 0.85 const patchwork = season === "winter" ? 0.3 : 1 - const grass = season === "winter" ? 0.03 : 0.1 + // Blades are a pixel-fine texture, which terminal cells would turn into diagonal hatching. + const grass = this.cells ? 0 : season === "winter" ? 0.03 : 0.1 const farTop = new Float32Array(W) this.nearTop = new Float32Array(W) for (let x = 0; x < W; x++) { @@ -304,17 +343,30 @@ class Farm extends Canvas { farTop[x] = (0.52 - 0.03 * fbm1(u * 1.5, 3) - 0.015 * Math.sin(u * 3)) * H this.nearTop[x] = (0.6 - 0.025 * Math.sin(u * 2.2 + 1) - 0.02 * fbm1(u * 2, 5)) * H } + const day = look === LOOKS.day + const sky = this.hdr.slice() this.fillBelow(farTop, (x, y, d) => { - const field = hash2(Math.floor((x / H) * 5 + (y / H) * 9), Math.floor((y / H) * 22)) + const fu = (x / H) * 5 + (y / H) * 9 + const fv = (y / H) * 22 + const field = hash2(Math.floor(fu), Math.floor(fv)) const k = 1 + (field < 0.33 ? -0.15 : field < 0.66 ? 0 : 0.12) * patchwork const gold = field > golden && season !== "winter" ? 0.3 : 0 - return this.rim([far[0] * k + gold * far[0], far[1] * k + gold * 0.2 * far[1], far[2] * k * (1 - gold)], x, d, 1.5) + const c: RGB = [far[0] * k + gold * far[0], far[1] * k + gold * 0.2 * far[1], far[2] * k * (1 - gold)] + if (!day || season === "winter") return this.rim(c, x, d, 1.5) + // By day each field takes its own crop, from deep green to ploughed ochre, edged by dark hedgerows. + const crop = CROPS[Math.floor(hash2(Math.floor(fu) + 7.1, Math.floor(fv) + 3.3) * CROPS.length)] + const hedge = Math.min(fu - Math.floor(fu), (fv - Math.floor(fv)) * 0.4) < 0.05 ? 0.5 : 1 + return [c[0] * crop[0] * hedge, c[1] * crop[1] * hedge, c[2] * crop[2] * hedge] }) + // The far fields fade into the sky, then the pasture in front of them stays crisp. + if (day) haze(this.hdr, sky, W, H, 0.5, 0.62, 0.24, 0.08) this.fillBelow(this.nearTop, (x, y, d) => { const blades = 1 - grass + grass * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) const depth = 1 - clamp((y / H - 0.6) * 0.6, 0, 0.25) return this.rim([near[0] * blades * depth, near[1] * blades * depth, near[2] * blades * depth], x, d, 2) }) + if (day) mottle(this.hdr, W, H, 0.58, 1, 0.55, season === "winter" ? [1.03, 1, 0.92] : [1.14, 1.06, 0.7], season === "winter" ? [0.62, 0.74, 1] : [0.66, 0.84, 0.66]) + const field = this.hdr.slice() // A dirt road winding across the middle distance. for (let x = 0; x < W; x++) { const cy = this.roadY(x / H) * H @@ -326,19 +378,28 @@ class Farm extends Canvas { } } if (season === "spring") this.drawWildflowers() - this.treeTops = TREES.map((t) => { - const base = this.nearTop[clamp(Math.round(t * W), 0, W - 1)] / H + 0.012 - this.drawTree(t * A, base) - return [t * A, base - 0.12] + this.barnX = A - 0.24 + const trees = [0.5, A - 0.04] + const ground = (x: number, y: number, w: number, h: number, tip?: number) => groundShadow(this.hdr, W, H, this.shade, x * H, y * H, w * H, h * H, tip) + for (const x of trees) ground(x, this.nearTop[clamp(Math.round(x * H), 0, W - 1)] / H + 0.012, 0.09, 0.17, 1.4) + ground(0.08, this.nearTop[clamp(Math.round(0.08 * H), 0, W - 1)] / H + 0.006, 0.08, 0.066, 0.6) + ground(this.millX, 0.65, 0.07, 0.27, 0.4) + ground(this.barnX, 0.655, 0.24, 0.19, 0.6) + for (const [dx, by] of [[-0.28, 0.705], [-0.2, 0.712]]) ground(this.barnX + dx, by + 0.02, 0.045, 0.04) + for (let x = 0.38 * A; x < A + 0.05; x += 0.07) ground(x, 0.725, 0.012, 0.045, 0.6) + this.treeTops = trees.map((x) => { + const base = this.nearTop[clamp(Math.round(x * H), 0, W - 1)] / H + 0.012 + this.drawTree(x, base) + return [x, base - 0.12] }) - this.drawFarmhouse(0.5 * A, this.nearTop[clamp(Math.round(0.5 * W), 0, W - 1)] / H + 0.006) - this.drawWindmillTower(0.3 * A, 0.65) - this.drawBarn(0.74 * A, 0.655) - for (const [bx, by] of [[0.58, 0.705], [0.625, 0.712]]) this.shape([ell(bx * A * H, by * H, 0.022 * H, 0.02 * H, 0, this.paint([0.78, 0.62, 0.3]), 3)], this.lighting, 0.8) + this.drawFarmhouse(0.08, this.nearTop[clamp(Math.round(0.08 * H), 0, W - 1)] / H + 0.006) + this.drawWindmillTower(this.millX, 0.65) + this.drawBarn(this.barnX, 0.655) + for (const [dx, by] of [[-0.28, 0.705], [-0.2, 0.712]]) this.shape([ell((this.barnX + dx) * H, by * H, 0.022 * H, 0.02 * H, 0, this.paint([0.78, 0.62, 0.3]), 3)], this.lighting, 0.8) // Pumpkins in front of the barn in autumn. if (season === "autumn") for (const [dx, r] of [[-0.062, 0.011], [-0.046, 0.008], [0.056, 0.01]]) { - const [px, py] = [(0.74 * A + dx) * H, 0.664 * H] + const [px, py] = [(this.barnX + dx) * H, 0.664 * H] this.shape([ell(px, py - r * H, r * 1.25 * H, r * H, 0, this.paint([0.92, 0.45, 0.06]), 3), cap(px, py - r * 1.9 * H, px + 0.003 * H, py - r * 2.4 * H, 0.0018 * H, 0.0012 * H, this.paint([0.3, 0.35, 0.1]))], this.lighting, 0.7) } // A fence along the front of the pasture. @@ -349,8 +410,49 @@ class Farm extends Canvas { posts.push(cap(0.38 * A * H, fy - 0.014 * H, W + 5, fy - 0.014 * H, 0.0025 * H, 0.0025 * H, this.paint(WOOD))) this.shape(posts, this.lighting, 0.6) if (this.activity === "teeming") this.shape([ell(0.52 * A * H, 0.91 * H, 0.08 * H, 0.025 * H, 0, this.paint([0.32, 0.22, 0.12]))], this.lighting, 0.3) + this.corn.begin(this.hdr) for (let row = 0; row < 3; row++) this.drawCornRow(row) + // Weighted by height on screen: the farther rows' tops sit higher, so each stalk bends from near its foot. + this.corn.end(this.hdr, W, H, 0.012 * H, (_x, y) => clamp((0.96 * H - y) / (0.26 * H), 0, 1)) this.background = this.hdr.slice() + // Open grass on the pasture hill: the pixels nothing was painted over. + const pasture: number[] = [] + for (let x = 0; x < W; x++) + for (let y = Math.ceil(this.nearTop[x]) + 1; y < H; y++) { + const o = (y * W + x) * 3 + if (this.hdr[o] === field[o] && this.hdr[o + 1] === field[o + 1] && this.hdr[o + 2] === field[o + 2]) pasture.push(y * W + x) + } + this.pasture = Int32Array.from(pasture) + this.gusts = new Float32Array(W) + } + + // Shadows under everything that walks or drives, drawn before any of it so none falls across another animal. + private drawShadows() { + const H = this.H + const ground = (x: number, y: number, w: number, h: number, tip?: number, lift?: number) => groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip, lift) + const t = this.tractor + if (t.x > -5) ground(t.x + t.dir * 0.04, this.roadY(t.x) + 0.004, 0.11, 0.094) + for (const c of this.chickens) { + const S = 0.05 * this.depthScale(c.y) + ground(c.x, c.y, 0.7 * S, 0.9 * S) + } + for (const p of this.pigs) { + const S = 0.08 * this.depthScale(p.y) + ground(p.x, p.y, 0.9 * S, 0.6 * S) + } + for (const c of this.cows) { + const S = 0.11 * this.depthScale(c.y) + ground(c.x + c.face * 0.1 * S, c.y, 1.1 * S, 0.7 * S, 0.8) + } + const e = this.egg + if (e.cow && e.t < 24) { + const rise = e.t < 0 ? 0 : smoothstep(13, 24, e.t) + const size = lerp(1, 0.35, rise) + const uy = lerp(0.1, e.cow.y - 0.4, smoothstep(0, 10, e.t)) + const S = 0.11 * this.depthScale(e.cow.y) * size + ground(e.cow.x + e.cow.face * 0.1 * S, e.cow.y, 1.1 * S, 0.7 * S, 0.8, rise * (e.cow.y - uy - 0.025) * H) + } + if (this.activity === "teeming") ground(this.farmer.x, 0.738, 0.03, 0.105, 0.6) } private paint(c: RGB): RGB { @@ -414,7 +516,7 @@ class Farm extends Canvas { this.polygon(P([[c - 0.077, b - 0.157], [c, b - 0.188], [c + 0.077, b - 0.157], [c + 0.072, b - 0.148], [c, b - 0.176], [c - 0.072, b - 0.148]]), snow) this.shape([ell((c - 0.13) * H, (b - 0.175) * H, 0.023 * H, 0.013 * H, 0, snow)], this.lighting, 0.4) } - this.polygon(P([[c - 0.035, b - 0.065], [c + 0.035, b - 0.065], [c + 0.035, b], [c - 0.035, b]]), this.paint([0.42, 0.07, 0.05])) + this.polygon(P([[c - 0.035, b - 0.065], [c + 0.035, b - 0.065], [c + 0.035, b], [c - 0.035, b]]), this.look.windows === 1 ? [1.1, 0.6, 0.18] : this.paint([0.42, 0.07, 0.05])) const trim = this.paint([0.88, 0.86, 0.8]) const t = 0.0025 * H this.shape( @@ -448,28 +550,32 @@ class Farm extends Canvas { const H = this.H const c = this.paint([0.5, 0.48, 0.45]) const parts: Part[] = [] - for (const s of [-1, 1]) parts.push(cap((x + s * 0.035) * H, base * H, (x + s * 0.007) * H, (base - 0.2) * H, 0.0028 * H, 0.0022 * H, c)) - for (let i = 0; i < 4; i++) { - const y0 = base - i * 0.05 - const y1 = base - (i + 1) * 0.05 - const w0 = 0.035 - i * 0.007 - const w1 = 0.035 - (i + 1) * 0.007 - parts.push(cap((x - w0) * H, y0 * H, (x + w1) * H, y1 * H, 0.0015 * H, 0.0015 * H, c), cap((x + w0) * H, y0 * H, (x - w1) * H, y1 * H, 0.0015 * H, 0.0015 * H, c)) + for (const s of [-1, 1]) parts.push(cap((x + s * 0.035) * H, base * H, (x + s * 0.007) * H, (base - 0.2) * H, this.thick(0.0028, 1.1), this.thick(0.0022, 1), c)) + // Fewer, sturdier braces where the terminal's cells would turn a fine lattice into a scribble. + const levels = this.cells ? 2 : 4 + for (let i = 0; i < levels; i++) { + const y0 = base - (i * 0.2) / levels + const y1 = base - ((i + 1) * 0.2) / levels + const w0 = 0.035 - (i * 0.028) / levels + const w1 = 0.035 - ((i + 1) * 0.028) / levels + parts.push(cap((x - w0) * H, y0 * H, (x + w1) * H, y1 * H, this.thick(0.0015, 0.7), this.thick(0.0015, 0.7), c), cap((x + w0) * H, y0 * H, (x - w1) * H, y1 * H, this.thick(0.0015, 0.7), this.thick(0.0015, 0.7), c)) } this.shape(parts, this.lighting, 0.5) } private drawFan() { const H = this.H - const x = 0.3 * this.A * H + const x = this.millX * H const y = (0.65 - 0.21) * H const metal = this.paint([0.75, 0.75, 0.72]) - const turn = this.time * 0.8 + const turn = this.fan this.polygon([[x - 0.01 * H, y], [x - 0.075 * H, y - 0.018 * H], [x - 0.075 * H, y + 0.012 * H]], this.paint([0.6, 0.15, 0.1])) const parts: Part[] = [ell(x, y, 0.009 * H, 0.009 * H, 0, this.paint([0.35, 0.33, 0.3]))] - for (let i = 0; i < 14; i++) { - const a = turn + (i / 14) * TAU - parts.push(cap(x + Math.cos(a) * 0.012 * H, y + Math.sin(a) * 0.012 * H, x + Math.cos(a) * 0.06 * H, y + Math.sin(a) * 0.06 * H, 0.004 * H, 0.008 * H, metal)) + // Blade tips stay several pixels apart, so the turning wheel doesn't shimmer in the terminal's cells. + const blades = this.cells ? 8 : 14 + for (let i = 0; i < blades; i++) { + const a = turn + (i / blades) * TAU + parts.push(cap(x + Math.cos(a) * 0.012 * H, y + Math.sin(a) * 0.012 * H, x + Math.cos(a) * 0.06 * H, y + Math.sin(a) * 0.06 * H, this.thick(0.004, 1), this.thick(0.008, 2), metal)) } this.shape(parts, this.lighting, 0.6) } @@ -480,27 +586,36 @@ class Farm extends Canvas { const corn = CORN[this.season] const base = [0.79, 0.87, 0.96, 1.05][row] * H const height = (0.12 + row * 0.045) * H * corn.height - const spacing = 0.026 + row * 0.006 + // Stalks stand at least a few cells apart, so in the terminal the field reads as plants rather than stripes. + const spacing = this.cells ? Math.max(0.026 + row * 0.006, 9 / H) : 0.026 + row * 0.006 + const stalk = this.paint(corn.stalk) + // The row's shaded mass of leaves, so the field reads as rows rather than a tangle of thin strokes. + const end = (0.36 * A + 0.03) * H + const mass: [number, number][] = [[-2, base + 2]] + for (let x = -2; x < end; x += 6) mass.push([x, base - height * (0.42 + 0.08 * Math.sin((x / H) * 37 + row * 2))]) + mass.push([end + 0.02 * H, base + 2]) + this.polygon(mass, [stalk[0] * 0.6, stalk[1] * 0.6, stalk[2] * 0.6]) for (let i = 0; i * spacing < 0.36 * A; i++) { const x = (i * spacing + (row % 2) * spacing * 0.5 + hash(i * 3.1 + row) * 0.008) * H const h = height * (0.85 + hash(i * 7.3 + row) * 0.3) - const sway = row === 3 ? Math.sin(this.time * 0.7 + i * 0.9) * 0.05 : (hash(i + row * 5) - 0.5) * 0.06 - const top: [number, number] = [x + Math.sin(sway) * h, base - Math.cos(sway) * h] + const bend = row === 3 ? sway(x / H, this.time, i * 0.9) * 0.05 : (hash(i + row * 5) - 0.5) * 0.06 + const top: [number, number] = [x + Math.sin(bend) * h, base - Math.cos(bend) * h] const at = (t: number): [number, number] => [lerp(x, top[0], t), lerp(base, top[1], t)] - const stalk = this.paint(corn.stalk) - const parts: Part[] = [cap(x, base, ...top, 0.006 * H * (1 + row * 0.3), 0.003 * H, stalk)] + const parts: Part[] = [cap(x, base, ...top, this.thick(0.006 * (1 + row * 0.3), 1.2), this.thick(0.003, 0.8), stalk)] for (let l = 0; l < 4; l++) { const [lx, ly] = at(0.3 + l * 0.15) const side = l % 2 ? 1 : -1 const reach = h * 0.28 const mid: [number, number] = [lx + side * reach * 0.6, ly - reach * 0.25] - parts.push(cap(lx, ly, ...mid, 0.006 * H, 0.004 * H, stalk), cap(...mid, lx + side * reach, ly + reach * 0.15 + sway * reach, 0.004 * H, 0.0015 * H, stalk)) + // Leaves stream downwind as the stalk bends. + parts.push(cap(lx, ly, ...mid, this.thick(0.006, 1.3), this.thick(0.004, 1.1), stalk), cap(...mid, lx + side * reach + bend * reach * 1.5, ly + reach * 0.15 + bend * reach, this.thick(0.004, 1.1), this.thick(0.0015, 0.6), stalk)) } const [ex, ey] = at(0.55) - if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, 0.007 * H, 0.016 * H, 0.25 + sway, this.paint(corn.ear))) - if (corn.tassel) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) - // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it. - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.2 : 0.6) + if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, this.thick(0.007, 1.6), this.thick(0.016, 3), 0.25 + bend, this.paint(corn.ear))) + if (corn.tassel && !this.cells) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) + // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it, and + // on terminal cells less still. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.08 : 0.2) : 0.6) } } @@ -551,8 +666,15 @@ class Farm extends Canvas { } } - // Walks between random points in a zone, pausing at each to graze or peck. - private wander(w: Walker, dt: number, zone: [number, number, number, number], speed: number, pause: [number, number]) { + // Walks between random points in a zone, pausing at each to graze or peck. stride is the distance a full step cycle + // covers near the front of the field, so the legs keep pace with the ground. + private wander(w: Walker, dt: number, zone: [number, number, number, number], speed: number, pause: [number, number], stride: number) { + const [x0, y0] = [w.x, w.y] + this.roam(w, dt, zone, speed, pause) + stepGait(w.g, Math.hypot(w.x - x0, w.y - y0) / (stride * this.depthScale(w.y)), dt, w.face) + } + + private roam(w: Walker, dt: number, zone: [number, number, number, number], speed: number, pause: [number, number]) { w.phase += dt * (w.moving ? 6 : 1.5) if (w.look > 0) { w.look -= dt @@ -570,8 +692,7 @@ class Farm extends Canvas { w.wanderT -= dt if (w.wanderT <= 0) { w.wanderT = rand(6, 14) - w.wx = rand(zone[0], zone[1]) - w.wy = rand(zone[2], zone[3]) + ;[w.wx, w.wy] = this.openSpot(...zone) } const dx = w.wx - w.x const dy = w.wy - w.y @@ -592,92 +713,129 @@ class Farm extends Canvas { return 0.6 + (y - 0.7) * 2.5 } - // A black-and-white dairy cow seen from the side, lowering its head to graze. lift raises it off the ground and - // size shrinks it, for the saucer's beam. + // A black-and-white dairy cow on jointed legs, walking in diagonal pairs with its head nodding and its tail swinging + // behind, and lowering its head to graze. lift raises it off the ground and size shrinks it, for the saucer's beam. private drawCow(c: Walker, lift = 0, size = 1) { const H = this.H - const S = 0.11 * H * this.depthScale(c.y) * size - const x = c.x * H - const y = (c.y - lift) * H - const P = (u: number, v: number): [number, number] => [x + u * c.face * S, y + v * S] - const swing = c.moving ? Math.sin(c.phase) * 0.05 : 0 + const g = c.g + const P = body(c.x * H, (c.y - lift) * H, 0.11 * H * this.depthScale(c.y) * size, g.turn) + const S = P.S const hide = this.paint([0.92, 0.9, 0.86]) - const g = c.graze - const [hu, hv] = [lerp(0.55, 0.6, g), lerp(-0.56, -0.2, g)] - const parts: Part[] = [] - for (const [u, s] of [[0.28, 1], [0.34, -1], [-0.24, -1], [-0.3, 1]] as const) parts.push(cap(...P(u, -0.3), ...P(u + swing * s, 0), 0.045 * S, 0.038 * S, hide)) - parts.push( - cap(...P(-0.4, -0.5), ...P(-0.47, -0.2), 0.014 * S, 0.012 * S, hide), - ell(...P(0, -0.43), 0.42 * S, 0.2 * S, 0, hide), - ell(...P(-0.1, -0.25), 0.07 * S, 0.045 * S, 0, this.paint([0.9, 0.6, 0.6])), - cap(...P(0.33, -0.5), ...P(hu - 0.06, hv), 0.11 * S, 0.08 * S, hide), - ell(...P(hu, hv), 0.13 * S, 0.09 * S, lerp(-0.25, 1, g) * c.face, hide), - ell(...P(hu + lerp(0.1, 0.03, g), hv + lerp(0.03, 0.1, g)), 0.06 * S, 0.055 * S, 0, this.paint([0.88, 0.62, 0.58])), - cap(...P(hu - 0.05, hv - 0.07), ...P(hu - 0.14, hv - 0.09), 0.025 * S, 0.012 * S, hide), - ell(...P(-0.47, -0.17), 0.025 * S, 0.035 * S, 0, this.paint(BLACK)), + const black = this.paint(BLACK) + const rise = bob(g.phase) * g.go * 0.015 + const legs = { fore: 0.25, hind: 0.27, top: 0.42 - rise, w: 0.08, a: 0.21, b: 0.21, reach: 0.15, lift: 0.08, r: [0.07, 0.036, 0.03] as [number, number, number], paw: [0.03, this.paint([0.25, 0.2, 0.17])] as [number, RGB] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([0.66, 0.64, 0.6]) }), this.lighting, 0.7) + const gz = smoothstep(0, 1, c.graze) + const nod = Math.cos(TAU * 2 * g.phase - 1) * 0.02 * g.go + const poll: [number, number] = [lerp(0.47, 0.54, gz), lerp(-0.6, -0.26, gz) + nod - rise] + const muzzle: [number, number] = [poll[0] + lerp(0.15, 0.05, gz), poll[1] + lerp(0.11, 0.2, gz)] + const swish = Math.sin(this.creatureTime * 2.3 + c.x * 17) * 0.05 - g.lag * 0.06 + const tail = (t: number) => P(-0.43 - t * 0.03 + swish * t * t, -0.6 - rise + t * 0.36) + this.shape( + [ + ...quadruped(P, g, false, { ...legs, color: hide }), + ell(...P(0.01, -0.47 - rise), P.len(0.34, 0.15), 0.17 * S, 0, hide), + ell(...P(-0.25, -0.5 - rise), P.len(0.16, 0.14), 0.15 * S, 0, hide), + ell(...P(0.24, -0.47 - rise), P.len(0.15, 0.14), 0.16 * S, 0, hide), + ell(...P(-0.12, -0.31 - rise), P.len(0.07, 0.05), 0.045 * S, 0, this.paint([0.9, 0.6, 0.6])), + cap(...P(0.28, -0.5 - rise), ...P(...poll), 0.13 * S, 0.085 * S, hide), + cap(...P(...poll), ...P(...muzzle), 0.085 * S, 0.068 * S, hide), + ell(...P(...muzzle), 0.06 * S, 0.055 * S, 0, this.paint([0.88, 0.62, 0.58])), + ...[-1, 1].map((s) => ell(...P(poll[0] - 0.04, poll[1] - 0.01, s * 0.1), 0.075 * S, 0.026 * S, (-0.2 - g.lag * 0.15) * P.along, hide)), + ...chain(tail, 3, 0.02 * S, 0.012 * S, hide), + ell(...tail(1.1), 0.025 * S, 0.045 * S, 0, black), + ], + this.lighting, + 0.7, + ) + this.shape( + [ + ell(...P(-0.15, -0.5 - rise), P.len(0.12, 0.06), 0.08 * S, 0.3 * P.along, black), + ell(...P(0.13, -0.4 - rise), P.len(0.1, 0.05), 0.07 * S, -0.4 * P.along, black), + ell(...P(-0.3, -0.38 - rise), P.len(0.06, 0.03), 0.05 * S, 0, black), + ell(...P(poll[0] + 0.02, poll[1] + 0.02), 0.05 * S, 0.045 * S, 0, black), + ], + this.lighting, + 0.4, ) - this.shape(parts, this.lighting, 0.7) - const spots = this.paint(BLACK) - this.shape([ell(...P(-0.15, -0.48), 0.12 * S, 0.08 * S, 0.3, spots), ell(...P(0.13, -0.39), 0.1 * S, 0.07 * S, -0.4, spots), ell(...P(-0.3, -0.37), 0.06 * S, 0.05 * S, 0, spots), ell(...P(hu - 0.02, hv - 0.02), 0.05 * S, 0.04 * S, 0, spots)], this.lighting, 0.4) } - // A hen bobbing its head to peck at the ground. + // A hen high-stepping on jointed legs, her head holding still in the air and darting forward at each step, and + // pecking at the ground when she stops. private drawChicken(h: Walker) { const H = this.H - const S = 0.05 * H * this.depthScale(h.y) - const x = h.x * H - const y = h.y * H - const P = (u: number, v: number): [number, number] => [x + u * h.face * S, y + v * S] - const peck = Math.max(h.graze, Math.pow(Math.max(0, Math.sin(h.phase * 2)), 6)) + const g = h.g + const P = body(h.x * H, h.y * H, 0.05 * H * this.depthScale(h.y), g.turn) + const S = P.S + const peck = Math.max(h.graze, Math.pow(Math.max(0, Math.sin(h.phase * 2)), 6) * (1 - g.go)) const feather = this.paint(h.x % 0.3 > 0.15 ? [0.6, 0.35, 0.15] : [0.92, 0.9, 0.82]) const leg = this.paint([0.95, 0.6, 0.15]) - const [hu, hv] = [lerp(0.32, 0.45, peck), lerp(-0.78, -0.35, peck)] + const red = this.paint([0.85, 0.08, 0.06]) + const rise = bob(g.phase) * g.go * 0.03 + const f = (g.phase * 2) % 1 + const thrust = g.go * 0.09 * (f < 0.75 ? 1 - (2 * f) / 0.75 : -1 + 2 * smoothstep(0.75, 1, f)) + const [hu, hv] = [lerp(0.3, 0.48, peck) + thrust, lerp(-0.84, -0.3, peck) - rise] + const legs = [-1, 1].flatMap((w) => { + const [reach, raise] = stride(g.phase, w > 0 ? 0 : 0.5) + const fu = reach * 0.15 * g.go + const fv = -raise * 0.12 * g.go - 0.02 + const Q = (u: number, v: number) => P(u, v, w * 0.06) + return [...limb(Q, 0, -0.4 - rise, fu, fv, 0.2, 0.22, 1, 0.07 * S, 0.03 * S, 0.025 * S, leg), cap(...Q(fu - 0.06, fv), ...Q(fu + 0.1, fv + 0.01), 0.025 * S, 0.015 * S, leg)] + }) this.shape( [ - cap(...P(0.05, -0.25), ...P(0.05, 0), 0.03 * S, 0.025 * S, leg), - cap(...P(-0.08, -0.25), ...P(-0.08, 0), 0.03 * S, 0.025 * S, leg), - cap(...P(-0.25, -0.5), ...P(-0.45, -0.85), 0.14 * S, 0.06 * S, feather), - ell(...P(0, -0.48), 0.35 * S, 0.27 * S, 0, feather), - cap(...P(0.2, -0.55), ...P(hu, hv), 0.12 * S, 0.1 * S, feather), - ell(...P(hu, hv), 0.14 * S, 0.13 * S, 0, feather), - ell(...P(hu, hv - 0.15), 0.06 * S, 0.06 * S, 0, this.paint([0.85, 0.08, 0.06])), - cap(...P(hu + 0.1, hv), ...P(hu + 0.24, hv + 0.03), 0.04 * S, 0.01 * S, leg), + ...legs, + cap(...P(-0.2, -0.55 - rise), ...P(-0.36 - g.lag * 0.03, -0.88 - rise), 0.13 * S, 0.05 * S, feather), + cap(...P(-0.2, -0.52 - rise), ...P(-0.42 - g.lag * 0.04, -0.72 - rise), 0.1 * S, 0.04 * S, feather), + ell(...P(0, -0.52 - rise), P.len(0.3, 0.18), 0.21 * S, 0.25 * P.along, feather), + ell(...P(0.14, -0.55 - rise), P.len(0.17, 0.16), 0.18 * S, 0, feather), + cap(...P(0.16, -0.62 - rise), ...P(hu, hv), 0.1 * S, 0.075 * S, feather), + ell(...P(hu, hv), 0.1 * S, 0.1 * S, 0, feather), + ell(...P(hu - 0.01, hv - 0.1), P.len(0.07, 0.02), 0.045 * S, 0, red), + ell(...P(hu + 0.05, hv + 0.09), 0.03 * S, 0.045 * S, 0, red), + cap(...P(hu + 0.07, hv), ...P(hu + 0.18, hv + 0.03), 0.035 * S, 0.008 * S, leg), ], this.lighting, 0.6, ) } - // A round pink pig, rooting in the mud. + // A round pink pig trotting on short legs, floppy ears bouncing and curly tail up, rooting in the mud. private drawPig(p: Walker) { const H = this.H - const S = 0.08 * H * this.depthScale(p.y) - const x = p.x * H - const y = p.y * H - const P = (u: number, v: number): [number, number] => [x + u * p.face * S, y + v * S] + const g = p.g + const P = body(p.x * H, p.y * H, 0.08 * H * this.depthScale(p.y), g.turn) + const S = P.S const pink = this.paint([0.95, 0.66, 0.66]) - const swing = p.moving ? Math.sin(p.phase) * 0.04 : 0 - const dip = p.graze * 0.12 + const snout = this.paint([0.88, 0.5, 0.52]) + const rise = bob(g.phase) * g.go * 0.015 + const legs = { fore: 0.2, hind: 0.21, top: 0.22 - rise, w: 0.1, a: 0.1, b: 0.1, reach: 0.13, lift: 0.06, r: [0.07, 0.045, 0.04] as [number, number, number], paw: [0.025, this.paint([0.5, 0.32, 0.3])] as [number, RGB] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([0.75, 0.5, 0.5]) }), this.lighting, 0.7) + const dip = p.graze * 0.14 + Math.cos(TAU * 2 * g.phase - 1) * 0.015 * g.go - rise + const flop = g.lag * 0.03 + Math.sin(TAU * 2 * g.phase - 2) * 0.015 * g.go + const curl = (t: number) => P(-0.4 - 0.05 * Math.sin(t * 5), -0.44 - rise + 0.05 * Math.cos(t * 5) - 0.03 * t) this.shape( [ - ...[[0.22, 1], [0.28, -1], [-0.2, -1], [-0.26, 1]].map(([u, s]) => cap(...P(u, -0.15), ...P(u + swing * s, 0), 0.05 * S, 0.045 * S, pink)), - ell(...P(0, -0.32), 0.38 * S, 0.24 * S, 0, pink), - ell(...P(0.36, -0.36 + dip), 0.15 * S, 0.14 * S, 0, pink), - ell(...P(0.5, -0.33 + dip), 0.06 * S, 0.07 * S, 0, this.paint([0.88, 0.5, 0.52])), - cap(...P(0.3, -0.46 + dip), ...P(0.38, -0.58 + dip), 0.05 * S, 0.01 * S, pink), - cap(...P(-0.36, -0.36), ...P(-0.44, -0.44), 0.02 * S, 0.015 * S, pink), + ...quadruped(P, g, false, { ...legs, color: pink }), + ell(...P(-0.04, -0.34 - rise), P.len(0.38, 0.2), 0.21 * S, -0.06 * P.along, pink), + ell(...P(0.34, -0.37 + dip), 0.15 * S, 0.15 * S, 0, pink), + cap(...P(0.4, -0.35 + dip), ...P(0.52, -0.31 + dip), 0.1 * S, 0.075 * S, pink), + ell(...P(0.54, -0.31 + dip), P.len(0.025, 0.065), 0.065 * S, 0, snout), + ...[-1, 1].map((s) => cap(...P(0.3, -0.46 + dip, s * 0.08), ...P(0.42, -0.4 + dip + flop, s * 0.11), 0.055 * S, 0.02 * S, pink)), + ...chain(curl, 3, 0.02 * S, 0.015 * S, pink), ], this.lighting, 0.7, ) } - // The farmer strolls along the fence in a straw hat, now and then stopping to wave. + // The farmer strolls along the fence in a straw hat, turning round at each end and now and then stopping to wave. private stepFarmer(dt: number) { const f = this.farmer if (f.wave >= 0) { f.wave += dt if (f.wave > 3) f.wave = -1 + stepGait(f.g, 0, dt, f.dir) return } f.next -= dt @@ -686,8 +844,8 @@ class Farm extends Canvas { f.next = rand(20, 35) return } - f.phase += dt * 5 f.x += f.dir * 0.03 * dt + stepGait(f.g, (0.03 * dt) / 0.079, dt, f.dir) if (f.x > 0.95 * this.A) f.dir = -1 if (f.x < 0.42 * this.A) f.dir = 1 } @@ -695,30 +853,43 @@ class Farm extends Canvas { private drawFarmer() { const H = this.H const f = this.farmer - const S = 0.15 * H - const x = f.x * H - const y = 0.738 * H - const P = (u: number, v: number): [number, number] => [x + u * f.dir * S, y + v * S] - const walking = f.wave < 0 - const swing = walking ? Math.sin(f.phase) * 0.12 : 0 + const g = f.g + const rise = bob(g.phase) * g.go * 0.015 + const P = body(f.x * H, 0.738 * H, 0.105 * H, g.turn) + const S = P.S const denim = this.paint([0.2, 0.3, 0.6]) const shirt = this.paint([0.75, 0.15, 0.12]) const skin = this.paint([0.85, 0.65, 0.5]) const straw = this.paint([0.9, 0.78, 0.4]) - const waveArm: [number, number] = f.wave >= 0 ? [0.14 + Math.sin(f.wave * 8) * 0.04, -1.02] : [swing * 0.6, -0.5] + const boot = this.paint([0.3, 0.2, 0.12]) + // Each side's leg and the opposite arm swing together; the near arm waves. + const side = (w: number, far: boolean): Part[] => { + const [reach, raise] = stride(g.phase, w > 0 ? 0 : 0.5) + const fu = reach * 0.24 * g.go + const fv = -raise * 0.07 * g.go - 0.03 + const Q = (u: number, v: number) => P(u, v, w * 0.05) + const A = (u: number, v: number) => P(u, v, w * 0.1) + const waving = f.wave >= 0 && !far + const hand: [number, number] = waving ? [0.12 + Math.sin(f.wave * 8) * 0.04, -1.1] : [-reach * 0.14 * g.go + 0.02, -0.42 - rise] + const pants = far ? this.paint([0.13, 0.2, 0.42]) : denim + return [ + ...limb(Q, 0, -0.48 - rise, fu, fv, 0.25, 0.25, -1, 0.055 * S, 0.045 * S, 0.04 * S, pants), + cap(...Q(fu - 0.02, fv), ...Q(fu + 0.06, fv + 0.005), 0.035 * S, 0.03 * S, boot), + ...limb(A, 0.01, -0.78 - rise, ...hand, 0.19, 0.19, waving ? -1 : 1, 0.04 * S, 0.033 * S, 0.03 * S, far ? this.paint([0.5, 0.1, 0.08]) : shirt), + ell(...A(...hand), 0.03 * S, 0.03 * S, 0, skin), + ] + } + const near = P.along >= 0 ? 1 : -1 + this.shape(side(-near, true), this.lighting, 0.7) this.shape( [ - cap(...P(0.01, -0.75), ...P(-swing * 0.6, -0.5), 0.035 * S, 0.03 * S, shirt), - cap(...P(0, -0.45), ...P(-swing, 0), 0.05 * S, 0.045 * S, denim), - ell(...P(-swing + 0.03, -0.01), 0.05 * S, 0.02 * S, 0, this.paint([0.3, 0.2, 0.12])), - cap(...P(0, -0.45), ...P(swing, 0), 0.05 * S, 0.045 * S, denim), - ell(...P(swing + 0.03, -0.01), 0.05 * S, 0.02 * S, 0, this.paint([0.3, 0.2, 0.12])), - cap(...P(0, -0.42), ...P(0.02, -0.78), 0.085 * S, 0.075 * S, denim), - cap(...P(0.02, -0.78), ...P(0.02, -0.66), 0.07 * S, 0.06 * S, shirt), - cap(...P(0.03, -0.75), ...P(...waveArm), 0.035 * S, 0.03 * S, shirt), - ell(...P(0.04, -0.87), 0.065 * S, 0.07 * S, 0, skin), - ell(...P(0.04, -0.93), 0.15 * S, 0.028 * S, 0, straw), - ell(...P(0.04, -0.97), 0.075 * S, 0.05 * S, 0, straw), + ...side(near, false), + cap(...P(0, -0.45 - rise), ...P(0.02, -0.78 - rise), 0.1 * S, 0.085 * S, denim), + cap(...P(0.02, -0.8 - rise, -0.06), ...P(0.02, -0.8 - rise, 0.06), 0.06 * S, 0.06 * S, shirt), + ell(...P(0.04, -0.89 - rise), 0.065 * S, 0.07 * S, 0, skin), + cap(...P(0.08, -0.89 - rise), ...P(0.115, -0.87 - rise), 0.02 * S, 0.015 * S, skin), + ell(...P(0.04, -0.95 - rise), 0.15 * S, 0.028 * S, 0, straw), + ell(...P(0.03, -0.99 - rise), 0.075 * S, 0.05 * S, 0, straw), ], this.lighting, 0.7, @@ -731,7 +902,7 @@ class Farm extends Canvas { for (let i = 0; i < 3; i++) { const a = this.creatureTime * (0.5 + i * 0.1) + i * 2.1 const x = (0.17 * this.A + Math.cos(a) * (0.08 + i * 0.03)) * H - const y = (0.42 + Math.sin(a) * 0.03) * H + const y = (0.3 + Math.sin(a) * 0.03) * H const flap = Math.sin(this.time * 6 + i * 2) const s = 0.03 * H const c = this.paint(BLACK) @@ -803,13 +974,13 @@ class Farm extends Canvas { if (!c) { e.wait -= dt if (e.wait > 0) return - e.cow = { x: this.A + 0.15, y: 0.83, wx: 0, wy: 0, wanderT: 0, face: -1, phase: 0, graze: 0, rest: 0, look: 0, moving: true } + e.cow = { x: this.A + 0.15, y: 0.83, wx: 0, wy: 0, wanderT: 0, face: -1, phase: 0, graze: 0, rest: 0, look: 0, moving: true, g: gait(-1) } e.t = -1 return } if (e.t < 0) { - c.phase += cdt * 6 c.x -= 0.03 * cdt + stepGait(c.g, (0.03 * cdt) / (0.055 * this.depthScale(c.y)), cdt, -1) if (c.x > 0.9 * this.A) return c.moving = false e.t = 0 @@ -817,6 +988,7 @@ class Farm extends Canvas { } e.t += dt c.phase += cdt * 1.5 + stepGait(c.g, 0, cdt, -1) // Grazing until the beam comes on, then head up in surprise. c.graze = e.t < 11 ? Math.min(1, c.graze + cdt * 0.8) : Math.max(0, c.graze - dt * 2) if (e.t < 36) return @@ -871,6 +1043,7 @@ class Farm extends Canvas { for (const f of this.fireflies) { const k = Math.pow(Math.max(0, Math.sin(this.time * 1.3 + f.phase * 7)), 6) if (k < 0.02) continue + fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 1.8 * k, 2.4 * k, 0.6 * k, 0.6) this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } @@ -881,6 +1054,21 @@ function lerpRGB(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } +// A gust flattens the grass as it passes, turning up the paler side of the blades in soft streaks. +function lightenGrass(hdr: Float32Array, W: number, H: number, pasture: Int32Array, gusts: Float32Array) { + for (let k = 0; k < pasture.length; k++) { + const p = pasture[k] + const x = p % W + const g = gusts[x] + if (g < 0.01) continue + const y = (p - x) / W + const m = 1 + 0.1 * g * (0.4 + 0.6 * Math.sin(((x - y * 1.4) / H) * 55) ** 2) + hdr[p * 3] *= m + hdr[p * 3 + 1] *= m + hdr[p * 3 + 2] *= m + } +} + export const farm: Wallpaper = { id: "farm", name: "Farm", @@ -897,9 +1085,9 @@ export const farm: Wallpaper = { [14, 28, 10], ], sunset: [ - [30, 14, 26], - [36, 16, 16], - [10, 6, 6], + [16, 18, 34], + [40, 28, 14], + [14, 12, 6], ], night: [ [4, 6, 16], diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 850f37c..68264f7 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -1,8 +1,13 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" +import { limb } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" +import { fireflyLight } from "../src/light" import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src/math" +import { groundShadow, sunShade, type Shade } from "../src/shadow" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -13,6 +18,8 @@ const CHORUS = 3 const RAY_N = 1024 // How long the sloth's visit lasts, in creature time. const SLOTH = 24 +// Where the monkey hangs from the high branch, in screen heights from the right edge. +const MONKEY = 0.38 // What changes with the time of day. Plants and animals have one daytime color each; tint darkens them into // silhouettes at sunset and night, while the mist, distant trees, water and light shafts get their own colors. @@ -32,11 +39,11 @@ const LOOKS: Record = { style: "front", light: [1, 0.95, 0.75], mist: [ - [0.7, 0.85, 0.72], - [0.32, 0.52, 0.36], - [0.06, 0.12, 0.06], + [0.6, 0.8, 0.6], + [0.22, 0.45, 0.28], + [0.04, 0.1, 0.04], ], - far: [0.33, 0.52, 0.38], + far: [0.3, 0.52, 0.34], water: [0.8, 0.92, 1], shafts: 0.35, tint: [1, 1, 1], @@ -58,11 +65,11 @@ const LOOKS: Record = { }, night: { style: "rim", - light: [0.1, 0.7, 0.62], + light: [0.14, 0.95, 0.85], mist: [ - [0.02, 0.08, 0.12], - [0.008, 0.035, 0.05], - [0.002, 0.008, 0.01], + [0.02, 0.085, 0.13], + [0.014, 0.065, 0.09], + [0.003, 0.012, 0.016], ], far: [0.015, 0.05, 0.06], water: [0.04, 0.38, 0.5], @@ -93,6 +100,8 @@ const DAYLIGHT = (() => { const RAYS_A = buildRays(3) const RAYS_B = buildRays(11) +const MIST_A = buildRays(23) +const MIST_B = buildRays(37) // A big foreground leaf rooted at (x, y), pointing along angle (radians, 0 = right, negative = up). interface Leaf { @@ -128,6 +137,7 @@ class Jungle extends Canvas { private readonly weather: WeatherLayer private readonly rainy: boolean private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private leaves: Leaf[] = [] private groundPx = new Float32Array(0) @@ -140,10 +150,15 @@ class Jungle extends Canvas { private pollen = Array.from({ length: 40 }, () => ({ x: Math.random() * 4, y: 0.1 + Math.random() * 0.7, s: Math.random() })) // A click sets the monkey swinging harder: kick is how hard, fading away, and swing is the phase of that swing. private monkey = { kick: 0, swing: 0 } + // How far the jaguar has lifted its head to look about: it springs toward that now and then and sinks back. + private jaguarHead = { p: 0, v: 0 } // The vine in the left corner, in screen heights, which the sloth climbs down. private liana = { x: 0, length: 0, phase: 0 } // The easter egg: a sloth lowers itself down the vine, smiles at you, and climbs back up. t is creature time. private sloth = { wait: eggWait() * TIME_SCALE, t: -1 } + // The hanging vines, painted once, swing out in the gusts; mist drifts along the floor on the same wind. + private vines = new SwayLayer() + private mist = 0 constructor(settings: Settings) { super() @@ -151,8 +166,11 @@ class Jungle extends Canvas { const rainy = settings.season === "summer" || settings.season === "autumn" this.rainy = rainy const look = LOOKS[settings.time] + // Under the canopy the light is diffuse, so things on the floor get contact shadows but cast none. + this.shade = sunShade(settings.time, { x: 0.85, y: -0.15 }, true) const wet = RAINY_MIST[settings.time] this.look = rainy ? { ...look, mist: [mixRGB(look.mist[0], wet, 0.55), mixRGB(look.mist[1], wet, 0.4), look.mist[2]], far: mixRGB(look.far, wet, 0.4), shafts: look.shafts * 0.25 } : look + if (settings.time === "day") this.frame = 0.3 // Without a weather setting, the rainy season brings a light shower. this.weather = new WeatherLayer(settings.weather ?? (rainy ? "rain" : "clear"), settings.time, 0.66, !settings.weather) const count = this.look.night ? 0 : { calm: 0, lively: 2, teeming: 4 }[settings.activity] @@ -166,8 +184,9 @@ class Jungle extends Canvas { this.time += dt const cdt = dt * CREATURE_SPEED this.creatureTime += cdt + this.mist += (0.02 + 0.1 * gust(0.5 * this.A, this.time)) * dt for (const p of this.pollen) { - p.x += (0.004 + p.s * 0.006) * dt + p.x += (0.004 + p.s * 0.006 + gust(p.x, this.time) * 0.05) * dt p.y += Math.sin(this.time * 0.4 + p.s * 20) * 0.003 * dt if (p.x > this.A) p.x -= this.A } @@ -187,6 +206,9 @@ class Jungle extends Canvas { const m = this.monkey m.kick *= Math.exp(-cdt * 0.5) m.swing += cdt * 6 + const j = this.jaguarHead + j.v += ((Math.max(0, Math.sin(this.creatureTime * 0.35)) ** 12 - j.p) * 25 - j.v * 3) * cdt + j.p += j.v * cdt const sl = this.sloth if (sl.t < 0) { sl.wait -= dt @@ -210,20 +232,23 @@ class Jungle extends Canvas { if (this.activity === "calm") return const m = this.monkey // Start the extra swing from rest so the push never makes it jump, swinging away from the click. - if (m.kick < 0.05) m.swing = x < this.rightBranch(0.71 * this.A)[0] / this.H ? Math.PI : 0 + if (m.kick < 0.05) m.swing = x < this.A - MONKEY ? Math.PI : 0 m.kick = 1 } render() { this.hdr.set(this.background) + this.vines.draw(this.hdr, this.W, this.H, this.time) this.drawShafts() this.drawFalls() + const mist = this.look.mist[1] + driftMist(this.hdr, this.W, this.H, this.mist, mist[0], mist[1], mist[2], this.rainy ? 0.3 : 0.22) if (this.toucan.x > -5) this.drawToucan(this.toucan) if (this.sloth.t >= 0) this.drawSloth() if (this.activity !== "calm") this.drawMonkey() if (this.activity === "teeming") { - this.drawMacaw(0.795, -1, 0) - this.drawMacaw(0.765, 1, 1.7) + this.drawMacaw(this.A - 0.19, -1, 0) + this.drawMacaw(this.A - 0.24, 1, 1.7) this.drawJaguar() this.drawFrog() } @@ -239,6 +264,7 @@ class Jungle extends Canvas { const { W, H, A, look } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: 0.85 * W, y: -0.15 * H } this.drawMist() + const mist = this.hdr.slice() // Distant trees fade into the mist: thin trunks and a hazy band of canopy. for (let i = 0; i < Math.round(A * 7); i++) { const x = (i + 0.2 + hash(i * 3.1) * 0.6) * (A / Math.round(A * 7)) * H @@ -250,8 +276,10 @@ class Jungle extends Canvas { this.lighting, 0.15, ) - // A rocky cliff with the waterfall pouring over it. - const fx = 0.55 * A + // By day the distant trees melt further into the mist. + if (look.style === "front") haze(this.hdr, mist, W, H, 1, 1.01, 0.3) + // A rocky cliff with the waterfall pouring over it, on the left; the animals' tree stands on the right. + const fx = 0.32 this.falls = { x: fx * H, top: 0.27 * H, bottom: 0.66 * H, half: (this.rainy ? 0.026 : 0.018) * H } const rock = mixRGB(this.paint(ROCK), look.far, 0.35) const boulders: [number, number, number, number][] = [ @@ -276,17 +304,20 @@ class Jungle extends Canvas { this.lighting, ) // Vines hanging from the canopy, clear of the waterfall. + this.vines.begin(this.hdr) for (let i = 0; i < 7; i++) { const vine = { x: (0.06 + (i / 6) * 0.88) * A, length: 0.25 + hash(i * 2.2) * 0.35, phase: hash(i * 4.4) * TAU } if (Math.abs(vine.x - fx) < 0.06) continue if (i === 0) this.liana = vine this.drawVine(vine.x * H, vine.length * H, vine.phase) } + // Hanging from the top, they swing more toward their tips. + this.vines.end(this.hdr, W, H, 0.014 * H, (_x, y) => clamp(y / (0.55 * H), 0, 1) ** 1.3) // Two big trunks with buttress roots, and the branches the animals use. - this.drawTrunk(0.2 * A * H, 0.045 * H) - this.drawTrunk(0.86 * A * H, 0.038 * H) - this.shape([cap(...this.leftBranch(0.2 * A + 0.03), ...this.leftBranch(0.48 * A), 0.022 * H, 0.012 * H, this.paint(TRUNK))], this.lighting) - this.shape([cap(...this.rightBranch(0.86 * A - 0.03), ...this.rightBranch(0.66 * A), 0.018 * H, 0.01 * H, this.paint(TRUNK))], this.lighting) + this.drawTrunk(0.04 * H, 0.045 * H) + this.drawTrunk((A - 0.12) * H, 0.038 * H) + this.shape([cap(...this.lowBranch(A - 0.15), ...this.lowBranch(A - 0.5), 0.022 * H, 0.012 * H, this.paint(TRUNK))], this.lighting) + this.shape([cap(...this.highBranch(A - 0.15), ...this.highBranch(A - 0.46), 0.018 * H, 0.01 * H, this.paint(TRUNK))], this.lighting) // The forest floor and its ferns. this.groundPx = new Float32Array(W) for (let x = 0; x < W; x++) this.groundPx[x] = (0.86 + 0.02 * Math.sin((x / H) * 3.3) + (fbm1(x * 0.02, 4) - 0.5) * 0.03) * H @@ -296,8 +327,12 @@ class Jungle extends Canvas { const c = this.paint(FLOOR) this.blend((y * W + x) * 3, c[0] * k, c[1] * k, c[2] * k, clamp(y + 1 - this.groundPx[x], 0, 1)) } + // Sun flecks and shade across the forest floor. + if (look.style === "front") mottle(this.hdr, W, H, 0.8, 1, 0.3, [1.5, 1.35, 0.8], [0.7, 0.8, 0.8]) + for (const [x, r] of [[0.04, 0.045], [A - 0.12, 0.038]]) groundShadow(this.hdr, W, H, this.shade, x * H, this.groundPx[clamp(Math.round(x * H), 0, W - 1)] + 0.03 * H, r * 6 * H, H) for (let i = 0; i < Math.round(A * 6); i++) { const x = (hash(i * 3.9) * A) * H + groundShadow(this.hdr, W, H, this.shade, x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2, (0.1 + hash(i * 1.1) * 0.07) * H, 0.1 * H) this.drawFern(x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2, (0.08 + hash(i * 1.1) * 0.06) * H, hash(i * 5.5) > 0.5 ? 1 : -1) } // Big leaves framing the corners, drawn each frame so they can sway. @@ -307,6 +342,7 @@ class Jungle extends Canvas { ...[0.6, 0.95].map((angle, i) => ({ x: -0.02, y: -0.04, angle, length: [0.3, 0.26][i], phase: i * 1.3 + 2 })), ...[2.5, 2.2].map((angle, i) => ({ x: A + 0.02, y: -0.04, angle, length: [0.3, 0.25][i], phase: i * 1.7 + 3 })), ] + this.vines.seal(this.hdr, W) this.background = this.hdr.slice() } @@ -315,15 +351,14 @@ class Jungle extends Canvas { return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] } - // Points along the two animal branches, in pixels, at x in screen heights. - private leftBranch(x: number): [number, number] { - const t = (x - (0.2 * this.A + 0.03)) / (0.28 * this.A - 0.03) - return [x * this.H, lerp(0.47, 0.43, t) * this.H] + // Points along the two animal branches of the right-hand tree, in pixels, at x in screen heights. They rise a little + // toward their tips. + private lowBranch(x: number): [number, number] { + return [x * this.H, lerp(0.69, 0.65, (this.A - 0.15 - x) / 0.35) * this.H] } - private rightBranch(x: number): [number, number] { - const t = ((0.86 * this.A - 0.03) - x) / (0.2 * this.A - 0.03) - return [x * this.H, lerp(0.31, 0.29, t) * this.H] + private highBranch(x: number): [number, number] { + return [x * this.H, lerp(0.31, 0.29, (this.A - 0.15 - x) / 0.31) * this.H] } // Misty depth: bright and hazy above, dark at the forest floor. @@ -378,10 +413,12 @@ class Jungle extends Canvas { for (let i = 0; i < 10; i++) { const [ax, ay] = at(i / 10) const [bx, by] = at((i + 1) / 10) - parts.push(cap(ax, ay, bx, by, size * 0.025, size * 0.02, this.paint(LEAF))) + parts.push(cap(ax, ay, bx, by, this.thick((size * 0.025) / this.H), this.thick((size * 0.02) / this.H, 0.8), this.paint(LEAF))) const leaflet = size * 0.14 * (1 - i / 12) - for (const s of [-1, 1]) parts.push(ell(ax + s * leaflet * 0.4, ay - leaflet * 0.5, leaflet * 0.6, leaflet * 0.18, s * 0.9 - 0.5 * side, this.paint(LEAF))) + if (!this.cells) for (const s of [-1, 1]) parts.push(ell(ax + s * leaflet * 0.4, ay - leaflet * 0.5, leaflet * 0.6, leaflet * 0.18, s * 0.9 - 0.5 * side, this.paint(LEAF))) } + // On terminal cells the leaflets merge into one blade. + if (this.cells) parts.push(...blade(Array.from({ length: 11 }, (_, i) => at(i / 10)), Array.from({ length: 11 }, (_, i) => size * 0.1 * (1 - i / 12)), this.paint(LEAF))) this.shape(parts, this.lighting, 0.6) } @@ -445,7 +482,7 @@ class Jungle extends Canvas { if (b.next > 0) return b.dir = Math.random() < 0.5 ? 1 : -1 b.x = b.dir > 0 ? -0.15 : this.A + 0.15 - b.y = rand(0.18, 0.32) + b.y = this.openRow(0.16, 0.32) return } b.x += b.dir * 0.08 * dt @@ -480,29 +517,34 @@ class Jungle extends Canvas { ) } - // A spider monkey hanging by its tail from the right-hand branch, swinging slowly; at night it barely stirs. + // A spider monkey hanging by its tail from the high branch, swinging slowly, its long arms and legs trailing each + // swing; at night it barely stirs. private drawMonkey() { const H = this.H - const [ax, ay] = this.rightBranch(0.71 * this.A) + const [ax, ay] = this.highBranch(this.A - MONKEY) const m = this.monkey - const swing = (this.look.night ? 0.05 : 0.35) * Math.sin(this.creatureTime * 1.2) + (this.look.night ? 0.2 : 0.4) * m.kick * Math.sin(m.swing) + const night = this.look.night + const swingAt = (t: number) => (night ? 0.05 : 0.35) * Math.sin(t * 1.2) + (night ? 0.2 : 0.4) * m.kick * Math.sin(m.swing - (this.creatureTime - t) * 6) + const swing = swingAt(this.creatureTime) + // Limbs hang a beat behind the body, so they trail each swing and catch up at its end. + const trail = (swingAt(this.creatureTime - 0.3) - swing) * 1.4 const cs = Math.cos(swing) const sn = Math.sin(swing) const P = (u: number, v: number): [number, number] => [ax + (u * cs - v * sn) * H, ay + (u * sn + v * cs) * H] + const hang = (u0: number, v0: number, u: number, v: number): [number, number] => [u0 + (u - u0) * Math.cos(trail) - (v - v0) * Math.sin(trail), v0 + (u - u0) * Math.sin(trail) + (v - v0) * Math.cos(trail)] const fur = this.paint([0.3, 0.2, 0.12]) const parts: Part[] = [cap(ax - 0.008 * H, ay - 0.01 * H, ax + 0.008 * H, ay - 0.01 * H, 0.004 * H, 0.004 * H, fur)] for (let i = 0; i < 4; i++) parts.push(cap(...P(Math.sin(i * 0.8) * 0.006, (i / 4) * 0.07), ...P(Math.sin((i + 1) * 0.8) * 0.006, ((i + 1) / 4) * 0.07), 0.004 * H, 0.004 * H, fur)) parts.push( - cap(...P(0, 0.07), ...P(0, 0.14), 0.018 * H, 0.016 * H, fur), - cap(...P(0, 0.075), ...P(-0.025, 0.045), 0.006 * H, 0.005 * H, fur), - cap(...P(0, 0.075), ...P(0.025, 0.05), 0.006 * H, 0.005 * H, fur), - cap(...P(-0.006, 0.135), ...P(-0.03, 0.2), 0.006 * H, 0.005 * H, fur), - cap(...P(0.006, 0.135), ...P(0.04, 0.19), 0.006 * H, 0.005 * H, fur), - ell(...P(0, 0.165), 0.02 * H, 0.019 * H, swing, fur), - ell(...P(0, 0.17), 0.012 * H, 0.011 * H, swing, this.paint([0.65, 0.5, 0.38])), + cap(...P(0, 0.07), ...P(0, 0.14), 0.018 * H, 0.02 * H, fur), + ...[-1, 1].flatMap((s) => limb(P, s * 0.006, 0.078, ...hang(s * 0.006, 0.078, s * 0.035, 0.05), 0.03, 0.03, s, 0.007 * H, 0.005 * H, 0.004 * H, fur)), + ...[-1, 1].flatMap((s) => limb(P, s * 0.008, 0.135, ...hang(s * 0.008, 0.135, s * 0.03, 0.225), 0.05, 0.05, -s, 0.006 * H, 0.005 * H, 0.004 * H, fur)), + ell(...P(0, 0.165), 0.019 * H, 0.019 * H, swing, fur), + ...[-1, 1].map((s) => ell(...P(s * 0.018, 0.16), 0.006 * H, 0.006 * H, 0, fur)), + ell(...P(0, 0.172), 0.012 * H, 0.011 * H, swing, this.paint([0.65, 0.5, 0.38])), ) this.shape(parts, this.lighting, 0.7) - for (const s of [-1, 1]) this.add(...P(s * 0.005, 0.167), 0.01, 0.01, 0.01) + for (const s of [-1, 1]) this.add(...P(s * 0.005, 0.169), 0.01, 0.01, 0.01) } // A three-toed sloth hanging from the corner vine by its long arms, lowering itself hand over hand out of the @@ -513,7 +555,8 @@ class Jungle extends Canvas { const v = this.liana const s = lerp(0.2, 0.75, smoothstep(0, 10, t) * (1 - smoothstep(14, 24, t))) const at = (q: number): [number, number] => [(v.x + Math.sin(q * Math.PI * 0.8 + v.phase) * 0.02 * q) * H, q * v.length * H] - const [gx, gy] = at(s) + const [vx, gy] = at(s) + const gx = vx + this.vines.offset(vx, gy) const S = 0.17 * H const climbing = (t < 10 || t > 14) && t < 24 const reach = climbing ? Math.sin(t * 4) * 0.05 : 0 @@ -543,10 +586,10 @@ class Jungle extends Canvas { this.shape([ell(...F(-0.035, 0.215), 0.028 * S, 0.012 * S, tilt - 0.35, dark), ell(...F(0.035, 0.215), 0.028 * S, 0.012 * S, tilt + 0.35, dark), ell(...F(0, 0.245), 0.014 * S, 0.008 * S, tilt, dark)], this.lighting, 0) } - // A scarlet macaw perched upright on the right-hand branch, bobbing its head now and then. + // A scarlet macaw perched upright on the high branch, bobbing its head now and then. private drawMacaw(at: number, face: number, phase: number) { const H = this.H - const [x, y] = this.rightBranch(at * this.A) + const [x, y] = this.highBranch(at) const S = 0.11 * H const bob = this.look.night ? 0 : Math.sin(this.creatureTime * 2 + phase) * 0.012 const P = (u: number, v: number): [number, number] => [x + u * face * S, y - 0.012 * H + v * S] @@ -568,52 +611,65 @@ class Jungle extends Canvas { this.add(...P(0.07, -0.7 + bob), 0.01, 0.01, 0.01) } - // A jaguar draped along the left-hand branch, legs dangling, breathing slowly as its tail sways. + // A jaguar draped along the low branch facing the trunk, chin on one paw and the other legs dangling, breathing + // slowly as its tail sways. Now and then it lifts its head to look about, and lets it sink back to rest. private drawJaguar() { - const { A, H } = this + const H = this.H + // x runs along its back from the tail end, in screen heights. const top = (x: number): [number, number] => { - const [px, py] = this.leftBranch(x * A) + const [px, py] = this.lowBranch(this.A - 0.46 + x) return [px, py - 0.035 * H] } const breathe = 1 + 0.03 * Math.sin(this.creatureTime * 1.5) const coat = this.paint([0.85, 0.58, 0.22]) - const [hx, hy] = top(0.445) - const [rx, ry] = top(0.29) - const [fx, fy] = top(0.415) + const pale = this.paint([0.92, 0.82, 0.62]) + const lift = this.jaguarHead.p + const [rx, ry] = top(0.015) + const [fx, fy] = top(0.167) + const [hx, hy] = [top(0.236)[0] - lift * 0.004 * H, top(0.236)[1] - lift * 0.022 * H] + // Dangling legs swing gently, the paws a beat behind. + const dangle = (t: number) => Math.sin(this.creatureTime * 0.6 - t) * 0.006 * H const parts: Part[] = [ - cap(...top(0.29), ...top(0.41), 0.03 * H * breathe, 0.028 * H * breathe, coat), - cap(fx, fy + 0.015 * H, fx + 0.004 * H, fy + 0.085 * H, 0.011 * H, 0.009 * H, coat), - cap(fx - 0.02 * H, fy + 0.015 * H, fx - 0.018 * H, fy + 0.07 * H, 0.01 * H, 0.008 * H, coat), - cap(rx + 0.01 * H, ry + 0.015 * H, rx + 0.012 * H, ry + 0.08 * H, 0.012 * H, 0.009 * H, coat), - ell(hx, hy - 0.005 * H, 0.026 * H, 0.023 * H, 0, coat), - ell(hx + 0.016 * H, hy + 0.004 * H, 0.012 * H, 0.009 * H, 0, this.paint([0.92, 0.82, 0.62])), - cap(hx - 0.014 * H, hy - 0.02 * H, hx - 0.016 * H, hy - 0.034 * H, 0.007 * H, 0.004 * H, coat), - cap(hx + 0.006 * H, hy - 0.024 * H, hx + 0.008 * H, hy - 0.038 * H, 0.007 * H, 0.004 * H, coat), + ell(rx, ry - 0.002 * H, 0.033 * H, 0.03 * H * breathe, -0.08, coat), + cap(...top(0.03), ...top(0.152), 0.025 * H * breathe, 0.027 * H * breathe, coat), + ell(fx, fy, 0.03 * H, 0.03 * H * breathe, 0, coat), + cap(fx + 0.012 * H, fy - 0.006 * H, hx - 0.012 * H, hy + 0.004 * H, 0.02 * H, 0.016 * H, coat), + cap(fx + 0.008 * H, fy + 0.012 * H, fx + 0.036 * H, fy + 0.03 * H, 0.011 * H, 0.009 * H, coat), + ell(fx + 0.042 * H, fy + 0.031 * H, 0.012 * H, 0.007 * H, 0, coat), + cap(fx - 0.012 * H, fy + 0.016 * H, fx - 0.008 * H + dangle(0), fy + 0.05 * H, 0.011 * H, 0.008 * H, coat), + cap(fx - 0.008 * H + dangle(0), fy + 0.05 * H, fx - 0.006 * H + dangle(0.6), fy + 0.08 * H, 0.008 * H, 0.007 * H, coat), + ell(fx - 0.004 * H + dangle(0.8), fy + 0.084 * H, 0.009 * H, 0.007 * H, 0, coat), + cap(rx + 0.006 * H, ry + 0.014 * H, rx + 0.016 * H + dangle(0.3), ry + 0.05 * H, 0.014 * H, 0.009 * H, coat), + cap(rx + 0.016 * H + dangle(0.3), ry + 0.05 * H, rx + 0.012 * H + dangle(0.9), ry + 0.08 * H, 0.008 * H, 0.007 * H, coat), + ell(rx + 0.015 * H + dangle(1.1), ry + 0.084 * H, 0.009 * H, 0.007 * H, 0, coat), + ell(hx, hy - 0.005 * H, 0.024 * H, 0.021 * H, 0, coat), + ell(hx + 0.017 * H, hy + 0.003 * H, 0.012 * H, 0.009 * H, 0.2, pale), + ...[-0.012, 0.004].map((dx) => ell(hx + dx * H - lift * 0.002 * H, hy - (0.024 + lift * 0.004) * H, 0.006 * H, 0.006 * H, 0, coat)), ] // The tail hangs from the hips and sways, more at the tip. - let [tx, ty] = [rx - 0.02 * H, ry] + let [tx, ty] = [rx - 0.026 * H, ry] let angle = Math.PI / 2 + 0.25 * Math.sin(this.creatureTime * 0.8) for (let i = 0; i < 6; i++) { const nx = tx + Math.cos(angle) * 0.022 * H const ny = ty + Math.sin(angle) * 0.022 * H - parts.push(cap(tx, ty, nx, ny, 0.007 * H, 0.006 * H, i === 5 ? this.paint(BLACK) : coat)) - angle += 0.12 * Math.sin(this.creatureTime * 0.8 + i * 0.5) + parts.push(cap(tx, ty, nx, ny, lerp(0.009, 0.006, i / 6) * H, lerp(0.009, 0.006, (i + 1) / 6) * H, i === 5 ? this.paint(BLACK) : coat)) + angle += 0.12 * Math.sin(this.creatureTime * 0.8 - i * 0.5) ;[tx, ty] = [nx, ny] } this.shape(parts, this.lighting, 0.7) // Rosettes along the body. for (let i = 0; i < 14; i++) { - const [sx, sy] = top(lerp(0.295, 0.405, hash(i * 3.3))) + const [sx, sy] = top(lerp(0.008, 0.174, hash(i * 3.3))) this.shape([ell(sx, sy + (hash(i * 7.1) - 0.5) * 0.035 * H, 0.0045 * H, 0.0035 * H, 0, this.paint([0.12, 0.07, 0.03]))], this.lighting, 0) } const glow = this.look.night ? 1.5 : 0 this.add(hx + 0.012 * H, hy - 0.008 * H, 0.8 * glow + 0.01, 1 * glow + 0.01, 0.3 * glow + 0.01) } - // A red-eyed tree frog on the left branch near the trunk, blinking now and then. + // A red-eyed tree frog on the high branch between the macaws and the monkey, blinking now and then. private drawFrog() { const H = this.H - const [x, y] = this.leftBranch(0.25 * this.A) + const [x, y] = this.highBranch(this.A - 0.3) const cy = y - 0.026 * H const green = this.paint([0.25, 0.7, 0.15]) const blink = this.time % 7 < 0.15 @@ -636,8 +692,7 @@ class Jungle extends Canvas { b.wanderT -= dt if (b.wanderT <= 0 || Math.hypot(b.wx - b.x, b.wy - b.y) < 0.03) { b.wanderT = rand(4, 9) - b.wx = rand(0.1 * A, 0.9 * A) - b.wy = rand(0.3, 0.75) + ;[b.wx, b.wy] = this.openSpot(0.1 * A, 0.9 * A, 0.3, 0.75) } const dx = b.wx - b.x const dy = b.wy - b.y @@ -672,6 +727,7 @@ class Jungle extends Canvas { for (const f of this.glowers) { const k = Math.max(together, Math.pow(Math.max(0, Math.sin(this.time * 1.3 + f.phase * 7)), 6)) if (k < 0.02) continue + fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 0.9 * k * 2, 1.2 * k * 2, 0.3 * k * 2, 0.6) this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } @@ -687,7 +743,8 @@ class Jungle extends Canvas { // A big glossy leaf on a short stalk, swaying gently, with a pale midrib. private drawLeaf(l: Leaf) { const H = this.H - const angle = l.angle + Math.sin(this.time * 0.5 + l.phase) * 0.035 + // Turned whichever way carries the tip downwind. + const angle = l.angle + sway(l.x, this.time, l.phase, 0.5) * 0.035 * (Math.sin(l.angle) > 0 ? -1 : 1) const dx = Math.cos(angle) const dy = Math.sin(angle) const x = l.x * H @@ -703,6 +760,22 @@ function mixRGB(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } +// Low mist drifting between the trunks on the breeze, thickest just above the floor and thinner behind the text. +function driftMist(hdr: Float32Array, W: number, H: number, drift: number, r: number, g: number, b: number, strength: number) { + for (let y = Math.floor(0.5 * H); y < Math.min(H, Math.ceil(0.96 * H)); y++) { + const v = y / H + const band = Math.exp(-(((v - 0.8) / 0.12) ** 2)) * strength + for (let x = 0; x < W; x++) { + const u = x / H + const a = band * (0.45 + 0.55 * smoothstep(0.1, 0.3, Math.abs(x / W - 0.5))) * MIST_A[((u - drift) * 90 + v * 40) & (RAY_N - 1)] * (0.3 + 0.7 * MIST_B[((u - drift * 0.6) * 55 - v * 25 + 300) & (RAY_N - 1)]) + const o = (y * W + x) * 3 + hdr[o] += (r - hdr[o]) * a + hdr[o + 1] += (g - hdr[o + 1]) * a + hdr[o + 2] += (b - hdr[o + 2]) * a + } + } +} + // A wrapping strip of soft bands; two sliding past each other make the light shafts. function buildRays(seed: number) { const tex = new Float32Array(RAY_N) diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index ec5e136..58c7610 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -1,6 +1,7 @@ import { Canvas, cap, ell, type Lighting } from "../src/canvas" import { eggWait } from "../src/egg" import { TAU, clamp, fbm1, hash, hash2, hashString, hsv, lerp, noise1, rand, smoothstep, type RGB } from "../src/math" +import { lightPool } from "../src/light" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" // Depth of the water surface as a fraction of the height. @@ -119,6 +120,8 @@ class Ocean extends Canvas { private floorPx = new Float32Array(0) private farPx = new Float32Array(0) private midPx = new Float32Array(0) + // By day, broad lighter and darker stretches along the near ridge, so it reads as rock rather than a flat cutout. + private midTone = new Float32Array(0) private pad = 0 private lastA = 1 private fish: Fish[] = [] @@ -127,13 +130,19 @@ class Ocean extends Canvas { private octopus: Octopus | undefined private kelp: Kelp[] = [] private particles: Particle[] = [] + // At night: the water alone, before anything swims over it; how much of each pixel is something in front of the + // water; and the night water color per row. + private water = new Float32Array(0) + private mask = new Float32Array(0) + private nightRow = new Float32Array(0) private snow: { x: number; y: number; z: number; s: number }[] = [] private whale = { x: -9, dir: 1, next: 14, y: 0.4 } // The easter egg: a little yellow submarine putters across, propeller turning. x < -5 while it waits. private sub = { x: -9, y: 0.4, dir: 1, wait: eggWait() * TIME_SCALE, spin: 0 } // Each creature keeps to its own home area so they never pile up: shark along the top, fish lower left, octopus - // lower right, diver upper left, turtle along the bottom. Areas are laid out before the first resize, at A = 1. + // lower right, diver upper left, turtle along the bottom, which leaves the middle, where text sits, to open water. + // Within its area each heads for the spots least covered by text. Areas are laid out before the first resize, at A = 1. private readonly activity: Activity private readonly timeOfDay: Time @@ -144,10 +153,10 @@ class Ocean extends Canvas { this.timeOfDay = settings.time if (activity === "teeming") this.fish.push(this.makeFish("ambient:shark", "shark", [0.3, 1, 0.17, 0.33], { len: 0.64, z: 0.55 })) if (activity !== "calm") { - this.fish.push(this.makeFish("ambient:fish", "fish", [0, 0.44, 0.48, 0.72], { len: 0.26, z: 0.35, hue: 0.07, sat: 0.85 })) - const zone: Zone = [0.58, 1, 0.46, 0.7] + this.fish.push(this.makeFish("ambient:fish", "fish", [0, 0.36, 0.5, 0.76], { len: 0.19, z: 0.35, hue: 0.07, sat: 0.85 })) + const zone: Zone = [0.74, 1, 0.54, 0.76] const ox = ((zone[0] + zone[1]) / 2) * this.A - this.octopus = { x: ox, y: 0.58, z: 0.4, vx: 0, vy: 0, size: 0.1, hue: 0.02, phase: 0, pulse: 0, next: 2, dir: -Math.PI / 2, wx: ox, wy: 0.58, zone } + this.octopus = { x: ox, y: 0.64, z: 0.4, vx: 0, vy: 0, size: 0.1, hue: 0.02, phase: 0, pulse: 0, next: 2, dir: -Math.PI / 2, wx: ox, wy: 0.64, zone } } if (activity === "teeming") { this.turtle = { x: 0.5 * this.A, y: 0.84, z: 0.4, vx: 0, vy: 0, face: -1, turn: -1, heading: -1, phase: 0, wx: 0.5 * this.A, wy: 0.84, wanderT: 0, zone: [0.22, 0.8, 0.78, 0.9] } @@ -213,8 +222,7 @@ class Ocean extends Canvas { tu.wanderT -= dt if (tu.wanderT <= 0 || Math.hypot(tu.wx - tu.x, tu.wy - tu.y) < 0.04) { tu.wanderT = rand(8, 14) - tu.wx = rand(zx0, zx1) - tu.wy = rand(tu.zone[2], tu.zone[3]) + ;[tu.wx, tu.wy] = this.openSpot(zx0, zx1, tu.zone[2], tu.zone[3]) } tu.phase += dt * 1.5 const dx = tu.wx - tu.x @@ -235,8 +243,7 @@ class Ocean extends Canvas { dv.wanderT -= dt if (dv.wanderT <= 0 || Math.hypot(dv.wx - dv.x, dv.wy - dv.y) < 0.03) { dv.wanderT = rand(5, 10) - dv.wx = rand(zx0, zx1) - dv.wy = rand(dv.zone[2], dv.zone[3]) + ;[dv.wx, dv.wy] = this.openSpot(zx0, zx1, dv.zone[2], dv.zone[3]) } const dx = dv.wx - dv.x const dy = dv.wy - dv.y @@ -262,8 +269,7 @@ class Ocean extends Canvas { o.next -= dt if (o.next <= 0) { if (Math.hypot(o.wx - o.x, o.wy - o.y) < 0.12 || Math.random() < 0.3) { - o.wx = rand(o.zone[0] * this.A + 0.12, o.zone[1] * this.A - 0.12) - o.wy = rand(o.zone[2] + 0.04, o.zone[3] - 0.04) + ;[o.wx, o.wy] = this.openSpot(o.zone[0] * this.A + 0.12, o.zone[1] * this.A - 0.12, o.zone[2] + 0.04, o.zone[3] - 0.04) } const dx = o.wx - o.x const dy = o.wy - o.y @@ -289,10 +295,13 @@ class Ocean extends Canvas { } render() { + const night = this.timeOfDay === "night" this.drawWater() + if (night) this.water.set(this.hdr) this.drawWhale() this.drawSub() - this.drawSnow(true) + // At night gradeNight turns the marine snow into glowing plankton. + if (!night) this.drawSnow(true) // Nearer creatures (lower z) draw later, over farther ones. const fish = this.fish.toSorted((a, b) => b.z - a.z) for (const f of fish) if (f.z >= 0.5) this.drawCreature(f) @@ -302,11 +311,11 @@ class Ocean extends Canvas { if (this.turtle) this.drawTurtle(this.turtle) for (const f of fish) if (f.z < 0.5) this.drawCreature(f) this.drawParticles() - this.drawSnow(false) + if (!night) this.drawSnow(false) if (this.timeOfDay === "sunset") this.gradeSunset() - if (this.timeOfDay === "night") this.gradeNight() + if (night) this.gradeNight() // At night the portholes glow amber through the dark water. - if (this.timeOfDay === "night" && this.sub.x > -5) + if (night && this.sub.x > -5) for (const u of [-0.16, 0.02, 0.2]) { const [px, py] = this.subPoint(u, 0) this.disc(px, py, 0.0135 * this.H, 1.4, 0.75, 0.18, 0.85) @@ -328,20 +337,28 @@ class Ocean extends Canvas { } } - // Moonlit water: the scene dims to deep blue, then light sources draw on top. The moon glows through the surface, - // marine snow becomes glowing plankton, and the diver's torch cuts through the dark. + // Moonlit water: lighter toward the surface, with everything in front of it dark and rimmed in teal on the side + // facing the moon. Light sources draw on top: marine snow becomes glowing plankton, which gathers in two glowing + // clouds low at the sides, and the diver's torch cuts through the dark. private gradeNight() { const { W, H, hdr } = this const mx = 0.3 * this.A * H const my = SURFACE * H - for (let y = 0; y < H; y++) - for (let x = 0; x < W; x++) { - const o = (y * W + x) * 3 - const moon = Math.exp(-Math.hypot(x - mx, (y - my) * 1.6) / (0.16 * H)) - hdr[o] = hdr[o] * 0.04 + moon * 0.05 - hdr[o + 1] = hdr[o + 1] * 0.16 + moon * 0.28 - hdr[o + 2] = hdr[o + 2] * 0.4 + moon * 0.6 + gradeNightWater(hdr, this.water, this.mask, this.rowCol, this.nightRow, W, H, mx, my) + for (const [i, u, v] of [[0, 0.09, 0.74], [1, 0.9, 0.84]]) { + const cx = (u + Math.sin(this.time * 0.05 + i * 2) * 0.02) * this.A * H + const cy = (v + Math.sin(this.time * 0.07 + i) * 0.015) * H + const breathe = 0.85 + 0.15 * Math.sin(this.time * 0.4 + i * 3) + lightPool(hdr, W, H, cx, cy, 0.26 * H, 0.12 * H, [0.12, 1, 0.75], 1.4 * breathe, 0.11 * breathe) + for (let j = 0; j < 16; j++) { + const a = hash2(i, j) * TAU + const r = Math.sqrt(hash2(j, i + 7)) + const px = cx + Math.cos(a + this.time * 0.03) * r * 0.24 * H + const py = cy + Math.sin(a + this.time * 0.03) * r * 0.12 * H + const k = 0.35 * Math.pow(0.5 + 0.5 * Math.sin(this.time * 1.2 + j * 2.3 + i), 2) * (1 - r * 0.6) + this.add(px, py, 0.1 * k, 0.95 * k, 0.65 * k) } + } for (const p of this.snow) { const pulse = Math.pow(0.5 + 0.5 * Math.sin(this.time * 1.5 + p.s * 50), 3) const k = (0.12 + p.s * 0.45) * pulse @@ -368,6 +385,20 @@ class Ocean extends Canvas { for (let c = 0; c < 3; c++) this.rowCol[y * 3 + c] = lerp(from[c], to[c], k) this.rayFade[y] = Math.pow(1 - smoothstep(0.02, 0.92, v), 1.6) * 0.42 } + if (this.timeOfDay === "night") { + this.water = new Float32Array(W * H * 3) + this.mask = new Float32Array(W * H) + this.nightRow = new Float32Array(H * 3) + const surface: RGB = [0.01, 0.13, 0.32] + const mid: RGB = [0.004, 0.045, 0.13] + const deep: RGB = [0.003, 0.022, 0.075] + for (let y = 0; y < H; y++) { + const v = y / H + const upper = v < 0.45 + const k = upper ? Math.pow(smoothstep(0.03, 0.45, v), 0.8) : smoothstep(0.45, 1, v) + for (let c = 0; c < 3; c++) this.nightRow[y * 3 + c] = lerp(upper ? surface[c] : mid[c], upper ? mid[c] : deep[c], k) + } + } // An invisible seabed that keeps creatures off the bottom edge. this.floorPx = new Float32Array(W) for (let x = 0; x < W; x++) { @@ -378,12 +409,14 @@ class Ocean extends Canvas { this.pad = Math.ceil(H * 0.2) this.farPx = new Float32Array(W + this.pad * 2) this.midPx = new Float32Array(W + this.pad * 2) + this.midTone = new Float32Array(W + this.pad * 2) for (let x = 0; x < W + this.pad * 2; x++) { const u = (x - this.pad) / H const spires = Math.pow(noise1(u * 4.2, 9), 5) * 0.32 this.farPx[x] = (0.56 - (fbm1(u * 1.4, 7) - 0.5) * 0.3 - spires) * H const arches = Math.pow(noise1(u * 6.5, 12), 7) * 0.25 this.midPx[x] = (0.72 - (fbm1(u * 2.3, 11) - 0.5) * 0.2 - arches) * H + this.midTone[x] = this.timeOfDay === "day" ? 0.6 + 0.55 * smoothstep(0.3, 0.7, fbm1(u * 3.1, 17)) : 1 } // Three kelp strands in the gaps between the creatures' areas. this.kelp = this.activity === "teeming" ? [[0.05, 0.28], [0.5, 0.21], [0.96, 0.3]].map(([x, height]) => ({ x: x * A, height, phase: x * 9 })) : [] @@ -423,8 +456,7 @@ class Ocean extends Canvas { if (f.wanderT <= 0 || Math.hypot(f.wx - f.x, f.wy - f.y) < 0.05) { f.wanderT = rand(3, 8) const m = f.len * this.zScale(f) * 0.5 - f.wx = rand(f.zone[0] * this.A + m, Math.max(f.zone[0] * this.A + m, f.zone[1] * this.A - m)) - f.wy = rand(f.zone[2], f.zone[3]) + ;[f.wx, f.wy] = this.openSpot(f.zone[0] * this.A + m, Math.max(f.zone[0] * this.A + m, f.zone[1] * this.A - m), f.zone[2], f.zone[3]) } const dx = tx - f.x const dy = ty - f.y @@ -474,7 +506,7 @@ class Ocean extends Canvas { if (w.next > 0) return w.dir = Math.random() < 0.5 ? 1 : -1 w.x = w.dir > 0 ? -0.9 : this.A + 0.9 - w.y = rand(0.3, 0.48) + w.y = this.openRow(0.22, 0.48) return } w.x += w.dir * 0.055 * dt @@ -491,7 +523,7 @@ class Ocean extends Canvas { if (s.wait > 0) return s.dir = Math.random() < 0.5 ? 1 : -1 s.x = s.dir > 0 ? -0.3 : this.A + 0.3 - s.y = rand(0.32, 0.42) + s.y = this.openRow(0.28, 0.44) return } s.x += s.dir * 0.1 * cdt @@ -612,12 +644,14 @@ class Ocean extends Canvas { // Depth-graded water with god rays, distant ridges, and a bright rippling surface with caustics. private drawWater() { - const { W, H, hdr, rowCol, rayFade, farPx, midPx } = this + const { W, H, hdr, rowCol, rayFade, farPx, midPx, midTone } = this const t = this.time const surf = SURFACE * H const camFar = Math.round(Math.sin(t * 0.09) * H * 0.04) + this.pad const camMid = Math.round(Math.sin(t * 0.09) * H * 0.08) + this.pad const cs = 128 / (H * 0.55) + // The caustic net is squashed into the thin surface band; less so on terminal cells, which would break its lines up. + const squash = this.cells ? 2 : 4 // After an error the rays fade, as if clouds had covered the sun. const overcast = 1 - smoothstep(0, 1, this.gloom) * 0.7 for (let y = 0; y < H; y++) { @@ -630,7 +664,7 @@ class Ocean extends Canvas { const sl = surf + Math.sin(x * 0.045 + t * 1.3) * 1.4 + Math.sin(x * 0.13 - t * 2.1) * 0.7 if (y < sl) { const cu = ((x * 0.6 * cs + t * 9) | 0) & 127 - const cv = ((y * 4 * cs + x * 0.2 * cs - t * 6) | 0) & 127 + const cv = ((y * squash * cs + x * 0.2 * cs - t * 6) | 0) & 127 const c = CAUSTICS[cv * CAUS_N + cu] const win = Math.exp(-Math.pow((x / W - 0.5) * 2.2, 2)) * 0.6 const edge = smoothstep(sl - 3, sl, y) @@ -652,9 +686,10 @@ class Ocean extends Canvas { const near = midPx[x + camMid] if (y > near) { const k = smoothstep(near, near + 2, y) - r = lerp(r, r * 0.35, k) - g = lerp(g, g * 0.42, k) - b = lerp(b, b * 0.5, k) + const tone = midTone[x + camMid] + r = lerp(r, r * 0.35 * tone, k) + g = lerp(g, g * 0.42 * tone, k) + b = lerp(b, b * 0.5 * tone, k) } const ray = RAYS_A[((x + ry1) | 0) & 1023] * (0.45 + 0.55 * RAYS_B[((x * 0.7 + ry2) | 0) & 1023]) * rf * flick hdr[i] = r + ray * 0.5 @@ -727,13 +762,18 @@ class Ocean extends Canvas { } } - // Marine snow: faint specks behind the whale's depth, a few brighter flakes in front. + // Marine snow: faint specks behind the whale's depth, a few brighter flakes in front, lit up like dust motes as they + // drift through a shaft of light. private drawSnow(back: boolean) { - const H = this.H + const { H, rayFade } = this + const t = this.time for (const s of this.snow) { if (back !== s.z > 0.35) continue - const tw = 0.6 + 0.4 * Math.sin(this.time * 2 + s.s * 40) - const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + const tw = 0.6 + 0.4 * Math.sin(t * 2 + s.s * 40) + const x = s.x * H + const y = clamp(Math.floor(s.y * H), 0, H - 1) + const ray = RAYS_A[((x + y * 0.42 + t * 7) | 0) & 1023] * (0.45 + 0.55 * RAYS_B[((x * 0.7 + y * 0.6 - t * 4.3) | 0) & 1023]) * rayFade[y] + const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + ray * 1.5 if (!back && s.s > 0.7) this.disc(s.x * H, s.y * H, 1.1, 0.6 + k, 0.75 + k, 0.8 + k, 0.35) else this.add(s.x * H, s.y * H, k * 0.8, k, k) } @@ -760,7 +800,9 @@ class Ocean extends Canvas { r = lerp(r, fog[0], 0.3) g = lerp(g, fog[1], 0.3) b = lerp(b, fog[2], 0.3) - const stem = Math.max(0.6, w * 0.55) + // On terminal cells the stem and blades stay at least a sub-pixel or so wide, so they sway without shimmering. + const least = this.cells ? 1.1 : 0.6 + const stem = Math.max(least, w * 0.55) const steps = Math.max(1, Math.ceil(Math.hypot(nx - px, ny - py) / Math.max(0.5, stem * 0.6))) for (let j = 0; j < steps; j++) { const u = j / steps @@ -779,7 +821,7 @@ class Ocean extends Canvas { const bend = dirA * (1 + q * 0.6) const lx = nx + Math.sin(bend) * bladeL * q const ly = ny - Math.cos(bend) * bladeL * q - const rad = Math.max(0.5, w * 0.95 * Math.sin(Math.PI * Math.min(0.98, q * 0.9 + 0.08))) + const rad = Math.max(least - 0.1, w * 0.95 * Math.sin(Math.PI * Math.min(0.98, q * 0.9 + 0.08))) this.disc(lx, ly, rad, lr * (0.85 + q * 0.3), lg * (0.85 + q * 0.3), lb * (0.85 + q * 0.3), 0.88) } } @@ -1162,7 +1204,9 @@ class Ocean extends Canvas { const skinFar: RGB = [0.26, 0.3, 0.2] const stroke = Math.sin(tu.phase) - // A flipper: a curved paddle from its root, swept back and rotated by the stroke. + // A flipper: a curved paddle from its root, swept back and rotated by the stroke. Its tip trails the stroke, + // curling one way on the downstroke and the other on the way back up. + const whip = Math.cos(tu.phase) * 0.05 const flipper = (u0: number, v0: number, length: number, angle: number, width: number, color: RGB) => { const [r, g, b] = tint(color) let u = u0 @@ -1176,7 +1220,7 @@ class Ocean extends Canvas { blob(x, y, Math.max(0.6, w * L), r, g, b) u += Math.cos(a) * (length / n) v += Math.sin(a) * (length / n) - a += 0.04 + a += 0.04 + whip * q } } const front = Math.PI - 0.55 - stroke * 0.6 @@ -1410,6 +1454,50 @@ class Ocean extends Canvas { // Turning is a constant-rate sweep toward the side the creature wants to go, decided by intent rather than current // speed so it never stalls mid-turn. The drawn width follows a sine, so the edge-on moment is brief and smooth. +// Night water: each pixel of the water keeps its daytime detail (ridges, rays, the bright surface) relative to its row, +// rescaled to the night color of that row; whatever differs from the bare water is something in front of it, drawn as +// a dark silhouette with a teal rim on the side facing the moon at (mx, my). +function gradeNightWater(hdr: Float32Array, water: Float32Array, mask: Float32Array, rowCol: Float32Array, nightRow: Float32Array, W: number, H: number, mx: number, my: number) { + for (let i = 0; i < W * H; i++) { + const o = i * 3 + mask[i] = clamp((Math.abs(hdr[o] - water[o]) + Math.abs(hdr[o + 1] - water[o + 1]) + Math.abs(hdr[o + 2] - water[o + 2])) * 10, 0, 1) + } + for (let y = 0; y < H; y++) { + const rowSum = rowCol[y * 3] + rowCol[y * 3 + 1] + rowCol[y * 3 + 2] || 1e-6 + const nr = nightRow[y * 3] + const ng = nightRow[y * 3 + 1] + const nb = nightRow[y * 3 + 2] + const reach = 0.75 - 0.4 * (y / H) + for (let x = 0; x < W; x++) { + const i = y * W + x + const o = i * 3 + const detail = clamp((water[o] + water[o + 1] + water[o + 2]) / rowSum, 0.3, 2.5) + const dx = mx - x + const dy = my - y + const dist = Math.hypot(dx, dy * 1.6) || 1 + const moon = Math.exp(-dist / (0.16 * H)) + const wr = nr * detail + moon * 0.02 + const wg = ng * detail + moon * 0.13 + const wb = nb * detail + moon * 0.3 + const m = mask[i] + if (m <= 0) { + hdr[o] = wr + hdr[o + 1] = wg + hdr[o + 2] = wb + continue + } + const l = Math.hypot(dx, dy) || 1 + const sx = clamp(Math.round(x + (dx / l) * 2), 0, W - 1) + const sy = clamp(Math.round(y + (dy / l) * 2), 0, H - 1) + const rim = m * (1 - mask[sy * W + sx]) * reach + const edge = m * (1 - Math.min(mask[Math.max(0, i - 1)], mask[Math.min(W * H - 1, i + 1)], mask[Math.max(0, i - W)], mask[Math.min(W * H - 1, i + W)])) * 0.12 + hdr[o] = lerp(wr, hdr[o] * 0.05, m) + rim * 0.04 + edge * 0.02 + hdr[o + 1] = lerp(wg, hdr[o + 1] * 0.11, m) + rim * 0.42 + edge * 0.24 + hdr[o + 2] = lerp(wb, hdr[o + 2] * 0.22, m) + rim * 0.55 + edge * 0.3 + } + } +} + function steerTurn(c: { turn: number; heading: number; face: number }, want: number, deadzone: number, rate: number, dt: number) { if (Math.abs(want) > deadzone) c.heading = Math.sign(want) const step = rate * dt diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d37610a..ce8e43b 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -1,18 +1,24 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" +import { bob, body, chain, gait, limb, quadruped, stepGait, stride, type Gait } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" import { bird, flyAway, startle, type Flier } from "../src/flock" +import { lightPool } from "../src/light" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 // Animals move at a quarter of scene speed, so even the T. rex plods. const CREATURE_SPEED = 0.25 const HORIZON = 0.6 -// The volcano's crater, in screen heights; its x is a fraction of the width. -const VENT_X = 0.7 +// The volcano's crater, in screen heights: its x from the right edge, so it stays clear of the text in the middle. +const VENT_X = 0.4 const CRATER = 0.205 // Smoke puffs leave the crater on a loop: each one is the same plume at a different age. const SMOKE_LIFE = 14 @@ -29,7 +35,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } ridge: RGB forest: RGB ground: [far: RGB, near: RGB] @@ -38,6 +44,8 @@ interface Look { glow: number smoke: [hot: RGB, cool: RGB, alpha: number] water: RGB + // The tint of what the lake mirrors. + mirror: RGB ripple: RGB tint: RGB } @@ -53,12 +61,12 @@ const LOOKS: Record = { ], orb: { x: 0.16, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.05], bottom: [0.62, 0.66, 0.74], alpha: 0.85 }, + clouds: { top: [1.1, 1.1, 1.05], bottom: [0.62, 0.66, 0.74], alpha: 0.85 }, ridge: [0.42, 0.52, 0.6], forest: [0.14, 0.3, 0.13], ground: [ [0.36, 0.5, 0.2], - [0.24, 0.42, 0.12], + [0.2, 0.38, 0.1], ], rock: [0.32, 0.25, 0.22], lava: [ @@ -68,54 +76,58 @@ const LOOKS: Record = { glow: 0.05, smoke: [[0.3, 0.25, 0.22], [0.66, 0.64, 0.64], 0.5], water: [0.22, 0.44, 0.52], + mirror: [0.78, 0.88, 0.92], ripple: [0.55, 0.7, 0.75], tint: [1, 1, 1], }, sunset: { style: "rim", - light: [1, 0.5, 0.2], + // Ash: a smoky gray-brown sky over a deep red haze, and a sun dimmed to a dull red disc. + light: [1, 0.3, 0.12], sky: [ - [0, [0.03, 0.02, 0.08]], - [0.25, [0.12, 0.04, 0.12]], - [0.45, [0.45, 0.14, 0.14]], - [HORIZON, [1, 0.5, 0.18]], + [0, [0.045, 0.04, 0.04]], + [0.25, [0.11, 0.075, 0.065]], + [0.45, [0.26, 0.06, 0.045]], + [HORIZON, [0.58, 0.1, 0.05]], ], - orb: { x: 0.2, y: 0.48, r: 0.06, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, - stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, - ridge: [0.32, 0.12, 0.14], - forest: [0.07, 0.03, 0.04], + orb: { x: 0.2, y: 0.36, r: 0.07, core: [1.25, 0.32, 0.12], glow: [0.9, 0.22, 0.08], near: 0.3, wide: 0.3 }, + stars: 0, + clouds: { top: [0.08, 0.06, 0.06], bottom: [0.36, 0.13, 0.08], alpha: 0.7 }, + ridge: [0.2, 0.07, 0.05], + forest: [0.05, 0.025, 0.02], ground: [ - [0.24, 0.1, 0.08], - [0.08, 0.04, 0.03], + [0.22, 0.07, 0.04], + [0.07, 0.03, 0.02], ], - rock: [0.12, 0.05, 0.07], + rock: [0.1, 0.05, 0.045], lava: [ [1.1, 0.12, 0.02], [2, 0.55, 0.04], ], - glow: 0.25, - smoke: [[0.9, 0.28, 0.07], [0.16, 0.07, 0.1], 0.55], - water: [0.5, 0.2, 0.12], - ripple: [1, 0.45, 0.15], - tint: [0.22, 0.13, 0.1], + glow: 0.35, + smoke: [[0.8, 0.2, 0.06], [0.1, 0.07, 0.07], 0.65], + water: [0.42, 0.12, 0.06], + mirror: [0.9, 0.78, 0.72], + ripple: [1, 0.3, 0.1], + tint: [0.2, 0.1, 0.08], }, night: { style: "rim", - light: [0.2, 0.45, 1], + light: [0.3, 0.62, 1.5], sky: [ [0, [0.004, 0.008, 0.03]], - [0.3, [0.012, 0.02, 0.06]], - [HORIZON, [0.04, 0.06, 0.15]], + [0.3, [0.014, 0.024, 0.07]], + [0.5, [0.035, 0.055, 0.14]], + [HORIZON, [0.07, 0.1, 0.24]], ], orb: { x: 0.18, y: 0.15, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, ridge: [0.02, 0.03, 0.08], forest: [0.006, 0.012, 0.028], ground: [ - [0.02, 0.035, 0.07], - [0.008, 0.016, 0.03], + [0.035, 0.06, 0.13], + [0.012, 0.022, 0.05], ], rock: [0.02, 0.018, 0.045], lava: [ @@ -125,8 +137,9 @@ const LOOKS: Record = { glow: 0.5, smoke: [[0.8, 0.12, 0.03], [0.1, 0.025, 0.07], 0.6], water: [0.02, 0.04, 0.1], + mirror: [0.8, 0.88, 1], ripple: [0.1, 0.35, 0.9], - tint: [0.025, 0.04, 0.09], + tint: [0.02, 0.032, 0.075], }, } @@ -159,6 +172,7 @@ interface Walker { rest: number moving: boolean size: number + g: Gait } class Prehistoric extends Canvas { @@ -166,22 +180,32 @@ class Prehistoric extends Canvas { private creatureTime = 0 private readonly activity: Activity private readonly look: Look + private readonly night: boolean private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private allStars: Star[] private stars: Star[] = [] private clouds: Cloud[] + // Ash clouds drift off downwind from the top of the plume. + private ash = makeClouds(3, "ash", 0.04, 0.11) + private shadows = makeShadows(3, HORIZON + 0.06, 0.95) private smoke = Array.from({ length: SMOKE_PUFFS }, (_, i) => ({ age: (i / SMOKE_PUFFS) * SMOKE_LIFE, seed: Math.random() * 100 })) // Lava pixels (hdr index, strength, height down the flow) that pulse each frame. private lava = { index: new Int32Array(0), k: new Float32Array(0), along: new Float32Array(0) } private trees: [number, number] = [0, 0] + // The far ferns and cycads, painted once, bend as the gusts pass. + private plants = new SwayLayer() private rex = { x: -9, dir: 1, next: 2, phase: 0 } private dust: { x: number; y: number; vx: number; age: number }[] = [] // p runs from browsing the left tree (0) to browsing the right one (1). - private sauropod = { p: 0, face: -1, target: -1, mode: "browse" as "browse" | "turn" | "walk", timer: rand(4.5, 6), phase: 0, pose: 1 } + private sauropod = { p: 0, face: -1, target: -1, mode: "browse" as "browse" | "turn" | "walk", timer: rand(4.5, 6), g: gait(-1), pose: 1 } private herd: Walker[] = [] private dragonflies: { x: number; y: number; tx: number; ty: number; rest: number; face: number; phase: number }[] = [] private nessie = { t: -1, x: 0, y: 0, dir: 1, wait: 1 } + // The lake's rows: which row each mirrors, how far its ripples wobble the reflection, and how much of it each pixel + // shows (none outside the water or under the plants in front of it). + private pool = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0) } private readonly weather: WeatherLayer private storm = makeStorm() private birds: Flier[] = [] @@ -192,11 +216,15 @@ class Prehistoric extends Canvas { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 + this.night = settings.time === "night" this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) this.allStars = makeStars(this.look.stars, 0.5) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.06, 0.3) + // Long thin streaks catch the low sun better than heaps of cumulus. + this.clouds = settings.time === "sunset" ? makeClouds(4, "stratus", 0.08, 0.3) : makeClouds(3, "cumulus", 0.06, 0.28) if (settings.activity !== "teeming") return - const walker = (x: number, y: number, size: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, moving: false, size }) + const walker = (x: number, y: number, size: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, moving: false, size, g: gait(1, hash(x * 7 + y)) }) this.herd = [walker(0.3, 0.8, 1), walker(0.55, 0.84, 1.1), walker(0.42, 0.82, 0.65)] this.dragonflies = Array.from({ length: 4 }, () => ({ x: Math.random() * 1.5, y: rand(0.75, 0.95), tx: 0, ty: 0, rest: rand(0, 3), face: 1, phase: Math.random() * TAU })) } @@ -207,7 +235,12 @@ class Prehistoric extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) + for (const c of this.ash) { + c.x += c.speed * dt + if (c.x > this.A + c.w) c.x = this.plumeTop() + } this.stepGloom(dt) this.weather.step(dt) this.birds = flyAway(this.birds, dt, this.A) @@ -228,20 +261,34 @@ class Prehistoric extends Canvas { if (this.activity !== "calm") this.stepSauropod(cdt) if (this.activity !== "teeming") return const A = this.A - for (const w of this.herd) this.wander(w, cdt, [0.08 * A, 0.45 * A, 0.78, 0.87], 0.03, [6, 14]) + for (const w of this.herd) { + const [x0, y0] = [w.x, w.y] + this.wander(w, cdt, [0.08 * A, 0.45 * A, 0.78, 0.87], 0.03, [6, 14]) + stepGait(w.g, Math.hypot(w.x - x0, w.y - y0) / (0.13 * (0.6 + (w.y - 0.7) * 2.5) * w.size), cdt, w.face) + } for (const d of this.dragonflies) this.stepDragonfly(d, cdt) this.stepNessie(cdt) } render() { this.hdr.set(this.background) + this.plants.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) const orange = this.look.lava[1] flowLava(this.hdr, this.lava.index, this.lava.k, this.lava.along, this.time, orange[0], orange[1], orange[2]) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) + const orb = this.weather.covered ? undefined : this.look.orb + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.02, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + // Ash takes the plume's cooled color, warmed underneath by the lava, and fades in as it leaves the plume. + const [hot, cool, alpha] = this.look.smoke + const start = this.plumeTop() + for (const c of this.ash) paintClouds(this.hdr, this.W, this.H, [c], [cool[0] * 1.15, cool[1] * 1.15, cool[2] * 1.15], [lerp(cool[0], hot[0], 0.3) * 0.7, lerp(cool[1], hot[1], 0.3) * 0.7, lerp(cool[2], hot[2], 0.3) * 0.7], alpha * 1.3 * smoothstep(start, start + 0.25, c.x), orb) + this.drawShadows() this.drawSmoke() + const p = this.pool + if (p.y1 > p.y0) reflect(this.hdr, this.W, this.H, p.y0, p.y1, p.rows, p.amp, p.gloss, this.look.mirror, this.time) if (this.activity !== "calm") { this.drawPterosaurs() this.drawSauropod() @@ -253,6 +300,8 @@ class Prehistoric extends Canvas { this.drawDust() this.drawRex() const A = this.A + // At night the lava lights the ground, the lake and anything walking near the volcano's foot. + if (this.night) lightPool(this.hdr, this.W, this.H, (A - VENT_X + 0.08) * this.H, 0.63 * this.H, 0.45 * this.H, 0.13 * this.H, [1, 0.36, 0.06], 2.4 + 0.4 * Math.sin(this.time * 0.6), 0.03) for (const [i, x] of [0.01, 0.2, 0.76, 0.99].entries()) this.drawFern(x * A, 1.03, 0.17 + hash(i) * 0.04, i, true) if (this.activity === "teeming") for (const d of this.dragonflies) this.drawDragonfly(d) if (this.delorean.t >= 0) this.drawDelorean() @@ -277,7 +326,10 @@ class Prehistoric extends Canvas { this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } paintSky(this.hdr, W, H, look.sky, look.orb) this.weather.cover(this.hdr, W, H) - const vx = VENT_X * A + const sky = this.hdr.slice() + const day = look === LOOKS.day + for (const [i, c] of this.ash.entries()) c.x = lerp(this.plumeTop(), A + c.w, (i + 0.3) / this.ash.length) + const vx = A - VENT_X const ridgeTop = new Float32Array(W) const volcanoTop = new Float32Array(W) const forestTop = new Float32Array(W) @@ -311,6 +363,8 @@ class Prehistoric extends Canvas { const b = lerp(look.rock[2] * (1 + ash), look.forest[2], green) * k return this.rim([r, gg, b], x, d, 2) }) + // By day the ridge and volcano fade into the sky, then the forest and far plain, while the foreground stays crisp. + if (day) haze(this.hdr, sky, W, H, 0.62, 0.63, 0.1) this.paintLava(vx) this.fillBelow(forestTop, (x, y, d) => { const k = 0.8 + 0.2 * fbm1(x * 0.3, 41) + (hash2(x, y) - 0.5) * 0.1 @@ -319,26 +373,68 @@ class Prehistoric extends Canvas { const [far, near] = look.ground this.fillBelow(groundTop, (x, y, d) => { const depth = clamp((y / H - 0.62) / 0.38, 0, 1) - const blades = 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 + // Blades are a pixel-fine texture, which terminal cells would turn into diagonal hatching. + const blades = this.cells ? 0.95 : 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 return this.rim([lerp(far[0], near[0], depth) * blades, lerp(far[1], near[1], depth) * blades, lerp(far[2], near[2], depth) * blades], x, d, 2) }) + if (day) { + mottle(this.hdr, W, H, 0.62, 1, 0.55, [1.16, 1.04, 0.66], [0.68, 0.84, 0.72]) + haze(this.hdr, sky, W, H, 0.62, 0.8, 0.14, 0, 0.56) + } + this.pool = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0) } if (this.activity === "teeming") this.paintLake() - this.trees = [0.08 * A, Math.max(0.4 * A, 0.08 * A + 0.45)] + const water = this.hdr.slice(this.pool.y0 * W * 3, this.pool.y1 * W * 3) + // The sauropod's two trees keep to the left, the volcano to the right. + this.trees = [0.06, 0.48] + const ground = (x: number, y: number, w: number, h: number, tip?: number) => groundShadow(this.hdr, W, H, this.shade, x * H, y * H, w * H, h * H, tip) + for (const [x, base, top] of [[this.trees[0], 0.665, 0.29], [this.trees[1], 0.662, 0.31], [A - 0.1, 0.64, 0.45]]) ground(x, base, 0.08, base - top, 1.5) + for (const [x, y, s] of [[0.32, 0.78, 0.07], [0.88, 0.74, 0.08], [0.48, 0.9, 0.08]]) ground(x * A, y, s * 1.4, s * 0.8, 1.2) + for (const [x, y, s] of [[0.04, 0.86, 0.09], [0.47, 0.835, 0.07], [0.9, 0.84, 0.1]]) ground(x * A, y, s * 1.4, s * 1.1, 1.5) this.drawTree(this.trees[0], 0.665, 0.29) this.drawTree(this.trees[1], 0.662, 0.31) - this.drawTree(0.93 * A, 0.64, 0.45) - for (const [i, [x, y, s]] of [ + this.drawTree(A - 0.1, 0.64, 0.45) + this.plants.begin(this.hdr) + const ferns = [ [0.32, 0.78, 0.07], [0.88, 0.74, 0.08], [0.48, 0.9, 0.08], - ].entries()) - this.drawFern(x * A, y, s, i + 7, false) - this.drawCycad(0.04 * A, 0.86, 0.09) - this.drawCycad(0.47 * A, 0.835, 0.07) - this.drawCycad(0.9 * A, 0.84, 0.1) + ] + for (const [i, [x, y, s]] of ferns.entries()) this.drawFern(x * A, y, s, i + 7, false) + const cycads = [ + [0.04, 0.86, 0.09], + [0.47, 0.835, 0.07], + [0.9, 0.84, 0.1], + ] + for (const [x, base, size] of cycads) this.drawCycad(x * A, base, size) + // Each plant bends from its base, the cycads' wide crowns over a wider reach. + const reach = [...ferns.map(([x, y, s]) => [x * A, y, s, s * 1.2]), ...cycads.map(([x, y, s]) => [x * A, y, s, s * 2.2])] + this.plants.end(this.hdr, W, H, 0.01 * H, (px, py) => reach.reduce((w, [x, y, s, r]) => (Math.abs(px / H - x) < r ? Math.max(w, clamp((y - py / H) / (s * 1.2), 0, 1)) : w), 0)) + // Plants painted over the water stay in front of its reflection. + const p = this.pool + const o = p.y0 * W * 3 + for (let i = 0; i < p.gloss.length; i++) if (this.hdr[o + i * 3] !== water[i * 3] || this.hdr[o + i * 3 + 1] !== water[i * 3 + 1] || this.hdr[o + i * 3 + 2] !== water[i * 3 + 2]) p.gloss[i] = 0 this.background = this.hdr.slice() } + // Shadows under everything that walks or drives, drawn before any of it so none falls across another animal. + private drawShadows() { + const H = this.H + const ground = (x: number, y: number, w: number, h: number, tip?: number) => groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip) + if (this.activity !== "calm") { + const s = this.sauropod + const [from, to] = this.sauropodSpots() + ground(lerp(from, to, s.p) - s.face * 0.06, 0.668, 0.28, 0.13, 0.8) + } + for (const w of this.herd) { + const S = 0.2 * (0.6 + (w.y - 0.7) * 2.5) * w.size + ground(w.x + w.face * 0.04 * S, w.y, 1.0 * S, 0.55 * S, 0.8) + } + const r = this.rex + if (r.x > -5) ground(r.x - r.dir * 0.03, 0.935, 0.24, 0.24, 0.8) + const d = this.delorean + if (d.t >= 0 && d.t <= DELOREAN) ground(d.x0 + 0.05 * Math.min(d.t, DELOREAN), 0.958, 0.18, 0.065, 0.9) + } + private paint(c: RGB): RGB { const t = this.look.tint return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] @@ -420,15 +516,18 @@ class Prehistoric extends Canvas { this.lava = { index: Int32Array.from(index), k: Float32Array.from(index, (i) => core[i] * (0.4 + heat[i])), along: Float32Array.from(index, (i) => along[i]) } } - // A still lake in front of the volcano, mirroring it and the sky, with a muddy shore. + // A lake in front of the volcano with a muddy shore. Its water mirrors the forest, the volcano and the sky above it, + // squeezed into the lake's depth, and render() puts the reflection in each frame so the lava and smoke move in it. private paintLake() { const { W, H, A, look } = this const [lx, ly, lrx, lry] = this.lake() const [cx, cy, rx, ry] = [lx * H, ly * H, lrx * H, lry * H] - const mirror = HORIZON * H const red = look.lava[0] const mud = this.paint([0.3, 0.24, 0.15]) - for (let y = Math.max(0, Math.floor(cy - ry - 2)); y < Math.min(H, cy + ry + 3); y++) + const y0 = Math.max(0, Math.floor(cy - ry - 2)) + const y1 = Math.min(H, Math.ceil(cy + ry + 3)) + const gloss = new Float32Array((y1 - y0) * W) + for (let y = y0; y < y1; y++) for (let x = Math.max(0, Math.floor(cx - rx - 3)); x < Math.min(W, cx + rx + 3); x++) { const q = Math.hypot((x + 0.5 - cx) / rx, (y + 0.5 - cy) / ry) const shore = clamp((1.12 - q) * 6, 0, 1) @@ -437,22 +536,30 @@ class Prehistoric extends Canvas { this.blend(o, mud[0], mud[1], mud[2], shore * 0.8) const wet = clamp((1 - q) * Math.min(rx, ry) * 0.5 + 0.5, 0, 1) if (wet <= 0) continue - const my = clamp(Math.round(2 * mirror - y), 0, H - 1) - const mx = clamp(Math.round(x + Math.sin(y * 1.3) * 0.8), 0, W - 1) - const m = (my * W + mx) * 3 - const k = 0.55 + 0.1 * Math.sin(y * 2.1 + x * 0.05) - this.blend(o, lerp(look.water[0], this.hdr[m], k), lerp(look.water[1], this.hdr[m + 1], k), lerp(look.water[2], this.hdr[m + 2], k), wet) + this.blend(o, look.water[0], look.water[1], look.water[2], wet) + gloss[(y - y0) * W + x] = wet * (0.72 + 0.06 * Math.sin(y * 2.1 + x * 0.05)) // The volcano's glow shimmers on the water below it. - const sheen = look.glow * 0.25 * Math.exp(-Math.abs(x / H - VENT_X * A) / 0.1) * wet * (0.7 + 0.3 * Math.sin(y * 2.7)) + const sheen = look.glow * 0.25 * Math.exp(-Math.abs(x / H - (A - VENT_X)) / 0.1) * wet * (0.7 + 0.3 * Math.sin(y * 2.7)) this.hdr[o] += red[0] * sheen this.hdr[o + 1] += red[1] * sheen this.hdr[o + 2] += red[2] * sheen } + // The far shore mirrors the forest's edge, and the near one the volcano's crater, squeezed more toward it. + const far = cy - ry + const n = y1 - y0 + const below = (r: number) => Math.max(0, y0 + r - far) + this.pool = { + y0, + y1, + rows: Float32Array.from({ length: n }, (_, r) => (HORIZON - 0.004) * H - below(r) * (1.5 + (3.2 * below(r)) / (2 * ry))), + amp: new Float32Array(n).fill(0.8 * (H / 180)), + gloss, + } } // The lake's center and radii, in screen heights. private lake() { - return [0.66 * this.A, 0.735, Math.min(0.3, 0.22 * this.A), 0.042] as const + return [this.A - VENT_X - 0.1, 0.735, Math.min(0.3, 0.22 * this.A), 0.042] as const } // A monkey-puzzle conifer: a bare trunk with tiers of drooping branches toward its rounded top. @@ -484,53 +591,73 @@ class Prehistoric extends Canvas { const len = size * (1.4 + hash(i * 3 + x) * 0.5) * H const parts: Part[] = [] let [px, py] = [x * H, top * H] + const spine: [number, number][] = [[px, py]] + const widths = [1] let angle = a for (let s = 0; s < 6; s++) { angle += Math.cos(a) > 0 ? 0.05 : -0.05 const nx = px + Math.cos(angle) * (len / 6) const ny = py + Math.sin(angle) * (len / 6) + s * 0.0015 * H - parts.push(cap(px, py, nx, ny, 0.004 * H, 0.003 * H, frond)) + parts.push(cap(px, py, nx, ny, this.thick(0.004), this.thick(0.003), frond)) const leaf = (1 - s / 8) * size * 0.4 * H - for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 0.8) * leaf, ny + Math.sin(angle + side * 0.8) * leaf, 0.004 * H, 0.0012 * H, frond)) + spine.push([nx, ny]) + widths.push(leaf * 0.45) + if (!this.cells) for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 0.8) * leaf, ny + Math.sin(angle + side * 0.8) * leaf, 0.004 * H, 0.0012 * H, frond)) ;[px, py] = [nx, ny] } - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) + if (this.cells) parts.push(...blade(spine, widths, frond)) + // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.18 : 0.3) : 0.6) } this.shape([ell(x * H, (top - 0.008) * H, size * 0.12 * H, size * 0.16 * H, 0, this.paint([0.7, 0.5, 0.2]), 3)], this.lighting, 0.6) } // A clump of fern fronds arching out and drooping at the tips; the front clumps sway in the breeze. - private drawFern(x: number, y: number, size: number, seed: number, sway: boolean) { + private drawFern(x: number, y: number, size: number, seed: number, swaying: boolean) { const H = this.H const fern = this.paint(FERN) for (let i = 0; i < 7; i++) { const base = -Math.PI / 2 + ((i / 6) * 2 - 1) * 1.15 + (hash(seed * 5 + i) - 0.5) * 0.2 - const bend = sway ? Math.sin(this.time * 0.7 + i * 1.3 + seed * 2) * 0.05 : 0 + // Turned whichever way carries the tip downwind. + const bend = swaying ? sway(x, this.time, i * 1.3 + seed * 2) * 0.05 * (Math.sin(base) > 0 ? -1 : 1) : 0 const len = size * (0.75 + hash(seed * 7 + i) * 0.35) * H const parts: Part[] = [] let [px, py] = [x * H, y * H] + const spine: [number, number][] = [[px, py]] + const widths = [1] let angle = base + bend for (let s = 0; s < 7; s++) { const q = s / 7 angle += (Math.cos(base) >= 0 ? 1 : -1) * 0.1 * (0.4 + Math.abs(Math.cos(base))) + bend * 0.3 const nx = px + Math.cos(angle) * (len / 7) const ny = py + Math.sin(angle) * (len / 7) - parts.push(cap(px, py, nx, ny, lerp(0.004, 0.0012, q) * H, lerp(0.004, 0.0012, q + 1 / 7) * H, fern)) + parts.push(cap(px, py, nx, ny, this.thick(lerp(0.004, 0.0012, q)), this.thick(lerp(0.004, 0.0012, q + 1 / 7), 0.7), fern)) const leaf = Math.sin(Math.PI * (0.25 + q * 0.75)) * size * 0.22 * H - for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 1.15) * leaf, ny + Math.sin(angle + side * 1.15) * leaf, 0.0045 * H, 0.001 * H, fern)) + spine.push([nx, ny]) + widths.push(leaf * 0.55) + if (!this.cells) for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 1.15) * leaf, ny + Math.sin(angle + side * 1.15) * leaf, 0.0045 * H, 0.001 * H, fern)) ;[px, py] = [nx, ny] } - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) + if (this.cells) parts.push(...blade(spine, widths, fern)) + // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.18 : 0.3) : 0.6) } } + // Where the plume levels off and its smoke starts to drift away as ash, in screen heights. + private plumeTop() { + return this.A - VENT_X + 0.08 + } + // Smoke billows up from the crater and leans downwind, lit from below by the lava when it is fresh. private drawSmoke() { const { A, H, look } = this const [hot, cool, alpha] = look.smoke + // A passing gust bends the plume further over. + const blown = 0.0025 * gust(A - VENT_X, this.time) for (const s of this.smoke) { const t = s.age - const x = (VENT_X * A + (hash(s.seed) - 0.5) * 0.02 + 0.002 * t + 0.0009 * t * t + 0.006 * Math.sin(t * 0.7 + s.seed)) * H + const x = (A - VENT_X + (hash(s.seed) - 0.5) * 0.02 + 0.002 * t + 0.0009 * t * t + 0.006 * Math.sin(t * 0.7 + s.seed) + blown * t) * H const y = (CRATER - 0.004 - 0.018 * t + 0.0004 * t * t) * H const r = (0.012 + 0.006 * t) * (0.8 + hash(s.seed + 1) * 0.4) * H const a = alpha * smoothstep(0, 1.5, t) * (1 - smoothstep(8, SMOKE_LIFE, t)) @@ -577,6 +704,7 @@ class Prehistoric extends Canvas { // The sauropod browses one tree's crown, turns, plods over to the other tree and browses there. private stepSauropod(dt: number) { const s = this.sauropod + stepGait(s.g, s.mode === "walk" ? (0.05 * dt) / 0.06 : 0, dt, s.face) s.pose = clamp(s.pose + (s.mode === "browse" ? 0.5 : -0.5) * dt, 0, 1) if (s.mode === "browse") { s.timer -= dt @@ -595,7 +723,6 @@ class Prehistoric extends Canvas { const [from, to] = this.sauropodSpots() const speed = 0.05 / Math.max(0.05, Math.abs(to - from)) s.p = clamp(s.p + s.face * speed * dt, 0, 1) - s.phase += dt * 0.05 / 0.06 if ((s.face > 0 && s.p < 1) || (s.face < 0 && s.p > 0)) return s.mode = "browse" s.timer = rand(3, 6) @@ -609,40 +736,35 @@ class Prehistoric extends Canvas { private drawSauropod() { const H = this.H const s = this.sauropod + const g = s.g const [from, to] = this.sauropodSpots() - const bx = lerp(from, to, s.p) - const F = 0.668 - const f = s.face - const side = f < 0 ? -1 : 1 + const side = s.face < 0 ? -1 : 1 + const rise = bob(g.phase) * g.go * 0.004 + const P = body(lerp(from, to, s.p) * H, 0.668 * H, H, s.face) const skin = this.paint(SAUROPOD) - const dark = this.paint([SAUROPOD[0] * 0.75, SAUROPOD[1] * 0.75, SAUROPOD[2] * 0.75]) - const walking = s.mode === "walk" ? 1 : 0 - const P = (u: number, v: number): [number, number] => [(bx + u * f) * H, (F + v) * H] - const leg = (u: number, offset: number, color: RGB) => { - const p = (s.phase + offset) % 1 - const q = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5 - const du = walking * (p < 0.5 ? lerp(0.025, -0.025, q) : lerp(-0.025, 0.025, smoothstep(0, 1, q))) - const lift = walking * (p < 0.5 ? 0 : Math.sin(Math.PI * q) * 0.01) - return cap(...P(u, -0.075), ...P(u + du, -lift), 0.014 * H, 0.012 * H, color) - } - this.shape([leg(0.055, 0.5, dark), leg(-0.075, 0, dark)], this.lighting, 0.5) + const legs = { fore: 0.07, hind: 0.06, top: 0.085 - rise, w: 0.022, a: 0.038, b: 0.037, reach: 0.018, lift: 0.012, r: [0.02, 0.016, 0.015] as [number, number, number] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([SAUROPOD[0] * 0.75, SAUROPOD[1] * 0.75, SAUROPOD[2] * 0.75]) }), this.lighting, 0.5) const browsing: [number, number] = [this.trees[side > 0 ? 1 : 0] - side * 0.035 + Math.sin(this.time * 0.4) * 0.006, (side > 0 ? 0.35 : 0.33) + Math.sin(this.time * 0.9) * 0.004] - const shoulder = P(0.07, -0.108) - const walkHead: [number, number] = [shoulder[0] / H + f * 0.11, shoulder[1] / H - 0.14] + const shoulder = P(0.07, -0.11 - rise) + // Walking, the head rides ahead of the shoulders, nodding a beat behind each step. + const walkHead = P(0.18, -0.25 - rise + Math.cos(TAU * 2 * g.phase - 1.5) * 0.004 * g.go) const pose = smoothstep(0, 1, s.pose) - const head: [number, number] = [lerp(walkHead[0], browsing[0], pose) * H, lerp(walkHead[1], browsing[1], pose) * H] + const head: [number, number] = [lerp(walkHead[0], browsing[0] * H, pose), lerp(walkHead[1], browsing[1] * H, pose)] const ctrl: [number, number] = [shoulder[0] + (head[0] - shoulder[0]) * 0.1, head[1] + (shoulder[1] - head[1]) * 0.6] - const parts: Part[] = [leg(0.075, 0, skin), leg(-0.055, 0.5, skin)] - parts.push(ell(...P(0, -0.09), Math.max(0.035, 0.115 * Math.abs(f)) * H, 0.05 * H, -0.12 * side * Math.abs(f), skin)) - const tail = (t: number): [number, number] => { - const sway = Math.sin(this.time * 0.5 + t * 2) * 0.01 * t - return P(lerp(-0.08, -0.29, t), lerp(-0.095, -0.03 + sway, t) + 0.03 * Math.sin(Math.PI * t)) - } - for (let i = 0; i < 6; i++) parts.push(cap(...tail(i / 6), ...tail((i + 1) / 6), lerp(0.03, 0.003, i / 6) * H, lerp(0.03, 0.003, (i + 1) / 6) * H, skin)) const neck = (t: number): [number, number] => [(1 - t) ** 2 * shoulder[0] + 2 * (1 - t) * t * ctrl[0] + t * t * head[0], (1 - t) ** 2 * shoulder[1] + 2 * (1 - t) * t * ctrl[1] + t * t * head[1]] - for (let i = 0; i < 8; i++) parts.push(cap(...neck(i / 8), ...neck((i + 1) / 8), lerp(0.028, 0.009, i / 8) * H, lerp(0.028, 0.009, (i + 1) / 8) * H, skin)) - parts.push(ell(head[0] + side * 0.008 * H, head[1], 0.017 * H, 0.0105 * H, side * 0.2, skin)) - this.shape(parts, this.lighting, 0.6) + // The tail trails a turn and sways a beat behind the steps. + const tail = (t: number) => P(lerp(-0.08, -0.29, t), lerp(-0.097 - rise, -0.03, t) + 0.03 * Math.sin(Math.PI * t) + (Math.sin(this.time * 0.5 + t * 2) * 0.01 + Math.sin(TAU * 2 * g.phase - t * 2) * 0.006 * g.go) * t, -g.lag * 0.06 * t * t) + this.shape( + [ + ...quadruped(P, g, false, { ...legs, color: skin }), + ell(...P(0, -0.092 - rise), P.len(0.115, 0.04), 0.05 * H, -0.12 * P.along, skin), + ...chain(tail, 6, 0.03 * H, 0.003 * H, skin), + ...chain(neck, 8, 0.028 * H, 0.009 * H, skin), + ell(head[0] + side * 0.008 * H, head[1], 0.017 * H, 0.0105 * H, side * 0.2, skin), + ], + this.lighting, + 0.6, + ) } // Now and then a T. rex stomps across the foreground, tail swaying, kicking up dust at each footfall. @@ -679,49 +801,51 @@ class Prehistoric extends Canvas { const r = this.rex if (r.x < -5) return const H = this.H - const S = 0.3 * H - const x = r.x * H - const y = 0.935 * H - const bob = Math.abs(Math.cos(r.phase * TAU)) * 0.015 - const P = (u: number, v: number): [number, number] => [x + u * r.dir * S, y + v * S] + const P = body(r.x * H, 0.935 * H, 0.3 * H, r.dir) + const S = P.S + const rise = bob(r.phase, 0.55) * 0.02 const skin = this.paint(REX) const dark = this.paint([REX[0] * 0.7, REX[1] * 0.7, REX[2] * 0.7]) + // A bird-like leg: a heavy thigh, the knee forward, a long shin back to the ankle, then the foot to splayed toes. const leg = (offset: number, color: RGB): Part[] => { - const p = (r.phase + offset) % 1 - const q = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5 - const fu = p < 0.5 ? lerp(0.17, -0.17, q) : lerp(-0.17, 0.17, smoothstep(0, 1, q)) - const lift = p < 0.5 ? 0 : Math.sin(Math.PI * q) * 0.07 - const hip: [number, number] = [0, -0.56 + bob] - const ankle: [number, number] = [fu - 0.06, -0.13 - lift] - const knee: [number, number] = [(hip[0] + ankle[0]) / 2 + 0.1, (hip[1] + ankle[1]) / 2] + const [reach, lift] = stride(r.phase, offset, 0.55) + const fu = reach * 0.19 + const fv = -lift * 0.08 + const ankle: [number, number] = [fu - 0.08, fv - 0.13] return [ - cap(...P(...hip), ...P(...knee), 0.11 * S, 0.06 * S, color), - cap(...P(...knee), ...P(...ankle), 0.055 * S, 0.035 * S, color), - cap(...P(...ankle), ...P(fu, -lift), 0.035 * S, 0.03 * S, color), - cap(...P(fu, -lift), ...P(fu + 0.1, -lift), 0.03 * S, 0.015 * S, color), + ...limb(P, 0, -0.57 - rise, ...ankle, 0.25, 0.27, -1, 0.14 * S, 0.065 * S, 0.04 * S, color), + cap(...P(...ankle), ...P(fu + 0.01, fv - 0.025), 0.04 * S, 0.03 * S, color), + cap(...P(fu - 0.01, fv - 0.022), ...P(fu + 0.12, fv - 0.012 - lift * 0.03), 0.032 * S, 0.012 * S, color), ] } this.shape(leg(0.5, dark), this.lighting, 0.5) - const parts = leg(0, skin) - const tail = (t: number): [number, number] => P(lerp(-0.18, -0.9, t), lerp(-0.62 + bob, -0.5, t) + Math.sin(r.phase * TAU + t * 2) * 0.03 * t) - for (let i = 0; i < 6; i++) parts.push(cap(...tail(i / 6), ...tail((i + 1) / 6), lerp(0.13, 0.012, i / 6) * S, lerp(0.13, 0.012, (i + 1) / 6) * S, skin)) - const nod = Math.sin(r.phase * TAU * 2) * 0.012 - parts.push( - ell(...P(0.04, -0.62 + bob), 0.3 * S, 0.15 * S, -0.12 * r.dir, skin), - cap(...P(0.24, -0.68 + bob), ...P(0.38, -0.78 + bob + nod), 0.1 * S, 0.075 * S, skin), - ell(...P(0.5, -0.8 + bob + nod), 0.15 * S, 0.075 * S, 0.06 * r.dir, skin), - cap(...P(0.42, -0.74 + bob + nod), ...P(0.62, -0.75 + bob + nod), 0.045 * S, 0.03 * S, skin), - cap(...P(0.27, -0.58 + bob), ...P(0.33, -0.5 + bob), 0.026 * S, 0.02 * S, skin), - cap(...P(0.33, -0.5 + bob), ...P(0.37, -0.53 + bob), 0.018 * S, 0.008 * S, skin), + const nod = Math.cos(TAU * 2 * r.phase - 1.2) * 0.015 + const jaw = Math.max(0, Math.sin(this.creatureTime * 0.9 + 1)) ** 8 * 0.05 + // The tail counterbalances the head and swings a beat behind each step. + const tail = (t: number) => P(lerp(-0.16, -0.95, t), lerp(-0.66 - rise, -0.57, t) - 0.04 * Math.sin(Math.PI * t) + Math.sin(TAU * 2 * r.phase - 1 - t * 2.5) * 0.03 * t) + const [hu, hv] = [0.47, -0.84 - rise + nod] + this.shape( + [ + ...leg(0, skin), + ...chain(tail, 7, 0.14 * S, 0.012 * S, skin), + ell(...P(0.04, -0.64 - rise), 0.29 * S, 0.15 * S, 0.12 * r.dir, skin), + ell(...P(0.2, -0.62 - rise), 0.13 * S, 0.14 * S, 0, skin), + cap(...P(0.24, -0.7 - rise), ...P(hu - 0.07, hv + 0.02), 0.11 * S, 0.075 * S, skin), + ell(...P(hu, hv), 0.12 * S, 0.075 * S, 0.1 * r.dir, skin), + cap(...P(hu + 0.04, hv - 0.01), ...P(hu + 0.19, hv + 0.025), 0.065 * S, 0.042 * S, skin), + cap(...P(hu - 0.02, hv + 0.05), ...P(hu + 0.16, hv + 0.06 + jaw), 0.05 * S, 0.026 * S, skin), + ...limb(P, 0.27, -0.6 - rise, 0.35, -0.53 - rise + nod * 0.5, 0.07, 0.07, 1, 0.026 * S, 0.02 * S, 0.014 * S, skin), + ], + this.lighting, + 0.7, ) - this.shape(parts, this.lighting, 0.7) this.shape( - [0, 1, 2, 3].map((k) => cap(...P(-0.16 + k * 0.1, -0.76 + bob), ...P(-0.13 + k * 0.1, -0.63 + bob), 0.02 * S, 0.01 * S, dark)), + [0, 1, 2, 3].map((k) => cap(...P(-0.16 + k * 0.1, -0.77 - rise), ...P(-0.13 + k * 0.1, -0.64 - rise), 0.02 * S, 0.01 * S, dark)), this.lighting, 0.2, ) const eye = this.paint([0.95, 0.7, 0.1]) - this.disc(...P(0.52, -0.84 + bob + nod), 0.016 * S, eye[0], eye[1], eye[2], 1) + this.disc(...P(hu + 0.05, hv - 0.035), 0.016 * S, eye[0], eye[1], eye[2], 1) } private stepDelorean(dt: number) { @@ -797,8 +921,7 @@ class Prehistoric extends Canvas { w.wanderT -= dt if (w.wanderT <= 0) { w.wanderT = rand(6, 14) - w.wx = rand(zone[0], zone[1]) - w.wy = rand(zone[2], zone[3]) + ;[w.wx, w.wy] = this.openSpot(...zone) } const dx = w.wx - w.x const dy = w.wy - w.y @@ -814,33 +937,38 @@ class Prehistoric extends Canvas { w.y += (dy / d) * speed * dt } - // A triceratops with its bony frill and three horns, lowering its head to crop the ferns. + // A triceratops with its bony frill and three horns, plodding on stout legs and lowering its head to crop the ferns. private drawTriceratops(w: Walker) { const H = this.H - const S = 0.2 * H * (0.6 + (w.y - 0.7) * 2.5) * w.size - const x = w.x * H - const y = w.y * H - const P = (u: number, v: number): [number, number] => [x + u * w.face * S, y + v * S] - const swing = w.moving ? Math.sin(w.phase) * 0.06 : 0 + const g = w.g + const P = body(w.x * H, w.y * H, 0.27 * H * (0.6 + (w.y - 0.7) * 2.5) * w.size, g.turn) + const S = P.S const skin = this.paint(TRIKE) - const g = w.graze - const [hu, hv] = [lerp(0.5, 0.58, g), lerp(-0.5, -0.24, g)] - const parts: Part[] = [] - for (const [u, s] of [[0.24, 1], [0.3, -1], [-0.22, -1], [-0.28, 1]] as const) parts.push(cap(...P(u, -0.3), ...P(u + swing * s, 0), 0.07 * S, 0.06 * S, skin)) - parts.push( - cap(...P(-0.36, -0.44), ...P(-0.68, -0.3), 0.09 * S, 0.015 * S, skin), - ell(...P(0, -0.44), 0.42 * S, 0.24 * S, 0, skin), - ell(...P(hu - 0.08, hv - 0.1), 0.12 * S, 0.19 * S, lerp(-0.45, -0.1, g) * w.face, this.paint([0.62, 0.34, 0.18])), - ell(...P(hu + 0.06, hv + 0.02), 0.14 * S, 0.085 * S, lerp(0.25, 0.8, g) * w.face, skin), - cap(...P(hu + 0.17, hv + 0.06), ...P(hu + 0.23, hv + 0.11), 0.04 * S, 0.012 * S, skin), + const rise = bob(g.phase) * g.go * 0.012 + const legs = { fore: 0.24, hind: 0.24, top: 0.32 - rise, w: 0.11, a: 0.17, b: 0.16, reach: 0.15, lift: 0.07, r: [0.1, 0.065, 0.055] as [number, number, number], paw: [0.03, this.paint([0.4, 0.33, 0.21])] as [number, RGB] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([TRIKE[0] * 0.75, TRIKE[1] * 0.75, TRIKE[2] * 0.75]) }), this.lighting, 0.7) + const gz = smoothstep(0, 1, w.graze) + const nod = Math.cos(TAU * 2 * g.phase - 1) * 0.02 * g.go + const [hu, hv] = [lerp(0.48, 0.56, gz), lerp(-0.44, -0.22, gz) + nod - rise] + const tail = (t: number) => P(lerp(-0.26, -0.74, t), lerp(-0.48 - rise, -0.3, t) + Math.sin(TAU * 2 * g.phase - t * 2) * 0.025 * t * g.go, -g.lag * 0.08 * t * t) + this.shape( + [ + ...quadruped(P, g, false, { ...legs, color: skin }), + ...chain(tail, 5, 0.15 * S, 0.015 * S, skin), + ell(...P(-0.02, -0.46 - rise), P.len(0.4, 0.2), 0.23 * S, 0.08 * P.along, skin), + ell(...P(hu - 0.08, hv - 0.1), P.len(0.11, 0.18), 0.19 * S, lerp(-0.45, -0.1, gz) * P.along, this.paint([0.62, 0.34, 0.18])), + cap(...P(hu - 0.02, hv - 0.02), ...P(hu + 0.19, hv + 0.07), 0.1 * S, 0.045 * S, skin), + cap(...P(hu + 0.17, hv + 0.06), ...P(hu + 0.23, hv + 0.12), 0.04 * S, 0.012 * S, this.paint([0.3, 0.25, 0.17])), + ], + this.lighting, + 0.7, ) - this.shape(parts, this.lighting, 0.7) const bone = this.paint(BONE) this.shape( [ - cap(...P(hu + 0.02, hv - 0.04), ...P(hu + 0.28, hv - 0.2 + g * 0.14), 0.025 * S, 0.004 * S, bone), - cap(...P(hu + 0.06, hv - 0.03), ...P(hu + 0.31, hv - 0.15 + g * 0.14), 0.022 * S, 0.004 * S, bone), - cap(...P(hu + 0.16, hv + 0.01), ...P(hu + 0.2, hv - 0.06), 0.02 * S, 0.004 * S, bone), + cap(...P(hu + 0.02, hv - 0.06), ...P(hu + 0.28, hv - 0.22 + gz * 0.14), 0.026 * S, 0.004 * S, bone), + cap(...P(hu + 0.05, hv - 0.05, 0.05), ...P(hu + 0.31, hv - 0.17 + gz * 0.14, 0.08), 0.022 * S, 0.004 * S, bone), + cap(...P(hu + 0.15, hv + 0.01), ...P(hu + 0.19, hv - 0.07), 0.022 * S, 0.004 * S, bone), ], this.lighting, 0.5, @@ -890,8 +1018,7 @@ class Prehistoric extends Canvas { if (n.wait > 0) return const [cx, cy, rx, ry] = this.lake() n.t = 0 - n.x = cx + rand(-0.6, 0.6) * rx - n.y = cy + rand(-0.2, 0.4) * ry + ;[n.x, n.y] = this.openSpot(cx - 0.6 * rx, cx + 0.6 * rx, cy - 0.2 * ry, cy + 0.4 * ry) n.dir = Math.random() < 0.5 ? 1 : -1 return } @@ -957,9 +1084,9 @@ export const prehistoric: Wallpaper = { [14, 28, 10], ], sunset: [ - [30, 14, 26], - [36, 16, 16], - [10, 6, 6], + [18, 14, 14], + [34, 12, 8], + [12, 6, 5], ], night: [ [4, 6, 16], diff --git a/wallpapers/space.ts b/wallpapers/space.ts index 6c39a47..a0c0d20 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -1,5 +1,6 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" +import { lightPool } from "../src/light" import { TAU, clamp, fbm1, fbm2, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" @@ -11,8 +12,9 @@ const CREATURE_SPEED = 0.25 const TW = 512 const TH = 256 -// Where the planet sits and how big it is: center x as a fraction of the width, y and radius in screen heights. -const PLANET = { x: 0.68, y: 1.1, r: 0.58 } +// Where the planet sits and how big it is, in screen heights: its center x from the right edge, so it rises in the +// lower right clear of the text in the middle, and its center y and radius. +const PLANET = { x: 0.52, y: 1.1, r: 0.58 } const RING = { tilt: 0.42, flat: 0.24, inner: 1.25, outer: 2.1 } // How long a shooting star lasts, in scene seconds. const METEOR = 1.2 @@ -49,8 +51,8 @@ const LOOKS: Record = { ring: 1, lighting: "front", tint: [1, 1, 1], - ringColor: [0.75, 0.68, 0.55], - rockColor: [0.75, 0.68, 0.6], + ringColor: [0.78, 0.64, 0.46], + rockColor: [0.62, 0.54, 0.46], starTints: false, }, sunset: { @@ -73,10 +75,10 @@ const LOOKS: Record = { sun: normalize(0.6, 0.6, -0.5), starK: 1, nebula: [ - [0.06, 0.02, 0.1], - [0.02, 0.04, 0.09], + [0.08, 0.025, 0.14], + [0.025, 0.055, 0.12], ], - milkyWay: 1, + milkyWay: 1.3, atmosphere: [0.2, 0.35, 0.8], ring: 0.12, lighting: "night", @@ -128,7 +130,7 @@ class Space extends Canvas { private stars = Array.from({ length: 320 }, (_, i) => ({ x: Math.random() * 3, y: Math.random(), layer: i % 3, b: 0.3 + Math.random() * 0.7, phase: Math.random() * TAU, tint: STAR_TINTS[i % STAR_TINTS.length] })) private comet = { x: -9, y: 0, vx: 0, vy: 0, next: 14 } private ufo = { x: -9, y: 0.2, dir: 1, next: 30 } - private station: Traveler = { x: 0.2, y: 0.3, dir: 1, wait: 0 } + private station: Traveler = { x: 0.2, y: 0.24, dir: 1, wait: 0 } private satellite: Traveler = { x: 1.4, y: 0.15, dir: -1, wait: 0 } private rocks: Rock[] = [] private meteors: { x: number; y: number; vx: number; vy: number; age: number }[] = [] @@ -139,6 +141,7 @@ class Space extends Canvas { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.25 if (settings.activity === "teeming") this.rocks = Array.from({ length: 6 }, (_, i) => ({ x: Math.random() * 2, @@ -237,7 +240,7 @@ class Space extends Canvas { // The nebula, the Milky Way and the far half of the rings never move, so they are painted once per size. protected override layout() { const { W, H, A, look } = this - const cx = PLANET.x * W + const cx = W - PLANET.x * H const cy = PLANET.y * H const R = PLANET.r * H this.starPos = look.lighting === "front" ? [0.14 * W, 0.14 * H] : [cx + R * look.sun[0] / Math.hypot(look.sun[0], look.sun[1]), cy + R * look.sun[1] / Math.hypot(look.sun[0], look.sun[1])] @@ -246,7 +249,7 @@ class Space extends Canvas { ? { style: "front", dir: look.sun } : look.lighting === "limb" ? { style: "rim", color: [1, 0.6, 0.3], x: this.starPos[0], y: this.starPos[1] } - : { style: "rim", color: [0.2, 0.4, 1], x: cx, y: cy } + : { style: "rim", color: [0.3, 0.6, 1.5], x: cx, y: cy } const [n1, n2] = look.nebula for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { @@ -287,7 +290,11 @@ class Space extends Canvas { const a = 0.75 * density const k = look.ring * (0.6 + 0.4 * density) const o = y * W + x - const [rr, rg, rb] = look.ringColor + // By day the bands alternate between warm tan and cool gray dust. + const warm = look.lighting === "front" ? 0.16 * Math.sin(rho * 9 + 1) : 0 + const rr = look.ringColor[0] * (1 + warm) + const rg = look.ringColor[1] + const rb = look.ringColor[2] * (1 - warm * 1.5) if (qy < 0) { this.blend(o * 3, rr * k, rg * k, rb * k, a) if (a > 0.3) this.ringMask[o] = 1 @@ -324,7 +331,7 @@ class Space extends Canvas { // the edge, and on the dark side city lights and aurora. private drawPlanet() { const { W, H, hdr, look } = this - const cx = PLANET.x * W + const cx = W - PLANET.x * H const cy = PLANET.y * H const R = PLANET.r * H const rot = this.time * 0.02 @@ -344,7 +351,8 @@ class Space extends Canvas { const facing = (dx * lsx + dy * lsy) / (r || 1) const glowLit = look.lighting === "night" ? 0.25 : look.lighting === "limb" ? 0.2 + 1.6 * Math.max(0, facing) ** 4 : 0.3 + 0.7 * Math.max(0, facing) if (r > 1) { - const halo = Math.exp(-(r - 1) / 0.025) * glowLit * 0.6 + // At night a wide band of airglow rises off the limb, so whatever passes in front of it reads dark. + const halo = Math.exp(-(r - 1) / 0.025) * glowLit * 0.6 + (look.lighting === "night" ? Math.exp(-(r - 1) / 0.09) * 0.07 : 0) hdr[o] += look.atmosphere[0] * halo hdr[o + 1] += look.atmosphere[1] * halo hdr[o + 2] += look.atmosphere[2] * halo @@ -422,9 +430,11 @@ class Space extends Canvas { private drawMoon() { const { W, H, look } = this const a = this.creatureTime * 0.05 - const cx = (0.24 * this.A + 0.06 * Math.cos(a)) * H - const cy = (0.36 + 0.025 * Math.sin(a)) * H + const cx = (0.3 + 0.06 * Math.cos(a)) * H + const cy = (0.24 + 0.025 * Math.sin(a)) * H const R = 0.035 * H + // Cobalt rather than grey at night. + const [mr, mg, mb] = look.lighting === "night" ? look.rockColor : [0.9, 0.9, 0.95] for (let y = Math.max(0, Math.floor(cy - R - 1)); y <= Math.min(H - 1, cy + R + 1); y++) for (let x = Math.max(0, Math.floor(cx - R - 1)); x <= Math.min(W - 1, cx + R + 1); x++) { const dx = (x + 0.5 - cx) / R @@ -435,7 +445,7 @@ class Space extends Canvas { const lit = Math.max(0, dx * look.sun[0] + dy * look.sun[1] + nz * look.sun[2]) const craters = 0.75 + 0.35 * fbm2(dx * 3 + 5, dy * 3 + 2, 4) const k = (0.015 + lit * 0.9) * craters - this.blend((y * W + x) * 3, k * 0.9, k * 0.9, k * 0.95, clamp((1 - Math.sqrt(r2)) * R + 0.5, 0, 1)) + this.blend((y * W + x) * 3, k * mr, k * mg, k * mb, clamp((1 - Math.sqrt(r2)) * R + 0.5, 0, 1)) } } @@ -558,6 +568,11 @@ class Space extends Canvas { for (const u of [-0.42, -0.27, 0.27, 0.42]) parts.push(cap(...P(u, -0.22), ...P(u, -0.04), 0.055 * S, 0.055 * S, panel, 4), cap(...P(u, 0.04), ...P(u, 0.22), 0.055 * S, 0.055 * S, panel, 4)) parts.push(cap(...P(-0.13, 0), ...P(0.13, 0), 0.045 * S, 0.045 * S, white), cap(...P(0, -0.12), ...P(0, 0.1), 0.035 * S, 0.035 * S, white), cap(...P(0.13, 0.02), ...P(0.2, 0.08), 0.02 * S, 0.02 * S, white)) this.shape(parts, this.lighting, 0.8) + // At night the modules' windows glow amber and light the panels beside them. + if (this.look.lighting === "night") { + lightPool(this.hdr, this.W, H, ...P(0, 0), 0.4 * S, 0.25 * S, [1, 0.6, 0.15], 3, 0.008) + for (const u of [-0.08, 0, 0.08]) this.disc(...P(u, 0), 0.018 * S, 1.6, 0.85, 0.2, 0.9) + } const blink = Math.sin(this.time * 4) > 0.6 ? 1.5 : 0 this.add(...P(0.5, 0), blink, 0.1 * blink, 0.1 * blink) this.add(...P(-0.5, 0), 0.1 * blink, blink, 0.2 * blink) @@ -595,7 +610,9 @@ class Space extends Canvas { const lit = Math.max(0, (dx / edge) * look.sun[0] + (dy / edge) * look.sun[1] + nz * look.sun[2]) const pits = 0.7 + 0.45 * fbm2(dx * 2.5 + Math.cos(r.angle) * 2 + r.seed, dy * 2.5 + Math.sin(r.angle) * 2, 4) const ambient = look.lighting === "night" ? 0.01 : 0.02 - const k = (ambient + lit * 0.7) * pits + // At night the planet's glow below rims the side of the rock facing it. + const rim = look.lighting === "night" ? Math.pow(1 - nz, 2) * Math.max(0, ((dx / edge) * (W - PLANET.x * H - cx) + (dy / edge) * (PLANET.y * H - cy)) / Math.hypot(W - PLANET.x * H - cx, PLANET.y * H - cy)) * 0.6 : 0 + const k = (ambient + lit * 0.7 + rim) * pits this.blend((y * W + x) * 3, k * look.rockColor[0], k * look.rockColor[1], k * look.rockColor[2], clamp((1.05 - rr) * R, 0, 1)) } } @@ -604,7 +621,7 @@ class Space extends Canvas { private drawAstronaut() { const H = this.H const t = this.creatureTime - const x = (0.24 * this.A + 0.05 * Math.sin(t * 0.13)) * H + const x = (0.28 + 0.05 * Math.sin(t * 0.13)) * H const y = (0.6 + 0.03 * Math.sin(t * 0.21)) * H const a = t * 0.08 const S = 0.09 * H @@ -628,6 +645,10 @@ class Space extends Canvas { 0.8, ) if (this.look.lighting !== "night") this.add(...P(0.14, -0.36), 1.2, 1.1, 0.9) + // At night the helmet lamp lights the suit and the dark around it amber. + if (this.look.lighting !== "night") return + lightPool(this.hdr, this.W, H, ...P(0.2, -0.33), 0.6 * S, 0.6 * S, [1, 0.6, 0.15], 2.5, 0.01) + this.disc(...P(0.12, -0.33), 0.06 * S, 1.2, 0.6, 0.12, 0.8) } // Now and then a flying saucer glides across the top of the sky, its rim lights chasing round. diff --git a/wallpapers/template.ts b/wallpapers/template.ts index 9e3aa7e..0b604af 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -1,7 +1,7 @@ // A starter wallpaper: rolling hills with a tree, a hot-air balloon and birds. It is not registered in index.ts; try it // with `WALLPAPER_MODULES=$PWD/wallpapers/template.ts bun dev/snap.ts template teeming day 30`. In a package of your // own, import from "opencode-wallpapers/api" instead of "../src/api". See CONTRIBUTING.md. -import { Canvas, TAU, cap, clamp, driftClouds, ell, fbm1, makeClouds, makeStars, paintClouds, paintSky, paintStars, type Activity, type Cloud, type Lighting, type Orb, type RGB, type Settings, type Star, type Time, type Wallpaper } from "../src/api" +import { Canvas, TAU, cap, clamp, driftClouds, ell, fbm1, groundShadow, makeClouds, makeStars, paintClouds, paintSky, paintStars, sunShade, type Activity, type Cloud, type Lighting, type Orb, type RGB, type Settings, type Shade, type Star, type Time, type Wallpaper } from "../src/api" // Every wallpaper runs slower than real time, and its creatures slower still, so nothing is busy behind text. const TIME_SCALE = 0.35 @@ -50,7 +50,7 @@ const LOOKS: Record = { ], orb: { x: 0.6, y: 0.58, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 25, - clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.6 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.8 }, hills: [ [0.2, 0.08, 0.1], [0.07, 0.04, 0.05], @@ -68,7 +68,7 @@ const LOOKS: Record = { // A gold moon rather than a white one. orb: { x: 0.75, y: 0.16, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, hills: [ [0.03, 0.05, 0.13], [0.015, 0.03, 0.08], @@ -90,6 +90,7 @@ class Template extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] @@ -99,8 +100,9 @@ class Template extends Canvas { super() this.activity = settings.activity this.look = LOOKS[settings.time] + this.shade = sunShade(settings.time, this.look.orb) this.stars = makeStars(this.look.stars, 0.55) - this.clouds = makeClouds(4, true, 0.08, 0.3) + this.clouds = makeClouds(4, "cumulus", 0.08, 0.3) } // Advances the scene by dt seconds. Clamp dt so a stalled frame doesn't make things jump. @@ -117,7 +119,7 @@ class Template extends Canvas { render() { this.hdr.set(this.background) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5) - paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, true) + paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, this.look.orb) if (this.activity !== "calm") this.drawBalloon() if (this.activity === "teeming" && this.look.night) this.drawFireflies() if (this.activity === "teeming" && !this.look.night) this.drawBirds() @@ -145,6 +147,8 @@ class Template extends Canvas { } // A tree on the near hill: shape() joins the parts into one lit form. const [tx, ty] = [0.8 * A * H, 0.86 * H] + // Its shadow first, so the tree stands on it: width and height in pixels, and how much wider the crown's end is. + groundShadow(hdr, W, H, this.shade, tx, ty, 0.08 * H, 0.24 * H, 1.6) this.shape([cap(tx, ty, tx, ty - 0.12 * H, 0.012 * H, 0.008 * H, this.paint([0.4, 0.28, 0.18]))], this.lighting) const leaves = this.paint([0.2, 0.42, 0.14]) this.shape([ell(tx, ty - 0.18 * H, 0.07 * H, 0.06 * H, 0, leaves), ell(tx - 0.05 * H, ty - 0.14 * H, 0.045 * H, 0.04 * H, 0, leaves), ell(tx + 0.05 * H, ty - 0.14 * H, 0.045 * H, 0.04 * H, 0, leaves)], this.lighting) diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..e517390 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -1,9 +1,15 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { bob, body, chain, gait, quadruped, stepGait, stride, view, type Gait } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" +import { lightPool } from "../src/light" import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { groundShadow, sunShade, type Shade } from "../src/shadow" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -22,7 +28,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } snow: RGB shade: RGB rock: RGB @@ -39,18 +45,18 @@ const LOOKS: Record = { style: "front", light: [1, 0.97, 0.9], sky: [ - [0, [0.2, 0.42, 0.75]], - [0.3, [0.4, 0.62, 0.85]], - [HORIZON, [0.75, 0.85, 0.92]], + [0, [0.12, 0.32, 0.7]], + [0.3, [0.28, 0.52, 0.84]], + [HORIZON, [0.62, 0.76, 0.9]], ], orb: { x: 0.78, y: 0.22, r: 0.032, core: [4.5, 4.3, 3.8], glow: [1, 0.95, 0.85], near: 0.4, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.05, 1.08, 1.12], bottom: [0.62, 0.7, 0.82], alpha: 0.85 }, - snow: [0.8, 0.87, 0.95], - shade: [0.55, 0.66, 0.85], - rock: [0.35, 0.4, 0.5], - peaks: [0.85, 0.9, 0.97], - ice: [0.55, 0.75, 0.88], + clouds: { top: [1.05, 1.08, 1.12], bottom: [0.62, 0.7, 0.82], alpha: 0.85 }, + snow: [0.76, 0.72, 0.64], + shade: [0.2, 0.36, 0.76], + rock: [0.26, 0.29, 0.4], + peaks: [0.92, 0.86, 0.78], + ice: [0.4, 0.66, 0.86], flakes: [0.9, 0.94, 1], tint: [1, 1, 1], aurora: 0, @@ -67,7 +73,7 @@ const LOOKS: Record = { ], orb: { x: 0.62, y: 0.53, r: 0.055, core: [3, 1.6, 0.9], glow: [1, 0.45, 0.3], near: 0.5, wide: 0.22 }, stars: 25, - clouds: { puffy: false, top: [0.12, 0.05, 0.12], bottom: [0.85, 0.35, 0.3], alpha: 0.55 }, + clouds: { top: [0.12, 0.05, 0.12], bottom: [0.85, 0.35, 0.3], alpha: 0.55 }, snow: [0.72, 0.42, 0.48], shade: [0.3, 0.18, 0.38], rock: [0.25, 0.12, 0.2], @@ -80,22 +86,23 @@ const LOOKS: Record = { }, night: { style: "rim", - light: [0.2, 0.5, 1], + light: [0.3, 0.65, 1.5], sky: [ [0, [0.004, 0.01, 0.035]], - [0.3, [0.01, 0.025, 0.07]], - [HORIZON, [0.03, 0.07, 0.16]], + [0.3, [0.012, 0.03, 0.08]], + [0.45, [0.025, 0.055, 0.14]], + [HORIZON, [0.05, 0.1, 0.24]], ], orb: { x: 0.2, y: 0.14, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 120, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.35 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.35 }, snow: [0.05, 0.12, 0.3], shade: [0.02, 0.05, 0.14], rock: [0.02, 0.04, 0.09], peaks: [0.08, 0.16, 0.36], ice: [0.04, 0.12, 0.25], flakes: [0.25, 0.45, 0.9], - tint: [0.04, 0.08, 0.18], + tint: [0.03, 0.06, 0.14], aurora: 1, glow: 1, }, @@ -125,6 +132,7 @@ interface Walker { moving: boolean // Creature time into a hop after a click; negative while waiting its turn, Infinity when not hopping. hop: number + g: Gait } class Tundra extends Canvas { @@ -133,9 +141,11 @@ class Tundra extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] + private shadows = makeShadows(3, HORIZON + 0.08, 0.95) private storm = makeStorm() private flakes: { x: number; y: number; z: number; s: number }[] private owl = { x: -9, y: 0.25, dir: 1, next: 14, phase: 0 } @@ -147,19 +157,23 @@ class Tundra extends Canvas { // time since it began. private yeti = { wait: eggWait() * TIME_SCALE, t: -1, x: 0 } private readonly weather: WeatherLayer + // The big pines framing the edges, painted once, bend a little as gusts pass. + private pines = new SwayLayer() constructor(settings: Settings) { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 4, this.look.clouds.puffy, 0.06, 0.28) + this.clouds = makeClouds(3, "stratus", 0.26, 0.4) // Its own gentle snowfall under a clear sky, unless weather is chosen. this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) this.flakes = Array.from({ length: settings.weather ? 0 : { calm: 70, lively: 100, teeming: 130 }[settings.activity] }, () => ({ x: Math.random() * 4, y: Math.random(), z: Math.random(), s: Math.random() })) - const walker = (x: number, y: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, rest: 0, moving: false, hop: Infinity }) + const walker = (x: number, y: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, rest: 0, moving: false, hop: Infinity, g: gait(1, hash(x * 7 + y)) }) if (settings.activity !== "calm") { - this.penguins = Array.from({ length: settings.activity === "teeming" ? 5 : 3 }, (_, i) => walker(0.6 + i * 0.12, 0.78 + (i % 2) * 0.015)) + this.penguins = Array.from({ length: settings.activity === "teeming" ? 5 : 3 }, (_, i) => walker(0.6 + i * 0.12, 0.81 + (i % 2) * 0.015)) this.fox = walker(0.9, 0.88) } if (settings.activity === "teeming") this.bear = walker(0.7, 0.655) @@ -171,12 +185,13 @@ class Tundra extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) for (const f of this.flakes) { f.y += (0.02 + f.z * 0.035) * dt - f.x += (Math.sin(this.time * 0.6 + f.s * 20) * 0.006 + 0.004) * dt + f.x += (Math.sin(this.time * 0.6 + f.s * 20) * 0.006 + 0.004 + gust(f.x, this.time) * (0.06 + f.z * 0.06)) * dt if (f.y > 1) { f.y -= 1 f.x = Math.random() * this.A @@ -185,9 +200,9 @@ class Tundra extends Canvas { } this.stepOwl(dt) const A = this.A - for (const p of this.penguins) this.wander(p, cdt, [0.34 * A, 0.58 * A, 0.77, 0.81], 0.025) - if (this.fox) this.wander(this.fox, cdt, [0.3 * A, 0.95 * A, 0.84, 0.92], 0.04) - if (this.bear) this.wander(this.bear, cdt, [0.25 * A, 0.85 * A, 0.648, 0.662], 0.025) + for (const p of this.penguins) this.wander(p, cdt, [0.34 * A, 0.58 * A, 0.8, 0.84], 0.025, 0.012) + if (this.fox) this.wander(this.fox, cdt, [0.3 * A, 0.95 * A, 0.84, 0.92], 0.04, 0.038) + if (this.bear) this.wander(this.bear, cdt, [0.25 * A, 0.85 * A, 0.648, 0.662], 0.025, 0.08) const f = this.fisher f.next -= cdt if (f.next <= 0) { @@ -201,7 +216,7 @@ class Tundra extends Canvas { y.wait -= dt if (y.wait > 0) return y.t = 0 - y.x = rand(0.56, 0.6) * this.A + y.x = this.openSpot(0.15 * this.A, 0.8 * this.A, 0.6, 0.6)[0] return } y.t += cdt @@ -221,17 +236,24 @@ class Tundra extends Canvas { render() { this.hdr.set(this.background) + this.pines.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) if (this.look.aurora > 0 && !this.weather.covered) this.drawAurora() const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) + const orb = this.weather.covered ? undefined : this.look.orb + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) + paintShadows(this.hdr, this.W, this.H, this.shadows, 0.61, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + this.drawShadows() if (this.owl.x > -5) this.drawOwl() if (this.yeti.t >= 0) this.drawYeti() if (this.bear) this.drawBear(this.bear) if (this.activity === "teeming") this.drawFisher() for (const p of [...this.penguins].sort((a, b) => a.y - b.y)) this.drawPenguin(p) if (this.fox) this.drawFox(this.fox) + // At night the igloo doors' warm light falls across the snow and anything passing them. + if (this.look.glow === 1) + for (const [x, base, R] of this.igloos()) lightPool(this.hdr, this.W, this.H, (x - R * 0.35) * this.H, (base + 0.01) * this.H, R * 3.2 * this.H, R * 1.1 * this.H, [1, 0.42, 0.08], 1, 0.03) this.drawFlakes() this.weather.draw(this.hdr, this.W, this.H) this.finish() @@ -247,6 +269,7 @@ class Tundra extends Canvas { this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } paintSky(this.hdr, W, H, look.sky, look.orb) this.weather.cover(this.hdr, W, H) + const sky = this.hdr.slice() this.drawMountains() // Gentle drifts near the horizon, then the snowfield sweeping to the bottom of the screen. const drift = new Float32Array(W) @@ -255,21 +278,66 @@ class Tundra extends Canvas { for (let y = Math.max(0, Math.floor(drift[x])); y < H; y++) { const v = y / H const waves = 0.5 + 0.5 * Math.sin((x / H) * 6 + v * 40 + fbm1(x * 0.02, 3) * 4) - const k = smoothstep(0.3, 1, waves) * 0.35 * (1 - (v - 0.6) * 0.8) - const c = lerpRGB(look.snow, look.shade, k) + const k = smoothstep(0.3, 1, waves) * (look === LOOKS.day ? 0.2 : 0.35) * (1 - (v - 0.6) * 0.8) + // By day the foreground lies in blue shadow, framing the sunlit middle distance. + const c = lerpRGB(look.snow, look.shade, look === LOOKS.day ? Math.min(1, k + smoothstep(0.7, 1.1, v) * 0.45) : k) const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - drift[x]) / 2) * 0.3 : 0 this.blend((y * W + x) * 3, c[0] + look.light[0] * edge, c[1] + look.light[1] * edge, c[2] + look.light[2] * edge, clamp(y + 1 - drift[x], 0, 1)) } - this.drawLake(0.5 * A, 0.72, 0.3, 0.045) - for (const [fx, h] of [[0.3, 0.1], [0.36, 0.08], [0.7, 0.09], [0.76, 0.11]] as const) this.drawPine(fx * A, 0.625, h, 0.5) - this.drawIgloo(0.86 * A, 0.7, 0.055) - this.drawIgloo(0.2 * A, 0.75, 0.075) + // By day, broad blue cloud shadows drift over the snow, and the far field and peaks fade into the sky. + if (look === LOOKS.day) { + mottle(this.hdr, W, H, 0.6, 1, 0.7, [1.04, 1, 0.9], [0.5, 0.66, 1]) + haze(this.hdr, sky, W, H, 0.6, 0.8, 0.15, 0) + } + this.drawLake(0.5 * A, 0.745, 0.3, 0.045) + // The pines and igloos keep to the sides, clear of the text in the middle. + const pines = [[0.3, 0.1], [0.37, 0.08], [A - 0.36, 0.09], [A - 0.29, 0.11]] + const ground = (x: number, y: number, w: number, h: number, tip?: number) => groundShadow(this.hdr, W, H, this.shade, x * H, y * H, w * H, h * H, tip) + for (const [x, h] of pines) ground(x, 0.625, h * 0.45, h, 0.5) + for (const [x, base, R] of this.igloos()) ground(x, base, R * 2, R * 0.85, 0.8) + if (this.activity === "teeming") ground(0.3 * A, 0.85, 0.068, 0.15, 0.6) + ground(0.62 * A, 0.87, 0.09, 0.195, 0.6) + for (const [x, h] of pines) this.drawPine(x, 0.625, h, 0.5) + for (const [x, base, R] of this.igloos()) this.drawIgloo(x, base, R) if (this.activity === "teeming") this.drawSnowman(0.3 * A, 0.85, 0.75, [0.2, 0.35, 0.8], false) this.drawSnowman(0.62 * A, 0.87, 1, [0.8, 0.12, 0.1], true) + this.pines.begin(this.hdr) for (const [fx, h] of [[0.04, 0.46], [0.11, 0.34], [0.955, 0.42], [0.995, 0.3]] as const) this.drawPine(fx * A, 1.02, h, 0) + this.pines.end(this.hdr, W, H, 0.01 * H, (_x, y) => clamp((0.95 * H - y) / (0.4 * H), 0, 1) ** 1.5) this.background = this.hdr.slice() } + // Shadows under everything on the snow, drawn before any of it so none falls across another animal. The yeti has + // none: it is never quite there. + private drawShadows() { + const H = this.H + const ground = (x: number, y: number, w: number, h: number, tip?: number, lift?: number) => groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip, lift) + if (this.bear) { + const S = 0.16 * this.depthScale(this.bear.y) + ground(this.bear.x + this.bear.face * 0.04 * S, this.bear.y, 0.85 * S, 0.5 * S, 0.8) + } + if (this.activity === "teeming") ground(0.68 * this.A + 0.05, 0.758, 0.033, 0.09, 0.6) + for (const p of this.penguins) { + const S = 0.075 * this.depthScale(p.y) + const hop = p.hop >= 0 && p.hop < HOP ? Math.abs(Math.sin((p.hop / HOP) * TAU)) * 0.025 * this.depthScale(p.y) : 0 + ground(p.x, p.y, 0.45 * S, S, 0.6, hop * H) + } + if (this.fox) { + const S = 0.064 * this.depthScale(this.fox.y) + ground(this.fox.x - this.fox.face * 0.05 * S, this.fox.y, 1.1 * S, 0.7 * S, 0.7) + } + } + + // Igloos: x, base and radius in screen heights, kept to the sides clear of the text in the middle. + private igloos() { + const A = this.A + return [ + [A - 0.2, 0.7, 0.055], + [A - 0.36, 0.77, 0.08], + [0.42, 0.79, 0.075], + ] + } + private paint(c: RGB): RGB { const t = this.look.tint return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] @@ -292,7 +360,8 @@ class Tundra extends Canvas { for (let y = Math.max(0, Math.floor(top[x])); y < (HORIZON + 0.06) * H; y++) { const haze = clamp((y / H - 0.35) * 1.2, 0, 0.4) let c = y < snowLine ? look.peaks : look.rock - if (look.style === "front") c = scaleRGB(c, slope > 0.3 ? 1.05 : slope < -0.3 ? 0.72 : 0.9) + // Sunlit faces warm, shaded ones a crisp blue. + if (look.style === "front") c = mulRGB(c, slope > 0.3 ? [1.05, 1, 0.92] : slope < -0.3 ? [0.45, 0.6, 0.92] : [0.8, 0.82, 0.9]) const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - top[x]) / 1.8) * 0.5 : 0 c = lerpRGB(c, look.sky[look.sky.length - 1][1], haze * 0.5) this.blend((y * W + x) * 3, c[0] + look.light[0] * edge, c[1] + look.light[1] * edge, c[2] + look.light[2] * edge, clamp(y + 1 - top[x], 0, 1)) @@ -300,11 +369,26 @@ class Tundra extends Canvas { } } - // A frozen lake with a few cracks in the ice. + // A frozen lake with a few cracks in the ice, its glossy surface dimly mirroring the peaks and the sky. private drawLake(cx: number, cy: number, rx: number, ry: number) { - const H = this.H + const { W, H } = this const ice = this.look.ice this.shape([ell(cx * H, cy * H, rx * H, ry * H, 0, ice)], this.lighting, 0.3) + const y0 = Math.max(0, Math.floor((cy - ry) * H)) + const y1 = Math.min(H, Math.ceil((cy + ry) * H) + 1) + const gloss = new Float32Array((y1 - y0) * W) + for (let y = y0; y < y1; y++) { + const dy = (y + 0.5 - cy * H) / (ry * H) + for (let x = Math.max(0, Math.floor((cx - rx) * H)); x < Math.min(W, (cx + rx) * H + 1); x++) { + const cover = clamp((1 - Math.hypot((x + 0.5 - cx * H) / (rx * H), dy)) * ry * H + 0.5, 0, 1) + gloss[(y - y0) * W + x] = cover * (0.5 - 0.2 * (dy * 0.5 + 0.5)) + } + } + // The far shore mirrors the foot of the mountains, and the near one the sky above the peaks. + const below = (r: number) => Math.max(0, y0 + r - (cy - ry) * H) + const rows = Float32Array.from({ length: y1 - y0 }, (_, r) => 0.598 * H - below(r) * (1.6 + (2.2 * below(r)) / (2 * ry * H))) + const top = Math.max(...ice) + reflect(this.hdr, W, H, y0, y1, rows, new Float32Array(y1 - y0).fill(0.4 * (H / 180)), gloss, [(ice[0] / top) * 0.9, (ice[1] / top) * 0.9, (ice[2] / top) * 0.9], 0) const crack = scaleRGB(ice, 0.7) for (let i = 0; i < 5; i++) { const x0 = cx + (hash(i * 3.7) - 0.5) * rx * 1.4 @@ -440,7 +524,14 @@ class Tundra extends Canvas { } } - private wander(w: Walker, dt: number, zone: [number, number, number, number], speed: number) { + // stride is the distance a full step cycle covers at the front of the snowfield, so the legs keep pace with the ground. + private wander(w: Walker, dt: number, zone: [number, number, number, number], speed: number, stride: number) { + const [x0, y0] = [w.x, w.y] + this.roam(w, dt, zone, speed) + stepGait(w.g, Math.hypot(w.x - x0, w.y - y0) / (stride * this.depthScale(w.y)), dt, w.face) + } + + private roam(w: Walker, dt: number, zone: [number, number, number, number], speed: number) { w.phase += dt * (w.moving ? 6 : 1) if (w.rest > 0) { w.rest -= dt @@ -450,8 +541,7 @@ class Tundra extends Canvas { w.wanderT -= dt if (w.wanderT <= 0) { w.wanderT = rand(6, 14) - w.wx = rand(zone[0], zone[1]) - w.wy = rand(zone[2], zone[3]) + ;[w.wx, w.wy] = this.openSpot(...zone) } const dx = w.wx - w.x const dy = w.wy - w.y @@ -471,33 +561,46 @@ class Tundra extends Canvas { return 0.55 + (y - 0.65) * 2.5 } - // A penguin waddling side to side, flippers out. + // A penguin waddling side to side on short steps, flippers out and trailing as it sets off and stops; its white + // front comes round as it turns. private drawPenguin(p: Walker) { const H = this.H - const S = 0.07 * H * this.depthScale(p.y) - const rock = p.moving ? Math.sin(p.phase) * 0.14 : Math.sin(p.phase) * 0.03 + const g = p.g + const S = 0.075 * H * this.depthScale(p.y) + const rock = Math.sin(TAU * g.phase) * 0.14 * g.go + Math.sin(p.phase) * 0.03 * (1 - g.go) const x = p.x * H const y = (p.y - (p.hop >= 0 && p.hop < HOP ? Math.abs(Math.sin((p.hop / HOP) * TAU)) * 0.025 * this.depthScale(p.y) : 0)) * H + const [al, ac] = view(g.turn) const cr = Math.cos(rock) const sr = Math.sin(rock) - const P = (u: number, v: number): [number, number] => [x + (u * p.face * cr - v * sr) * S, y + (u * p.face * sr + v * cr) * S] + // Rocks about the feet, which stay put. + const P = (u: number, v: number, w = 0): [number, number] => { + const sx = u * al - w * ac + return [x + (sx * cr - v * sr) * S, y + (sx * sr + v * cr) * S] + } + const len = (u: number, w: number) => (u * Math.abs(al) + w * ac) * S const black = this.paint(DARK) const belly = this.paint([0.92, 0.92, 0.95]) const orange = this.paint(ORANGE) + const feet = [-1, 1].map((w) => { + const [reach, lift] = stride(g.phase, w > 0 ? 0 : 0.5) + const fu = reach * 0.06 * g.go + return ell(...P(fu + 0.05, -0.02 - lift * 0.05 * g.go, w * 0.09), len(0.1, 0.05), 0.035 * S, 0, orange) + }) + const flip = 0.1 + g.lag * 0.12 + Math.abs(rock) * 0.3 this.shape( [ - ell(...P(0.06, -0.02), 0.1 * S, 0.035 * S, 0, orange), - ell(...P(-0.08, -0.02), 0.1 * S, 0.035 * S, 0, orange), - ell(...P(0, -0.42), 0.22 * S, 0.4 * S, rock, black), - cap(...P(-0.05, -0.62), ...P(-0.2, -0.32), 0.06 * S, 0.03 * S, black), - ell(...P(0.03, -0.82), 0.15 * S, 0.14 * S, 0, black), + ...feet, + ell(...P(0, -0.42), len(0.22, 0.2), 0.4 * S, rock, black), + ...[-1, 1].map((w) => cap(...P(-0.04, -0.62, w * 0.17), ...P(-0.14 - flip * 0.3, -0.32, w * (0.2 + flip)), 0.06 * S, 0.025 * S, black)), + ell(...P(0.04, -0.82), len(0.15, 0.13), 0.14 * S, 0, black), ], this.lighting, 0.6, ) - this.shape([ell(...P(0.07, -0.38), 0.13 * S, 0.3 * S, rock, belly)], this.lighting, 0.6) - this.shape([cap(...P(0.13, -0.84), ...P(0.26, -0.81), 0.035 * S, 0.012 * S, orange)], this.lighting, 0.5) - this.add(...P(0.09, -0.86), 0.5, 0.5, 0.5) + this.shape([ell(...P(0.08, -0.38), len(0.13, 0.15), 0.3 * S, rock, belly)], this.lighting, 0.6) + this.shape([cap(...P(0.14, -0.84), ...P(0.27, -0.81), 0.035 * S, 0.012 * S, orange)], this.lighting, 0.5) + for (const w of ac > 0.3 ? [-1, 1] : [0]) this.add(...P(0.1, -0.86, w * 0.06), 0.5, 0.5, 0.5) } // A shaggy silhouette on the far snowfield, fading in and out of the falling snow. It stands, looks around, shuffles @@ -533,62 +636,69 @@ class Tundra extends Canvas { if (look.style === "rim") for (const s of [-1, 1]) this.add(...P(turn + s * 0.035, -0.99), 0.5 * fade, 0.3 * fade, 0.04 * fade) } - // An arctic fox trotting across the snow, bushy tail out behind. + // An arctic fox trotting across the snow on slim legs, ears up and bushy tail streaming out behind. private drawFox(f: Walker) { const H = this.H - const S = 0.08 * H * this.depthScale(f.y) - const x = f.x * H - const y = f.y * H - const P = (u: number, v: number): [number, number] => [x + u * f.face * S, y + v * S] + const g = f.g + const P = body(f.x * H, f.y * H, 0.064 * H * this.depthScale(f.y), g.turn) + const S = P.S const fur = this.paint(FUR) - const swing = f.moving ? Math.sin(f.phase) * 0.08 : 0 + const rise = bob(g.phase) * g.go * 0.02 + const legs = { fore: 0.21, hind: 0.21, top: 0.3 - rise, w: 0.06, a: 0.5, b: 0.5, reach: 0.18, lift: 0.09, r: [0.065, 0.032, 0.028] as [number, number, number], paw: [0.035, fur] as [number, RGB] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([0.72, 0.72, 0.7]) }), this.lighting, 0.7) + const nod = Math.cos(TAU * 2 * g.phase - 1) * 0.015 * g.go - rise + const flick = Math.sin(TAU * 2 * g.phase - 2) * 0.03 * g.go - g.lag * 0.05 + const tail = (t: number) => P(-0.26 - t * 0.4, -0.4 - rise + t * 0.12 - Math.sin(Math.PI * t) * 0.06 + flick * t * t) this.shape( [ - ...[[0.22, 1], [0.27, -1], [-0.2, -1], [-0.25, 1]].map(([u, s]) => cap(...P(u, -0.28), ...P(u + swing * s, 0), 0.035 * S, 0.028 * S, fur)), - cap(...P(-0.32, -0.4), ...P(-0.7, -0.3), 0.1 * S, 0.06 * S, fur), - ell(...P(0, -0.38), 0.34 * S, 0.14 * S, 0, fur), - cap(...P(0.28, -0.42), ...P(0.4, -0.52), 0.09 * S, 0.08 * S, fur), - ell(...P(0.44, -0.55), 0.11 * S, 0.09 * S, 0, fur), - cap(...P(0.5, -0.53), ...P(0.62, -0.5), 0.05 * S, 0.02 * S, fur), - cap(...P(0.4, -0.62), ...P(0.36, -0.74), 0.035 * S, 0.006 * S, fur), - cap(...P(0.47, -0.62), ...P(0.46, -0.75), 0.035 * S, 0.006 * S, fur), + ...quadruped(P, g, false, { ...legs, color: fur }), + ell(...P(0, -0.4 - rise), P.len(0.3, 0.11), 0.12 * S, 0, fur), + ...chain(tail, 3, 0.07 * S, 0.1 * S, fur), + ell(...tail(0.95), P.len(0.09, 0.08), 0.08 * S, 0, fur), + cap(...P(0.22, -0.43 - rise), ...P(0.36, -0.54 + nod), 0.1 * S, 0.075 * S, fur), + ell(...P(0.4, -0.56 + nod), 0.09 * S, 0.08 * S, 0, fur), + cap(...P(0.44, -0.54 + nod), ...P(0.58, -0.5 + nod), 0.05 * S, 0.016 * S, fur), + ...[-1, 1].map((w) => cap(...P(0.38, -0.62 + nod, w * 0.05), ...P(0.36 - g.lag * 0.02, -0.76 + nod, w * 0.07), 0.035 * S, 0.006 * S, fur)), ], this.lighting, 0.7, ) - this.add(...P(0.62, -0.5), 0.02, 0.02, 0.02) + this.add(...P(0.58, -0.5 + nod), 0.02, 0.02, 0.02) } - // A polar bear plodding along the far shore of the lake, head low. + // A polar bear plodding along the far shore of the lake, head low and swinging, rump high, on big furry paws. private drawBear(b: Walker) { const H = this.H - const S = 0.13 * H * this.depthScale(b.y) - const x = b.x * H - const y = b.y * H - const P = (u: number, v: number): [number, number] => [x + u * b.face * S, y + v * S] + const g = b.g + const P = body(b.x * H, b.y * H, 0.16 * H * this.depthScale(b.y), g.turn) + const S = P.S const fur = this.paint([0.95, 0.9, 0.74]) - const swing = b.moving ? Math.sin(b.phase * 0.8) * 0.06 : 0 + const rise = bob(g.phase) * g.go * 0.015 + const legs = { fore: 0.24, hind: 0.24, top: 0.33 - rise, w: 0.09, a: 0.5, b: 0.5, reach: 0.15, lift: 0.07, r: [0.11, 0.075, 0.07] as [number, number, number], paw: [0.05, fur] as [number, RGB] } + this.shape(quadruped(P, g, true, { ...legs, color: this.paint([0.76, 0.72, 0.6]) }), this.lighting, 0.7) + const sway = Math.sin(TAU * g.phase - 1) * 0.04 * g.go + const nod = Math.cos(TAU * 2 * g.phase - 1) * 0.015 * g.go - rise this.shape( [ - ...[[0.25, 1], [0.3, -1], [-0.22, -1], [-0.27, 1]].map(([u, s]) => cap(...P(u, -0.3), ...P(u + swing * s, 0), 0.07 * S, 0.06 * S, fur)), - ell(...P(0, -0.38), 0.38 * S, 0.2 * S, 0, fur), - ell(...P(-0.22, -0.42), 0.18 * S, 0.17 * S, 0, fur), - cap(...P(0.3, -0.42), ...P(0.46, -0.34), 0.12 * S, 0.09 * S, fur), - ell(...P(0.5, -0.32), 0.1 * S, 0.08 * S, 0.2 * b.face, fur), - ell(...P(0.43, -0.42), 0.035 * S, 0.035 * S, 0, fur), + ...quadruped(P, g, false, { ...legs, color: fur }), + ell(...P(-0.02, -0.4 - rise), P.len(0.36, 0.17), 0.19 * S, 0.08 * P.along, fur), + cap(...P(0.24, -0.44 - rise), ...P(0.46, -0.36 + nod, sway), 0.14 * S, 0.09 * S, fur), + cap(...P(0.46, -0.37 + nod, sway), ...P(0.62, -0.32 + nod, sway), 0.085 * S, 0.045 * S, fur), + ...[-1, 1].map((w) => ell(...P(0.45, -0.45 + nod, sway + w * 0.05), 0.035 * S, 0.035 * S, 0, fur)), + ell(...P(-0.38, -0.46 - rise), 0.04 * S, 0.035 * S, 0, fur), ], this.lighting, 0.7, ) - this.add(...P(0.6, -0.31), 0.02, 0.02, 0.02) + this.add(...P(0.63, -0.33 + nod, sway), 0.02, 0.02, 0.02) } // Someone in a red parka ice fishing on the lake, rod tugging now and then. private drawFisher() { const H = this.H - const S = 0.12 * H + const S = 0.1 * H const hx = 0.68 * this.A * H - const hy = 0.718 * H + const hy = 0.758 * H const x = hx + 0.06 * H const y = hy const P = (u: number, v: number): [number, number] => [x + u * S, y + v * S] @@ -621,7 +731,7 @@ class Tundra extends Canvas { if (o.next > 0) return o.dir = Math.random() < 0.5 ? 1 : -1 o.x = o.dir > 0 ? -0.15 : this.A + 0.15 - o.y = rand(0.2, 0.35) + o.y = this.openRow(0.18, 0.35) return } o.x += o.dir * 0.07 * dt @@ -659,6 +769,10 @@ function scaleRGB(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k] } +function mulRGB(a: RGB, b: RGB): RGB { + return [a[0] * b[0], a[1] * b[1], a[2] * b[2]] +} + export const tundra: Wallpaper = { id: "tundra", name: "Tundra", diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index c1f2340..59225fa 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -1,10 +1,15 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { limb } from "../src/creature" import { eggWait } from "../src/egg" +import { haze, mottle } from "../src/grade" import { bird, flyAway, startle, type Flier } from "../src/flock" +import { fireflyLight, lightPool } from "../src/light" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" +import { groundShadow, sunShade, type Shade } from "../src/shadow" import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -24,7 +29,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } hills: [far: RGB, near: RGB] snow: RGB mist: RGB @@ -51,7 +56,7 @@ const LOOKS: Record = { ], orb: { x: 0.4, y: 0.12, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, hills: [ [0.42, 0.55, 0.72], [0.18, 0.36, 0.22], @@ -59,7 +64,7 @@ const LOOKS: Record = { snow: [1, 1.02, 1.08], mist: [0.72, 0.82, 0.9], moss: [0.2, 0.4, 0.12], - gravel: [0.8, 0.76, 0.66], + gravel: [0.72, 0.66, 0.55], water: [0.03, 0.14, 0.13], mirror: 0.55, glitter: 0.15, @@ -71,50 +76,52 @@ const LOOKS: Record = { }, sunset: { style: "rim", - light: [1, 0.5, 0.2], + // Soft peach low down fading into lavender, with a mild sun and little contrast. + light: [1, 0.68, 0.5], sky: [ - [0, [0.03, 0.02, 0.08]], - [0.25, [0.12, 0.04, 0.12]], - [0.4, [0.45, 0.14, 0.14]], - [HORIZON, [1, 0.5, 0.18]], + [0, [0.05, 0.055, 0.15]], + [0.16, [0.2, 0.18, 0.36]], + [0.3, [0.85, 0.48, 0.36]], + [HORIZON, [1, 0.62, 0.36]], ], - orb: { x: 0.52, y: 0.375, r: 0.05, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, - stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + orb: { x: 0.52, y: 0.39, r: 0.045, core: [2.2, 1.35, 0.75], glow: [1, 0.58, 0.36], near: 0.38, wide: 0.22 }, + stars: 10, + clouds: { top: [0.26, 0.18, 0.32], bottom: [1, 0.6, 0.45], alpha: 0.5 }, hills: [ - [0.38, 0.13, 0.16], - [0.1, 0.04, 0.05], + [0.56, 0.33, 0.36], + [0.17, 0.11, 0.16], ], - snow: [1, 0.48, 0.38], - mist: [0.85, 0.36, 0.22], - moss: [0.09, 0.05, 0.04], - gravel: [0.34, 0.19, 0.16], - water: [0.05, 0.025, 0.04], + snow: [1, 0.7, 0.55], + mist: [1, 0.58, 0.4], + moss: [0.1, 0.08, 0.1], + gravel: [0.32, 0.22, 0.22], + water: [0.06, 0.045, 0.08], mirror: 0.7, - glitter: 0.8, - ripple: [0.9, 0.42, 0.18], - petal: [0.95, 0.42, 0.48], - lamps: 0.5, - tint: [0.22, 0.13, 0.1], + glitter: 0.6, + ripple: [1, 0.66, 0.5], + petal: [0.95, 0.52, 0.6], + lamps: 0.45, + tint: [0.26, 0.17, 0.2], night: false, }, night: { style: "rim", - light: [0.2, 0.45, 1], + light: [0.3, 0.62, 1.5], sky: [ [0, [0.004, 0.008, 0.03]], - [0.3, [0.012, 0.02, 0.06]], - [HORIZON, [0.04, 0.06, 0.15]], + [0.3, [0.014, 0.024, 0.07]], + [0.42, [0.035, 0.055, 0.14]], + [HORIZON, [0.07, 0.1, 0.24]], ], orb: { x: 0.6, y: 0.16, r: 0.032, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 130, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, hills: [ [0.025, 0.04, 0.1], [0.008, 0.016, 0.035], ], snow: [0.07, 0.12, 0.3], - mist: [0.04, 0.07, 0.18], + mist: [0.05, 0.09, 0.22], moss: [0.01, 0.024, 0.035], gravel: [0.045, 0.07, 0.15], water: [0.014, 0.045, 0.12], @@ -123,7 +130,7 @@ const LOOKS: Record = { ripple: [0.06, 0.25, 0.55], petal: [0.45, 0.12, 0.35], lamps: 1, - tint: [0.025, 0.04, 0.09], + tint: [0.02, 0.032, 0.075], night: true, }, } @@ -196,6 +203,7 @@ class Zen extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } + private readonly shade: Shade private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] @@ -229,15 +237,20 @@ class Zen extends Canvas { private sparrows: Flier[] = [] // The easter egg: a tanuki with a leaf on its head. t counts scene seconds since it appeared. private tanuki = { wait: eggWait() * TIME_SCALE, t: -1 } + // The maple and cherry crowns, painted once, bend in the gusts, which here blow from the right like the breeze that + // carries the petals. + private trees = new SwayLayer(-1) constructor(settings: Settings) { super() this.activity = settings.activity this.look = LOOKS[settings.time] + if (settings.time === "day") this.frame = 0.4 this.season = settings.season ?? "spring" this.weather = new WeatherLayer(settings.weather ?? (this.season === "winter" ? "snow" : "clear"), settings.time, HORIZON, !settings.weather) + this.shade = sunShade(settings.time, this.look.orb, this.weather.covered) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 5, this.look.clouds.puffy, 0.06, 0.26) + this.clouds = makeClouds(4, "wisp", 0.06, 0.24) const count = { calm: 0, lively: 3, teeming: 6 }[settings.activity] this.koi = Array.from({ length: count }, (_, i) => ({ a: rand(0, TAU), r: 0.32 + (i % 3) * 0.16, dir: i === 4 ? -1 : 1, phase: rand(0, TAU), size: rand(0.85, 1.1), colors: KOI[i] })) const falling = this.season === "spring" || this.season === "autumn" @@ -295,9 +308,10 @@ class Zen extends Canvas { render() { this.hdr.set(this.background) + this.trees.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.weather.covered ? undefined : this.look.orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawWater() for (const r of this.rings) this.drawRing(r) @@ -318,6 +332,8 @@ class Zen extends Canvas { if (this.tanuki.t >= 0) this.drawTanuki() for (const p of this.petals) if (p.state !== 1) this.drawPetal(p) for (const b of this.sparrows) this.shape(bird(b, this.H, 0.02 * this.H, this.paint([0.45, 0.33, 0.22])), this.lighting, 0.4) + // At night the lanterns light the gravel, the bank and whatever stands near them. + if (this.look.night) for (const l of this.lanterns()) lightPool(this.hdr, this.W, this.H, l.x * this.H, l.base * this.H, l.scale * 1.6 * this.H, l.scale * 0.6 * this.H, [1, 0.5, 0.15], 2, 0.01) this.drawFireflies() this.weather.draw(this.hdr, this.W, this.H) this.finish() @@ -343,9 +359,19 @@ class Zen extends Canvas { this.cherry = { x: A - 0.2, y: 0.27 } paintSky(this.hdr, W, H, look.sky, look.orb) this.weather.cover(this.hdr, W, H) + const clear = this.hdr.slice() this.drawHills() + if (look === LOOKS.day) haze(this.hdr, clear, W, H, HORIZON, GROUND, 0.15) const sky = this.hdr.slice() this.drawGround() + // By day, broad warm and cool patches of sun and shade lie over the raked gravel. + if (look === LOOKS.day) mottle(this.hdr, W, H, GROUND, 1, 0.5, [1.08, 1, 0.84], [0.76, 0.84, 0.96]) + // Shadows of the trees, rocks and lanterns on the gravel; the pond's water is redrawn over any that reach it. + const ground = (x: number, y: number, w: number, h: number, tip?: number) => groundShadow(this.hdr, W, H, this.shade, x * H, y * H, w * H, h * H, tip) + ground(0.07 * A, 0.66, 0.1, 0.4, 1.6) + ground(this.cherry.x + 0.08, 0.66, 0.12, 0.38, 2) + for (const r of this.rocks()) ground(r.x + r.r * 0.2, r.y + r.r * 0.15, r.r * 2.2, r.r * 1.4, 0.8) + for (const [i, l] of this.lanterns().entries()) ground(l.x, l.base, l.scale * (i === 0 ? 0.45 : 1), l.scale * (i === 0 ? 1 : 0.8), i === 0 ? 0.9 : 0.8) this.drawBackGarden() // Find the water, note what lies under its edges, and work out its still color with the sky mirrored in it. const p = this.pond @@ -413,6 +439,7 @@ class Zen extends Canvas { this.front.set([black[i], black[i + 1], black[i + 2]], k * 3) this.keep[k] = white[i] - black[i] } + this.trees.seal(this.hdr, W) this.background = this.hdr.slice() } @@ -481,7 +508,7 @@ class Zen extends Canvas { this.blend((y * W + x) * 3, c[0], c[1], c[2], clamp(y + 1 - top, 0, 1)) } } - this.drawPagoda(0.71 * A, 0.482) + this.drawPagoda(A - 0.4, 0.482) } // A three-tiered pagoda on the far ridge, hazed by distance. @@ -516,10 +543,17 @@ class Zen extends Canvas { const rocks = this.rocks() const s0 = 0.011 const k = (0.03 - s0) / (1 - GROUND) + // Lines stay at least 8 pixels apart (two terminal cells), so they read as lines rather than moiré. Nearer than + // where the perspective spacing passes that, they widen as before. + const least = this.cells ? Math.max(s0, 8 / H) : s0 + const near = GROUND + (least - s0) / k for (let y = Math.floor(GROUND * H); y < H; y++) { const v = (y + 0.5) / H - const spacing = s0 + k * (v - GROUND) - const lines = Math.log(spacing / s0) / k / s0 + const perspective = s0 + k * (v - GROUND) + const spacing = Math.max(least, perspective) + // Rings are squashed to 0.42 of their width, so they sit farther apart to stay clear of each other down the screen. + const ring = this.cells ? Math.max(perspective, 6 / (0.42 * H)) : perspective + const lines = v < near ? (v - GROUND) / least : (near - GROUND) / least + Math.log(spacing / least) / k for (let x = 0; x < W; x++) { const u = (x + 0.5) / H const top = GROUND + 0.004 * Math.sin(u * 9) @@ -531,7 +565,7 @@ class Zen extends Canvas { let moss = smoothstep(0.62, 0.6, v + 0.008 * Math.sin(u * 13)) + smoothstep(1.22, 1.15, pd + 0.04 * Math.sin(u * 23)) + smoothstep(0.04, 0.03, sd) for (const r of rocks) { const d = Math.hypot(u - r.x, (v - r.y) / 0.42) - if (d < r.r + 4.4 * spacing) groove = Math.sin(((d - r.r) / spacing) * TAU) + if (d < r.r + 4.4 * perspective + ring) groove = Math.sin(((d - r.r) / ring) * TAU) moss += smoothstep(r.r * 1.12, r.r * 0.95, d) } const grain = (hash2(x, y) - 0.5) * 0.08 @@ -574,8 +608,10 @@ class Zen extends Canvas { const c = this.paint(ROCK) this.shape([ell(r.x * H, (r.y - r.r * 0.55) * H, r.r * H, r.r * 0.75 * H, -0.15, c), ell((r.x + r.r * 0.75) * H, (r.y - r.r * 0.25) * H, r.r * 0.55 * H, r.r * 0.45 * H, 0.2, this.paint([0.4, 0.38, 0.35]))], this.lighting, 0.8) } + this.trees.begin(this.hdr) this.drawMaple() this.drawCherry() + this.trees.end(this.hdr, this.W, H, 0.012 * H, (_x, y) => clamp((0.56 * H - y) / (0.3 * H), 0, 1)) } // A Japanese maple with red leaves in layered tiers, leaning in from the left. @@ -605,7 +641,8 @@ class Zen extends Canvas { const across = (h - 0.5) * 2 parts.push(ell((cx + across * w) * H, (cy + (hash(h * 13) - 0.5) * 0.022 + across * across * 0.012 - shade * 0.007) * H, (0.02 - shade * 0.003) * H, (0.011 - shade * 0.002) * H, (hash(h * 7) - 0.5) * 0.6, this.paint(reds[shade]))) } - this.shape(parts, this.lighting, 0.7) + // Rim light on every small cluster would speckle the crown on terminal cells. + this.shape(parts, this.lighting, this.cells && this.look.style === "rim" ? 0.25 : 0.7) } } @@ -872,7 +909,7 @@ class Zen extends Canvas { if (p.age < 0) return if (p.state === 0) { p.spin += dt * 2 - p.x += (p.vx + 0.012 * Math.sin(this.time * 0.8 + p.spin * 0.3)) * dt + p.x += (p.vx + 0.012 * Math.sin(this.time * 0.8 + p.spin * 0.3) - 0.09 * gust(this.A - p.x, this.time)) * dt p.y += p.vy * dt if (p.x < -0.05) Object.assign(p, this.newPetal(false)) if (p.y < p.land) return @@ -1001,11 +1038,16 @@ class Zen extends Canvas { : h.phase === "in" ? [lerp(-0.25, sx, 1 - (1 - p) ** 2), lerp(0.22, sy, 1 - (1 - p) ** 2), clamp((1 - p) / 0.25, 0, 1)] : [lerp(sx, this.A + 0.3, p * p), lerp(sy, 0.18, p * p), clamp(p / 0.12, 0, 1)] - const S = 0.2 * H + const S = 0.16 * H const P = (u: number, v: number): [number, number] => [x * H + u * S, y * H + v * S] const L = (a: [number, number], b: [number, number]): [number, number] => [lerp(a[0], b[0], fly), lerp(a[1], b[1], fly)] - const hunt = h.phase === "stand" ? smoothstep(0.2, 0.9, 0.5 - 0.5 * Math.cos(h.t * 0.45)) : 0 + const huntAt = (t: number) => (h.phase === "stand" ? smoothstep(0.2, 0.9, 0.5 - 0.5 * Math.cos(t * 0.45)) : 0) + const hunt = huntAt(h.t) + // The crest plume trails the head as it leans out and back. + const sweep = (huntAt(h.t - 0.5) - hunt) * 0.6 + // The wingtips beat a moment behind the arm. const flap = Math.sin(this.time * 2.6) + const lag = Math.sin(this.time * 2.6 - 0.7) const body = this.paint([0.62, 0.66, 0.72]) const neck = this.paint([0.82, 0.82, 0.84]) const wing = this.paint([0.38, 0.42, 0.5]) @@ -1015,13 +1057,20 @@ class Zen extends Canvas { const n2 = L([lerp(0.09, 0.3, hunt), lerp(-0.7, -0.56, hunt)], [0.16, -0.49]) const head = L([lerp(0.14, 0.38, hunt), lerp(-0.8, -0.52, hunt)], [0.22, -0.47]) const beak = L([lerp(0.15, 0.1, hunt), lerp(0.01, 0.11, hunt)], [0.15, 0.015]) - const wingTip: [number, number] = [-0.08, -0.45 - 0.42 * flap] + const wingTip: [number, number] = [-0.08, -0.45 - 0.42 * lag] const wingMid: [number, number] = [-0.03, -0.45 - 0.22 * flap] + // Wading, it now and then lifts a foot and sets it down a little further on; the ankles bend back. + const wade = (side: number) => (h.phase === "stand" ? Math.max(0, Math.sin(h.t * 0.3 + side * 1.6)) ** 10 : 0) + const leg = (side: number): Part[] => { + const hip = L([side * 0.015, -0.33], [-0.1, -0.4 + side * 0.01]) + const foot = L([side * 0.025 + wade(side) * 0.04, -wade(side) * 0.09], [-0.4, -0.37 + side * 0.02]) + return [...limb(P, ...hip, ...foot, 0.17, 0.17, 1, 0.012 * S, 0.01 * S, 0.008 * S, legs), cap(...P(...foot), ...P(foot[0] + L([0.06, 0], [-0.04, 0])[0], foot[1]), 0.006 * S, 0.004 * S, legs)] + } if (fly > 0.3) this.shape([cap(...P(0.03, -0.45), ...P(wingMid[0] + 0.04, wingMid[1] + 0.02), 0.07 * S, 0.05 * S, this.paint([0.28, 0.31, 0.38])), cap(...P(wingMid[0] + 0.04, wingMid[1] + 0.02), ...P(wingTip[0] + 0.05, wingTip[1] + 0.03), 0.05 * S, 0.02 * S, this.paint([0.28, 0.31, 0.38]))], this.lighting, 0.4) this.shape( [ - cap(...P(...L([-0.02, 0], [-0.4, -0.37])), ...P(...L([-0.01, -0.32], [-0.1, -0.4])), 0.011 * S, 0.012 * S, legs), - cap(...P(...L([0.03, 0], [-0.42, -0.4])), ...P(...L([0.01, -0.32], [-0.1, -0.41])), 0.011 * S, 0.012 * S, legs), + ...leg(-1), + ...leg(1), ell(...P(0, L([0, -0.41], [0, -0.42])[1]), 0.17 * S, 0.075 * S, -0.4 * (1 - fly), body), cap(...P(-0.12, L([-0.34, -0.33], [-0.42, -0.42])[1]), ...P(-0.2, L([-0.3, -0.3], [-0.42, -0.42])[1]), 0.04 * S, 0.02 * S, wing), cap(...P(...shoulder), ...P(...n1), 0.042 * S, 0.032 * S, neck), @@ -1029,12 +1078,12 @@ class Zen extends Canvas { cap(...P(...n2), ...P(...head), 0.026 * S, 0.03 * S, neck), ell(...P(...head), 0.042 * S, 0.032 * S, 0, neck), cap(...P(...head), ...P(head[0] + beak[0], head[1] + beak[1]), 0.016 * S, 0.003 * S, this.paint([0.9, 0.72, 0.2])), - cap(...P(head[0] - 0.01, head[1] - 0.02), ...P(head[0] - 0.12, head[1] - 0.01), 0.009 * S, 0.003 * S, this.paint([0.08, 0.08, 0.1])), + cap(...P(head[0] - 0.01, head[1] - 0.02), ...P(head[0] - 0.12 - sweep * 0.3, head[1] - 0.01 - sweep * 0.15), 0.009 * S, 0.003 * S, this.paint([0.08, 0.08, 0.1])), ], this.lighting, 0.6, ) - if (fly > 0.3) this.shape([cap(...P(0.03, -0.44), ...P(...wingMid), 0.08 * S, 0.06 * S, wing), cap(...P(...wingMid), ...P(...wingTip), 0.06 * S, 0.025 * S, wing), cap(...P(...wingTip), ...P(wingTip[0] - 0.08, wingTip[1] + 0.02), 0.03 * S, 0.012 * S, this.paint([0.15, 0.16, 0.2]))], this.lighting, 0.6) + if (fly > 0.3) this.shape([cap(...P(0.03, -0.44), ...P(...wingMid), 0.08 * S, 0.06 * S, wing), cap(...P(...wingMid), ...P(...wingTip), 0.06 * S, 0.025 * S, wing), cap(...P(...wingTip), ...P(wingTip[0] - 0.08, wingTip[1] + 0.02 + (lag - flap) * 0.1), 0.03 * S, 0.012 * S, this.paint([0.15, 0.16, 0.2]))], this.lighting, 0.6) if (fly === 0) this.add(...P(head[0] + 0.02, head[1] - 0.005), 0.25, 0.2, 0.02) } @@ -1060,6 +1109,7 @@ class Zen extends Canvas { const sitting = t >= TANUKI.arrive && t < TANUKI.leave const u0 = t < TANUKI.arrive ? lerp(-0.12, seat, t / TANUKI.arrive) : sitting ? seat : lerp(seat, A + 0.12, (t - TANUKI.leave) / (TANUKI.gone - TANUKI.leave)) const S = 0.12 * H + groundShadow(this.hdr, this.W, H, this.shade, u0 * H, 0.965 * H, (sitting ? 0.4 : 0.65) * S, (sitting ? 0.6 : 0.4) * S) const P = (u: number, v: number): [number, number] => [u0 * H + u * S, 0.965 * H + v * S] const fur = this.paint([0.5, 0.4, 0.28]) const dark = this.paint([0.14, 0.11, 0.09]) @@ -1160,6 +1210,7 @@ class Zen extends Canvas { const S = 0.04 * H const x = pad.x * H const y = (pad.y + 0.003) * H + groundShadow(this.hdr, this.W, H, this.shade, x, y, 0.6 * S, 0.6 * S) const P = (u: number, v: number): [number, number] => [x + u * S, y + v * S] const green = this.paint([0.3, 0.58, 0.15]) const sac = this.frog.croak >= 0 ? Math.max(0, Math.sin(this.frog.croak * Math.PI * 2)) : 0 @@ -1192,8 +1243,7 @@ class Zen extends Canvas { const l = Math.hypot(dx, dy) if (l < 0.004) { d.rest = rand(2, 5) - d.tx = this.pond.x + rand(-0.9, 0.9) * this.pond.rx - d.ty = rand(0.6, 0.82) + ;[d.tx, d.ty] = this.openSpot(this.pond.x - 0.9 * this.pond.rx, this.pond.x + 0.9 * this.pond.rx, 0.6, 0.82) return } if (Math.abs(dx) > 0.002) d.face = Math.sign(dx) @@ -1219,6 +1269,7 @@ class Zen extends Canvas { for (const f of this.fireflies) { const k = Math.pow(Math.max(0, Math.sin(this.time * 1.3 + f.phase * 7)), 6) if (k < 0.02) continue + fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 1.8 * k, 2.4 * k, 0.6 * k, 0.6) this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } @@ -1241,9 +1292,9 @@ export const zen: Wallpaper = { [30, 34, 26], ], sunset: [ - [30, 14, 26], - [36, 16, 18], - [14, 8, 8], + [20, 14, 32], + [38, 24, 26], + [16, 11, 13], ], night: [ [4, 6, 16],