/* SMALL SUN (third cut). One continuous world in side elevation: a cut-away house, its porch, the hill behind it. Cast and line from SCION 18 (Cartoon Saloon method): authored curves, watercolour wash, tapered brush, IK limbs, planted feet. Deterministic: frame(t) is called in order, t = i/24. Events are logged for the score. */ 'use strict'; const FPS = 24, DT = 1 / FPS, KG = 2, KC = 2.6, END = 117; const cv = document.getElementById('c'), X = cv.getContext('2d'); const WC = document.createElement('canvas'); WC.width = W; WC.height = H; const wx = WC.getContext('2d'); const MC = document.createElement('canvas'); MC.width = W; MC.height = H; const mx = MC.getContext('2d'); const s01 = u => { u = clamp(u); return u * u * (3 - 2 * u); }; const sm = (a, b, t) => s01((t - a) / (b - a)); const hash = i => { const s = Math.sin(i * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; /* ---------- keyframes: [t, {props}] cumulative; numbers & arrays ease (smoothstep, or linear with _e:'lin'); other values snap at their key */ function KF(list) { const out = []; let cur = {}; for (const [t, p] of list) { const { _e, ...q } = p; cur = { ...cur, ...q }; out.push([t, { ...cur }, _e || null]); } return t => { if (t <= out[0][0]) return { ...out[0][1] }; for (let i = 0; i < out.length - 1; i++) { const [t0, a] = out[i], [t1, b, e] = out[i + 1]; if (t < t1) { let u = (t - t0) / (t1 - t0); u = e === 'lin' ? u : u * u * (3 - 2 * u); const r = {}; for (const k in b) { const va = a[k], vb = b[k]; if (typeof va === 'number' && typeof vb === 'number') r[k] = va + (vb - va) * u; else if (Array.isArray(va) && Array.isArray(vb)) r[k] = va.map((v, j) => v + (vb[j] - v) * u); else r[k] = va === undefined ? vb : va; } return r; } } return { ...out[out.length - 1][1] }; }; } /* ---------- events for the score */ const EV = []; let TNOW = 0, TPREV = -1; const ev = (k, o = {}) => EV.push({ t: +TNOW.toFixed(3), k, ...o }); const AT = (tt, k, o) => { if (TPREV < tt && TNOW >= tt) ev(k, o || {}); }; /* ---------- the world */ const SX = 3560; // the flat stone function Gy(x) { // ground height (px, y down). house floor 0, porch 0, three steps, then the hill if (x < 1300) return 0; if (x < 1390) return Math.min(30, Math.ceil((x - 1300) / 30) * 10) * 0.35 + (x - 1300) / 90 * 30 * 0.65; let y = 30 - 590 * s01((x - 1450) / 2850); if (x > 4700) y += 260 * s01((x - 4700) / 1400); return y; } const slope = x => (Gy(x + 4) - Gy(x - 4)) / 8; const STONE = { w: 42, h: 14.5 }; const stoneShape = [[-22, 0], [-21, 6], [-15, 12], [-4, 15.5], [9, 15], [18, 11], [22.5, 4], [20, 0], [8, -0.5], [-10, -0.5]]; /* ---------- time of day: 0 late afternoon, 1 sunset, 2 dusk, 3 night */ function TOD(t) { if (t < 31) return lerp(0.15, 0.85, sm(0, 30, t)); if (t < 85) return t < 48 ? lerp(0.85, 1.25, sm(31, 47, t)) : lerp(1.25, 3.0, sm(46.5, 60, t)); return 3; } const SKY = [['#7f9cba', '#d8c3a0', '#f2d39c'], ['#40507e', '#c9806e', '#f4a35e'], ['#1f2346', '#4b3f6c', '#b8707a'], ['#0a0f22', '#162040', '#2a3158']]; const TINT = ['#fff6ea', '#f8c8a4', '#9488b2', '#5c6896']; function mixc3(arr, tn) { const i = Math.min(2, Math.floor(tn)), u = tn - i; return mixHex(arr[i], arr[i + 1], u); } /* ---------- camera (pure function of t) */ let VIEW = { x: 0, y: 0, z: 1 }; function setCam(ctx, p = 1) { const zp = 1 + (VIEW.z - 1) * p; ctx.setTransform(zp, 0, 0, zp, W / 2 - zp * VIEW.x * p, H / 2 - zp * VIEW.y * p); CAM.z = zp; } function w2s(px, py) { return [W / 2 + VIEW.z * (px - VIEW.x), H / 2 + VIEW.z * (py - VIEW.y)]; } /* ---------- patch of sun through the transom (house, first act) */ const TRANS = { x: 900, y0: -470, y1: -400 }; function sunElev(t) { return (34 - 14 * clamp(t / 15)) * Math.PI / 180; } function rayHit(h, e) { // from the transom at height h going left and down const tn = Math.tan(e), xf = 900 - h / tn; return xf >= 24 ? [xf, 0] : [24, -h + 876 * tn]; } function patchPoly(t) { const e = sunElev(t), A = rayHit(-TRANS.y0, e), B = rayHit(-TRANS.y1, e), P = [A]; if (A[0] === 24 && B[0] > 24) P.push([24, 0]); P.push(B); return P; } function patchCenter(t) { const P = patchPoly(t); let sx = 0; for (const p of P) sx += p[0]; return sx / P.length; } const PATCH_A = t => clamp(1 - sm(14.2, 15.6, t)); /* ======================= TRACKS ======================= */ const gTrack = KF([ [0, { x: 380, face: -1, mode: 'sit', rise: 0, hN: [18, 36], hF: [14, 38], lean: 0.12, nod: 0.22, smile: 0.3, laugh: 0, browUp: 0, mirror: 0, carry: 0, shiver: 0, crouch: 0, eyes: 1 }], [5, { nod: 0.28 }], [9, { nod: 0.18, smile: 0.5 }], [12.4, { nod: -0.05 }], [13.1, { laugh: 1, smile: 1 }], [14.6, { laugh: 0 }], [15.3, {}], [16.1, { rise: 1, hN: null, hF: null, lean: 0, nod: 0, smile: 0.4 }], [16.15, { mode: 'stand' }], [16.3, { face: 1 }], [17.2, { x: 790 }], [17.35, {}], [17.8, { hN: [20, 119], nod: -0.3 }], [17.95, { mirror: 1 }], [18.3, { hN: [14, 76], nod: 0 }], [19.3, { x: 862 }], [19.45, { hN: [24, 74] }], [19.9, { hN: [24, 70] }], [20.2, { hN: null }], [20.5, { x: 880 }], [20.55, { face: -1 }], [20.8, { hN: [15, 80], hF: [11, 78], nod: 0.15 }], [22.2, { hN: [15, 84] }], [24.0, { hN: [17, 78] }], [25.4, { hN: [16, 76], nod: 0.05 }], [26.4, { hN: [15, 82] }], [27.3, { laugh: 1, smile: 1 }], [28.1, { laugh: 0 }], [28.3, { hN: [-6, 70], hF: [-4, 66], browUp: 1, nod: 0.2, smile: 0.2 }], [29.2, {}], [29.4, { hN: [14, 76], browUp: 0 }], [30.0, { hN: [10, 62], hF: [8, 60], nod: 0.35, smile: 0.2 }], [30.55, { nod: 0 }], [30.6, { face: 1 }], [31.0, { browUp: 1, nod: -0.15 }], [31.4, { x: 880, hN: null, hF: null, browUp: 0, nod: 0 }], [32.4, { x: 1240 }], [41.0, { x: 4150, _e: 'lin' }], [41.6, { x: 4190 }], [42.1, { hN: [18, 104], hF: [14, 100], nod: -0.2 }], [42.9, {}], [42.95, { face: -1 }], [43.2, { hN: [16, 74], hF: [12, 72], nod: 0.15 }], [44.2, { hN: [16, 80], hF: [12, 78] }], [45.0, { laugh: 1, smile: 1 }], [46.3, { laugh: 0 }], [46.35, { face: 1 }], [46.6, { hN: [8, 60], hF: null, nod: -0.05, smile: 0.2 }], [48.6, {}], [48.65, { mode: 'kneel', rise: 1 }], [49.6, { rise: 0, hN: [14, 40], hF: [10, 42], lean: 0.05 }], [52.1, {}], [52.15, { face: -1 }], [52.7, { hN: [16, 84], hF: [12, 82], nod: -0.25 }], [54.4, {}], [54.9, { hN: [12, 52], hF: [10, 50], nod: 0.1, laugh: 0.4 }], [55.6, { laugh: 0, smile: 0.5 }], [56.3, { mirror: 0 }], [57.1, { rise: 1, hN: null, hF: null, lean: 0, nod: 0 }], [57.15, { mode: 'stand' }], [60.7, { laugh: 1, smile: 1 }], [62.3, { laugh: 0 }], [63.2, { x: 4190 }], [63.9, { x: 4130, hN: [18, 80], hF: [16, 82], nod: -0.15 }], [64.3, { hN: [18, 76], hF: [16, 77], nod: 0.35, smile: 0.6 }], [66.0, {}], [66.4, { hN: [18, 96], hF: [15, 98], nod: -0.35, smile: 0.4 }], [67.1, { hN: [6, 66], hF: [4, 68], nod: 0.1, shiver: 1, smile: 0 }], [68.3, {}], [68.35, { face: 1 }], [69.1, {}], [69.15, { face: -1 }], [70.2, { x: 4130, shiver: 0.4, hN: null, hF: null }], [72.8, { x: 3462 }], [72.85, { face: 1 }], [72.95, { mode: 'crouch', rise: 1, shiver: 0 }], [73.7, { rise: 0, crouch: 0.9, lean: 0.45, hN: [18, 34], hF: [14, 34], nod: 0.3 }], [74.1, { hN: [27, 24] }], [74.5, { nod: -0.15, smile: 1, browUp: 0.6 }], [74.9, { hF: [24, 25] }], [75.6, { hN: [22, 38], hF: [19, 39], browUp: 0, nod: 0.2 }], [76.2, { hN: [18, 44], lean: 0.12 }], [76.6, { hN: [6, 30], hF: [4, 31], lean: 0.25, browUp: 1, nod: -0.1 }], [77.3, { laugh: 0.6 }], [78.0, { laugh: 0, browUp: 0 }], [78.5, { hN: [24, 17], hF: [21, 17], nod: 0.3, lean: 0.45 }], [78.7, { carry: 1 }], [79.6, { rise: 1, hN: [18, 60], hF: [14, 60], lean: 0.05, nod: 0.25 }], [79.65, { mode: 'stand' }], [79.9, { face: -1 }], [80.2, { x: 3462 }], [84.6, { x: 3150, _e: 'lin' }], [84.98, { x: 3110 }], [85.0, { x: 1580 }], [89.0, { x: 980, _e: 'lin' }], [90.3, { x: 830 }], [91.4, { x: 742 }], [91.6, {}], [91.65, { mode: 'kneel', rise: 1 }], [92.6, { rise: 0, hN: [20, 42], hF: [16, 42], nod: 0.2 }], [94.0, { hN: [38, 57], hF: [34, 57], lean: 0.32 }], [94.15, { carry: 0 }], [94.8, { hN: [16, 40], hF: [12, 42], lean: 0.08, smile: 1, nod: 0.1 }], [97.4, { lean: 0.08, nod: 0.28, hN: [24, 42], hF: [20, 44] }], [99.8, { eyes: 0 }], ]); const granTrack = KF([ [0, { hunch: 0.28, nod: 0.22, hN: [34, 58], hF: [30, 60], rub: 1, smile: 0, eyes: 1, shiver: 0.6 }], [13.4, { smile: 0.5, nod: 0.12 }], [15.2, { smile: 0.2 }], [21, { nod: 0.18 }], [27.25, {}], [27.5, { nod: -0.45, hunch: -0.12, hN: [30, 96], hF: [22, 94], rub: 0, smile: 0, shiver: 0 }], [28.1, {}], [28.6, { nod: 0.2, hunch: 0.15, smile: 1, hN: [36, 70], hF: [26, 66] }], [28.9, { rub: 1.8 }], [31.0, { rub: 0 }], [31.2, {}], [31.9, { hN: [58, 86], nod: -0.1, smile: 0.2 }], [32.9, { hN: [34, 58], hF: [30, 60], nod: 0.25, hunch: 0.3, rub: 1, shiver: 0.6 }], [85, { hunch: 0.36, nod: 0.3, shiver: 1 }], [92.4, { nod: 0.08, shiver: 0.5 }], [93.0, { rub: 0, hN: [46, 66], hF: [40, 64], hunch: 0.05 }], [94.3, { hN: [33, 64], hF: [27, 65], shiver: 0, smile: 0.6 }], [95.2, { eyes: 0, smile: 1, hunch: 0.12, nod: 0.18 }], [96.8, { eyes: 1 }], [97.6, { hN: 'girl' }], [100.5, { eyes: 0 }], ]); /* the cat: built from the sun patch for the scoots */ function catKeys() { const L = [[0, { x: patchCenter(0), yy: null, face: -1, pose: 0, eyes: 0, lookUp: 0, tailUp: 0, flick: 0.3, wig: 0, purr: 1, night: 0 }]]; let last = patchCenter(0); for (const s of [4.0, 7.4, 10.2]) { const to = patchCenter(s + 1.4); L.push([s, { x: last }], [s + 0.35, { pose: 2, eyes: 1 }], [s + 1.15, { x: to }], [s + 1.45, { pose: 0 }], [s + 1.9, { eyes: 0 }]); last = to; } L.push([12.2, { x: last }], [12.5, { pose: 2, eyes: 1 }], [13.2, { x: 70 }], [13.5, { pose: 1, lookUp: 0.9, tailUp: 0.6, flick: 1.2 }], [15.5, {}], [16.1, { lookUp: 0.1, flick: 2 }], [16.3, { face: 1 }], [16.5, { pose: 2 }], [17.5, { x: 250 }], [17.8, { pose: 0, lookUp: 0, flick: 0.6 }], [20.8, { pose: 0, eyes: 1 }], [21.0, { face: 1 }], [21.6, { pose: 3, lookUp: 0.1 }], [21.7, { wig: 1 }], [22.3, { wig: 0, x: 250 }], [22.8, { x: 345, _e: 'lin' }], [23.1, {}], [23.15, { face: -1 }], [23.4, { wig: 1 }], [24.0, { wig: 0, x: 345 }], [24.5, { x: 185, _e: 'lin' }], [24.75, {}], [24.8, { face: 1, pose: 2.6 }], [25.6, { x: 540 }], [25.8, { pose: 3 }], [26.0, { wig: 1 }], [26.8, { wig: 0, x: 540, yy: 0 }], [27.4, { x: 652, yy: -116, _e: 'lin' }], [27.9, { pose: 0 }], [28.3, { eyes: 0 }], [29.9, {}], [30.4, { pose: 0.25, eyes: 1, lookUp: 0.35 }], [32.1, { x: 652, yy: -116 }], [32.6, { x: 780, yy: 0, _e: 'lin' }], [32.7, { pose: 2.6, yy: null, lookUp: 0 }], [34.4, { x: 1300 }], [37.0, { x: 3100, _e: 'lin' }], [37.35, { x: 3150 }], [37.6, { pose: 1 }], [37.65, { face: -1 }], [38.5, {}], [38.55, { face: 1 }], [38.8, { pose: 2.6 }], [39.4, { x: 3536, pose: 2, lookUp: -0.45 }], [40.0, { x: 3541, lookUp: -0.3 }], [40.15, { pose: 2.6, lookUp: 0 }], [41.6, { x: 4060 }], [41.9, { x: 4080, pose: 1 }], [43.0, {}], [43.05, { face: -1 }], [43.5, { pose: 3, lookUp: -0.2 }], [44.0, { pose: 1, lookUp: 0.1 }], [44.25, { eyes: 0, lookUp: 0.3 }], [44.95, { lookUp: 0.5 }], [45.05, { lookUp: -0.5 }], [45.3, { lookUp: 0.05 }], [45.5, { eyes: 1 }], [45.85, {}], [45.9, { face: 1, flick: 2 }], [48, { night: 0.5 }], [53.2, { night: 1 }], [53.5, {}], [53.55, { face: -1 }], [53.9, { lookUp: -0.35 }], [54.6, { lookUp: -0.05, pose: 2, flick: 1.5 }], [54.8, { x: 4080 }], [56.6, { x: 3900 }], [56.9, { pose: 2 }], [56.95, { face: 1 }], [57.0, { pose: 3, lookUp: 0.5 }], [57.1, { wig: 1 }], [57.6, { wig: 0, x: 3900 }], [58.1, { x: 3975, _e: 'lin' }], [58.6, { pose: 2, lookUp: 0.8 }], [59.0, { x: 3975 }], [59.35, { x: 3990, _e: 'lin' }], [59.8, { pose: 1, lookUp: 0.3 }], [60.5, { lookUp: 0.05 }], [60.9, { lookUp: -0.25, flick: 0.1 }], [62.7, {}], [62.9, { lookUp: 0.9, pose: 2, flick: 1.6 }], [63.0, { x: 3990 }], [63.4, { x: 4000, _e: 'lin' }], [63.8, { lookUp: 0 }], [63.85, { face: -1 }], [64.0, { x: 4000 }], [65.6, { x: 3600 }], [65.62, { yy: 'g' }], [66.0, { x: 3566, yy: 'stone' }], [66.3, { pose: 0 }], [66.8, { eyes: 0, flick: 0.4 }], [76.1, {}], [76.25, { eyes: 1, lookUp: -0.2, flick: 2.5 }], [77.1, { eyes: 0, lookUp: 0, flick: 0.4 }], [78.7, { yy: 'carry' }], [95.4, { pose: 0.35 }], [96.4, { pose: 0 }]); return L; } let cTrack; const JUMPS = [[22.3, 22.8, 55], [24.0, 24.5, 50], [26.8, 27.4, 70], [32.1, 32.6, 26], [57.6, 58.1, 80], [59.0, 59.35, 34], [63.0, 63.4, 46]]; /* mirror spot: on (0..1), position, cold */ const spotTrack = KF([ [0, { on: 0, x: 330, y: 0, cold: 0, r: 1 }], [20.6, { on: 0, x: 330 }], [20.9, { on: 1 }], [21.4, { x: 315 }], [22.0, { x: 345 }], [22.62, { x: 345 }], [22.72, { x: 185 }], [24.3, { x: 190 }], [24.42, { x: 470 }], [25.4, { x: 520 }], [26.4, { x: 652, y: -112 }], [28.8, { x: 650, y: -114 }], [29.6, { on: 0 }], [42.3, { on: 0, x: 4020, y: 'g' }], [43.3, { on: 0 }], [43.55, { on: 1 }], [44.0, { x: 4040 }], [44.15, { y: 'cat' }], [46.6, { on: 1 }], [47.6, { on: 0 }], [53.0, { on: 0, x: 4040, y: 'g', cold: 1 }], [53.4, { on: 0.8 }], [55.2, { on: 0.8, x: 4060 }], [55.9, { on: 0 }], ]); /* fireflies */ const FF = []; for (let i = 0; i < 34; i++) FF.push({ x0: 3700 + hash(i) * 760, h: 30 + hash(i + 50) * 120, ph: hash(i + 90) * 6.28, ax: 18 + hash(i + 7) * 30, fa: 0.4 + hash(i + 9) * 0.5, born: 55.6 + hash(i + 11) * 3.5, blink: 0.8 + hash(i + 13) * 1.4 }); function ffPos(f, t) { const age = t - f.born; return [f.x0 + f.ax * Math.sin(f.fa * t + f.ph) + Math.sin(t * 0.31 + f.ph) * 16, Gy(f.x0) - Math.min(f.h, age * 40) - 10 * Math.sin(f.fa * 0.7 * t + f.ph * 2)]; } function ffOn(f, t) { const p = (t * f.blink / 2.4 + f.ph) % 1; return p < 0.25 ? Math.sin(p / 0.25 * Math.PI) : 0.06; } /* ---------- state */ let girl, cat, last = {}, HOUSE = null; function reset() { girl = newGirl(); cat = newCat(); last = { catHead: [0, 0], girlHead: [0, 0], hands: [[0, 0], [0, 0]] }; EV.length = 0; TPREV = -1; } /* ======================= PAINTING ======================= */ function stonesRow(ctx, x0, x1, y0, y1, seed) { let x = x0, k = 0; for (let row = 0; row < 2; row++) { x = x0 - row * 22; const yy = y0 + row * (y1 - y0) / 2, hh = (y1 - y0) / 2; while (x < x1) { const w = 34 + hash(seed + k) * 26; const pts = [[x + 3, yy + 3], [x + w - 3, yy + 2], [x + w - 1, yy + hh - 3], [x + 2, yy + hh - 2]]; ink(ctx, crSample(pts, true, 4), { fill: mixHex('#5d5650', '#7a7068', hash(seed + k + 3)), line: '#2a2522', lw: 1.3, seed: seed + k, tex: 1, texA: 0.45 }); x += w; k++; } } } function spiral(ctx, cx, cy, r, turns, w, col, seed, dir = 1) { const pts = []; for (let i = 0; i <= 40; i++) { const u = i / 40, a = dir * u * turns * 6.283, rr = r * (1 - u * 0.92); pts.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr]); } brush(ctx, pts, false, w, col, seed); } function tree(ctx, x, y, r, seed, col = '#2f5a52') { const trunk = [[x - 7, y], [x - 5, y - r * 0.9], [x + 5, y - r * 0.9], [x + 8, y]]; ink(ctx, trunk, { fill: '#3a2a20', line: '#160e0a', lw: 1.5, seed, tex: 1 }); const can = []; for (let k = 0; k < 11; k++) { const a = k / 11 * 6.283; const rr = r * (0.9 + 0.12 * hash(seed + k)); can.push([x + Math.cos(a) * rr, y - r * 1.55 + Math.sin(a) * rr * 0.92]); } ink(ctx, crSample(can, true, 6), { fill: col, line: mixHex(col, '#000000', 0.55), lw: 2, seed, tex: 0, texA: 0.5 }); for (let k = 0; k < 4; k++) spiral(ctx, x + (hash(seed + k * 3) - 0.5) * r * 1.1, y - r * 1.55 + (hash(seed + k * 3 + 1) - 0.5) * r * 1.0, r * (0.22 + hash(seed + k) * 0.12), 1.6, 1.6, mixHex(col, '#000000', 0.35), seed + k, k % 2 ? 1 : -1); } function buildHouse() { // static parts, rasterised at 2x for crisp zooms const ox = -150, oy = -900, sc = 2, c = document.createElement('canvas'); c.width = 1650 * sc; c.height = 1000 * sc; const g = c.getContext('2d'); g.setTransform(sc, 0, 0, sc, -ox * sc, -oy * sc); CAM.z = 1; // back wall ink(g, [[0, -525], [900, -525], [900, 0], [0, 0]], { fill: '#7e2f26', line: '#3a1410', lw: 0, seed: 1, tex: 0, texA: 0.6 }); g.save(); g.beginPath(); g.rect(0, -525, 900, 410); g.clip(); for (let j = 0; j < 7; j++) for (let i = 0; i < 16; i++) { const cx = 30 + i * 58 + (j % 2) * 29, cy = -490 + j * 56; spiral(g, cx, cy, 9, 1.4, 1.4, 'rgba(214,150,74,0.55)', i * 7 + j, 1); spiral(g, cx + 12, cy + 6, 6, 1.2, 1.1, 'rgba(214,150,74,0.4)', i * 7 + j + 3, -1); } g.restore(); // wainscot ink(g, [[0, -118], [900, -118], [900, 0], [0, 0]], { fill: '#3e4a3c', line: '#1a2019', lw: 0, seed: 2, tex: 1, texA: 0.55 }); for (let i = 0; i < 30; i++) brush(g, [[i * 30 + 15, -114], [i * 30 + 15, -4]], false, 1.2, 'rgba(15,20,14,0.6)', 30 + i); ink(g, [[0, -124], [900, -124], [900, -114], [0, -114]], { fill: '#6b4a30', line: '#2b1a10', lw: 1.2, seed: 3, tex: 1 }); // window in the back wall (left as a hole: the real sky shows through) // hearth ink(g, crSample([[24, 0], [20, -250], [70, -300], [190, -300], [238, -250], [236, 0]], true, 6), { fill: '#7a726a', line: '#2c2724', lw: 2, seed: 4, tex: 1, texA: 0.6 }); for (let i = 0; i < 18; i++) { const a = i / 18; brush(g, [[30 + hash(i) * 190, -20 - hash(i + 4) * 260], [50 + hash(i) * 190, -18 - hash(i + 4) * 260]], false, 1.2, 'rgba(40,34,30,0.5)', 40 + i); } ink(g, crSample([[66, 0], [64, -150], [96, -190], [164, -190], [196, -150], [194, 0]], true, 6), { fill: '#16100d', line: '#050302', lw: 1.5, seed: 5, tex: 1, texA: 0.4 }); ink(g, crSample([[84, 0], [100, -14], [130, -20], [160, -12], [178, 0]], true, 4), { fill: '#59534e', line: '#2a2522', lw: 1.2, seed: 6, tex: 1 }); ink(g, [[6, -318], [256, -318], [256, -302], [6, -302]], { fill: '#5b3a22', line: '#22140b', lw: 1.6, seed: 7, tex: 1 }); // jug & bowls on the mantel ink(g, crSample([[40, -318], [36, -350], [46, -372], [60, -372], [70, -350], [66, -318]], true, 5), { fill: '#c9b89a', line: '#4a3e30', lw: 1.4, seed: 8, tex: 2 }); ink(g, crSample([[150, -318], [146, -330], [190, -330], [186, -318]], true, 3), { fill: '#3e5d6e', line: '#16242c', lw: 1.2, seed: 9, tex: 2 }); // shelf by the door ink(g, [[716, -234], [890, -234], [890, -222], [716, -222]], { fill: '#5b3a22', line: '#22140b', lw: 1.5, seed: 10, tex: 1 }); for (const bx of [730, 870]) ink(g, [[bx - 4, -222], [bx + 4, -222], [bx + 4, -200], [bx - 4, -210]], { fill: '#4a2f1c', line: '#22140b', lw: 1.1, seed: bx, tex: null }); ink(g, crSample([[742, -234], [738, -262], [748, -280], [762, -280], [772, -262], [768, -234]], true, 5), { fill: '#a8452e', line: '#4c1a10', lw: 1.3, seed: 11, tex: 2 }); // footstool ink(g, [[350, -52], [416, -52], [416, -42], [350, -42]], { fill: '#6b4a30', line: '#2b1a10', lw: 1.4, seed: 12, tex: 1 }); for (const lx of [356, 402]) ink(g, [[lx, -42], [lx + 8, -42], [lx + 10, 0], [lx - 2, 0]], { fill: '#5b3a22', line: '#22140b', lw: 1.2, seed: lx, tex: null }); // rug ink(g, crSample([[180, 2], [560, 2], [568, 8], [172, 8]], true, 3), { fill: '#355a4c', line: '#14241b', lw: 1.2, seed: 13, tex: 0 }); // ceiling beam and rafter ends ink(g, [[-40, -545], [960, -545], [960, -522], [-40, -522]], { fill: '#4a2f1c', line: '#1a0f08', lw: 1.6, seed: 14, tex: 1 }); // left wall section (the cut) ink(g, [[-40, -545], [0, -545], [0, 0], [-40, 0]], { fill: '#3a2a2a', line: '#140c0c', lw: 1.6, seed: 15, tex: 1, texA: 0.5 }); // right wall section with transom and doorway (holes) ink(g, [[900, -545], [940, -545], [940, -472], [900, -472]], { fill: '#3a2a2a', line: '#140c0c', lw: 1.4, seed: 16, tex: 1 }); ink(g, [[900, -398], [940, -398], [940, -382], [900, -382]], { fill: '#3a2a2a', line: '#140c0c', lw: 1.4, seed: 17, tex: 1 }); for (const bx of [912, 928]) ink(g, [[bx - 2, -472], [bx + 2, -472], [bx + 2, -398], [bx - 2, -398]], { fill: '#2a1c14', line: null, lw: 0, seed: bx, tex: null }); // carved door post (as in the reference: pale, with spirals) ink(g, [[940, -545], [962, -545], [962, 0], [940, 0]], { fill: '#c9a06a', line: '#4a2d18', lw: 1.6, seed: 18, tex: 1 }); for (let j = 0; j < 12; j++) spiral(g, 951, -525 + j * 44, 6, 1.3, 1.1, 'rgba(90,55,30,0.7)', 50 + j, j % 2 ? 1 : -1); // foundation under the house and porch skirt stonesRow(g, -40, 960, 4, 66, 100); ink(g, [[962, 14], [1300, 14], [1300, 70], [962, 70]], { fill: '#2b1d16', line: '#120b07', lw: 1.2, seed: 19, tex: 1 }); for (let i = 0; i < 12; i++) { brush(g, [[970 + i * 28, 16], [990 + i * 28, 68]], false, 1.2, 'rgba(140,100,70,0.5)', 60 + i); brush(g, [[990 + i * 28, 16], [970 + i * 28, 68]], false, 1.2, 'rgba(140,100,70,0.5)', 80 + i); } // porch boards ink(g, [[940, 0], [1306, 0], [1306, 16], [940, 16]], { fill: '#6d4f3a', line: '#2b1c14', lw: 1.5, seed: 20, tex: 1 }); // steps for (let k = 0; k < 3; k++) ink(g, [[1300 + k * 30, 10 * k], [1334 + k * 30, 10 * k], [1334 + k * 30, 10 * k + 10], [1300 + k * 30, 10 * k + 10]], { fill: '#5e4432', line: '#2b1c14', lw: 1.2, seed: 21 + k, tex: 1 }); // posts and rail ink(g, [[1262, -478], [1282, -476], [1282, 0], [1262, 0]], { fill: '#5e4432', line: '#22140b', lw: 1.6, seed: 24, tex: 1 }); ink(g, [[962, -98], [1264, -98], [1264, -88], [962, -88]], { fill: '#5e4432', line: '#22140b', lw: 1.4, seed: 25, tex: 1 }); for (let i = 0; i < 10; i++) brush(g, [[980 + i * 29, -88], [980 + i * 29, 0]], false, 3, '#3a281c', 90 + i); // roof: thatch, with a porch lean-to const roof = [[-120, -522], [400, -880], [990, -548], [1340, -478], [1344, -458], [990, -520], [-120, -500]]; ink(g, roof, { fill: '#5b4636', line: '#20160e', lw: 2.4, seed: 26, tex: 0, texA: 0.5 }); g.save(); polyPath(g, roof); g.clip(); for (let i = 0; i < 70; i++) { const xx = -120 + i * 22; brush(g, [[xx, -900], [xx - 30, -440]], false, 1.4, 'rgba(30,20,12,0.45)', 200 + i); } for (let i = 0; i < 40; i++) { const xx = -100 + i * 37; brush(g, [[xx, -880 + hash(i) * 300], [xx + 18, -870 + hash(i) * 300]], false, 1.3, 'rgba(210,170,110,0.35)', 260 + i); } g.restore(); // fringe of the eave for (let i = 0; i < 70; i++) { const u = i / 69, px = lerp(990, 1340, u), py = lerp(-520, -458, u); brush(g, [[px, py], [px - 2, py + 9 + hash(i) * 6]], false, 1.6, '#3a2a1c', 300 + i); } for (let i = 0; i < 60; i++) { const u = i / 59, px = lerp(-120, 400, u) + 0, py = -502; if (px > -120) brush(g, [[px, py], [px - 1, py + 8 + hash(i + 9) * 6]], false, 1.4, '#3a2a1c', 380 + i); } return { c, ox, oy, sc }; } function drawSky(t, tn) { X.setTransform(1, 0, 0, 1, 0, 0); const s = [0, 1, 2].map(k => mixc3(SKY.map(a => a[k]), tn)); const g = X.createLinearGradient(0, 0, 0, H); g.addColorStop(0, s[0]); g.addColorStop(0.55, s[1]); g.addColorStop(1, s[2]); X.fillStyle = g; X.fillRect(0, 0, W, H); const st = sm(1.7, 2.7, tn); if (st > 0) for (let i = 0; i < 170; i++) { X.fillStyle = `rgba(236,240,255,${st * (0.3 + 0.6 * hash(i)) * (0.75 + 0.25 * Math.sin(t * 2 + i))})`; X.fillRect(hash(i + 1) * W, hash(i + 2) * H * 0.75, 1.6, 1.6); } // the sun (layer 0.08): sinks slowly, then sets behind the far ridge; climbing the hill lifts the horizon back below it const sunY = t < 42 ? -99 + (t - 28.8) * 0.55 : -91.7 + (t - 42) * 9.5; if (t < 50) { const zp = 1 + (VIEW.z - 1) * 0.08, sxp = W / 2 + zp * (560 + 260 * sm(31, 41, t) - VIEW.x * 0.08), syp = H / 2 + zp * (sunY - VIEW.y * 0.08); const sg = X.createRadialGradient(sxp, syp, 6, sxp, syp, 240); sg.addColorStop(0, 'rgba(255,236,190,0.95)'); sg.addColorStop(0.18, 'rgba(255,196,120,0.45)'); sg.addColorStop(1, 'rgba(255,170,90,0)'); X.fillStyle = sg; X.beginPath(); X.arc(sxp, syp, 240, 0, 7); X.fill(); X.fillStyle = '#fff3d4'; X.beginPath(); X.arc(sxp, syp, 22, 0, 7); X.fill(); SUN_S = [sxp, syp]; } else SUN_S = null; // the moon: PHOTOGRAPHED (archive plate, matted), layer 0.05 const mo = sm(48.5, 53, t) + (t > 84 ? 1 : 0); if (mo > 0.01) { const zp = 1 + (VIEW.z - 1) * 0.05, my = t > 84 ? -250 : lerp(-40, -250, sm(48.5, 58, t)); const mxp = W / 2 + zp * (-120 - VIEW.x * 0.05 + (t > 84 ? 70 : 0)), myp = H / 2 + zp * (my - VIEW.y * 0.05), r = 58; const mg = X.createRadialGradient(mxp, myp, r * 0.8, mxp, myp, r * 3); mg.addColorStop(0, `rgba(200,215,255,${0.22 * Math.min(1, mo)})`); mg.addColorStop(1, 'rgba(200,215,255,0)'); X.fillStyle = mg; X.beginPath(); X.arc(mxp, myp, r * 3, 0, 7); X.fill(); X.globalAlpha = Math.min(1, mo); X.drawImage(IMG.moon, mxp - r, myp - r, r * 2, r * 2); X.globalAlpha = 1; MOON_S = [mxp, myp]; } else MOON_S = null; } let SUN_S = null, MOON_S = null; function farLayers(ctx, t) { // far ridge 0.22 setCam(ctx, 0.22); const ridge = [[-1600, 400]]; for (let i = 0; i <= 50; i++) { const xx = -1600 + i * 90; ridge.push([xx, -150 - 34 * Math.sin(i * 0.55) - 16 * Math.sin(i * 1.7 + 1) + (i > 30 ? (i - 30) * 2 : 0)]); } ridge.push([2900, 400]); ink(ctx, ridge, { fill: '#8e90ad', line: '#4a4c66', lw: 1.6, seed: 500, tex: 0, texA: 0.35 }); // middle hills 0.5, with spiral trees setCam(ctx, 0.5); const hill = [[-900, 600]]; for (let i = 0; i <= 60; i++) { const xx = -900 + i * 80; hill.push([xx, -40 - 50 * Math.sin(i * 0.33 + 0.4) - 18 * Math.sin(i * 0.9) - i * 1.6]); } hill.push([3900, 600]); ink(ctx, hill, { fill: '#5e7360', line: '#2b3a2e', lw: 1.8, seed: 501, tex: 0, texA: 0.45 }); for (let i = 0; i < 11; i++) { const xx = -500 + i * 390 + hash(i + 70) * 120; const yy = -40 - 50 * Math.sin(((xx + 900) / 80) * 0.33 + 0.4) - 18 * Math.sin(((xx + 900) / 80) * 0.9) - ((xx + 900) / 80) * 1.6 + 6; tree(ctx, xx, yy, 34 + hash(i + 40) * 16, 600 + i, '#33584e'); } } function nearGround(ctx, t) { setCam(ctx, 1); const x0 = VIEW.x - W / VIEW.z / 2 - 60, x1 = VIEW.x + W / VIEW.z / 2 + 60; const pts = [[Math.max(1240, x0), 500]]; for (let xx = Math.max(1240, x0); xx <= x1; xx += 16) pts.push([xx, Gy(xx)]); pts.push([x1, 500]); if (x1 > 1240) { ink(ctx, pts, { fill: '#7c7a44', line: '#33331a', lw: 2, seed: 700, tex: 0, texA: 0.5, anchor: [0, 0] }); // a darker earth band below the turf const band = pts.slice(1, -1).map(p => [p[0], p[1] + 26]); ink(ctx, [...band, ...band.slice().reverse().map(p => [p[0], p[1] + 400])], { fill: '#4b4a2e', line: null, lw: 0, seed: 701, tex: 1, texA: 0.4 }); // grass tufts, swaying in phase with the wind for (let xx = Math.ceil(Math.max(1400, x0) / 13) * 13; xx < x1; xx += 13) { const y = Gy(xx), h = 9 + hash(xx) * 14, sw = Math.sin(t * 1.6 + xx * 0.012) * 3 + Math.sin(t * 2.7 + xx) * 1; brush(ctx, [[xx, y + 2], [xx + sw * 0.4, y - h * 0.55], [xx + sw, y - h]], false, 2.2, hash(xx + 1) > 0.5 ? '#5b5c2c' : '#93904e', xx); } } // earth under the house if (x0 < 1340) ink(ctx, [[-400, 60], [1300, 60], [1300, 40], [1400, 40], [1400, 600], [-400, 600]], { fill: '#3a3020', line: null, lw: 0, seed: 702, tex: 1, texA: 0.4 }); // world trees for (const [tx, r, sd] of [[-300, 120, 801], [1720, 70, 802], [2860, 86, 803], [4620, 96, 804], [5100, 80, 805]]) if (tx > x0 - 300 && tx < x1 + 300) tree(ctx, tx, Gy(tx) + 4, r, sd); } function drawStone(ctx, cx, cy, ang, warm) { const sc = KG * 0.95, pts = crSample(stoneShape, true, 5).map((p, i) => rot([cx + p[0] * sc + noise1(900, i * 0.7) * 1.6, cy - p[1] * sc + noise1(901, i * 0.9) * 1.4], [cx, cy], ang)); ink(ctx, pts, { fill: '#6c6862', line: '#25221f', lw: 2, seed: 900, tex: 1, texA: 0.7, anchor: [cx, cy] }); ctx.save(); polyPath(ctx, pts); ctx.clip(); for (let i = 0; i < 60; i++) { const px = cx + (hash(i + 900) - 0.5) * 84, py = cy - hash(i + 960) * 30; ctx.fillStyle = hash(i + 990) > 0.5 ? 'rgba(20,18,16,0.55)' : 'rgba(210,205,195,0.45)'; ctx.fillRect(px, py, 1.8, 1.8); } brush(ctx, [[cx - 20, cy - 4], [cx - 8, cy - 12], [cx + 2, cy - 9]].map(p => rot(p, [cx, cy], ang)), false, 1.2, 'rgba(25,22,20,0.7)', 905); ctx.restore(); brush(ctx, [[cx - 24, cy - 22], [cx + 5, cy - 28], [cx + 26, cy - 21]].map(p => rot(p, [cx, cy], ang)), false, 1.4, 'rgba(255,240,210,0.35)', 901); } function drawMirror(ctx, p, ang, held, glint) { const c = [p[0] + Math.cos(ang) * 10, p[1] + Math.sin(ang) * 10]; brush(ctx, [p, c], false, 4.5, '#6b4a30', 950); ink(ctx, circlePts(c, 9, 14, 1, 1.1), { fill: '#b8aa86', line: '#3a2a18', lw: 1.6, seed: 951, tex: null }); ink(ctx, circlePts(c, 6.4, 14, 1, 1.1), { fill: glint > 0.05 ? '#fff6dc' : '#9fb4c6', line: null, lw: 0, seed: 952, tex: null }); return c; } function breath(ctx, p, age, dir, size = 1) { if (age < 0 || age > 1.7) return; const u = age / 1.7; for (let k = 0; k < 3; k++) { const r = (4 + u * 16 + k * 3) * size, px = p[0] + dir * (6 + u * 26 + k * 5) * size, py = p[1] - u * 8 - k * 3; ctx.fillStyle = `rgba(232,236,245,${0.32 * (1 - u) * (1 - k * 0.25)})`; ctx.beginPath(); ctx.ellipse(px, py, r, r * 0.65, 0, 0, 7); ctx.fill(); } } /* ======================= THE FRAME ======================= */ function camAt(t) { const g = gTrack(t); let c; if (t < 3) c = { x: 560, y: -330, z: 0.96 }; else if (t < 15.5) { const u = sm(3, 9, t); c = { x: lerp(560, 330, u), y: lerp(-330, -170, u), z: lerp(0.96, 1.55, u) }; } else if (t < 31.2) { const u = sm(15.3, 18.5, t); c = { x: lerp(330, 560, u), y: lerp(-170, -250, u), z: lerp(1.55, 1.2, u) }; if (t > 29.5) { const v = sm(29.5, 31.2, t); c.x = lerp(c.x, 820, v); c.z = lerp(1.2, 1.35, v); c.y = lerp(-250, -230, v); } } else if (t < 42) { const v = sm(31.2, 33, t); c = { x: lerp(820, g.x + 260, v), y: lerp(-230, Gy(g.x) - 170, v), z: lerp(1.35, 1.2, v) }; if (t > 39) { const w = sm(39, 42, t); c.x = lerp(c.x, 4130, w); c.y = lerp(c.y, -680, w); c.z = lerp(1.2, 1.4, w); } } else if (t < 47) c = { x: 4130, y: -680 + 20 * sm(43, 45, t), z: 1.4 + 0.25 * sm(43, 44.2, t) * (1 - sm(45.6, 47, t)) }; else if (t < 56.5) { const u = sm(48, 53, t); c = { x: lerp(4130, 4010, u), y: lerp(-680, -760, u), z: lerp(1.4, 1.0, u) }; } else if (t < 69.5) { const u = sm(56.5, 58, t); c = { x: lerp(4010, 4050, u), y: lerp(-760, -660, u), z: lerp(1.0, 1.45, u) }; if (t > 63.5) { const v = sm(63.5, 65, t); c.x = lerp(c.x, 4120, v); c.y = lerp(c.y, -680, v); c.z = lerp(c.z, 1.7, v); } if (t > 60.2 && t < 63.6) { const w = sm(60.2, 61, t) * (1 - sm(62.7, 63.6, t)); c.x = lerp(c.x, 4080, w); c.y = lerp(c.y, Gy(4080) - 150, w); c.z = lerp(c.z, 1.75, w); } if (t > 66.6) { const v = sm(66.6, 68, t); c.z = lerp(c.z, 1.35, v); c.x = lerp(c.x, 4040, v); } } else if (t < 85) { const v = sm(69.5, 72.6, t); c = { x: lerp(4040, 3620, v), y: lerp(-660, -560, v), z: lerp(1.35, 1.6, v) }; if (t > 73.6 && t < 79) { const w = sm(73.6, 74.6, t) * (1 - sm(77.4, 79, t)); c.z = lerp(c.z, 2.1, w); c.x = lerp(c.x, 3540, w); c.y = lerp(c.y, Gy(3540) - 95, w); } if (t > 79.6) { const w = sm(79.6, 82, t); c.x = lerp(c.x, g.x - 120, w); c.y = lerp(c.y, Gy(g.x) - 200, w); c.z = lerp(c.z, 1.15, w); } } else if (t < 102) { const v = sm(85, 86, t); c = { x: g.x - 140, y: -260, z: 1.15 }; if (t > 88) { const w = sm(88, 91.5, t); c.x = lerp(c.x, 690, w); c.y = lerp(-260, -190, w); c.z = lerp(1.15, 1.75, w); } if (t > 97) { const w = sm(97, 101, t); c.z = lerp(1.75, 2.2, w); c.y = lerp(-190, -170, w); c.x = lerp(690, 680, w); } } else { const w = sm(102, 110, t); c = { x: lerp(680, 760, w), y: lerp(-170, -420, w), z: lerp(2.2, 0.62, w) }; } return c; } function girlQ(t) { const g = gTrack(t), h = 1 / 48, g0 = gTrack(t - h), g1 = gTrack(t + h); let vx = (g1.x - g0.x) / (2 * h); if (Math.abs(vx) > 900) vx = 0; const run = Math.abs(vx) > 220, moving = Math.abs(vx) > 14 && (g.mode === 'stand'); const stride = run ? 62 : 34; const Q = { x: g.x, y: g.x > 1290 ? Gy(g.x) : 0, K: KG, face: g.face, mode: moving ? 'gait' : g.mode, rise: g.rise, crouch: g.crouch, speed: Math.abs(vx) / KG / stride, stride, duty: run ? 0.36 : 0.62, lift: run ? 10 : 6, lean: g.lean + (run ? 0.16 : moving ? 0.04 : 0), flare: run ? 0.9 : 0.5, nod: g.nod + g.laugh * 0.07 * Math.sin(t * 26), bob: g.laugh * 1.2 * Math.abs(Math.sin(t * 13)), mouth: g.laugh * (0.6 + 0.4 * Math.abs(Math.sin(t * 13))), smile: g.smile, browUp: g.browUp * 0.8, handN: g.hN, handF: g.hF, scarf: 1, wind: t > 31 && t < 85 ? 0.5 + 0.5 * Math.sin(t * 0.7) : 0, blink: g.eyes < 0.5 ? 1 : ((t * 0.31 + 0.2) % 1 < 0.035 ? 1 : 0) }; if (g.shiver > 0) Q.x += Math.sin(t * 47) * 0.8 * g.shiver; Q.onStep = () => ev('step', { who: 'girl', surf: g.x < 1300 ? 'wood' : 'grass', run: run ? 1 : 0 }); return { Q, g, vx }; } let IMG = {}; async function load() { await loadTextures('tex/'); IMG.moon = new Image(); IMG.moon.src = 'moon.png'; await IMG.moon.decode(); await Promise.all(['40px FellSC', 'italic 24px Fell'].map(f => document.fonts.load(f))); HOUSE = buildHouse(); cTrack = KF(catKeys()); reset(); } function catY(c, t, gq) { if (c.yy === null || c.yy === undefined || c.yy === 'g') return Gy(c.x); if (c.yy === 'stone') return Gy(SX) - STONE.h * KG + 1; if (c.yy === 'carry') return STONE_POS[1] - STONE.h * KG + 1; return c.yy; } let STONE_POS = [SX, 0]; function frame(t) { TNOW = t; const tn = TOD(t); VIEW = camAt(t); drawSky(t, tn); // ---------- world canvas wx.setTransform(1, 0, 0, 1, 0, 0); wx.clearRect(0, 0, W, H); farLayers(wx, t); nearGround(wx, t); setCam(wx, 1); // house (static raster + dynamic door, mirror) const H0 = HOUSE; wx.drawImage(H0.c, H0.ox, H0.oy, H0.c.width / H0.sc, H0.c.height / H0.sc); // back-wall window frame (hole shows the world behind); door const doorOpen = clamp(sm(19.6, 20.2, t) * (1 - sm(90.4, 91.2, t))); if (doorOpen < 1) { ink(wx, [[900, -380], [940, -380], [940, 0], [900, 0]], { fill: '#2f4f3f', line: '#14241b', lw: 1.6, seed: 990, tex: 1 }); } if (doorOpen > 0) { const lw = 70 * doorOpen; ink(wx, [[900 - lw, -378], [900, -380], [900, 0], [900 - lw, -2]], { fill: '#2f4f3f', line: '#14241b', lw: 1.6, seed: 991, tex: 1 }); if (lw > 20) { ink(wx, [[900 - lw * 0.85, -340], [900 - lw * 0.15, -340], [900 - lw * 0.15, -210], [900 - lw * 0.85, -210]], { fill: '#3a5e4c', line: '#14241b', lw: 1.2, seed: 992, tex: null }); } } AT(19.6, 'door'); AT(90.4, 'door', { close: 1 }); if (t < 17.95) drawMirror(wx, [826, -236], -1.35, 0, 0.2); // ---------- characters // grandmother const gr = granTrack(t); let ghN = gr.hN === 'girl' ? null : gr.hN; if (gr.hN === 'girl') { const gh = last.girlHead; const u = sm(97.6, 98.6, t); const tgt = [(gh[0] - 600) / KG - 4, -(gh[1]) / KG - 4]; ghN = [lerp(33, tgt[0], u), lerp(64, tgt[1], u)]; } const rub = gr.rub * Math.sin(t * 5.2) * 3; const gq = { x: 600 + Math.sin(t * 43) * 0.7 * gr.shiver, y: 0, K: KG, face: 1, hunch: gr.hunch, nod: gr.nod, smile: gr.smile, blink: gr.eyes < 0.5 ? 1 : ((t * 0.27 + 0.6) % 1 < 0.035 ? 1 : 0), handN: [ghN[0] + rub, ghN[1]], handF: [gr.hF[0] - rub, gr.hF[1]] }; const GR = drawGran(wx, gq); // gran breath while cold if (t < 27 || (t > 85 && t < 94)) for (let k = 0; k < 40; k++) { const t0 = 1 + k * 3.1; breath(wx, [GR.mouth[0] + 4, GR.mouth[1]], t - t0, 1, 0.9); AT(t0, 'breath', { who: 'gran' }); } if (t > 30 && t < 78.6) drawStone(wx, SX, Gy(SX), Math.atan(slope(SX)), 0); // stone position const gS = girlQ(t); // girl const GQ = gS.Q; const GD = drawGirl(wx, girl, GQ, DT); last.girlHead = GD.head; const hands = [GD.handN, GD.handF]; if (gS.g.carry > 0.5) STONE_POS = [(hands[0][0] + hands[1][0]) / 2 - 2 * GQ.face, (hands[0][1] + hands[1][1]) / 2 - 2]; else if (t > 94) STONE_POS = [660 - 2, -116]; else STONE_POS = [SX, Gy(SX)]; // the stone (before the cat sits on it) const stoneWarm = sm(66, 74, t) * 0.6 + sm(94, 102, t) * 0.4; if (t >= 78.6) drawStone(wx, STONE_POS[0], STONE_POS[1], gS.g.carry > 0.5 || t > 94 ? 0 : Math.atan(slope(SX)), stoneWarm); // the cat const c = cTrack(t); let cxp = c.x, cyp = catY(c, t); if (c.yy === 'carry') cxp = STONE_POS[0] + 6 * GQ.face; if (t > 94 && c.yy === 'carry') cxp = STONE_POS[0] + 4; let pose = c.pose, lift = 0, inAir = 0; for (const [a, b, h] of JUMPS) if (t > a && t < b) { const u = (t - a) / (b - a); cyp += -h * 4 * u * (1 - u); pose = lerp(3, 3.55, Math.sin(Math.PI * u)); inAir = 1; } for (const [a, b] of JUMPS) { AT(a, 'pounce'); AT(b, 'land'); } if (c.wig > 0) cxp += Math.sin(t * 34) * 2.2 * c.wig; const cv0 = cTrack(t - 1 / 48), cv1 = cTrack(t + 1 / 48); let cvx = (cv1.x - cv0.x) * 24; if (Math.abs(cvx) > 2000 || c.yy === 'carry') cvx = 0; const cMoving = Math.abs(cvx) > 12 && !inAir; const cStride = Math.abs(cvx) > 300 ? 34 : 15; const CQ = { x: cxp, y: cyp, K: KC, face: c.face, pose, mode: cMoving ? 'gait' : 'still', speed: Math.abs(cvx) / KC / cStride, stride: cStride, lift, legsTuck: inAir ? 0.8 : 0.6, lookUp: c.lookUp, eyesClosed: c.eyes < 0.5, night: c.night > 0.5 || tn > 2.2 ? 1 : 0, tailUp: c.tailUp, tailFlick: c.flick, t }; const ph0 = cat.phase; const CD = drawCat(wx, cat, CQ, DT); if (cMoving && Math.floor(cat.phase * 2) !== Math.floor(ph0 * 2)) ev('step', { who: 'cat', surf: cxp < 1300 ? 'wood' : 'grass' }); last.catHead = CD.head; // the mirror in the girl's hand let mirrorC = null; if (gS.g.mirror > 0.5 && t < 92) { const mAng = t > 20.6 && t < 29.6 ? -0.4 : (t > 41.9 && t < 47 ? -1.0 : (t > 52.5 && t < 55.4 ? -1.2 : 1.2)); mirrorC = drawMirror(wx, GD.handN, GQ.face > 0 ? mAng : Math.PI - mAng, 1, 0); } // the near grass in front of everything (hill only) // ---------- tint the world for the hour (multiply), keep the alpha mx.setTransform(1, 0, 0, 1, 0, 0); mx.globalCompositeOperation = 'copy'; mx.drawImage(WC, 0, 0); wx.setTransform(1, 0, 0, 1, 0, 0); wx.globalCompositeOperation = 'multiply'; wx.fillStyle = mixc3(TINT, tn); wx.fillRect(0, 0, W, H); // the house interior is dimmer than the open air until the end const inDim = t < 85 ? '#c4b6b4' : mixHex('#ffffff', '#fff6e6', sm(94.5, 104, t)); setCam(wx, 1); wx.fillStyle = inDim; wx.fillRect(0, -545, 900, 545); wx.setTransform(1, 0, 0, 1, 0, 0); wx.globalCompositeOperation = 'destination-in'; wx.drawImage(MC, 0, 0); wx.globalCompositeOperation = 'source-over'; X.setTransform(1, 0, 0, 1, 0, 0); X.drawImage(WC, 0, 0); // ---------- light (screen) setCam(X, 1); X.globalCompositeOperation = 'screen'; // sunbeam through the transom const pa = PATCH_A(t) * (1 - 0.25 * sm(8, 15, t)); if (pa > 0.01) { const P = patchPoly(t), e = sunElev(t); const beam = [[900, TRANS.y0], ...P, [900, TRANS.y1]]; X.fillStyle = `rgba(255,196,120,${0.10 * pa})`; X.beginPath(); beam.forEach((p, i) => i ? X.lineTo(p[0], p[1]) : X.moveTo(p[0], p[1])); X.closePath(); X.fill(); // the patch: a soft band where it meets the floor and the wainscot, then a quad climbing the hearth's face const warmC = `rgba(255,${200 - 50 * sm(9, 15, t)},${120 - 50 * sm(9, 15, t)},`; const A = P[0], B = P[P.length - 1]; X.save(); X.filter = 'blur(7px)'; X.fillStyle = warmC + (0.75 * pa) + ')'; X.beginPath(); if (B[0] > 24) { const x0 = Math.max(24, A[0]); X.moveTo(x0, 2); X.lineTo(B[0], 2); X.lineTo(B[0] + 20, -30); X.lineTo(x0 + 20, -30); X.closePath(); } if (A[0] === 24) { const y0 = A[1], y1 = B[0] === 24 ? B[1] : 0; X.closePath(); X.fill(); X.filter = 'blur(16px)'; X.beginPath(); X.ellipse(70, (y0 + y1) / 2 + 8, 46, Math.max(14, Math.abs(y1 - y0) * 0.6), 0.25, 0, 7); } X.fill(); X.restore(); // dust in the beam for (let i = 0; i < 40; i++) { const u = hash(i), v = hash(i + 40); const top = lerp(TRANS.y0, TRANS.y1, v); const hp = rayHit(-top, e); const px = lerp(900, hp[0], u) + Math.sin(t * 0.7 + i) * 6, py = lerp(top, hp[1], u) + Math.sin(t * 0.5 + i * 2) * 5; X.fillStyle = `rgba(255,230,180,${0.5 * pa * (0.4 + 0.6 * hash(i + 7))})`; X.fillRect(px, py, 2, 2); } if (pa > 0.05) for (const s of [4.0, 7.4, 10.2]) AT(s, 'scoot'); } // light through the open door at sunset if (doorOpen > 0.05 && t < 31) { const a = doorOpen * (1 - sm(28.6, 30.2, t)); const gg = X.createLinearGradient(900, 0, 600, 0); gg.addColorStop(0, `rgba(255,170,100,${0.22 * a})`); gg.addColorStop(1, 'rgba(255,170,100,0)'); X.fillStyle = gg; X.beginPath(); X.moveTo(900, -380); X.lineTo(900, 0); X.lineTo(560, 0); X.closePath(); X.fill(); } // the mirror's spot const sp = spotTrack(t); if (sp.on > 0.01) { let spx = sp.x, spy = sp.y === 'g' ? Gy(sp.x) - 2 : (sp.y === 'cat' ? null : sp.y); if (sp.y === 'cat') { const u = sm(44.0, 44.25, t); spx = lerp(4040, last.catHead[0] + 4 * c.face, u); spy = lerp(Gy(4040) - 2, last.catHead[1], u); } const col = sp.cold ? '215,228,255' : '255,240,200', r = sp.cold ? 15 : 13; const g2 = X.createRadialGradient(spx, spy, 0, spx, spy, r * 2.4); g2.addColorStop(0, `rgba(${col},${0.95 * sp.on})`); g2.addColorStop(0.45, `rgba(${col},${0.55 * sp.on})`); g2.addColorStop(1, `rgba(${col},0)`); X.fillStyle = g2; X.beginPath(); X.ellipse(spx, spy, r * 2.4, r * (spy < -60 && !sp.cold && sp.y !== 'cat' ? 2.4 : 1.2), 0, 0, 7); X.fill(); if (false) { X.strokeStyle = `rgba(${col},${0.10 * sp.on})`; X.lineWidth = 10; X.beginPath(); X.moveTo(mirrorC[0], mirrorC[1]); X.lineTo(spx, spy); X.stroke(); } if (mirrorC) { const g3 = X.createRadialGradient(mirrorC[0], mirrorC[1], 0, mirrorC[0], mirrorC[1], 26); g3.addColorStop(0, `rgba(${col},${0.8 * sp.on})`); g3.addColorStop(1, `rgba(${col},0)`); X.fillStyle = g3; X.beginPath(); X.arc(mirrorC[0], mirrorC[1], 26, 0, 7); X.fill(); } } AT(20.9, 'spot'); AT(22.72, 'flick'); AT(24.42, 'flick'); AT(43.55, 'spot'); AT(53.4, 'spot', { cold: 1 }); AT(45.05, 'sneeze'); // glints when the mirror finds the sun for (const tg of [20.7, 42.4]) if (t > tg && t < tg + 0.7 && mirrorC) { const a = Math.sin((t - tg) / 0.7 * Math.PI); const gl = X.createRadialGradient(mirrorC[0], mirrorC[1], 0, mirrorC[0], mirrorC[1], 10 + 50 * a); gl.addColorStop(0, `rgba(255,250,230,${0.95 * a})`); gl.addColorStop(0.25, `rgba(255,236,190,${0.5 * a})`); gl.addColorStop(1, 'rgba(255,230,180,0)'); X.fillStyle = gl; X.beginPath(); X.arc(mirrorC[0], mirrorC[1], 10 + 50 * a, 0, 7); X.fill(); X.strokeStyle = `rgba(255,248,225,${0.6 * a})`; X.lineWidth = 1.5; for (let k = 0; k < 8; k++) { const an = k / 8 * 6.283 + t; X.beginPath(); X.moveTo(mirrorC[0] + Math.cos(an) * 8, mirrorC[1] + Math.sin(an) * 8); X.lineTo(mirrorC[0] + Math.cos(an) * (14 + 40 * a), mirrorC[1] + Math.sin(an) * (14 + 40 * a)); X.stroke(); } AT(tg, 'flash'); } // fireflies if (t > 55.5 && t < 85) { for (let i = 2; i < FF.length; i++) { const f = FF[i]; if (t < f.born) continue; const [fx, fy] = ffPos(f, t); fly(fx, fy, ffOn(f, t) * sm(f.born, f.born + 1, t)); } // one lands on the cat's nose { const f = FF[0]; let p, on; if (t < 60.6) { const u = sm(58.5, 60.6, t); p = [lerp(4060, last.catHead[0] + 26 * c.face, u) + Math.sin(t * 3) * 10 * (1 - u), lerp(Gy(4060) - 110, last.catHead[1] + 4, u) + Math.cos(t * 2.3) * 8 * (1 - u)]; on = 0.6 + 0.4 * Math.sin(t * 4); } else if (t < 62.8) { p = [last.catHead[0] + 26 * c.face, last.catHead[1] + 4]; on = 0.55 + 0.45 * Math.sin(t * 3.2); } else { const v = t - 62.8; p = [last.catHead[0] + 26 * c.face + v * 50, last.catHead[1] + 4 - v * 70 + Math.sin(v * 6) * 8]; on = ffOn(FF[0], t); } if (t > 58.5) fly(p[0], p[1], on, 1.2); AT(60.6, 'ff_land'); AT(62.8, 'ff_off'); } // one is caught in her hands { let p, on; const hm = [(hands[0][0] + hands[1][0]) / 2, (hands[0][1] + hands[1][1]) / 2 - 4]; if (t < 64.2) { const u = sm(62.6, 64.2, t); p = [lerp(4090, hm[0], u) + Math.sin(t * 2.7) * 10 * (1 - u), lerp(Gy(4090) - 150, hm[1], u)]; on = ffOn(FF[1], t); } else if (t < 66.4) { p = hm; on = 0.78 + 0.22 * Math.sin(t * 5.2); const gg = X.createRadialGradient(GD.head[0], GD.head[1], 0, GD.head[0], GD.head[1], 50); gg.addColorStop(0, `rgba(190,255,120,${0.25 * (0.6 + 0.4 * Math.sin(t * 5.2))})`); gg.addColorStop(1, 'rgba(190,255,120,0)'); X.fillStyle = gg; X.beginPath(); X.arc(GD.head[0], GD.head[1], 50, 0, 7); X.fill(); } else { const v = t - 66.4; p = [hm[0] - v * 30 + Math.sin(v * 5) * 9, hm[1] - v * 80]; on = ffOn(FF[1], t); } if (t > 62.6 && t < 70) fly(p[0], p[1], on, t > 64.2 && t < 66.4 ? 1.6 : 1); AT(64.2, 'catch'); AT(66.4, 'release'); } AT(56.0, 'ff_first'); } // frost: arrives everywhere on the hill except the stone if (t > 64 && t < 85) { for (let i = 0; i < 300; i++) { const xw = 3350 + hash(i + 300) * 1100; if (Math.abs(xw - SX) < 70 && t < 78.7) continue; const arrive = sm(64.5 + hash(i + 340) * 4, 65.5 + hash(i + 340) * 4, t); if (arrive < 0.01) continue; const y = Gy(xw) - hash(i + 310) * 12; const tw = Math.max(0, Math.sin(t * 3 + i * 1.7)); X.fillStyle = `rgba(225,238,255,${arrive * (0.35 + 0.55 * tw)})`; X.fillRect(xw, y, 2.2, 2.2); } AT(65, 'frost'); } if (t > 33 && t < 49) { const a = sm(34, 36, t) * (1 - sm(45.5, 47.5, t)); const g7 = X.createRadialGradient(SX + 10, Gy(SX) - 24, 0, SX + 10, Gy(SX) - 24, 46); g7.addColorStop(0, `rgba(255,214,140,${0.45 * a})`); g7.addColorStop(1, 'rgba(255,200,120,0)'); X.fillStyle = g7; X.beginPath(); X.arc(SX + 10, Gy(SX) - 24, 46, 0, 7); X.fill(); } AT(39.4, 'sniff'); // the stone keeps the day: a faint warmth on the hill, stronger once it is home if (t > 64) { const sw = (t < 85 ? 0.22 * sm(66, 70, t) : 0.22) + 0.1 * sm(94, 104, t); const p = [STONE_POS[0], STONE_POS[1] - 14]; const g4 = X.createRadialGradient(p[0], p[1], 0, p[0], p[1], 80); g4.addColorStop(0, `rgba(255,170,90,${sw})`); g4.addColorStop(1, 'rgba(255,170,90,0)'); X.fillStyle = g4; X.beginPath(); X.arc(p[0], p[1], 80, 0, 7); X.fill(); } // her face when she touches it if (false) { const a = sm(74.1, 74.8, t) * (1 - sm(77.5, 79, t)); const g5 = X.createRadialGradient(GD.head[0], GD.head[1], 0, GD.head[0], GD.head[1], 46); g5.addColorStop(0, `rgba(255,190,120,${0.3 * a})`); g5.addColorStop(1, 'rgba(255,190,120,0)'); X.fillStyle = g5; X.beginPath(); X.arc(GD.head[0], GD.head[1], 46, 0, 7); X.fill(); } // moonlight indoors once she is home, so the hand-off is legible // the room warms if (t > 94) { const w = sm(94.5, 104, t); const g6 = X.createRadialGradient(650, -150, 20, 650, -150, 560); g6.addColorStop(0, `rgba(255,170,96,${0.22 * w})`); g6.addColorStop(1, 'rgba(255,150,80,0)'); X.fillStyle = g6; X.fillRect(-40, -545, 980, 600); } // the back window glows from outside at the end X.globalCompositeOperation = 'source-over'; // breath in the cold (girl) setCam(X, 1); if (t > 47 && t < 74) for (let k = 0; k < 14; k++) { const t0 = 48 + k * 1.9 + (k % 3) * 0.2; const mouth = [GD.head[0] + GQ.face * 12, GD.head[1] + 10]; breath(X, mouth, t - t0, GQ.face, t > 66 && t < 70 ? 1.4 : 1); AT(t0, 'breath', { who: 'girl' }); } if (t > 85 && t < 92) for (let k = 0; k < 4; k++) { const t0 = 85.5 + k * 1.8; breath(X, [GD.head[0] + GQ.face * 12, GD.head[1] + 10], t - t0, GQ.face); AT(t0, 'breath', { who: 'girl' }); } // events for the story beats AT(13.1, 'laugh'); AT(27.3, 'laugh'); AT(45.0, 'laugh'); AT(54.9, 'laugh', { small: 1 }); AT(60.7, 'laugh'); AT(77.3, 'laugh', { small: 1 }); AT(17.95, 'pick'); AT(28.0, 'purr_on'); AT(31.9, 'purr_off'); AT(29.4, 'sunset'); AT(46.6, 'sunset2'); AT(49.0, 'moonrise'); AT(67.6, 'purr_on'); AT(74.1, 'touch'); AT(74.5, 'warm'); AT(76.25, 'grumble'); AT(78.7, 'lift'); AT(91.65, 'kneel'); AT(94.15, 'give'); AT(94.3, 'hands'); AT(95.2, 'warmth'); AT(97.6, 'head'); AT(31.4, 'run'); AT(85, 'home'); AT(110, 'title_out'); // ---------- paper, vignette, fades, title, credits X.setTransform(1, 0, 0, 1, 0, 0); CAM.z = 1; X.globalCompositeOperation = 'multiply'; X.globalAlpha = 0.35; X.fillStyle = PAPER; X.fillRect(0, 0, W, H); X.globalAlpha = 1; X.globalCompositeOperation = 'source-over'; const vg = X.createRadialGradient(W / 2, H / 2, H * 0.4, W / 2, H / 2, H * 1.0); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.42)'); X.fillStyle = vg; X.fillRect(0, 0, W, H); fade(1 - sm(0, 1.4, t)); { const a = sm(84.0, 84.9, t) * (1 - sm(85.1, 86.2, t)); if (a > 0) { X.fillStyle = `rgba(8,10,22,${a})`; X.fillRect(0, 0, W, H); } } title(t); credits(t); fade(sm(END - 1, END, t)); TPREV = t; } function fly(fx, fy, on, size = 1) { if (on < 0.03) { X.fillStyle = 'rgba(40,60,20,0.5)'; X.beginPath(); X.arc(fx, fy, 1.3, 0, 7); X.fill(); return; } const g = X.createRadialGradient(fx, fy, 0, fx, fy, 14 * size); g.addColorStop(0, `rgba(215,255,120,${0.9 * on})`); g.addColorStop(0.3, `rgba(170,255,80,${0.4 * on})`); g.addColorStop(1, 'rgba(170,255,80,0)'); X.fillStyle = g; X.beginPath(); X.arc(fx, fy, 14 * size, 0, 7); X.fill(); X.fillStyle = `rgba(240,255,190,${on})`; X.beginPath(); X.arc(fx, fy, 1.9 * size, 0, 7); X.fill(); } function fade(a) { if (a <= 0.001) return; X.setTransform(1, 0, 0, 1, 0, 0); X.fillStyle = `rgba(0,0,0,${a})`; X.fillRect(0, 0, W, H); } function title(t) { const a = sm(104.5, 106, t) * (1 - sm(109.2, 110.2, t)); if (a < 0.01) return; X.setTransform(1, 0, 0, 1, 0, 0); X.globalAlpha = a; X.textAlign = 'center'; X.fillStyle = '#f1e8d4'; X.font = '58px FellSC'; X.fillText('Small Sun', W * 0.74, H * 0.2); X.font = 'italic 22px Fell'; X.fillStyle = 'rgba(230,226,236,0.85)'; X.fillText('an AFTERLIGHT fable', W * 0.74, H * 0.2 + 36); X.globalAlpha = 1; } function credits(t) { if (t < 110.2) return; const u = t - 110.2; X.setTransform(1, 0, 0, 1, 0, 0); X.fillStyle = '#070708'; X.fillRect(0, 0, W, H); X.textAlign = 'center'; const L = [['Small Sun', '40px FellSC', '#ece6d8', 250], ['an AFTERLIGHT fable ยท CRASH LAB', 'italic 20px Fell', '#9a9fac', 288], ['The moon is photographed: Moon and Cloud 4, movingimagearchive.com, via CINEOSIS LAB.', 'italic 19px Fell', '#d8d2c2', 370], ['Everything else is drawn in code, after the Cartoon Saloon study in PAELEO handmade.', 'italic 19px Fell', '#b9b3a3', 404]]; L.forEach(([s2, f, c, y], i) => { X.globalAlpha = sm(0.2 + i * 0.25, 0.9 + i * 0.25, u); X.font = f; X.fillStyle = c; X.fillText(s2, W / 2, y); }); X.globalAlpha = 1; } let PAPER = null; async function boot() { await load(); PAPER = X.createPattern(TEX.paper, 'repeat'); window.READY = true; } // seek: warm the chains and gait for a second before t, so stills match the film function still(t) { for (let k = 24; k >= 1; k--) frame(Math.max(0, t - k / 24)); frame(t); } window.SS = { boot, frame, still, reset, EV, END }; boot();