@fluritheme/fluri
Version:
Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.
440 lines • 16.1 kB
JavaScript
const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
const radius = (mass) => Math.sqrt(mass / Math.PI);
const offset = (surface) => {
const win = surface?.element.ownerDocument?.defaultView;
return { x: win?.scrollX ?? 0, y: win?.scrollY ?? 0 };
};
const usable = (s) => s.visible && !s.occluded && s.accumulation && s.width >= 150 && Math.abs(s.angle) < 0.2;
/** A single visitor, one real snowball, and a bounded state machine. No timers or DOM nodes. */
export class Yeti {
context;
world;
phase = 'hidden';
support;
x = 0;
age = 0;
elapsed = 0;
held = 0;
facing = 1;
wait = 0;
projectile;
target;
constructor(context, world) {
this.context = context;
this.world = world;
}
get state() {
const point = this.point();
return {
phase: this.phase,
...point,
mass: this.held + (this.projectile?.mass ?? 0),
flying: !!this.projectile,
};
}
point() {
const s = this.support;
if (!s)
return { x: 0, y: 0 };
const depth = (s.profile[clamp(Math.floor(this.x / s.binSize), 0, s.profile.length - 1)] ?? 0) / s.binSize;
return {
x: s.x + this.x * Math.cos(s.angle) + depth * Math.sin(s.angle),
y: s.y + this.x * Math.sin(s.angle) - depth * Math.cos(s.angle),
};
}
change(phase) {
this.phase = phase;
this.elapsed = 0;
this.context.emit('yeti:phase', { phase, surfaceId: this.support?.id });
}
spawn(preferred) {
if (!this.context.getConfig().interactions.yeti.enabled || this.support)
return;
const surfaces = this.context.getSurfaces();
const support = preferred && usable(preferred)
? preferred
: (surfaces.find((s) => usable(s) && s.element.hasAttribute('data-weather-yeti')) ??
surfaces.find(usable));
if (!support)
return;
const margin = Math.min(36 * this.context.getConfig().interactions.yeti.size, support.width / 4);
const localX = support.width * 0.18;
if (support.exposed?.[Math.floor(localX / support.binSize)] === 0)
return;
this.support = support;
this.x = clamp(localX, margin, support.width - margin);
this.age = 0;
this.wait = 0;
this.change('arriving');
this.context.emit('yeti:spawned', { surfaceId: support.id });
}
spill() {
const p = this.point();
if (this.held > 0)
this.context.spawnClump(p.x, p.y - 18, this.held, 0, -25);
this.held = 0;
if (this.projectile) {
const scroll = offset(this.support), b = this.projectile;
this.context.spawnClump(b.x - scroll.x, b.y - scroll.y, b.mass, b.vx, b.vy);
this.projectile = undefined;
}
}
clear(recycle = false) {
if (recycle)
this.spill();
this.support = undefined;
this.target = undefined;
this.projectile = undefined;
this.held = 0;
this.wait = 0;
this.phase = 'hidden';
}
update(dt) {
const settings = this.context.getConfig().interactions.yeti;
if (!settings.enabled) {
if (this.support)
this.clear(true);
return;
}
if (this.support &&
(!this.context.getSurfaces().includes(this.support) ||
!this.support.accumulation ||
this.support.width < 150 ||
Math.abs(this.support.angle) >= 0.2)) {
this.clear(true);
return;
}
if (!this.support) {
this.spawn();
return;
}
if (!this.support.visible || this.support.occluded)
return;
dt = clamp(dt, 0, 1 / 15);
this.elapsed += dt;
this.age += dt;
this.wait = Math.max(0, this.wait - dt);
const s = this.support;
const point = this.point();
const scale = settings.size;
if (this.projectile) {
const ball = this.projectile, scroll = offset(s);
const from = { x: ball.x - scroll.x, y: ball.y - scroll.y };
ball.x += ball.vx * dt;
ball.y += ball.vy * dt + 220 * dt * dt;
ball.vy += 440 * dt;
ball.age += dt;
const to = { x: ball.x - scroll.x, y: ball.y - scroll.y };
if (this.world.hit(from, to, radius(ball.mass), ball.vx)) {
this.context.spawnClump(to.x, to.y, ball.mass, ball.vx * 0.15, -60);
this.projectile = undefined;
this.context.emit('yeti:hit', { surfaceId: s.id });
this.change('celebrating');
}
else if (ball.age > 1.8 || Math.hypot(to.x - point.x, to.y - point.y) > 1000) {
this.spill();
this.context.emit('yeti:miss', { surfaceId: s.id });
this.change('shrugging');
}
}
switch (this.phase) {
case 'arriving':
if (this.elapsed > 1.1)
this.change('idle');
break;
case 'idle': {
if (this.wait > 0)
break;
this.target = this.world
.targets()
.filter((t) => t.support === s && Math.abs(t.x - point.x) > 65 * scale)[0];
if (this.target) {
this.facing = this.target.localX >= this.x ? 1 : -1;
this.change('sneaking');
}
break;
}
case 'sneaking': {
if (!this.target ||
!this.world.targets().some((t) => t.support === s && t.localX === this.target?.localX)) {
this.change('idle');
break;
}
const stand = clamp(this.target.localX - this.facing * 130 * scale, 30 * scale, s.width - 30 * scale);
const nextX = this.x + clamp(stand - this.x, -38 * dt, 38 * dt);
const bin = Math.floor(nextX / s.binSize);
if (s.exposed?.[bin] === 0) {
this.wait = settings.cooldown;
this.change('idle');
break;
}
this.x = nextX;
if (Math.abs(stand - this.x) < 1)
this.change('gathering');
break;
}
case 'gathering': {
const wanted = Math.PI * (6 * scale) ** 2;
// Never scoop from covered bins or manufacture snow on an empty ledge.
const bin = Math.floor(this.x / s.binSize);
if (s.exposed?.[bin] !== 0)
this.held += this.world.gather(s, this.x, 16 * scale, Math.min(wanted - this.held, wanted * dt));
if (this.elapsed > 1.4) {
if (this.held >= Math.PI * (2 * scale) ** 2)
this.change('packing');
else {
this.spill();
this.wait = 3;
this.change('shrugging');
}
}
break;
}
case 'packing':
if (this.elapsed > 1.05)
this.change('aiming');
break;
case 'aiming':
if (this.elapsed > 0.65)
this.change('throwing');
break;
case 'throwing': {
if (this.elapsed >= 0.25 && this.held > 0) {
const target = this.world
.targets()
.find((t) => t.support === s && t.localX === this.target?.localX);
if (!target) {
this.spill();
this.change('shrugging');
break;
}
const origin = { x: point.x + this.facing * 24 * scale, y: point.y - 38 * scale };
const flight = clamp(Math.abs(target.x - origin.x) / 220, 0.55, 0.95);
const scroll = offset(s);
this.projectile = {
x: origin.x + scroll.x,
y: origin.y + scroll.y,
vx: (target.x - origin.x) / flight,
vy: (target.y - origin.y) / flight - 220 * flight,
age: 0,
mass: this.held,
};
this.held = 0;
this.context.emit('yeti:thrown', { mass: this.projectile.mass, surfaceId: s.id });
}
if (this.elapsed > 0.55)
this.change('watching');
break;
}
case 'celebrating':
case 'shrugging':
if (this.elapsed > 1.6) {
this.wait = settings.cooldown;
this.target = undefined;
this.change('idle');
}
break;
default:
break;
}
}
render(ctx) {
if (!this.support)
return;
const point = this.point();
const scale = this.context.getConfig().interactions.yeti.size;
ctx.save();
if (this.support.options.clip !== false)
for (const clip of this.support.clipRegions) {
ctx.beginPath();
ctx.roundRect(clip.x, clip.y, clip.width, clip.height, clip.radius);
ctx.clip();
}
ctx.translate(point.x, point.y);
ctx.rotate(this.support.angle);
ctx.scale(scale * this.facing, scale);
drawYeti(ctx, this.phase, this.elapsed, this.age, radius(this.held) / scale);
ctx.restore();
if (this.projectile) {
const b = this.projectile, scroll = offset(this.support);
ctx.save();
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#9aaec2';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.arc(b.x - scroll.x, b.y - scroll.y, radius(b.mass), 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
}
/** Original vector artwork: furry silhouette, mittens, tiny horns and expressive poses. */
export function drawYeti(ctx, phase, elapsed, age, ball) {
const gather = phase === 'gathering';
const pack = phase === 'packing';
const happy = phase === 'celebrating';
const shrug = phase === 'shrugging';
const windup = phase === 'aiming' || (phase === 'throwing' && elapsed < 0.25);
const release = phase === 'throwing' && elapsed >= 0.25;
const walk = phase === 'sneaking';
const arrival = phase === 'arriving' ? Math.min(1, elapsed / 0.85) : 1;
const bounce = happy
? -Math.abs(Math.sin(elapsed * 9)) * 7
: walk
? Math.sin(age * 13) * 1.5
: Math.sin(age * 2.5) * 0.65;
ctx.globalAlpha *= arrival;
ctx.translate(0, (1 - arrival) * 12 + bounce + (gather ? 7 : 0));
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = 1.1;
const shape = (x, y, rx, ry, fill, stroke = '#a6bac7') => {
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
};
ctx.save();
ctx.globalAlpha *= 0.14;
shape(0, -1 - bounce, 21, 3, '#182c48', 'transparent');
ctx.restore();
const step = walk ? Math.sin(age * 13) * 4 : 0;
shape(-10, -4 - step, 8, 5, '#e7f2fa');
shape(10, -4 + step, 8, 5, '#e7f2fa');
// A scalloped coat, with rounded tufts rather than a smooth generic circle.
ctx.fillStyle = '#f6fbff';
ctx.strokeStyle = '#a6bac7';
ctx.beginPath();
ctx.moveTo(-17, -9);
ctx.bezierCurveTo(-23, -17, -22, -32, -17, -39);
ctx.lineTo(-19, -43);
ctx.lineTo(-13, -42);
ctx.lineTo(-13, -47);
ctx.lineTo(-7, -45);
ctx.quadraticCurveTo(-2, -53, 3, -46);
ctx.lineTo(8, -50);
ctx.lineTo(10, -44);
ctx.lineTo(16, -46);
ctx.lineTo(16, -40);
ctx.bezierCurveTo(23, -32, 23, -16, 17, -9);
ctx.quadraticCurveTo(13, -5, 10, -9);
ctx.quadraticCurveTo(5, -4, 1, -8);
ctx.quadraticCurveTo(-5, -4, -8, -8);
ctx.quadraticCurveTo(-13, -4, -17, -9);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#ffe0b2';
ctx.strokeStyle = '#bca88e';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * 13, -42);
ctx.quadraticCurveTo(side * 21, -51, side * 16, -53);
ctx.quadraticCurveTo(side * 13, -49, side * 10, -44);
ctx.fill();
ctx.stroke();
}
shape(0, -34, 14, 11, '#d6eaf5', '#b6cfdd');
const blink = Math.sin(age * 1.7) > 0.994 || (happy && Math.sin(elapsed * 6) > 0.3);
for (const eye of [-5.5, 5.5]) {
if (blink) {
ctx.strokeStyle = '#253447';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(eye - 2, -35);
ctx.quadraticCurveTo(eye, -37, eye + 2, -35);
ctx.stroke();
}
else {
shape(eye, -35, 2.3, 3.1, '#253447', '#253447');
shape(eye + 0.7, -36, 0.6, 0.8, '#fff', '#fff');
}
}
shape(-10, -30, 2.7, 1.5, '#f2b8bc', 'transparent');
shape(10, -30, 2.7, 1.5, '#f2b8bc', 'transparent');
ctx.strokeStyle = '#405668';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(-3, -28);
ctx.quadraticCurveTo(0, shrug ? -30 : -24, 3, -28);
ctx.stroke();
// Tangerine scarf and a small belly tuft keep the silhouette readable on snow.
ctx.fillStyle = '#ff7959';
ctx.strokeStyle = '#db5d46';
ctx.beginPath();
ctx.roundRect(-14, -24, 28, 5, 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.roundRect(8, -21, 5, 12, 1.5);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = '#c4d7e3';
ctx.beginPath();
ctx.moveTo(-5, -17);
ctx.lineTo(-2, -13);
ctx.lineTo(0, -16);
ctx.lineTo(3, -13);
ctx.lineTo(6, -17);
ctx.stroke();
const wave = phase === 'arriving' ? Math.sin(elapsed * 16) * 4 : 0;
let left = { x: -24, y: -20 }, right = { x: 24, y: -20 };
if (gather) {
left = { x: 2, y: -9 };
right = { x: 19, y: -7 + Math.sin(age * 15) * 2 };
}
if (pack) {
const squeeze = Math.sin(elapsed * 17) * 2;
left = { x: 1 + squeeze, y: -20 };
right = { x: 19 - squeeze, y: -20 };
}
if (windup) {
right = { x: 22, y: -50 };
left = { x: -21, y: -17 };
}
if (release || phase === 'watching')
right = { x: 31, y: -36 };
if (happy) {
left = { x: -26, y: -42 };
right = { x: 26, y: -42 };
}
if (shrug) {
left = { x: -27, y: -27 };
right = { x: 27, y: -27 };
}
if (phase === 'arriving')
right = { x: 25 + wave, y: -41 };
ctx.strokeStyle = '#b1c7d5';
ctx.lineWidth = 8;
for (const [side, hand] of [
[-1, left],
[1, right],
]) {
ctx.beginPath();
ctx.moveTo(side * 16, -25);
ctx.lineTo(hand.x, hand.y);
ctx.stroke();
}
ctx.strokeStyle = '#f6fbff';
ctx.lineWidth = 6;
for (const [side, hand] of [
[-1, left],
[1, right],
]) {
ctx.beginPath();
ctx.moveTo(side * 16, -25);
ctx.lineTo(hand.x, hand.y);
ctx.stroke();
}
ctx.lineWidth = 0.9;
if (ball > 0) {
const bx = windup ? right.x : 10, by = windup ? right.y - 4 : gather ? -8 : -21;
shape(bx, by, ball, ball * (pack ? 0.9 : 1), '#fff', '#9aafbe');
}
ctx.lineWidth = 1;
shape(left.x, left.y, 5, 5, '#edf7ff');
shape(right.x, right.y, 5, 5, '#edf7ff');
}
//# sourceMappingURL=Yeti.js.map