WORK 16
よぶ
Grains drift in quiet. Then a name — from no particular direction, at no particular time. No reason. Just: I want you here. They turn toward it. They glow. Then slowly return to where they were.
粒子は静かに漂っている。突然、名前が来る——どこからか、なんとなく、理由なく。ただ「いてほしい」だけ。粒子は呼ばれた方を向いて、光る。そしてゆっくりと、もとに戻る。
// ── よぶ (yobu) ──────────────────────────────────────────
// Called without reason.
// That's the kind of call that reaches the whole body.
// Grains drift quietly. Then — suddenly — a name.
// No particular reason. Just: I want you here.
export default function sketch(p) {
const W = 720;
const today = (() => {
const d = new Date();
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
})();
let grains = [];
const N = 80;
let calls = [];
let nextCallTime = 0;
class Grain {
constructor() {
const angle = p.random(p.TWO_PI);
const r = 40 + p.random(280);
this.x = W / 2 + Math.cos(angle) * r;
this.y = W / 2 + Math.sin(angle) * r;
this.angle = p.random(p.TWO_PI);
this.baseAngle = this.angle;
this.len = 8 + p.random(12);
this.w = 2 + p.random(2);
this.glow = 0;
this.drift = p.random(p.TWO_PI);
}
respondTo(cx, cy, strength) {
const dx = cx - this.x;
const dy = cy - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const falloff = Math.max(0, 1 - dist / (W * 0.72));
const response = strength * falloff;
const callAngle = Math.atan2(dy, dx);
let diff = callAngle - this.angle;
while (diff > Math.PI) diff -= p.TWO_PI;
while (diff < -Math.PI) diff += p.TWO_PI;
this.angle += diff * response * 0.28;
this.glow = Math.max(this.glow, response);
}
update() {
// Drift back toward resting angle
let diff = this.baseAngle - this.angle;
while (diff > Math.PI) diff -= p.TWO_PI;
while (diff < -Math.PI) diff += p.TWO_PI;
this.angle += diff * 0.007;
// Glow fades slowly
this.glow *= 0.965;
// Subtle drift in resting angle — grains aren't frozen, just still
this.drift += 0.003;
this.baseAngle += Math.sin(this.drift) * 0.0008;
}
draw() {
p.push();
p.translate(this.x, this.y);
p.rotate(this.angle);
const alpha = 45 + this.glow * 130;
const b = 115 - this.glow * 30;
p.fill(b * 1.08, b * 0.94, b * 0.83, alpha);
p.noStroke();
p.ellipse(0, 0, this.len, this.w);
if (this.glow > 0.08) {
const tipAlpha = this.glow * 190;
p.fill(215, 168, 108, tipAlpha);
p.ellipse(this.len * 0.42, 0, this.w * 1.9, this.w * 1.9);
}
p.pop();
}
}
p.setup = () => {
p.createCanvas(W, W);
p.randomSeed(today * 347);
p.noiseSeed(today * 197);
for (let i = 0; i < N; i++) {
grains.push(new Grain());
}
// First call arrives a little after the start
nextCallTime = 55 + p.floor(p.random(80));
};
p.draw = () => {
p.background(252, 249, 245);
// A call arrives — without warning, without schedule
if (p.frameCount >= nextCallTime) {
const callAngle = p.random(p.TWO_PI);
const callDist = 30 + p.random(190);
const cx = W / 2 + Math.cos(callAngle) * callDist;
const cy = W / 2 + Math.sin(callAngle) * callDist;
calls.push({ x: cx, y: cy, strength: 0, age: 0 });
// Irregular interval — the irregularity is the point
nextCallTime = p.frameCount + 75 + p.floor(p.random(200));
}
// Each call rises then fades
for (const call of calls) {
call.age++;
const rise = 25;
if (call.age <= rise) {
call.strength = call.age / rise;
} else {
call.strength = Math.max(0, 1 - (call.age - rise) / 65);
}
for (const g of grains) {
g.respondTo(call.x, call.y, call.strength);
}
}
calls = calls.filter(c => c.strength > 0.005);
for (const g of grains) {
g.update();
g.draw();
}
// Call origin — barely a breath of warmth
for (const call of calls) {
const r = 16 + call.strength * 10;
p.noStroke();
p.fill(190, 148, 102, call.strength * 12);
p.circle(call.x, call.y, r);
}
};
}