@fluritheme/spider
Version:
A tiny, expressive spider for your website. Delicate webs that follow DOM layers, blinking eyes, and playful pointer reactions.
177 lines • 7.37 kB
JavaScript
import { clamp, cornerPoint, webLines } from './geometry.js';
import { silkWhite, strokeSilk } from './silk-paint.js';
const geometry = new Map();
export function paintWeb(ctx, surface, progress, opacity) {
let lines = geometry.get(surface.span);
if (!lines) {
lines = webLines(surface.span);
if (geometry.size > 32)
geometry.clear();
geometry.set(surface.span, lines);
}
ctx.strokeStyle = surface.ink;
ctx.globalAlpha = opacity;
ctx.lineWidth = 0.8;
ctx.lineCap = 'round';
for (let i = 0; i < lines.length; i++) {
const reveal = clamp(progress * lines.length - i, 0, 1);
if (reveal <= 0)
break;
const points = lines[i];
ctx.beginPath();
for (let j = 0; j < points.length; j++) {
const p = points[j], q = cornerPoint(surface.box, surface.corner, p.x, p.y);
if (j === 0)
ctx.moveTo(q.x, q.y);
else
ctx.lineTo(q.x, q.y);
}
strokeSilk(ctx, surface.ink, 0.8, opacity * reveal);
}
ctx.globalAlpha = 1;
}
/** A small jumping spider, built from vector geometry: no sprite downloads or image assets. */
export function paintSpider(ctx, position, anchor, ink, size, time, gaze, fear, reduced, angle = 0, gait = 0, walking = false, silkInk = silkWhite, silkOpacity = 0.4) {
if (anchor) {
ctx.beginPath();
ctx.moveTo(anchor.x, anchor.y);
ctx.quadraticCurveTo(anchor.x + (position.x - anchor.x) * 0.25, position.y - 18 * size, position.x, position.y - 12 * size);
strokeSilk(ctx, silkInk, 0.7, silkOpacity);
}
ctx.globalAlpha = 1;
ctx.save();
ctx.translate(position.x, position.y);
ctx.scale(size, size);
ctx.rotate(angle);
const dark = '#302823', middle = ink === '#29282b' || ink === '#f2eee6' ? '#6c5140' : ink, cream = '#dfc5a0';
const lift = reduced ? 0 : Math.sin(gait * 2) * (walking ? 0.7 : 0.15);
ctx.translate(0, lift);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Alternating diagonal footfalls: each foot plants, pushes, then lifts forward.
for (const side of [-1, 1])
for (let leg = 0; leg < 4; leg++) {
const phase = gait + leg * Math.PI * 0.82 + (side < 0 ? Math.PI : 0);
const stride = reduced ? 0 : Math.sin(phase) * (walking ? 5 : 0.8);
const step = reduced ? 0 : Math.max(0, Math.cos(phase)) * (walking ? 3 : 0.5);
const root = { x: side * 7, y: -7 + leg * 4.2 };
const knee = { x: side * (20 + Math.sin(leg * 1.1) * 3), y: -22 + leg * 12 + stride * 0.5 };
const foot = { x: side * (31 - leg * 1.3 - step), y: -28 + leg * 18 + stride };
// Three rounded segments, with restrained sand-colored banding.
ctx.strokeStyle = '#eedcc3';
ctx.lineWidth = leg === 0 ? 4.2 : 3.8;
ctx.beginPath();
ctx.moveTo(root.x, root.y);
ctx.lineTo(knee.x, knee.y);
ctx.lineTo(foot.x, foot.y);
ctx.stroke();
ctx.strokeStyle = dark;
ctx.lineWidth = leg === 0 ? 3.4 : 3;
ctx.stroke();
for (const [a, b] of [
[root, knee],
[knee, foot],
]) {
ctx.strokeStyle = cream;
ctx.lineWidth = 2.3;
ctx.beginPath();
ctx.moveTo(a.x + (b.x - a.x) * 0.42, a.y + (b.y - a.y) * 0.42);
ctx.lineTo(a.x + (b.x - a.x) * 0.63, a.y + (b.y - a.y) * 0.63);
ctx.stroke();
ctx.strokeStyle = middle;
ctx.lineWidth = 0.55;
for (let hair = 0; hair < 4; hair++) {
const t = 0.2 + hair * 0.19, x = a.x + (b.x - a.x) * t, y = a.y + (b.y - a.y) * t;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + side * 1.8, y - 1.6);
ctx.stroke();
}
}
}
ctx.shadowColor = '#17100c30';
ctx.shadowBlur = 5;
ctx.shadowOffsetY = 2;
const abdomen = ctx.createRadialGradient(-4, 7, 1, 0, 11, 17);
abdomen.addColorStop(0, '#927052');
abdomen.addColorStop(0.55, middle);
abdomen.addColorStop(1, dark);
ctx.fillStyle = abdomen;
ctx.beginPath();
ctx.ellipse(0, 11, 12.3, 15, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#e3c9a7';
ctx.lineWidth = 0.65;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Original chevron markings, following the volume of the abdomen.
ctx.strokeStyle = cream;
ctx.lineWidth = 2.4;
for (let stripe = 0; stripe < 3; stripe++) {
const y = 3 + stripe * 7, width = stripe === 0 ? 7.5 : stripe === 1 ? 9.5 : 6;
ctx.beginPath();
ctx.moveTo(-width, y);
ctx.quadraticCurveTo(-3, y - 1, 0, y + 2.4);
ctx.quadraticCurveTo(3, y - 1, width, y);
ctx.stroke();
}
const head = ctx.createRadialGradient(-3, -10, 1, 0, -5, 13);
head.addColorStop(0, '#9a7654');
head.addColorStop(0.7, middle);
head.addColorStop(1, dark);
ctx.fillStyle = head;
ctx.beginPath();
ctx.ellipse(0, -6, 11, 10.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#d5b68e';
ctx.lineWidth = 0.65;
ctx.stroke();
// Fine, deterministic fuzz stays crisp even at small sizes.
ctx.strokeStyle = cream;
ctx.lineWidth = 0.55;
ctx.globalAlpha = 0.65;
for (let hair = 0; hair < 48; hair++) {
const a = (hair * Math.PI * 2) / 48, back = Math.sin(a) > 0;
const rx = back ? 12.3 : 10.4, ry = back ? 15 : 10.5, cy = back ? 11 : -6;
const x = Math.cos(a) * rx, y = cy + Math.sin(a) * ry;
ctx.beginPath();
ctx.moveTo(x * 0.94, cy + (y - cy) * 0.94);
ctx.lineTo(x + Math.cos(a) * (1.2 + Math.sin(hair * 7) * 0.6), y + Math.sin(a) * 1.6);
ctx.stroke();
}
ctx.globalAlpha = 1;
const blinkPhase = time % 5.3, blink = reduced ? 1 : blinkPhase > 4.94 && blinkPhase < 5.08 ? 0.15 : 1;
// Two large forward eyes plus two smaller side eyes, with glass-like catchlights.
const gazeX = gaze ? clamp((gaze.x - position.x) / 200, -1, 1) * 0.7 : 0;
for (const side of [-1, 1]) {
ctx.fillStyle = cream;
ctx.beginPath();
ctx.ellipse(side * 4.2, -10.3, 4.2, 4.8 * blink, side * 0.09, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#130f11';
ctx.beginPath();
ctx.ellipse(side * 4.2 + gazeX, -10.6, 3.3 + fear * 0.15, 3.9 * blink, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff6df';
ctx.beginPath();
ctx.ellipse(side * 4.2 - 1 + gazeX, -12, 1.1, 1.3 * blink, -0.3, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.arc(side * 4.2 + 1.2, -9.2, 0.55, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = '#171218';
ctx.beginPath();
ctx.arc(side * 8.8, -7.4, 1.75, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e1c6a2';
ctx.beginPath();
ctx.ellipse(side * 3.7, -17, 2.5, 3, side * 0.25, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
//# sourceMappingURL=paint.js.map