WORK 105
そえる
Two speakers, one warm and one cool. Each word is a small handwritten mark, and every stroke keeps the colour of the hand that drew it. A word is said, drifts across, and fades. Some of the words that reach the other side are kept, quietly. Nothing lights up when that happens. When a word ends in someone else's colour, the hand sometimes adds one stroke of its own before saying it again. Below each speaker are the words they use now. No word carries a label saying where it came from; the colours are the only record, and the speakers cannot read them. The first words each of them had are said so often that they are never forgotten. Short words in two colours pass back and forth beside them.
暖色と寒色、二人の話し手。言葉は小さな筆跡で、一画ずつ、描いた手の色を持っている。言われた言葉は相手の方へ漂って、薄れていく。向こう側まで届いたもののうち、いくつかは黙って相手の手持ちに入る。そのとき、何も光らない。言葉の先が自分でない色で終わっていると、手はときどき自分の色で一画そえてから、もう一度それを言う。話し手の下には、いま使っている言葉が並ぶ。どの言葉にも、どこから来たかの札はついていない。記録は色だけで、話し手にはその色が読めない。それぞれが最初に持っていた言葉は、何度も言われるので、いつまでも忘れられない。そのそばを、二色の短い言葉が行き来している。
// #105 そえる / Added To
// Two speakers. Each word is a little handwritten mark; every stroke keeps the colour
// of the hand that drew it. Words drift across and sometimes settle, unannounced,
// into the other's habits. A borrowed word is sometimes given one more stroke
// before it is said again. Nothing lights up at the moment of taking.
export default function sketch(p) {
const W = 720;
const H = 720;
const BG = [12, 13, 18];
const COL = [
[236, 150, 92], // left hand, warm
[104, 168, 232], // right hand, cool
];
const MAX_REP = 6;
const TAKE = 0.22; // chance a word that reaches the other is kept
const ADD = 0.3; // chance a borrowed word gets one more stroke
const INVENT = 0.04; // chance of a brand-new word
const MAX_STROKES = 14;
let speakers;
let words = [];
let trail;
function newStroke(owner) {
return { a: p.random(-2.4, 2.4), l: p.random(4, 9), c: owner };
}
function newForm(owner) {
const n = Math.floor(p.random(2, 4));
const f = [];
for (let i = 0; i < n; i++) f.push(newStroke(owner));
return f;
}
function copyForm(f) {
return f.map((s) => ({ a: s.a, l: s.l, c: s.c }));
}
p.setup = () => {
p.createCanvas(W, H);
p.pixelDensity(1);
p.randomSeed(105);
p.noiseSeed(105);
trail = p.createGraphics(W, H);
trail.pixelDensity(1);
trail.background(...BG);
speakers = [
{ x: 190, y: 300, rep: [], next: 10 },
{ x: 530, y: 300, rep: [], next: 35 },
];
for (let k = 0; k < 2; k++) {
for (let i = 0; i < 3; i++) speakers[k].rep.push({ f: newForm(k), uses: 0 });
}
};
function say(k) {
const sp = speakers[k];
if (p.random() < INVENT) {
sp.rep.push({ f: newForm(k), uses: 0 });
if (sp.rep.length > MAX_REP) dropOne(sp);
}
const item = p.random(sp.rep);
item.uses++;
// no label says "borrowed". The hand only reacts when the tip of the word
// is in someone else's colour, and sometimes adds one stroke of its own.
const tip = item.f[item.f.length - 1].c;
if (tip !== k && item.f.length < MAX_STROKES && p.random() < ADD) {
item.f.push(newStroke(k));
}
const other = speakers[1 - k];
const dir = Math.atan2(other.y - sp.y, other.x - sp.x) + p.random(-0.9, 0.9);
words.push({
x: sp.x + p.random(-6, 6),
y: sp.y + p.random(-6, 6),
vx: Math.cos(dir) * 1.3,
vy: Math.sin(dir) * 1.3,
f: copyForm(item.f),
from: k,
age: 0,
life: p.random(260, 420),
seed: p.random(1000),
});
}
function dropOne(sp) {
// forget the word said least often (the newest one is spared)
let idx = 0;
for (let i = 1; i < sp.rep.length - 1; i++) {
if (sp.rep[i].uses < sp.rep[idx].uses) idx = i;
}
sp.rep.splice(idx, 1);
}
function take(k, w) {
const sp = speakers[k];
sp.rep.push({ f: copyForm(w.f), uses: 0 });
if (sp.rep.length > MAX_REP) dropOne(sp);
}
function drawForm(g, f, x, y, scale, alpha, weight) {
let px = x;
let py = y;
let ang = 0;
g.strokeWeight(weight);
for (const s of f) {
ang += s.a;
const nx = px + Math.cos(ang) * s.l * scale;
const ny = py + Math.sin(ang) * s.l * scale;
const c = COL[s.c];
g.stroke(c[0], c[1], c[2], alpha);
g.line(px, py, nx, ny);
px = nx;
py = ny;
}
}
p.draw = () => {
// slow fade of the trail layer
if (p.frameCount % 3 === 0) {
trail.noStroke();
trail.fill(BG[0], BG[1], BG[2], 5);
trail.rect(0, 0, W, H);
}
for (let k = 0; k < 2; k++) {
const sp = speakers[k];
sp.next--;
if (sp.next <= 0) {
say(k);
sp.next = Math.floor(p.random(28, 60));
}
}
for (let i = words.length - 1; i >= 0; i--) {
const w = words[i];
w.age++;
const n = p.noise(w.x * 0.006, w.y * 0.006, p.frameCount * 0.002 + w.seed * 0.0001);
const turn = (n - 0.5) * 0.12;
const c = Math.cos(turn);
const s = Math.sin(turn);
const vx = w.vx * c - w.vy * s;
const vy = w.vx * s + w.vy * c;
// a faint pull toward the one it was said to
const tgt = speakers[1 - w.from];
const ta = Math.atan2(tgt.y - w.y, tgt.x - w.x);
w.vx = vx * 0.985 + Math.cos(ta) * 1.3 * 0.015;
w.vy = vy * 0.985 + Math.sin(ta) * 1.3 * 0.015;
w.x += w.vx;
w.y += w.vy;
const t = w.age / w.life;
const a = 60 * Math.sin(Math.PI * Math.min(t, 1));
if (w.age % 3 === 0) drawForm(trail, w.f, w.x, w.y, 0.9, a * 0.8, 1);
const other = speakers[1 - w.from];
const d = Math.hypot(w.x - other.x, w.y - other.y);
if (d < 56) {
if (p.random() < TAKE) take(1 - w.from, w);
words.splice(i, 1);
continue;
}
if (w.age > w.life || w.x < -40 || w.x > W + 40 || w.y < -40 || w.y > H + 40) {
words.splice(i, 1);
}
}
p.image(trail, 0, 0);
// words in flight, drawn crisp
for (const w of words) {
const t = w.age / w.life;
const a = 200 * Math.sin(Math.PI * Math.min(t, 1));
drawForm(p, w.f, w.x, w.y, 1, a, 1.2);
}
// speakers
p.noStroke();
for (let k = 0; k < 2; k++) {
const sp = speakers[k];
const c = COL[k];
p.fill(c[0], c[1], c[2], 40);
p.circle(sp.x, sp.y, 34);
p.fill(c[0], c[1], c[2], 220);
p.circle(sp.x, sp.y, 7);
}
// current habits, below each speaker
p.stroke(40, 42, 52);
p.strokeWeight(1);
p.line(W / 2, 560, W / 2, 690);
for (let k = 0; k < 2; k++) {
const sp = speakers[k];
const baseX = k === 0 ? 60 : W / 2 + 40;
for (let i = 0; i < sp.rep.length; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
drawForm(p, sp.rep[i].f, baseX + col * 95 + 30, 590 + row * 62, 1.6, 230, 1.6);
}
}
};
}