UNPKG

@fluritheme/spider

Version:

An expressive website spider that weaves DOM-aware silk, hunts occasional flies and wraps its catch. Original vector artwork, TypeScript, zero dependencies.

306 lines • 12.4 kB
import { clamp } from './geometry.js'; import { curvePoint } from './weaving.js'; import { silkWhite, strokeSilk } from './silk-paint.js'; const cross = (a, b) => a.x * b.y - a.y * b.x; const delta = (a, b) => ({ x: b.x - a.x, y: b.y - a.y }); const projection = (p, a, b) => { const d = delta(a, b); return clamp(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / Math.max(0.001, d.x * d.x + d.y * d.y), 0, 1); }; /** Swept motion against the part of a curved capture strand that actually exists. */ export function catchThread(from, to, threads) { for (const thread of threads) { if (thread.kind !== 'capture' || thread.laid <= 0) continue; if (Math.max(from.x, to.x) + 4 < Math.min(thread.from.x, thread.to.x) || Math.min(from.x, to.x) - 4 > Math.max(thread.from.x, thread.to.x) || Math.max(from.y, to.y) + 4 < Math.min(thread.from.y, thread.to.y) + Math.min(0, thread.sag) || Math.min(from.y, to.y) - 4 > Math.max(thread.from.y, thread.to.y) + Math.max(0, thread.sag)) continue; const count = Math.min(40, Math.max(4, Math.ceil((Math.hypot(thread.to.x - thread.from.x, thread.to.y - thread.from.y) * thread.laid) / 8))); let a = thread.from; for (let i = 1; i <= count; i++) { const t = (thread.laid * i) / count, b = curvePoint(thread.from, thread.to, thread.sag, t); const r = delta(from, to), s = delta(a, b), d = delta(from, a), denominator = cross(r, s); if (Math.abs(denominator) > 0.0001) { const flight = cross(d, s) / denominator, strand = cross(d, r) / denominator; if (flight >= 0 && flight <= 1 && strand >= 0 && strand <= 1) return { thread, t: (thread.laid * (i - 1 + strand)) / count }; } for (const p of [from, to]) { const u = projection(p, a, b), q = { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u }; if (Math.hypot(p.x - q.x, p.y - q.y) <= 3) return { thread, t: (thread.laid * (i - 1 + u)) / count }; } for (const [p, u] of [ [a, 0], [b, 1], ]) { const v = projection(p, from, to), q = { x: from.x + (to.x - from.x) * v, y: from.y + (to.y - from.y) * v }; if (Math.hypot(p.x - q.x, p.y - q.y) <= 3) return { thread, t: (thread.laid * (i - 1 + u)) / count }; } a = b; } } return null; } const within = (p, box, margin = 0) => p.x >= box.x - margin && p.x <= box.x + box.width + margin && p.y >= box.y - margin && p.y <= box.y + box.height + margin; export function mouth(resident, size) { return { x: resident.x + Math.sin(resident.angle) * 19 * size, y: resident.y - Math.cos(resident.angle) * 19 * size, }; } /** One occasional visitor shared by the instance. Uses the existing simulation clock and canvas. */ export class Prey { random; fly = null; eaten = 0; nextAt = 12; requested = false; turnAt = 0; constructor(random = Math.random) { this.random = random; } request() { if (!this.fly) this.requested = true; } clear(time, interval) { if (this.fly?.owner && ['hunting', 'eating'].includes(this.fly.owner.behavior)) { this.fly.owner.behavior = 'resting'; this.fly.owner.until = time + 0.6; this.fly.owner.velocity = 0; } this.fly = null; this.requested = false; this.nextAt = time + interval; } releaseWeb() { if (this.fly?.state === 'trapped') this.escape(); } escape() { const f = this.fly; if (!f) return; f.state = 'flying'; f.trap = null; f.owner = null; f.phaseAge = 0; f.destination = { x: f.x + 110, y: f.y - 90 }; this.turnAt = f.age + 1; } goal(resident) { const f = this.fly; return f?.owner === resident ? { point: f, feeding: f.state === 'wrapping' || f.state === 'eating' } : undefined; } feeding(resident) { return this.fly?.owner === resident && ['wrapping', 'eating'].includes(this.fly.state) ? 1 : 0; } update(dt, time, box, residents, opacity, interval, size, open) { const available = residents.filter((r) => within(r, box, 15) && r.visible && !r.behind && r.behavior !== 'descending'); if (!this.fly) { if ((!this.requested && time < this.nextAt) || !available.length || box.width < 80 || box.height < 80) return; const focus = available[Math.floor(this.random() * available.length)]; const x = clamp(focus.x + (this.random() < 0.5 ? -1 : 1) * 150, box.x + 15, box.x + box.width - 15); const y = clamp(focus.y - 60 - this.random() * 70, box.y + 15, box.y + box.height - 15); this.fly = { x, y, state: 'flying', age: 0, phaseAge: 0, destination: { x: focus.x + 25, y: focus.y - 15 }, vx: 0, vy: 0, heading: 0, owner: null, trap: null, }; this.requested = false; this.turnAt = 1.6; return; } const f = this.fly; f.age += dt; f.phaseAge += dt; if (f.owner && (!residents.includes(f.owner) || f.owner.scared || f.owner.behind || !within(f.owner, box, 120))) this.escape(); if (f.state === 'trapped' && (!f.trap || !residents.some((r) => r.threads.includes(f.trap.thread)) || opacity <= 0)) this.escape(); if (f.state === 'flying') { if (f.age > 18 || !within(f, box, 130)) { this.clear(time, interval * (0.8 + this.random() * 0.4)); return; } if (f.age >= this.turnAt || Math.hypot(f.destination.x - f.x, f.destination.y - f.y) < 15) { const focus = available[Math.floor(this.random() * available.length)]; const thread = focus?.threads.filter((t) => t.kind === 'capture' && t.laid > 0.6)[Math.floor(this.random() * Math.max(1, focus?.threads.filter((t) => t.kind === 'capture' && t.laid > 0.6).length ?? 0))]; const target = thread && this.random() < 0.55 ? curvePoint(thread.from, thread.to, thread.sag, 0.35 + this.random() * 0.3) : (focus ?? f); f.destination = { x: clamp(target.x + (this.random() - 0.5) * 130, box.x + 12, box.x + box.width - 12), y: clamp(target.y + (this.random() - 0.5) * 100, box.y + 12, box.y + box.height - 12), }; this.turnAt = f.age + 0.8 + this.random() * 1.7; } const from = { x: f.x, y: f.y }, dx = f.destination.x - f.x, dy = f.destination.y - f.y, d = Math.hypot(dx, dy) || 1; const speed = 65 + Math.sin(f.age * 2.4) * 15; f.vx += ((dx / d) * speed - f.vx) * (1 - Math.exp(-dt * 5)); f.vy += ((dy / d) * speed - f.vy) * (1 - Math.exp(-dt * 5)); f.x += f.vx * dt + Math.sin(f.age * 19) * dt * 12; f.y += f.vy * dt + Math.cos(f.age * 23) * dt * 10; f.heading = Math.atan2(f.vy, f.vx) + Math.PI / 2; if (opacity > 0 && open(f)) for (const r of residents) { const trap = catchThread(from, f, r.threads); if (trap) { f.trap = trap; f.state = 'trapped'; f.phaseAge = 0; f.owner = r; break; } } if (!f.owner || f.owner.scared) f.owner = available .filter((r) => !r.scared && Math.hypot(r.x - f.x, r.y - f.y) < 320 * size) .sort((a, b) => Math.hypot(a.x - f.x, a.y - f.y) - Math.hypot(b.x - f.x, b.y - f.y))[0] ?? null; } else if (f.state === 'trapped' && f.trap) { const p = curvePoint(f.trap.thread.from, f.trap.thread.to, f.trap.thread.sag, f.trap.t); f.x = p.x + Math.sin(f.phaseAge * 27) * 1.4; f.y = p.y + Math.sin(f.phaseAge * 21) * 1.3; if (f.phaseAge > 12) this.escape(); } else if (f.owner) { Object.assign(f, mouth(f.owner, size)); if (f.state === 'wrapping' && f.phaseAge > 1.15) { f.state = 'eating'; f.phaseAge = 0; } else if (f.state === 'eating' && f.phaseAge > 1.8) { this.eaten++; this.clear(time, interval * (0.8 + this.random() * 0.4)); } } } contact(resident, size) { const f = this.fly; if (!f || f.owner !== resident) return; if (resident.scared) { this.escape(); return; } if ((f.state === 'flying' || f.state === 'trapped') && Math.hypot(resident.x - f.x, resident.y - f.y) < 24 * size) { f.state = 'wrapping'; f.phaseAge = 0; f.trap = null; resident.behavior = 'eating'; resident.velocity = 0; Object.assign(f, mouth(resident, size)); } } paint(ctx, time, size) { const f = this.fly; if (!f) return; ctx.save(); ctx.globalAlpha = f.state === 'flying' ? clamp(f.age / 0.25, 0, 1) : 1; paintFly(ctx, f, time, size); ctx.restore(); } } /** Original tiny fly, translucent beating wings and a silk parcel during feeding. */ export function paintFly(ctx, f, time, size) { ctx.save(); ctx.translate(f.x, f.y); ctx.scale(size, size); ctx.rotate(f.heading); const wrapped = f.state === 'wrapping' || f.state === 'eating'; if (f.state === 'eating') { const shrink = clamp(1 - f.phaseAge / 1.8, 0, 1); ctx.scale(0.4 + shrink * 0.6, 0.4 + shrink * 0.6); ctx.globalAlpha = shrink; } const wing = wrapped ? 0.3 : 0.55 + Math.abs(Math.sin(time * (f.state === 'trapped' ? 58 : 85))) * 0.65; ctx.fillStyle = '#e7eff5b0'; ctx.strokeStyle = '#59656e'; ctx.lineWidth = 0.65; for (const side of [-1, 1]) { ctx.beginPath(); ctx.ellipse(side * 4, 1, 3.7, 6.7 * wing, side * 0.55, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(side * 2, 0); ctx.lineTo(side * 5, 7 * wing); ctx.stroke(); } ctx.strokeStyle = '#33343b'; ctx.lineWidth = 0.7; for (const side of [-1, 1]) for (let leg = 0; leg < 3; leg++) { ctx.beginPath(); ctx.moveTo(side * 1.8, 1 + leg); ctx.lineTo(side * (4 + Math.sin(time * 15 + leg) * 0.6), 3 + leg * 1.3); ctx.stroke(); } ctx.fillStyle = '#505b61'; ctx.beginPath(); ctx.ellipse(0, 2.6, 2.8, 4.1, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#b8c6ca'; ctx.lineWidth = 0.6; ctx.beginPath(); ctx.moveTo(-2, 2); ctx.lineTo(2, 2); ctx.moveTo(-2, 4); ctx.lineTo(2, 4); ctx.stroke(); ctx.fillStyle = '#34383f'; ctx.beginPath(); ctx.ellipse(0, -1.4, 3.1, 2.5, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#a5b18b'; for (const side of [-1, 1]) { ctx.beginPath(); ctx.ellipse(side * 2, -2.1, 1.4, 1.8, side * 0.2, 0, Math.PI * 2); ctx.fill(); } if (wrapped) { const progress = f.state === 'eating' ? 1 : clamp(f.phaseAge / 1.15, 0, 1); for (let i = 0; i < Math.ceil(progress * 7); i++) { ctx.beginPath(); ctx.ellipse(0, 0.7, 4 - i * 0.15, 6, (i % 2 ? 1 : -1) * (0.2 + i * 0.16), 0, Math.PI * 2); strokeSilk(ctx, silkWhite, 0.65, 0.65); } } ctx.restore(); ctx.globalAlpha = 1; } //# sourceMappingURL=prey.js.map