ジェネラティブアート 作り方 9種 — 育つ模様をコードで
シードの数がひとつ同じなら、何度描き直しても同じ絵になります。02番は散らばった引き寄せ点へ葉脈を伸ばし、03番は線が伸びるたびに節を足してしわを折り、06番は隣のマスと辺が合うタイルだけを残します。
ひとつのシードから九枚の絵が育っていくキャンバスをそのまま眺める
01 gen-art-flow-field-even-streamlines
02 gen-art-vein-attractor-growth
03 gen-art-folding-line-growth
04 gen-art-circle-pack-inflate
05 gen-art-crack-right-angle-spread
06 gen-art-tile-neighbor-match-collapse
07 gen-art-tree-ring-noise-growth
08 gen-art-lichen-edge-accretion
09 gen-art-hero-seeded-growth-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 間隔を保ちながら流れ場に沿って伸びる線
- 02 散らばった引き寄せ点へ伸びていく葉脈
- 03 節を挟み込みながらしわを寄せて育つ線
- 04 触れ合うまで膨らむ円詰め
- 05 直角に枝分かれして広がるひび
- 06 隣のマスと合うものだけを残して埋まるタイル
- 07 ノイズで揺らぎながら一層ずつ増える年輪
- 08 縁のマスがひとつずつ付いて広がる苔の群れ
- 09 ひとつのシードから三層が育つヒーロー背景
各マスは空の盤で200ms休んでから育ち始め、育ち終えると900ms(09は1,400ms)とどまって次のシードへ移ります。キャンバスやボタンをポインターで押すか、キャンバスでEnter・R・Spaceのキーを押すと、01〜08は今のシードが最後まで育った所で止まり、09だけは手を触れても次のシードへ続けて進みます。
ジェネラティブアート 作り方 9種 — 流れ線・葉脈・しわの線・円詰め・直角のひび・タイル合わせ・年輪・苔の広がり・育つヒーロー
01〜03は線が育つ規則を扱います。ほかの線との間隔が線を止め(01)、引き寄せ点が枝の向きを決め(02)、増えていく節が線を折りたたみます(03)。04〜06は面を埋める規則で、膨らむ円・直角に分かれるひび・辺を合わせるタイルが並び、07・08は年輪と苔のように時間が層になって積もる自然物、09は01・02・04の計算をひとつのシードで重ねたトップ画面向けの場面です。九枚とも480×300の座標を持つ<canvas>の2Dに描かれ、動きはrequestAnimationFrameの時刻をためて16msの固定ティックを1フレームに最大6回進めるループが受け持ちます。スタイルシートの@keyframesは0個で、transitionはボタンの背景色ひとつだけです。
九つの場面がレンダーのキャプチャで動いた量
ヘッドレスブラウザで2秒間に24枚を撮り、隣り合う2枚のあいだに起きた変化23回のうち、実際に画素が変わった回数を「動いたフレーム」に記しました。
| 場面 | 累積面積 | 動いたフレーム | 強度の平均 |
|---|---|---|---|
| 01 間隔を守る流れ線 | 28.0% | 17/23 | 97.0 |
| 02 引き寄せ点の葉脈 | 9.8% | 16/23 | 60.2 |
| 03 節を挟むしわの線 | 15.5% | 23/23 | 81.9 |
| 04 膨らむ円詰め | 44.2% | 23/23 | 92.8 |
| 05 直角のひび | 42.4% | 17/23 | 56.6 |
| 06 辺を合わせるタイル | 50.6% | 23/23 | 83.0 |
| 07 年輪 | 35.8% | 18/23 | 138.6 |
| 08 縁から広がる苔 | 75.5% | 21/23 | 112.3 |
| 09 三層のヒーロー | 39.0% | 23/23 | 73.4 |
10%を下回ったのは02だけです。2.6pxの細い節が葉の形の中だけで、しかもティックごとに一度(ITER_PER_FRAMEが1)しか伸びないので、変わる画素が少ないと考えられます。08が75.5%で一番広いのは、4pxのマスをティックあたり60マスずつ盤全体の9,000マスに塗っていくためと読めます。05は100ティック、つまり1,600msで育ち終わり、とどまる区間がキャプチャに入るため17/23にとどまったと考えられます。
育つ絵を敷きやすい画面
ランディングページの最初の画面の背景なら09をそのまま使えばよく、文字の多い画面の後ろには筋のそろった01のほうが目立ちすぎません。同じシードは同じ絵を出すので、記事番号や商品番号をシードに入れれば、ページごとに違っていて開き直しても変わらない見出し画像を、画像ファイルなしで作れます。
ジェネラティブアート 作品を額に入れて眺めるのとは違い、待ち時間の画面には進み具合が目に見える場面が向いています。08は群れがにじむように盤を埋め、06は霧が晴れるようにマスが決まって、ときどき一度崩れてから埋め直されます。植物や環境に配慮したブランドなら02の葉脈と07の年輪、紙や石膏のような質感がほしいポートフォリオや404画面なら05のひび、イベントのバナーのようにすき間なく飾りたいときは04の円が合います。
ジェネラティブアート コードでは、偶然にどこまで任せて、どこから規則で押さえるのか?
乱数の点を画面に均一にまくだけでは、どこを見ても密度が同じなので、目で追える筋やかたまりが生まれません。この編の九枚で乱数が受け持つのは、出発点を選ぶことと候補の中からひとつをつまむことまでで、形を決めているのはいつ止まるかを書いた規則のほうです。01の線はほかの線の点が4px以内に入ると止まり、04の円は隣とのすき間が2pxまで縮むと固まり、05のひびは角度の違うひびに触れた瞬間に終わります。
規則が形を作っていることは、捨てられた側を見るとよくわかります。04は場所の抽選30,588回のうち29,021回(94.9%)を捨て、残った場所にだけ円を植えました。01では育ち終えた絵の中で、違う線どうしの一番近い距離がちょうど4.00px、つまりLINE_SEPARATIONの8にSEP_TESTの0.5をかけた値でした。乱数がどれだけ揺さぶっても、規則が決めた距離より下には一点も入らなかったということです。
ですからジェネラティブデザインを始めるときに最初に決めるのは、どの乱数を使うかより、何が成長を止めるかです。九枚の止まる条件は距離(01・04)、消えていく引き寄せ点(02)、節の数の上限(03)、違う角度(05)、決めるマスが残らないこと(06)、半径の上限(07)、空きマスが残らないこと(08)とそれぞれ違い、その違いがそのまま絵の違いになります。
同じシードが同じ絵を出す疑似乱数と、育つ線が隣をすばやく探すマス目
九つのソースのどこにもMath.random・Date.now・new Dateはありません。乱数はすべて下のmakeRngひとつから出てきます。32ビットの整数をMath.imulと>>>で混ぜて0以上1未満の数を返す列なので、同じシードを入れれば同じ順番の数がもう一度出てきます。
// シード固定の乱数 — 同じシードなら同じ数列、つまり同じ絵がもう一度出る(32ビット整数の混ぜ合わせ)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 次のシード — 描き直すを押すたびに整数ひとつだけが変わる(10007〜99995 の5桁)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
「描き直す」ボタンとEnterはnextSeedで次の5桁のシードを作り、「同じシード」ボタンとRキーは今のシードで盤を最初から育て直します。01を47101 → 13161 → 47101の順に育てて点の座標全体のハッシュを取ると、1433911356 → 239204140 → 1433911356となり、同じシードは同じ絵を、違うシードは違う絵を出すことが数字で確かめられます。成長はフレームではなく16msのティックで区切られ、ティックごとにする仕事の量(01のSTEPS_PER_FRAMEが70、06のCOLLAPSE_PER_FRAMEが2など)が定数で決まっているので、リフレッシュレートの違う画面でも同じ順番で育つと考えられます。
隣を探す仕事は格子のバケットが受け持ちます。画面を決まった大きさのマス(01は8px、02は60px、03は12px、04は34px)に分けて点をそれぞれのマスに入れておき、距離の検査はまわりのマスに入った点どうしだけで行います。01は育ち終えたとき点が8,386個あるので、一歩ごとにすべての点と比べていたら、一歩で8千回を超える計算をしていたはずです。
01ほかの線と4px以内に近づかない、間隔を守る流れ線
12pxのマスの頂点ごとにノイズで向きを一度だけ決めておき、線は半歩先の向きを見て2pxずつ前後に歩き、ほかの線の点が4px以内に入ると止まります。終わった線の両脇8pxの位置が次の線の出発候補として列に並ぶので、画面がそろった間隔の筋で満ちていき、等高線や風の流れのような筋を敷きたい紹介ページの背景に合います。シード47101は135ティックで線155本・合計16,462pxまで育ち、キャプチャでは累積面積28.0%・強度97.0・17/23フレームでした。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="ノイズで決めた流れに沿って線が前後に伸び、別の線と決めた間隔より近づくと止まる絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">流れ線が伸びています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng, noise, o) {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 長さ 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f, x, y) {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 長さ 안)은 세지 않는다
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f, x, y) {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f, x, y) {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f, x, y) {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 線 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f) {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f, budget, seg) {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) {
flowStart(f, cx, cy);
}
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) {
f.fails = 0;
flowStart(f, rx, ry);
}
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) {
f.done = true;
}
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 線 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 노이즈 주파수(px 당) — 작을수록 소용돌이가 크고 느슨해진다
const FLOW_NOISE_SCALE = 0.0045;
// 선과 線 사이 간격(px) — 새 선의 출발점은 다른 선의 점과 이만큼 떨어져야 한다
const LINE_SEPARATION = 8;
// 한 걸음 長さ(px)
const STEP_LENGTH = 2;
// 자라는 선은 간격의 이 비율보다 가까워지면 멈춘다 — 출발 간격보다 느슨하게 둬 線 끝이 너무 일찍 끊기지 않게
const SEP_TEST = 0.5;
// 방향을 미리 정해 두는 칸 크기(px)
const FIELD_CELL = 12;
// 한 틱(16ms)에 쓰는 일의 양(걸음 1, 출발점 검사 0.2) — 성장 속도이자 프레임당 계산량 상한
const STEPS_PER_FRAME = 70;
// 線 하나의 점 수 상한 — 소용돌이 가운데를 끝없이 도는 선을 끊는다
const MAX_LINE_POINTS = 700;
const SEED0 = 47101;
const TXT_RUN = '流れ線が伸びています';
function newFlow(s) {
const rng = makeRng(s);
const noise = makeNoise(rng);
return makeFlow(rng, noise, { sep: LINE_SEPARATION, test: SEP_TEST, step: STEP_LENGTH, scale: FLOW_NOISE_SCALE, cell: FIELD_CELL, maxPts: MAX_LINE_POINTS, margin: 6 });
}
let flow = newFlow(SEED0);
function plant(s) {
clearArt(INK);
flow = newFlow(s);
}
// 선분 색 — 진행 방향의 세로 성분으로 하늘색과 분홍 사이를 섞어, 같은 흐름은 같은 색 띠로 읽힌다
function drawSeg(ax, ay, bx, by) {
const t = (by - ay) / STEP_LENGTH * 0.5 + 0.5;
actx.strokeStyle = rgba(mix(PAL[0], PAL[1], clamp(t, 0, 1)), 0.92);
actx.lineWidth = 1.5;
actx.lineCap = 'round';
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function grow() { return flowStep(flow, STEPS_PER_FRAME, drawSeg); }
function paintFrame() {
blitArt();
// 지금 자라는 線 끝
if (flow.active) {
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(flow.x, flow.y, 2.6, 0, TAU);
ctx.fill();
}
}
function statPairs() {
return [['線', String(flow.lines)], ['長さ', Math.round(flow.length) + 'px']];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// FlowFieldEvenLines - vanilla 판(01-gen-art-flow-field-even-streamlines) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlowFieldEvenLines.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FlowFieldEvenLines() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 간격 제한 흐름 선 — 칸마다 노이즈로 정한 방향을 따라 선을 앞뒤로 늘리고, 다른 선의 점이 정한 간격보다 가까우면 그 자리에서 멈춘다
type FlowOpts = { sep: number; test: number; step: number; scale: number; cell: number; maxPts: number; margin: number };
type FlowSeg = (ax: number, ay: number, bx: number, by: number, line: number) => void;
type Flow = {
o: FlowOpts; rng: () => number; vx: Float64Array; vy: Float64Array; fcols: number; frows: number;
px: number[]; py: number[]; pl: number[]; po: number[]; grid: Buckets;
cand: number[]; candAt: number; line: number; dir: number; ord: number; x: number; y: number; sx: number; sy: number; pts: number[];
active: boolean; lines: number; length: number; fails: number; done: boolean;
};
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng: () => number, noise: Noise, o: FlowOpts): Flow {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f: Flow, x: number, y: number): [number, number] {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
function flowTooClose(f: Flow, x: number, y: number, d: number, line: number, ord: number): boolean {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id: number): boolean {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) { return false; }
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f: Flow, x: number, y: number): boolean {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f: Flow, x: number, y: number): void {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f: Flow, x: number, y: number): void {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f: Flow): void {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f: Flow, budget: number, seg: FlowSeg): boolean {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) { flowStart(f, cx, cy); }
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) { f.fails = 0; flowStart(f, rx, ry); }
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) { f.done = true; }
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
} else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 노이즈 주파수(px 당) — 작을수록 소용돌이가 크고 느슨해진다
const FLOW_NOISE_SCALE = 0.0045;
// 선과 선 사이 간격(px) — 새 선의 출발점은 다른 선의 점과 이만큼 떨어져야 한다
const LINE_SEPARATION = 8;
// 한 걸음 길이(px)
const STEP_LENGTH = 2;
// 자라는 선은 간격의 이 비율보다 가까워지면 멈춘다 — 출발 간격보다 느슨하게 둬 선 끝이 너무 일찍 끊기지 않게
const SEP_TEST = 0.5;
// 방향을 미리 정해 두는 칸 크기(px)
const FIELD_CELL = 12;
// 한 틱(16ms)에 쓰는 일의 양(걸음 1, 출발점 검사 0.2) — 성장 속도이자 프레임당 계산량 상한
const STEPS_PER_FRAME = 70;
// 선 하나의 점 수 상한 — 소용돌이 가운데를 끝없이 도는 선을 끊는다
const MAX_LINE_POINTS = 700;
const SEED0 = 47101;
const TXT_RUN = '흐름 선이 자라는 중';
function newFlow(s: number): Flow {
const rng = makeRng(s);
const noise = makeNoise(rng);
return makeFlow(rng, noise, { sep: LINE_SEPARATION, test: SEP_TEST, step: STEP_LENGTH, scale: FLOW_NOISE_SCALE, cell: FIELD_CELL, maxPts: MAX_LINE_POINTS, margin: 6 });
}
let flow: Flow = newFlow(SEED0);
function plant(s: number): void {
clearArt(INK);
flow = newFlow(s);
}
// 선분 색 — 진행 방향의 세로 성분으로 하늘색과 분홍 사이를 섞어, 같은 흐름은 같은 색 띠로 읽힌다
function drawSeg(ax: number, ay: number, bx: number, by: number): void {
const t = (by - ay) / STEP_LENGTH * 0.5 + 0.5;
actx.strokeStyle = rgba(mix(PAL[0], PAL[1], clamp(t, 0, 1)), 0.92);
actx.lineWidth = 1.5;
actx.lineCap = 'round';
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function grow(): boolean { return flowStep(flow, STEPS_PER_FRAME, drawSeg); }
function paintFrame(): void {
blitArt();
// 지금 자라는 선 끝
if (flow.active) {
ctx.fillStyle = rgba(PAPER, 0.95);
ctx.beginPath();
ctx.arc(flow.x, flow.y, 2.6, 0, TAU);
ctx.fill();
}
}
function statPairs(): [string, string][] {
return [['선', String(flow.lines)], ['길이', Math.round(flow.length) + 'px']];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="노이즈로 정한 흐름을 따라 선이 앞뒤로 자라고, 다른 선과 정한 간격보다 가까워지면 멈추는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">흐름 선이 자라는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}01 · 間隔を保ちながら流れ場に沿って伸びる線 — 計測 累積面積 28.044% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// (x, y) の半径 d の中にほかの点があるか — 同じ線のすぐ前後の点(間隔の2倍の長さ以内)は数えない
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
// 一歩 — 半歩先の向きへ歩く中点法(曲線の外へずれる誤差を減らす)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 前がふさがれば出発点に戻って反対側へ、反対側もふさがれば線ひとつ終わり
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
02散らばった900個の引き寄せ点へ枝を伸ばす葉脈
葉の形の中に引き寄せ点900個をシード付きの乱数でまき、点ごとに半径60pxの中で一番近い節をひとつ選んで、その節に引き寄せ点たちの平均の向きへ2.6pxの新しい節を出させ、新しい節から6px以内に入った点は消します。枝の太さは節ごとに数えた子孫の数の平方根に従って幹から葉の縁へ向かうほど細くなり、育つ様子そのものが物語になる植物や環境系ブランドの紹介画面に向いています。シード47102は103ティックで引き寄せ点をすべて消して節794個・枝先71本で止まり、累積面積9.8%・強度60.2・16/23フレームと、九枚のうちただひとつ10%を下回りました。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="葉の形の中に散らばった引き寄せ点へ葉脈が伸び、届いた点が消えて枝が分かれていく絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">葉脈が引き寄せ点へ伸びています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
function makeVein(rng, o) {
const ax = [];
const ay = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) {
ax.push(x);
ay.push(y);
}
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) {
bucketAdd(agrid, ax[i], ay[i], i);
}
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 節 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v, x, y, parent) {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 周長 kill 안의 끌개를 지운다
function veinKill(v, from) {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a) {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) {
v.alive[a] = 0;
v.left -= 1;
}
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v) {
if (v.done) {
return true;
}
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 節 하나 — 그 節 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) {
continue;
}
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id) {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) {
bd = d2;
best = id;
}
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) {
v.reached = true;
}
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
mx += v.ax[a];
my += v.ay[a];
}
}
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
}
else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) {
continue;
}
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) {
continue;
}
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
v.done = true;
}
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v, c, col, alpha, base, k, cap) {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) {
continue;
}
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v, c, col, alpha) {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6);
}
}
c.fill();
}
// 잎 안에 뿌리는 끌개 점 수
const ATTRACTOR_COUNT = 900;
// 끌개가 마디를 끌어당기는 반경(px) — 이 안의 節 중 가장 가까운 하나만 끈다
const INFLUENCE_RADIUS = 60;
// 마디가 이만큼 다가오면 그 끌개는 지운다(px)
const KILL_DISTANCE = 6;
// 새 節 하나의 長さ(px)
const SEGMENT_LENGTH = 2.6;
// 잎 모양 — 가운데·반 長さ·가장 넓은 반폭(px)·기울기
const LEAF_CX = 292;
const LEAF_CY = 152;
const LEAF_HALF_LEN = 128;
const LEAF_HALF_W = 64;
const LEAF_TILT = -28 * DEG;
// 한 틱에 도는 성장 횟수와 節 수·횟수 상한 — 프레임당 계산량 상한
const ITER_PER_FRAME = 1;
const MAX_NODES = 4000;
const MAX_ITER = 400;
const SEED0 = 47102;
const TXT_RUN = '葉脈が引き寄せ点へ伸びています';
// 잎 좌표 — u 는 잎자루에서 잎끝 방향, v 는 그 옆 방향
function leafLocal(x, y) {
const dx = x - LEAF_CX;
const dy = y - LEAF_CY;
const c = Math.cos(-LEAF_TILT);
const s = Math.sin(-LEAF_TILT);
return [dx * c - dy * s, dx * s + dy * c];
}
function leafWorld(u, v) {
const c = Math.cos(LEAF_TILT);
const s = Math.sin(LEAF_TILT);
return [LEAF_CX + u * c - v * s, LEAF_CY + u * s + v * c];
}
// 반폭 — 잎자루 쪽(t = −1)은 둥글게, 잎끝(t = 1)은 뾰족하게 닫히도록 두 지수를 다르게 둔다
function leafHalf(t) { return LEAF_HALF_W * Math.pow(1 - t, 0.8) * Math.pow(1 + t, 0.62); }
function insideLeaf(x, y) {
const p = leafLocal(x, y);
const t = p[0] / LEAF_HALF_LEN;
if (t <= -1 || t >= 1) {
return false;
}
return Math.abs(p[1]) < leafHalf(t);
}
const ROOT = leafWorld(-LEAF_HALF_LEN - 14, 0);
function newVein(s) {
return makeVein(makeRng(s), {
count: ATTRACTOR_COUNT, influence: INFLUENCE_RADIUS, kill: KILL_DISTANCE, seg: SEGMENT_LENGTH,
rootX: ROOT[0], rootY: ROOT[1], maxNodes: MAX_NODES, maxIter: MAX_ITER,
x0: LEAF_CX - 150, y0: LEAF_CY - 150, x1: LEAF_CX + 150, y1: LEAF_CY + 150, inside: insideLeaf,
});
}
let vein = newVein(SEED0);
function leafPath(c) {
c.beginPath();
const n = 60;
for (let i = 0; i <= n; i += 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, leafHalf(t));
if (i === 0) {
c.moveTo(p[0], p[1]);
}
else {
c.lineTo(p[0], p[1]);
}
}
for (let i = n; i >= 0; i -= 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, -leafHalf(t));
c.lineTo(p[0], p[1]);
}
c.closePath();
}
function plant(s) {
clearArt(INK);
leafPath(actx);
actx.fillStyle = rgba(mix(PAL[2], INK, 0.58), 1);
actx.fill();
actx.strokeStyle = rgba(PAL[2], 0.55);
actx.lineWidth = 1.5;
actx.stroke();
vein = newVein(s);
}
function grow() {
for (let i = 0; i < ITER_PER_FRAME; i += 1) {
veinStep(vein);
}
return vein.done;
}
function paintFrame() {
blitArt();
veinDots(vein, ctx, PAL[5], 0.6);
veinDraw(vein, ctx, mix(PAL[5], PAPER, 0.5), 0.95, 0.5, 0.22, 5.2);
}
function statPairs() {
return [['節', String(vein.nx.length)], ['残りの引き寄せ点', String(vein.left)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// VeinAttractorGrowth - vanilla 판(02-gen-art-vein-attractor-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./VeinAttractorGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function VeinAttractorGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 끌개 점 잎맥 — 끌개마다 가장 가까운 마디를 찾고, 마디는 자기를 고른 끌개 방향들의 평균으로 한 칸 자란다. 마디가 닿은 끌개는 지운다
type VeinOpts = {
count: number; influence: number; kill: number; seg: number; rootX: number; rootY: number;
maxNodes: number; maxIter: number; x0: number; y0: number; x1: number; y1: number;
inside: (x: number, y: number) => boolean;
};
type Vein = {
o: VeinOpts; rng: () => number; ax: number[]; ay: number[]; alive: Uint8Array; left: number;
nx: number[]; ny: number[]; par: number[]; desc: number[]; grid: Buckets; agrid: Buckets;
iter: number; reached: boolean; stall: number; done: boolean;
};
function makeVein(rng: () => number, o: VeinOpts): Vein {
const ax: number[] = [];
const ay: number[] = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) { ax.push(x); ay.push(y); }
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) { bucketAdd(agrid, ax[i], ay[i], i); }
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v: Vein = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 마디 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v: Vein, x: number, y: number, parent: number): void {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 둘레 kill 안의 끌개를 지운다
function veinKill(v: Vein, from: number): void {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a: number): boolean {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) { v.alive[a] = 0; v.left -= 1; }
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v: Vein): boolean {
if (v.done) { return true; }
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 마디 하나 — 그 마디 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) { continue; }
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id: number): boolean {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = id; }
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) { v.reached = true; }
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) { if (v.alive[a]) { mx += v.ax[a]; my += v.ay[a]; } }
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
} else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) { continue; }
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) { continue; }
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) { v.done = true; }
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number, base: number, k: number, cap: number): void {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) { continue; }
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number): void {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) { c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6); }
}
c.fill();
}
// 잎 안에 뿌리는 끌개 점 수
const ATTRACTOR_COUNT = 900;
// 끌개가 마디를 끌어당기는 반경(px) — 이 안의 마디 중 가장 가까운 하나만 끈다
const INFLUENCE_RADIUS = 60;
// 마디가 이만큼 다가오면 그 끌개는 지운다(px)
const KILL_DISTANCE = 6;
// 새 마디 하나의 길이(px)
const SEGMENT_LENGTH = 2.6;
// 잎 모양 — 가운데·반 길이·가장 넓은 반폭(px)·기울기
const LEAF_CX = 292;
const LEAF_CY = 152;
const LEAF_HALF_LEN = 128;
const LEAF_HALF_W = 64;
const LEAF_TILT = -28 * DEG;
// 한 틱에 도는 성장 횟수와 마디 수·횟수 상한 — 프레임당 계산량 상한
const ITER_PER_FRAME = 1;
const MAX_NODES = 4000;
const MAX_ITER = 400;
const SEED0 = 47102;
const TXT_RUN = '잎맥이 끌개로 뻗는 중';
// 잎 좌표 — u 는 잎자루에서 잎끝 방향, v 는 그 옆 방향
function leafLocal(x: number, y: number): [number, number] {
const dx = x - LEAF_CX;
const dy = y - LEAF_CY;
const c = Math.cos(-LEAF_TILT);
const s = Math.sin(-LEAF_TILT);
return [dx * c - dy * s, dx * s + dy * c];
}
function leafWorld(u: number, v: number): [number, number] {
const c = Math.cos(LEAF_TILT);
const s = Math.sin(LEAF_TILT);
return [LEAF_CX + u * c - v * s, LEAF_CY + u * s + v * c];
}
// 반폭 — 잎자루 쪽(t = −1)은 둥글게, 잎끝(t = 1)은 뾰족하게 닫히도록 두 지수를 다르게 둔다
function leafHalf(t: number): number { return LEAF_HALF_W * Math.pow(1 - t, 0.8) * Math.pow(1 + t, 0.62); }
function insideLeaf(x: number, y: number): boolean {
const p = leafLocal(x, y);
const t = p[0] / LEAF_HALF_LEN;
if (t <= -1 || t >= 1) { return false; }
return Math.abs(p[1]) < leafHalf(t);
}
const ROOT = leafWorld(-LEAF_HALF_LEN - 14, 0);
function newVein(s: number): Vein {
return makeVein(makeRng(s), {
count: ATTRACTOR_COUNT, influence: INFLUENCE_RADIUS, kill: KILL_DISTANCE, seg: SEGMENT_LENGTH,
rootX: ROOT[0], rootY: ROOT[1], maxNodes: MAX_NODES, maxIter: MAX_ITER,
x0: LEAF_CX - 150, y0: LEAF_CY - 150, x1: LEAF_CX + 150, y1: LEAF_CY + 150, inside: insideLeaf,
});
}
let vein: Vein = newVein(SEED0);
function leafPath(c: CanvasRenderingContext2D): void {
c.beginPath();
const n = 60;
for (let i = 0; i <= n; i += 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, leafHalf(t));
if (i === 0) { c.moveTo(p[0], p[1]); } else { c.lineTo(p[0], p[1]); }
}
for (let i = n; i >= 0; i -= 1) {
const t = -1 + (2 * i) / n;
const p = leafWorld(t * LEAF_HALF_LEN, -leafHalf(t));
c.lineTo(p[0], p[1]);
}
c.closePath();
}
function plant(s: number): void {
clearArt(INK);
leafPath(actx);
actx.fillStyle = rgba(mix(PAL[2], INK, 0.58), 1);
actx.fill();
actx.strokeStyle = rgba(PAL[2], 0.55);
actx.lineWidth = 1.5;
actx.stroke();
vein = newVein(s);
}
function grow(): boolean {
for (let i = 0; i < ITER_PER_FRAME; i += 1) { veinStep(vein); }
return vein.done;
}
function paintFrame(): void {
blitArt();
veinDots(vein, ctx, PAL[5], 0.6);
veinDraw(vein, ctx, mix(PAL[5], PAPER, 0.5), 0.95, 0.5, 0.22, 5.2);
}
function statPairs(): [string, string][] {
return [['마디', String(vein.nx.length)], ['남은 끌개', String(vein.left)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="잎 모양 안에 흩어진 끌개 점을 향해 잎맥이 뻗고, 닿은 끌개가 지워지며 가지가 갈라지는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">잎맥이 끌개로 뻗는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}02 · 散らばった引き寄せ点へ伸びていく葉脈 — 計測 累積面積 9.836% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) {
continue;
}
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 引き寄せ点どうしが反対側から引っぱり合って平均がほぼ 0 なら、その節は今回は伸びない
if (L < 0.2) {
continue;
}
// ごく小さな揺れ — 同じ場所に節が重なって積もらないよう対称を崩す
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 消える引き寄せ点がないまま VEIN_STALL_LIMIT 回続けて伸びるだけなら止める — 届かない点を追って回り続けないように
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
v.done = true;
}
03伸びるたびに節を挟み込んで折れていくしわの線
閉じた線の節はひとつひとつが両隣の真ん中へ引かれ、隣の節と4pxを保とうとするばねにつながれ、両隣以外の節が半径12px以内に来ると押し返します。5pxより長くなった辺とシード付きの乱数で選んだ辺にティックごとに節を挟むと、楕円の囲いの中で伸びる場所のない線が折れて珊瑚や脳のしわのような模様になり、有機的な質感がほしい科学や医療のコンテンツの見出し画像に使えます。シード47103は214ティックで節の上限2,600個に届いて周長は5,611pxになり、レンダーの値は累積面積15.5%・強度81.9・23/23フレームです。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="閉じた線の節が押し合い、長い辺に新しい節が挟まって珊瑚のようにしわが寄る絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">しわを寄せながら育っています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
// 마디끼리 밀어내는 반경(px) — 이 안에 든 마디는 거리에 반비례해 밀린다
const REPEL_RADIUS = 12;
// 節 사이가 이보다 벌어지면 가운데에 새 마디를 끼운다(px)
const MAX_EDGE_LENGTH = 5;
// 양옆 마디의 가운데 쪽으로 끌어당기는 비율 — 클수록 선이 매끈해진다
const ALIGN_FORCE = 0.4;
// 밀어내는 힘의 크기
const REPEL_FORCE = 0.5;
// 이웃 마디끼리 잇는 용수철 — 쉬는 長さ(px)보다 짧으면 밀고 길면 당긴다
const SPRING_REST = 4;
const SPRING_FORCE = 0.3;
// 한 틱에 아무 변에나 끼우는 성장 節 수 — 마디가 늘면 용수철이 선을 늘이려 하고, 울타리 안에서 늘어날 자리가 없으니 접힌다
const GROWTH_PER_FRAME = 12;
// 한 틱에 끼우는 節 수 상한(긴 변 나누기 + 성장)과 전체 節 수 상한 — 프레임당 계산량 상한
const SPLITS_PER_FRAME = 24;
const MAX_NODES = 2600;
// 자랄 수 있는 타원 울타리
const BOUND_CX = 300;
const BOUND_CY = 150;
const BOUND_RX = 168;
const BOUND_RY = 124;
const START_NODES = 24;
const START_R = 22;
const SEED0 = 47103;
const TXT_RUN = 'しわを寄せながら育っています';
let xs = [];
let ys = [];
let lrng = makeRng(SEED0);
let splitFrom = 0;
const grid = makeBuckets(REPEL_RADIUS);
function plant(s) {
clearArt(INK);
lrng = makeRng(s);
xs = [];
ys = [];
splitFrom = 0;
for (let i = 0; i < START_NODES; i += 1) {
const a = (i / START_NODES) * TAU;
const r = START_R * (1 + (lrng() - 0.5) * 0.3);
xs.push(BOUND_CX + Math.cos(a) * r);
ys.push(BOUND_CY + Math.sin(a) * r);
}
}
// 힘 합 — 양옆 가운데로 끌기 + 반경 안 이웃(격자 근방만) 밀기 + 타원 밖이면 안으로
function relax() {
const n = xs.length;
bucketsClear(grid);
for (let i = 0; i < n; i += 1) {
bucketAdd(grid, xs[i], ys[i], i);
}
const nx = new Array(n);
const ny = new Array(n);
const R = REPEL_RADIUS;
for (let i = 0; i < n; i += 1) {
const x = xs[i];
const y = ys[i];
const pv = (i + n - 1) % n;
const nt = (i + 1) % n;
let fx = ((xs[pv] + xs[nt]) * 0.5 - x) * ALIGN_FORCE;
let fy = ((ys[pv] + ys[nt]) * 0.5 - y) * ALIGN_FORCE;
const nbs = [pv, nt];
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j) {
if (j === i || j === pv || j === nt) {
return false;
}
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) {
return false;
}
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
const ex = (x - BOUND_CX) / BOUND_RX;
const ey = (y - BOUND_CY) / BOUND_RY;
const e = ex * ex + ey * ey;
if (e > 1) {
fx -= (x - BOUND_CX) * (e - 1) * 0.5;
fy -= (y - BOUND_CY) * (e - 1) * 0.5;
}
nx[i] = x + fx;
ny[i] = y + fy;
}
xs = nx;
ys = ny;
}
// 긴 변 나누기 — 시작 자리를 틱마다 옮겨 가며 MAX_EDGE_LENGTH 보다 긴 변 가운데에 마디를 끼운다
function splitEdges() {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// 가운데에서 아주 조금 옆으로 — 대칭이 깨져야 주름이 접힌다
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
// 성장 — 남은 몫만큼 시드 난수로 고른 변 가운데에 마디를 더 끼운다
for (let g = 0; g < GROWTH_PER_FRAME && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; g += 1) {
const a = Math.floor(lrng() * xs.length);
const b = (a + 1) % xs.length;
xs.splice(a + 1, 0, (xs[a] + xs[b]) * 0.5);
ys.splice(a + 1, 0, (ys[a] + ys[b]) * 0.5);
made += 1;
}
}
function grow() {
relax();
splitEdges();
return xs.length >= MAX_NODES;
}
function perimeter() {
let s = 0;
for (let i = 0; i < xs.length; i += 1) {
const j = (i + 1) % xs.length;
s += Math.hypot(xs[j] - xs[i], ys[j] - ys[i]);
}
return s;
}
function paintFrame() {
blitArt();
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < xs.length; i += 1) {
ctx.lineTo(xs[i], ys[i]);
}
ctx.closePath();
ctx.fillStyle = rgba(mix(PAL[1], PAL[7], 0.4), 0.9);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 1.3;
ctx.lineJoin = 'round';
ctx.stroke();
}
function statPairs() {
return [['節', String(xs.length)], ['周長', Math.round(perimeter()) + 'px']];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// FoldingLineGrowth - vanilla 판(03-gen-art-folding-line-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FoldingLineGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FoldingLineGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 마디끼리 밀어내는 반경(px) — 이 안에 든 마디는 거리에 반비례해 밀린다
const REPEL_RADIUS = 12;
// 마디 사이가 이보다 벌어지면 가운데에 새 마디를 끼운다(px)
const MAX_EDGE_LENGTH = 5;
// 양옆 마디의 가운데 쪽으로 끌어당기는 비율 — 클수록 선이 매끈해진다
const ALIGN_FORCE = 0.4;
// 밀어내는 힘의 크기
const REPEL_FORCE = 0.5;
// 이웃 마디끼리 잇는 용수철 — 쉬는 길이(px)보다 짧으면 밀고 길면 당긴다
const SPRING_REST = 4;
const SPRING_FORCE = 0.3;
// 한 틱에 아무 변에나 끼우는 성장 마디 수 — 마디가 늘면 용수철이 선을 늘이려 하고, 울타리 안에서 늘어날 자리가 없으니 접힌다
const GROWTH_PER_FRAME = 12;
// 한 틱에 끼우는 마디 수 상한(긴 변 나누기 + 성장)과 전체 마디 수 상한 — 프레임당 계산량 상한
const SPLITS_PER_FRAME = 24;
const MAX_NODES = 2600;
// 자랄 수 있는 타원 울타리
const BOUND_CX = 300;
const BOUND_CY = 150;
const BOUND_RX = 168;
const BOUND_RY = 124;
const START_NODES = 24;
const START_R = 22;
const SEED0 = 47103;
const TXT_RUN = '주름이 접히며 자라는 중';
let xs: number[] = [];
let ys: number[] = [];
let lrng: () => number = makeRng(SEED0);
let splitFrom = 0;
const grid: Buckets = makeBuckets(REPEL_RADIUS);
function plant(s: number): void {
clearArt(INK);
lrng = makeRng(s);
xs = [];
ys = [];
splitFrom = 0;
for (let i = 0; i < START_NODES; i += 1) {
const a = (i / START_NODES) * TAU;
const r = START_R * (1 + (lrng() - 0.5) * 0.3);
xs.push(BOUND_CX + Math.cos(a) * r);
ys.push(BOUND_CY + Math.sin(a) * r);
}
}
// 힘 합 — 양옆 가운데로 끌기 + 반경 안 이웃(격자 근방만) 밀기 + 타원 밖이면 안으로
function relax(): void {
const n = xs.length;
bucketsClear(grid);
for (let i = 0; i < n; i += 1) { bucketAdd(grid, xs[i], ys[i], i); }
const nx: number[] = new Array(n);
const ny: number[] = new Array(n);
const R = REPEL_RADIUS;
for (let i = 0; i < n; i += 1) {
const x = xs[i];
const y = ys[i];
const pv = (i + n - 1) % n;
const nt = (i + 1) % n;
let fx = ((xs[pv] + xs[nt]) * 0.5 - x) * ALIGN_FORCE;
let fy = ((ys[pv] + ys[nt]) * 0.5 - y) * ALIGN_FORCE;
const nbs = [pv, nt];
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j: number): boolean {
if (j === i || j === pv || j === nt) { return false; }
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) { return false; }
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
const ex = (x - BOUND_CX) / BOUND_RX;
const ey = (y - BOUND_CY) / BOUND_RY;
const e = ex * ex + ey * ey;
if (e > 1) {
fx -= (x - BOUND_CX) * (e - 1) * 0.5;
fy -= (y - BOUND_CY) * (e - 1) * 0.5;
}
nx[i] = x + fx;
ny[i] = y + fy;
}
xs = nx;
ys = ny;
}
// 긴 변 나누기 — 시작 자리를 틱마다 옮겨 가며 MAX_EDGE_LENGTH 보다 긴 변 가운데에 마디를 끼운다
function splitEdges(): void {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// 가운데에서 아주 조금 옆으로 — 대칭이 깨져야 주름이 접힌다
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
// 성장 — 남은 몫만큼 시드 난수로 고른 변 가운데에 마디를 더 끼운다
for (let g = 0; g < GROWTH_PER_FRAME && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; g += 1) {
const a = Math.floor(lrng() * xs.length);
const b = (a + 1) % xs.length;
xs.splice(a + 1, 0, (xs[a] + xs[b]) * 0.5);
ys.splice(a + 1, 0, (ys[a] + ys[b]) * 0.5);
made += 1;
}
}
function grow(): boolean {
relax();
splitEdges();
return xs.length >= MAX_NODES;
}
function perimeter(): number {
let s = 0;
for (let i = 0; i < xs.length; i += 1) {
const j = (i + 1) % xs.length;
s += Math.hypot(xs[j] - xs[i], ys[j] - ys[i]);
}
return s;
}
function paintFrame(): void {
blitArt();
ctx.beginPath();
ctx.moveTo(xs[0], ys[0]);
for (let i = 1; i < xs.length; i += 1) { ctx.lineTo(xs[i], ys[i]); }
ctx.closePath();
ctx.fillStyle = rgba(mix(PAL[1], PAL[7], 0.4), 0.9);
ctx.fill();
ctx.strokeStyle = rgba(PAPER, 0.95);
ctx.lineWidth = 1.3;
ctx.lineJoin = 'round';
ctx.stroke();
}
function statPairs(): [string, string][] {
return [['마디', String(xs.length)], ['둘레', Math.round(perimeter()) + 'px']];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="닫힌 선의 마디가 서로 밀어내고 긴 변에 새 마디가 끼워지며 산호처럼 주름지는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">주름이 접히며 자라는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}03 · 節を挟み込みながらしわを寄せて育つ線 — 計測 累積面積 15.466% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let k = 0; k < 2; k += 1) {
const j = nbs[k];
const dx = xs[j] - x;
const dy = ys[j] - y;
const d = Math.hypot(dx, dy) || 1e-6;
const pull = ((d - SPRING_REST) / d) * SPRING_FORCE * 0.5;
fx += dx * pull;
fy += dy * pull;
}
bucketNear(grid, x, y, R, function (j) {
if (j === i || j === pv || j === nt) {
return false;
}
const dx = x - xs[j];
const dy = y - ys[j];
const d2 = dx * dx + dy * dy;
if (d2 >= R * R || d2 < 1e-9) {
return false;
}
const d = Math.sqrt(d2);
const push = ((R - d) / R) * REPEL_FORCE;
fx += (dx / d) * push;
fy += (dy / d) * push;
return false;
});
// 長い辺の分割 — 始める位置をティックごとにずらしながら、MAX_EDGE_LENGTH より長い辺の真ん中に節を挟む
function splitEdges() {
let made = 0;
const n0 = xs.length;
let i = splitFrom % n0;
for (let k = 0; k < n0 && made < SPLITS_PER_FRAME && xs.length < MAX_NODES; k += 1) {
const j = (i + 1) % xs.length;
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
if (dx * dx + dy * dy > MAX_EDGE_LENGTH * MAX_EDGE_LENGTH) {
// 真ん中からほんの少し横へ — 対称が崩れてはじめてしわが折れる
const jit = (lrng() - 0.5) * 0.4;
xs.splice(i + 1, 0, (xs[i] + xs[j]) * 0.5 - dy * jit * 0.1);
ys.splice(i + 1, 0, (ys[i] + ys[j]) * 0.5 + dx * jit * 0.1);
made += 1;
i += 1;
}
i = (i + 1) % xs.length;
}
splitFrom = i + 37;
04すき間2pxに届くまで膨らむ円詰め
ティックごとにシード付きの乱数で場所を60か所抽選し、ほかの円や縁とのあいだに2pxのすき間が残る所にだけ半径1pxの円を植え、植えた円はティックごとに0.8pxずつ大きくして、場所がふさがった瞬間にその円だけを描画板に固めます。大きな円のすき間を小さな円が埋めていく過程がそのまま見えるので、イベントのポスターやキャンペーンのバナーのように画面をすき間なく埋める飾りに向いています。シード47104は565ティックのあいだに円1,567個で画面の46.1%を覆い、キャプチャの値は累積面積44.2%・強度92.8・23/23フレームでした。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="空いた場所に植えた円が膨らみ、隣の円や縁に触れると止まって画面を埋める絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">円が膨らんで場所を埋めています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
function makePack(rng, o) {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 円(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
function packStep(p, onStop) {
if (p.done) {
return true;
}
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
}
else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
}
// 한 틱에 반지름이 자라는 양(px)
const GROW_RATE = 0.8;
// 한 틱에 새 円 자리를 뽑아 보는 횟수 — 겹치면 버리는 거절 표본
const SPAWN_ATTEMPTS = 60;
// 원과 円, 원과 테두리 사이에 남기는 틈(px) — 틈이 이만큼으로 줄면 그 원은 멈춘다
const CIRCLE_GAP = 2;
// 円 하나의 반지름 상한과 처음 반지름(px)
const MAX_RADIUS = 34;
const START_RADIUS = 1;
// 한 틱에 새로 심는 円 수 상한 — 프레임당 계산량 상한
const SPAWN_PER_FRAME = 8;
// 이만큼 연달아 한 개도 못 심으면 빈자리가 다 찼다고 본다(틱)
const FAIL_TICKS = 8;
const SEED0 = 47104;
const TXT_RUN = '円が膨らんで場所を埋めています';
function newPack(s) {
return makePack(makeRng(s), {
grow: GROW_RATE, attempts: SPAWN_ATTEMPTS, gap: CIRCLE_GAP, maxR: MAX_RADIUS, startR: START_RADIUS,
perTick: SPAWN_PER_FRAME, failTicks: FAIL_TICKS, x0: 4, y0: 4, x1: W - 4, y1: H - 4,
blocked: function () { return false; },
});
}
let pack = newPack(SEED0);
let stopped = 0;
function plant(s) {
clearArt(INK);
pack = newPack(s);
stopped = 0;
}
// 円 하나 — 바깥 면과, 반지름 62% 자리의 옅은 안쪽 円
function disc(c, i, alpha) {
const col = PAL[pack.col[i]];
c.fillStyle = rgba(col, alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
c.fill();
if (pack.cr[i] > 3) {
c.fillStyle = rgba(mix(col, PAPER, 0.45), alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i] * 0.62, 0, TAU);
c.fill();
}
}
// 멈춘 원은 그림판에 한 번만 굳혀 그린다
function settleDisc(i) {
disc(actx, i, 1);
stopped += 1;
}
function grow() { return packStep(pack, settleDisc); }
function paintFrame() {
blitArt();
// 아직 成長中の円 — 반투명 면 + 밝은 테두리
for (let k = 0; k < pack.growing.length; k += 1) {
const i = pack.growing[k];
disc(ctx, i, 0.7);
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
ctx.stroke();
}
}
function statPairs() {
return [['円', String(pack.cx.length)], ['成長中の円', String(pack.growing.length)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// CirclePackInflate - vanilla 판(04-gen-art-circle-pack-inflate) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CirclePackInflate.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CirclePackInflate() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 부푸는 원 채우기 — 빈자리를 몇 번 골라 원을 심고 틱마다 반지름을 키우다가, 이웃 원·테두리와의 틈이 gap 에 닿으면 그 원만 멈춘다
type PackOpts = {
grow: number; attempts: number; gap: number; maxR: number; startR: number; perTick: number; failTicks: number;
x0: number; y0: number; x1: number; y1: number; blocked: (x: number, y: number, r: number) => boolean;
};
type Pack = {
o: PackOpts; rng: () => number; cx: number[]; cy: number[]; cr: number[]; col: number[];
growing: number[]; grid: Buckets; fails: number; tries: number; rejects: number; done: boolean;
};
function makePack(rng: () => number, o: PackOpts): Pack {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p: Pack, x: number, y: number, r: number, self: number): boolean {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) { return false; }
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j: number): boolean {
if (j === self) { return false; }
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) { return false; }
return !p.o.blocked(x, y, r + g);
}
function packStep(p: Pack, onStop: (i: number) => void): boolean {
if (p.done) { return true; }
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
} else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next: number[] = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
} else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) { p.done = true; }
return p.done;
}
// 한 틱에 반지름이 자라는 양(px)
const GROW_RATE = 0.8;
// 한 틱에 새 원 자리를 뽑아 보는 횟수 — 겹치면 버리는 거절 표본
const SPAWN_ATTEMPTS = 60;
// 원과 원, 원과 테두리 사이에 남기는 틈(px) — 틈이 이만큼으로 줄면 그 원은 멈춘다
const CIRCLE_GAP = 2;
// 원 하나의 반지름 상한과 처음 반지름(px)
const MAX_RADIUS = 34;
const START_RADIUS = 1;
// 한 틱에 새로 심는 원 수 상한 — 프레임당 계산량 상한
const SPAWN_PER_FRAME = 8;
// 이만큼 연달아 한 개도 못 심으면 빈자리가 다 찼다고 본다(틱)
const FAIL_TICKS = 8;
const SEED0 = 47104;
const TXT_RUN = '원이 부풀며 자리를 채우는 중';
function newPack(s: number): Pack {
return makePack(makeRng(s), {
grow: GROW_RATE, attempts: SPAWN_ATTEMPTS, gap: CIRCLE_GAP, maxR: MAX_RADIUS, startR: START_RADIUS,
perTick: SPAWN_PER_FRAME, failTicks: FAIL_TICKS, x0: 4, y0: 4, x1: W - 4, y1: H - 4,
blocked: function (): boolean { return false; },
});
}
let pack: Pack = newPack(SEED0);
let stopped = 0;
function plant(s: number): void {
clearArt(INK);
pack = newPack(s);
stopped = 0;
}
// 원 하나 — 바깥 면과, 반지름 62% 자리의 옅은 안쪽 원
function disc(c: CanvasRenderingContext2D, i: number, alpha: number): void {
const col = PAL[pack.col[i]];
c.fillStyle = rgba(col, alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
c.fill();
if (pack.cr[i] > 3) {
c.fillStyle = rgba(mix(col, PAPER, 0.45), alpha);
c.beginPath();
c.arc(pack.cx[i], pack.cy[i], pack.cr[i] * 0.62, 0, TAU);
c.fill();
}
}
// 멈춘 원은 그림판에 한 번만 굳혀 그린다
function settleDisc(i: number): void {
disc(actx, i, 1);
stopped += 1;
}
function grow(): boolean { return packStep(pack, settleDisc); }
function paintFrame(): void {
blitArt();
// 아직 자라는 원 — 반투명 면 + 밝은 테두리
for (let k = 0; k < pack.growing.length; k += 1) {
const i = pack.growing[k];
disc(ctx, i, 0.7);
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(pack.cx[i], pack.cy[i], pack.cr[i], 0, TAU);
ctx.stroke();
}
}
function statPairs(): [string, string][] {
return [['원', String(pack.cx.length)], ['자라는 원', String(pack.growing.length)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="빈자리에 심은 원이 부풀다가 이웃 원이나 테두리에 닿으면 멈추며 화면을 채우는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">원이 부풀며 자리를 채우는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}04 · 触れ合うまで膨らむ円詰め — 計測 累積面積 44.177% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 半径 r の円を (x, y) に置けるか — 縁、格子バケット内の隣の円(中心距離 < r + 隣の r + gap)、ふさがった場所の順に見る
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
// 育てる — 一段大きくした半径が場所に収まれば大きくし、収まらなければその円はここで止まる
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
05角度の違うひびに触れると止まる直角のひび
画素ごとに通ったひびの角度を書き込む盤を持ち、ひびは0.5pxずつ歩いて空いた画素か角度の差が5°以内の画素なら進み続け、違う角度に出会うと止まり、止まったひび1本ごとに、すでに引いたひびの上の一点から直角に4°前後の揺れを足した向きへ新しいひびが2本出ます。ひびの横には次のひびまでの距離を測ってその内側に淡い砂粒10個を重ねて打つのでひびのあいだに筋が生まれ、紙や石膏の質感が似合うポートフォリオの背景に合います。シード47105は100ティックでひびの上限320本を満たして角度が書き込まれた画素14,337個を残し、測定値は累積面積42.4%・強度56.6・17/23フレームです。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="ひびがまっすぐ進んで別のひびに当たると止まり、既存のひびからほぼ直角に新しいひびが分かれ、横に砂の筋が積もる絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">ひびが直角に分かれています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
// ひび 개수 상한 — 처음 금과 갈라져 나온 금을 모두 센다
const CRACK_MAX = 320;
// 새 금이 직각(±90°)에서 벗어나는 흔들림 폭(도)
const BRANCH_ANGLE_JITTER = 4;
// ひび 한 걸음마다 법선 방향으로 흩뿌리는 모래알 수
const SAND_GRAINS = 10;
// 처음 각도를 심어 두는 자리 수와 처음 ひび 수
const SEED_SITES = 12;
const START_CRACKS = 6;
// 동시에 伸びているひび 수 상한, 한 걸음 長さ(px), 한 틱에 금마다 걷는 걸음 수 — 프레임당 계산량 상한
const ACTIVE_MAX = 36;
const CRACK_STEP = 0.5;
const STEPS_PER_FRAME = 14;
// 같은 금으로 치는 각도 차(도) — 이보다 다른 각도가 적힌 칸에 닿으면 멈춘다
const ANGLE_TOLERANCE = 5;
// 출발 직후 이 걸음 수 동안은 충돌을 보지 않는다 — 갈라져 나온 자리(부모 ひび 칸)에서 바로 멈추지 않게
const START_GRACE = 4;
// 모래를 뿌릴 빈칸을 법선으로 훑는 최대 거리(px)
const SAND_REACH = 48;
const EMPTY = 10001;
const SEED0 = 47105;
const TXT_RUN = 'ひびが直角に分かれています';
// 각도 격자 — 화소마다 그 자리를 지나간 금의 각도(도). 아직 금이 없으면 EMPTY
const cgrid = new Float32Array(W * H);
// 각도가 적힌 화소 번호 목록 — 새 금의 출발점을 기존 ひび 위에서 바로 고른다
let marks = [];
let cracks = [];
let started = 0;
let crng = makeRng(SEED0);
function sandStyles(col) {
const out = [];
for (let i = 0; i < SAND_GRAINS; i += 1) {
out.push(rgba(col, 0.34 * (1 - i / SAND_GRAINS)));
}
return out;
}
// 기존 ひび 위 한 점에서 거의 직각으로 새 금을 낸다
function makeCrack() {
if (started >= CRACK_MAX || cracks.length >= ACTIVE_MAX) {
return;
}
if (!marks.length) {
return;
}
const found = marks[Math.floor(crng() * marks.length)];
const sgn = crng() < 0.5 ? -1 : 1;
const t = cgrid[found] + sgn * (90 + (crng() * 2 - 1) * BRANCH_ANGLE_JITTER);
const x = (found % W) + 0.61 * Math.cos(t * DEG);
const y = Math.floor(found / W) + 0.61 * Math.sin(t * DEG);
cracks.push({ x: x, y: y, t: t, steps: 0, g: crng(), sand: sandStyles(PAL[Math.floor(crng() * PAL.length)]), alive: true });
started += 1;
}
function plant(s) {
crng = makeRng(s);
cgrid.fill(EMPTY);
clearArt(PAPER);
cracks = [];
marks = [];
started = 0;
for (let k = 0; k < SEED_SITES; k += 1) {
const i = Math.floor(crng() * W) + Math.floor(crng() * H) * W;
cgrid[i] = Math.floor(crng() * 360);
marks.push(i);
}
for (let k = 0; k < START_CRACKS; k += 1) {
makeCrack();
}
}
// 법선 방향 모래 — 금에서 옆으로 빈칸을 따라가 다른 ひび(또는 테두리)까지의 거리를 재고, 그 안쪽에 옅은 알갱이를 겹쳐 찍는다
function paintSand(c) {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
break;
}
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c) {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
c.alive = false;
return;
}
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) {
marks.push(i);
}
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
}
else if (c.steps > START_GRACE) {
// 각도가 다른 금에 닿았다 — 여기서 멈춘다
c.alive = false;
}
}
function grow() {
for (let k = 0; k < cracks.length; k += 1) {
const c = cracks[k];
for (let s = 0; s < STEPS_PER_FRAME && c.alive; s += 1) {
moveCrack(c);
}
}
let ended = 0;
cracks = cracks.filter(function (c) { if (!c.alive) {
ended += 1;
} return c.alive; });
// 멈춘 ひび 하나마다 새 ひび 둘 — 상한(CRACK_MAX)까지 금이 늘어난다
for (let e = 0; e < ended; e += 1) {
makeCrack();
makeCrack();
}
return cracks.length === 0;
}
function paintFrame() {
blitArt();
// 지금 伸びているひび 끝
ctx.fillStyle = rgba(INK, 0.9);
for (let k = 0; k < cracks.length; k += 1) {
ctx.beginPath();
ctx.arc(cracks[k].x, cracks[k].y, 1.8, 0, TAU);
ctx.fill();
}
}
function statPairs() {
return [['ひび', started + ' / ' + CRACK_MAX], ['伸びているひび', String(cracks.length)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// CrackRightAngleSpread - vanilla 판(05-gen-art-crack-right-angle-spread) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CrackRightAngleSpread.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CrackRightAngleSpread() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 금 개수 상한 — 처음 금과 갈라져 나온 금을 모두 센다
const CRACK_MAX = 320;
// 새 금이 직각(±90°)에서 벗어나는 흔들림 폭(도)
const BRANCH_ANGLE_JITTER = 4;
// 금 한 걸음마다 법선 방향으로 흩뿌리는 모래알 수
const SAND_GRAINS = 10;
// 처음 각도를 심어 두는 자리 수와 처음 금 수
const SEED_SITES = 12;
const START_CRACKS = 6;
// 동시에 자라는 금 수 상한, 한 걸음 길이(px), 한 틱에 금마다 걷는 걸음 수 — 프레임당 계산량 상한
const ACTIVE_MAX = 36;
const CRACK_STEP = 0.5;
const STEPS_PER_FRAME = 14;
// 같은 금으로 치는 각도 차(도) — 이보다 다른 각도가 적힌 칸에 닿으면 멈춘다
const ANGLE_TOLERANCE = 5;
// 출발 직후 이 걸음 수 동안은 충돌을 보지 않는다 — 갈라져 나온 자리(부모 금 칸)에서 바로 멈추지 않게
const START_GRACE = 4;
// 모래를 뿌릴 빈칸을 법선으로 훑는 최대 거리(px)
const SAND_REACH = 48;
const EMPTY = 10001;
const SEED0 = 47105;
const TXT_RUN = '금이 직각으로 갈라지는 중';
type Crack = { x: number; y: number; t: number; steps: number; g: number; sand: string[]; alive: boolean };
// 각도 격자 — 화소마다 그 자리를 지나간 금의 각도(도). 아직 금이 없으면 EMPTY
const cgrid = new Float32Array(W * H);
// 각도가 적힌 화소 번호 목록 — 새 금의 출발점을 기존 금 위에서 바로 고른다
let marks: number[] = [];
let cracks: Crack[] = [];
let started = 0;
let crng: () => number = makeRng(SEED0);
function sandStyles(col: RGB): string[] {
const out: string[] = [];
for (let i = 0; i < SAND_GRAINS; i += 1) { out.push(rgba(col, 0.34 * (1 - i / SAND_GRAINS))); }
return out;
}
// 기존 금 위 한 점에서 거의 직각으로 새 금을 낸다
function makeCrack(): void {
if (started >= CRACK_MAX || cracks.length >= ACTIVE_MAX) { return; }
if (!marks.length) { return; }
const found = marks[Math.floor(crng() * marks.length)];
const sgn = crng() < 0.5 ? -1 : 1;
const t = cgrid[found] + sgn * (90 + (crng() * 2 - 1) * BRANCH_ANGLE_JITTER);
const x = (found % W) + 0.61 * Math.cos(t * DEG);
const y = Math.floor(found / W) + 0.61 * Math.sin(t * DEG);
cracks.push({ x: x, y: y, t: t, steps: 0, g: crng(), sand: sandStyles(PAL[Math.floor(crng() * PAL.length)]), alive: true });
started += 1;
}
function plant(s: number): void {
crng = makeRng(s);
cgrid.fill(EMPTY);
clearArt(PAPER);
cracks = [];
marks = [];
started = 0;
for (let k = 0; k < SEED_SITES; k += 1) {
const i = Math.floor(crng() * W) + Math.floor(crng() * H) * W;
cgrid[i] = Math.floor(crng() * 360);
marks.push(i);
}
for (let k = 0; k < START_CRACKS; k += 1) { makeCrack(); }
}
// 법선 방향 모래 — 금에서 옆으로 빈칸을 따라가 다른 금(또는 테두리)까지의 거리를 재고, 그 안쪽에 옅은 알갱이를 겹쳐 찍는다
function paintSand(c: Crack): void {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) { break; }
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c: Crack): void {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) { c.alive = false; return; }
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) { marks.push(i); }
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
} else if (c.steps > START_GRACE) {
// 각도가 다른 금에 닿았다 — 여기서 멈춘다
c.alive = false;
}
}
function grow(): boolean {
for (let k = 0; k < cracks.length; k += 1) {
const c = cracks[k];
for (let s = 0; s < STEPS_PER_FRAME && c.alive; s += 1) { moveCrack(c); }
}
let ended = 0;
cracks = cracks.filter(function (c: Crack): boolean { if (!c.alive) { ended += 1; } return c.alive; });
// 멈춘 금 하나마다 새 금 둘 — 상한(CRACK_MAX)까지 금이 늘어난다
for (let e = 0; e < ended; e += 1) { makeCrack(); makeCrack(); }
return cracks.length === 0;
}
function paintFrame(): void {
blitArt();
// 지금 자라는 금 끝
ctx.fillStyle = rgba(INK, 0.9);
for (let k = 0; k < cracks.length; k += 1) {
ctx.beginPath();
ctx.arc(cracks[k].x, cracks[k].y, 1.8, 0, TAU);
ctx.fill();
}
}
function statPairs(): [string, string][] {
return [['금', started + ' / ' + CRACK_MAX], ['자라는 금', String(cracks.length)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="금이 곧게 걷다가 다른 금에 닿으면 멈추고, 기존 금에서 거의 직각으로 새 금이 갈라지며 옆에 모래 결이 쌓이는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">금이 직각으로 갈라지는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}05 · 直角に枝分かれして広がるひび — 計測 累積面積 42.388% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 法線方向の砂 — ひびから横へ空きマスをたどってほかのひび(または縁)までの距離を測り、その内側に淡い粒を重ねて打つ
function paintSand(c) {
const nxv = Math.cos((c.t + 90) * DEG);
const nyv = Math.sin((c.t + 90) * DEG);
let d = 1;
for (; d < SAND_REACH; d += 1) {
const px = Math.floor(c.x + nxv * d);
const py = Math.floor(c.y + nyv * d);
if (px < 0 || px >= W || py < 0 || py >= H || cgrid[py * W + px] < EMPTY) {
break;
}
}
c.g = clamp(c.g + (crng() - 0.5) * 0.1, 0.05, 1);
for (let i = 0; i < SAND_GRAINS; i += 1) {
const k = (d * c.g * i) / SAND_GRAINS;
actx.fillStyle = c.sand[i];
actx.fillRect(c.x + nxv * k, c.y + nyv * k, 1, 1);
}
}
function moveCrack(c) {
c.x += CRACK_STEP * Math.cos(c.t * DEG);
c.y += CRACK_STEP * Math.sin(c.t * DEG);
c.steps += 1;
const cx = Math.floor(c.x + (crng() - 0.5) * 0.66);
const cy = Math.floor(c.y + (crng() - 0.5) * 0.66);
if (cx < 0 || cx >= W || cy < 0 || cy >= H) {
c.alive = false;
return;
}
const i = cy * W + cx;
const g = cgrid[i];
if (g >= EMPTY || Math.abs(g - c.t) < ANGLE_TOLERANCE) {
if (g >= EMPTY) {
marks.push(i);
}
cgrid[i] = c.t;
paintSand(c);
actx.fillStyle = rgba(INK, 0.85);
actx.fillRect(c.x, c.y, 1, 1);
}
else if (c.steps > START_GRACE) {
// 角度の違うひびに当たった — ここで止まる
c.alive = false;
}
06隣のマスと辺が合うタイルだけを残すタイル合わせ
24×15のマスごとに草地・道・水路の辺を持つタイル18種をすべて候補として持ち、候補が一番少ないマスをひとつ重み付きの乱数で決めてから、その決定が隣のマスの候補を削る処理をキューで最後まで広げます。まだ決まっていないマスは残った候補の割合だけ濃い霧で描くので制約が広がる様子が見え、地図型のゲーム画面や道案内サービスの待ち画面に似合います。実演のシード66110は912msで候補が0のマスが出て盤を一度最初から埋め直し、3,664msで360マスを埋め終えて、レンダーの値は累積面積50.6%・強度83.0・23/23フレームでした。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="候補が最も少ないマスから隣と辺が合うタイルを選んで道と水路の地図を埋め、矛盾が出たら最初からやり直す絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">隣と合うタイルを選んでいます</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
// 타일마다 네 변(위·오른쪽·아래·왼쪽)의 종류 — 0 풀밭, 1 길, 2 물길. 맞닿는 두 변의 숫자가 같아야 이웃이 된다.
// 길과 물길이 엇갈리는 타일(다리)은 없다 — 그래서 길과 물길이 한 칸에서 만나야 하는 자리가 생기면 모순이 난다
const TILE_EDGES = [
'0000',
'0101', '1010',
'1100', '0110', '0011', '1001',
'1110', '0111', '1011', '1101',
'1111',
'0202', '2020',
'2200', '0220', '0022', '2002',
];
// 고를 때의 가중치 — 같은 순서. 풀밭이 가장 넓게
const TILE_WEIGHTS = [
6,
2, 2,
1, 1, 1, 1,
0.6, 0.6, 0.6, 0.6,
0.4,
1.6, 1.6,
0.8, 0.8, 0.8, 0.8,
];
// 한 틱에 고르는(무너뜨리는) 칸 수 — 성장 속도이자 프레임당 계산량 상한
const COLLAPSE_PER_FRAME = 2;
const CELL = 20;
const COLS = W / CELL;
const ROWS = H / CELL;
const NT = TILE_EDGES.length;
const ALL = (1 << NT) - 1;
const SEED0 = 66110;
const TXT_RUN = '隣と合うタイルを選んでいます';
const TXT_RESTARTS = '矛盾でやり直し';
// 방향 d(0 위·1 오른쪽·2 아래·3 왼쪽)의 이웃 칸 오프셋과 맞은편 변
const DC = [0, 1, 0, -1];
const DR = [-1, 0, 1, 0];
function edgeOf(t, d) { return TILE_EDGES[t].charCodeAt(d) - 48; }
// 미리 계산 — FIT[d][e] 는 "방향 d 의 이웃으로 놓였을 때 맞은편 변이 e 인 타일" 의 비트 묶음
const FIT = [];
for (let d = 0; d < 4; d += 1) {
const row = [0, 0, 0];
for (let t = 0; t < NT; t += 1) {
row[edgeOf(t, (d + 2) % 4)] |= 1 << t;
}
FIT.push(row);
}
function bits(m) {
let c = 0;
let x = m;
while (x) {
x &= x - 1;
c += 1;
}
return c;
}
const cand = new Int32Array(COLS * ROWS);
const fixedTile = new Int8Array(COLS * ROWS);
const flash = new Float64Array(COLS * ROWS);
let tie = new Float64Array(COLS * ROWS);
let trng = makeRng(SEED0);
let left = 0;
let restarts = 0;
let lastContradiction = -1;
function resetGrid() {
cand.fill(ALL);
fixedTile.fill(-1);
flash.fill(0);
left = COLS * ROWS;
clearArt(INK);
}
function plant(s) {
trng = makeRng(s);
tie = new Float64Array(COLS * ROWS);
for (let i = 0; i < tie.length; i += 1) {
tie[i] = trng() * 0.1;
}
restarts = 0;
lastContradiction = -1;
resetGrid();
}
function drawTile(i) {
const t = fixedTile[i];
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
const mx = x0 + CELL / 2;
const my = y0 + CELL / 2;
actx.fillStyle = rgba(mix(PAL[8], INK, 0.18), 1);
actx.fillRect(x0, y0, CELL, CELL);
// 물길 먼저, 그 위에 길
for (let kind = 2; kind >= 1; kind -= 1) {
const half = kind === 2 ? 4.5 : 2.6;
actx.fillStyle = kind === 2 ? rgba(PAL[6], 1) : rgba(mix(PAL[4], PAPER, 0.5), 1);
let any = false;
for (let d = 0; d < 4; d += 1) {
if (edgeOf(t, d) !== kind) {
continue;
}
any = true;
if (d === 0) {
actx.fillRect(mx - half, y0, half * 2, CELL / 2 + half);
}
else if (d === 1) {
actx.fillRect(mx - half, my - half, CELL / 2 + half, half * 2);
}
else if (d === 2) {
actx.fillRect(mx - half, my - half, half * 2, CELL / 2 + half);
}
else {
actx.fillRect(x0, my - half, CELL / 2 + half, half * 2);
}
}
if (any) {
// 가운데 이음 — 꺾이는 자리도 끊기지 않게
actx.beginPath();
actx.arc(mx, my, half, 0, TAU);
actx.fill();
}
}
}
function fix(i, t) {
fixedTile[i] = t;
cand[i] = 1 << t;
flash[i] = 1;
left -= 1;
drawTile(i);
}
// 제약 전파 — 바뀐 칸을 큐에 넣고, 이웃마다 "맞닿은 변이 맞는 후보" 만 남긴다. 후보가 0 이 되면 모순(false)
function propagate(start) {
const queue = [start];
let q = 0;
while (q < queue.length) {
const i = queue[q];
q += 1;
const c = i % COLS;
const r = Math.floor(i / COLS);
for (let d = 0; d < 4; d += 1) {
const nc = c + DC[d];
const nr = r + DR[d];
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) {
continue;
}
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) {
continue;
}
// 이 칸 후보들이 방향 d 쪽에 내놓는 변 종류 — 그 종류와 맞는 타일만 이웃에 남는다
let allow = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[i] & (1 << t)) {
allow |= FIT[d][edgeOf(t, d)];
}
}
const nm = cand[j] & allow;
if (nm === cand[j]) {
continue;
}
if (nm === 0) {
return false;
}
cand[j] = nm;
if (bits(nm) === 1) {
fix(j, 31 - Math.clz32(nm));
}
queue.push(j);
}
}
return true;
}
// 후보가 가장 적은 칸(같으면 シード 난수로 정한 순서) 하나를 가중치 난수로 고른다
function collapseOne() {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) {
continue;
}
const s = bits(cand[i]) + tie[i];
if (s < bestScore) {
bestScore = s;
best = i;
}
}
if (best < 0) {
return true;
}
let total = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[best] & (1 << t)) {
total += TILE_WEIGHTS[t];
}
}
let pick = trng() * total;
let chosen = 31 - Math.clz32(cand[best]);
for (let t = 0; t < NT; t += 1) {
if (!(cand[best] & (1 << t))) {
continue;
}
pick -= TILE_WEIGHTS[t];
if (pick <= 0) {
chosen = t;
break;
}
}
fix(best, chosen);
if (!propagate(best)) {
// 모순 — 어떤 칸에도 놓을 타일이 없다. 횟수를 세고 판 전체를 처음부터(난수는 이어서)
restarts += 1;
lastContradiction = best;
resetGrid();
}
return left === 0;
}
function grow() {
for (let k = 0; k < cand.length; k += 1) {
flash[k] = Math.max(0, flash[k] - 0.08);
}
for (let k = 0; k < COLLAPSE_PER_FRAME; k += 1) {
if (collapseOne()) {
return true;
}
}
return left === 0;
}
function paintFrame() {
blitArt();
// 아직 정해지지 않은 칸 — 남은 후보가 적을수록 안개가 옅어진다(전파가 퍼지는 모습)
for (let i = 0; i < cand.length; i += 1) {
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
if (fixedTile[i] < 0) {
const k = bits(cand[i]) / NT;
ctx.fillStyle = rgba(mix(PAL[3], INK, 0.55), 0.25 + 0.6 * k);
ctx.fillRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
else if (flash[i] > 0) {
ctx.strokeStyle = rgba(PAPER, flash[i]);
ctx.lineWidth = 1.5;
ctx.strokeRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
}
}
function statPairs() {
return [['残りのマス', String(left)], [TXT_RESTARTS, String(restarts)]];
}
function runText() { return restarts > 0 ? TXT_RUN + ' · ' + TXT_RESTARTS + ' ' + restarts : TXT_RUN; }
function doneText() { return TXT_DONE + ' · ' + TXT_RESTARTS + ' ' + restarts; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// TileNeighborMatch - vanilla 판(06-gen-art-tile-neighbor-match-collapse) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TileNeighborMatch.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TileNeighborMatch() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 타일마다 네 변(위·오른쪽·아래·왼쪽)의 종류 — 0 풀밭, 1 길, 2 물길. 맞닿는 두 변의 숫자가 같아야 이웃이 된다.
// 길과 물길이 엇갈리는 타일(다리)은 없다 — 그래서 길과 물길이 한 칸에서 만나야 하는 자리가 생기면 모순이 난다
const TILE_EDGES: string[] = [
'0000',
'0101', '1010',
'1100', '0110', '0011', '1001',
'1110', '0111', '1011', '1101',
'1111',
'0202', '2020',
'2200', '0220', '0022', '2002',
];
// 고를 때의 가중치 — 같은 순서. 풀밭이 가장 넓게
const TILE_WEIGHTS: number[] = [
6,
2, 2,
1, 1, 1, 1,
0.6, 0.6, 0.6, 0.6,
0.4,
1.6, 1.6,
0.8, 0.8, 0.8, 0.8,
];
// 한 틱에 고르는(무너뜨리는) 칸 수 — 성장 속도이자 프레임당 계산량 상한
const COLLAPSE_PER_FRAME = 2;
const CELL = 20;
const COLS = W / CELL;
const ROWS = H / CELL;
const NT = TILE_EDGES.length;
const ALL = (1 << NT) - 1;
const SEED0 = 66110;
const TXT_RUN = '이웃과 맞는 타일을 고르는 중';
const TXT_RESTARTS = '모순 재시작';
// 방향 d(0 위·1 오른쪽·2 아래·3 왼쪽)의 이웃 칸 오프셋과 맞은편 변
const DC = [0, 1, 0, -1];
const DR = [-1, 0, 1, 0];
function edgeOf(t: number, d: number): number { return TILE_EDGES[t].charCodeAt(d) - 48; }
// 미리 계산 — FIT[d][e] 는 "방향 d 의 이웃으로 놓였을 때 맞은편 변이 e 인 타일" 의 비트 묶음
const FIT: number[][] = [];
for (let d = 0; d < 4; d += 1) {
const row: number[] = [0, 0, 0];
for (let t = 0; t < NT; t += 1) { row[edgeOf(t, (d + 2) % 4)] |= 1 << t; }
FIT.push(row);
}
function bits(m: number): number {
let c = 0;
let x = m;
while (x) { x &= x - 1; c += 1; }
return c;
}
const cand = new Int32Array(COLS * ROWS);
const fixedTile = new Int8Array(COLS * ROWS);
const flash = new Float64Array(COLS * ROWS);
let tie = new Float64Array(COLS * ROWS);
let trng: () => number = makeRng(SEED0);
let left = 0;
let restarts = 0;
let lastContradiction = -1;
function resetGrid(): void {
cand.fill(ALL);
fixedTile.fill(-1);
flash.fill(0);
left = COLS * ROWS;
clearArt(INK);
}
function plant(s: number): void {
trng = makeRng(s);
tie = new Float64Array(COLS * ROWS);
for (let i = 0; i < tie.length; i += 1) { tie[i] = trng() * 0.1; }
restarts = 0;
lastContradiction = -1;
resetGrid();
}
function drawTile(i: number): void {
const t = fixedTile[i];
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
const mx = x0 + CELL / 2;
const my = y0 + CELL / 2;
actx.fillStyle = rgba(mix(PAL[8], INK, 0.18), 1);
actx.fillRect(x0, y0, CELL, CELL);
// 물길 먼저, 그 위에 길
for (let kind = 2; kind >= 1; kind -= 1) {
const half = kind === 2 ? 4.5 : 2.6;
actx.fillStyle = kind === 2 ? rgba(PAL[6], 1) : rgba(mix(PAL[4], PAPER, 0.5), 1);
let any = false;
for (let d = 0; d < 4; d += 1) {
if (edgeOf(t, d) !== kind) { continue; }
any = true;
if (d === 0) { actx.fillRect(mx - half, y0, half * 2, CELL / 2 + half); }
else if (d === 1) { actx.fillRect(mx - half, my - half, CELL / 2 + half, half * 2); }
else if (d === 2) { actx.fillRect(mx - half, my - half, half * 2, CELL / 2 + half); }
else { actx.fillRect(x0, my - half, CELL / 2 + half, half * 2); }
}
if (any) {
// 가운데 이음 — 꺾이는 자리도 끊기지 않게
actx.beginPath();
actx.arc(mx, my, half, 0, TAU);
actx.fill();
}
}
}
function fix(i: number, t: number): void {
fixedTile[i] = t;
cand[i] = 1 << t;
flash[i] = 1;
left -= 1;
drawTile(i);
}
// 제약 전파 — 바뀐 칸을 큐에 넣고, 이웃마다 "맞닿은 변이 맞는 후보" 만 남긴다. 후보가 0 이 되면 모순(false)
function propagate(start: number): boolean {
const queue: number[] = [start];
let q = 0;
while (q < queue.length) {
const i = queue[q];
q += 1;
const c = i % COLS;
const r = Math.floor(i / COLS);
for (let d = 0; d < 4; d += 1) {
const nc = c + DC[d];
const nr = r + DR[d];
if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) { continue; }
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) { continue; }
// 이 칸 후보들이 방향 d 쪽에 내놓는 변 종류 — 그 종류와 맞는 타일만 이웃에 남는다
let allow = 0;
for (let t = 0; t < NT; t += 1) { if (cand[i] & (1 << t)) { allow |= FIT[d][edgeOf(t, d)]; } }
const nm = cand[j] & allow;
if (nm === cand[j]) { continue; }
if (nm === 0) { return false; }
cand[j] = nm;
if (bits(nm) === 1) { fix(j, 31 - Math.clz32(nm)); }
queue.push(j);
}
}
return true;
}
// 후보가 가장 적은 칸(같으면 씨앗 난수로 정한 순서) 하나를 가중치 난수로 고른다
function collapseOne(): boolean {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) { continue; }
const s = bits(cand[i]) + tie[i];
if (s < bestScore) { bestScore = s; best = i; }
}
if (best < 0) { return true; }
let total = 0;
for (let t = 0; t < NT; t += 1) { if (cand[best] & (1 << t)) { total += TILE_WEIGHTS[t]; } }
let pick = trng() * total;
let chosen = 31 - Math.clz32(cand[best]);
for (let t = 0; t < NT; t += 1) {
if (!(cand[best] & (1 << t))) { continue; }
pick -= TILE_WEIGHTS[t];
if (pick <= 0) { chosen = t; break; }
}
fix(best, chosen);
if (!propagate(best)) {
// 모순 — 어떤 칸에도 놓을 타일이 없다. 횟수를 세고 판 전체를 처음부터(난수는 이어서)
restarts += 1;
lastContradiction = best;
resetGrid();
}
return left === 0;
}
function grow(): boolean {
for (let k = 0; k < cand.length; k += 1) { flash[k] = Math.max(0, flash[k] - 0.08); }
for (let k = 0; k < COLLAPSE_PER_FRAME; k += 1) {
if (collapseOne()) { return true; }
}
return left === 0;
}
function paintFrame(): void {
blitArt();
// 아직 정해지지 않은 칸 — 남은 후보가 적을수록 안개가 옅어진다(전파가 퍼지는 모습)
for (let i = 0; i < cand.length; i += 1) {
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
if (fixedTile[i] < 0) {
const k = bits(cand[i]) / NT;
ctx.fillStyle = rgba(mix(PAL[3], INK, 0.55), 0.25 + 0.6 * k);
ctx.fillRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
} else if (flash[i] > 0) {
ctx.strokeStyle = rgba(PAPER, flash[i]);
ctx.lineWidth = 1.5;
ctx.strokeRect(x0 + 1, y0 + 1, CELL - 2, CELL - 2);
}
}
}
function statPairs(): [string, string][] {
return [['남은 칸', String(left)], [TXT_RESTARTS, String(restarts)]];
}
function runText(): string { return restarts > 0 ? TXT_RUN + ' · ' + TXT_RESTARTS + ' ' + restarts : TXT_RUN; }
function doneText(): string { return TXT_DONE + ' · ' + TXT_RESTARTS + ' ' + restarts; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="후보가 가장 적은 칸부터 이웃과 변이 맞는 타일을 골라 길과 물길 지도를 채우고, 모순이 나면 처음부터 다시 하는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">이웃과 맞는 타일을 고르는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}06 · 隣のマスと合うものだけを残して埋まるタイル — 計測 累積面積 50.553% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const j = nr * COLS + nc;
if (fixedTile[j] >= 0) {
continue;
}
// このマスの候補が向き d の側に出す辺の種類 — その種類と合うタイルだけが隣に残る
let allow = 0;
for (let t = 0; t < NT; t += 1) {
if (cand[i] & (1 << t)) {
allow |= FIT[d][edgeOf(t, d)];
}
}
const nm = cand[j] & allow;
if (nm === cand[j]) {
continue;
}
if (nm === 0) {
return false;
}
cand[j] = nm;
if (bits(nm) === 1) {
fix(j, 31 - Math.clz32(nm));
}
queue.push(j);
// 候補が最も少ないマス(同じならシードの乱数で決めた順)ひとつを重み付きの乱数で選ぶ
function collapseOne() {
let best = -1;
let bestScore = 1e9;
for (let i = 0; i < cand.length; i += 1) {
if (fixedTile[i] >= 0) {
continue;
}
const s = bits(cand[i]) + tie[i];
if (s < bestScore) {
bestScore = s;
best = i;
}
}
if (best < 0) {
return true;
}
07一周しても継ぎ目が出ない、ノイズで揺らぐ年輪
年ごとに幅の違う輪を半径138pxまで重ね、輪の角度をノイズ空間の円周上の座標に変えて読んで半径を±8%の範囲で揺らし、輪ごとにその円を0.07ずつずらして隣り合う輪どうしを似せます。広い年は明るく、狭い年は濃く太い線で塗り、新しい輪は5ティックかけて膨らむので、木や家具のブランドや沿革の紹介画面によくなじみます。シード47107は120ティックで輪24本を描き終えて0と2πの地点の半径の差は0pxとなり、強度138.6は九枚の中で一番高く、累積面積35.8%・18/23フレームです。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="ノイズで揺らぐ年輪が、年ごとに違う幅で一層ずつ外側に増えていく絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">年輪が一層ずつ増えています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 円 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = '年輪が一層ずつ増えています';
let wnoise = makeNoise(makeRng(SEED0));
let radii = [];
let widths = [];
let pts = [];
let grown = 0;
let ringTick = 0;
function plant(s) {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 円 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k, a, base) {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k, base) {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow() {
if (grown >= radii.length) {
return true;
}
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p) {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) {
ctx.lineTo(p[j * 2], p[j * 2 + 1]);
}
ctx.closePath();
}
const EARLY = mix(PAL[4], PAPER, 0.35);
const LATE = mix(PAL[4], INK, 0.42);
function paintFrame() {
blitArt();
const rings = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs() {
return [['年輪', grown + ' / ' + radii.length]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// TreeRingNoiseGrowth - vanilla 판(07-gen-art-tree-ring-noise-growth) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./TreeRingNoiseGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function TreeRingNoiseGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 평균 고리 폭(px) — 해마다 이 값의 0.45~1.55 배로 넓었다 좁았다 한다
const RING_SPACING = 5.5;
// 노이즈가 반지름을 일그러뜨리는 비율 — 0.08 이면 반지름의 ±8%
const RING_NOISE_AMP = 0.08;
// 고리 하나를 이루는 점 수
const RING_SEGMENTS = 160;
// 고리 하나가 자라는 틱 수 — 성장 속도
const RING_TICKS = 5;
// 줄기 반지름(px)과 가운데
const TRUNK_R = 138;
const TRUNK_CX = 300;
const TRUNK_CY = 150;
// 노이즈 공간에 그리는 원의 반지름 — 각도를 이 원 위의 좌표로 바꿔 읽으므로 0 과 2π 가 같은 자리다
const NOISE_LOOP_R = 1.3;
// 고리마다 노이즈 원의 가운데를 이만큼 옮긴다 — 이웃 고리는 닮고, 멀리 떨어진 고리는 달라진다
const RING_DRIFT = 0.07;
const SEED0 = 47107;
const TXT_RUN = '나이테가 한 겹씩 느는 중';
let wnoise: Noise = makeNoise(makeRng(SEED0));
let radii: number[] = [];
let widths: number[] = [];
let pts: Float64Array[] = [];
let grown = 0;
let ringTick = 0;
function plant(s: number): void {
clearArt(INK);
const rng = makeRng(s);
wnoise = makeNoise(rng);
radii = [];
widths = [];
pts = [];
grown = 0;
ringTick = 0;
// 누적 반지름 — 해마다 다른 폭을 더해 간다
let r = 4;
while (r < TRUNK_R) {
const w = RING_SPACING * (0.45 + rng() * 1.1);
r = Math.min(TRUNK_R, r + w);
radii.push(r);
widths.push(w);
}
}
// k 번째 고리의 각도 a 에서의 반지름 — 각도를 노이즈 공간의 원 위 좌표로 바꿔 읽어, 한 바퀴 돌면 같은 값으로 닫힌다
function ringR(k: number, a: number, base: number): number {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k: number, base: number): Float64Array {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
function grow(): boolean {
if (grown >= radii.length) { return true; }
ringTick += 1;
if (ringTick >= RING_TICKS) {
// 다 자란 고리는 점을 한 번만 계산해 둔다
pts.push(ringPoints(grown, radii[grown]));
grown += 1;
ringTick = 0;
}
return grown >= radii.length;
}
function ringPath(p: Float64Array): void {
ctx.beginPath();
ctx.moveTo(p[0], p[1]);
for (let j = 1; j < RING_SEGMENTS; j += 1) { ctx.lineTo(p[j * 2], p[j * 2 + 1]); }
ctx.closePath();
}
const EARLY: RGB = mix(PAL[4], PAPER, 0.35);
const LATE: RGB = mix(PAL[4], INK, 0.42);
function paintFrame(): void {
blitArt();
const rings: Float64Array[] = pts.slice();
// 지금 자라는 고리 — 앞 고리 반지름에서 제 반지름까지 부드럽게 부푼다
if (grown < radii.length) {
const from = grown > 0 ? radii[grown - 1] : 0;
const k = ringTick / RING_TICKS;
rings.push(ringPoints(grown, lerp(from, radii[grown], k * k * (3 - 2 * k))));
}
// 바깥 고리부터 칠한다 — 안쪽 고리가 위에 덮여 고리 사이 띠만 남는다. 넓은 해는 밝게, 좁은 해는 진하게
for (let k = rings.length - 1; k >= 0; k -= 1) {
ringPath(rings[k]);
const wide = clamp((widths[k] / RING_SPACING - 0.45) / 1.1, 0, 1);
ctx.fillStyle = rgba(mix(LATE, EARLY, 0.35 + 0.65 * wide), 1);
ctx.fill();
ctx.strokeStyle = rgba(LATE, 0.95);
ctx.lineWidth = 0.8 + (1 - wide) * 1.4;
ctx.stroke();
}
if (grown >= radii.length && pts.length) {
ringPath(pts[pts.length - 1]);
ctx.strokeStyle = rgba(mix(LATE, INK, 0.5), 1);
ctx.lineWidth = 6;
ctx.stroke();
}
}
function statPairs(): [string, string][] {
return [['나이테', grown + ' / ' + radii.length]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="노이즈로 일렁이는 나이테가 해마다 다른 폭으로 한 겹씩 바깥에 늘어나는 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">나이테가 한 겹씩 느는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}07 · ノイズで揺らぎながら一層ずつ増える年輪 — 計測 累積面積 35.837% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// k 番目の輪の角度 a での半径 — 角度をノイズ空間の円周上の座標に変えて読み、一周すると同じ値で閉じる
function ringR(k, a, base) {
const nx = 7.3 + k * RING_DRIFT + Math.cos(a) * NOISE_LOOP_R;
const ny = 3.1 + Math.sin(a) * NOISE_LOOP_R;
return base * (1 + RING_NOISE_AMP * (fbm2(wnoise, nx, ny, 2) * 2 - 1));
}
function ringPoints(k, base) {
const out = new Float64Array(RING_SEGMENTS * 2);
for (let j = 0; j < RING_SEGMENTS; j += 1) {
const a = (j / RING_SEGMENTS) * TAU;
const r = ringR(k, a, base);
out[j * 2] = TRUNK_CX + Math.cos(a) * r;
out[j * 2 + 1] = TRUNK_CY + Math.sin(a) * r;
}
return out;
}
08群れごとに縁のマスをひとつずつ取っていく苔の広がり
4pxのマス9,000個の盤に群れを7つ植え、群れを順番に回って自分の縁のリストからシード付きの乱数で1マスを取り出し、空いていれば自分のものにして、取り出した位置はリストの一番後ろのマスと入れ替えて抜きます。先に生まれたマスは濃く、遅く生まれたマスは明るく塗り、ほかの群れと接する辺には濃い1pxの線を引くので、地域ごとの区画やチームの担当を分けるダッシュボードの背景のような場所に合います。シード47108は150ティックで9,000マスを埋め終えて境目732か所を残し、累積面積75.5%は九枚の中で一番広く、強度112.3・21/23フレームです。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="群れごとに縁の空きマスをひとつランダムに選んで埋めて広がり、生まれた順に色が淡くなる苔の絵。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">苔の群れが広がっています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
// 군락 수 — シード 칸 하나씩에서 출발한다
const COLONY_COUNT = 7;
// 한 틱에 새로 채우는 칸 수(군락을 차례로 돌며 하나씩) — 성장 속도이자 프레임당 계산량 상한
const CELLS_PER_FRAME = 60;
// 태어난 순서로 옅어지는 정도 — 마지막 칸은 짙은 색에서 밝은 색 쪽으로 이 비율만큼 간다
const AGE_FADE = 0.6;
const CELL = 4;
const COLS = W / CELL;
const ROWS = H / CELL;
const TOTAL = COLS * ROWS;
const SEED0 = 47108;
const TXT_RUN = '苔の群れが広がっています';
const owner = new Int8Array(TOTAL);
// 칸마다 그 칸을 가장자리 목록에 넣은 군락들의 비트 — 같은 칸을 한 군락 목록에 두 번 넣지 않게
const inFront = new Int32Array(TOTAL);
let fronts = [];
let lrng = makeRng(SEED0);
let born = 0;
let turn = 0;
let fresh = [];
let borders = 0;
function colonyDeep(c) { return mix(PAL[c % PAL.length], INK, 0.35); }
function colonyLight(c) { return mix(PAL[c % PAL.length], PAPER, 0.55); }
function claim(i, c) {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// 태어난 순서로 색을 옅게 — 먼저 난 가운데는 짙고 늦게 난 가장자리는 밝다
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) {
continue;
}
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) {
inFront[j] |= 1 << c;
fronts[c].push(j);
}
}
else if (owner[j] !== c) {
// 다른 군락과 맞닿은 변 — 짙은 선으로 경계를 긋는다
borders += 1;
actx.fillStyle = rgba(INK, 0.7);
if (k === 0) {
actx.fillRect(x0, y0, 1, CELL);
}
else if (k === 1) {
actx.fillRect(x0 + CELL - 1, y0, 1, CELL);
}
else if (k === 2) {
actx.fillRect(x0, y0, CELL, 1);
}
else {
actx.fillRect(x0, y0 + CELL - 1, CELL, 1);
}
}
}
}
function plant(s) {
clearArt(INK);
lrng = makeRng(s);
owner.fill(-1);
inFront.fill(0);
fronts = [];
born = 0;
turn = 0;
fresh = [];
borders = 0;
for (let c = 0; c < COLONY_COUNT; c += 1) {
fronts.push([]);
let i = Math.floor(lrng() * TOTAL);
while (owner[i] >= 0) {
i = (i + 1) % TOTAL;
}
claim(i, c);
}
}
function grow() {
fresh = [];
let made = 0;
let idle = 0;
while (made < CELLS_PER_FRAME && idle < COLONY_COUNT) {
const c = turn;
turn = (turn + 1) % COLONY_COUNT;
const f = fronts[c];
if (!f.length) {
idle += 1;
continue;
}
idle = 0;
// 가장자리 목록에서 하나를 무작위로 꺼낸다(맨 끝 칸과 자리를 바꿔 빼서 목록을 당기지 않는다)
const k = Math.floor(lrng() * f.length);
const i = f[k];
f[k] = f[f.length - 1];
f.pop();
inFront[i] &= ~(1 << c);
if (owner[i] < 0) {
claim(i, c);
made += 1;
}
}
return idle >= COLONY_COUNT;
}
function paintFrame() {
blitArt();
// 이번 틱에 붙은 칸 — 밝게 반짝
ctx.fillStyle = rgba(PAPER, 0.85);
for (let k = 0; k < fresh.length; k += 1) {
const i = fresh[k];
ctx.fillRect((i % COLS) * CELL + 0.5, Math.floor(i / COLS) * CELL + 0.5, CELL - 1, CELL - 1);
}
}
function statPairs() {
return [['埋めたマス', born + ' / ' + TOTAL], ['境目', String(borders)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// LichenEdgeAccretion - vanilla 판(08-gen-art-lichen-edge-accretion) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LichenEdgeAccretion.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LichenEdgeAccretion() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 군락 수 — 씨앗 칸 하나씩에서 출발한다
const COLONY_COUNT = 7;
// 한 틱에 새로 채우는 칸 수(군락을 차례로 돌며 하나씩) — 성장 속도이자 프레임당 계산량 상한
const CELLS_PER_FRAME = 60;
// 태어난 순서로 옅어지는 정도 — 마지막 칸은 짙은 색에서 밝은 색 쪽으로 이 비율만큼 간다
const AGE_FADE = 0.6;
const CELL = 4;
const COLS = W / CELL;
const ROWS = H / CELL;
const TOTAL = COLS * ROWS;
const SEED0 = 47108;
const TXT_RUN = '이끼 군락이 번지는 중';
const owner = new Int8Array(TOTAL);
// 칸마다 그 칸을 가장자리 목록에 넣은 군락들의 비트 — 같은 칸을 한 군락 목록에 두 번 넣지 않게
const inFront = new Int32Array(TOTAL);
let fronts: number[][] = [];
let lrng: () => number = makeRng(SEED0);
let born = 0;
let turn = 0;
let fresh: number[] = [];
let borders = 0;
function colonyDeep(c: number): RGB { return mix(PAL[c % PAL.length], INK, 0.35); }
function colonyLight(c: number): RGB { return mix(PAL[c % PAL.length], PAPER, 0.55); }
function claim(i: number, c: number): void {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// 태어난 순서로 색을 옅게 — 먼저 난 가운데는 짙고 늦게 난 가장자리는 밝다
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) { continue; }
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) { inFront[j] |= 1 << c; fronts[c].push(j); }
} else if (owner[j] !== c) {
// 다른 군락과 맞닿은 변 — 짙은 선으로 경계를 긋는다
borders += 1;
actx.fillStyle = rgba(INK, 0.7);
if (k === 0) { actx.fillRect(x0, y0, 1, CELL); }
else if (k === 1) { actx.fillRect(x0 + CELL - 1, y0, 1, CELL); }
else if (k === 2) { actx.fillRect(x0, y0, CELL, 1); }
else { actx.fillRect(x0, y0 + CELL - 1, CELL, 1); }
}
}
}
function plant(s: number): void {
clearArt(INK);
lrng = makeRng(s);
owner.fill(-1);
inFront.fill(0);
fronts = [];
born = 0;
turn = 0;
fresh = [];
borders = 0;
for (let c = 0; c < COLONY_COUNT; c += 1) {
fronts.push([]);
let i = Math.floor(lrng() * TOTAL);
while (owner[i] >= 0) { i = (i + 1) % TOTAL; }
claim(i, c);
}
}
function grow(): boolean {
fresh = [];
let made = 0;
let idle = 0;
while (made < CELLS_PER_FRAME && idle < COLONY_COUNT) {
const c = turn;
turn = (turn + 1) % COLONY_COUNT;
const f = fronts[c];
if (!f.length) { idle += 1; continue; }
idle = 0;
// 가장자리 목록에서 하나를 무작위로 꺼낸다(맨 끝 칸과 자리를 바꿔 빼서 목록을 당기지 않는다)
const k = Math.floor(lrng() * f.length);
const i = f[k];
f[k] = f[f.length - 1];
f.pop();
inFront[i] &= ~(1 << c);
if (owner[i] < 0) { claim(i, c); made += 1; }
}
return idle >= COLONY_COUNT;
}
function paintFrame(): void {
blitArt();
// 이번 틱에 붙은 칸 — 밝게 반짝
ctx.fillStyle = rgba(PAPER, 0.85);
for (let k = 0; k < fresh.length; k += 1) {
const i = fresh[k];
ctx.fillRect((i % COLS) * CELL + 0.5, Math.floor(i / COLS) * CELL + 0.5, CELL - 1, CELL - 1);
}
}
function statPairs(): [string, string][] {
return [['채운 칸', born + ' / ' + TOTAL], ['경계', String(borders)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="군락마다 가장자리 빈칸 하나를 무작위로 골라 채우며 번지고, 태어난 순서로 색이 옅어지는 이끼 그림. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">이끼 군락이 번지는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}08 · 縁のマスがひとつずつ付いて広がる苔の群れ — 計測 累積面積 75.494% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function claim(i, c) {
owner[i] = c;
const x0 = (i % COLS) * CELL;
const y0 = Math.floor(i / COLS) * CELL;
// 生まれた順に色を淡く — 先に生まれた中心は濃く、遅く生まれた縁は明るい
actx.fillStyle = rgba(mix(colonyDeep(c), colonyLight(c), (AGE_FADE * born) / TOTAL), 1);
actx.fillRect(x0, y0, CELL, CELL);
born += 1;
fresh.push(i);
const col = i % COLS;
const row = Math.floor(i / COLS);
const nb = [col > 0 ? i - 1 : -1, col < COLS - 1 ? i + 1 : -1, row > 0 ? i - COLS : -1, row < ROWS - 1 ? i + COLS : -1];
for (let k = 0; k < 4; k += 1) {
const j = nb[k];
if (j < 0) {
continue;
}
if (owner[j] < 0) {
if (!(inFront[j] & (1 << c))) {
inFront[j] |= 1 << c;
fronts[c].push(j);
}
}
09ひとつのシードで流れ線・葉脈・円の三層を育てるヒーロー
ひとつのシードを枝番号と混ぜて流れ線・葉脈・円・ノイズがそれぞれ別の乱数列を受け取るようにし、流れ線は0ms、葉脈は480ms、円は960msから順に育ち、円は葉脈の節から2px以内を避けて場所を取ります。三つが育ち終えると1,400msとどまったあと今の場面を別に写し取り、次のシードの場面の上に700msかけて淡く重ねて切り替わるので、ランディングの最初の画面のヒーロー背景としてそのまま貼れるように作った一枚です。実演の一巡(47109 → 76513)は909ティック・14,544msで、キャプチャでは累積面積39.0%・強度73.4・23/23フレームでした。

<main class="stage">
<div class="pc is-demo" id="pc">
<canvas class="pc__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="同じシードから流れ線・葉脈・円詰めが順に育って重なり、育ち終えると少し止まってから次のシードの絵へ淡く切り替わるヒーロー背景。Enterで新しいシードを描き、Rで同じシードを最初から育て直し、Spaceで一時停止します。"></canvas>
<p class="pc__status" data-role="status" aria-live="off">三層が順に育っています</p>
<div class="pc__tools">
<button class="pc__btn" data-role="redraw" type="button">描き直す</button>
<button class="pc__btn" data-role="replay" type="button">同じシード</button>
<button class="pc__btn" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: $sp-3;
bottom: $sp-4;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.pc__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.pc__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.pc__btn:hover {
background: $subject-cream;
}
.pc__btn[aria-pressed="true"] {
background: $subject-mint;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.pc__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.pc__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.pc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.pc__status {
position: absolute;
left: 12px;
bottom: 16px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.pc__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pc__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.pc__btn:hover {
background: #fff7e6;
}
.pc__btn[aria-pressed=true] {
background: #4cd4a6;
}
.pc__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
@media (max-width: 340px) {
.pc__btn {
font-size: 10px;
padding: 4px 8px;
}
.pc__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pc__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pc");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 線·면을 얹어 명도 차로 형태가 읽힌다
const INK = [29, 26, 44];
const PAPER = [255, 248, 234];
const PAL = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue) {
let s = seedValue >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 シード — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s) { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const actx = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]');
const replayBtn = root.querySelector('[data-role="replay"]');
const pauseBtn = root.querySelector('[data-role="pause"]');
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 一時停止 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
let dprNow = 0;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) {
return;
}
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) {
kctx.drawImage(artEl, 0, 0);
}
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) {
actx.drawImage(keep, 0, 0, W, H);
}
dprNow = dpr;
}
function clearArt(c) {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt() { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs) {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) {
ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3);
}
}
function makeNoise(rng) {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) {
perm[i] = i;
vals[i] = rng();
}
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
return { perm: perm, vals: vals };
}
function noise2(n, x, y) {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n, x, y, octaves) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeBuckets(cell) {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b = [];
for (let i = 0; i < cols * rows; i += 1) {
b.push([]);
}
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g, x, y, id) {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g) {
for (let i = 0; i < g.b.length; i += 1) {
g.b[i].length = 0;
}
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g, x, y, rad, visit) {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) {
return true;
}
}
}
}
return false;
}
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng, noise, o) {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 長さ 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f, x, y) {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 長さ 안)은 세지 않는다
function flowTooClose(f, x, y, d, line, ord) {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id) {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) {
return false;
}
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f, x, y) {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f, x, y) {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f, x, y) {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 線 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f) {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f, budget, seg) {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) {
flowStart(f, cx, cy);
}
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) {
f.fails = 0;
flowStart(f, rx, ry);
}
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) {
f.done = true;
}
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 線 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
}
else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
function makeVein(rng, o) {
const ax = [];
const ay = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) {
ax.push(x);
ay.push(y);
}
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) {
bucketAdd(agrid, ax[i], ay[i], i);
}
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 節 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v, x, y, parent) {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 周長 kill 안의 끌개를 지운다
function veinKill(v, from) {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a) {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) {
v.alive[a] = 0;
v.left -= 1;
}
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v) {
if (v.done) {
return true;
}
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 節 하나 — 그 節 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) {
continue;
}
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id) {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) {
bd = d2;
best = id;
}
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) {
v.reached = true;
}
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
mx += v.ax[a];
my += v.ay[a];
}
}
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
}
else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) {
continue;
}
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) {
continue;
}
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) {
v.done = true;
}
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v, c, col, alpha, base, k, cap) {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) {
continue;
}
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v, c, col, alpha) {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) {
c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6);
}
}
c.fill();
}
function makePack(rng, o) {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 円(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p, x, y, r, self) {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) {
return false;
}
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j) {
if (j === self) {
return false;
}
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) {
return false;
}
return !p.o.blocked(x, y, r + g);
}
function packStep(p, onStop) {
if (p.done) {
return true;
}
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
}
else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
}
else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) {
p.done = true;
}
return p.done;
}
// 첫 그림의 シード — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 線 0, 잎맥 1배, 円 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = '三層が順に育っています';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x, y) {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s) {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s) {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl = buildFlow(SEED0);
let vn = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 円 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x, y, r) {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id) {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s) {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s) {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s) {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax, ay, bx, by) {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c) {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot() {
if (!pctx) {
return;
}
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow() {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) {
flowStep(fl, HERO_FLOW_STEPS, flowSeg);
}
if (age >= LAYER_STAGGER_MS && !vn.done) {
veinStep(vn);
}
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
packStep(pk, function () { });
}
const all = fl.done && vn.done && pk.done;
if (!all) {
return false;
}
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) {
return true;
}
if (holdAt < 0) {
holdAt = age;
}
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame() {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs() {
return [['線', String(fl.lines)], ['節', String(vn.nx.length)], ['円', String(pk.cx.length)]];
}
function runText() { return TXT_RUN; }
function doneText() { return TXT_DONE; }
const TXT_DONE = '育ち終わり — Enterで新しいシード';
const TXT_PAUSE = '一時停止 — Spaceで再開';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s) {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow() {
if (done) {
return;
}
age += FIXED_DT;
if (grow()) {
done = true;
}
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow() {
let g = 0;
while (!done && g < GROW_GUARD) {
tickGrow();
g += 1;
}
}
function report() {
if (paused) {
say(TXT_PAUSE);
}
else if (done) {
say(doneText());
}
else {
say(runText());
}
}
function render() {
paintFrame();
const pairs = [['シード', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v) {
paused = v;
if (pauseBtn) {
pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
}
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after() {
if (still) {
finishNow();
}
render();
}
function newSeed() { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed() { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function () { wake(); after(); });
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'Enter') {
newSeed();
}
else if (k === 'r' || k === 'R') {
replaySeed();
}
else if (k === ' ') {
setPaused(!paused);
}
else {
return;
}
ev.preventDefault();
wake();
after();
});
if (redrawBtn) {
redrawBtn.addEventListener('click', function () { wake(); newSeed(); after(); });
}
if (replayBtn) {
replayBtn.addEventListener('click', function () { wake(); replaySeed(); after(); });
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () { wake(); setPaused(!paused); after(); });
}
function onResize() {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick() {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) {
return;
}
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) {
reseed(nextSeed(seed));
}
return;
}
}
if (!paused) {
tickGrow();
}
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (still) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref() {
still = reduceQuery.matches;
if (still) {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
after();
}
else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') {
reduceQuery.addEventListener('change', onMotionPref);
}
if (still) {
after();
}
else {
render();
startLoop();
}
return function () {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') {
reduceQuery.removeEventListener('change', onMotionPref);
}
};
})();// HeroSeededGrowth - vanilla 판(09-gen-art-hero-seeded-growth-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./HeroSeededGrowth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroSeededGrowth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
// 파스텔 팔레트 — 어두운 무대(INK) 위에 밝은 선·면을 얹어 명도 차로 형태가 읽힌다
const INK: RGB = [29, 26, 44];
const PAPER: RGB = [255, 248, 234];
const PAL: RGB[] = [
[124, 196, 255],
[255, 160, 190],
[126, 226, 192],
[190, 164, 255],
[255, 208, 168],
[246, 228, 140],
[150, 222, 236],
[232, 182, 228],
[196, 220, 160],
];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 씨앗이면 같은 수열, 곧 같은 그림이 다시 나온다 (32비트 정수 섞기)
function makeRng(seedValue: number): () => number {
let s = seedValue >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 다음 씨앗 — 다시 그리기를 누를 때마다 정수 하나만 바뀐다(10007~99995 사이 다섯 자리)
function nextSeed(s: number): number { return ((s * 7919 + 104729) % 89989) + 10007; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
// 그림판 — 자란 결과를 쌓아 두는 화면 밖 캔버스. 프레임마다 이걸 옮겨 그리고 그 위에 지금 자라는 부분만 얹는다
const artEl = document.createElement('canvas');
const actxRaw = artEl.getContext('2d');
if (!canvasEl || !ctxRaw || !statusEl || !actxRaw) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const actx: CanvasRenderingContext2D = actxRaw;
const redrawBtn = root.querySelector('[data-role="redraw"]') as HTMLButtonElement | null;
const replayBtn = root.querySelector('[data-role="replay"]') as HTMLButtonElement | null;
const pauseBtn = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 tail 의 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
// 지금 그림의 씨앗과, 그 씨앗을 심은 뒤 흐른 성장 시간(ms, 일시정지 동안은 멈춘다)
let seed = 0;
let age = 0;
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
let dprNow = 0;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dpr === dprNow) { return; }
// 캔버스 크기를 바꾸면 내용이 지워진다 — 그려 둔 그림판을 잠깐 옮겨 두었다가 새 배율로 다시 담는다
const keep = document.createElement('canvas');
keep.width = artEl.width;
keep.height = artEl.height;
const kctx = keep.getContext('2d');
if (kctx && dprNow) { kctx.drawImage(artEl, 0, 0); }
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
artEl.width = cv.width;
artEl.height = cv.height;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (kctx && dprNow) { actx.drawImage(keep, 0, 0, W, H); }
dprNow = dpr;
}
function clearArt(c: RGB): void {
actx.fillStyle = rgba(c, 1);
actx.fillRect(0, 0, W, H);
}
function blitArt(): void { ctx.drawImage(artEl, 0, 0, W, H); }
// 오른쪽 아래 수치 상자 — 씨앗과 항목별 개수를 이름표 + 값 한 줄씩
function drawStats(pairs: [string, string][]): void {
ctx.font = '600 11px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'alphabetic';
const lines: string[] = [];
let wide = 0;
for (let i = 0; i < pairs.length; i += 1) {
const t = pairs[i][0] + ' ' + pairs[i][1];
lines.push(t);
wide = Math.max(wide, ctx.measureText(t).width);
}
const lh = 15;
const bw = wide + 16;
const bh = lines.length * lh + 8;
ctx.fillStyle = rgba(INK, 0.66);
ctx.fillRect(W - 10 - bw, H - 10 - bh, bw, bh);
ctx.fillStyle = rgba(PAPER, 0.95);
for (let i = 0; i < lines.length; i += 1) { ctx.fillText(lines[i], W - 18, H - 10 - bh + 4 + lh * (i + 1) - 3); }
}
// 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값을 하나씩 두고, 그 사이를 3t²−2t³ 곡선으로 부드럽게 이어 읽는다
type Noise = { perm: Uint16Array; vals: Float64Array };
function makeNoise(rng: () => number): Noise {
const perm = new Uint16Array(512);
const vals = new Float64Array(256);
for (let i = 0; i < 256; i += 1) { perm[i] = i; vals[i] = rng(); }
// 섞기 — 같은 씨앗이면 같은 순서
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const t = perm[i];
perm[i] = perm[j];
perm[j] = t;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
return { perm: perm, vals: vals };
}
function noise2(n: Noise, x: number, y: number): number {
const xf = Math.floor(x);
const yf = Math.floor(y);
const X = xf & 255;
const Y = yf & 255;
const fx = x - xf;
const fy = y - yf;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const p = n.perm;
const a = n.vals;
const v00 = a[p[p[X] + Y] & 255];
const v10 = a[p[p[X + 1] + Y] & 255];
const v01 = a[p[p[X] + Y + 1] & 255];
const v11 = a[p[p[X + 1] + Y + 1] & 255];
return lerp(lerp(v00, v10, u), lerp(v01, v11, u), v);
}
// 겹 노이즈 — 주파수를 두 배, 세기를 절반씩 줄여 octaves 겹을 더하고 0~1 로 되돌린다
function fbm2(n: Noise, x: number, y: number, octaves: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let o = 0; o < octaves; o += 1) {
sum += noise2(n, x * f, y * f) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 격자 버킷 — 점 번호를 칸(한 변 cell px)별 목록에 담아 두고, 반경 안 이웃은 주변 칸만 훑어 찾는다
type Buckets = { cell: number; cols: number; rows: number; b: number[][] };
function makeBuckets(cell: number): Buckets {
const cols = Math.ceil(W / cell) + 1;
const rows = Math.ceil(H / cell) + 1;
const b: number[][] = [];
for (let i = 0; i < cols * rows; i += 1) { b.push([]); }
return { cell: cell, cols: cols, rows: rows, b: b };
}
function bucketAdd(g: Buckets, x: number, y: number, id: number): void {
const c = clamp(Math.floor(x / g.cell), 0, g.cols - 1);
const r = clamp(Math.floor(y / g.cell), 0, g.rows - 1);
g.b[r * g.cols + c].push(id);
}
function bucketsClear(g: Buckets): void {
for (let i = 0; i < g.b.length; i += 1) { g.b[i].length = 0; }
}
// (x, y) 에서 반경 rad 를 덮는 칸들의 번호를 visit 에 넘긴다 — visit 이 true 를 돌려주면 그 자리에서 멈추고 true
function bucketNear(g: Buckets, x: number, y: number, rad: number, visit: (id: number) => boolean): boolean {
const c0 = clamp(Math.floor((x - rad) / g.cell), 0, g.cols - 1);
const c1 = clamp(Math.floor((x + rad) / g.cell), 0, g.cols - 1);
const r0 = clamp(Math.floor((y - rad) / g.cell), 0, g.rows - 1);
const r1 = clamp(Math.floor((y + rad) / g.cell), 0, g.rows - 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const list = g.b[r * g.cols + c];
for (let k = 0; k < list.length; k += 1) {
if (visit(list[k])) { return true; }
}
}
}
return false;
}
// 간격 제한 흐름 선 — 칸마다 노이즈로 정한 방향을 따라 선을 앞뒤로 늘리고, 다른 선의 점이 정한 간격보다 가까우면 그 자리에서 멈춘다
type FlowOpts = { sep: number; test: number; step: number; scale: number; cell: number; maxPts: number; margin: number };
type FlowSeg = (ax: number, ay: number, bx: number, by: number, line: number) => void;
type Flow = {
o: FlowOpts; rng: () => number; vx: Float64Array; vy: Float64Array; fcols: number; frows: number;
px: number[]; py: number[]; pl: number[]; po: number[]; grid: Buckets;
cand: number[]; candAt: number; line: number; dir: number; ord: number; x: number; y: number; sx: number; sy: number; pts: number[];
active: boolean; lines: number; length: number; fails: number; done: boolean;
};
// 무작위 출발점이 연달아 이만큼 거절되면 빈자리가 없다고 보고 끝낸다
const FLOW_SEED_FAILS = 160;
function makeFlow(rng: () => number, noise: Noise, o: FlowOpts): Flow {
const fcols = Math.ceil(W / o.cell) + 2;
const frows = Math.ceil(H / o.cell) + 2;
const vx = new Float64Array(fcols * frows);
const vy = new Float64Array(fcols * frows);
// 칸 꼭짓점마다 방향을 한 번만 정해 둔다 — 노이즈 0~1 을 두 바퀴(4π) 각도로
for (let j = 0; j < frows; j += 1) {
for (let i = 0; i < fcols; i += 1) {
const a = fbm2(noise, i * o.cell * o.scale, j * o.cell * o.scale, 2) * TAU * 2;
vx[j * fcols + i] = Math.cos(a);
vy[j * fcols + i] = Math.sin(a);
}
}
return {
o: o, rng: rng, vx: vx, vy: vy, fcols: fcols, frows: frows,
px: [], py: [], pl: [], po: [], grid: makeBuckets(o.sep),
cand: [], candAt: 0, line: -1, dir: 1, ord: 0, x: 0, y: 0, sx: 0, sy: 0, pts: [],
active: false, lines: 0, length: 0, fails: 0, done: false,
};
}
// 네 꼭짓점 방향을 거리 비율로 섞어(쌍선형) 길이 1 로 — 칸 경계에서 방향이 꺾이지 않는다
function flowDir(f: Flow, x: number, y: number): [number, number] {
const gx = clamp(x / f.o.cell, 0, f.fcols - 1.001);
const gy = clamp(y / f.o.cell, 0, f.frows - 1.001);
const i = Math.floor(gx);
const j = Math.floor(gy);
const u = gx - i;
const v = gy - j;
const k = j * f.fcols + i;
const dx = lerp(lerp(f.vx[k], f.vx[k + 1], u), lerp(f.vx[k + f.fcols], f.vx[k + f.fcols + 1], u), v);
const dy = lerp(lerp(f.vy[k], f.vy[k + 1], u), lerp(f.vy[k + f.fcols], f.vy[k + f.fcols + 1], u), v);
const L = Math.hypot(dx, dy);
return L < 1e-6 ? [1, 0] : [dx / L, dy / L];
}
// (x, y) 반경 d 안에 다른 점이 있는가 — 같은 선의 바로 앞뒤 점(간격 두 배 길이 안)은 세지 않는다
function flowTooClose(f: Flow, x: number, y: number, d: number, line: number, ord: number): boolean {
const skip = Math.ceil((f.o.sep * 2) / f.o.step);
const d2 = d * d;
return bucketNear(f.grid, x, y, d, function (id: number): boolean {
if (f.pl[id] === line && Math.abs(f.po[id] - ord) <= skip) { return false; }
const dx = f.px[id] - x;
const dy = f.py[id] - y;
return dx * dx + dy * dy < d2;
});
}
function flowInside(f: Flow, x: number, y: number): boolean {
const m = f.o.margin;
return x >= m && x <= W - m && y >= m && y <= H - m;
}
function flowAddPoint(f: Flow, x: number, y: number): void {
const id = f.px.length;
f.px.push(x);
f.py.push(y);
f.pl.push(f.line);
f.po.push(f.ord);
bucketAdd(f.grid, x, y, id);
f.pts.push(id);
}
function flowStart(f: Flow, x: number, y: number): void {
f.line = f.lines;
f.lines += 1;
f.dir = 1;
f.ord = 0;
f.x = x;
f.y = y;
f.sx = x;
f.sy = y;
f.pts = [];
flowAddPoint(f, x, y);
f.active = true;
}
// 선 하나가 끝나면 그 선을 따라 양옆으로 간격만큼 떨어진 자리를 다음 출발 후보로 줄 세운다
function flowEndLine(f: Flow): void {
const stride = Math.max(1, Math.round(f.o.sep / f.o.step));
for (let k = 0; k < f.pts.length; k += stride) {
const id = f.pts[k];
const d = flowDir(f, f.px[id], f.py[id]);
f.cand.push(f.px[id] - d[1] * f.o.sep, f.py[id] + d[0] * f.o.sep);
f.cand.push(f.px[id] + d[1] * f.o.sep, f.py[id] - d[0] * f.o.sep);
}
f.active = false;
}
// 한 틱의 일 — 걸음 하나가 1, 출발점 검사 하나가 0.2. budget 을 다 쓰면 다음 틱으로 넘긴다
function flowStep(f: Flow, budget: number, seg: FlowSeg): boolean {
let b = budget;
while (b > 0 && !f.done) {
if (!f.active) {
b -= 0.2;
if (f.candAt < f.cand.length) {
const cx = f.cand[f.candAt];
const cy = f.cand[f.candAt + 1];
f.candAt += 2;
if (flowInside(f, cx, cy) && !flowTooClose(f, cx, cy, f.o.sep * 0.999, -1, 0)) { flowStart(f, cx, cy); }
continue;
}
// 후보가 바닥나면 무작위 자리를 뽑아 거절 표본으로 빈자리를 찾는다
const m = f.o.margin;
const rx = m + f.rng() * (W - 2 * m);
const ry = m + f.rng() * (H - 2 * m);
if (!flowTooClose(f, rx, ry, f.o.sep * 0.999, -1, 0)) { f.fails = 0; flowStart(f, rx, ry); }
else {
f.fails += 1;
if (f.fails >= FLOW_SEED_FAILS) { f.done = true; }
}
continue;
}
// 한 걸음 — 반걸음 앞의 방향으로 걷는 중간점 방법(곡선 바깥으로 새는 오차를 줄인다)
b -= 1;
const h = f.o.step * f.dir;
const d1 = flowDir(f, f.x, f.y);
const d2 = flowDir(f, f.x + d1[0] * h * 0.5, f.y + d1[1] * h * 0.5);
const nx = f.x + d2[0] * h;
const ny = f.y + d2[1] * h;
const nord = f.ord + f.dir;
if (!flowInside(f, nx, ny) || f.pts.length >= f.o.maxPts || flowTooClose(f, nx, ny, f.o.sep * f.o.test, f.line, nord)) {
// 앞쪽이 막히면 출발점으로 돌아가 반대쪽으로, 반대쪽도 막히면 선 하나 끝
if (f.dir === 1) {
f.dir = -1;
f.ord = 0;
f.x = f.sx;
f.y = f.sy;
} else {
flowEndLine(f);
}
continue;
}
seg(f.x, f.y, nx, ny, f.line);
f.length += f.o.step;
f.x = nx;
f.y = ny;
f.ord = nord;
flowAddPoint(f, nx, ny);
}
return f.done;
}
// 끌개 점 잎맥 — 끌개마다 가장 가까운 마디를 찾고, 마디는 자기를 고른 끌개 방향들의 평균으로 한 칸 자란다. 마디가 닿은 끌개는 지운다
type VeinOpts = {
count: number; influence: number; kill: number; seg: number; rootX: number; rootY: number;
maxNodes: number; maxIter: number; x0: number; y0: number; x1: number; y1: number;
inside: (x: number, y: number) => boolean;
};
type Vein = {
o: VeinOpts; rng: () => number; ax: number[]; ay: number[]; alive: Uint8Array; left: number;
nx: number[]; ny: number[]; par: number[]; desc: number[]; grid: Buckets; agrid: Buckets;
iter: number; reached: boolean; stall: number; done: boolean;
};
function makeVein(rng: () => number, o: VeinOpts): Vein {
const ax: number[] = [];
const ay: number[] = [];
// 거절 표본 — 상자 안에서 뽑아 모양(inside) 밖이면 버린다
let tries = 0;
while (ax.length < o.count && tries < o.count * 40) {
tries += 1;
const x = lerp(o.x0, o.x1, rng());
const y = lerp(o.y0, o.y1, rng());
if (o.inside(x, y)) { ax.push(x); ay.push(y); }
}
const agrid = makeBuckets(o.influence);
for (let i = 0; i < ax.length; i += 1) { bucketAdd(agrid, ax[i], ay[i], i); }
const alive = new Uint8Array(ax.length);
alive.fill(1);
const v: Vein = {
o: o, rng: rng, ax: ax, ay: ay, alive: alive, left: ax.length,
nx: [], ny: [], par: [], desc: [], grid: makeBuckets(o.influence), agrid: agrid, iter: 0, reached: false, stall: 0, done: false,
};
veinAdd(v, o.rootX, o.rootY, -1);
veinKill(v, 0);
return v;
}
// 마디 하나를 더하고, 뿌리까지 거슬러 올라가며 조상마다 후손 수를 하나씩 늘린다(굵기의 재료)
function veinAdd(v: Vein, x: number, y: number, parent: number): void {
const id = v.nx.length;
v.nx.push(x);
v.ny.push(y);
v.par.push(parent);
v.desc.push(0);
bucketAdd(v.grid, x, y, id);
let p = parent;
while (p >= 0) {
v.desc[p] += 1;
p = v.par[p];
}
}
// from 번 마디부터 새로 난 마디들 둘레 kill 안의 끌개를 지운다
function veinKill(v: Vein, from: number): void {
const k2 = v.o.kill * v.o.kill;
for (let i = from; i < v.nx.length; i += 1) {
const x = v.nx[i];
const y = v.ny[i];
bucketNear(v.agrid, x, y, v.o.kill, function (a: number): boolean {
if (v.alive[a]) {
const dx = v.ax[a] - x;
const dy = v.ay[a] - y;
if (dx * dx + dy * dy < k2) { v.alive[a] = 0; v.left -= 1; }
}
return false;
});
}
}
const VEIN_STALL_LIMIT = 40;
function veinStep(v: Vein): boolean {
if (v.done) { return true; }
v.iter += 1;
const n = v.nx.length;
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const cnt = new Uint16Array(n);
const r2 = v.o.influence * v.o.influence;
let any = false;
// 끌개마다 반경 안에서 가장 가까운 마디 하나 — 그 마디 쪽으로 단위 방향을 더해 준다
for (let a = 0; a < v.ax.length; a += 1) {
if (!v.alive[a]) { continue; }
const x = v.ax[a];
const y = v.ay[a];
let best = -1;
let bd = r2;
bucketNear(v.grid, x, y, v.o.influence, function (id: number): boolean {
const dx = x - v.nx[id];
const dy = y - v.ny[id];
const d2 = dx * dx + dy * dy;
if (d2 < bd) { bd = d2; best = id; }
return false;
});
if (best >= 0) {
const d = Math.sqrt(bd) || 1;
sx[best] += (x - v.nx[best]) / d;
sy[best] += (y - v.ny[best]) / d;
cnt[best] += 1;
any = true;
}
}
if (any) { v.reached = true; }
if (!any) {
// 아직 어느 끌개도 닿은 적이 없으면 마지막 마디를 끌개 무리의 가운데 쪽으로 곧게 민다(줄기).
// 한 번 닿은 뒤로 끌개가 반경 밖에만 남으면 더 자라지 않는다 — 외딴 끌개 사이에서 줄기가 맴돌지 않게
if (v.left > 0 && !v.reached) {
let mx = 0;
let my = 0;
for (let a = 0; a < v.ax.length; a += 1) { if (v.alive[a]) { mx += v.ax[a]; my += v.ay[a]; } }
mx /= v.left;
my /= v.left;
const last = n - 1;
const dx = mx - v.nx[last];
const dy = my - v.ny[last];
const L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[last] + (dx / L) * v.o.seg, v.ny[last] + (dy / L) * v.o.seg, last);
}
} else {
for (let i = 0; i < n; i += 1) {
if (!cnt[i]) { continue; }
let dx = sx[i];
let dy = sy[i];
let L = Math.hypot(dx, dy);
// 끌개들이 서로 반대쪽에서 맞당겨 평균이 거의 0 이면 그 마디는 이번에 자라지 않는다
if (L < 0.2) { continue; }
// 아주 작은 흔들림 — 같은 자리에 마디가 겹쳐 쌓이지 않게 대칭을 깬다
dx = dx / L + (v.rng() - 0.5) * 0.12;
dy = dy / L + (v.rng() - 0.5) * 0.12;
L = Math.hypot(dx, dy) || 1;
veinAdd(v, v.nx[i] + (dx / L) * v.o.seg, v.ny[i] + (dy / L) * v.o.seg, i);
}
}
const leftBefore = v.left;
veinKill(v, n);
// 지워지는 끌개 없이 VEIN_STALL_LIMIT 번 연달아 자라기만 하면 멈춘다 — 닿지 못할 끌개를 쫓아 맴돌지 않게
v.stall = v.left === leftBefore ? v.stall + 1 : 0;
if (v.stall >= VEIN_STALL_LIMIT || v.nx.length === n || v.left === 0 || v.nx.length >= v.o.maxNodes || v.iter >= v.o.maxIter) { v.done = true; }
return v.done;
}
// 굵기 = base + √(후손 수 + 1) × k (cap 에서 자름). 같은 굵기 칸끼리 묶어 한 번에 긋는다
const VEIN_WIDTH_BINS = 16;
function veinDraw(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number, base: number, k: number, cap: number): void {
const n = v.nx.length;
const bin = new Uint8Array(n);
for (let i = 1; i < n; i += 1) {
const w = Math.min(cap, base + Math.sqrt(v.desc[i] + 1) * k);
bin[i] = Math.round((w / cap) * (VEIN_WIDTH_BINS - 1));
}
c.lineCap = 'round';
c.strokeStyle = rgba(col, alpha);
for (let b = 0; b < VEIN_WIDTH_BINS; b += 1) {
c.beginPath();
let any = false;
for (let i = 1; i < n; i += 1) {
if (bin[i] !== b || v.par[i] < 0) { continue; }
const p = v.par[i];
c.moveTo(v.nx[p], v.ny[p]);
c.lineTo(v.nx[i], v.ny[i]);
any = true;
}
if (any) {
c.lineWidth = Math.max(0.4, ((b + 0.5) / VEIN_WIDTH_BINS) * cap);
c.stroke();
}
}
}
// 아직 남은 끌개를 작은 점으로 — 잎맥이 지나간 자리부터 비어 간다
function veinDots(v: Vein, c: CanvasRenderingContext2D, col: RGB, alpha: number): void {
c.fillStyle = rgba(col, alpha);
c.beginPath();
for (let a = 0; a < v.ax.length; a += 1) {
if (v.alive[a]) { c.rect(v.ax[a] - 0.8, v.ay[a] - 0.8, 1.6, 1.6); }
}
c.fill();
}
// 부푸는 원 채우기 — 빈자리를 몇 번 골라 원을 심고 틱마다 반지름을 키우다가, 이웃 원·테두리와의 틈이 gap 에 닿으면 그 원만 멈춘다
type PackOpts = {
grow: number; attempts: number; gap: number; maxR: number; startR: number; perTick: number; failTicks: number;
x0: number; y0: number; x1: number; y1: number; blocked: (x: number, y: number, r: number) => boolean;
};
type Pack = {
o: PackOpts; rng: () => number; cx: number[]; cy: number[]; cr: number[]; col: number[];
growing: number[]; grid: Buckets; fails: number; tries: number; rejects: number; done: boolean;
};
function makePack(rng: () => number, o: PackOpts): Pack {
return { o: o, rng: rng, cx: [], cy: [], cr: [], col: [], growing: [], grid: makeBuckets(o.maxR), fails: 0, tries: 0, rejects: 0, done: false };
}
// 반지름 r 인 원을 (x, y) 에 둘 수 있는가 — 테두리, 격자 버킷 안 이웃 원(중심 거리 < r + 이웃 r + gap), 막힌 자리 순서로 본다
function packFree(p: Pack, x: number, y: number, r: number, self: number): boolean {
const g = p.o.gap;
if (x - r < p.o.x0 + g || x + r > p.o.x1 - g || y - r < p.o.y0 + g || y + r > p.o.y1 - g) { return false; }
const hit = bucketNear(p.grid, x, y, r + p.o.maxR + g, function (j: number): boolean {
if (j === self) { return false; }
const dx = p.cx[j] - x;
const dy = p.cy[j] - y;
const lim = r + p.cr[j] + g;
return dx * dx + dy * dy < lim * lim;
});
if (hit) { return false; }
return !p.o.blocked(x, y, r + g);
}
function packStep(p: Pack, onStop: (i: number) => void): boolean {
if (p.done) { return true; }
// 심기 — attempts 번 무작위 자리를 뽑고, 겹치는 자리는 버린다(거절 표본)
let made = 0;
for (let a = 0; a < p.o.attempts && made < p.o.perTick; a += 1) {
const x = lerp(p.o.x0, p.o.x1, p.rng());
const y = lerp(p.o.y0, p.o.y1, p.rng());
p.tries += 1;
if (packFree(p, x, y, p.o.startR, -1)) {
const id = p.cx.length;
p.cx.push(x);
p.cy.push(y);
p.cr.push(p.o.startR);
p.col.push(Math.floor(p.rng() * PAL.length));
bucketAdd(p.grid, x, y, id);
p.growing.push(id);
made += 1;
} else {
p.rejects += 1;
}
}
p.fails = made === 0 ? p.fails + 1 : 0;
// 키우기 — 한 칸 키운 반지름이 자리에 맞으면 키우고, 안 맞으면 그 원은 여기서 멈춘다
const next: number[] = [];
for (let k = 0; k < p.growing.length; k += 1) {
const i = p.growing[k];
const nr = p.cr[i] + p.o.grow;
if (nr <= p.o.maxR && packFree(p, p.cx[i], p.cy[i], nr, i)) {
p.cr[i] = nr;
next.push(i);
} else {
onStop(i);
}
}
p.growing = next;
if (p.fails >= p.o.failTicks && p.growing.length === 0) { p.done = true; }
return p.done;
}
// 첫 그림의 씨앗 — 다시 그리기를 누르면 이 수만 바뀌고 세 겹이 모두 함께 바뀐다
const HERO_SEED = 47109;
// 겹과 겹 사이 출발 간격(ms) — 흐름 선 0, 잎맥 1배, 원 채우기 2배 뒤에 자라기 시작한다
const LAYER_STAGGER_MS = 480;
// 세 겹이 다 자란 뒤 머무는 시간(ms) — 그 뒤 다음 씨앗의 그림으로 옅게 바뀐다
const HOLD_MS = 1400;
// 앞 그림이 사라지는 교차 페이드 시간(ms)
const FADE_MS = 700;
// 겹마다 한 틱의 일 — 프레임당 계산량 상한
const HERO_FLOW_STEPS = 44;
const SEED0 = HERO_SEED;
const TXT_RUN = '세 겹이 차례로 자라는 중';
// 같은 씨앗에서 갈래(lane)마다 다른 난수열 — 세 겹이 같은 수를 나눠 쓰지 않아 서로 무늬가 겹치지 않는다
function laneRng(base: number, lane: number): () => number { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x: number, y: number): boolean {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s: number): Flow {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function buildVein(s: number): Vein {
return makeVein(laneRng(s, 1), {
count: 420, influence: 70, kill: 7, seg: 4, rootX: VEIN_CX, rootY: H + 2, maxNodes: 3000, maxIter: 300,
x0: VEIN_CX - 150, y0: VEIN_CY - 100, x1: VEIN_CX + 150, y1: VEIN_CY + 100, inside: insideCrown,
});
}
let fl: Flow = buildFlow(SEED0);
let vn: Vein = buildVein(SEED0);
// 원은 잎맥 마디를 피해 자란다 — 마디가 원 안(r + 2px)에 있으면 그 자리는 막혔다
function blockedByVein(x: number, y: number, r: number): boolean {
const lim = r + 2;
return bucketNear(vn.grid, x, y, lim, function (id: number): boolean {
const dx = vn.nx[id] - x;
const dy = vn.ny[id] - y;
return dx * dx + dy * dy < lim * lim;
});
}
function buildPack(s: number): Pack {
return makePack(laneRng(s, 2), {
grow: 0.6, attempts: 50, gap: 3, maxR: 20, startR: 1, perTick: 6, failTicks: 18,
x0: 8, y0: 8, x1: W - 8, y1: H - 8, blocked: blockedByVein,
});
}
let pk: Pack = buildPack(SEED0);
let holdAt = -1;
let fadeLeft = 0;
const prevEl = document.createElement('canvas');
const pctx = prevEl.getContext('2d');
function plantLayers(s: number): void {
clearArt(INK);
fl = buildFlow(s);
vn = buildVein(s);
pk = buildPack(s);
holdAt = -1;
}
function plant(s: number): void {
plantLayers(s);
fadeLeft = 0;
}
function flowSeg(ax: number, ay: number, bx: number, by: number): void {
actx.strokeStyle = rgba(mix(PAL[3], PAL[0], clamp((bx - ax) / 2.5 * 0.5 + 0.5, 0, 1)), 0.5);
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(ax, ay);
actx.lineTo(bx, by);
actx.stroke();
}
function drawScene(c: CanvasRenderingContext2D): void {
c.drawImage(artEl, 0, 0, W, H);
veinDraw(vn, c, mix(PAL[2], PAPER, 0.3), 0.95, 0.5, 0.17, 4.4);
for (let i = 0; i < pk.cx.length; i += 1) {
c.fillStyle = rgba(PAL[pk.col[i]], 0.88);
c.beginPath();
c.arc(pk.cx[i], pk.cy[i], pk.cr[i], 0, TAU);
c.fill();
}
}
// 앞 그림을 따로 떠 둔다 — 새 씨앗이 자라는 동안 그 위에 옅어지며 겹친다
function snapshot(): void {
if (!pctx) { return; }
prevEl.width = cv.width;
prevEl.height = cv.height;
pctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
drawScene(pctx);
}
function grow(): boolean {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) { flowStep(fl, HERO_FLOW_STEPS, flowSeg); }
if (age >= LAYER_STAGGER_MS && !vn.done) { veinStep(vn); }
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) { packStep(pk, function (): void { /* 멈춘 원도 프레임마다 다시 그린다 */ }); }
const all = fl.done && vn.done && pk.done;
if (!all) { return false; }
// 동작 줄이기에서는 다 자란 한 장에서 멈춘다
if (still) { return true; }
if (holdAt < 0) { holdAt = age; }
if (age - holdAt >= HOLD_MS) {
snapshot();
seed = nextSeed(seed);
plantLayers(seed);
age = 0;
fadeLeft = FADE_MS;
}
return false;
}
function paintFrame(): void {
drawScene(ctx);
if (fadeLeft > 0) {
ctx.globalAlpha = fadeLeft / FADE_MS;
ctx.drawImage(prevEl, 0, 0, W, H);
ctx.globalAlpha = 1;
}
}
function statPairs(): [string, string][] {
return [['선', String(fl.lines)], ['마디', String(vn.nx.length)], ['원', String(pk.cx.length)]];
}
function runText(): string { return TXT_RUN; }
function doneText(): string { return TXT_DONE; }
const TXT_DONE = '다 자람 — Enter 로 새 씨앗';
const TXT_PAUSE = '일시정지 — Space 로 이어서';
// 시연 — 첫 200ms 는 빈 판으로 두고(첫 변화 200ms 뒤) 자라게 한 뒤, 다 자라면 900ms 머물고 다음 씨앗으로
const DEMO_LEAD = 200;
const DEMO_HOLD_MS = 900;
// 동작 줄이기에서 한 번에 끝까지 키울 때 틱 수 상한 — 계산이 끝나지 않는 씨앗이 있어도 화면이 멈추지 않게
const GROW_GUARD = 6000;
let paused = false;
let done = false;
let holdMs = 0;
let clock = 0;
function reseed(s: number): void {
seed = s;
age = 0;
done = false;
holdMs = 0;
plant(s);
}
function tickGrow(): void {
if (done) { return; }
age += FIXED_DT;
if (grow()) { done = true; }
}
// 끝까지 한 번에 — 동작 줄이기에서는 자라는 과정 없이 다 자란 그림을 바로 보인다
function finishNow(): void {
let g = 0;
while (!done && g < GROW_GUARD) { tickGrow(); g += 1; }
}
function report(): void {
if (paused) { say(TXT_PAUSE); }
else if (done) { say(doneText()); }
else { say(runText()); }
}
function render(): void {
paintFrame();
const pairs: [string, string][] = [['씨앗', String(seed)]];
drawStats(pairs.concat(statPairs()));
report();
}
function setPaused(v: boolean): void {
paused = v;
if (pauseBtn) { pauseBtn.setAttribute('aria-pressed', v ? 'true' : 'false'); }
}
// 입력 한 번 뒤 — 동작 줄이기면 그 씨앗의 다 자란 모습으로 바로 그린다
function after(): void {
if (still) { finishNow(); }
render();
}
function newSeed(): void { setPaused(false); reseed(nextSeed(seed)); }
function replaySeed(): void { setPaused(false); reseed(seed); }
cv.addEventListener('pointerdown', function (): void { wake(); after(); });
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'Enter') { newSeed(); }
else if (k === 'r' || k === 'R') { replaySeed(); }
else if (k === ' ') { setPaused(!paused); }
else { return; }
ev.preventDefault();
wake();
after();
});
if (redrawBtn) { redrawBtn.addEventListener('click', function (): void { wake(); newSeed(); after(); }); }
if (replayBtn) { replayBtn.addEventListener('click', function (): void { wake(); replaySeed(); after(); }); }
if (pauseBtn) { pauseBtn.addEventListener('click', function (): void { wake(); setPaused(!paused); after(); }); }
function onResize(): void {
fitCanvas();
render();
}
fitCanvas();
window.addEventListener('resize', onResize);
reseed(SEED0);
function tick(): void {
clock += FIXED_DT;
if (demo) {
if (clock < DEMO_LEAD) { return; }
if (done) {
holdMs += FIXED_DT;
if (holdMs >= DEMO_HOLD_MS) { reseed(nextSeed(seed)); }
return;
}
}
if (!paused) { tickGrow(); }
}
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (still) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 한 프레임에 성장 계산이 몰리지 않게
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
tick();
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
// 동작 줄이기 설정이 페이지를 연 채 바뀌면 — 켜지면 루프를 멈추고 다 자란 그림으로, 꺼지면 루프를 다시 돈다
function onMotionPref(): void {
still = reduceQuery.matches;
if (still) {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
after();
} else {
startLoop();
}
}
if (typeof reduceQuery.addEventListener === 'function') { reduceQuery.addEventListener('change', onMotionPref); }
if (still) {
after();
} else {
render();
startLoop();
}
return function (): void {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
if (typeof reduceQuery.removeEventListener === 'function') { reduceQuery.removeEventListener('change', onMotionPref); }
};
}, []);
return (
<div className={cx("pc") + " is-demo"} ref={rootRef}>
<canvas className={cx("pc__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="같은 씨앗에서 흐름 선·잎맥·원 채우기가 차례로 자라 겹치고, 다 자라면 머문 뒤 다음 씨앗의 그림으로 옅게 바뀌는 히어로 배경. Enter 로 새 씨앗을 그리고, R 로 같은 씨앗을 처음부터 다시 키우고, Space 로 일시정지한다"></canvas>
<p className={cx("pc__status")} data-role="status" aria-live="off">세 겹이 차례로 자라는 중</p>
<div className={cx("pc__tools")}>
<button className={cx("pc__btn")} data-role="redraw" type="button">다시 그리기</button>
<button className={cx("pc__btn")} data-role="replay" type="button">같은 씨앗</button>
<button className={cx("pc__btn")} data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</div>
);
}09 · ひとつのシードから三層が育つヒーロー背景 — 計測 累積面積 39.016% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 同じシードから枝(lane)ごとに別の乱数列 — 三層が同じ数を分け合わないので模様が重ならない
function laneRng(base, lane) { return makeRng((base ^ Math.imul(lane + 1, 0x9e3779b1)) >>> 0); }
const VEIN_CX = 316;
const VEIN_CY = 124;
function insideCrown(x, y) {
const dx = (x - VEIN_CX) / 150;
const dy = (y - VEIN_CY) / 100;
return dx * dx + dy * dy < 1;
}
function buildFlow(s) {
return makeFlow(laneRng(s, 0), makeNoise(laneRng(s, 3)), { sep: 12, test: 0.5, step: 2.5, scale: 0.004, cell: 14, maxPts: 500, margin: 4 });
}
function grow() {
fadeLeft = Math.max(0, fadeLeft - FIXED_DT);
if (!fl.done) {
flowStep(fl, HERO_FLOW_STEPS, flowSeg);
}
if (age >= LAYER_STAGGER_MS && !vn.done) {
veinStep(vn);
}
if (age >= LAYER_STAGGER_MS * 2 && !pk.done) {
packStep(pk, function () { });
}
const all = fl.done && vn.done && pk.done;
if (!all) {
return false;
}
点が数万個に増える・シードを変えても似た絵になる・画面の大きさが変わる — 育つ模様が壊れるところ
この節のティック数と個数は、vanillaのindex.htmlの関数をNodeでそのまま呼び出して測り直した値で、「最初の版」の数値は、作る途中で前の版を同じ方法で測ったものです。msで書いた計算時間はこのPCのNodeで測ったもので機器ごとに変わるため、比べるときはティック数で見るほうが確かです。
点が増えるほど、隣探しが仕事を食う
01は点8,386個、04は円1,500個前後、03は節2,600個まで積み上がります。格子なしですべての点と比べると、04はティックごとに場所の抽選60回と育っている円の数の合計に1,500をかけた回数だけ比べることになり、数が増えるほど画面がもたつくと考えられます。03は節の上限がなければティックあたりの仕事が節の数に比例して大きくなり、上限2,600個のときNodeで測った一番重いティックは2.0msでした。08は縁のリストの途中のマスをspliceで抜くとマスの数だけ後ろを詰める必要があるので、一番後ろのマスと入れ替えて抜く方法を選んでいます。
最初の一歩で自分の点にふさがれる流れ線
01が同じ線のすぐ前の点まで距離の検査に入れると、最初の一歩から自分の点が4px以内にあって線が止まってしまいます。そのためflowTooCloseは、同じ線で前後8歩(間隔の2倍の長さ)以内の点を数えません。渦の真ん中では線がいつまでも回り続けることがあるので、点700個(MAX_LINE_POINTS)で打ち切ります。
引っぱり合う引き寄せ点に捕まった葉脈
02を最初に作ったときは、平均の向きが0に近い節も正規化してそのまま伸ばしていましたが、シード47102と27672が引き寄せ点2個を最後まで消せないままMAX_ITERの400まで回り、節を1,102個まで増やしました。反対側にある引き寄せ点ふたつがひとつの節を引っぱり合い、その場で足踏みしたと考えられます。平均の長さが0.2未満の節はそのティックは休ませ、引き寄せ点がひとつも消えないティックが40回続いたら止めるようにすると、103ティックで終わりました。消す距離の6pxを節の長さ2.6pxより大きくしたのも、新しい節が引き寄せ点を飛び越えて行ったり来たりしないための値と考えられます。
押し返しと分割だけでは育たないしわの線
03の最初の版には長い辺の分割と押し返しだけがあり、8,000ティック回しても節は47個から増えませんでした。両隣の節を押す力が線に沿った向きなので、互いに打ち消し合ったと考えられます。隣と4pxを保とうとするばねと、シード付きの乱数で選んだ辺に挟む成長の節を足して、ようやく線が伸びるようになりました。spliceで挟むとその後ろの節の番号がひとつずつずれるので、挟んだ直後にiをもうひとつ飛ばします。
終わりの判定が遅いと、ほとんど同じ絵を長く待つ
04は、円をひとつも植えられないティックが何回続いたら終えるかをFAIL_TICKSで決めます。この値を24にしたときは1,070ティックかかり、8に減らすと565ティックで終わりました。そのあいだに残る空きは半径1〜2pxのものばかりなので、絵の違いはほとんどありません。ふたつの円が同じティックに育っても、後から見る円は先の円がすでに大きくなった半径で判定するので重なりません。
出発点を見つけられなかったひび
05の最初の版では、新しいひびの出発点をでたらめな画素を1,200回試して探させたところ、角度が書き込まれた画素が12個しかない始めのころに一度も当たらず、ひびが0本で終わりました。今は角度が書かれた画素のリストmarksから直接選びます。出発してから4歩は猶予を置き、親のひびのマスですぐに止まらないようにしました。角度の差は360°で折り返さないので、同じ向きでも−90°と270°は別のひびとして扱うという限界が、ソースにそのまま残っています。
橋のタイルを入れると崩れないタイルの盤
06には道と水路が交差する橋のタイルがありません。橋2種を入れていた最初の版はシード200個で矛盾が0回でしたが、外した今はシード400個のうち7個が一度ずつ崩れます。道と水路が出会う場所があれば行き止まりのマスが生まれないということで、実演のシード66110は、崩れる7個のうちのひとつを選んで埋め直す場面が見えるようにしたものです。巻き戻しをせずに盤全体を最初からやり直す方式なので、盤が大きくなるほどやり直しの手間も大きくなります。候補の数が同じマスを番号順に選ぶと左上から行をそろえて埋まっていくため、マスごとにシード付きの乱数で決めた同点崩しの値tieを足しています。
角度を直線で読むと出る7.40pxの段差
07のringRを、角度をそのままノイズのxとして読むように変えると、同じ振幅で0と2πの地点の半径が7.40pxずれて、輪ごとに段差ができます。円周上の座標で読めば一周の終わりが始まりと同じノイズの位置に戻るので、差は0pxです。振幅が半径に比例するため外側の138pxの輪では±11pxまで揺れ、幅が2.67pxの年と重なると隣の輪と交わることがあります。隣の輪のノイズの円を0.07だけずらして似せてあるので、実際の絵ではほとんど見えないと考えられます。
シードを変えても似て見える理由
シードを変えると配置は変わりますが、性格は変わりません。02はシード21080・27672でも103ティックで引き寄せ点を消し切り(枝先85本・103本)、04はシード36918・94117で棄却率が95.2%・94.2%と、47104の94.9%から1ポイントも離れず、08は9,000マスをティックあたり60マスずつ埋めるのでどのシードでも150ティックで終わります(境目732・821・975か所)。絵の印象を変えたいなら、シードより下の変形の表にある定数を動かすほうが早道です。
三層が互いを揺さぶるヒーロー
09が三層に同じ乱数列を分けて使うと、流れ線の設定をひとつ変えただけで後から引く葉脈と円の模様まで丸ごと変わると考えられるので、laneRngで枝を分けました。円は960msに始まるとき、それまでに育った葉脈の節だけを避けるので、葉脈がその後も伸びると円と重なりうる箇所がソースに残っています。前の場面を画面のキャンバスからそのまま写し取ると、右下の数値の箱まで淡く重なって残るため、snapshotはdrawSceneで場面だけを描き直します。
画面の大きさと画素の倍率が変わるとき
キャンバスの中の座標は480×300に固定で、外側の箱だけがwidth: min(92vw, calc(160vh - 12.8vw))とaspect-ratio: 480 / 300で伸び縮みします。そのためスマートフォンで開いても、同じシードは同じ絵になります。実際の画素数には機器の倍率をかけますが、Math.min(window.devicePixelRatio || 1, 2)で2倍に抑えています。キャンバスの大きさを変えると描いた内容が消えるので、倍率が変わるとfitCanvasが描画板を一時キャンバスへ移しておき、新しい大きさで描き戻します。
九つのフォルダにあるvanillaのソースを自分のシードと自分の色に替えて育ててみたい場合は、ダウンロードしたzipを開くときに9rhueet8を入力してください。同じ計算を移したReact版も、同じzipに入っています。
流れ線の間隔・引き寄せ半径・押し返す半径を変えると絵はどう変わるか
| 名前 | 変えた値 | 感じ |
|---|---|---|
| 流れ線の間隔を広げる | 01 LINE_SEPARATION 8 → 16 |
同じシード47101で線が155本から79本に、長さの合計が16,462pxから8,372px(0.51倍)に減り、135ティックだった成長が66ティックで終わります。間隔を2倍に開くと面積を間隔で割った分しか線が入らないので長さがほぼ半分になり、筋がまばらで涼しげになります。 |
| 引き寄せ半径を広げる | 02 INFLUENCE_RADIUS 60 → 120 |
節794 → 815個、枝先71 → 81本で、成長は103ティックのままなので絵はほとんど同じです。Nodeで測った成長全体の計算は24.6msから40.0msに増えました。引き寄せ点900個が葉の中に密に詰まっていて、60px以内ですでに一番近い節が決まっているためと考えられます。 |
| 押し返す半径を大きくする | 03 REPEL_RADIUS 12 → 22 |
同じ120ティックで平均の太さ(面積×2÷周長)が7.5pxから13.0pxへ、横幅が171pxから271pxへ、面積が12,036px²から29,297px²へ大きくなります。細かく折れたしわが、太くゆったりした曲がりに変わります。 |
三行のうち01と03は絵の筋目が目に見えて変わりますが、02の引き寄せ半径は絵をほとんどそのままにして計算だけを増やします。半径を大きくする前に、絵が本当に変わるかと計算がどれだけ増えるかをいっしょに測っておくほうがよいことが、02の行から見えてきます。
CSSのキーフレームで決まった絵を動かす方向はジェネラティブパターン背景、点から一番近い領域を分ける模様はボロノイ図のパターンで続きを読めます。一歩ずつさまよって付着していく結晶は雪の結晶の成長、規則の文字列を書き換えて育てる木はフラクタルの木、画素単位の計算で場面を作る効果はキャンバスの特殊効果9種が近い題材です。
育ち終えた一枚を静止画に — 動きを減らした画面と背景画のアクセシビリティ
OSで動きを減らす設定(prefers-reduced-motion)をオンにした画面では、九枚ともrequestAnimationFrameのループを回しません。代わりにfinishNow()がティックの上限6,000(GROW_GUARD)の中でそのシードを最後まで育て、育ち終えた絵を一枚すぐに描きます。Enter・R・ボタンでシードを替えたり育て直したりしても、その場で新しい絵の完成形だけが入れ替わります。09はクロスフェードも次のシードもなく、育ち終えた三層の一枚にとどまります。設定はmatchMediaのchangeリスナーが見張っているので、ページを開いたまま切り替えても反映されます。
キーボードでも九枚すべてを操作できます。キャンバスごとにtabindex="0"と、絵とキーを説明するaria-labelが付いていて、フォーカスが入るとEnterは新しいシード、Rは同じシードの育て直し、Spaceは一時停止と再開です。既定の動作を止めるのはこの三つのキーだけなので、Tabをはじめほかのキーはそのまま通ります。「描き直す」「同じシード」「一時停止」は本物の<button>で、一時停止ボタンはaria-pressedで押された状態を知らせ、キャンバスとボタンのどちらにも:focus-visibleの枠があります。
状態の行は実演が回っているあいだaria-live="off"で黙っていて、人が初めて操作した瞬間にpoliteへ切り替わり、文言も育っている途中・育ち終わり・一時停止のように段階が変わるときだけ書き換えます。06の状態の行は、育っている途中なら矛盾でやり直した回数が1以上のときだけその回数を添え、育ち終わりの文言には0回でも回数が付きます。線・節・円の数のようにティックごとに変わる数字は状態の行ではなくキャンバス右下の箱に描いているので、スクリーンリーダーが毎フレーム数字を読み上げることはありません。絵を文章の後ろに飾りとしてだけ敷くときは、ボタンとキーの案内をいっしょに外し、キャンバスを読み上げの対象から外しておくほうが、聞く人の負担が少なくなります。
ジェネラティブアートをサイトの背景に敷く前によくある質問
ジェネラティブアート コードを、開くたびに違う絵が出るように変えられますか?
01〜08はSEED0、09はHERO_SEEDの一行が最初のシードです。ソースは同じシードが同じ絵を出すように、時刻やMath.randomをわざと使っていません。訪問ごとに変えたいならこの一行の数をページ側で作って渡せばよく、その場合に同じ絵をもう一度見るにはその数もいっしょに残しておく必要があります。記事番号のようにページごとに決まった数を入れれば、ページごとに違っていながら、開き直すたびに同じ絵が出ます。
色を自分のブランドの色に替えるには、どこを直しますか?
九つのソースとも、色はスクリプト上部のINK・PAPER・PALの定数にRGBの配列でまとまっていて、三つとも描画に使われています。この配列の値だけを替えれば、計算と形はそのままで線・節・円の色が変わります。舞台の箱の角の丸み(border-radius)と影はstyle.scssのトークンに従います。
スマートフォンで動かしても重くなりませんか?
ティックごとの仕事には上限があります。01は歩数70、04は新しい円8個、08は60マスのように定数で縛られ、フレームあたりのティックも最大6回、画素の倍率も2倍で抑えています。03は節の上限2,600個のときNodeで測った一番重いティックが2.0msでしたが、この値はこのPCでのものなので、遅い機器ではMAX_NODESやティックあたりの上限を下げてみるほうが安心です。









