Seeded procedural animated hand drawn like characters, Canvas2D brush strokes on a Three.js rig.
hand-drawn-character-creator.html
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| Who are you? | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| background: #f2ecdd; | |
| color: #2c2a25; | |
| font-family: 'Patrick Hand', cursive; | |
| } | |
| h1 { | |
| font-size: 44px; | |
| font-weight: 400; | |
| line-height: 1.1; | |
| margin-top: 180px; | |
| margin-bottom: -70px; | |
| } | |
| sketchy-avatar { width: 440px; height: 476px; } | |
| .field { position: relative; width: 336px; height: 62px; } | |
| .field input { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 2; | |
| width: 100%; | |
| padding: 0 24px; | |
| border: none; | |
| outline: none; | |
| background: transparent; | |
| font-family: 'Patrick Hand', cursive; | |
| font-size: 26px; | |
| color: #2c2a25; | |
| } | |
| .field input::placeholder { color: #2c2a25; opacity: 0.35; } | |
| </style> | |
| </head> | |
| <body> | |
Who are you? | |
| </div> | |
| { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } } | |
| </script> | |
| import * as THREE from 'three'; | |
| /* ── seeded random ───────────────────────────────────────────── */ | |
| function hashSeed(str) { | |
| let h = 1779033703 ^ str.length; | |
| for (let i = 0; i < str.length; i++) { | |
| h = Math.imul(h ^ str.charCodeAt(i), 3432918353); | |
| h = (h << 13) | (h >>> 19); | |
| } | |
| h = Math.imul(h ^ (h >>> 16), 2246822507); | |
| h = Math.imul(h ^ (h >>> 13), 3266489909); | |
| return (h ^ (h >>> 16)) >>> 0; | |
| } | |
| function mulberry32(a) { | |
| return function () { | |
| a = (a + 0x6D2B79F5) | 0; | |
| let t = Math.imul(a ^ (a >>> 15), 1 | a); | |
| t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; | |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| }; | |
| } | |
| const rngFrom = (str) => mulberry32(hashSeed(str)); | |
| const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; | |
| const jit = (rng, a) => (rng() - 0.5) * 2 * a; | |
| const lerp = (a, b, t) => a + (b - a) * t; | |
| const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); | |
| /* ── brush engine ────────────────────────────────────────────── */ | |
| const INK = '#2c2a25'; | |
| const PAPER = '#f2ecdd'; | |
| const ROSE = '#c05a48'; | |
| // Multi-pass wobbly polyline — the core pencil look. | |
| function strokePts(ctx, rng, pts, o = {}) { | |
| const { w = 5, passes = 2, wob = 3, color = INK, alpha = 0.9, close = false } = o; | |
| const width = Math.max(1, w * 0.58); | |
| const wobble = wob * 1.9; | |
| ctx.strokeStyle = color; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| for (let p = 0; p < passes; p++) { | |
| ctx.globalAlpha = alpha * (p ? 0.4 : 1); | |
| ctx.lineWidth = Math.max(1, width * (0.7 + rng() * 0.6)); | |
| const q = pts.map(pt => [pt[0] + jit(rng, wobble), pt[1] + jit(rng, wobble)]); | |
| ctx.beginPath(); | |
| ctx.moveTo(q[0][0], q[0][1]); | |
| for (let i = 1; i < q.length; i++) { | |
| const a = q[i - 1], b = q[i]; | |
| ctx.quadraticCurveTo(a[0], a[1], (a[0] + b[0]) / 2, (a[1] + b[1]) / 2); | |
| } | |
| const last = q[q.length - 1]; | |
| ctx.lineTo(last[0], last[1]); | |
| if (close) ctx.closePath(); | |
| ctx.stroke(); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| function ellipsePts(cx, cy, rx, ry, n = 18, a0 = 0, a1 = Math.PI * 2) { | |
| const pts = []; | |
| for (let i = 0; i <= n; i++) { | |
| const a = a0 + (a1 - a0) * i / n; | |
| pts.push([cx + Math.cos(a) * rx, cy + Math.sin(a) * ry]); | |
| } | |
| return pts; | |
| } | |
| function line(ctx, rng, x0, y0, x1, y1, o = {}) { | |
| const pts = []; | |
| for (let i = 0; i <= 4; i++) pts.push([lerp(x0, x1, i / 4), lerp(y0, y1, i / 4)]); | |
| strokePts(ctx, rng, pts, o); | |
| } | |
| function polyFill(ctx, rng, pts, color, alpha = 1) { | |
| ctx.globalAlpha = alpha; | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0][0], pts[0][1]); | |
| for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| function blobFill(ctx, rng, cx, cy, rx, ry, color, alpha = 1) { | |
| const pts = ellipsePts(cx, cy, rx, ry, 14) | |
| .map(p => [p[0] + jit(rng, rx * 0.07), p[1] + jit(rng, ry * 0.07)]); | |
| polyFill(ctx, rng, pts, color, alpha); | |
| } | |
| function hatch(ctx, rng, cx, cy, r, o = {}) { | |
| const { n = 5, color = INK, alpha = 0.16, w = 3 } = o; | |
| for (let i = 0; i < n; i++) { | |
| const off = (i - n / 2) * r * 0.3; | |
| line(ctx, rng, | |
| cx - r * 0.5 + off, cy + r * 0.55 + off * 0.3, | |
| cx + off + r * 0.15, cy - r * 0.25 + off * 0.3, | |
| { w, color, alpha, wob: 2, passes: 1 }); | |
| } | |
| } | |
| /* ── palettes ────────────────────────────────────────────────── */ | |
| const ACCENTS = ['#a8503c', '#3f6f8e', '#6f7f3f', '#8a6a9c', '#b0813a', '#4f8a6b']; | |
| const NIGHT_ACCENTS = ['#7c2f3e', '#4a2f66', '#2f4a5e']; | |
| const SKINS = ['#f0e5cd', '#ecdcc4', '#e7d3be', '#f2e8d6']; | |
| function makePalette(rng, dark) { | |
| if (dark) return { ink: INK, accent: pick(rng, NIGHT_ACCENTS), fill: '#e6ddc8', skin: '#e6ddc8' }; | |
| return { ink: INK, accent: pick(rng, ACCENTS), fill: '#f6f1e3', skin: pick(rng, SKINS) }; | |
| } | |
| /* ── features ────────────────────────────────────────────────── */ | |
| /* Each is a pure function over a canvas. They read ch.state so a | |
| redraw reflects the current blink / gaze. */ | |
| function drawEyes(ctx, W, H, rng, ch) { | |
| const st = ch.state, pal = ch.pal; | |
| const er = H * 0.30 * ch.eyeScale, cy = H * 0.52; | |
| const px = st.lookX * er * 0.35, py = st.lookY * er * 0.3; | |
| const closed = (x, r) => | |
| strokePts(ctx, rng, ellipsePts(x, cy, r, r * 0.45, 8, 0.2, Math.PI - 0.2), { w: 5, wob: 1.5, color: pal.ink }); | |
| if (ch.eyeV === 'many') { | |
| [[W * 0.2, 0.6], [W * 0.5, 0.95], [W * 0.8, 0.6]].forEach(([x, s]) => { | |
| const r = er * s; | |
| if (st.blink) return closed(x, r); | |
| blobFill(ctx, rng, x, cy, r, r, pal.ink, 0.95); | |
| blobFill(ctx, rng, x + px * 0.6, cy + py * 0.6, r * 0.3, r * 0.3, PAPER); | |
| }); | |
| return; | |
| } | |
| const ex = [W * 0.30, W * 0.70]; | |
| ex.forEach((x, i) => { | |
| if (st.blink) { | |
| closed(x, er); | |
| if (ch.eyeV === 'glasses') strokePts(ctx, rng, ellipsePts(x, cy, er * 1.5, er * 1.4), { w: 4, wob: 2, color: pal.ink, close: true }); | |
| return; | |
| } | |
| switch (ch.eyeV) { | |
| case 'dot': | |
| blobFill(ctx, rng, x + px, cy + py, er * 0.42, er * 0.46, pal.ink); | |
| blobFill(ctx, rng, x + px + er * 0.13, cy + py - er * 0.15, er * 0.12, er * 0.12, PAPER, 0.9); | |
| break; | |
| case 'sleepy': | |
| strokePts(ctx, rng, ellipsePts(x, cy - er * 0.3, er, er * 0.7, 8, Math.PI + 0.2, Math.PI * 2 - 0.2), { w: 5, wob: 1.5, color: pal.ink }); | |
| blobFill(ctx, rng, x + px, cy + er * 0.15 + py * 0.5, er * 0.3, er * 0.25, pal.ink); | |
| break; | |
| case 'angry': { | |
| strokePts(ctx, rng, ellipsePts(x, cy, er * 0.85, er * 0.9), { w: 4.5, wob: 1.5, color: pal.ink, close: true }); | |
| blobFill(ctx, rng, x + px, cy + py, er * 0.35, er * 0.35, pal.ink); | |
| const s = i === 0 ? 1 : -1; | |
| line(ctx, rng, x - s * er * 1.1, cy - er * 1.6, x + s * er * 0.8, cy - er * 0.9, { w: 6, color: pal.ink, wob: 2 }); | |
| break; | |
| } | |
| case 'glasses': | |
| strokePts(ctx, rng, ellipsePts(x, cy, er * 1.5, er * 1.4), { w: 4, wob: 2, color: pal.ink, close: true }); | |
| blobFill(ctx, rng, x + px, cy + py, er * 0.4, er * 0.42, pal.ink); | |
| if (i === 1) line(ctx, rng, ex[0] + er * 1.5, cy - er * 0.3, ex[1] - er * 1.5, cy - er * 0.3, { w: 4, color: pal.ink, wob: 1 }); | |
| break; | |
| case 'hollow': | |
| blobFill(ctx, rng, x, cy, er * 1.05, er * 1.15, pal.ink, 0.9); | |
| blobFill(ctx, rng, x + px, cy + py, er * 0.3, er * 0.32, PAPER); | |
| break; | |
| default: // round | |
| strokePts(ctx, rng, ellipsePts(x, cy, er, er * 1.05), { w: 4.5, wob: 1.8, color: pal.ink, close: true }); | |
| blobFill(ctx, rng, x + px, cy + py, er * 0.38, er * 0.42, pal.ink); | |
| blobFill(ctx, rng, x + px + er * 0.12, cy + py - er * 0.14, er * 0.12, er * 0.12, PAPER, 0.95); | |
| } | |
| }); | |
| } | |
| function drawMouth(ctx, W, H, rng, ch) { | |
| const pal = ch.pal, cx = W / 2, cy = H * 0.42, mw = W * 0.34 * ch.mouthScale; | |
| const o = { w: 5, wob: 2, color: pal.ink }; | |
| switch (ch.mouthV) { | |
| case 'smile': | |
| strokePts(ctx, rng, ellipsePts(cx, cy - H * 0.12, mw, H * 0.3, 10, 0.25, Math.PI - 0.25), o); | |
| break; | |
| case 'flat': | |
| line(ctx, rng, cx - mw, cy, cx + mw * 0.9, cy + jit(rng, 4), o); | |
| break; | |
| case 'open': | |
| blobFill(ctx, rng, cx, cy + H * 0.05, mw * 0.38, H * 0.17, ROSE, 0.55); | |
| strokePts(ctx, rng, ellipsePts(cx, cy + H * 0.05, mw * 0.4, H * 0.18), { ...o, w: 4, close: true }); | |
| break; | |
| case 'teeth': | |
| strokePts(ctx, rng, ellipsePts(cx, cy - H * 0.1, mw, H * 0.32, 10, 0.2, Math.PI - 0.2), o); | |
| line(ctx, rng, cx - mw * 0.85, cy + H * 0.02, cx + mw * 0.85, cy + H * 0.02, { ...o, w: 4 }); | |
| for (let i = -2; i <= 2; i++) { | |
| line(ctx, rng, cx + i * mw * 0.32, cy + H * 0.02, cx + i * mw * 0.3, cy + H * 0.16, { ...o, w: 3, alpha: 0.8, passes: 1 }); | |
| } | |
| break; | |
| case 'catmouth': // little "w" | |
| strokePts(ctx, rng, ellipsePts(cx - mw * 0.4, cy, mw * 0.4, H * 0.14, 6, 0.3, Math.PI - 0.3), { ...o, w: 4 }); | |
| strokePts(ctx, rng, ellipsePts(cx + mw * 0.4, cy, mw * 0.4, H * 0.14, 6, 0.3, Math.PI - 0.3), { ...o, w: 4 }); | |
| break; | |
| case 'jagged': { | |
| const pts = []; | |
| for (let i = 0; i <= 8; i++) pts.push([cx - mw + (2 * mw) * i / 8, cy + (i % 2 ? H * 0.18 : -H * 0.05)]); | |
| strokePts(ctx, rng, pts, { ...o, w: 4.5 }); | |
| break; | |
| } | |
| } | |
| } | |
| function drawNose(ctx, W, H, rng, ch) { | |
| const pal = ch.pal, cx = W / 2, cy = H * 0.5; | |
| const o = { w: 4.5, wob: 1.6, color: pal.ink }; | |
| const whiskers = () => { | |
| for (const s of [-1, 1]) { | |
| for (let i = 0; i < 3; i++) { | |
| line(ctx, rng, cx + s * W * 0.12, cy + (i - 1) * 6, cx + s * W * 0.46, cy + (i - 1) * 13 - 3, | |
| { w: 2.5, color: pal.ink, wob: 2, passes: 1, alpha: 0.8 }); | |
| } | |
| } | |
| }; | |
| switch (ch.noseV) { | |
| case 'curve': | |
| strokePts(ctx, rng, [[cx - 4, cy - H * 0.22], [cx + 7, cy + H * 0.05], [cx - 6, cy + H * 0.12]], o); | |
| break; | |
| case 'button': | |
| strokePts(ctx, rng, ellipsePts(cx, cy, W * 0.07, W * 0.06), { ...o, close: true }); | |
| break; | |
| case 'big': | |
| blobFill(ctx, rng, cx, cy, W * 0.12, H * 0.2, pal.skin, 0.7); | |
| strokePts(ctx, rng, ellipsePts(cx, cy, W * 0.12, H * 0.2), { ...o, close: true }); | |
| blobFill(ctx, rng, cx - W * 0.05, cy + H * 0.1, 3, 3, pal.ink); | |
| blobFill(ctx, rng, cx + W * 0.05, cy + H * 0.1, 3, 3, pal.ink); | |
| break; | |
| case 'triangle': // cat | |
| blobFill(ctx, rng, cx, cy - 2, W * 0.06, H * 0.08, ROSE, 0.9); | |
| strokePts(ctx, rng, [ | |
| [cx - W * 0.06, cy - H * 0.08], [cx + W * 0.06, cy - H * 0.08], | |
| [cx, cy + H * 0.06], [cx - W * 0.06, cy - H * 0.08], | |
| ], { ...o, w: 3.5 }); | |
| whiskers(); | |
| break; | |
| case 'snout': // dog | |
| blobFill(ctx, rng, cx, cy, W * 0.16, H * 0.22, ch.dark ? pal.fill : '#efe6d2', 0.85); | |
| strokePts(ctx, rng, ellipsePts(cx, cy, W * 0.16, H * 0.22), { ...o, close: true }); | |
| blobFill(ctx, rng, cx, cy - H * 0.06, W * 0.05, H * 0.06, pal.ink); | |
| for (const s of [-1, 1]) { | |
| for (let i = 0; i < 3; i++) blobFill(ctx, rng, cx + s * W * 0.08, cy + H * 0.08 + i * 5, 1.8, 1.8, pal.ink, 0.8); | |
| } | |
| break; | |
| } | |
| } | |
| function drawHair(ctx, W, H, rng, ch) { | |
| const pal = ch.pal, cx = W / 2, base = H * 0.72; | |
| const o = { w: 5, wob: 3, color: pal.ink }; | |
| switch (ch.hairV) { | |
| case 'messy': | |
| for (let i = 0; i < 7; i++) { | |
| const x0 = cx + (rng() - 0.5) * W * 0.7; | |
| strokePts(ctx, rng, [ | |
| [x0, base], | |
| [x0 + jit(rng, 20), base - H * (0.3 + rng() * 0.35)], | |
| [x0 + jit(rng, 34), base - H * 0.15], | |
| ], { ...o, w: 4, passes: 1, alpha: 0.85 }); | |
| } | |
| break; | |
| case 'spiky': { | |
| const pts = []; | |
| for (let i = 0; i <= 10; i++) pts.push([cx - W * 0.35 + W * 0.7 * i / 10, base - (i % 2 ? H * 0.5 : H * 0.12)]); | |
| strokePts(ctx, rng, pts, { ...o, w: 4.5 }); | |
| break; | |
| } | |
| case 'beret': | |
| blobFill(ctx, rng, cx, base - H * 0.28, W * 0.29, H * 0.22, PAPER, 1); | |
| blobFill(ctx, rng, cx, base - H * 0.28, W * 0.29, H * 0.22, pal.accent, 1); | |
| strokePts(ctx, rng, ellipsePts(cx, base - H * 0.28, W * 0.29, H * 0.22), { ...o, close: true }); | |
| line(ctx, rng, cx, base - H * 0.5, cx + 4, base - H * 0.62, { ...o, w: 4 }); | |
| break; | |
| case 'antennae': | |
| for (const s of [-1, 1]) { | |
| strokePts(ctx, rng, [ | |
| [cx + s * W * 0.12, base], | |
| [cx + s * W * 0.2, base - H * 0.45], | |
| [cx + s * W * 0.26, base - H * 0.72], | |
| ], { ...o, w: 3.5 }); | |
| blobFill(ctx, rng, cx + s * W * 0.26, base - H * 0.76, 6, 6, pal.accent, 0.95); | |
| strokePts(ctx, rng, ellipsePts(cx + s * W * 0.26, base - H * 0.76, 7, 7), { ...o, w: 2.5, close: true }); | |
| } | |
| break; | |
| case 'horns': | |
| for (const s of [-1, 1]) { | |
| strokePts(ctx, rng, [ | |
| [cx + s * W * 0.18, base], | |
| [cx + s * W * 0.3, base - H * 0.4], | |
| [cx + s * W * 0.24, base - H * 0.72], | |
| ], { ...o, w: 7 }); | |
| } | |
| break; | |
| case 'helmet': | |
| blobFill(ctx, rng, cx, base - H * 0.05, W * 0.36, H * 0.42, PAPER, 1); | |
| blobFill(ctx, rng, cx, base - H * 0.05, W * 0.36, H * 0.42, pal.accent, 1); | |
| strokePts(ctx, rng, ellipsePts(cx, base + H * 0.05, W * 0.36, H * 0.48, 12, Math.PI, Math.PI * 2), { ...o, w: 6 }); | |
| line(ctx, rng, cx - W * 0.36, base + H * 0.02, cx + W * 0.36, base + H * 0.02, { ...o, w: 5 }); | |
| for (let i = -1; i <= 1; i++) blobFill(ctx, rng, cx + i * W * 0.17, base - H * 0.22, 3, 3, pal.ink); | |
| break; | |
| case 'bald': | |
| if (rng() < 0.4) { | |
| for (let i = 0; i < 2; i++) line(ctx, rng, cx + i * 10 - 5, base, cx + i * 14 - 7, base - H * 0.3, { ...o, w: 3, passes: 1 }); | |
| } | |
| break; | |
| } | |
| } | |
| // One ear; the right side is the same texture mirrored via mesh scale. | |
| function drawEar(ctx, W, H, rng, ch) { | |
| const pal = ch.pal, o = { w: 5, wob: 2, color: pal.ink }; | |
| // Opaque paper base + the head tint, so ears read as part of the head. | |
| const base = (pts) => { | |
| polyFill(ctx, rng, pts, PAPER, 1); | |
| polyFill(ctx, rng, pts, ch.dark ? pal.fill : pal.skin, ch.dark ? 0.95 : 0.55); | |
| }; | |
| switch (ch.earV) { | |
| case 'human': { | |
| const cx = W * 0.5, cy = H * 0.5, r = W * 0.33; | |
| base(ellipsePts(cx, cy, r, r, 16)); | |
| strokePts(ctx, rng, ellipsePts(cx, cy, r, r, 16), { ...o, w: 6, wob: 1.5, close: true }); | |
| blobFill(ctx, rng, cx, cy + r * 0.05, r * 0.45, r * 0.45, ROSE, 0.3); | |
| break; | |
| } | |
| case 'cat': { | |
| const tri = [[W * 0.16, H * 0.98], [W * 0.46, H * 0.12], [W * 0.88, H * 0.82], [W * 0.16, H * 0.98]]; | |
| base(tri); | |
| strokePts(ctx, rng, tri.slice(0, 3), { ...o, w: 6 }); // open base merges with the head | |
| polyFill(ctx, rng, [[W * 0.35, H * 0.8], [W * 0.48, H * 0.36], [W * 0.7, H * 0.68]], ROSE, 0.55); | |
| break; | |
| } | |
| case 'dog': { // floppy lobe hanging down | |
| const pts = ellipsePts(W * 0.52, H * 0.52, W * 0.2, H * 0.4, 16); | |
| polyFill(ctx, rng, pts, PAPER, 1); | |
| polyFill(ctx, rng, pts, ch.dark ? pal.fill : pal.skin, ch.dark ? 0.95 : 0.6); | |
| strokePts(ctx, rng, pts, { ...o, w: 5, wob: 1.8, close: true }); | |
| break; | |
| } | |
| case 'pointed': { | |
| const tri = [[W * 0.2, H * 0.98], [W * 0.72, H * 0.12], [W * 0.78, H * 0.9], [W * 0.2, H * 0.98]]; | |
| base(tri); | |
| strokePts(ctx, rng, tri.slice(0, 3), { ...o, w: 6 }); | |
| break; | |
| } | |
| } | |
| } | |
| function drawHead(ctx, W, H, rng, ch) { | |
| const cx = W / 2, cy = H * 0.52; | |
| const rx = W * 0.36 * ch.headW, ry = H * 0.38 * ch.headH; | |
| const pts = ellipsePts(cx, cy, rx, ry, 16).map(p => [p[0] + jit(rng, rx * 0.05), p[1] + jit(rng, ry * 0.05)]); | |
| polyFill(ctx, rng, pts, PAPER, 1); // opaque base so nothing shows through | |
| polyFill(ctx, rng, pts, ch.dark ? ch.pal.fill : ch.pal.skin, ch.dark ? 1 : 0.55); | |
| strokePts(ctx, rng, ellipsePts(cx, cy, rx, ry, 20), { w: 6, wob: 4, color: ch.pal.ink, close: true }); | |
| if (ch.dark) return; | |
| hatch(ctx, rng, cx + rx * 0.5, cy + ry * 0.4, 38, { color: ch.pal.ink }); | |
| if (ch.blush) { | |
| blobFill(ctx, rng, cx - rx * 0.6, cy + ry * 0.3, 13, 8, ch.pal.accent, 0.28); | |
| blobFill(ctx, rng, cx + rx * 0.6, cy + ry * 0.3, 13, 8, ch.pal.accent, 0.28); | |
| } | |
| } | |
| function drawTorso(ctx, W, H, rng, ch) { | |
| const pal = ch.pal, cx = W / 2, cy = H * 0.52; | |
| const rx = W * 0.34 * ch.bodyW, ry = H * 0.4; | |
| const pts = ellipsePts(cx, cy, rx, ry, 16).map(p => [p[0] + jit(rng, rx * 0.05), p[1] + jit(rng, ry * 0.05)]); | |
| polyFill(ctx, rng, pts, PAPER, 1); | |
| polyFill(ctx, rng, pts, ch.dark ? pal.fill : pal.accent, ch.dark ? 1 : 0.32); | |
| strokePts(ctx, rng, ellipsePts(cx, cy, rx, ry, 18), { w: 6, wob: 4, color: pal.ink, close: true }); | |
| if (ch.bodyDeco === 'buttons') { | |
| for (let i = 0; i < 3; i++) blobFill(ctx, rng, cx + jit(rng, 3), cy - ry * 0.4 + i * ry * 0.4, 4.5, 4.5, pal.ink, 0.9); | |
| } | |
| if (ch.bodyDeco === 'stripes') { | |
| for (let i = 0; i < 3; i++) { | |
| line(ctx, rng, cx - rx * 0.85, cy - ry * 0.45 + i * ry * 0.45, cx + rx * 0.85, cy - ry * 0.4 + i * ry * 0.45, | |
| { w: 5, color: pal.accent, wob: 3, passes: 1, alpha: 0.75 }); | |
| } | |
| } | |
| if (ch.bodyDeco === 'belly') { | |
| blobFill(ctx, rng, cx, cy + ry * 0.25, rx * 0.55, ry * 0.5, ch.dark ? '#3a3140' : '#f6f1e3', 0.8); | |
| strokePts(ctx, rng, ellipsePts(cx, cy + ry * 0.25, rx * 0.55, ry * 0.5, 12), | |
| { w: 3.5, wob: 3, color: pal.ink, alpha: 0.6, passes: 1, close: true }); | |
| } | |
| if (!ch.dark) hatch(ctx, rng, cx + rx * 0.55, cy + ry * 0.45, 34, { color: pal.ink, n: 4 }); | |
| } | |
| // Shoulder joint is a light scribble into the torso; the arm bows around the belly. | |
| function drawArm(ctx, W, H, rng, ch) { | |
| const bend = W * (0.26 + 0.14 * ch.bodyW + rng() * 0.06); | |
| const x0 = W * 0.86, mid = x0 - bend, hx = x0 - bend * 0.7; | |
| strokePts(ctx, rng, [[W * 0.99, H * 0.07], [x0 + (W * 0.99 - x0) * 0.35, H * 0.05], [x0, H * 0.04]], | |
| { w: 3.5, wob: 4, color: ch.pal.ink, alpha: 0.45 }); | |
| strokePts(ctx, rng, [[x0, H * 0.04], [mid, H * 0.38], [hx, H * 0.86]], { w: 6, wob: 2.5, color: ch.pal.ink }); | |
| if (ch.dark && rng() < 0.6) { // claws | |
| for (let i = -1; i <= 1; i++) line(ctx, rng, hx, H * 0.84, hx + i * 9 - 3, H * 0.97, { w: 3.5, color: ch.pal.ink, wob: 1.5, passes: 1 }); | |
| } else { | |
| strokePts(ctx, rng, ellipsePts(hx, H * 0.9, W * 0.08, H * 0.05), { w: 4, wob: 1.5, color: ch.pal.ink, close: true }); | |
| } | |
| } | |
| // Cheering pose: thrown up and out in a V so the hand clears the head. | |
| function drawArmUp(ctx, W, H, rng, ch) { | |
| const hx = W * 0.14, hy = H * 0.24; | |
| strokePts(ctx, rng, [[W * 0.99, H * 0.98], [W * 0.925, H * 0.97], [W * 0.86, H * 0.96]], | |
| { w: 3.5, wob: 4, color: ch.pal.ink, alpha: 0.45 }); | |
| strokePts(ctx, rng, [[W * 0.86, H * 0.96], [W * 0.44, H * 0.62], [hx, hy]], { w: 6, wob: 2.5, color: ch.pal.ink }); | |
| if (ch.dark) { | |
| for (let i = -1; i <= 1; i++) line(ctx, rng, hx, hy, hx + i * 9 - 3, hy - H * 0.12, { w: 3.5, color: ch.pal.ink, wob: 1.5, passes: 1 }); | |
| } else { | |
| strokePts(ctx, rng, ellipsePts(hx, hy - H * 0.04, W * 0.085, H * 0.05), { w: 4, wob: 1.5, color: ch.pal.ink, close: true }); | |
| for (let i = -1; i <= 1; i++) { | |
| line(ctx, rng, hx + i * 6, hy - H * 0.08, hx + i * 11, hy - H * 0.15, { w: 3, color: ch.pal.ink, wob: 1.2, passes: 1, alpha: 0.9 }); | |
| } | |
| } | |
| } | |
| function drawLeg(ctx, W, H, rng, ch) { | |
| strokePts(ctx, rng, [[W * 0.5, H * 0.04], [W * 0.5 + jit(rng, 4), H * 0.55], [W * 0.5, H * 0.96]], | |
| { w: 6, wob: 1.5, color: ch.pal.ink }); | |
| } | |
| function drawGround(ctx, W, H, rng) { | |
| const pts = []; | |
| for (let i = 0; i <= 24; i++) pts.push([W * i / 24, H * 0.5 + jit(rng, 5)]); | |
| strokePts(ctx, rng, pts, { w: 4, wob: 3, color: INK, alpha: 0.28, passes: 1 }); | |
| } | |
| /* ── species tables ──────────────────────────────────────────── */ | |
| const SPECIES = { | |
| human: { | |
| ear: 'human', | |
| noses: ['curve', 'button', 'big'], | |
| hairs: ['messy', 'beret', 'antennae', 'helmet', 'spiky', 'bald', 'messy'], | |
| mouths: ['smile', 'smile', 'open', 'flat'], | |
| eyes: ['round', 'round', 'dot', 'sleepy', 'glasses'], | |
| }, | |
| cat: { | |
| ear: 'cat', | |
| noses: ['triangle'], | |
| hairs: ['bald', 'bald', 'beret'], | |
| mouths: ['catmouth', 'smile', 'flat'], | |
| eyes: ['round', 'dot', 'sleepy'], | |
| }, | |
| dog: { | |
| ear: 'dog', | |
| noses: ['snout'], | |
| hairs: ['bald', 'messy'], | |
| mouths: ['smile', 'open', 'flat'], | |
| eyes: ['round', 'dot', 'sleepy'], | |
| }, | |
| nightmare: { | |
| ear: 'pointed', | |
| noses: ['curve', 'triangle', 'button'], | |
| hairs: ['horns', 'antennae', 'messy', 'spiky'], | |
| mouths: ['jagged', 'teeth', 'open'], | |
| eyes: ['hollow', 'many', 'angry', 'round'], | |
| }, | |
| }; | |
| const SPECIES_ROLL = ['human', 'human', 'cat', 'dog', 'human', 'cat', 'dog']; | |
| /* ── canvas-textured plane ───────────────────────────────────── */ | |
| function makePart(pxW, pxH, worldW, worldH, z = 0) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = pxW; | |
| canvas.height = pxH; | |
| const ctx = canvas.getContext('2d'); | |
| const tex = new THREE.CanvasTexture(canvas); | |
| tex.colorSpace = THREE.SRGBColorSpace; | |
| const mesh = new THREE.Mesh( | |
| new THREE.PlaneGeometry(worldW, worldH), | |
| new THREE.MeshBasicMaterial({ map: tex, transparent: true }) | |
| ); | |
| mesh.position.z = z; | |
| return { | |
| mesh, tex, | |
| redraw(fn) { | |
| ctx.clearRect(0, 0, pxW, pxH); | |
| fn(ctx, pxW, pxH); | |
| tex.needsUpdate = true; | |
| }, | |
| dispose() { | |
| tex.dispose(); | |
| mesh.material.dispose(); | |
| mesh.geometry.dispose(); | |
| }, | |
| }; | |
| } | |
| /* ── character ───────────────────────────────────────────────── */ | |
| const HEAD_R = 0.34; // invisible head sphere the features are pinned to | |
| const HEAD_PLANE = 1.05; | |
| const HEAD_BASE_Y = 1.16; | |
| class Character { | |
| constructor(seed, nightmareChance) { | |
| this.seed = seed; | |
| this.boilN = 0; | |
| const rng = this.rng = rngFrom(seed); | |
| this.species = rng() < nightmareChance ? 'nightmare' : pick(rng, SPECIES_ROLL); | |
| this.dark = this.species === 'nightmare'; | |
| this.pal = makePalette(rng, this.dark); | |
| const sp = SPECIES[this.species]; | |
| this.headW = 1.0 + rng() * 0.25; | |
| this.headH = 1.0 + rng() * 0.25; | |
| this.bodyW = 0.85 + rng() * 0.35; | |
| this.eyeScale = 1.05 + rng() * 0.35; | |
| this.mouthScale = 0.65 + rng() * 0.35; | |
| this.eyeV = pick(rng, sp.eyes); | |
| this.mouthV = pick(rng, sp.mouths); | |
| this.noseV = pick(rng, sp.noses); | |
| this.hairV = pick(rng, sp.hairs); | |
| this.earV = this.dark ? pick(rng, ['pointed', 'cat', 'human']) : sp.ear; | |
| if (this.earV === 'cat' || this.earV === 'pointed') this.hairV = 'bald'; // no headgear over upright ears | |
| this.blush = rng() < 0.9; | |
| this.bodyDeco = pick(rng, this.species === 'human' ? ['buttons', 'stripes', 'none', 'none'] : ['belly', 'none', 'stripes']); | |
| this.state = { blink: false, lookX: 0, lookY: 0 }; | |
| // independent animation clocks so no two characters sync up | |
| this.phase = rng() * Math.PI * 2; | |
| this.breathRate = 1.4 + rng() * 1.2; | |
| this.swayRate = 0.5 + rng() * 0.7; | |
| this.armRate = 0.7 + rng() * 0.9; | |
| this.nextBlink = 1 + rng() * 4; | |
| this.blinkEnd = 0; | |
| this.nextGlance = 1 + rng() * 3; | |
| this.holdUntil = 0; | |
| this.nextExpr = 6 + rng() * 12; | |
| this.yaw = 0; this.pitch = 0; | |
| this.yawT = 0; this.pitchT = 0; | |
| this.jumpStart = null; | |
| this.parts = []; | |
| this.build(rng); | |
| } | |
| // Fresh stream per redraw so re-inked strokes wobble differently ("boiling line"). | |
| boilRng() { return rngFrom(this.seed + ':' + (this.boilN++)); } | |
| track(part) { this.parts.push(part); return part; } | |
| build(rng) { | |
| this.root = new THREE.Group(); | |
| this.body = new THREE.Group(); | |
| this.root.add(this.body); | |
| this.bodyScale = (this.species === 'human' ? 0.9 : 0.78) * (0.9 + rng() * 0.2) * (this.dark ? 1.12 : 1); | |
| this.body.scale.setScalar(this.bodyScale); | |
| for (const s of [-1, 1]) { | |
| const leg = this.track(makePart(48, 60, 0.16, 0.2, 0.001 * s)); | |
| leg.redraw((c, w, h) => drawLeg(c, w, h, rng, this)); | |
| leg.mesh.position.set(s * 0.12, 0.1, 0.002); | |
| if (s === 1) leg.mesh.scale.x = -1; | |
| this.body.add(leg.mesh); | |
| } | |
| this.torso = this.track(makePart(224, 224, 0.78, 0.82, 0.01)); | |
| this.torso.redraw((c, w, h) => drawTorso(c, w, h, rng, this)); | |
| this.torso.mesh.position.y = 0.5; | |
| this.body.add(this.torso.mesh); | |
| this.arms = []; | |
| for (const s of [-1, 1]) { | |
| const pivot = new THREE.Group(); | |
| pivot.position.set(s * 0.19 * this.bodyW, 0.66, 0.015); // just inside the torso outline, in front of it | |
| const idle = this.track(makePart(96, 160, 0.38, 0.52)); | |
| idle.redraw((c, w, h) => drawArm(c, w, h, rng, this)); | |
| idle.mesh.position.set(s * 0.137, -0.2, 0); | |
| const up = this.track(makePart(96, 160, 0.38, 0.52)); | |
| up.redraw((c, w, h) => drawArmUp(c, w, h, rng, this)); | |
| up.mesh.position.set(s * 0.137, 0.24, 0); | |
| up.mesh.visible = false; | |
| if (s === 1) { idle.mesh.scale.x = -1; up.mesh.scale.x = -1; } | |
| pivot.add(idle.mesh, up.mesh); | |
| pivot.userData = { s, base: s * -0.12, idleMesh: idle.mesh, upMesh: up.mesh }; | |
| this.body.add(pivot); | |
| this.arms.push(pivot); | |
| } | |
| this.headGroup = new THREE.Group(); | |
| this.headGroup.position.y = HEAD_BASE_Y; | |
| this.body.add(this.headGroup); | |
| const head = this.track(makePart(256, 256, HEAD_PLANE, HEAD_PLANE, 0.02)); | |
| head.redraw((c, w, h) => drawHead(c, w, h, rng, this)); | |
| this.headGroup.add(head.mesh); | |
| // Features pinned by (u,v) angle on the invisible head sphere. | |
| this.features = []; | |
| const addFeat = (part, u, v, rad, z) => { | |
| part.mesh.position.z = z; | |
| this.headGroup.add(part.mesh); | |
| this.features.push({ part, u, v, rad }); | |
| return this.track(part); | |
| }; | |
| this.eyes = addFeat(makePart(160, 64, 0.56, 0.22), 0, 0.22, HEAD_R, 0.03); | |
| this.eyes.redraw((c, w, h) => drawEyes(c, w, h, this.boilRng(), this)); | |
| const wideNose = this.noseV === 'triangle' || this.noseV === 'snout'; | |
| const nose = addFeat(makePart(wideNose ? 192 : 96, 96, wideNose ? 0.72 : 0.36, 0.36), 0, -0.06, HEAD_R, 0.035); | |
| nose.redraw((c, w, h) => drawNose(c, w, h, this.boilRng(), this)); | |
| this.mouth = addFeat(makePart(128, 80, 0.46, 0.29), 0, -0.52, HEAD_R, 0.032); | |
| this.mouth.redraw((c, w, h) => drawMouth(c, w, h, this.boilRng(), this)); | |
| const hair = addFeat(makePart(256, 144, 0.95, 0.53), 0, 1.3, HEAD_R * 1.15, 0.038); | |
| hair.redraw((c, w, h) => drawHair(c, w, h, this.boilRng(), this)); | |
| // Ears pin to the head ELLIPSE, not the sphere, so they touch the outline | |
| // whatever the head proportions are. | |
| const upright = this.earV === 'cat' || this.earV === 'pointed'; | |
| const ang = upright ? 0.5 : (this.earV === 'dog' ? 1.0 : 0.85); | |
| const lift = upright ? 0.09 : (this.earV === 'dog' ? -0.02 : 0.04); | |
| for (const s of [-1, 1]) { | |
| const ear = this.track(makePart(96, 96, 0.36, 0.36)); | |
| ear.redraw((c, w, h) => drawEar(c, w, h, this.boilRng(), this)); | |
| if (s === 1) ear.mesh.scale.x = -1; | |
| ear.mesh.position.z = 0.025; // in front of the head, behind the eyes | |
| this.headGroup.add(ear.mesh); | |
| this.features.push({ part: ear, ear: true, ang: s * ang, lift }); | |
| } | |
| this.placeFeatures(); | |
| } | |
| placeFeatures() { | |
| const rxW = HEAD_PLANE * 0.36 * this.headW; | |
| const ryW = HEAD_PLANE * 0.38 * this.headH; | |
| for (const f of this.features) { | |
| if (f.ear) { | |
| f.part.mesh.position.x = Math.sin(f.ang + this.yaw * 0.8) * rxW * 0.93; | |
| f.part.mesh.position.y = Math.cos(f.ang) * ryW * 0.93 - 0.02 + f.lift + Math.sin(this.pitch) * 0.06; | |
| } else { | |
| f.part.mesh.position.x = Math.sin(f.u + this.yaw) * f.rad * this.headW; | |
| f.part.mesh.position.y = Math.sin(f.v + this.pitch) * f.rad * 0.92 * this.headH; | |
| } | |
| } | |
| } | |
| redrawEyes() { this.eyes.redraw((c, w, h) => drawEyes(c, w, h, this.boilRng(), this)); } | |
| redrawMouth() { this.mouth.redraw((c, w, h) => drawMouth(c, w, h, this.boilRng(), this)); } | |
| // nx/ny in [-1,1] relative to the head; ny positive = below. | |
| lookAt(t, nx, ny, hold) { | |
| this.yawT = clamp(nx, -1, 1) * 0.5; | |
| this.pitchT = clamp(-ny * 0.35, -0.35, 0.3); | |
| this.state.lookX = clamp(nx, -1, 1); | |
| this.state.lookY = clamp(ny, -1, 1); | |
| this.holdUntil = t + hold; | |
| this.nextGlance = t + hold + 0.1; | |
| } | |
| celebrate(t) { | |
| this.jumpStart = t; | |
| const mouths = SPECIES[this.species].mouths; | |
| this.mouthV = mouths.includes('smile') && this.rng() < 0.5 ? 'smile' : 'open'; | |
| this.redrawMouth(); | |
| this.yawT = 0; | |
| this.pitchT = 0.22; | |
| this.state.lookX = 0; | |
| this.state.lookY = -0.8; | |
| this.holdUntil = t + 1.4; | |
| this.nextGlance = t + 1.5; | |
| if (!this.state.blink) this.redrawEyes(); | |
| } | |
| update(t, dt) { | |
| const ph = this.phase; | |
| const breath = Math.sin(t * this.breathRate + ph); | |
| this.torso.mesh.scale.y = 1 + breath * 0.022; | |
| this.headGroup.position.y = HEAD_BASE_Y + breath * 0.02; | |
| this.root.rotation.z = Math.sin(t * this.swayRate + ph * 1.7) * 0.028; | |
| // Hop: anticipation crouch → ballistic flight → landing squash. | |
| let up = 0, squash = 1; | |
| if (this.jumpStart != null) { | |
| const e = t - this.jumpStart, A = 0.16, F = 0.58, L = 0.24; | |
| if (e < A) { | |
| squash = 1 - 0.1 * Math.sin((e / A) * Math.PI / 2); | |
| } else if (e < A + F) { | |
| const p = (e - A) / F; | |
| up = 4 * p * (1 - p); | |
| squash = 1 + Math.abs(1 - 2 * p) * 0.045; | |
| } else if (e < A + F + L) { | |
| squash = 1 - 0.08 * Math.sin(((e - A - F) / L) * Math.PI); | |
| } else { | |
| this.jumpStart = null; | |
| } | |
| } | |
| this.root.position.y = up * 0.55; | |
| const bs = this.bodyScale; // squash/stretch about the feet | |
| this.body.scale.set(bs * (1 + (1 - squash) * 0.55), bs * squash, bs); | |
| for (const g of this.arms) { | |
| const cheering = up > 0.3; | |
| const idle = g.userData.base + Math.sin(t * this.armRate + ph + g.userData.s) * 0.07; | |
| g.userData.idleMesh.visible = !cheering; | |
| g.userData.upMesh.visible = cheering; | |
| g.rotation.z = cheering ? g.userData.s * 0.22 * up : idle * Math.max(0, 1 - up * 3); | |
| } | |
| if (!this.state.blink && t > this.nextBlink) { | |
| this.state.blink = true; | |
| this.blinkEnd = t + 0.1 + this.rng() * 0.08; | |
| this.redrawEyes(); | |
| } else if (this.state.blink && t > this.blinkEnd) { | |
| this.state.blink = false; | |
| this.nextBlink = t + 1.5 + this.rng() * 5; | |
| this.redrawEyes(); | |
| } | |
| if (t > this.holdUntil && t > this.nextGlance) { | |
| this.yawT = jit(this.rng, 0.45); | |
| this.pitchT = jit(this.rng, 0.22); | |
| this.state.lookX = clamp(this.yawT * 2.2, -1, 1); | |
| this.state.lookY = clamp(-this.pitchT * 2.5, -1, 1); | |
| this.nextGlance = t + 1.5 + this.rng() * 4; | |
| if (!this.state.blink) this.redrawEyes(); | |
| } | |
| const k = Math.min(1, dt * 5); | |
| this.yaw = lerp(this.yaw, this.yawT, k); | |
| this.pitch = lerp(this.pitch, this.pitchT, k); | |
| this.headGroup.rotation.z = this.yaw * -0.12; | |
| this.placeFeatures(); | |
| if (t > this.nextExpr) { | |
| this.nextExpr = t + 7 + this.rng() * 14; | |
| if (this.rng() < 0.5) { | |
| this.mouthV = pick(this.rng, SPECIES[this.species].mouths); | |
| this.redrawMouth(); | |
| } |
评论
?
参与讨论