07洞窟断面 — ノイズで掘られた空洞
上下2本のfbm曲線で天井と床を描き、clip でその間の空洞だけを残し、ノイズ値が高い地点を選んで鍾乳石を配置し洞窟断面を仕上げます。
使いどころ探検・ゲーム・ダークファンタジーLP
fbmquadraticCurveToclip

<main class="stage">
<div class="tcc" id="tcc">
<p class="tcc__hint">
<svg class="tcc__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m32-88a32 32 0 1 0-32 32a32 32 0 0 0 32-32"/></g></svg>
<span>シードを変えると別の洞窟が掘られます</span>
</p>
<div class="tcc__field" id="field">
<canvas class="tcc__cv" id="cv"></canvas>
</div>
<div class="tcc__bar">
<label class="tcc__dial">シード
<input type="number" id="seed" min="1" max="9999" step="1" value="7" aria-label="洞窟シード">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 동굴 단면 — 위아래 두 fbm 곡선으로 천장·바닥을 그리고 clip 으로 공동만 남긴 뒤
// 종유석·석순을 노이즈 값이 높은 지점에 배치한다. 무대는 잉크(어두운 바위).
// 파스텔 — mix() 로 옅게 섞은 로컬 변수만 쓴다. 리터럴 hex 0개(BD26).
$terrain-lilac: $subject-lilac;
$terrain-mint: mix($subject-mint, $stage-paper, 42%);
$duration: $dur-loop;
$easing: $ease-out;
$color: $terrain-lilac;
@include stage($stage-ink);
.tcc {
// 바위 몸통(위→아래) · 공동 채움 · 공동 안쪽 아래 채광(위→아래)
--rock-top: #{mix($subject-ink, $terrain-lilac, 78%)};
--rock-bottom: #{mix($subject-ink, $terrain-mint, 84%)};
--cav-top: #{$terrain-lilac};
--cav-bottom: #{mix($terrain-lilac, $subject-ink, 38%)};
--glow-top: #{$terrain-mint};
--glow-bottom: #{mix($terrain-mint, $subject-ink, 30%)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.tcc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.tcc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-cream; }
.tcc__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.tcc__cv { display: block; width: 100%; height: 100%; }
.tcc__bar { display: flex; align-items: center; gap: $sp-3; }
.tcc__dial {
display: flex; align-items: center; gap: $sp-2;
font-weight: 700; font-size: 11px; color: $subject-cream;
}
.tcc__dial input {
width: 72px; margin: 0;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-sm;
background: rgba($subject-cream, .12); color: $subject-cream;
font-weight: 700; font-size: 12px; font-family: inherit;
}
.tcc__dial input:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
@media (max-width: 340px) {
.tcc { gap: $sp-1; }
.tcc__field { height: 100px; }
.tcc__dial input { width: 56px; }
.tcc__hint { font-size: 10px; }
}
// reduce — 루프 없이 시드의 정적 한 장만 남는다. 커서 신호가 없으므로 클래스만 걸어 둔다(다른 항목과 형식 일치)
@media (prefers-reduced-motion: reduce) {
.tcc.is-still .tcc__field { cursor: default; }
}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);
}
.tcc {
--rock-top: rgb(22.9960784314%, 19.9215686275%, 29.9529411765%);
--rock-bottom: rgb(18.8592941176%, 21.4550588235%, 22.2192941176%);
--cav-top: #b9a0ff;
--cav-bottom: rgb(33.1607843137%, 28.7058823529%, 44.3215686275%);
--glow-top: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--glow-bottom: rgb(27.4690196078%, 33.3654901961%, 32.7396078431%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.tcc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.tcc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #fff7e6;
}
.tcc__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.tcc__cv {
display: block;
width: 100%;
height: 100%;
}
.tcc__bar {
display: flex;
align-items: center;
gap: 12px;
}
.tcc__dial {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 11px;
color: #fff7e6;
}
.tcc__dial input {
width: 72px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
font-family: inherit;
}
.tcc__dial input:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tcc {
gap: 4px;
}
.tcc__field {
height: 100px;
}
.tcc__dial input {
width: 56px;
}
.tcc__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tcc.is-still .tcc__field {
cursor: default;
}
}// 동굴 단면 — 위아래 두 fbm 곡선으로 천장과 바닥을 그리고 clip 으로 그 사이 공동만 남긴다.
// 천장 곡선은 fbm, 바닥 곡선도 fbm(다른 오프셋) — 두 곡선 사이가 노이즈로 파낸 공동(cavity)이 된다.
// 종유석(천장에서 아래로)·석순(바닥에서 위로)은 노이즈 값이 높은 지점에 골라 배치한다:
// x 칸마다 vnoise 를 하나 더 읽어 문턱을 넘으면 그 길이로 밑뿌리를 내리는 식이다(좌표는 격자로 양자화).
// 천장·바닥 곡선 둘 다 옥타브 위상을 시간에 흘려 동굴이 살아 움직이게 한다(브리프 명시).
var root = document.getElementById('tcc');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var seedIn = document.getElementById('seed');
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var PERM = new Uint8Array(512);
var VAL = new Float32Array(256);
// シード → PERM(순서표)·VAL(격자점 난수값) 재생성 — シード UI 가 이걸 다시 부른다
function buildTables(seed) {
var rng = mulberry32(seed >>> 0);
var p = [], i, j, t;
for (i = 0; i < 256; i++) p[i] = i;
for (i = 255; i > 0; i--) { j = (rng() * (i + 1)) | 0; t = p[i]; p[i] = p[j]; p[j] = t; }
for (i = 0; i < 512; i++) PERM[i] = p[i & 255];
for (i = 0; i < 256; i++) VAL[i] = rng();
}
function fade(t) { return t * t * (3 - 2 * t); } // 스무스스텝 — 격자 경계에서 각이 안 지게
// 1차원 값 노이즈 — 격자점마다 난수를 하나 두고 그 사이를 fade 로 부드럽게 잇는다
function vnoise1(x) {
var xi = Math.floor(x);
var xf = x - xi;
var a = VAL[PERM[xi & 255]], b = VAL[PERM[(xi + 1) & 255]];
return a + (b - a) * fade(xf);
}
var OCT = 4;
// fbm — 주파수 2배·진폭 절반으로 4겹. 옥타브마다 자기 위상을 따로 흘려보낸다
function fbm1(x, sec, flow) {
var sum = 0, amp = 0.5, norm = 0, f = 1;
for (var i = 0; i < OCT; i++) {
var phase = sec * flow * (i + 1);
sum += vnoise1(x * f + phase) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
var CEIL_SCALE = 0.020, CEIL_FLOW = 9.0, CEIL_OFFSET = 17.3;
var FLOOR_SCALE = 0.017, FLOOR_FLOW = 7.5, FLOOR_OFFSET = 203.8;
var STAL_FLOW = 6.0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var raf = 0;
var css = getComputedStyle(root);
function tok(name) { return css.getPropertyValue(name).trim(); }
var C_ROCK_TOP = tok('--rock-top'), C_ROCK_BOTTOM = tok('--rock-bottom');
var C_CAV_TOP = tok('--cav-top'), C_CAV_BOTTOM = tok('--cav-bottom');
var C_GLOW_TOP = tok('--glow-top'), C_GLOW_BOTTOM = tok('--glow-bottom');
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function ceilY(x, sec) {
var n = fbm1(x * CEIL_SCALE + CEIL_OFFSET, sec, CEIL_FLOW);
n = n * 1.7 - 0.35; // 값 노이즈는 가운데로 몰린다 — 대비를 한 번 펴 준다
if (n < 0.06) n = 0.06; else if (n > 0.62) n = 0.62;
return H * (0.14 + n * 0.34);
}
function floorY(x, sec) {
var n = fbm1(x * FLOOR_SCALE + FLOOR_OFFSET, sec, FLOOR_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.30) n = 0.30; else if (n > 0.94) n = 0.94;
return H * (0.44 + n * 0.40);
}
// 종유석 — x 칸마다 노이즈를 하나 더 읽어 문턱 위일 때만 내린다(좌표는 4px 격자로 양자화).
// 옆면은 quadraticCurveTo 로 안으로 휘게(밑뿌리가 좁아지는 물방울 곡선) — 진짜 종유석 단면처럼
function drawStalactites(sec) {
ctx.fillStyle = C_ROCK_BOTTOM;
var cell = 4;
for (var gx = 2; gx < W; gx += cell) {
var s = vnoise1(gx * 0.055 + 71.7 + sec * STAL_FLOW * 0.35);
if (s > 0.62) {
var len = (s - 0.62) / 0.38; // 0..1 — 문턱을 얼마나 넘었나
var top = ceilY(gx, sec);
var h = 4 + len * H * 0.26;
var w = 3 + Math.round(len * 3);
var g = ctx.createLinearGradient(0, top, 0, top + h);
g.addColorStop(0, C_ROCK_TOP);
g.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(gx - w, top);
ctx.quadraticCurveTo(gx - w * 0.15, top + h * 0.55, gx, top + h);
ctx.quadraticCurveTo(gx + w * 0.15, top + h * 0.55, gx + w, top);
ctx.closePath();
ctx.fill();
}
}
// 석순(stalagmite) — 바닥에서 위로, 다른 노이즈 창으로 자리를 골라 대칭을 피한다
ctx.fillStyle = C_ROCK_TOP;
for (var fx = 6; fx < W; fx += cell) {
var s2 = vnoise1(fx * 0.055 + 141.9 + sec * STAL_FLOW * 0.3);
if (s2 > 0.66) {
var len2 = (s2 - 0.66) / 0.34;
var fy = floorY(fx, sec);
var h2 = 3 + len2 * H * 0.20;
var w2 = 3 + Math.round(len2 * 3);
var g2 = ctx.createLinearGradient(0, fy, 0, fy - h2);
g2.addColorStop(0, C_ROCK_TOP);
g2.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = g2;
ctx.beginPath();
ctx.moveTo(fx - w2, fy);
ctx.quadraticCurveTo(fx - w2 * 0.15, fy - h2 * 0.55, fx, fy - h2);
ctx.quadraticCurveTo(fx + w2 * 0.15, fy - h2 * 0.55, fx + w2, fy);
ctx.closePath();
ctx.fill();
}
}
}
function paint(sec) {
ctx.clearRect(0, 0, W, H);
// 1) 바위 몸통 — 화면 전체를 어두운 암석 톤으로 깐다
var rock = ctx.createLinearGradient(0, 0, 0, H);
rock.addColorStop(0, C_ROCK_TOP);
rock.addColorStop(1, C_ROCK_BOTTOM);
ctx.fillStyle = rock;
ctx.fillRect(0, 0, W, H);
// 2) 공동 — 천장·바닥 두 fbm 곡선 사이만 clip 으로 남긴다
ctx.save();
ctx.beginPath();
ctx.moveTo(0, ceilY(0, sec));
for (var x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
for (var x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
ctx.closePath();
ctx.clip();
var cav = ctx.createLinearGradient(0, 0, 0, H);
cav.addColorStop(0, C_CAV_TOP);
cav.addColorStop(1, C_CAV_BOTTOM);
ctx.fillStyle = cav;
ctx.fillRect(0, 0, W, H);
// 공동 안쪽 빛 — 아래에서 올라오는 민트 채광
var glow = ctx.createLinearGradient(0, H * 0.45, 0, H);
glow.addColorStop(0, C_GLOW_TOP);
glow.addColorStop(1, C_GLOW_BOTTOM);
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// 3) 종유석·석순 — 공동 안에서만 그린다(clip 이 걸린 상태)
drawStalactites(sec);
ctx.restore();
}
function frame(now) {
paint(now / 1000);
raf = requestAnimationFrame(frame);
}
function onSeed() {
var v = Math.max(1, Math.min(9999, Math.floor(Number(seedIn.value)) || 1));
buildTables(v);
if (reduce) paint(0);
}
seedIn.addEventListener('input', onSeed);
seedIn.addEventListener('change', onSeed);
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
buildTables(Number(seedIn.value) || 7);
fit();
if (reduce) {
// reduce — 루프를 시작하지 않고 시드가 정한 동굴 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
paint(0);
} else {
raf = requestAnimationFrame(frame);
}// CaveCrossSection — vanilla 판(run/309/demo/07-terrain-cave-cross-section/index.html) 의 React 포트.
// 위아래 두 fbm 곡선으로 천장·바닥을 그리고 clip 으로 공동만 남긴 뒤,
// 종유석·석순을 노이즈 값이 높은 지점에 배치한다. 천장·바닥 둘 다 옥타브 위상을 시간에 흘린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CaveCrossSection.module.scss";
export type CaveCrossSectionProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
const OCT = 4;
const CEIL_SCALE = 0.020, CEIL_FLOW = 9.0, CEIL_OFFSET = 17.3;
const FLOOR_SCALE = 0.017, FLOOR_FLOW = 7.5, FLOOR_OFFSET = 203.8;
const STAL_FLOW = 6.0;
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function fade(t: number): number {
return t * t * (3 - 2 * t);
}
export default function CaveCrossSection({
duration = "2s",
easing = "ease-out",
color = "#b9a0ff",
}: CaveCrossSectionProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const permRef = useRef(new Uint8Array(512)) as Box<Uint8Array>;
const valRef = useRef(new Float32Array(256)) as Box<Float32Array>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const [seed, setSeed] = useState(7);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 시드 → PERM(순서표)·VAL(격자점 난수값) 재생성 — 시드 UI 가 이걸 다시 부른다
function buildTables(sd: number): void {
const rng = mulberry32(sd >>> 0);
const p: number[] = [];
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
const t = p[i]; p[i] = p[j]; p[j] = t;
}
const perm = permRef.current, val = valRef.current;
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
for (let i = 0; i < 256; i++) val[i] = rng();
}
// 1차원 값 노이즈 — 격자점마다 난수를 하나 두고 그 사이를 fade 로 부드럽게 잇는다
function vnoise1(x: number): number {
const perm = permRef.current, val = valRef.current;
const xi = Math.floor(x);
const xf = x - xi;
const a = val[perm[xi & 255]], b = val[perm[(xi + 1) & 255]];
return a + (b - a) * fade(xf);
}
// fbm — 주파수 2배·진폭 절반으로 4겹. 옥타브마다 자기 위상을 따로 흘려보낸다
function fbm1(x: number, sec: number, flow: number): number {
let sum = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < OCT; i++) {
const phase = sec * flow * (i + 1);
sum += vnoise1(x * f + phase) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function ceilY(x: number, sec: number): number {
const H = sizeRef.current.h;
let n = fbm1(x * CEIL_SCALE + CEIL_OFFSET, sec, CEIL_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.06) n = 0.06; else if (n > 0.62) n = 0.62;
return H * (0.14 + n * 0.34);
}
function floorY(x: number, sec: number): number {
const H = sizeRef.current.h;
let n = fbm1(x * FLOOR_SCALE + FLOOR_OFFSET, sec, FLOOR_FLOW);
n = n * 1.7 - 0.35;
if (n < 0.30) n = 0.30; else if (n > 0.94) n = 0.94;
return H * (0.44 + n * 0.40);
}
function tok(name: string): string {
const root = rootRef.current;
if (!root) return "#000";
return getComputedStyle(root).getPropertyValue(name).trim();
}
// 종유석 — x 칸마다 노이즈를 하나 더 읽어 문턱 위일 때만 내린다(좌표는 4px 격자로 양자화).
// 옆면은 quadraticCurveTo 로 안으로 휘게(밑뿌리가 좁아지는 물방울 곡선) — 진짜 종유석 단면처럼
function drawStalactites(ctx: CanvasRenderingContext2D, sec: number): void {
const W = sizeRef.current.w, H = sizeRef.current.h;
const cell = 4;
const rockTop = tok("--rock-top"), glowBottom = tok("--glow-bottom");
for (let gx = 2; gx < W; gx += cell) {
const s = vnoise1(gx * 0.055 + 71.7 + sec * STAL_FLOW * 0.35);
if (s > 0.62) {
const len = (s - 0.62) / 0.38;
const top = ceilY(gx, sec);
const h = 4 + len * H * 0.26;
const w = 3 + Math.round(len * 3);
const g = ctx.createLinearGradient(0, top, 0, top + h);
g.addColorStop(0, rockTop);
g.addColorStop(1, glowBottom);
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(gx - w, top);
ctx.quadraticCurveTo(gx - w * 0.15, top + h * 0.55, gx, top + h);
ctx.quadraticCurveTo(gx + w * 0.15, top + h * 0.55, gx + w, top);
ctx.closePath();
ctx.fill();
}
}
// 석순(stalagmite) — 바닥에서 위로, 다른 노이즈 창으로 자리를 골라 대칭을 피한다
for (let fx = 6; fx < W; fx += cell) {
const s2 = vnoise1(fx * 0.055 + 141.9 + sec * STAL_FLOW * 0.3);
if (s2 > 0.66) {
const len2 = (s2 - 0.66) / 0.34;
const fy = floorY(fx, sec);
const h2 = 3 + len2 * H * 0.20;
const w2 = 3 + Math.round(len2 * 3);
const g2 = ctx.createLinearGradient(0, fy, 0, fy - h2);
g2.addColorStop(0, rockTop);
g2.addColorStop(1, glowBottom);
ctx.fillStyle = g2;
ctx.beginPath();
ctx.moveTo(fx - w2, fy);
ctx.quadraticCurveTo(fx - w2 * 0.15, fy - h2 * 0.55, fx, fy - h2);
ctx.quadraticCurveTo(fx + w2 * 0.15, fy - h2 * 0.55, fx + w2, fy);
ctx.closePath();
ctx.fill();
}
}
}
function paint(sec: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
ctx.clearRect(0, 0, W, H);
// 1) 바위 몸통 — 화면 전체를 어두운 암석 톤으로 깐다
const rock = ctx.createLinearGradient(0, 0, 0, H);
rock.addColorStop(0, tok("--rock-top"));
rock.addColorStop(1, tok("--rock-bottom"));
ctx.fillStyle = rock;
ctx.fillRect(0, 0, W, H);
// 2) 공동 — 천장·바닥 두 fbm 곡선 사이만 clip 으로 남긴다
ctx.save();
ctx.beginPath();
ctx.moveTo(0, ceilY(0, sec));
for (let x = 2; x <= W; x += 2) ctx.lineTo(x, ceilY(x, sec));
for (let x2 = W; x2 >= 0; x2 -= 2) ctx.lineTo(x2, floorY(x2, sec));
ctx.closePath();
ctx.clip();
const cav = ctx.createLinearGradient(0, 0, 0, H);
cav.addColorStop(0, tok("--cav-top"));
cav.addColorStop(1, tok("--cav-bottom"));
ctx.fillStyle = cav;
ctx.fillRect(0, 0, W, H);
// 공동 안쪽 빛 — 아래에서 올라오는 민트 채광
const glow = ctx.createLinearGradient(0, H * 0.45, 0, H);
glow.addColorStop(0, tok("--glow-top"));
glow.addColorStop(1, tok("--glow-bottom"));
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
// 3) 종유석·석순 — 공동 안에서만 그린다(clip 이 걸린 상태)
drawStalactites(ctx, sec);
ctx.restore();
}
function frame(now: number): void {
paint(now / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onSeed(e: InputEvt): void {
const v = Math.max(1, Math.min(9999, Math.floor(Number(e.target.value)) || 1));
setSeed(v);
buildTables(v);
if (reduced()) paint(0);
}
function onResize(): void {
fit();
if (reduced()) paint(0);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
buildTables(seed);
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
setStill(true);
paint(0);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m32-88a32 32 0 1 0-32 32a32 32 0 0 0 32-32"/></g></svg>
<span>시드를 바꾸면 다른 동굴이 뚫립니다</span>
</p>
<div className={styles.field} ref={fieldRef}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<label className={styles.dial}>시드
<input type="number" min={1} max={9999} step={1} value={seed} aria-label="동굴 시드" onChange={onSeed} />
</label>
</div>
</div>
</main>
);
}07 洞窟断面 — ノイズで掘られた空洞 — 計測 累積面積 31.048% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パーリンノイズ地形9選 — ライブラリなし、計算だけで
このデモが属する記事
パーリンノイズ地形9選 — ライブラリなし、計算だけでパーリンノイズとは、隣り合う値がなめらかにつながる乱数のことで、山・雲・海・星空といった自然の形を数式だけで描き出す元になる素材です。この9種をノイズ用ライブラリを一切使わずCanvas 2Dに直接実装しました。実測では08の河川侵食が累積 この記事のデモをすべて見る


