ライフゲーム9種 — キャンバスで世代を計算
ライフゲームのルールを計算だけで動かす9種のキャンバス実装です。格子は勝手に動いているように見えるが、実際には毎世代、生存隣接数という一つの値が生死を分け、その値が積み重なった年齢の分だけ色が薄れ、同じ値が配線を伝って信号のように流れる — 九つの計算型セルオートマトンをその値で見る。
01 ca-game-of-life-basic
02 ca-elementary-rule-strip
03 ca-glider-gun-scene
04 ca-color-age-trail
05 ca-pointer-seed-draw
06 ca-rule-variant-lab
07 ca-hex-grid-automaton
08 ca-wireworld-circuit
09 ca-hero-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 生存隣接数の値で生死が決まる基本格子オートマトン
- 02 1行の規則番号の値が次の行の模様を決めるストライプ・オートマトン
- 03 固定した発射パターンが一定間隔で移動体を放つシーン・オートマトン
- 04 世代の年齢値の分だけ色が薄れる残像オートマトン
- 05 カーソル座標値でマスを直接オン・オフする描画オートマトン
- 06 規則表の値を切り替えると模様が変わる規則実験オートマトン
- 07 六角形の隣接数値で分かれるハニカム格子オートマトン
- 08 4段階の値が配線を伝う信号回路オートマトン
- 09 複数の規則値が同一画面で同時に回るヒーローシーン・オートマトン
01は他の8種が土台にする、あるいはそこから外れていく基本ルールをそのまま動かします。02は同じ考え方を一行の帯に落とし込み、03は小さな固まりを一箇所に固定して繰り返し発射する装置として使います。04と05はルールの結果を年齢の値やポインタ座標の値に置き換え、06と09はルール表そのものを実行中に、あるいは領域ごとに入れ替えます。07と08はそもそも何を隣接と数えるかを変えていて、四角い8方向の代わりに六角形の6方向を数えたり、二値ではなく四段階の状態を数えたりします。
隣接数の折り返し処理が壊れる、6つの格子に共通する落とし穴
02の帯オートマトンは端を意図的に折り返していて、(x - 1 + COLS) % COLS と (x + 1) % COLS で左右の端の隣が反対側の端から取られるため、ルール30の模様が片側からはみ出しても反対側に現れ続けます。同じ剰余演算を01・03・04・05・06・08に持ち込むと壊れます — この6種は代わりに境界チェック(if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue)を使っていて、端のセルは単純に数える隣接が減るだけで、グライダーや信号ヘッドが端に達しても折り返さずそこで止まるか消えます。もう一つの落とし穴はGEN_INTERVAL_MSにあります — どの実装もrequestAnimationFrameの呼び出し回数ではなく蓄積時間(acc += dt; while (acc >= GEN_INTERVAL_MS))で世代を進めるので、144Hzのディスプレイで60Hzの2.4倍速く世代が進んでしまうことはありません。
ライフゲームとは何か — 隣接数という一つの値が全画面を作るしくみ
01・03・04・05・06・08のstep()を読むと形は共通しています。各セルの周囲の生きた隣接数を数え、すでに生きているセルなら生存範囲と、死んでいるセルなら誕生数一つと比較して次の状態を決め、別バッファに書き込んでから入れ替える、という流れです。02も隣接数を数えて比較するという同じ形をたどりますが、輪を一次元の3セルに狭め、生存/誕生の比較を8ビットの参照テーブルに置き換えています。07は3x3の輪の考え方を保ったまま、8マスではなく六角形の6マスに数え直しています。ローディング アニメーション js 9パターン — 物理演算で作るの9番目のパターンも同じ生存/誕生ルールを動かしていますが、それは毎世代「点灯セルの割合」という一つの数値を計算するためだけで、その割合が進行度の目標に届いた瞬間に世代を止めます。このページの9種はどれも止まりません — 世代を重ねること自体が目的で、目指す割合を数えているわけではないからです。
移動面積・強度・フレーム数で比べる9種のライフゲームパターン
どのパターンも自前の蓄積時間間隔で格子全体を再計算し、生きたセルをfillRectか六角パスで塗り直すので、以下の面積と強度は480x300のキャンバスのうちどれだけ実際に塗り替わったかを表していて、JavaScriptの実行速度ではありません。
| パターン | 累積移動面積 | 平均強度 | 移動フレーム |
|---|---|---|---|
| 01 隣接数による生死判定 | 51.9% | 144.5 | 23/23 |
| 02 一次元ルール帯 | 53.9% | 47.8 | 23/23 |
| 03 グライダー銃シーン | 27.6% | 47.6 | 23/23 |
| 04 年齢による色減衰 | 55.7% | 32.8 | 23/23 |
| 05 ポインタ描画シード | 17.0% | 146.7 | 23/23 |
| 06 ルール実験ラボ | 60.3% | 55.1 | 23/23 |
| 07 六角格子オートマトン | 49.5% | 55.0 | 23/23 |
| 08 ワイヤーワールド回路 | 26.7% | 136.8 | 23/23 |
| 09 ヒーローシーン | 62.4% | 148.5 | 23/23 |
01生存隣接数の値が生死を分ける格子オートマトン
各セルは8方向の生きた隣接数を数え、すでに生きていればSURVIVE_MIN/SURVIVE_MAXと、死んでいればBIRTH_COUNTと比較し、48x30の格子全体が別バッファに再計算されてから入れ替わります — 面積を広く均等に塗り替えるので、9種の中でも平均面積が高いタイプの代表です。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="ライフゲーム進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-cream;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}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);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #fff7e6;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(셀룰러 오토마타 로더 — 진행률 채움)와 다른 축: 여기선 규칙·세대·격자 자체가 주인공이지 진행률 표시가 아니다.
// 매 세대 이웃 수 값을 세어 살아있는 칸 전체를 다시 계산하고 fillRect 로 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const ALIVE_COLOR = '#93a4ca';
let SEED = 71301;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2; // 변형 01 대상 — 생존에 필요한 최소 이웃 수
const SURVIVE_MAX = 3;
const BIRTH_COUNT = 3; // 죽은 칸이 태어나는 데 필요한 이웃 수
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
let generation = 0;
function idx(x, y){ return y * COLS + x; }
function seedGrid(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
grid[idx(x, y)] = rnd() < 0.32 ? 1 : 0;
}
}
generation = 0;
}
function neighborCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
generation++;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = 'ライフゲーム進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = 'ライフゲーム停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render();
if (reduceMQ.matches){
// 정지 세대 1장 — 입력 직후 render() 를 즉시 1회 더 부른다 (rAF 루프 없이)
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './GameOfLifeBasic.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const ALIVE_COLOR = '#93a4ca';
let SEED = 71301;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2; // 변형 01 대상 — 생존에 필요한 최소 이웃 수
const SURVIVE_MAX = 3, BIRTH_COUNT = 3;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function seedGrid(): void {
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.3 ? 1 : 0;
}
function neighborCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '생명 게임 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '생명 게임 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function GameOfLifeBasic(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="생명 게임 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}01 · 生存隣接数の値で生死が決まる基本格子オートマトン — 計測 累積面積 51.899% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
generation++;
}
02ルール番号の値一つで次の行の模様が決まる帯オートマトン
RULEは左・自分・右の組み合わせごとに1ビットずつ、8ビットの参照テーブルに一度だけ展開されます。毎世代copyWithinでバッファ全体を一行上へずらしてから、最下行をそのテーブルで計算し直します。帯オートマトンには上下方向の隣接がないため、一行は必ずその一つ上の行だけに依存します。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="ルールストリップ進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 규칙 번호 값이 다음 줄 무늬를 직접 결정하는 계산이 주인공.
// 매 세대 한 줄을 규칙표와 대조해 계산하고 화면 전체를 아래로 밀어 fillRect 로 다시 그린다.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 5, COLS = W / CELL, ROWS = H / CELL;
const YELLOW = '#d2c7a4';
const ALIVE_COLOR = '#93a4ca';
let SEED = 30071;
let rnd = makeLcg(SEED);
const RULE = 30; // 울프럼 초등 오토마타 규칙 번호(이진 8비트) — 왼쪽·자신·오른쪽 조합마다 생사 결정
const ruleBits = new Uint8Array(8);
for (let p = 0; p < 8; p++) ruleBits[p] = (RULE >> p) & 1;
let grid = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function clearGrid(){
grid.fill(0);
}
function seedRow(seedX){
grid[idx(seedX, ROWS - 1)] = 1;
}
function step(){
grid.copyWithin(0, COLS, ROWS * COLS);
const base = (ROWS - 2) * COLS;
const outBase = (ROWS - 1) * COLS;
for (let x = 0; x < COLS; x++){
const l = grid[base + ((x - 1 + COLS) % COLS)];
const m = grid[base + x];
const r = grid[base + ((x + 1) % COLS)];
const pattern = l * 4 + m * 2 + r;
grid[outBase + x] = ruleBits[pattern];
}
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = YELLOW;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL, y * CELL, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = 'ルールストリップ進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = 'ルールストリップ停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
clearGrid();
const seedX = Math.floor(rnd() * COLS);
seedRow(seedX);
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 32;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
clearGrid();
seedRow(Math.floor(COLS / 2));
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './ElementaryRuleStrip.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 5, COLS = W / CELL, ROWS = H / CELL;
const YELLOW = '#d2c7a4';
const ALIVE_COLOR = '#93a4ca';
let SEED = 30071;
let rnd = makeLcg(SEED);
const RULE = 30; // 울프럼 초등 오토마타 규칙 번호(이진 8비트) — 왼쪽·자신·오른쪽 조합마다 생사 결정
const ruleBits = new Uint8Array(8);
for (let p = 0; p < 8; p++) ruleBits[p] = (RULE >> p) & 1;
const grid = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function clearGrid(): void {
grid.fill(0);
}
function seedRow(seedX: number): void {
grid[idx(seedX, ROWS - 1)] = 1;
}
function step(): void {
grid.copyWithin(0, COLS, ROWS * COLS);
const base = (ROWS - 2) * COLS;
const outBase = (ROWS - 1) * COLS;
for (let x = 0; x < COLS; x++){
const l = grid[base + ((x - 1 + COLS) % COLS)];
const m = grid[base + x];
const r = grid[base + ((x + 1) % COLS)];
const pattern = l * 4 + m * 2 + r;
grid[outBase + x] = ruleBits[pattern];
}
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = YELLOW;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL, y * CELL, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '규칙 스트립 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '규칙 스트립 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function ElementaryRuleStrip(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
clearGrid();
const seedX = Math.floor(rnd() * COLS);
seedRow(seedX);
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 32;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
clearGrid();
seedRow(Math.floor(COLS / 2));
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="규칙 스트립 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}02 · 1行の規則番号の値が次の行の模様を決めるストライプ・オートマトン — 計測 累積面積 53.91% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
let grid = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function clearGrid(){
grid.fill(0);
}
function seedRow(seedX){
grid[idx(seedX, ROWS - 1)] = 1;
}
function step(){
grid.copyWithin(0, COLS, ROWS * COLS);
const base = (ROWS - 2) * COLS;
const outBase = (ROWS - 1) * COLS;
for (let x = 0; x < COLS; x++){
const l = grid[base + ((x - 1 + COLS) % COLS)];
const m = grid[base + x];
const r = grid[base + ((x + 1) % COLS)];
const pattern = l * 4 + m * 2 + r;
grid[outBase + x] = ruleBits[pattern];
}
}
03固定発射装置が一定間隔で移動体を放つシーンオートマトン
GUN_ROWSは36セルのライフゲーム グライダー銃の固まりを、左右反転させた2組ずつ固定行に配置します。この固まり自身のセルも他と同じ生存/誕生ルールに従うため、毎サイクル自分で再生してグライダーを一つずつ押し出し続けます。放たれたグライダーはキャンバスを出るまで形を保ちます — 9種の中で平均面積が最も低い(27.6%)のは、発射装置と漂うグライダーの間の大部分が空いたままだからです。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="発射台シーン進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-cream;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #fff7e6;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 고정된 발사틀이 규칙에 따라 스스로 회복하며 이동체를 밀어내는 장면이 주인공.
// 라이프 규칙(이웃 수 값)으로 매 세대 전체 격자를 계산해 fillRect 로 다시 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 6, COLS = W / CELL, ROWS = H / CELL;
const ORANGE = '#c89a8f';
const ALIVE_COLOR = '#93a4ca';
let SEED = 30301;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
// 고정 발사틀(발사기) 셀 좌표 — 36칸, 37x10 경계상자 안에서 스스로 회복하며 대각 이동체를 밀어낸다.
const GUN_CELLS = [
[1,5],[1,6],[2,5],[2,6],
[11,5],[11,6],[11,7],[12,4],[12,8],[13,3],[13,9],[14,3],[14,9],
[15,6],[16,4],[16,8],[17,5],[17,6],[17,7],[18,6],
[21,3],[21,4],[21,5],[22,3],[22,4],[22,5],[23,2],[23,6],
[25,1],[25,2],[25,6],[25,7],
[35,3],[35,4],[36,3],[36,4]
];
const GUN_W = 37;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function placeGun(ox, oy, mirror){
for (let i = 0; i < GUN_CELLS.length; i++){
const dx = GUN_CELLS[i][0], dy = GUN_CELLS[i][1];
const x = ox + (mirror ? (GUN_W - 1 - dx) : dx);
const y = oy + dy;
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) grid[idx(x, y)] = 1;
}
}
const GUN_ROWS = [4, 18, 32];
function seedGrid(offset){
grid.fill(0);
for (let i = 0; i < GUN_ROWS.length; i++){
placeGun(2 + offset, GUN_ROWS[i], false);
placeGun(COLS - 2 - (GUN_W - 1) - offset, GUN_ROWS[i], true);
}
}
function neighborCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = ORANGE;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '発射台シーン進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = '発射台シーン停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
const offset = Math.floor(rnd() * 6);
seedGrid(offset);
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 20;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid(0);
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './GliderGunScene.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 6, COLS = W / CELL, ROWS = H / CELL;
const ORANGE = '#c89a8f';
const ALIVE_COLOR = '#93a4ca';
let SEED = 30301;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
// 고정 발사틀(발사기) 셀 좌표 — 36칸, 37x10 경계상자 안에서 스스로 회복하며 대각 이동체를 밀어낸다.
const GUN_CELLS: [number, number][] = [
[1,5],[1,6],[2,5],[2,6],
[11,5],[11,6],[11,7],[12,4],[12,8],[13,3],[13,9],[14,3],[14,9],
[15,6],[16,4],[16,8],[17,5],[17,6],[17,7],[18,6],
[21,3],[21,4],[21,5],[22,3],[22,4],[22,5],[23,2],[23,6],
[25,1],[25,2],[25,6],[25,7],
[35,3],[35,4],[36,3],[36,4]
];
const GUN_W = 37;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function placeGun(ox: number, oy: number, mirror: boolean): void {
for (let i = 0; i < GUN_CELLS.length; i++){
const dx = GUN_CELLS[i][0], dy = GUN_CELLS[i][1];
const x = ox + (mirror ? (GUN_W - 1 - dx) : dx);
const y = oy + dy;
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) grid[idx(x, y)] = 1;
}
}
const GUN_ROWS = [4, 18, 32];
function seedGrid(offset: number): void {
grid.fill(0);
for (let i = 0; i < GUN_ROWS.length; i++){
placeGun(2 + offset, GUN_ROWS[i], false);
placeGun(COLS - 2 - (GUN_W - 1) - offset, GUN_ROWS[i], true);
}
}
function neighborCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = ORANGE;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '발사틀 장면 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '발사틀 장면 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function GliderGunScene(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
const offset = Math.floor(rnd() * 6);
seedGrid(offset);
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 20;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid(0);
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="발사틀 장면 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}03 · 固定した発射パターンが一定間隔で移動体を放つシーン・オートマトン — 計測 累積面積 27.599% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function placeGun(ox, oy, mirror){
for (let i = 0; i < GUN_CELLS.length; i++){
const dx = GUN_CELLS[i][0], dy = GUN_CELLS[i][1];
const x = ox + (mirror ? (GUN_W - 1 - dx) : dx);
const y = oy + dy;
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) grid[idx(x, y)] = 1;
}
}
const GUN_ROWS = [4, 18, 32];
function seedGrid(offset){
grid.fill(0);
for (let i = 0; i < GUN_ROWS.length; i++){
placeGun(2 + offset, GUN_ROWS[i], false);
placeGun(COLS - 2 - (GUN_W - 1) - offset, GUN_ROWS[i], true);
}
}
04世代年齢の値で色が薄れていくオートマトン
ageという別バッファが、セルが生きている間はAGE_CAPまで上がり、死ぬとTRAIL_DECAYずつ下がります。描画側はその年齢の値を読んで低い色から高い色へのグラデーションに変換するので、alive自体はもう0に切り替わっていても、死んだ直後のセルはしばらく薄れながら塗られ続けます。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="年齢残像進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 칸의 나이 값이 색으로 누적·감쇠되는 계산이 주인공, 진행률 표시가 아니다.
// 매 세대 나이 값을 갱신하고 fillRect 로 화면 전체를 다시 계산해 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const PAPER = '#fff7e6';
const TRAIL_LOW = [225, 219, 201];
const TRAIL_HIGH = [147, 164, 202];
let SEED = 40401;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
const AGE_CAP = 10;
const TRAIL_DECAY = 1; // 변형 04 대상 — 죽은 뒤 나이 값이 세대마다 줄어드는 속도
let alive = new Uint8Array(COLS * ROWS);
let nextAlive = new Uint8Array(COLS * ROWS);
let age = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function seedGrid(){
for (let i = 0; i < alive.length; i++){
alive[i] = rnd() < 0.3 ? 1 : 0;
age[i] = 0;
}
}
function neighborCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += alive[idx(nx, ny)];
}
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const wasAlive = alive[i] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (wasAlive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
nextAlive[i] = willLive;
}
}
for (let i = 0; i < alive.length; i++){
if (nextAlive[i]){
age[i] = Math.min(AGE_CAP, age[i] + 1);
} else {
age[i] = Math.max(0, age[i] - TRAIL_DECAY);
}
}
const tmp = alive; alive = nextAlive; nextAlive = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const a = age[i];
if (a === 0) continue;
const t = a / AGE_CAP;
const r = Math.round(TRAIL_LOW[0] + (TRAIL_HIGH[0] - TRAIL_LOW[0]) * t);
const g = Math.round(TRAIL_LOW[1] + (TRAIL_HIGH[1] - TRAIL_LOW[1]) * t);
const b = Math.round(TRAIL_LOW[2] + (TRAIL_HIGH[2] - TRAIL_LOW[2]) * t);
ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
const ARIA_PLAY = '年齢残像進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = '年齢残像停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './ColorAgeTrail.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const PAPER = '#fff7e6';
const TRAIL_LOW = [225, 219, 201];
const TRAIL_HIGH = [147, 164, 202];
let SEED = 40401;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
const AGE_CAP = 10;
const TRAIL_DECAY = 1; // 변형 04 대상 — 죽은 뒤 나이 값이 세대마다 줄어드는 속도
let alive = new Uint8Array(COLS * ROWS);
let nextAlive = new Uint8Array(COLS * ROWS);
const age = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function seedGrid(): void {
for (let i = 0; i < alive.length; i++){
alive[i] = rnd() < 0.3 ? 1 : 0;
age[i] = 0;
}
}
function neighborCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += alive[idx(nx, ny)];
}
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const wasAlive = alive[i] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (wasAlive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
nextAlive[i] = willLive;
}
}
for (let i = 0; i < alive.length; i++){
if (nextAlive[i]){
age[i] = Math.min(AGE_CAP, age[i] + 1);
} else {
age[i] = Math.max(0, age[i] - TRAIL_DECAY);
}
}
const tmp = alive; alive = nextAlive; nextAlive = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const a = age[i];
if (a === 0) continue;
const t = a / AGE_CAP;
const r = Math.round(TRAIL_LOW[0] + (TRAIL_HIGH[0] - TRAIL_LOW[0]) * t);
const g = Math.round(TRAIL_LOW[1] + (TRAIL_HIGH[1] - TRAIL_LOW[1]) * t);
const b = Math.round(TRAIL_LOW[2] + (TRAIL_HIGH[2] - TRAIL_LOW[2]) * t);
ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
const ARIA_PLAY = '나이 잔상 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '나이 잔상 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function ColorAgeTrail(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="나이 잔상 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}04 · 世代の年齢値の分だけ色が薄れる残像オートマトン — 計測 累積面積 55.72% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const wasAlive = alive[i] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (wasAlive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
nextAlive[i] = willLive;
}
}
for (let i = 0; i < alive.length; i++){
if (nextAlive[i]){
age[i] = Math.min(AGE_CAP, age[i] + 1);
} else {
age[i] = Math.max(0, age[i] - TRAIL_DECAY);
}
}
const tmp = alive; alive = nextAlive; nextAlive = tmp;
}
05ポインタ座標の値が直接セルを切り替える描画オートマトン
ポインタがボタンを押したまま動くたびに、そのクライアント座標が格子のセルに変換され、そのセルを強制的に生きた状態にして即座に描画します。ポインタが押されている間は世代の進行そのものが止まるので、描いた形はボタンを離すまで画面に静止し、離した瞬間に他のパターンと同じ生存/誕生のstep()へ制御が戻ります。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="シード描画進行中 — ポインターでマスを描くか、スペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">ポインターで描画・スペースキーで停止・R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: crosshair;
touch-action: none;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-cream;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: crosshair;
}
}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);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: crosshair;
touch-action: none;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #fff7e6;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: crosshair;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 포인터 좌표 값이 칸의 생사를 직접 뒤집는 계산이 주인공.
// 항목 05 는 click 대신 pointerdown·pointermove 로 씨앗을 그린다 — 손을 떼면 규칙표로 자동 진행.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const ALIVE_COLOR = '#93a4ca';
let SEED = 50501;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function autoSeed(){
grid.fill(0);
for (let y = 8; y <= 21; y++){
for (let x = 14; x <= 33; x++){
if (rnd() < 0.34) grid[idx(x, y)] = 1;
}
}
}
function neighborCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = 'シード描画進行中 — ポインターでマスを描くか、スペースキーで停止、R で再シード';
const ARIA_DRAW = 'シード描画中 — 手を離すとルール表で自動進行';
const ARIA_PAUSE = 'シード描画停止中 — ポインターでマスを描くか、スペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', isDrawing ? ARIA_DRAW : (paused ? ARIA_PAUSE : ARIA_PLAY));
}
let paused = false;
let isDrawing = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){
if (rootEl.classList.contains('is-demo')){
rootEl.classList.remove('is-demo');
grid.fill(0);
}
}
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
autoSeed();
paused = false;
}
function cellFromEvent(e){
const rect = cvEl.getBoundingClientRect();
const px = (e.clientX - rect.left) * (W / rect.width);
const py = (e.clientY - rect.top) * (H / rect.height);
const cx = Math.max(0, Math.min(COLS - 1, Math.floor(px / CELL)));
const cy = Math.max(0, Math.min(ROWS - 1, Math.floor(py / CELL)));
return [cx, cy];
}
function paintAt(e){
const c = cellFromEvent(e);
grid[idx(c[0], c[1])] = 1;
render();
}
function onPointerDown(e){
human();
isDrawing = true;
paused = true;
updateAria();
paintAt(e);
}
function onPointerMove(e){
if (!isDrawing) return;
paintAt(e);
}
function onPointerUp(){
if (!isDrawing) return;
isDrawing = false;
paused = false;
updateAria();
render();
}
function onKey(e){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
updateAria();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
human();
if (reduceMQ.matches){
step();
render();
} else {
paused = !paused;
updateAria();
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused && !isDrawing){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('pointerdown', onPointerDown);
rootEl.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
autoSeed();
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './PointerSeedDraw.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const ALIVE_COLOR = '#93a4ca';
let SEED = 50501;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 3;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function autoSeed(): void {
grid.fill(0);
for (let y = 8; y <= 21; y++){
for (let x = 14; x <= 33; x++){
if (rnd() < 0.34) grid[idx(x, y)] = 1;
}
}
}
function neighborCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '씨앗 그리기 진행 중 — 포인터로 칸을 그리거나 스페이스바로 정지, R 로 재시드';
const ARIA_DRAW = '씨앗 그리는 중 — 손을 떼면 규칙표로 자동 진행';
const ARIA_PAUSE = '씨앗 그리기 정지됨 — 포인터로 칸을 그리거나 스페이스바로 재생, R 로 재시드';
export default function PointerSeedDraw(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', isDrawing ? ARIA_DRAW : (paused ? ARIA_PAUSE : ARIA_PLAY));
}
let paused = false;
let isDrawing = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void {
if (rootEl.classList.contains('is-demo')){
rootEl.classList.remove('is-demo');
grid.fill(0);
}
}
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
autoSeed();
paused = false;
}
function cellFromEvent(e: PointerEvent): [number, number] {
const rect = cvEl.getBoundingClientRect();
const px = (e.clientX - rect.left) * (W / rect.width);
const py = (e.clientY - rect.top) * (H / rect.height);
const cx = Math.max(0, Math.min(COLS - 1, Math.floor(px / CELL)));
const cy = Math.max(0, Math.min(ROWS - 1, Math.floor(py / CELL)));
return [cx, cy];
}
function paintAt(e: PointerEvent): void {
const c = cellFromEvent(e);
grid[idx(c[0], c[1])] = 1;
render(ctx);
}
function onPointerDown(e: PointerEvent): void {
human();
isDrawing = true;
paused = true;
updateAria();
paintAt(e);
}
function onPointerMove(e: PointerEvent): void {
if (!isDrawing) return;
paintAt(e);
}
function onPointerUp(): void {
if (!isDrawing) return;
isDrawing = false;
paused = false;
updateAria();
render(ctx);
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render(ctx);
updateAria();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
paused = !paused;
updateAria();
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused && !isDrawing){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('pointerdown', onPointerDown);
rootEl.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
autoSeed();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('pointerdown', onPointerDown);
rootEl.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
rootEl.removeEventListener('keydown', onKey);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="씨앗 그리기 진행 중 — 포인터로 칸을 그리거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>포인터로 그리기 · 스페이스바 정지 · R 재시드</p>
</div>
</main>
);
}05 · カーソル座標値でマスを直接オン・オフする描画オートマトン — 計測 累積面積 16.988% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function onPointerDown(e){
human();
isDrawing = true;
paused = true;
updateAria();
paintAt(e);
}
function onPointerMove(e){
if (!isDrawing) return;
paintAt(e);
}
function onPointerUp(){
if (!isDrawing) return;
isDrawing = false;
paused = false;
updateAria();
render();
}
06ルール表の値を丸ごと入れ替えるルール実験ラボ
RULESETSには完全な生存/誕生テーブルが4組入っていて、CYCLE_GENERATIONS世代ごとにテーブル全体が次のものへ入れ替わります。そのたびにseedSame()が保存しておいたスナップショットから開始配置をそのまま復元するので、同じ初期配置が4つの異なるルール表を順番に通り抜け、4回目には元とはまったく違う見た目になります。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="ルール実験進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 규칙표 값 자체를 통째로 바꿔치기하는 계산이 주인공.
// 같은 시작 배치를 규칙표 값만 바꿔가며 다시 계산해 fillRect 로 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const YELLOW = '#d2c7a4';
const ALIVE_COLOR = '#93a4ca';
let SEED = 60601;
let rnd = makeLcg(SEED);
// 규칙표 값 4 세트 — 통째로 바꿔치기 대상(생존 이웃 수 집합 · 탄생 이웃 수 집합)
const RULESETS = [
{ born: [3], survive: [2, 3] }, // Life
{ born: [3, 6], survive: [2, 3] }, // HighLife
{ born: [2], survive: [] }, // Seeds
{ born: [1, 3, 5, 7], survive: [1, 3, 5, 7] } // Replicator
];
let ruleIndex = 0;
const CYCLE_GENERATIONS = 26;
let genCount = 0;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function seedGrid(){
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.24 ? 1 : 0;
genCount = 0;
ruleIndex = 0;
}
function neighborCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(){
const rule = RULESETS[ruleIndex];
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
const tmp = grid; grid = next; next = tmp;
genCount++;
if (genCount >= CYCLE_GENERATIONS){
genCount = 0;
ruleIndex = (ruleIndex + 1) % RULESETS.length;
seedSame();
}
}
let baseSeedSnapshot = null;
function seedSame(){
if (!baseSeedSnapshot) return;
grid.set(baseSeedSnapshot);
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = YELLOW;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = 'ルール実験進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = 'ルール実験停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
baseSeedSnapshot = grid.slice();
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 70;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid();
baseSeedSnapshot = grid.slice();
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './RuleVariantLab.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const YELLOW = '#d2c7a4';
const ALIVE_COLOR = '#93a4ca';
let SEED = 60601;
let rnd = makeLcg(SEED);
// 규칙표 값 4 세트 — 통째로 바꿔치기 대상(생존 이웃 수 집합 · 탄생 이웃 수 집합)
const RULESETS: { born: number[]; survive: number[] }[] = [
{ born: [3], survive: [2, 3] }, // Life
{ born: [3, 6], survive: [2, 3] }, // HighLife
{ born: [2], survive: [] }, // Seeds
{ born: [1, 3, 5, 7], survive: [1, 3, 5, 7] } // Replicator
];
let ruleIndex = 0;
const CYCLE_GENERATIONS = 26;
let genCount = 0;
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
let baseSeedSnapshot: Uint8Array | null = null;
function idx(x: number, y: number): number { return y * COLS + x; }
function seedGrid(): void {
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.24 ? 1 : 0;
genCount = 0;
ruleIndex = 0;
}
function seedSame(): void {
if (!baseSeedSnapshot) return;
grid.set(baseSeedSnapshot);
}
function neighborCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(): void {
const rule = RULESETS[ruleIndex];
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
const tmp = grid; grid = next; next = tmp;
genCount++;
if (genCount >= CYCLE_GENERATIONS){
genCount = 0;
ruleIndex = (ruleIndex + 1) % RULESETS.length;
seedSame();
}
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = YELLOW;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
}
const ARIA_PLAY = '규칙 실험 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '규칙 실험 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function RuleVariantLab(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
baseSeedSnapshot = grid.slice();
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 70;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid();
baseSeedSnapshot = grid.slice();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="규칙 실험 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}06 · 規則表の値を切り替えると模様が変わる規則実験オートマトン — 計測 累積面積 60.251% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(){
const rule = RULESETS[ruleIndex];
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
const tmp = grid; grid = next; next = tmp;
genCount++;
if (genCount >= CYCLE_GENERATIONS){
genCount = 0;
ruleIndex = (ruleIndex + 1) % RULESETS.length;
seedSame();
}
}
07六方向の値で生死が分かれる蜂の巣格子オートマトン
このパターンだけ、どのセルも隣接は常に8ではなく6です。ただしその6方向は自分の列が偶数か奇数かで変わるため、OFF_EVENとOFF_ODDという二つの異なるオフセット表を用意していて、隣接数を数える側は列の偶奇を一回判定するだけでどちらを使うか選びます。四角格子の8方向ルールでは届かない方向にも、模様が蜂の巣を伝って広がっていきます。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="ハニカムグリッド進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-cream;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #fff7e6;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 육각 이웃 수 값이 생사를 가르는 계산이 주인공.
// odd-q 어긋난 육각 좌표계에서 매 세대 여섯 이웃 수를 세어 전체를 다시 계산하고 beginPath·fill 로 그린다.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const SIZE = 16;
const HORIZ = SIZE * 1.5;
const VERT = SIZE * Math.sqrt(3);
const COLS = 18, ROWS = 9;
const OX = 30, OY = 28;
const ORANGE = '#c89a8f';
const ALIVE_COLOR = '#93a4ca';
let SEED = 70701;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 2; // 육각 6이웃 기준 생존·탄생 문턱
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
// odd-q 어긋난 육각 좌표계 이웃 여섯 개 — 열이 짝수냐 홀수냐에 따라 표가 다르다.
const OFF_EVEN = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
const OFF_ODD = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];
function seedGrid(){
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.33 ? 1 : 0;
}
function neighborCount(x, y){
const offs = (x & 1) ? OFF_ODD : OFF_EVEN;
let n = 0;
for (let i = 0; i < 6; i++){
const nx = x + offs[i][0], ny = y + offs[i][1];
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function hexPath(cx, cy){
ctx.beginPath();
for (let i = 0; i < 6; i++){
const a = Math.PI / 180 * (60 * i);
const vx = cx + (SIZE - 1.2) * Math.cos(a);
const vy = cy + (SIZE - 1.2) * Math.sin(a);
if (i === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy);
}
ctx.closePath();
}
function render(){
ctx.fillStyle = ORANGE;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
const cx = OX + x * HORIZ;
const cy = OY + VERT * (y + 0.5 * (x & 1));
hexPath(cx, cy);
ctx.fill();
}
}
}
}
const ARIA_PLAY = 'ハニカムグリッド進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = 'ハニカムグリッド停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './HexGridAutomaton.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const SIZE = 16;
const HORIZ = SIZE * 1.5;
const VERT = SIZE * Math.sqrt(3);
const COLS = 18, ROWS = 9;
const OX = 30, OY = 28;
const ORANGE = '#c89a8f';
const ALIVE_COLOR = '#93a4ca';
let SEED = 70701;
let rnd = makeLcg(SEED);
const SURVIVE_MIN = 2, SURVIVE_MAX = 3, BIRTH_COUNT = 2; // 육각 6이웃 기준 생존·탄생 문턱
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
// odd-q 어긋난 육각 좌표계 이웃 여섯 개 — 열이 짝수냐 홀수냐에 따라 표가 다르다.
const OFF_EVEN = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
const OFF_ODD = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];
function seedGrid(): void {
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.33 ? 1 : 0;
}
function neighborCount(x: number, y: number): number {
const offs = (x & 1) ? OFF_ODD : OFF_EVEN;
let n = 0;
for (let i = 0; i < 6; i++){
const nx = x + offs[i][0], ny = y + offs[i][1];
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCount(x, y);
let willLive = 0;
if (alive){
willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
} else {
willLive = (n === BIRTH_COUNT) ? 1 : 0;
}
next[idx(x, y)] = willLive;
}
}
const tmp = grid; grid = next; next = tmp;
}
function hexPath(ctx: CanvasRenderingContext2D, cx: number, cy: number): void {
ctx.beginPath();
for (let i = 0; i < 6; i++){
const a = Math.PI / 180 * (60 * i);
const vx = cx + (SIZE - 1.2) * Math.cos(a);
const vy = cy + (SIZE - 1.2) * Math.sin(a);
if (i === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy);
}
ctx.closePath();
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = ORANGE;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = ALIVE_COLOR;
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
const cx = OX + x * HORIZ;
const cy = OY + VERT * (y + 0.5 * (x & 1));
hexPath(ctx, cx, cy);
ctx.fill();
}
}
}
}
const ARIA_PLAY = '벌집 격자 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '벌집 격자 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function HexGridAutomaton(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 85;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="벌집 격자 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}07 · 六角形の隣接数値で分かれるハニカム格子オートマトン — 計測 累積面積 49.486% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// odd-q オフセット六角座標系の隣接6マス — 列が偶数か奇数かで表が変わる。
const OFF_EVEN = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
const OFF_ODD = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];
function seedGrid(){
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.33 ? 1 : 0;
}
function neighborCount(x, y){
const offs = (x & 1) ? OFF_ODD : OFF_EVEN;
let n = 0;
for (let i = 0; i < 6; i++){
const nx = x + offs[i][0], ny = y + offs[i][1];
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
n += grid[idx(nx, ny)];
}
return n;
}
08四段階の値が配線を伝う信号回路オートマトン
このパターンでは各セルが「空・配線・信号ヘッド・信号テール」の四つの値のうちどれか一つを持ちます。配線セルは、生きた信号ヘッドの隣接数がHEAD_NEIGHBORS_MINからHEAD_NEIGHBORS_MAXの範囲に収まったときだけヘッドに切り替わり、ヘッドは次の世代で必ずテールに、テールは必ず配線に戻ります。そのため信号はあらかじめ組んだジグザグの配線に沿って一方向にしか進めません。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="信号回路進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 네 단계 상태 값이 미리 그려둔 전선을 타고 흐르는 계산이 주인공.
// 매 세대 이웃 신호머리 수를 세어 전체를 다시 계산하고 fillRect 로 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const PAPER = '#fff7e6';
const WIRE_COLOR = '#17141a';
const HEAD_COLOR = '#c89a8f';
const TAIL_COLOR = '#d2c7a4';
let SEED = 80801;
let rnd = makeLcg(SEED);
const EMPTY = 0, WIRE = 1, HEAD = 2, TAIL = 3;
const HEAD_NEIGHBORS_MIN = 1;
const HEAD_NEIGHBORS_MAX = 2; // 변형 08 대상 — 전선이 신호머리로 바뀌는 이웃 신호머리 수 상한
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
// 고정 회로: 촘촘한 지그재그 전선 — 행 간격 2칸(빈 행 하나 사이)이라 인접 가닥끼리 대각으로 겹치지 않는다.
const path = [];
(function buildPath(){
let dir = 1;
for (let r = 0; r < 13; r++){
const y = 3 + r * 2;
if (dir === 1){
for (let x = 4; x <= 43; x++) path.push([x, y]);
} else {
for (let x = 43; x >= 4; x--) path.push([x, y]);
}
if (r < 12){
const jointX = (dir === 1) ? 43 : 4;
path.push([jointX, y + 1]);
dir *= -1;
}
}
})();
function buildCircuit(gap, offset){
grid.fill(EMPTY);
for (let i = 0; i < path.length; i++){
grid[idx(path[i][0], path[i][1])] = WIRE;
}
for (let i = offset; i + 1 < path.length; i += gap){
grid[idx(path[i][0], path[i][1])] = TAIL;
grid[idx(path[i + 1][0], path[i + 1][1])] = HEAD;
}
}
function neighborHeadCount(x, y){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
if (grid[idx(nx, ny)] === HEAD) n++;
}
}
return n;
}
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const s = grid[i];
let ns = s;
if (s === WIRE){
const hc = neighborHeadCount(x, y);
if (hc >= HEAD_NEIGHBORS_MIN && hc <= HEAD_NEIGHBORS_MAX) ns = HEAD;
} else if (s === HEAD){
ns = TAIL;
} else if (s === TAIL){
ns = WIRE;
}
next[i] = ns;
}
}
const tmp = grid; grid = next; next = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const s = grid[idx(x, y)];
if (s === EMPTY) continue;
ctx.fillStyle = s === WIRE ? WIRE_COLOR : (s === HEAD ? HEAD_COLOR : TAIL_COLOR);
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
const ARIA_PLAY = '信号回路進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = '信号回路停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
const gap = 10 + Math.floor(rnd() * 6);
const offset = Math.floor(rnd() * gap);
buildCircuit(gap, offset);
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 45;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
buildCircuit(12, 0);
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './WireworldCircuit.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const PAPER = '#fff7e6';
const WIRE_COLOR = '#17141a';
const HEAD_COLOR = '#c89a8f';
const TAIL_COLOR = '#d2c7a4';
let SEED = 80801;
let rnd = makeLcg(SEED);
const EMPTY = 0, WIRE = 1, HEAD = 2, TAIL = 3;
const HEAD_NEIGHBORS_MIN = 1;
const HEAD_NEIGHBORS_MAX = 2; // 변형 08 대상 — 전선이 신호머리로 바뀌는 이웃 신호머리 수 상한
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
// 고정 회로: 촘촘한 지그재그 전선 — 행 간격 2칸(빈 행 하나 사이)이라 인접 가닥끼리 대각으로 겹치지 않는다.
const path: [number, number][] = [];
(function buildPath(){
let dir = 1;
for (let r = 0; r < 13; r++){
const y = 3 + r * 2;
if (dir === 1){
for (let x = 4; x <= 43; x++) path.push([x, y]);
} else {
for (let x = 43; x >= 4; x--) path.push([x, y]);
}
if (r < 12){
const jointX = (dir === 1) ? 43 : 4;
path.push([jointX, y + 1]);
dir *= -1;
}
}
})();
function buildCircuit(gap: number, offset: number): void {
grid.fill(EMPTY);
for (let i = 0; i < path.length; i++){
grid[idx(path[i][0], path[i][1])] = WIRE;
}
for (let i = offset; i + 1 < path.length; i += gap){
grid[idx(path[i][0], path[i][1])] = TAIL;
grid[idx(path[i + 1][0], path[i + 1][1])] = HEAD;
}
}
function neighborHeadCount(x: number, y: number): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
if (grid[idx(nx, ny)] === HEAD) n++;
}
}
return n;
}
function step(): void {
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const s = grid[i];
let ns = s;
if (s === WIRE){
const hc = neighborHeadCount(x, y);
if (hc >= HEAD_NEIGHBORS_MIN && hc <= HEAD_NEIGHBORS_MAX) ns = HEAD;
} else if (s === HEAD){
ns = TAIL;
} else if (s === TAIL){
ns = WIRE;
}
next[i] = ns;
}
}
const tmp = grid; grid = next; next = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = PAPER;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const s = grid[idx(x, y)];
if (s === EMPTY) continue;
ctx.fillStyle = s === WIRE ? WIRE_COLOR : (s === HEAD ? HEAD_COLOR : TAIL_COLOR);
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
const ARIA_PLAY = '신호 회로 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '신호 회로 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function WireworldCircuit(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
const gap = 10 + Math.floor(rnd() * 6);
const offset = Math.floor(rnd() * gap);
buildCircuit(gap, offset);
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 45;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
buildCircuit(12, 0);
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="신호 회로 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}08 · 4段階の値が配線を伝う信号回路オートマトン — 計測 累積面積 26.672% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(){
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
const i = idx(x, y);
const s = grid[i];
let ns = s;
if (s === WIRE){
const hc = neighborHeadCount(x, y);
if (hc >= HEAD_NEIGHBORS_MIN && hc <= HEAD_NEIGHBORS_MAX) ns = HEAD;
} else if (s === HEAD){
ns = TAIL;
} else if (s === TAIL){
ns = WIRE;
}
next[i] = ns;
}
}
const tmp = grid; grid = next; next = tmp;
}
09複数のルールの値が一画面で同時に回るヒーローシーンオートマトン
QUADSがキャンバスを四つの領域に分け、RULESETSが領域ごとに専用の生存/誕生テーブルを割り当てます。隣接数を数える側はその領域自身の境界だけを探索し、別の領域の隣接が境界を越えて漏れ込むことはありません。一枚のキャンバスの上で4つの異なるルール表が同時に回っているため、9種の中で平均面積・平均強度がどちらも最も高くなっています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="ヒーローシーン進行中 — クリックまたはスペースキーで停止、R で再シード">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで停止、R で再シード</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.rv {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-cream;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}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);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #fff7e6;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
// 395-09(진행률 채움 로더)와 다른 축: 구역마다 다른 규칙 값이 동시에 계산되는 장면 자체가 주인공.
// 매 세대 구역별로 분리 계산해 fillRect 로 화면 전체를 다시 그린다 — 속성 트윈 없음.
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const QUAD_COLORS = ['#93a4ca', '#c89a8f', '#d2c7a4', '#b2bed9'];
let SEED = 90901;
let rnd = makeLcg(SEED);
// 규칙표 값 4 세트 — 구역마다 하나씩 배정해 동시에 돌린다(항목 06 규칙표 값 재사용, 값은 구역 전용)
const RULESETS = [
{ born: [3], survive: [2, 3] }, // 좌상 Life
{ born: [3, 6], survive: [2, 3] }, // 우상 HighLife
{ born: [2], survive: [] }, // 좌하 Seeds
{ born: [1, 3, 5, 7], survive: [1, 3, 5, 7] } // 우하 Replicator
];
const HALF_C = COLS / 2, HALF_R = ROWS / 2;
const QUADS = [
{ x0: 0, y0: 0, x1: HALF_C - 1, y1: HALF_R - 1 },
{ x0: HALF_C, y0: 0, x1: COLS - 1, y1: HALF_R - 1 },
{ x0: 0, y0: HALF_R, x1: HALF_C - 1, y1: ROWS - 1 },
{ x0: HALF_C, y0: HALF_R, x1: COLS - 1, y1: ROWS - 1 }
];
function quadIndex(x, y){
return (x < HALF_C ? 0 : 1) + (y < HALF_R ? 0 : 2);
}
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x, y){ return y * COLS + x; }
function seedGrid(){
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.3 ? 1 : 0;
}
function neighborCountInQuad(x, y, q){
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < q.x0 || ny < q.y0 || nx > q.x1 || ny > q.y1) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(){
for (let qi = 0; qi < 4; qi++){
const q = QUADS[qi];
const rule = RULESETS[qi];
for (let y = q.y0; y <= q.y1; y++){
for (let x = q.x0; x <= q.x1; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCountInQuad(x, y, q);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
}
const tmp = grid; grid = next; next = tmp;
}
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const rootEl = document.querySelector('.rv');
function render(){
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillStyle = QUAD_COLORS[quadIndex(x, y)];
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
ctx.strokeStyle = 'rgba(255,247,230,.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(HALF_C * CELL, 0); ctx.lineTo(HALF_C * CELL, H);
ctx.moveTo(0, HALF_R * CELL); ctx.lineTo(W, HALF_R * CELL);
ctx.stroke();
}
const ARIA_PLAY = 'ヒーローシーン進行中 — クリックまたはスペースキーで停止、R で再シード';
const ARIA_PAUSE = 'ヒーローシーン停止中 — クリックまたはスペースキーで再生、R で再シード';
function updateAria(){
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(){ rootEl.classList.remove('is-demo'); }
function reseed(){
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(){
paused = !paused;
updateAria();
}
function onActivate(e){
if (e.type === 'keydown'){
if (e.key === 'r' || e.key === 'R'){
human();
reseed();
render();
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render();
} else {
togglePause();
}
}
function onKey(e){
if (e.key === 'r' || e.key === 'R' || e.key === 'Enter' || e.key === ' '){
onActivate(e);
}
}
const GEN_INTERVAL_MS = 70;
let acc = 0, last = 0;
function frame(now){
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render();
}
requestAnimationFrame(frame);
}
}
function onResize(){ render(); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onKey);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render();
if (!reduceMQ.matches){
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './HeroScene.module.scss';
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CELL = 10, COLS = W / CELL, ROWS = H / CELL;
const INK = '#17141a';
const QUAD_COLORS = ['#93a4ca', '#c89a8f', '#d2c7a4', '#b2bed9'];
let SEED = 90901;
let rnd = makeLcg(SEED);
interface Ruleset { born: number[]; survive: number[]; }
const RULESETS: Ruleset[] = [
{ born: [3], survive: [2, 3] },
{ born: [3, 6], survive: [2, 3] },
{ born: [2], survive: [] },
{ born: [1, 3, 5, 7], survive: [1, 3, 5, 7] }
];
const HALF_C = COLS / 2, HALF_R = ROWS / 2;
interface Quad { x0: number; y0: number; x1: number; y1: number; }
const QUADS: Quad[] = [
{ x0: 0, y0: 0, x1: HALF_C - 1, y1: HALF_R - 1 },
{ x0: HALF_C, y0: 0, x1: COLS - 1, y1: HALF_R - 1 },
{ x0: 0, y0: HALF_R, x1: HALF_C - 1, y1: ROWS - 1 },
{ x0: HALF_C, y0: HALF_R, x1: COLS - 1, y1: ROWS - 1 }
];
function quadIndex(x: number, y: number): number {
return (x < HALF_C ? 0 : 1) + (y < HALF_R ? 0 : 2);
}
let grid = new Uint8Array(COLS * ROWS);
let next = new Uint8Array(COLS * ROWS);
function idx(x: number, y: number): number { return y * COLS + x; }
function seedGrid(): void {
for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.3 ? 1 : 0;
}
function neighborCountInQuad(x: number, y: number, q: Quad): number {
let n = 0;
for (let dy = -1; dy <= 1; dy++){
for (let dx = -1; dx <= 1; dx++){
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (nx < q.x0 || ny < q.y0 || nx > q.x1 || ny > q.y1) continue;
n += grid[idx(nx, ny)];
}
}
return n;
}
function step(): void {
for (let qi = 0; qi < 4; qi++){
const q = QUADS[qi];
const rule = RULESETS[qi];
for (let y = q.y0; y <= q.y1; y++){
for (let x = q.x0; x <= q.x1; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCountInQuad(x, y, q);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
}
const tmp = grid; grid = next; next = tmp;
}
function render(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = INK;
ctx.fillRect(0, 0, W, H);
for (let y = 0; y < ROWS; y++){
for (let x = 0; x < COLS; x++){
if (grid[idx(x, y)] === 1){
ctx.fillStyle = QUAD_COLORS[quadIndex(x, y)];
ctx.fillRect(x * CELL + 1, y * CELL + 1, CELL - 1, CELL - 1);
}
}
}
ctx.strokeStyle = 'rgba(255,247,230,.14)';
ctx.beginPath();
ctx.moveTo(HALF_C * CELL, 0);
ctx.lineTo(HALF_C * CELL, H);
ctx.moveTo(0, HALF_R * CELL);
ctx.lineTo(W, HALF_R * CELL);
ctx.stroke();
}
const ARIA_PLAY = '히어로 장면 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드';
const ARIA_PAUSE = '히어로 장면 정지됨 — 클릭하거나 스페이스바로 재생, R 로 재시드';
export default function HeroScene(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
const ctx = ctx2d(cvEl);
function updateAria(): void {
rootEl.setAttribute('aria-label', paused ? ARIA_PAUSE : ARIA_PLAY);
}
let paused = false;
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function human(): void { rootEl.classList.remove('is-demo'); }
function reseed(): void {
SEED = (SEED * 1103515245 + 12345) >>> 0;
rnd = makeLcg(SEED || 1);
seedGrid();
}
function togglePause(): void {
paused = !paused;
updateAria();
}
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key === 'r' || ke.key === 'R'){
human();
reseed();
render(ctx);
return;
}
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
if (reduceMQ.matches){
step();
render(ctx);
} else {
togglePause();
}
}
const GEN_INTERVAL_MS = 70;
let acc = 0, last = 0, rafId = 0;
function frame(now: number): void {
if (!reduceMQ.matches){
if (!last) last = now;
const dt = now - last;
last = now;
if (!paused){
acc += dt;
while (acc >= GEN_INTERVAL_MS){
step();
acc -= GEN_INTERVAL_MS;
}
render(ctx);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { render(ctx); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
seedGrid();
updateAria();
render(ctx);
if (!reduceMQ.matches){
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="히어로 장면 진행 중 — 클릭하거나 스페이스바로 정지, R 로 재시드">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭·스페이스바로 정지, R 로 재시드</p>
</div>
</main>
);
}09 · 複数の規則値が同一画面で同時に回るヒーローシーン・オートマトン — 計測 累積面積 62.378% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(){
for (let qi = 0; qi < 4; qi++){
const q = QUADS[qi];
const rule = RULESETS[qi];
for (let y = q.y0; y <= q.y1; y++){
for (let x = q.x0; x <= q.x1; x++){
const alive = grid[idx(x, y)] === 1;
const n = neighborCountInQuad(x, y, q);
const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
next[idx(x, y)] = willLive ? 1 : 0;
}
}
}
const tmp = grid; grid = next; next = tmp;
}
生存の下限・減衰速度・信号の範囲を動かすと何が変わるか
以下の3種はどれも定数を一つだけ変えていて、格子もシード配置もイベント処理もそのままです。見た目の違いはすべてその一つの数値から来ています。
| 変形 | 変えた値 | 感触 |
|---|---|---|
| 生存隣接数の下限の値を調整 | 01 SURVIVE_MIN 2 → 1 |
生存の下限を2から1に下げると、隣接が一つでも生きていればセルが生き続けるため、基本パターンよりはるかに長くセルが残り、格子全体が薄まらずに密なまま固まりやすくなります。 |
| 年齢による色減衰速度の値を調整 | 04 TRAIL_DECAY 1 → 2 |
減衰の値を1から2に上げると、死んだセルの年齢の値が毎世代半分の速さで下がるため、基本パターンのおよそ半分の長さで軌跡が消えます。 |
| 信号ヘッドの隣接数許容範囲の値を調整 | 08 HEAD_NEIGHBORS_MAX 2 → 1 |
許容上限を2から1に狭めると、隣接ヘッドがちょうど1つのときしか配線がヘッドに切り替わらなくなり、二本の信号が合流する分岐点で条件を満たせず、基本パターンより信号が途切れやすくなります。 |
prefers-reduced-motionが9種すべてから取り除くもの — アクセシビリティの注記
どのタイルも読み込み時に一度window.matchMedia('(prefers-reduced-motion: reduce)')を確認します — この一致条件はMDNのprefers-reduced-motion解説にあるメディアクエリそのものです。一致した場合、自走するrequestAnimationFrameループはそもそも一度も始まらず、最初のクリック・Enter・スペースキーの押下でちょうど一世代分のstep()を計算し、一度だけ描画します — 格子は確かに一世代進みますが、その後は何もひとりでに動き続けません。どのタイルも実行中と停止中で切り替わるaria-labelを持ち続けるので、タブキーでたどり着いたキーボード利用者(tabindex="0"、role="button")は、キャンバスを見なくても状態を把握できます。イベントは9種のうち8種で共通していて、タイルへのclick・keydown・resizeと、同じkeydown内のRキーによる再シードだけです。05だけが描画モードのためにpointerdown・pointermove・pointerupを追加しています。上のすべてのstep()とSCSS・React版一式は一つのzipにまとまっていて、p9w3yqnrで開きます — このページのどのパターンでも同じパスワードです。
生成背景・回路図・繰り返しループとして実際に使う場面
09の4つのルール表が同時に回る画面は、ヒーローセクションや空状態のイラストの背後に置く生成背景としてそのまま使えます。訪れるたびに違う見た目になりますが、シード配置とルール表が毎回その仕事を肩代わりするので、デザイナーが形を手で配置する必要はありません。08の配線と信号のオートマトンは背景というより説明図に近く、固定された回路の形の上を信号ヘッドが這っていく様子は、静的な矢印やループ動画では伝えにくい「これがこれにつながっている」という関係を、動画ファイルを配信せずに伝えられます。
03のグライダー銃は、生成的な画面よりも小さな繰り返しループを求める場所で役に立ちます。セクションの区切りや、一定間隔でグライダーを外へ放つ角の装飾は、09のヒーローシーンのように毎フレーム画面全体を描き直さなくても、生きているように見えます。
反応拡散の格子・落ち物系の格子との違い
チューリングパターン シミュレーション9選 — ライブラリなし、2物質の反応グリッドだけでは1セルにつき二つの連続した濃度の値を持ち、毎フレーム拡散と反応の式で両方を更新するので、模様は水にインクが広がるように隣のセルとにじみ合います。このページの9種は、どのセルの状態も小さな整数だけです — 0か1、あるいは配線の4状態のうちどれか — 隣接数を数えて固定テーブルと照らし合わせて決まるので、にじみは一切起きません。セルは生きているか死んでいるか、オンかオフか、配線かヘッドかのどちらかで、その間の値は存在しません。
フォーリングサンド(falling sand simulator) 9種の作り方は重力のルールに従って素材を下へ動かし、下や横のセルと入れ替えるので、一粒の位置がフレームごとに変わります。この9種はどれもセルの位置を動かしません — セルのx, yは変わらず、生きていると数えられるかどうかだけが変わり、動いているように見える感覚はどの固定セルが次にオン・オフを切り替えるかだけから生まれています。
html5 canvas アニメーション9種 — 水滴とブラックホールは個々のピクセルを直接読み書きするので、屈折やレンズのような計算が格子セルとセルの間にある値を曲げられます。この9種はピクセルには一切触れず、最小の単位は常にセル一つで、fillRectか六角パスだけがキャンバスに届きます。
このグリッドを自分のプロジェクトで動かす前によくある質問
ライフゲーム javascriptとしてstep()だけをキャンバス抜きで使えますか
はい — 上に載せたどのstep()も、読み書きするのはUint8Arrayのプレーンなバッファだけです。キャンバスのコンテキストに触れるのは描画側だけなので、ルールのロジック自体はキャンバスに依存せず、Node.jsのスクリプトやテストファイル、別のレンダラーにそのまま持ち込めます。
なぜ07だけ隣接を8ではなく6で数えるのですか
07はセルを四角ではなく六角形として配置していて、六角形は隣り合う辺を持つ相手が常に6つしかないからです。そのためオフセット表も四角格子の8マス分ではなく、6マス分だけを用意しています。
03のグライダー銃はいつか発射を止めますか
止まりません — 発射装置自身のセルも格子の他の部分と同じ生存/誕生ルールに従うため、36セルの固まりが毎サイクル自分自身を再生し続け、タイルが世代を進めている限り発射し続けます。









