css 그림자 효과 9가지 — 광원 위치에서 그림자를 계산하는 장면
css 그림자 효과를 box-shadow 한 줄로 끝내면 광원이 돌아도 그림자는 제자리고, 벽 뒤까지 빛이 새고, 두 빛이 만나도 그림자는 하나다 — 01번·05번·08번이 계산으로 푼다.
그림자 아홉 장면을 화면에서 바로 본다
01 shcast-point-light-planar-projection
02 shcast-pointer-light-pillar-row
03 shcast-height-penumbra-blur
04 shcast-sun-arc-sundial
05 shcast-wall-visibility-polygon
06 shcast-elevation-token-card-stack
07 shcast-contact-occlusion-darken
08 shcast-two-colored-lights-overlap
09 shcast-desk-lamp-dusk-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 광원이 돌 때 바닥에 투영되는 그림자
- 02 포인터를 광원으로 삼는 기둥 줄
- 03 높이에 따라 흐려지는 그림자
- 04 가상 해가 호를 그리는 해시계
- 05 벽에 가려지는 빛의 영역
- 06 층 높이에서 계산되는 카드 그림자 값
- 07 물체가 내려앉을수록 짙어지는 접촉 그림자
- 08 두 색 광원이 겹쳐 만드는 이중 그림자
- 09 해가 지고 램프가 켜지는 책상 히어로
css 그림자 효과 9가지 — 광원에서 계산해야 그림자가 제자리에 있지 않는다
아홉 장면은 모두 480×300(W·H) 캔버스 한 장에 그리고, 입력은 광원 목표점 하나다. 장면은 목표점을 LIGHT_LERP 비율로 따라가며 매 프레임 그림자를 처음부터 다시 계산하고, 시간은 16밀리초(FIXED_DT) 단위로 끊어 쓴다. 캔버스를 누르기 전에는 가상 경로가 광원을 몰고, 누른 뒤에는 포인터와 방향키가 광원을 움직인다.
css 박스 그림자 한 줄은 광원이 어디 있는지 모르는 값이다. box-shadow: 8px 12px 20px 의 오프셋은 이미 정해진 숫자라서 광원이 돌아도 따라 돌지 않는다. 아홉 장면은 이 숫자를 손으로 쓰지 않고 광원 위치와 높이에서 꺼내게 바꿨다.
계산의 뼈대는 한 줄이다. 광원 (lx, ly) 와 높이 lh, 물체의 점 (x, y) 와 높이 h 가 있으면 바닥 그림자는 광원 + (점 − 광원) × lh ÷ (lh − h) 에 떨어진다. 점이 높을수록 분모가 줄어 광원 반대쪽으로 더 멀리 밀리고, 분모는 최소 8 로 묶어 0 으로 나누는 일을 막는다. 01·02·08·09 는 이 식을 상자 윗면 네 점에 적용해 볼록 껍질로 묶고, 03·04·06·07 은 같은 생각을 높이에서 번짐·길이·농도를 뽑는 식으로 바꿨으며, 05 는 투영 대신 광선으로 빛을 가린다.
글자 위에 값을 겹쳐 입체를 만드는 쪽은 css 텍스트 그림자 효과 9가지 — 층 값이 입체를 만든다가 다루고, 이 편은 그림자 모양 자체를 광원 위치에서 계산하는 데 집중한다. 같은 입력이면 같은 그림이 나오고, Date·fetch·저장소·WebGL·라이브러리는 아홉 파일 어디에도 없다.
기법별 코드로 보는 그림자
아래 조각은 각 장면 index.html 의 이어진 줄을 잘라 붙인 것이고, 처음부터 끝까지 이어진 파일은 압축 파일 쪽에서 연다. 장면마다 makeScene 이 다르고 입력·시간·버튼을 맡는 main 은 아홉 파일이 같은 뼈대를 쓴다.
01광원이 돌 때 바닥에 투영되는 그림자
castPoint 가 광원 높이 220 과 상자 높이 70 에서 배율 220÷(220−70) = 1.47 을 구해 윗면 네 꼭짓점을 광원 반대편 바닥으로 밀어 내고, boxShadow 가 바닥 네 점과 밀려난 네 점을 볼록 껍질로 묶어 한 덩어리로 칠한다. 광원이 타원 궤도(ORBIT_RADIUS 110)를 한 바퀴 돌면 그림자는 늘 반대쪽에 서서 같이 돈다. 측정은 누적면적 63.4%·평균 강도 40.4·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="광원의 위치와 물체 높이로 꼭짓점마다 바닥 좌표를 계산해, 광원이 돌면 그림자가 반대쪽으로 돌아간다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">광원의 위치와 물체 높이로 꼭짓점마다 바닥 좌표를 계산해, 광원이 돌면 그림자가 반대쪽으로 돌아간다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_HEIGHT = 220;
const ORBIT_RADIUS = 110;
const LIGHT_LERP = 0.2;
const BOX_HEIGHT = 70;
const BOX_RECT = [212, 140, 268, 178];
const CENTER_X = 240;
const CENTER_Y = 150;
const FLOOR_RGB = [243, 238, 250];
const SHADOW_RGB = [74, 56, 120];
const WARM_RGB = [255, 226, 150];
const LILAC_TOP = [196, 172, 255];
const LILAC_FRONT = [150, 120, 232];
function makeScene(ctx) {
let lx = CENTER_X + ORBIT_RADIUS;
let ly = CENTER_Y;
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx, ty) { lx = tx; ly = ty; },
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 300, WARM_RGB, 0.95);
const poly = boxShadow(lx, ly, LIGHT_HEIGHT, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT);
fillShadow(ctx, poly, SHADOW_RGB, 0.46);
drawBlock(ctx, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT, LILAC_TOP, LILAC_FRONT);
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastPointLightPlanarProjection.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_HEIGHT = 220;
const ORBIT_RADIUS = 110;
const LIGHT_LERP = 0.2;
const BOX_HEIGHT = 70;
const BOX_RECT = [212, 140, 268, 178];
const CENTER_X = 240;
const CENTER_Y = 150;
const FLOOR_RGB = [243, 238, 250];
const SHADOW_RGB = [74, 56, 120];
const WARM_RGB = [255, 226, 150];
const LILAC_TOP = [196, 172, 255];
const LILAC_FRONT = [150, 120, 232];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = CENTER_X + ORBIT_RADIUS;
let ly = CENTER_Y;
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { lx = tx; ly = ty; },
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 300, WARM_RGB, 0.95);
const poly = boxShadow(lx, ly, LIGHT_HEIGHT, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT);
fillShadow(ctx, poly, SHADOW_RGB, 0.46);
drawBlock(ctx, BOX_RECT[0], BOX_RECT[1], BOX_RECT[2], BOX_RECT[3], BOX_HEIGHT, LILAC_TOP, LILAC_FRONT);
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastPointLightPlanarProjection() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="광원의 위치와 물체 높이로 꼭짓점마다 바닥 좌표를 계산해, 광원이 돌면 그림자가 반대쪽으로 돌아간다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>광원의 위치와 물체 높이로 꼭짓점마다 바닥 좌표를 계산해, 광원이 돌면 그림자가 반대쪽으로 돌아간다.</p>
</div>
</main>
);
}01 · 광원이 돌 때 바닥에 투영되는 그림자 — 측정 누적면적 63.387% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [CENTER_X + ORBIT_RADIUS * Math.cos(a), CENTER_Y + ORBIT_RADIUS * 0.72 * Math.sin(a)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
02포인터를 광원으로 삼는 기둥 줄
광원 하나가 LIGHT_LERP 0.12 비율로 목표점을 따라가고, 기둥 여섯 개는 시드 444 로 정한 높이 67·101·88·59·64·74 에 맞춰 그림자를 각자 다시 투영한다. 동작 줄임이 꺼져 있으면 시연 중에는 가상 경로로 움직이고 캔버스를 누르면 그때부터 포인터를 따라간다(동작 줄임이 켜져 있으면 누르지 않아도 포인터 이동만으로 광원이 따라온다). 측정은 누적면적 35.0%·평균 강도 44.0·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="포인터 위치로 다가가는 광원 하나가 기둥 여럿의 그림자 방향과 길이를 각각 다시 계산한다. 화면을 누르면 직접 움직인다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">포인터 위치로 다가가는 광원 하나가 기둥 여럿의 그림자 방향과 길이를 각각 다시 계산한다. 화면을 누르면 직접 움직인다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_LERP = 0.12;
const LIGHT_HEIGHT = 200;
const PILLAR_COUNT = 6;
const PILLAR_WIDTH = 22;
const PILLAR_DEPTH = 14;
const FLOOR_TOP = 118;
const BASE_Y = 214;
const SEED = 444;
const WALL_RGB = [226, 214, 250];
const FLOOR_RGB = [250, 240, 226];
const SHADOW_RGB = [80, 58, 120];
const WARM_RGB = [255, 214, 140];
const PILLAR_TOPS = [[255, 214, 204], [190, 226, 255], [214, 196, 255], [180, 240, 216], [255, 232, 160], [255, 200, 224]];
const PILLAR_FRONTS = [[236, 160, 146], [120, 180, 238], [170, 144, 236], [120, 214, 178], [236, 194, 100], [236, 150, 190]];
function makeScene(ctx) {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) {
heights.push(58 + Math.floor(rnd() * 44));
}
function pillarX(i) { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 190 * Math.sin(a), 212 + 70 * Math.sin(2 * a + 1)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx, ty) { lx = tx; ly = ty; },
draw: function () {
const fy = clamp(ly, FLOOR_TOP + 24, H - 12);
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, FLOOR_TOP);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.save();
ctx.beginPath();
ctx.rect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.clip();
glow(ctx, lx, fy, 280, WARM_RGB, 0.9);
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
ctx.restore();
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
drawBlock(ctx, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i], PILLAR_TOPS[i], PILLAR_FRONTS[i]);
}
glow(ctx, lx, fy, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, fy, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastPointerLightPillarRow.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_LERP = 0.12;
const LIGHT_HEIGHT = 200;
const PILLAR_COUNT = 6;
const PILLAR_WIDTH = 22;
const PILLAR_DEPTH = 14;
const FLOOR_TOP = 118;
const BASE_Y = 214;
const SEED = 444;
const WALL_RGB = [226, 214, 250];
const FLOOR_RGB = [250, 240, 226];
const SHADOW_RGB = [80, 58, 120];
const WARM_RGB = [255, 214, 140];
const PILLAR_TOPS = [[255, 214, 204], [190, 226, 255], [214, 196, 255], [180, 240, 216], [255, 232, 160], [255, 200, 224]];
const PILLAR_FRONTS = [[236, 160, 146], [120, 180, 238], [170, 144, 236], [120, 214, 178], [236, 194, 100], [236, 150, 190]];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights: number[] = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) { heights.push(58 + Math.floor(rnd() * 44)); }
function pillarX(i: number): number { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 190 * Math.sin(a), 212 + 70 * Math.sin(2 * a + 1)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { lx = tx; ly = ty; },
draw: function (): void {
const fy = clamp(ly, FLOOR_TOP + 24, H - 12);
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, FLOOR_TOP);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.save();
ctx.beginPath();
ctx.rect(0, FLOOR_TOP, W, H - FLOOR_TOP);
ctx.clip();
glow(ctx, lx, fy, 280, WARM_RGB, 0.9);
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
ctx.restore();
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
drawBlock(ctx, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i], PILLAR_TOPS[i], PILLAR_FRONTS[i]);
}
glow(ctx, lx, fy, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, fy, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastPointerLightPillarRow() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="포인터 위치로 다가가는 광원 하나가 기둥 여럿의 그림자 방향과 길이를 각각 다시 계산한다. 화면을 누르면 직접 움직인다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>포인터 위치로 다가가는 광원 하나가 기둥 여럿의 그림자 방향과 길이를 각각 다시 계산한다. 화면을 누르면 직접 움직인다.</p>
</div>
</main>
);
}02 · 포인터를 광원으로 삼는 기둥 줄 — 측정 누적면적 34.99% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function makeScene(ctx) {
let lx = 240;
let ly = 200;
const rnd = mulberry32(SEED);
const heights = [];
for (let i = 0; i < PILLAR_COUNT; i += 1) {
heights.push(58 + Math.floor(rnd() * 44));
}
function pillarX(i) { return 58 + i * (364 / (PILLAR_COUNT - 1)); }
for (let i = 0; i < PILLAR_COUNT; i += 1) {
const x = pillarX(i);
const poly = boxShadow(lx, fy, LIGHT_HEIGHT, x - PILLAR_WIDTH / 2, BASE_Y - PILLAR_DEPTH / 2, x + PILLAR_WIDTH / 2, BASE_Y + PILLAR_DEPTH / 2, heights[i]);
fillShadow(ctx, poly, SHADOW_RGB, 0.44);
}
03높이에 따라 흐려지는 그림자
판을 64개 띠로 쪼개 띠마다 바닥에서 뜬 높이를 구하고, 번짐은 2 + 높이×BLUR_PER_HEIGHT, 농도는 CONTACT_ALPHA÷(1 + 높이×0.03) 으로 계산해 띠마다 세로 그라데이션을 칠한다. 닿은 끝 띠(높이 0.86)는 번짐 2.17px·농도 0.604 이고 뜬 끝 띠(높이 109.14)는 번짐 23.83px·농도 0.145 라서 한 판 안에서 선명함이 갈린다. 측정은 누적면적 32.8%·평균 강도 44.9·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="한쪽 끝만 뜬 판에서 바닥 높이에 비례해 그림자의 흐림과 농도를 띠마다 계산해, 맞닿은 곳과 떨어진 곳이 다르게 보인다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">한쪽 끝만 뜬 판에서 바닥 높이에 비례해 그림자의 흐림과 농도를 띠마다 계산해, 맞닿은 곳과 떨어진 곳이 다르게 보인다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const BLUR_PER_HEIGHT = 0.2;
const CONTACT_ALPHA = 0.62;
const LIFT_MAX = 110;
const LIGHT_LERP = 0.16;
const PLANK_LEFT = 80;
const PLANK_LEN = 320;
const PLANK_THICK = 14;
const STRIPS = 64;
const BASE_Y = 168;
const WALL_RGB = [255, 244, 214];
const FLOOR_RGB = [236, 228, 250];
const SHADOW_RGB = [70, 54, 116];
const PLANK_TOP = [150, 206, 255];
const PLANK_EDGE = [96, 160, 226];
function makeScene(ctx) {
let px = 240;
let py = 160;
function lift() { return LIFT_MAX * clamp((py - 30) / 240, 0, 1); }
// 가로 위치 u (0 은 닿은 쪽, 1 은 뜬 쪽) 에서 판의 높이
function heightAt(u, total) { return total * u; }
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 170 * Math.sin(a * 2), 150 + 130 * Math.sin(a - 1.2)];
},
pos: function () { return [px, py]; },
update: function (tx, ty) {
px = lerp(px, tx, LIGHT_LERP);
py = lerp(py, ty, LIGHT_LERP);
},
snap: function (tx, ty) { px = tx; py = ty; },
draw: function () {
const total = lift();
const slide = (px - 240) / 240;
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, BASE_Y - 20);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, BASE_Y - 20, W, H - BASE_Y + 20);
// 그림자 띠 — 띠마다 높이로 번짐과 농도를 따로 계산한다
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
// 판 — 왼쪽 끝은 바닥에 닿고 오른쪽 끝만 들린다
ctx.beginPath();
for (let i = 0; i <= STRIPS; i += 1) {
const hh = heightAt(i / STRIPS, total);
const x = PLANK_LEFT + (PLANK_LEN * i) / STRIPS;
const y = BASE_Y - hh * 0.7 - PLANK_THICK;
if (i === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
for (let i = STRIPS; i >= 0; i -= 1) {
const hh = heightAt(i / STRIPS, total);
ctx.lineTo(PLANK_LEFT + (PLANK_LEN * i) / STRIPS, BASE_Y - hh * 0.7);
}
ctx.closePath();
ctx.fillStyle = rgba(PLANK_TOP, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PLANK_EDGE, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(PLANK_LEFT, BASE_Y - PLANK_THICK / 2, 5, 0, PI2);
ctx.fillStyle = rgba(PLANK_EDGE, 1);
ctx.fill();
// 광원 표시 — 가로 위치가 그림자가 쏠리는 쪽을 정한다
glow(ctx, px, 30, 46, [255, 220, 120], 0.9);
ctx.beginPath();
ctx.arc(px, 30, 8, 0, PI2);
ctx.fillStyle = 'rgba(255,246,208,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastHeightPenumbraBlur.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const BLUR_PER_HEIGHT = 0.2;
const CONTACT_ALPHA = 0.62;
const LIFT_MAX = 110;
const LIGHT_LERP = 0.16;
const PLANK_LEFT = 80;
const PLANK_LEN = 320;
const PLANK_THICK = 14;
const STRIPS = 64;
const BASE_Y = 168;
const WALL_RGB = [255, 244, 214];
const FLOOR_RGB = [236, 228, 250];
const SHADOW_RGB = [70, 54, 116];
const PLANK_TOP = [150, 206, 255];
const PLANK_EDGE = [96, 160, 226];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let px = 240;
let py = 160;
function lift(): number { return LIFT_MAX * clamp((py - 30) / 240, 0, 1); }
// 가로 위치 u (0 은 닿은 쪽, 1 은 뜬 쪽) 에서 판의 높이
function heightAt(u: number, total: number): number { return total * u; }
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 170 * Math.sin(a * 2), 150 + 130 * Math.sin(a - 1.2)];
},
pos: function (): Pt { return [px, py]; },
update: function (tx: number, ty: number): void {
px = lerp(px, tx, LIGHT_LERP);
py = lerp(py, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { px = tx; py = ty; },
draw: function (): void {
const total = lift();
const slide = (px - 240) / 240;
ctx.fillStyle = rgba(WALL_RGB, 1);
ctx.fillRect(0, 0, W, BASE_Y - 20);
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, BASE_Y - 20, W, H - BASE_Y + 20);
// 그림자 띠 — 띠마다 높이로 번짐과 농도를 따로 계산한다
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
// 판 — 왼쪽 끝은 바닥에 닿고 오른쪽 끝만 들린다
ctx.beginPath();
for (let i = 0; i <= STRIPS; i += 1) {
const hh = heightAt(i / STRIPS, total);
const x = PLANK_LEFT + (PLANK_LEN * i) / STRIPS;
const y = BASE_Y - hh * 0.7 - PLANK_THICK;
if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
for (let i = STRIPS; i >= 0; i -= 1) {
const hh = heightAt(i / STRIPS, total);
ctx.lineTo(PLANK_LEFT + (PLANK_LEN * i) / STRIPS, BASE_Y - hh * 0.7);
}
ctx.closePath();
ctx.fillStyle = rgba(PLANK_TOP, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PLANK_EDGE, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(PLANK_LEFT, BASE_Y - PLANK_THICK / 2, 5, 0, PI2);
ctx.fillStyle = rgba(PLANK_EDGE, 1);
ctx.fill();
// 광원 표시 — 가로 위치가 그림자가 쏠리는 쪽을 정한다
glow(ctx, px, 30, 46, [255, 220, 120], 0.9);
ctx.beginPath();
ctx.arc(px, 30, 8, 0, PI2);
ctx.fillStyle = 'rgba(255,246,208,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastHeightPenumbraBlur() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="한쪽 끝만 뜬 판에서 바닥 높이에 비례해 그림자의 흐림과 농도를 띠마다 계산해, 맞닿은 곳과 떨어진 곳이 다르게 보인다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>한쪽 끝만 뜬 판에서 바닥 높이에 비례해 그림자의 흐림과 농도를 띠마다 계산해, 맞닿은 곳과 떨어진 곳이 다르게 보인다.</p>
</div>
</main>
);
}03 · 높이에 따라 흐려지는 그림자 — 측정 누적면적 32.814% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let i = 0; i < STRIPS; i += 1) {
const h0 = heightAt(i / STRIPS, total);
const h1 = heightAt((i + 1) / STRIPS, total);
const hm = (h0 + h1) / 2;
const xa = Math.round(PLANK_LEFT + (PLANK_LEN * i) / STRIPS + h0 * slide * 0.9);
const xb = Math.round(PLANK_LEFT + (PLANK_LEN * (i + 1)) / STRIPS + h1 * slide * 0.9);
const blur = 2 + hm * BLUR_PER_HEIGHT;
const alpha = CONTACT_ALPHA / (1 + hm * 0.03);
const yTop = BASE_Y - 2;
const yBot = BASE_Y + 16 + hm * 0.5;
const g = ctx.createLinearGradient(0, yTop - blur, 0, yBot + blur);
const span = yBot - yTop + 2 * blur;
g.addColorStop(0, rgba(SHADOW_RGB, 0));
g.addColorStop(clamp(blur * 1.2 / span, 0, 0.45), rgba(SHADOW_RGB, alpha));
g.addColorStop(clamp(1 - blur * 1.2 / span, 0.55, 1), rgba(SHADOW_RGB, alpha * 0.85));
g.addColorStop(1, rgba(SHADOW_RGB, 0));
ctx.fillStyle = g;
ctx.fillRect(xa, yTop - blur, xb - xa, span);
}
04가상 해가 호를 그리는 해시계
해의 진행 하나에서 고도를 14° + 56°×sin(π·진행) 으로 정하고, 그림자 길이는 GNOMON_HEIGHT÷tan(고도) 를 SHADOW_LEN_MAX 140 으로 잘라 구한다. 방향은 해 각도의 반대쪽이라, 해가 160° 호를 지나는 동안 그림자가 길었다 짧아지며 반대로 돈다. 측정은 누적면적 28.5%·평균 강도 37.1·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="해의 고도각 하나에서 그림자 길이를 높이 나누기 탄젠트로 구하고, 정해진 호를 따라 방향과 길이를 바꾼다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">해의 고도각 하나에서 그림자 길이를 높이 나누기 탄젠트로 구하고, 정해진 호를 따라 방향과 길이를 바꾼다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const SUN_ARC_DEG = 160;
const SHADOW_LEN_MAX = 140;
const GNOMON_HEIGHT = 60;
const DIAL_RADIUS = 112;
const SUN_RING_RADIUS = 134;
const ELEV_LOW_DEG = 14;
const ELEV_SPAN_DEG = 56;
const LIGHT_LERP = 0.2;
const DIAL_X = 240;
const DIAL_Y = 160;
const BG_RGB = [255, 244, 222];
const PLATE_RGB = [214, 232, 255];
const INK_RGB = [74, 56, 120];
const SUN_RGB = [255, 214, 120];
function makeScene(ctx) {
let ux = 0.04;
// 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
function sunAngle(u) {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u) { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const scene = {
path: function (t) {
const c = (t / PERIOD_MS) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * (0.04 + 0.92 * smooth(tri)), DIAL_Y];
},
pos: function () { return [ux * W, DIAL_Y]; },
update: function (tx) { ux = lerp(ux, clamp(tx / W, 0, 1), LIGHT_LERP); },
snap: function (tx) { ux = clamp(tx / W, 0, 1); },
draw: function () {
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
ctx.fillStyle = rgba(BG_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 해가 지나는 호
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, SUN_RING_RADIUS, sunAngle(0), sunAngle(1));
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(214,170,70,0.75)';
ctx.stroke();
ctx.setLineDash([]);
// 해시계 판 — 해가 높을수록 밝다
ctx.save();
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.fillStyle = rgba(PLATE_RGB, 1);
ctx.fill();
ctx.clip();
const sx = DIAL_X + Math.cos(phi) * SUN_RING_RADIUS;
const sy = DIAL_Y + Math.sin(phi) * SUN_RING_RADIUS;
glow(ctx, sx, sy, 230, SUN_RGB, 0.55 + 0.4 * Math.sin(Math.PI * ux));
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(150,170,230,0.9)';
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.stroke();
// 눈금 — 해가 8 등분 지점에 있을 때 그림자 끝이 닿는 방향
for (let k = 0; k <= 8; k += 1) {
const a = sunAngle(k / 8) + Math.PI;
ctx.beginPath();
ctx.moveTo(DIAL_X + Math.cos(a) * 92, DIAL_Y + Math.sin(a) * 92);
ctx.lineTo(DIAL_X + Math.cos(a) * 106, DIAL_Y + Math.sin(a) * 106);
ctx.lineWidth = k % 4 === 0 ? 4 : 2;
ctx.strokeStyle = 'rgba(96,110,190,0.85)';
ctx.stroke();
}
// 그림자 — 해 반대쪽으로 길이 = 막대 높이 / tan(고도)
const da = phi + Math.PI;
const tipX = DIAL_X + Math.cos(da) * len;
const tipY = DIAL_Y + Math.sin(da) * len;
const nx = -Math.sin(da) * 6;
const ny = Math.cos(da) * 6;
ctx.beginPath();
ctx.moveTo(DIAL_X + nx, DIAL_Y + ny);
ctx.lineTo(tipX, tipY);
ctx.lineTo(DIAL_X - nx, DIAL_Y - ny);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 0.52);
ctx.fill();
ctx.beginPath();
ctx.arc(tipX, tipY, 4, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.8);
ctx.fill();
// 막대 윗부분과 해
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y - 4, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,196,176,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,120,100,0.95)';
ctx.stroke();
glow(ctx, sx, sy, 34, SUN_RGB, 0.95);
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, PI2);
ctx.fillStyle = 'rgba(255,240,186,1)';
ctx.fill();
ctx.strokeStyle = 'rgba(224,170,60,0.95)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastSunArcSundial.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const SUN_ARC_DEG = 160;
const SHADOW_LEN_MAX = 140;
const GNOMON_HEIGHT = 60;
const DIAL_RADIUS = 112;
const SUN_RING_RADIUS = 134;
const ELEV_LOW_DEG = 14;
const ELEV_SPAN_DEG = 56;
const LIGHT_LERP = 0.2;
const DIAL_X = 240;
const DIAL_Y = 160;
const BG_RGB = [255, 244, 222];
const PLATE_RGB = [214, 232, 255];
const INK_RGB = [74, 56, 120];
const SUN_RGB = [255, 214, 120];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let ux = 0.04;
// 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
function sunAngle(u: number): number {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u: number): number { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const scene: Scene = {
path: function (t: number): Pt {
const c = (t / PERIOD_MS) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * (0.04 + 0.92 * smooth(tri)), DIAL_Y];
},
pos: function (): Pt { return [ux * W, DIAL_Y]; },
update: function (tx: number): void { ux = lerp(ux, clamp(tx / W, 0, 1), LIGHT_LERP); },
snap: function (tx: number): void { ux = clamp(tx / W, 0, 1); },
draw: function (): void {
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
ctx.fillStyle = rgba(BG_RGB, 1);
ctx.fillRect(0, 0, W, H);
// 해가 지나는 호
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, SUN_RING_RADIUS, sunAngle(0), sunAngle(1));
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.strokeStyle = 'rgba(214,170,70,0.75)';
ctx.stroke();
ctx.setLineDash([]);
// 해시계 판 — 해가 높을수록 밝다
ctx.save();
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.fillStyle = rgba(PLATE_RGB, 1);
ctx.fill();
ctx.clip();
const sx = DIAL_X + Math.cos(phi) * SUN_RING_RADIUS;
const sy = DIAL_Y + Math.sin(phi) * SUN_RING_RADIUS;
glow(ctx, sx, sy, 230, SUN_RGB, 0.55 + 0.4 * Math.sin(Math.PI * ux));
ctx.restore();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(150,170,230,0.9)';
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y, DIAL_RADIUS, 0, PI2);
ctx.stroke();
// 눈금 — 해가 8 등분 지점에 있을 때 그림자 끝이 닿는 방향
for (let k = 0; k <= 8; k += 1) {
const a = sunAngle(k / 8) + Math.PI;
ctx.beginPath();
ctx.moveTo(DIAL_X + Math.cos(a) * 92, DIAL_Y + Math.sin(a) * 92);
ctx.lineTo(DIAL_X + Math.cos(a) * 106, DIAL_Y + Math.sin(a) * 106);
ctx.lineWidth = k % 4 === 0 ? 4 : 2;
ctx.strokeStyle = 'rgba(96,110,190,0.85)';
ctx.stroke();
}
// 그림자 — 해 반대쪽으로 길이 = 막대 높이 / tan(고도)
const da = phi + Math.PI;
const tipX = DIAL_X + Math.cos(da) * len;
const tipY = DIAL_Y + Math.sin(da) * len;
const nx = -Math.sin(da) * 6;
const ny = Math.cos(da) * 6;
ctx.beginPath();
ctx.moveTo(DIAL_X + nx, DIAL_Y + ny);
ctx.lineTo(tipX, tipY);
ctx.lineTo(DIAL_X - nx, DIAL_Y - ny);
ctx.closePath();
ctx.fillStyle = rgba(INK_RGB, 0.52);
ctx.fill();
ctx.beginPath();
ctx.arc(tipX, tipY, 4, 0, PI2);
ctx.fillStyle = rgba(INK_RGB, 0.8);
ctx.fill();
// 막대 윗부분과 해
ctx.beginPath();
ctx.arc(DIAL_X, DIAL_Y - 4, 7, 0, PI2);
ctx.fillStyle = 'rgba(255,196,176,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,120,100,0.95)';
ctx.stroke();
glow(ctx, sx, sy, 34, SUN_RGB, 0.95);
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, PI2);
ctx.fillStyle = 'rgba(255,240,186,1)';
ctx.fill();
ctx.strokeStyle = 'rgba(224,170,60,0.95)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastSunArcSundial() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="해의 고도각 하나에서 그림자 길이를 높이 나누기 탄젠트로 구하고, 정해진 호를 따라 방향과 길이를 바꾼다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>해의 고도각 하나에서 그림자 길이를 높이 나누기 탄젠트로 구하고, 정해진 호를 따라 방향과 길이를 바꾼다.</p>
</div>
</main>
);
}04 · 가상 해가 호를 그리는 해시계 — 측정 누적면적 28.476% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 해의 진행 u (0 은 해 뜬 직후, 1 은 지기 직전) 를 화면 각도로 바꾼다
function sunAngle(u) {
const mid = 270;
return (mid - SUN_ARC_DEG / 2 + u * SUN_ARC_DEG) * Math.PI / 180;
}
function elevation(u) { return (ELEV_LOW_DEG + ELEV_SPAN_DEG * Math.sin(Math.PI * u)) * Math.PI / 180; }
const phi = sunAngle(ux);
const e = elevation(ux);
const len = Math.min(SHADOW_LEN_MAX, GNOMON_HEIGHT / Math.tan(e));
05벽에 가려지는 빛의 영역
visibilityPolygon 이 균등 광선 RAY_COUNT 180 개와 벽 선분마다 한 끝점을 향해 −ANGLE_EPS·0·+ANGLE_EPS 로 어긋나게 쏜 광선 세 개씩을 모아 각도 순으로 세우고, castRay 가 가장 가까운 교차점을 찾아 그 점들을 이어 빛 다각형을 만든다. 이 다각형 안쪽만 clip 해 밝게 칠하니 벽 뒤는 어두운 채 남는다. 측정은 누적면적 65.6%·평균 강도 74.3·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="광원에서 쏜 광선이 벽 선분과 처음 만나는 점을 이어, 빛이 닿는 다각형만 밝게 칠한다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">광원에서 쏜 광선이 벽 선분과 처음 만나는 점을 이어, 빛이 닿는 다각형만 밝게 칠한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const RAY_COUNT = 180;
const ANGLE_EPS = 0.0004;
const LIGHT_RADIUS = 330;
const LIGHT_LERP = 0.2;
const WALL_MARGIN = 4;
const WALL_RECTS = [[60, 64, 60, 56], [188, 22, 34, 96], [300, 84, 72, 40], [104, 206, 92, 32], [318, 200, 32, 72], [214, 150, 42, 42]];
const FAR_RGB = [160, 148, 196];
const LIT_NEAR = [255, 246, 214];
const LIT_FAR = [255, 206, 160];
const WALL_TOP = [226, 214, 255];
const WALL_SIDE = [170, 150, 232];
function buildSegments() {
const out = [[0, 0, W, 0], [W, 0, W, H], [W, H, 0, H], [0, H, 0, 0]];
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
out.push([r[0], r[1], r[0] + r[2], r[1]]);
out.push([r[0] + r[2], r[1], r[0] + r[2], r[1] + r[3]]);
out.push([r[0] + r[2], r[1] + r[3], r[0], r[1] + r[3]]);
out.push([r[0], r[1] + r[3], r[0], r[1]]);
}
return out;
}
const WALL_SEGMENTS = buildSegments();
// 광선 하나가 벽 선분 가운데 가장 가까이에서 부딪히는 거리
function castRay(ox, oy, dx, dy) {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) {
continue;
}
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) {
best = t;
}
}
return best;
}
// 광원이 벽 안에 들어가면 가장 가까운 바깥 변으로 밀어낸다
function pushOut(p) {
let x = clamp(p[0], WALL_MARGIN, W - WALL_MARGIN);
let y = clamp(p[1], WALL_MARGIN, H - WALL_MARGIN);
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
const x0 = r[0] - WALL_MARGIN;
const y0 = r[1] - WALL_MARGIN;
const x1 = r[0] + r[2] + WALL_MARGIN;
const y1 = r[1] + r[3] + WALL_MARGIN;
if (x > x0 && x < x1 && y > y0 && y < y1) {
const dl = x - x0;
const dr = x1 - x;
const dt = y - y0;
const db = y1 - y;
const m = Math.min(dl, dr, dt, db);
if (m === dl) {
x = x0;
}
else if (m === dr) {
x = x1;
}
else if (m === dt) {
y = y0;
}
else {
y = y1;
}
}
}
return [x, y];
}
function visibilityPolygon(ox, oy) {
const angles = [];
for (let i = 0; i < RAY_COUNT; i += 1) {
angles.push(PI2 * i / RAY_COUNT);
}
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p, q) { return p - q; });
const out = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
function makeScene(ctx) {
let lx = 240;
let ly = 120;
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return pushOut([240 + 200 * Math.sin(a), 150 + 118 * Math.sin(a * 2 + 0.6)]);
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
const p = pushOut([lerp(lx, tx, LIGHT_LERP), lerp(ly, ty, LIGHT_LERP)]);
lx = p[0];
ly = p[1];
},
snap: function (tx, ty) {
const p = pushOut([tx, ty]);
lx = p[0];
ly = p[1];
},
draw: function () {
ctx.fillStyle = rgba(FAR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const poly = visibilityPolygon(lx, ly);
ctx.save();
polyPath(ctx, poly);
ctx.clip();
const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, LIGHT_RADIUS);
g.addColorStop(0, rgba(LIT_NEAR, 1));
g.addColorStop(1, rgba(LIT_FAR, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.restore();
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
ctx.fillStyle = rgba(WALL_SIDE, 1);
ctx.fillRect(r[0], r[1], r[2], r[3]);
ctx.fillStyle = rgba(WALL_TOP, 1);
ctx.fillRect(r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 8);
}
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,250,224,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastWallVisibilityPolygon.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
type Seg = [number, number, number, number];
const RAY_COUNT = 180;
const ANGLE_EPS = 0.0004;
const LIGHT_RADIUS = 330;
const LIGHT_LERP = 0.2;
const WALL_MARGIN = 4;
const WALL_RECTS = [[60, 64, 60, 56], [188, 22, 34, 96], [300, 84, 72, 40], [104, 206, 92, 32], [318, 200, 32, 72], [214, 150, 42, 42]];
const FAR_RGB = [160, 148, 196];
const LIT_NEAR = [255, 246, 214];
const LIT_FAR = [255, 206, 160];
const WALL_TOP = [226, 214, 255];
const WALL_SIDE = [170, 150, 232];
function buildSegments(): Seg[] {
const out: Seg[] = [[0, 0, W, 0], [W, 0, W, H], [W, H, 0, H], [0, H, 0, 0]];
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
out.push([r[0], r[1], r[0] + r[2], r[1]]);
out.push([r[0] + r[2], r[1], r[0] + r[2], r[1] + r[3]]);
out.push([r[0] + r[2], r[1] + r[3], r[0], r[1] + r[3]]);
out.push([r[0], r[1] + r[3], r[0], r[1]]);
}
return out;
}
const WALL_SEGMENTS: Seg[] = buildSegments();
// 광선 하나가 벽 선분 가운데 가장 가까이에서 부딪히는 거리
function castRay(ox: number, oy: number, dx: number, dy: number): number {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) { continue; }
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) { best = t; }
}
return best;
}
// 광원이 벽 안에 들어가면 가장 가까운 바깥 변으로 밀어낸다
function pushOut(p: Pt): Pt {
let x = clamp(p[0], WALL_MARGIN, W - WALL_MARGIN);
let y = clamp(p[1], WALL_MARGIN, H - WALL_MARGIN);
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
const x0 = r[0] - WALL_MARGIN;
const y0 = r[1] - WALL_MARGIN;
const x1 = r[0] + r[2] + WALL_MARGIN;
const y1 = r[1] + r[3] + WALL_MARGIN;
if (x > x0 && x < x1 && y > y0 && y < y1) {
const dl = x - x0;
const dr = x1 - x;
const dt = y - y0;
const db = y1 - y;
const m = Math.min(dl, dr, dt, db);
if (m === dl) { x = x0; } else if (m === dr) { x = x1; } else if (m === dt) { y = y0; } else { y = y1; }
}
}
return [x, y];
}
function visibilityPolygon(ox: number, oy: number): Pt[] {
const angles: number[] = [];
for (let i = 0; i < RAY_COUNT; i += 1) { angles.push(PI2 * i / RAY_COUNT); }
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p: number, q: number): number { return p - q; });
const out: Pt[] = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 120;
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return pushOut([240 + 200 * Math.sin(a), 150 + 118 * Math.sin(a * 2 + 0.6)]);
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
const p = pushOut([lerp(lx, tx, LIGHT_LERP), lerp(ly, ty, LIGHT_LERP)]);
lx = p[0];
ly = p[1];
},
snap: function (tx: number, ty: number): void {
const p = pushOut([tx, ty]);
lx = p[0];
ly = p[1];
},
draw: function (): void {
ctx.fillStyle = rgba(FAR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const poly = visibilityPolygon(lx, ly);
ctx.save();
polyPath(ctx, poly);
ctx.clip();
const g = ctx.createRadialGradient(lx, ly, 0, lx, ly, LIGHT_RADIUS);
g.addColorStop(0, rgba(LIT_NEAR, 1));
g.addColorStop(1, rgba(LIT_FAR, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
ctx.restore();
for (let i = 0; i < WALL_RECTS.length; i += 1) {
const r = WALL_RECTS[i];
ctx.fillStyle = rgba(WALL_SIDE, 1);
ctx.fillRect(r[0], r[1], r[2], r[3]);
ctx.fillStyle = rgba(WALL_TOP, 1);
ctx.fillRect(r[0] + 2, r[1] + 2, r[2] - 4, r[3] - 8);
}
glow(ctx, lx, ly, 26, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,250,224,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastWallVisibilityPolygon() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="광원에서 쏜 광선이 벽 선분과 처음 만나는 점을 이어, 빛이 닿는 다각형만 밝게 칠한다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>광원에서 쏜 광선이 벽 선분과 처음 만나는 점을 이어, 빛이 닿는 다각형만 밝게 칠한다.</p>
</div>
</main>
);
}05 · 벽에 가려지는 빛의 영역 — 측정 누적면적 65.603% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function castRay(ox, oy, dx, dy) {
let best = 4000;
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const ex = s[2] - s[0];
const ey = s[3] - s[1];
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) {
continue;
}
const t = ((s[0] - ox) * ey - (s[1] - oy) * ex) / den;
const u = ((s[0] - ox) * dy - (s[1] - oy) * dx) / den;
if (t > 0 && u >= 0 && u <= 1 && t < best) {
best = t;
}
}
return best;
}
function visibilityPolygon(ox, oy) {
const angles = [];
for (let i = 0; i < RAY_COUNT; i += 1) {
angles.push(PI2 * i / RAY_COUNT);
}
for (let i = 0; i < WALL_SEGMENTS.length; i += 1) {
const s = WALL_SEGMENTS[i];
const a = Math.atan2(s[1] - oy, s[0] - ox);
angles.push(a - ANGLE_EPS, a, a + ANGLE_EPS);
}
angles.sort(function (p, q) { return p - q; });
const out = [];
for (let i = 0; i < angles.length; i += 1) {
const dx = Math.cos(angles[i]);
const dy = Math.sin(angles[i]);
const d = castRay(ox, oy, dx, dy);
out.push([ox + dx * d, oy + dy * d]);
}
return out;
}
06층 높이에서 계산되는 카드 그림자 값
shadowOf 가 층 번호 하나에서 오프셋 level × ELEV_STEP × (LIGHT_HEIGHT÷180), 번짐 2 + level × ELEV_STEP × 0.9, 농도 min(0.12 + 0.045·level, 0.6) 을 뽑아 캔버스 shadowBlur 로 두 겹을 칠한다. 층 1 은 오프셋 3.33·번짐 5.6·농도 0.165, 층 5 는 16.67·20.0·0.345 이고 이 세 값은 그대로 box-shadow 의 오프셋·번짐·알파로 옮길 수 있다. 측정은 누적면적 41.6%·평균 강도 28.5·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
function shadowOf(level, dx, dy) {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
function makeScene(ctx) {
let lx = 60;
let ly = 40;
const bonus = [];
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus.push(0);
}
function cardX(i) { return 62 + i * 88; }
function nearest() {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) {
best = i;
}
}
return best;
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14);
}
},
snap: function (tx, ty) {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) {
bonus[i] = i === n ? HOVER_BONUS : 0;
}
},
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastElevationTokenCardStack.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const ELEV_STEP = 4;
const SHADOW_ALPHA_BASE = 0.12;
const CARD_COUNT = 5;
const CARD_W = 68;
const CARD_H = 124;
const CARD_Y = 98;
const HOVER_BONUS = 3;
const LIGHT_LERP = 0.16;
const LIGHT_HEIGHT = 150;
const FLOOR_RGB = [244, 238, 252];
const INK_RGB = [70, 50, 116];
const CARD_RGBS = [[255, 226, 214], [255, 240, 196], [214, 244, 226], [206, 232, 255], [232, 218, 255]];
// 층 번호 하나에서 그림자 값 하나를 계산한다 — 오프셋 · 번짐 · 농도
function shadowOf(level: number, dx: number, dy: number): { ox: number; oy: number; blur: number; alpha: number } {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 60;
let ly = 40;
const bonus: number[] = [];
for (let i = 0; i < CARD_COUNT; i += 1) { bonus.push(0); }
function cardX(i: number): number { return 62 + i * 88; }
function nearest(): number {
let best = 0;
for (let i = 1; i < CARD_COUNT; i += 1) {
if (Math.abs(cardX(i) + CARD_W / 2 - lx) < Math.abs(cardX(best) + CARD_W / 2 - lx)) { best = i; }
}
return best;
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 210 * Math.sin(a), 52 + 30 * Math.sin(a * 2)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = lerp(bonus[i], i === n ? HOVER_BONUS : 0, 0.14); }
},
snap: function (tx: number, ty: number): void {
lx = tx;
ly = ty;
const n = nearest();
for (let i = 0; i < CARD_COUNT; i += 1) { bonus[i] = i === n ? HOVER_BONUS : 0; }
},
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, lx, ly, 260, [255, 226, 160], 0.85);
for (let i = 0; i < CARD_COUNT; i += 1) {
const level = i + 1 + bonus[i];
const x = cardX(i);
const y = CARD_Y - bonus[i] * 3;
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = rgba(INK_RGB, s.alpha * 0.7);
ctx.shadowBlur = s.blur * 0.4;
ctx.shadowOffsetX = s.ox * 0.35;
ctx.shadowOffsetY = s.oy * 0.35;
ctx.fillRect(x, y, CARD_W, CARD_H);
ctx.shadowColor = 'rgba(0,0,0,0)';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = rgba(INK_RGB, 0.72);
ctx.font = '600 14px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('E' + String(i + 1), x + CARD_W / 2, y + 26);
ctx.font = '500 10px ' + FONT;
ctx.fillText(String(Math.round(s.blur)) + 'px', x + CARD_W / 2, y + 44);
}
glow(ctx, lx, ly, 24, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, PI2);
ctx.fillStyle = 'rgba(255,248,214,1)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(214,170,70,0.9)';
ctx.stroke();
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastElevationTokenCardStack() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>카드 층 번호 하나에서 오프셋·흐림·농도를 식으로 뽑아 카드마다 다른 그림자를 칠하고, 그 값을 box-shadow 에 그대로 옮겨 쓸 수 있다.</p>
</div>
</main>
);
}06 · 층 높이에서 계산되는 카드 그림자 값 — 측정 누적면적 41.603% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shadowOf(level, dx, dy) {
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const reach = level * ELEV_STEP * (LIGHT_HEIGHT / 180);
return {
ox: (dx / dist) * reach,
oy: (dy / dist) * reach + level * 0.5,
blur: 2 + level * ELEV_STEP * 0.9,
alpha: Math.min(SHADOW_ALPHA_BASE + level * 0.045, 0.6)
};
}
const s = shadowOf(level, x + CARD_W / 2 - lx, y + CARD_H / 2 - ly);
// 큰 번짐의 주변광 층과 짧은 주 그림자 층을 겹친다
ctx.shadowColor = rgba(INK_RGB, s.alpha);
ctx.shadowBlur = s.blur;
ctx.shadowOffsetX = s.ox;
ctx.shadowOffsetY = s.oy;
ctx.fillStyle = rgba(CARD_RGBS[i], 1);
ctx.fillRect(x, y, CARD_W, CARD_H);
07물체가 내려앉을수록 짙어지는 접촉 그림자
contactAlpha 는 높이 h 를 HEIGHT_MAX 96 으로 나눈 값에서 AO_STRENGTH×(1 − h÷96)² + 0.05 로 짙기를 정하고, contactRadius 는 반지름을 (AO_RADIUS + 반폭×0.4)×(1 + 1.2·h÷96) 으로 넓힌다. 반폭 26 인 구는 바닥에 닿으면 농도 0.71·반지름 36.4 이고 가장 높은 96 에서는 농도 0.05·반지름 80.1 로 풀어지며, 이 그림자는 DOM 의 box-shadow 가 아니라 캔버스에 칠한 타원이다. 측정은 누적면적 16.8%·평균 강도 41.9·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="물체와 바닥 사이 높이 하나에서 접촉 그림자의 반지름과 농도를 식으로 뽑아, 가까울수록 작고 짙게, 멀수록 넓고 옅게 칠한다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">물체와 바닥 사이 높이 하나에서 접촉 그림자의 반지름과 농도를 식으로 뽑아, 가까울수록 작고 짙게, 멀수록 넓고 옅게 칠한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
const AO_RADIUS = 26;
const AO_STRENGTH = 0.66;
const HEIGHT_MAX = 96;
const OBJECT_COUNT = 3;
const LIGHT_LERP = 0.16;
const GROUND_Y = 220;
const FLOOR_RGB = [243, 238, 250];
const INK_RGB = [66, 48, 112];
const OBJ_XS = [118, 240, 362];
const OBJ_HALF = [26, 24, 20];
const OBJ_REACH = [1, 0.72, 0.88];
const OBJ_FOLLOW = [0.12, 0.09, 0.07];
const OBJ_RGBS = [[255, 206, 190], [186, 222, 255], [200, 240, 214]];
const OBJ_EDGE = [[232, 150, 130], [120, 170, 232], [120, 208, 160]];
// 높이 h 의 물체 아래 접촉 그림자 — 가까울수록 작고 진하고, 멀수록 넓고 옅다
function contactAlpha(h) {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h, half) {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
function makeScene(ctx) {
let lx = 240;
let ly = 150;
const level = [];
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level.push(0.5);
}
function follow(k) {
const s = clamp((ly - 20) / 260, 0, 1);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level[i] = lerp(level[i], s, k * OBJ_FOLLOW[i] / 0.12);
}
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 140 * Math.sin(a), 150 + 138 * Math.sin(a * 1.0 - 1.57)];
},
pos: function () { return [lx, ly]; },
update: function (tx, ty) {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
follow(0.16);
},
snap: function (tx, ty) {
lx = tx;
ly = ty;
for (let i = 0; i < OBJECT_COUNT; i += 1) {
level[i] = clamp((ly - 20) / 260, 0, 1);
}
},
draw: function () {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const wall = ctx.createLinearGradient(0, 0, 0, GROUND_Y - 30);
wall.addColorStop(0, 'rgba(226,214,250,1)');
wall.addColorStop(1, 'rgba(255,244,226,1)');
ctx.fillStyle = wall;
ctx.fillRect(0, 0, W, GROUND_Y - 30);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
const h = HEIGHT_MAX * level[i] * OBJ_REACH[i];
const half = OBJ_HALF[i];
const x = OBJ_XS[i];
const rx = contactRadius(h, half);
const a = contactAlpha(h);
// 번진 바깥 층과 맞닿은 안쪽 핵 — 두 반지름이 높이에 따라 따로 변한다
ctx.save();
ctx.translate(x, GROUND_Y);
ctx.scale(1, 0.3);
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
g.addColorStop(0, rgba(INK_RGB, a));
g.addColorStop(0.55, rgba(INK_RGB, a * 0.45));
g.addColorStop(1, rgba(INK_RGB, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, rx, 0, PI2);
ctx.fill();
ctx.restore();
const cy = GROUND_Y - h * 0.8 - half;
if (i === 1) {
ctx.fillStyle = rgba(OBJ_RGBS[i], 1);
ctx.fillRect(x - half, cy - half, half * 2, half * 2);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(OBJ_EDGE[i], 1);
ctx.strokeRect(x - half, cy - half, half * 2, half * 2);
}
else {
const sg = ctx.createRadialGradient(x - half * 0.35, cy - half * 0.4, half * 0.1, x, cy, half);
sg.addColorStop(0, 'rgba(255,255,255,1)');
sg.addColorStop(0.35, rgba(OBJ_RGBS[i], 1));
sg.addColorStop(1, rgba(OBJ_EDGE[i], 1));
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(x, cy, half, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.7);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('h ' + String(Math.round(h)), x, GROUND_Y + 22);
}
glow(ctx, lx, 24, 30, [255, 226, 150], 0.9);
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastContactOcclusionDarken.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
const AO_RADIUS = 26;
const AO_STRENGTH = 0.66;
const HEIGHT_MAX = 96;
const OBJECT_COUNT = 3;
const LIGHT_LERP = 0.16;
const GROUND_Y = 220;
const FLOOR_RGB = [243, 238, 250];
const INK_RGB = [66, 48, 112];
const OBJ_XS = [118, 240, 362];
const OBJ_HALF = [26, 24, 20];
const OBJ_REACH = [1, 0.72, 0.88];
const OBJ_FOLLOW = [0.12, 0.09, 0.07];
const OBJ_RGBS = [[255, 206, 190], [186, 222, 255], [200, 240, 214]];
const OBJ_EDGE = [[232, 150, 130], [120, 170, 232], [120, 208, 160]];
// 높이 h 의 물체 아래 접촉 그림자 — 가까울수록 작고 진하고, 멀수록 넓고 옅다
function contactAlpha(h: number): number {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h: number, half: number): number {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let lx = 240;
let ly = 150;
const level: number[] = [];
for (let i = 0; i < OBJECT_COUNT; i += 1) { level.push(0.5); }
function follow(k: number): void {
const s = clamp((ly - 20) / 260, 0, 1);
for (let i = 0; i < OBJECT_COUNT; i += 1) { level[i] = lerp(level[i], s, k * OBJ_FOLLOW[i] / 0.12); }
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 140 * Math.sin(a), 150 + 138 * Math.sin(a * 1.0 - 1.57)];
},
pos: function (): Pt { return [lx, ly]; },
update: function (tx: number, ty: number): void {
lx = lerp(lx, tx, LIGHT_LERP);
ly = lerp(ly, ty, LIGHT_LERP);
follow(0.16);
},
snap: function (tx: number, ty: number): void {
lx = tx;
ly = ty;
for (let i = 0; i < OBJECT_COUNT; i += 1) { level[i] = clamp((ly - 20) / 260, 0, 1); }
},
draw: function (): void {
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const wall = ctx.createLinearGradient(0, 0, 0, GROUND_Y - 30);
wall.addColorStop(0, 'rgba(226,214,250,1)');
wall.addColorStop(1, 'rgba(255,244,226,1)');
ctx.fillStyle = wall;
ctx.fillRect(0, 0, W, GROUND_Y - 30);
for (let i = 0; i < OBJECT_COUNT; i += 1) {
const h = HEIGHT_MAX * level[i] * OBJ_REACH[i];
const half = OBJ_HALF[i];
const x = OBJ_XS[i];
const rx = contactRadius(h, half);
const a = contactAlpha(h);
// 번진 바깥 층과 맞닿은 안쪽 핵 — 두 반지름이 높이에 따라 따로 변한다
ctx.save();
ctx.translate(x, GROUND_Y);
ctx.scale(1, 0.3);
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
g.addColorStop(0, rgba(INK_RGB, a));
g.addColorStop(0.55, rgba(INK_RGB, a * 0.45));
g.addColorStop(1, rgba(INK_RGB, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, rx, 0, PI2);
ctx.fill();
ctx.restore();
const cy = GROUND_Y - h * 0.8 - half;
if (i === 1) {
ctx.fillStyle = rgba(OBJ_RGBS[i], 1);
ctx.fillRect(x - half, cy - half, half * 2, half * 2);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(OBJ_EDGE[i], 1);
ctx.strokeRect(x - half, cy - half, half * 2, half * 2);
} else {
const sg = ctx.createRadialGradient(x - half * 0.35, cy - half * 0.4, half * 0.1, x, cy, half);
sg.addColorStop(0, 'rgba(255,255,255,1)');
sg.addColorStop(0.35, rgba(OBJ_RGBS[i], 1));
sg.addColorStop(1, rgba(OBJ_EDGE[i], 1));
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(x, cy, half, 0, PI2);
ctx.fill();
}
ctx.fillStyle = rgba(INK_RGB, 0.7);
ctx.font = '500 10px ' + FONT;
ctx.textAlign = 'center';
ctx.fillText('h ' + String(Math.round(h)), x, GROUND_Y + 22);
}
glow(ctx, lx, 24, 30, [255, 226, 150], 0.9);
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastContactOcclusionDarken() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="물체와 바닥 사이 높이 하나에서 접촉 그림자의 반지름과 농도를 식으로 뽑아, 가까울수록 작고 짙게, 멀수록 넓고 옅게 칠한다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>물체와 바닥 사이 높이 하나에서 접촉 그림자의 반지름과 농도를 식으로 뽑아, 가까울수록 작고 짙게, 멀수록 넓고 옅게 칠한다.</p>
</div>
</main>
);
}07 · 물체가 내려앉을수록 짙어지는 접촉 그림자 — 측정 누적면적 16.829% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function contactAlpha(h) {
const near = 1 - clamp(h / HEIGHT_MAX, 0, 1);
return AO_STRENGTH * near * near + 0.05;
}
function contactRadius(h, half) {
return (AO_RADIUS + half * 0.4) * (1 + 1.2 * clamp(h / HEIGHT_MAX, 0, 1));
}
08두 색 광원이 겹쳐 만드는 이중 그림자
광원마다 오프스크린 층에 색 웅덩이를 그리고 destination-out 으로 자기 그림자 다각형만 지운 뒤 lighter 로 본 캔버스에 더한다. 그래서 한 빛만 막힌 곳은 다른 빛의 색이 남고 두 빛이 모두 막힌 곳만 바닥색 그대로 가장 어둡다. 측정은 누적면적 66.3%·평균 강도 34.1·23/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="광원 두 개가 각자 그림자를 만들고, 두 빛이 모두 막힌 곳만 가장 어둡게 계산한다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">광원 두 개가 각자 그림자를 만들고, 두 빛이 모두 막힌 곳만 가장 어둡게 계산한다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_A_HUE = 330;
const LIGHT_B_HUE = 170;
const LIGHT_GAP = 160;
const LIGHT_HEIGHT = 190;
const AXIS_SPEED = 0.0008;
const LIGHT_LERP = 0.14;
const POOL_RADIUS = 300;
const FLOOR_RGB = [58, 50, 86];
const SHADOW_FLOOR = [58, 50, 86];
const BLOCKS = [[132, 112, 168, 136, 58], [222, 170, 262, 196, 66], [330, 100, 366, 124, 54]];
const BLOCK_TOPS = [[255, 226, 214], [255, 244, 204], [214, 244, 232]];
const BLOCK_FRONTS = [[226, 160, 150], [226, 200, 120], [130, 200, 176]];
function lightColor(hue, a) { return 'hsla(' + hue + ',88%,76%,' + a.toFixed(3) + ')'; }
function makeScene(ctx) {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lctx0 = layer.getContext('2d');
if (!lctx0) {
throw new Error('canvas 2d unavailable');
}
const lctx = lctx0;
let cx = 240;
let cy = 150;
function lights(t) {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
const scene = {
path: function (t) {
const a = PI2 * t / PERIOD_MS;
return [240 + 70 * Math.sin(a), 150 + 56 * Math.sin(a * 2)];
},
pos: function () { return [cx, cy]; },
update: function (tx, ty) {
cx = lerp(cx, tx, LIGHT_LERP);
cy = lerp(cy, ty, LIGHT_LERP);
},
snap: function (tx, ty) { cx = tx; cy = ty; },
draw: function (t) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const ls = lights(t);
const hues = [LIGHT_A_HUE, LIGHT_B_HUE];
for (let i = 0; i < 2; i += 1) {
// 광원마다 빛 웅덩이에서 자기 그림자만 지운 층을 만들어 더한다
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
ctx.globalCompositeOperation = 'source-over';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], BLOCK_TOPS[b], BLOCK_FRONTS[b]);
}
for (let i = 0; i < 2; i += 1) {
glow(ctx, ls[i][0], ls[i][1], 28, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(ls[i][0], ls[i][1], 7, 0, PI2);
ctx.fillStyle = lightColor(hues[i], 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.stroke();
}
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastTwoColoredLightsOverlap.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const LIGHT_A_HUE = 330;
const LIGHT_B_HUE = 170;
const LIGHT_GAP = 160;
const LIGHT_HEIGHT = 190;
const AXIS_SPEED = 0.0008;
const LIGHT_LERP = 0.14;
const POOL_RADIUS = 300;
const FLOOR_RGB = [58, 50, 86];
const SHADOW_FLOOR = [58, 50, 86];
const BLOCKS = [[132, 112, 168, 136, 58], [222, 170, 262, 196, 66], [330, 100, 366, 124, 54]];
const BLOCK_TOPS = [[255, 226, 214], [255, 244, 204], [214, 244, 232]];
const BLOCK_FRONTS = [[226, 160, 150], [226, 200, 120], [130, 200, 176]];
function lightColor(hue: number, a: number): string { return 'hsla(' + hue + ',88%,76%,' + a.toFixed(3) + ')'; }
function makeScene(ctx: CanvasRenderingContext2D): Scene {
const layer = document.createElement('canvas');
layer.width = W;
layer.height = H;
const lctx0 = layer.getContext('2d');
if (!lctx0) { throw new Error('canvas 2d unavailable'); }
const lctx: CanvasRenderingContext2D = lctx0;
let cx = 240;
let cy = 150;
function lights(t: number): Pt[] {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
const scene: Scene = {
path: function (t: number): Pt {
const a = PI2 * t / PERIOD_MS;
return [240 + 70 * Math.sin(a), 150 + 56 * Math.sin(a * 2)];
},
pos: function (): Pt { return [cx, cy]; },
update: function (tx: number, ty: number): void {
cx = lerp(cx, tx, LIGHT_LERP);
cy = lerp(cy, ty, LIGHT_LERP);
},
snap: function (tx: number, ty: number): void { cx = tx; cy = ty; },
draw: function (t: number): void {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = rgba(FLOOR_RGB, 1);
ctx.fillRect(0, 0, W, H);
const ls = lights(t);
const hues = [LIGHT_A_HUE, LIGHT_B_HUE];
for (let i = 0; i < 2; i += 1) {
// 광원마다 빛 웅덩이에서 자기 그림자만 지운 층을 만들어 더한다
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
ctx.globalCompositeOperation = 'source-over';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], BLOCK_TOPS[b], BLOCK_FRONTS[b]);
}
for (let i = 0; i < 2; i += 1) {
glow(ctx, ls[i][0], ls[i][1], 28, [255, 255, 255], 0.9);
ctx.beginPath();
ctx.arc(ls[i][0], ls[i][1], 7, 0, PI2);
ctx.fillStyle = lightColor(hues[i], 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.stroke();
}
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastTwoColoredLightsOverlap() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="광원 두 개가 각자 그림자를 만들고, 두 빛이 모두 막힌 곳만 가장 어둡게 계산한다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>광원 두 개가 각자 그림자를 만들고, 두 빛이 모두 막힌 곳만 가장 어둡게 계산한다.</p>
</div>
</main>
);
}08 · 두 색 광원이 겹쳐 만드는 이중 그림자 — 측정 누적면적 66.294% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function lights(t) {
const a = t * AXIS_SPEED;
const dx = Math.cos(a) * LIGHT_GAP / 2;
const dy = Math.sin(a) * LIGHT_GAP / 2;
return [
[clamp(cx + dx, 10, W - 10), clamp(cy + dy, 10, H - 10)],
[clamp(cx - dx, 10, W - 10), clamp(cy - dy, 10, H - 10)]
];
}
lctx.globalCompositeOperation = 'source-over';
lctx.clearRect(0, 0, W, H);
const g = lctx.createRadialGradient(ls[i][0], ls[i][1], 0, ls[i][0], ls[i][1], POOL_RADIUS);
g.addColorStop(0, lightColor(hues[i], 0.62));
g.addColorStop(1, lightColor(hues[i], 0.05));
lctx.fillStyle = g;
lctx.fillRect(0, 0, W, H);
lctx.globalCompositeOperation = 'destination-out';
lctx.fillStyle = 'rgba(0,0,0,1)';
for (let b = 0; b < BLOCKS.length; b += 1) {
const r = BLOCKS[b];
const poly = boxShadow(ls[i][0], ls[i][1], LIGHT_HEIGHT, r[0], r[1], r[2], r[3], r[4]);
polyPath(lctx, poly);
lctx.fill();
}
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(layer, 0, 0);
}
09해가 지고 램프가 켜지는 책상 히어로
진행 하나에서 해 높이를 420 에서 70 으로 lerp 하고 램프 켜짐은 smooth((p−0.35)÷0.4), 해 농도는 1 − 0.9·smooth((p−0.55)÷0.45) 로 정해 같은 상자 세 개에 해 그림자와 램프 그림자를 따로 겹쳐 칠한다. 진행 0.75 에서 해 높이는 124.7·램프 켜짐 1·해 농도 0.625 라서 두 그림자가 함께 보이고, 진행 1 에서는 해 농도 0.1 만 남는다. 측정은 누적면적 70.1%·평균 강도 21.4·18/23 프레임이다.

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="앞의 투영·흐림·해 높이 계산을 한 장면에 모아, 해 그림자가 길어지다 사라지면 램프 그림자가 이어받는다."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">시연 중</p>
<button class="sc__btn" id="scBtn" type="button">멈춤</button>
<p class="sc__caption">앞의 투영·흐림·해 높이 계산을 한 장면에 모아, 해 그림자가 길어지다 사라지면 램프 그림자가 이어받는다.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.sc {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 62%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 68%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.sc__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sc { 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);
}
.sc {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.sc__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.sc__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.sc__status {
position: absolute;
left: 12px;
top: 12px;
max-width: 62%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.sc__btn {
position: absolute;
right: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.sc__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
.sc__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 68%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.sc__caption, .sc__btn {
font-size: 10px;
padding: 4px 8px;
}
.sc__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc {
cursor: default;
}
}(function () {
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx, x, y, r, c, a) {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx, poly) {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) {
ctx.moveTo(poly[i][0], poly[i][1]);
}
else {
ctx.lineTo(poly[i][0], poly[i][1]);
}
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx, ly, lh, x, y, h) {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src) {
const p = src.slice().sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
function cross(o, a, b) { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) {
lo.pop();
}
lo.push(p[i]);
}
const up = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) {
up.pop();
}
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx, ly, lh, x0, y0, x1, y1, h) {
const base = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts = base.slice();
for (let i = 0; i < base.length; i += 1) {
pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h));
}
return hull(pts);
}
function fillShadow(ctx, poly, ink, alpha) {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx, x0, y0, x1, y1, h, top, front) {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const DUSK_FRAMES = 125;
const LAMP_HEIGHT = 150;
const SUN_HEIGHT_NOON = 420;
const SUN_HEIGHT_DUSK = 70;
const LIGHT_LERP = 0.2;
const DUSK_MS = DUSK_FRAMES * FIXED_DT;
const DESK_DAY = [244, 226, 200];
const DESK_NIGHT = [52, 46, 92];
const INK_RGB = [66, 48, 112];
const SUN_RGB = [255, 206, 120];
const LAMP_RGB = [255, 214, 140];
const LAMP_SPOT = [404, 74];
const SUN_SPOT = [-60, 96];
const ITEMS = [[104, 168, 188, 218, 16], [236, 188, 270, 222, 40], [312, 128, 344, 160, 56]];
const ITEM_TOPS = [[255, 214, 204], [190, 226, 255], [180, 240, 216]];
const ITEM_FRONTS = [[226, 150, 140], [120, 176, 230], [110, 200, 160]];
function makeScene(ctx) {
let px = 0;
const scene = {
path: function (t) {
const c = (t / (DUSK_MS * 2)) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * smooth(tri), 150];
},
pos: function () { return [px, 150]; },
update: function (tx) { px = lerp(px, tx, LIGHT_LERP); },
snap: function (tx) { px = tx; },
draw: function () {
const p = clamp(px / W, 0, 1);
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], ITEM_TOPS[i], ITEM_FRONTS[i]);
}
// 램프 머리 — 켜지면 빛이 번진다
ctx.beginPath();
ctx.arc(LAMP_SPOT[0], LAMP_SPOT[1], 15, 0, PI2);
ctx.fillStyle = rgba([lerp(236, 255, lampOn), lerp(214, 244, lampOn), lerp(206, 186, lampOn)], 1);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(200,170,120,0.95)';
ctx.stroke();
// 해의 높이를 알려 주는 창 표시
glow(ctx, 20, SUN_SPOT[1], 36, SUN_RGB, 0.9 * sunAlpha);
ctx.fillStyle = rgba(SUN_RGB, Math.max(sunAlpha, 0.15));
ctx.fillRect(0, SUN_SPOT[1] - 22, 8, 44);
}
};
return scene;
}
function main(rootEl, canvas, statusEl, btnEl) {
const ctx0 = canvas.getContext('2d');
if (!ctx0) {
return function () { return; };
}
canvas.width = W;
canvas.height = H;
const scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
rootEl.classList.remove('is-demo');
}
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs = null;
let accMs = 0;
let tgt = scene.path(elapsed);
const cleaners = [];
function listen(target, type, fn) {
target.addEventListener(type, fn);
cleaners.push(function () { target.removeEventListener(type, fn); });
}
function setMode() {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e) {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver() {
if (reduce || !autoplay) {
return;
}
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput() {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton() {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
}
else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts) {
if (lastTs === null) {
lastTs = ts;
}
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) {
tgt = scene.path(elapsed);
}
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function () { onButton(); });
listen(canvas, 'pointerdown', function (e) {
takeOver();
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'pointermove', function (e) {
if (autoplay && !reduce) {
return;
}
tgt = toLocal(e);
afterInput();
});
listen(canvas, 'keydown', function (e) {
const k = e.key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') {
dx = -KEY_STEP;
}
else if (k === 'ArrowRight') {
dx = KEY_STEP;
}
else if (k === 'ArrowUp') {
dy = -KEY_STEP;
}
else if (k === 'ArrowDown') {
dy = KEY_STEP;
}
else {
return;
}
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
}
else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy() {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) {
cleaners[i]();
}
}
return destroy;
}
main(document.getElementById('scRoot'), document.getElementById('scCanvas'), document.getElementById('scStatus'), document.getElementById('scBtn'));
})();import { useEffect, useRef } from "react";
import styles from "./ShcastDeskLampDuskHeroScene.module.scss";
const W = 480;
const H = 300;
const PI2 = Math.PI * 2;
const FIXED_DT = 16;
const REDUCE_STEP_MS = 900;
const REDUCE_START_MS = 1000;
const PERIOD_MS = 4000;
const KEY_STEP = 12;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
type Pt = [number, number];
interface Scene {
path(t: number): Pt;
pos(): Pt;
update(tx: number, ty: number): void;
snap(tx: number, ty: number): void;
draw(t: number): void;
}
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: number[], a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + a.toFixed(3) + ')';
}
// 시드 고정 난수 — 같은 시드면 언제나 같은 수열
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function glow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, c: number[], a: number): void {
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, rgba(c, a));
g.addColorStop(1, rgba(c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
function polyPath(ctx: CanvasRenderingContext2D, poly: Pt[]): void {
ctx.beginPath();
for (let i = 0; i < poly.length; i += 1) {
if (i === 0) { ctx.moveTo(poly[i][0], poly[i][1]); } else { ctx.lineTo(poly[i][0], poly[i][1]); }
}
ctx.closePath();
}
// 광원 (lx, ly, lh) 에서 점 (x, y, h) 를 바닥 높이 0 으로 내리는 투영 — 닮음비 lh / (lh - h)
function castPoint(lx: number, ly: number, lh: number, x: number, y: number, h: number): Pt {
const k = lh / Math.max(lh - h, 8);
return [lx + (x - lx) * k, ly + (y - ly) * k];
}
// 볼록 껍질 (단조 체인) — 바닥 꼭짓점과 투영된 윗 꼭짓점을 한 덩어리 그림자로 묶는다
function hull(src: Pt[]): Pt[] {
const p = src.slice().sort(function (a: Pt, b: Pt): number { return a[0] - b[0] || a[1] - b[1]; });
function cross(o: Pt, a: Pt, b: Pt): number { return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); }
const lo: Pt[] = [];
for (let i = 0; i < p.length; i += 1) {
while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p[i]) <= 0) { lo.pop(); }
lo.push(p[i]);
}
const up: Pt[] = [];
for (let i = p.length - 1; i >= 0; i -= 1) {
while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p[i]) <= 0) { up.pop(); }
up.push(p[i]);
}
lo.pop();
up.pop();
return lo.concat(up);
}
// 직육면체 한 개의 그림자 다각형 — 바닥 네 꼭짓점과 높이 h 위 네 꼭짓점을 광원에서 투영한다
function boxShadow(lx: number, ly: number, lh: number, x0: number, y0: number, x1: number, y1: number, h: number): Pt[] {
const base: Pt[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const pts: Pt[] = base.slice();
for (let i = 0; i < base.length; i += 1) { pts.push(castPoint(lx, ly, lh, base[i][0], base[i][1], h)); }
return hull(pts);
}
function fillShadow(ctx: CanvasRenderingContext2D, poly: Pt[], ink: number[], alpha: number): void {
polyPath(ctx, poly);
ctx.fillStyle = rgba(ink, alpha);
ctx.fill();
ctx.lineJoin = 'round';
ctx.lineWidth = 7;
ctx.strokeStyle = rgba(ink, alpha * 0.32);
ctx.stroke();
}
// 비스듬히 본 직육면체 — 윗면은 높이의 0.6 만큼 위로 밀고 앞면을 아래에 붙인다
function drawBlock(ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, h: number, top: number[], front: number[]): void {
const vis = h * 0.6;
ctx.fillStyle = rgba(front, 1);
ctx.fillRect(x0, y1 - vis, x1 - x0, vis);
ctx.fillStyle = rgba(top, 1);
ctx.fillRect(x0, y0 - vis, x1 - x0, y1 - y0);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(x0, y0 - vis, x1 - x0, 2);
}
const DUSK_FRAMES = 125;
const LAMP_HEIGHT = 150;
const SUN_HEIGHT_NOON = 420;
const SUN_HEIGHT_DUSK = 70;
const LIGHT_LERP = 0.2;
const DUSK_MS = DUSK_FRAMES * FIXED_DT;
const DESK_DAY = [244, 226, 200];
const DESK_NIGHT = [52, 46, 92];
const INK_RGB = [66, 48, 112];
const SUN_RGB = [255, 206, 120];
const LAMP_RGB = [255, 214, 140];
const LAMP_SPOT = [404, 74];
const SUN_SPOT = [-60, 96];
const ITEMS = [[104, 168, 188, 218, 16], [236, 188, 270, 222, 40], [312, 128, 344, 160, 56]];
const ITEM_TOPS = [[255, 214, 204], [190, 226, 255], [180, 240, 216]];
const ITEM_FRONTS = [[226, 150, 140], [120, 176, 230], [110, 200, 160]];
function makeScene(ctx: CanvasRenderingContext2D): Scene {
let px = 0;
const scene: Scene = {
path: function (t: number): Pt {
const c = (t / (DUSK_MS * 2)) % 1;
const tri = c < 0.5 ? c * 2 : 2 - c * 2;
return [W * smooth(tri), 150];
},
pos: function (): Pt { return [px, 150]; },
update: function (tx: number): void { px = lerp(px, tx, LIGHT_LERP); },
snap: function (tx: number): void { px = tx; },
draw: function (): void {
const p = clamp(px / W, 0, 1);
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
drawBlock(ctx, r[0], r[1], r[2], r[3], r[4], ITEM_TOPS[i], ITEM_FRONTS[i]);
}
// 램프 머리 — 켜지면 빛이 번진다
ctx.beginPath();
ctx.arc(LAMP_SPOT[0], LAMP_SPOT[1], 15, 0, PI2);
ctx.fillStyle = rgba([lerp(236, 255, lampOn), lerp(214, 244, lampOn), lerp(206, 186, lampOn)], 1);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(200,170,120,0.95)';
ctx.stroke();
// 해의 높이를 알려 주는 창 표시
glow(ctx, 20, SUN_SPOT[1], 36, SUN_RGB, 0.9 * sunAlpha);
ctx.fillStyle = rgba(SUN_RGB, Math.max(sunAlpha, 0.15));
ctx.fillRect(0, SUN_SPOT[1] - 22, 8, 44);
}
};
return scene;
}
function main(rootEl: HTMLElement, canvas: HTMLCanvasElement, statusEl: HTMLElement, btnEl: HTMLButtonElement): () => void {
const ctx0 = canvas.getContext('2d');
if (!ctx0) { return function (): void { return; }; }
canvas.width = W;
canvas.height = H;
const scene: Scene = makeScene(ctx0);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { rootEl.classList.remove('is-demo'); }
let paused = false;
let autoplay = !reduce;
let elapsed = reduce ? REDUCE_START_MS : 0;
let rafId = 0;
let lastTs: number | null = null;
let accMs = 0;
let tgt: Pt = scene.path(elapsed);
const cleaners: Array<() => void> = [];
function listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
target.addEventListener(type, fn);
cleaners.push(function (): void { target.removeEventListener(type, fn); });
}
function setMode(): void {
if (reduce) {
statusEl.textContent = '한 장면씩 넘기는 중';
btnEl.textContent = '다음 장면';
return;
}
statusEl.textContent = !autoplay ? '직접 조작 중' : (paused ? '일시정지됨' : '시연 중');
btnEl.textContent = (autoplay && !paused) ? '멈춤' : '재생';
}
function toLocal(e: PointerEvent): Pt {
const r = canvas.getBoundingClientRect();
return [clamp((e.clientX - r.left) * W / r.width, 0, W), clamp((e.clientY - r.top) * H / r.height, 0, H)];
}
function takeOver(): void {
if (reduce || !autoplay) { return; }
autoplay = false;
paused = false;
rootEl.classList.remove('is-demo');
const p = scene.pos();
tgt = [p[0], p[1]];
setMode();
}
function afterInput(): void {
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
}
}
function onButton(): void {
if (reduce) {
elapsed += REDUCE_STEP_MS;
tgt = scene.path(elapsed);
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
return;
}
if (autoplay && !paused) {
paused = true;
} else {
paused = false;
autoplay = true;
rootEl.classList.add('is-demo');
}
setMode();
}
function tick(ts: number): void {
if (lastTs === null) { lastTs = ts; }
const frameDt = Math.min(ts - lastTs, 160);
lastTs = ts;
if (!paused) {
accMs += frameDt;
while (accMs >= FIXED_DT) {
elapsed += FIXED_DT;
accMs -= FIXED_DT;
if (autoplay) { tgt = scene.path(elapsed); }
scene.update(tgt[0], tgt[1]);
}
scene.draw(elapsed);
}
rafId = requestAnimationFrame(tick);
}
listen(btnEl, 'click', function (): void { onButton(); });
listen(canvas, 'pointerdown', function (e: Event): void {
takeOver();
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'pointermove', function (e: Event): void {
if (autoplay && !reduce) { return; }
tgt = toLocal(e as PointerEvent);
afterInput();
});
listen(canvas, 'keydown', function (e: Event): void {
const k = (e as KeyboardEvent).key;
if (k === 'Enter' || k === ' ') {
e.preventDefault();
onButton();
return;
}
let dx = 0;
let dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; } else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; } else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
takeOver();
tgt = [clamp(tgt[0] + dx, 0, W), clamp(tgt[1] + dy, 0, H)];
afterInput();
});
if (reduce) {
scene.snap(tgt[0], tgt[1]);
scene.draw(elapsed);
setMode();
} else {
scene.draw(elapsed);
setMode();
rafId = requestAnimationFrame(tick);
}
function destroy(): void {
cancelAnimationFrame(rafId);
for (let i = 0; i < cleaners.length; i += 1) { cleaners[i](); }
}
return destroy;
}
export default function ShcastDeskLampDuskHeroScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const statusEl = statusRef.current;
const btnEl = btnRef.current;
if (!root || !canvas || !statusEl || !btnEl) return;
return main(root, canvas, statusEl, btnEl);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sc} is-demo`} ref={rootRef}>
<canvas className={styles.sc__canvas} ref={canvasRef} width={480} height={300} tabIndex={0} aria-label="앞의 투영·흐림·해 높이 계산을 한 장면에 모아, 해 그림자가 길어지다 사라지면 램프 그림자가 이어받는다." />
<p className={styles.sc__status} ref={statusRef} aria-live="polite">시연 중</p>
<button className={styles.sc__btn} ref={btnRef} type="button">멈춤</button>
<p className={styles.sc__caption}>앞의 투영·흐림·해 높이 계산을 한 장면에 모아, 해 그림자가 길어지다 사라지면 램프 그림자가 이어받는다.</p>
</div>
</main>
);
}09 · 해가 지고 램프가 켜지는 책상 히어로 — 측정 누적면적 70.11% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const night = smooth(p);
const lampOn = smooth((p - 0.35) / 0.4);
const sunAlpha = 1 - 0.9 * smooth((p - 0.55) / 0.45);
const desk = [lerp(DESK_DAY[0], DESK_NIGHT[0], night), lerp(DESK_DAY[1], DESK_NIGHT[1], night), lerp(DESK_DAY[2], DESK_NIGHT[2], night)];
ctx.fillStyle = rgba(desk, 1);
ctx.fillRect(0, 0, W, H);
glow(ctx, SUN_SPOT[0] + 20, SUN_SPOT[1], 380, SUN_RGB, 0.7 * sunAlpha);
glow(ctx, LAMP_SPOT[0], LAMP_SPOT[1], 280, LAMP_RGB, 0.9 * lampOn);
const sunH = lerp(SUN_HEIGHT_NOON, SUN_HEIGHT_DUSK, smooth(p));
for (let i = 0; i < ITEMS.length; i += 1) {
const r = ITEMS[i];
fillShadow(ctx, boxShadow(SUN_SPOT[0], SUN_SPOT[1], sunH, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.4 * sunAlpha);
fillShadow(ctx, boxShadow(LAMP_SPOT[0], LAMP_SPOT[1], LAMP_HEIGHT, r[0], r[1], r[2], r[3], r[4]), INK_RGB, 0.5 * lampOn);
}
값이 장면을 바꾸는 곳 — 광원 높이·광선 수·두 광원 간격
손대면 장면이 달라지는 값은 셋이다. 01 의 광원 높이 LIGHT_HEIGHT, 05 의 광선 수 RAY_COUNT, 08 의 두 광원 간격 LIGHT_GAP 이다. 흐릿함은 03 의 BLUR_PER_HEIGHT 가 정하고, css 그림자 투명도에 해당하는 농도는 06 의 SHADOW_ALPHA_BASE 와 ELEV_STEP 이 정한다.
광원 높이를 낮추면 그림자가 길게 늘어난다
LIGHT_HEIGHT 220 에서 상자 윗면(높이 70)의 배율은 220÷150 = 1.47 이다. 120 으로 낮추면 120÷50 = 2.4 가 되어 그림자가 훨씬 길게 뻗고, 400 으로 올리면 400÷330 = 1.21 로 상자 바로 옆에 붙는다.
광선을 두 배로 늘려도 빛의 모양은 같다
05 의 RAY_COUNT 를 180 에서 360 으로 늘리면 다각형 꼭짓점이 264개(180 + 3×28)에서 444개로 늘지만 빛이 닿는 모양은 그대로다. 모양을 정하는 것은 벽 선분 방향으로 쏜 광선이고 균등 광선은 곧은 변을 쪼갤 뿐이라, 늘어나는 것은 광선×선분 교차 계산 비용뿐이다.
두 광원 간격을 벌리면 겹침이 줄어든다
LIGHT_GAP 160 을 260 으로 벌리면 상자마다 색상 330 과 170 의 두 빛이 만드는 그림자가 갈라지는 폭이 커지고, 두 빛이 모두 막혀 가장 어두운 겹침 영역은 줄어든다. 두 광원은 중점을 기준으로 AXIS_SPEED 0.0008 라디안/ms 로 도는 축 위에 놓여, 간격은 그 축을 따라 벌어진다.
아홉 장면을 렌더해서 잰 값은 아래 표에 모았다. 04 와 07 을 뺀 일곱 장면이 누적면적 30% 를 넘고 01·05·08·09 는 60% 를 넘으며, 접촉 그림자 하나뿐인 07 이 16.8% 로 가장 작아도 10% 아래로 내려간 장면은 없다. 움직인 프레임은 09 만 18/23 이고 나머지는 23/23 이라, 시연 동안 화면이 거의 쉬지 않고 바뀐다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 광원 궤도 투영 | 63.4% | 40.4 | 23/23 |
| 02 포인터 기둥 줄 | 35.0% | 44.0 | 23/23 |
| 03 높이별 번짐 | 32.8% | 44.9 | 23/23 |
| 04 해시계 | 28.5% | 37.1 | 23/23 |
| 05 벽 가림 다각형 | 65.6% | 74.3 | 23/23 |
| 06 층 높이 카드 | 41.6% | 28.5 | 23/23 |
| 07 접촉 그림자 | 16.8% | 41.9 | 23/23 |
| 08 두 색 광원 | 66.3% | 34.1 | 23/23 |
| 09 램프 히어로 | 70.1% | 21.4 | 18/23 |
어디서 깨지는가 — 좁은 화면·광원이 물체 안으로 들어올 때·읽히지 않는 대비
광원이 물체 높이 아래로 내려오면 배율이 튄다
01 에서 광원 높이가 상자 높이 70 이하가 되면 분모 lh − h 가 8 로 묶이면서 배율이 70÷8 = 8.75 로 튄다. 광원이 물체보다 낮으면 식 자체가 성립하지 않아 이 묶음은 안전장치일 뿐이니, 높이는 물체 위에 둔다. 02 도 가장 높은 기둥(101) 근처까지 LIGHT_HEIGHT 를 내리면 같은 묶음이 먼저 걸린다.
해가 지평선 가까우면 그림자가 잘린다
04 에서 해 진행이 0.04 이면 고도 21.0° 라서 원래 길이는 156.2 인데 SHADOW_LEN_MAX 140 에서 잘린다. 같은 장면에서 진행 0.25 는 고도 53.6°·길이 44.2, 진행 0.5 는 고도 70°·길이 21.8 이라 낮 한가운데는 그림자가 가장 짧다.
벽 사이 틈이 좁으면 광원이 못 들어간다
05 는 벽 사이 틈이 WALL_MARGIN 4 의 두 배(8)보다 좁으면 광원이 틈에 들어가지 못하고 pushOut 이 가장 가까운 바깥 변으로 밀어 낸다. 광원을 벽 안쪽에 놓으면 광선이 곧바로 벽에 막혀 다각형이 무너지기 때문이다.
가장자리에서는 두 광원이 가까워진다
08 의 두 광원은 각각 캔버스 가장자리에서 10px 안쪽으로 묶인다. 중점이 가장자리 근처에 가면 실제 간격이 LIGHT_GAP 보다 줄어들어, 같은 값인데도 겹침 영역이 더 커진다.
어두운 바닥에서는 그림자 색을 그대로 못 쓴다
01 은 밝은 바닥 [243, 238, 250] 에 그림자 색 [74, 56, 120] 을 농도 0.46 으로 얹는다. 같은 값을 어두운 보라 바닥에 옮기면 그림자가 바닥에 묻혀 읽히지 않는다. 08 의 바닥이 어두운 보라 [58, 50, 86] 인 것은 색광이 겹치는 모습을 보이려는 장면상 예외이고, 08 은 그림자를 칠하는 대신 빛을 더하는 방식이라 어두운 바닥에서도 겹침이 읽힌다.
좁은 화면과 움직임 판정
캔버스는 aspect-ratio 480 / 300 으로 폭만 줄고, 아홉 장면의 넘침·폰 폭 넘침 판정은 모두 아니오다. 09 의 움직인 프레임이 18/23 인 것은 시연 경로가 좌우로 왕복하다 양 끝에서 감속해 프레임 사이 변화가 없는 구간이 생기기 때문이다.
직접 고쳐 보며 경계를 확인하는 법
02 의 광원 높이와 01 의 상자 높이, 04 의 상한과 05 의 마진을 한 번씩 바꿔 가며 위 경계가 실제로 나타나는 값을 찾을 수 있다. 어느 값에서 어떻게 틀어지는지 손으로 확인하고 싶다면 아래 파일을 비밀번호 6bvjpvkj 으로 연 뒤 이 상수들을 하나씩 바꿔 본다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 광원 높이 바꾸기 | 01 LIGHT_HEIGHT 220 → 120 | 상자 윗면이 바닥에 그리는 거리의 배율이 1.47배(220÷150)에서 2.4배(120÷50)로 늘어 그림자가 훨씬 길게 늘어나고, 400 으로 올리면 1.21배로 줄어 짧아진다. |
| 광선 수 늘리기 | 05 RAY_COUNT 180 → 360 | 다각형 꼭짓점이 264개에서 444개로 늘지만 빛이 닿는 모양은 같다. 늘어나는 것은 광선×선분 교차 계산 비용이다. |
| 두 광원 간격 바꾸기 | 08 LIGHT_GAP 160 → 260 | 상자마다 색상 330 과 170 의 두 빛이 만드는 그림자가 갈라지는 폭이 커지고, 두 빛이 모두 막혀 어두운 겹침 영역은 줄어든다. |
prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고 1000밀리초 지점의 장면 한 장을 그린다. 이 설정에서 버튼 글자는 「다음 장면」 으로 바뀌고, 한 번 누르면 시연 경로가 900밀리초 앞으로 가서 보간 없이 바로 그려지며 포인터와 방향키 입력도 같은 식으로 즉시 반영된다. 캔버스는 tabindex 0 이라 포커스를 받고, 방향키는 광원을 12px 씩 옮기고 Enter·Space 는 버튼과 같은 동작을 한다. 상태 글자는 aria-live="polite" 한 줄이라 시연 중·일시정지됨·직접 조작 중이 읽히고, 버튼은 <button> 하나로 멈춤과 재생을 오간다.
css 그림자 넣기는 box-shadow 한 줄이면 충분한가?
광원이 고정이고 모양이 사각형인 카드라면 충분하다. box-shadow 는 오프셋·번짐·색 세 값으로 끝나고, 정적인 목록 카드에 광원 계산을 얹는 것은 비용만 늘린다. 값이 광원에 묶여야 할 때부터 계산이 필요하다.
각 장면이 붙는 자리는 다르다. 01 은 상품 상자를 소개하는 장면, 02 와 09 는 랜딩 히어로 배경, 03 은 떠 있는 카드와 플로팅 버튼, 04 는 시간대를 소개하는 섹션, 05 는 던전 지도와 방 구조 소개, 06 은 대시보드 패널과 카드 겹침, 07 은 책상 위 소품과 상품 사진 합성, 08 은 무대 조명 같은 이벤트 배너에 맞다. 06 의 세 값은 CSS 로 그대로 옮길 수 있고, 07 은 타원 그라데이션이라 box-shadow 값이 아니라 농도·반지름 식을 가져다 쓴다.
물체를 회전 계산으로 그리는 장면은 주사위 굴리기 9가지 — 회전 행렬로 윗면을 읽어 내는 3D 장면과 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면에서 이어지고, 그림자를 계산하는 쪽은 여기서 광원 한 개와 두 개까지 다룬다.
css 그림자 효과 전에 자주 묻는 질문
css 그림자 그라데이션처럼 가장자리가 서서히 옅어지는 그림자는 어떻게 만드나?
03 이 그 계산이다. 띠마다 높이에 비례해 번짐을 키우고 농도를 줄여 세로 그라데이션으로 칠하면 닿은 끝은 짙고 뜬 끝은 넓게 옅어진다. 값을 옮길 때는 BLUR_PER_HEIGHT 와 CONTACT_ALPHA 두 개부터 바꾼다.
내 페이지에 붙이려면 무엇부터 바꾸나?
압축 파일에서 장면 하나의 index.html 을 열고 위쪽 상수 LIGHT_HEIGHT 와 색 배열을 먼저 바꾼다. 캔버스는 480×300 비율이라 자리에 맞게 CSS 폭만 조절하면 된다.
라이브러리나 이미지가 필요한가?
필요 없다. 장면의 박스·글자·그림자를 전부 캔버스 2d 로 그리고, 라이브러리·이미지·외부 주소 호출이 없어서 파일 하나만 옮겨도 돈다.









