Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ details), and values in between soften it.
- `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`.

`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.

`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.

Expand Down Expand Up @@ -272,7 +279,7 @@ 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,
A wallpaper package imports the API from `opencode-wallpapers/api`, which has `Canvas`, `cap`, `ell`, the creature, 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:

Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// 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 { 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 { TAU, clamp, fbm1, fbm2, hash, hash2, hashString, hsv, lerp, noise1, noise2, rand, smoothstep, type RGB } from "./math"
Expand Down
121 changes: 121 additions & 0 deletions src/creature.ts
Original file line number Diff line number Diff line change
@@ -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))
}
72 changes: 47 additions & 25 deletions wallpapers/beach.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas"
import { limb, stride } from "../src/creature"
import { eggWait } from "../src/egg"
import { bird, flyAway, startle, type Flier } from "../src/flock"
import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math"
Expand Down Expand Up @@ -227,10 +228,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
Expand Down Expand Up @@ -559,6 +560,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
Expand All @@ -568,21 +571,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.
Expand Down Expand Up @@ -642,27 +650,41 @@ 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
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.
Expand Down
Loading
Loading