05The Area the Light Reaches Around Walls
Rays from the light stop at the first wall segment they meet, and the polygon joining those points is the only lit area.
Best forA dungeon map and a room layout intro
RAY_COUNTWALL_SEGMENTSANGLE_EPSLIGHT_RADIUS

<main class="stage">
<div class="sc is-demo" id="scRoot">
<canvas class="sc__canvas" id="scCanvas" width="480" height="300" tabindex="0" aria-label="Rays from the light stop at the first wall segment they meet, and the polygon joining those points is the only lit area."></canvas>
<p class="sc__status" id="scStatus" aria-live="polite">Demo running</p>
<button class="sc__btn" id="scBtn" type="button">Pause</button>
<p class="sc__caption">Rays from the light stop at the first wall segment they meet, and the polygon joining those points is the only lit area.</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 = 'Stepping one scene at a time';
btnEl.textContent = 'Next scene';
return;
}
statusEl.textContent = !autoplay ? 'Manual control' : (paused ? 'Paused' : 'Demo running');
btnEl.textContent = (autoplay && !paused) ? 'Pause' : 'Play';
}
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 The Area the Light Reaches Around Walls — Measured area 65.603% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Shadow Effects: 9 Demos That Compute the Cast
The post this demo lives in
CSS Shadow Effects: 9 Demos That Compute the CastWith css shadow effects from one box-shadow line, the shadow stays put as the light circles, light leaks behind walls an See all demos in this post
If you like this effect - similar demos
Creative UI09 A Dither Hero Where Only the Area Around the Pointer Gets DenseCSS Dither: 9 Scenes Drawn by Threshold Math
Creative UI09 Scatter and reassemble — letters reforming around the pointer9 Text Particle Effect CSS Ideas From Pixels
Creative UI04 Coral growth — the reaction front reaches outward from a seed9 Reaction Diffusion Simulations, No Library