09複数の規則値が同一画面で同時に回るヒーローシーン・オートマトン
画面を複数の区域に分け、区域ごとに異なる規則表の値を割り当て、毎世代各区域は自分の規則値だけで計算されつつ境界では隣接区域の値が互いに漏れないよう区域別に計算を分離し、一枚のキャンバスに複数の模様が同時に生きる。
使いどころLPのヒーロー背景、ポートフォリオのイントロ演出
RULESETSQUADSneighborCountInQuad

<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 に同梱 · ライフゲーム9種 — キャンバスで世代を計算
このデモが属する記事
ライフゲーム9種 — キャンバスで世代を計算ライフゲームのルールを計算だけで動かす9種のキャンバス実装です。格子は勝手に動いているように見えるが、実際には毎世代、生存隣接数という一つの値が生死を分け、その値が積み重なった年齢の分だけ色が薄れ、同じ値が配線を伝って信号のように流れる — この記事のデモをすべて見る


