@fluritheme/fluri
Version:
Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.
780 lines • 35.1 kB
JavaScript
import { snowYAt } from '../physics/profiles.js';
export const radiusFromMass = (mass) => Math.sqrt(Math.max(0, mass) / Math.PI);
export const massFromRadius = (radius) => Math.PI * radius * radius;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const localPoint = (surface, x, y) => ({
x: (x - surface.x) * Math.cos(surface.angle) + (y - surface.y) * Math.sin(surface.angle),
y: -(x - surface.x) * Math.sin(surface.angle) + (y - surface.y) * Math.cos(surface.angle),
});
const worldPoint = (surface, x, y = 0) => ({
x: surface.x + x * Math.cos(surface.angle) - y * Math.sin(surface.angle),
y: surface.y + x * Math.sin(surface.angle) + y * Math.cos(surface.angle),
});
const clipToSurface = (ctx, surface) => {
if (surface.options.clip !== false)
for (const clip of surface.clipRegions) {
ctx.beginPath();
ctx.roundRect(clip.x, clip.y, clip.width, clip.height, clip.radius);
ctx.clip();
}
};
const depthAt = (surface, x) => (surface.profile[clamp(Math.floor(x / surface.binSize), 0, surface.profile.length - 1)] ?? 0) /
surface.binSize;
/** Removes real stored mass, never visual depth. Used by gestures and the plough. */
export function gatherMass(surface, localX, radius, requested) {
if (!(requested > 0) || !(radius > 0))
return 0;
let remaining = requested;
const first = Math.max(0, Math.floor((localX - radius) / surface.binSize));
const last = Math.min(surface.profile.length - 1, Math.floor((localX + radius) / surface.binSize));
for (let bin = first; bin <= last && remaining > 0; bin++) {
const available = Math.max(0, surface.profile[bin] ?? 0);
const removed = Math.min(available, remaining);
surface.profile[bin] = available - removed;
remaining -= removed;
}
return requested - remaining;
}
/** Recognizes coherent loops with bounded history; ordinary straight cursor motion cannot activate it. */
export class CircleRecognizer {
points = [];
reset() {
this.points.length = 0;
}
sample(x, y, time) {
if (![x, y, time].every(Number.isFinite))
return false;
this.points.push({ x, y, time });
this.points = this.points.filter((point) => time - point.time <= 1.4).slice(-96);
if (this.points.length < 14)
return false;
const duration = time - (this.points[0]?.time ?? time);
if (duration < 0.3 || duration > 1.4)
return false;
const center = this.points.reduce((sum, point) => ({
x: sum.x + point.x / this.points.length,
y: sum.y + point.y / this.points.length,
}), { x: 0, y: 0 });
const radii = this.points.map((point) => Math.hypot(point.x - center.x, point.y - center.y));
const mean = radii.reduce((sum, radius) => sum + radius, 0) / radii.length;
if (mean < 8 ||
mean > 90 ||
radii.some((radius) => radius < mean * 0.45 || radius > mean * 1.6))
return false;
let sweep = 0;
let absolute = 0;
for (let index = 1; index < this.points.length; index++) {
const a = this.points[index - 1];
const b = this.points[index];
if (!a || !b)
continue;
const delta = Math.atan2((a.x - center.x) * (b.y - center.y) - (a.y - center.y) * (b.x - center.x), (a.x - center.x) * (b.x - center.x) + (a.y - center.y) * (b.y - center.y));
sweep += delta;
absolute += Math.abs(delta);
}
if (Math.abs(sweep) >= Math.PI * 1.7 && Math.abs(sweep) / Math.max(absolute, 0.001) > 0.85) {
this.reset();
return true;
}
return false;
}
}
export class Actors {
context;
balls = [];
snowmen = [];
ploughs = [];
active;
nextId = 1;
circle = new CircleRecognizer();
lastPointer;
constructor(context) {
this.context = context;
}
get gathering() {
return this.active !== undefined;
}
get counts() {
return {
snowballs: this.balls.length,
snowmen: this.snowmen.length,
ploughs: this.ploughs.length,
};
}
get snowballCount() {
return this.balls.length;
}
get snowmanCount() {
return this.snowmen.length;
}
selected(options) {
return this.context
.getSurfaces()
.find((surface) => surface.visible &&
(options.surface
? surface.element === options.surface
: options.selector
? surface.element.matches(options.selector)
: surface.element.hasAttribute('data-weather-surface')));
}
createBall(x, y, radius, mass = massFromRadius(radius)) {
const config = this.context.getConfig().interactions.snowballs;
if (!config.enabled || this.balls.length + this.snowmen.length * 3 >= config.maxCount)
return undefined;
const ball = {
id: this.nextId++,
x,
y,
radius: radiusFromMass(mass),
mass,
vx: 0,
vy: 0,
state: 'airborne',
localX: 0,
};
this.balls.push(ball);
this.context.emit('snowball:created', { id: ball.id, mass });
return ball;
}
trigger(type, options = {}) {
const support = this.selected(options);
if (type === 'snowburst') {
const matches = (ball) => options.surface || options.selector
? ball.support === support
: options.x !== undefined && options.y !== undefined
? Math.hypot(ball.x - options.x, ball.y - options.y) <= (options.radius ?? 90)
: true;
for (let index = this.balls.length - 1; index >= 0; index--) {
const ball = this.balls[index];
if (!ball || !matches(ball))
continue;
this.fragment(ball);
if (ball === this.active)
this.active = undefined;
this.balls.splice(index, 1);
}
for (let index = this.snowmen.length - 1; index >= 0; index--) {
const snowman = this.snowmen[index];
if (!snowman)
continue;
if ((options.surface || options.selector) && snowman.support !== support)
continue;
const point = worldPoint(snowman.support, snowman.localX);
if (options.x !== undefined &&
options.y !== undefined &&
Math.hypot(point.x - options.x, point.y - options.y) > (options.radius ?? 120))
continue;
let top = -depthAt(snowman.support, snowman.localX);
for (const ball of snowman.balls) {
const position = worldPoint(snowman.support, snowman.localX, top - ball.radius);
this.fragment({ ...ball, ...position });
top -= ball.radius * 2;
}
this.snowmen.splice(index, 1);
this.context.emit('snowman:removed', {
surfaceId: snowman.support.id,
reason: 'break-apart',
});
}
}
if (type === 'plough') {
const route = support?.element.hasAttribute('data-plough-route')
? support
: !options.surface && !options.selector
? this.context
.getSurfaces()
.find((surface) => surface.visible && surface.element.hasAttribute('data-plough-route'))
: undefined;
if (!route ||
!route.accumulation ||
this.ploughs.length >= 2 ||
this.ploughs.some((actor) => actor.support === route) ||
Math.abs(route.angle) > 0.35)
return;
const direction = options.direction === 'left' ? -1 : 1;
this.ploughs.push({
support: route,
x: direction > 0 ? 0 : route.width,
direction,
style: options.style ?? 'classic',
speed: clamp(options.speed ?? Math.max(45, route.width / 4), 10, 600),
mass: 0,
previousBin: direction > 0 ? -1 : route.profile.length,
});
this.context.emit('plough:start', {
surfaceId: route.id,
direction: options.direction ?? 'right',
});
}
if (type === 'snowball') {
const config = this.context.getConfig().interactions.snowballs;
const radius = clamp(options.radius ?? 15, config.minRadius, config.maxRadius);
const localX = support
? clamp(options.x === undefined
? support.width / 2
: localPoint(support, options.x, options.y ?? support.y).x, radius, Math.max(radius, support.width - radius))
: 0;
const point = support
? worldPoint(support, localX, -radius - depthAt(support, localX) - 1)
: { x: options.x ?? 120, y: options.y ?? 90 };
// Manual spawning is an explicit snow source, analogous to addSnow(). Deposited mass is reused first.
if (this.balls.length + this.snowmen.length * 3 >= config.maxCount || !config.enabled)
return;
if (support)
gatherMass(support, localX, radius * 2, massFromRadius(radius));
const ball = this.createBall(point.x, point.y, radius);
if (ball && support) {
ball.support = support;
ball.localX = localX;
ball.state = 'resting';
}
}
if (type === 'snowman' &&
support &&
this.context.getConfig().interactions.snowman.enabled &&
this.snowmen.length < 4) {
const config = this.context.getConfig().interactions.snowballs;
if (this.balls.length + this.snowmen.length * 3 + 3 > config.maxCount ||
!config.enabled ||
support.width < config.minRadius * 6)
return;
const base = clamp(options.radius ?? 23, config.minRadius, config.maxRadius);
const localX = support.width / 2;
let top = -depthAt(support, localX);
for (const radius of [base, base * 0.74, base * 0.52]) {
const point = worldPoint(support, localX, top - radius);
gatherMass(support, localX, base * 2, massFromRadius(radius));
const ball = this.createBall(point.x, point.y, radius);
if (ball) {
ball.support = support;
ball.localX = localX;
ball.state = 'resting';
top -= radius * 2;
}
}
this.assemble();
}
}
pointer(input) {
const settings = this.context.getConfig().interactions.snowballs;
if (!settings.enabled) {
this.release(true);
return;
}
if (input.kind === 'cancel') {
this.release(true);
this.circle.reset();
return;
}
if (input.kind === 'up') {
this.release(false, input);
return;
}
const hit = this.balls.find((ball) => Math.hypot(input.x - ball.x, input.y - ball.y) <= ball.radius + 8);
const hoverMode = settings.activation === 'hover-circle';
const loop = hoverMode && this.circle.sample(input.x, input.y, input.time);
if ((input.kind === 'down' && input.shiftKey) || loop) {
if (hit)
this.active = hit;
else {
const support = this.context
.getSurfaces()
.filter((surface) => surface.visible && !surface.occluded && surface.accumulation)
.map((surface) => {
const local = localPoint(surface, input.x, input.y);
const x = clamp(local.x, 0, surface.width);
const first = Math.max(0, Math.floor((x - 32) / surface.binSize));
const last = Math.min(surface.profile.length - 1, Math.floor((x + 32) / surface.binSize));
const snowy = surface.profile.some((mass, bin) => bin >= first && bin <= last && surface.exposed?.[bin] !== 0 && mass > 0.01);
const distance = Math.hypot(local.x - x, local.y - snowYAt(surface, x));
return {
surface,
local,
distance,
eligible: snowy && local.x >= -12 && local.x <= surface.width + 12 && distance < 36,
};
})
.filter((candidate) => candidate.eligible)
.sort((a, b) => a.distance - b.distance)[0]?.surface;
if (support && this.balls.length + this.snowmen.length * 3 < settings.maxCount) {
const local = localPoint(support, input.x, input.y);
const mass = gatherMass(support, local.x, 32, massFromRadius(settings.minRadius));
if (mass > 0) {
this.active = this.createBall(input.x, input.y, radiusFromMass(mass), mass);
if (this.active)
this.active.state = 'gathering';
}
}
}
if (this.active) {
this.active.state = 'gathering';
delete this.active.support;
}
}
if (this.active) {
if (!hoverMode && (!input.pressed || !input.shiftKey)) {
this.release(false, input);
return;
}
const ball = this.active;
const previous = this.lastPointer;
const distance = previous ? Math.hypot(input.x - previous.x, input.y - previous.y) : 0;
for (const surface of this.context.getSurfaces()) {
if (!surface.visible || !surface.accumulation)
continue;
const local = localPoint(surface, input.x, input.y);
if (local.x < -ball.radius ||
local.x > surface.width + ball.radius ||
Math.abs(local.y - snowYAt(surface, clamp(local.x, 0, surface.width))) > ball.radius + 24)
continue;
const mass = gatherMass(surface, local.x, ball.radius + 12, Math.min(massFromRadius(settings.maxRadius) - ball.mass, distance * 5));
ball.mass += mass;
ball.radius = radiusFromMass(ball.mass);
}
ball.x = input.x;
ball.y = input.y;
ball.vx = clamp(input.vx, -1200, 1200);
ball.vy = clamp(input.vy, -1200, 1200);
if (hoverMode && previous && input.time - previous.time > 0.7)
this.release(false, input);
}
this.lastPointer = input;
}
release(cancel, input) {
if (!this.active)
return;
const ball = this.active;
ball.state = 'airborne';
ball.vx = cancel ? 0 : clamp(input?.vx ?? ball.vx, -1200, 1200);
ball.vy = cancel ? 0 : clamp(input?.vy ?? ball.vy, -1200, 1200);
this.context.emit('snowball:thrown', { id: ball.id, vx: ball.vx, vy: ball.vy });
this.active = undefined;
this.lastPointer = undefined;
}
update(dt, time) {
dt = clamp(dt, 0, 1 / 15);
const surfaces = this.context.getSurfaces();
const config = this.context.getConfig();
if (this.active &&
config.interactions.snowballs.activation === 'hover-circle' &&
time - (this.lastPointer?.time ?? time) > 0.4)
this.release(false);
for (let index = this.balls.length - 1; index >= 0; index--) {
const ball = this.balls[index];
if (!ball || ball === this.active)
continue;
const maxRadius = config.interactions.snowballs.maxRadius;
if (ball.radius > maxRadius) {
const excess = ball.mass - massFromRadius(maxRadius);
this.context.spawnClump(ball.x, ball.y - ball.radius, excess, ball.vx, -25);
ball.mass -= excess;
ball.radius = maxRadius;
}
const meltRate = config.snow.accumulation.meltRate;
if (meltRate > 0) {
ball.radius = Math.max(0, ball.radius - meltRate * dt);
ball.mass = massFromRadius(ball.radius);
if (ball.radius < 0.5) {
this.balls.splice(index, 1);
continue;
}
}
if (ball.support) {
if (surfaces.includes(ball.support) &&
ball.support.visible &&
Math.abs(ball.support.angle) < 0.4 &&
Math.hypot(ball.support.acceleration.x, ball.support.acceleration.y) < 1000) {
const point = worldPoint(ball.support, ball.localX, -ball.radius - depthAt(ball.support, ball.localX));
ball.x = point.x;
ball.y = point.y;
continue;
}
ball.vx = ball.support.velocity.x;
ball.vy = ball.support.velocity.y - 30;
delete ball.support;
ball.state = 'airborne';
}
const previous = { x: ball.x, y: ball.y };
ball.vy += 440 * dt;
ball.vx *= Math.exp(-0.13 * dt);
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
let collision;
for (const surface of surfaces) {
if (!surface.visible || !surface.accumulation || surface.material === 'no-accumulation')
continue;
const a = localPoint(surface, previous.x, previous.y);
const b = localPoint(surface, ball.x, ball.y);
const top = -depthAt(surface, b.x);
if (b.y <= a.y ||
a.y + ball.radius > top + 2 ||
b.y + ball.radius < top ||
b.x < 0 ||
b.x > surface.width)
continue;
const fraction = clamp((top - ball.radius - a.y) / (b.y - a.y), 0, 1);
if (!collision || fraction < collision.fraction)
collision = { surface, localX: a.x + (b.x - a.x) * fraction, fraction };
}
if (collision) {
const surface = collision.surface;
const point = worldPoint(surface, collision.localX, -ball.radius - depthAt(surface, collision.localX));
ball.x = point.x;
ball.y = point.y;
const nx = -Math.sin(surface.angle);
const ny = Math.cos(surface.angle);
const normalSpeed = ball.vx * nx + ball.vy * ny;
ball.vx -= 1.28 * normalSpeed * nx;
ball.vy -= 1.28 * normalSpeed * ny;
ball.vx *= surface.material === 'slippery' ? 0.94 : 0.65;
if (Math.abs(normalSpeed) < 75 && Math.abs(ball.vx) < 70 && Math.abs(surface.angle) < 0.4) {
ball.support = surface;
ball.localX = collision.localX;
ball.state = 'resting';
ball.vx = 0;
ball.vy = 0;
}
}
if (ball.y > 100000 || Math.abs(ball.x) > 100000 || !Number.isFinite(ball.x + ball.y))
this.balls.splice(index, 1);
// Released balls leave the scene promptly instead of accumulating invisible entities.
else if (typeof window !== 'undefined' &&
(ball.y > window.innerHeight + 500 || ball.x < -500 || ball.x > window.innerWidth + 500))
this.balls.splice(index, 1);
}
this.collideBalls();
this.assemble(config.interactions.snowman.enabled);
for (let index = this.snowmen.length - 1; index >= 0; index--) {
const snowman = this.snowmen[index];
if (!snowman)
continue;
snowman.age += dt;
const meltRate = config.snow.accumulation.meltRate;
if (meltRate > 0)
for (const ball of snowman.balls) {
ball.radius = Math.max(0, ball.radius - meltRate * dt);
ball.mass = massFromRadius(ball.radius);
}
if ((snowman.balls[0]?.radius ?? 0) < 0.5) {
this.snowmen.splice(index, 1);
this.context.emit('snowman:removed', { surfaceId: snowman.support.id, reason: 'melted' });
continue;
}
if (!surfaces.includes(snowman.support) ||
!snowman.support.visible ||
Math.abs(snowman.support.angle) > 0.4 ||
Math.hypot(snowman.support.acceleration.x, snowman.support.acceleration.y) > 1000) {
let top = -depthAt(snowman.support, snowman.localX);
for (const ball of snowman.balls) {
const point = worldPoint(snowman.support, snowman.localX, top - ball.radius);
ball.x = point.x;
ball.y = point.y;
ball.vx = snowman.support.velocity.x;
ball.vy = snowman.support.velocity.y;
delete ball.support;
ball.state = 'airborne';
this.balls.push(ball);
top -= ball.radius * 2;
}
this.snowmen.splice(index, 1);
this.context.emit('snowman:removed', { surfaceId: snowman.support.id });
}
}
for (let index = this.ploughs.length - 1; index >= 0; index--) {
const plough = this.ploughs[index];
if (!plough)
continue;
if (!surfaces.includes(plough.support) || !plough.support.visible) {
this.spill(plough);
this.ploughs.splice(index, 1);
continue;
}
plough.x += plough.direction * plough.speed * dt;
const bin = clamp(Math.floor(plough.x / plough.support.binSize), 0, plough.support.profile.length - 1);
for (let cursor = plough.previousBin + plough.direction; plough.direction > 0 ? cursor <= bin : cursor >= bin; cursor += plough.direction) {
const mass = plough.support.profile[cursor] ?? 0;
plough.mass += mass;
if (cursor >= 0 && cursor < plough.support.profile.length)
plough.support.profile[cursor] = 0;
}
plough.previousBin = bin;
if (plough.x < 0 || plough.x > plough.support.width) {
this.spill(plough);
this.ploughs.splice(index, 1);
this.context.emit('plough:complete', { surfaceId: plough.support.id, mass: plough.mass });
}
}
}
fragment(ball) {
const count = Math.min(16, Math.max(4, Math.ceil(ball.radius / 2)));
for (let index = 0; index < count; index++) {
const angle = (index / count) * Math.PI * 2;
this.context.spawnClump(ball.x + Math.cos(angle) * ball.radius * 0.5, ball.y + Math.sin(angle) * ball.radius * 0.5, ball.mass / count, ball.vx + Math.cos(angle) * 100, ball.vy + Math.sin(angle) * 80 - 60);
}
}
spill(plough) {
if (plough.mass <= 0)
return;
const point = worldPoint(plough.support, clamp(plough.x, 0, plough.support.width));
const count = Math.min(12, Math.max(1, Math.ceil(plough.mass / 60)));
for (let index = 0; index < count; index++)
this.context.spawnClump(point.x + plough.direction * (6 + index * 2), point.y - 4, plough.mass / count, plough.direction * (55 + index * 5), -35 - index * 4);
}
collideBalls() {
for (let a = 0; a < this.balls.length; a++)
for (let b = a + 1; b < this.balls.length; b++) {
const first = this.balls[a];
const second = this.balls[b];
if (!first || !second || first === this.active || second === this.active)
continue;
const dx = second.x - first.x;
const dy = second.y - first.y;
const distance = Math.hypot(dx, dy);
const total = first.radius + second.radius;
if (distance >= total || distance < 0.001 || (first.support && second.support))
continue;
const nx = dx / distance;
const ny = dy / distance;
const lower = dy > 0 ? second : first;
const upper = dy > 0 ? first : second;
if ((lower.support || lower.state === 'resting') &&
Math.abs(upper.x - lower.x) < lower.radius * 0.5 &&
Math.abs(upper.vy) < 160) {
upper.y = lower.y - total;
upper.vx = 0;
upper.vy = 0;
upper.state = 'resting';
continue;
}
const firstWeight = first.support
? 0
: second.support
? 1
: second.mass / (first.mass + second.mass);
const secondWeight = second.support
? 0
: first.support
? 1
: first.mass / (first.mass + second.mass);
first.x -= nx * (total - distance) * firstWeight;
first.y -= ny * (total - distance) * firstWeight;
second.x += nx * (total - distance) * secondWeight;
second.y += ny * (total - distance) * secondWeight;
const relative = (second.vx - first.vx) * nx + (second.vy - first.vy) * ny;
if (relative < 0) {
first.vx += relative * nx * firstWeight * 1.25;
first.vy += relative * ny * firstWeight * 1.25;
second.vx -= relative * nx * secondWeight * 1.25;
second.vy -= relative * ny * secondWeight * 1.25;
}
}
}
assemble(enabled = this.context.getConfig().interactions.snowman.enabled) {
if (!enabled || this.snowmen.length >= 4)
return;
for (const base of [...this.balls]) {
if (!base.support || base.state !== 'resting')
continue;
const candidates = this.balls
.filter((ball) => ball !== base &&
ball.state === 'resting' &&
Math.abs(ball.x - base.x) < base.radius * 0.6 &&
ball.y < base.y &&
ball.radius <= base.radius * 0.95)
.sort((a, b) => b.radius - a.radius);
for (const middle of candidates) {
if (Math.abs(base.y - middle.y - base.radius - middle.radius) > 9)
continue;
const head = candidates.find((ball) => ball !== middle &&
ball.radius <= middle.radius * 0.95 &&
Math.abs(middle.y - ball.y - middle.radius - ball.radius) < 9);
if (!head)
continue;
const balls = [base, middle, head];
this.snowmen.push({ balls, support: base.support, localX: base.localX, age: 0 });
for (const ball of balls) {
const index = this.balls.indexOf(ball);
if (index >= 0)
this.balls.splice(index, 1);
this.context.emit('snowball:stacked', { id: ball.id });
}
this.context.emit('snowman:created', {
surfaceId: base.support.id,
mass: balls.reduce((sum, ball) => sum + ball.mass, 0),
});
return;
}
}
}
render(ctx, time) {
for (const ball of this.balls) {
ctx.save();
if (ball.support)
clipToSurface(ctx, ball.support);
drawSnowball(ctx, ball.x, ball.y, ball.radius);
ctx.restore();
}
for (const snowman of this.snowmen) {
ctx.save();
clipToSurface(ctx, snowman.support);
const point = worldPoint(snowman.support, snowman.localX, -depthAt(snowman.support, snowman.localX));
ctx.translate(point.x, point.y);
ctx.rotate(snowman.support.angle);
let top = 0;
for (const ball of snowman.balls) {
drawSnowball(ctx, 0, top - ball.radius, ball.radius);
top -= ball.radius * 2;
}
const base = snowman.balls[0];
const middle = snowman.balls[1];
const head = snowman.balls[2];
if (base && middle && head) {
const headY = -base.radius * 2 - middle.radius * 2 - head.radius;
const blink = Math.sin(time * 0.7 + snowman.localX) > 0.998;
ctx.fillStyle = '#142334';
for (const x of [-head.radius * 0.3, head.radius * 0.3]) {
ctx.beginPath();
ctx.ellipse(x, headY - head.radius * 0.12, 1.5, blink ? 0.35 : 2, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#f19048';
ctx.beginPath();
ctx.moveTo(-1, headY + 1);
ctx.lineTo(head.radius * 0.75, headY + 3);
ctx.lineTo(-1, headY + 5);
ctx.fill();
ctx.fillStyle = '#ca4764';
ctx.fillRect(-middle.radius * 0.8, headY + head.radius - 2, middle.radius * 1.6, 5);
ctx.save();
ctx.translate(middle.radius * 0.5, headY + head.radius);
ctx.rotate(Math.sin(time * 1.7) * 0.07);
ctx.fillRect(0, 0, 5, 14);
ctx.restore();
for (let index = 0; index < 3; index++) {
ctx.fillStyle = '#26384e';
ctx.beginPath();
ctx.arc(0, -base.radius * 2 - middle.radius * 1.5 + index * middle.radius * 0.5, 1.7, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = '#7e5a41';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
for (const direction of [-1, 1]) {
const armY = -base.radius * 2 - middle.radius;
const tipY = armY - 10 - (direction > 0 ? Math.sin(time * 2) * 4 : 0);
ctx.beginPath();
ctx.moveTo(direction * middle.radius * 0.8, armY);
ctx.lineTo(direction * (middle.radius + 15), tipY);
ctx.lineTo(direction * (middle.radius + 18), tipY - 6);
ctx.moveTo(direction * (middle.radius + 12), tipY + 1);
ctx.lineTo(direction * (middle.radius + 18), tipY + 4);
ctx.stroke();
}
}
ctx.restore();
}
for (const plough of this.ploughs) {
const point = worldPoint(plough.support, plough.x);
ctx.save();
clipToSurface(ctx, plough.support);
ctx.translate(point.x, point.y);
ctx.rotate(plough.support.angle);
ctx.scale(plough.direction, 1);
drawPlough(ctx, plough.style);
if (plough.mass > 0)
drawSnowball(ctx, 25 + Math.min(16, radiusFromMass(plough.mass)), -Math.min(20, radiusFromMass(plough.mass)), Math.min(20, radiusFromMass(plough.mass)));
ctx.restore();
}
}
clear() {
this.active = undefined;
this.balls.length = 0;
this.snowmen.length = 0;
this.ploughs.length = 0;
this.circle.reset();
this.lastPointer = undefined;
}
destroy() {
this.clear();
}
}
export function drawSnowball(ctx, x, y, radius) {
ctx.save();
const shade = ctx.createRadialGradient(x - radius * 0.35, y - radius * 0.4, radius * 0.1, x, y, radius);
shade.addColorStop(0, '#ffffff');
shade.addColorStop(0.7, '#e8f3ff');
shade.addColorStop(1, '#b2cee9');
ctx.fillStyle = shade;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** Original tiny vector vehicle; no bitmap downloads or third-party artwork. */
export function drawPlough(ctx, style = 'classic') {
const body = style === 'festive' ? '#bd5269' : style === 'minimal' ? '#bdccdc' : '#efb93c';
const roof = style === 'festive' ? '#40745d' : style === 'minimal' ? '#e6edf5' : '#f7d85a';
ctx.fillStyle = '#233349';
ctx.beginPath();
ctx.roundRect(-21, -10, 39, 9, 4);
ctx.fill();
ctx.fillStyle = body;
ctx.beginPath();
ctx.roundRect(-17, -24, 20, 18, [5, 5, 0, 0]);
ctx.fill();
ctx.fillRect(2, -16, 16, 10);
ctx.fillStyle = '#bce8f4';
ctx.fillRect(-13, -21, 11, 10);
ctx.fillStyle = '#12243a';
ctx.fillRect(-7, -21, 2, 10);
ctx.fillStyle = roof;
ctx.fillRect(-17, -27, 21, 3);
if (style !== 'minimal') {
ctx.fillStyle = '#f9b867';
ctx.beginPath();
ctx.arc(-5, -29, 3, 0, Math.PI * 2);
ctx.fill();
}
if (style === 'festive') {
ctx.fillStyle = '#f4e5ba';
ctx.fillRect(4, -14, 12, 3);
ctx.fillRect(9, -17, 3, 11);
ctx.strokeStyle = '#7fc49a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-16, -25);
ctx.lineTo(1, -25);
ctx.stroke();
for (const x of [-14, -8, -2]) {
ctx.fillStyle = '#ffe2a4';
ctx.beginPath();
ctx.arc(x, -24, 1.3, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.strokeStyle = '#b8d2e1';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(17, -8);
ctx.lineTo(24, -5);
ctx.stroke();
ctx.fillStyle = '#b1c7db';
ctx.beginPath();
ctx.moveTo(23, -17);
ctx.lineTo(29, -16);
ctx.lineTo(32, -1);
ctx.lineTo(25, -1);
ctx.closePath();
ctx.fill();
for (const x of [-10, 12]) {
ctx.fillStyle = '#172334';
ctx.beginPath();
ctx.arc(x, -3, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#8090a2';
ctx.beginPath();
ctx.arc(x, -3, 2.5, 0, Math.PI * 2);
ctx.fill();
}
}
//# sourceMappingURL=Actors.js.map