08스트라이크 플래시 — 타격 직후 화면 전체가 지수로 식는다
번개가 그려진 프레임에서 흰 오버레이의 불투명도를 최댓값으로 올린 뒤 매 프레임 이전 값에 감쇠율을 곱해 줄이면, 사진 플래시처럼 순간 밝아졌다 지수적으로 어두워지는 잔광이 됩니다.
쓰임낙뢰 순간을 강조하는 풀스크린 인트로, 알림 배너의 강한 주의 환기
fillRectglobalCompositeOperationrequestAnimationFrame

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 64h-32l-16-24H96L80 64H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-80 104a36 36 0 1 1 36-36a36 36 0 0 1-36 36" opacity=".2"/><path d="M208 56h-27.72l-13.63-20.44A8 8 0 0 0 160 32H96a8 8 0 0 0-6.65 3.56L75.71 56H48a24 24 0 0 0-24 24v112a24 24 0 0 0 24 24h160a24 24 0 0 0 24-24V80a24 24 0 0 0-24-24m8 136a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h32a8 8 0 0 0 6.66-3.56L100.28 48h55.43l13.63 20.44A8 8 0 0 0 176 72h32a8 8 0 0 1 8 8ZM128 88a44 44 0 1 0 44 44a44.05 44.05 0 0 0-44-44m0 72a28 28 0 1 1 28-28a28 28 0 0 1-28 28"/></g></svg>
<span>낙뢰 프레임에 전면 플래시 광원의 세기를 최대로 올린 뒤 매 프레임 이전 값에 감쇠율을 곱합니다 — 실루엣 언덕까지 잠깐 밝아졌다가 사진 플래시처럼 지수로 식는 잔광입니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="스트라이크 플래시 잔광 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta">감쇠율 <b class="cd__val" id="cdVal">0.95</b> · 잔광 프레임</span>
<span class="cd__meta cd__meta--dim">fillRect · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스트라이크 플래시(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(중점변위 낙뢰 + 전면 플래시 광원 지수 감쇠)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --bolt-* 로 편 편 공통 규격.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD26: vivid 순색 없음) ──
$bolt-ink: #1b1e26; // 어스름 무대
$bolt-sky: #323749; // 하늘 깊은 슬레이트
$bolt-slab: #4b5170; // 실루엣 언덕 슬레이트
$bolt-ridge: #2c3040; // 뒷산 능선 — 한 단 어두운 슬레이트
$bolt-core: #f6e7ff; // 줄기 코어 — 밝은 파스텔 라벤더
$bolt-lilac: #b9a0ff; // 플래시 광 라일락
$bolt-mint: #b9ddd1; // 플래시 차가운 광
$bolt-node: #d8dce6; // 메타 글자 안개색
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --bolt-* 를 받아 쓴다
--bolt-ink: #{$bolt-ink};
--bolt-sky: #{$bolt-sky};
--bolt-slab: #{$bolt-slab};
--bolt-ridge: #{$bolt-ridge};
--bolt-core: #{$bolt-core};
--bolt-lilac: #{$bolt-lilac};
--bolt-mint: #{$bolt-mint};
--bolt-node: #{$bolt-node};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $bolt-node; // 잉크 무대 위 — 안개색 글자(대비 9.8:1)
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $bolt-lilac; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $bolt-ink;
box-shadow: inset 0 0 0 1px rgba($bolt-node, .18);
}
.cd__field:focus-visible { outline: 2px solid $bolt-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($bolt-node, .78); }
.cd__val { color: $bolt-mint; font-weight: 700; } // 잉크 위 민트 라벨(대비 9.4:1)
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 낙뢰가 켜진 한 장만 남긴다(JS 가 is-still 부착).
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}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);
}
.cd {
--bolt-ink: #1b1e26;
--bolt-sky: #323749;
--bolt-slab: #4b5170;
--bolt-ridge: #2c3040;
--bolt-core: #f6e7ff;
--bolt-lilac: #b9a0ff;
--bolt-mint: #b9ddd1;
--bolt-node: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.78);
}
.cd__val {
color: #b9ddd1;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 스트라이크 플래시(08). 중점변위 낙뢰가 화면 꼭대기에서 지면으로 떨어지는 순간,
// 전면 플래시 광원(하늘 방사형 그라디언트 + 하늘색 배경 증폭)을 최대치로 켠 뒤
// 매 프레임 flash *= DECAY 로 지수 감쇠시킨다. 배경 실루엣(뒷산·언덕 능선·작은 탑)도
// 플래시에 함께 밝아졌다 식으므로 변하는 픽셀이 화면 대부분을 덮는다 — 면적 확보의 핵심.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var DECAY = 0.95; // 프레임당 플래시 감쇠율 — 1 에 가까울수록 잔광이 오래 남는다
var DISPLACE_PX = 30; // 중점변위 최대 폭 — 늘리면 줄기가 더 거칠어진다
var CYCLE_MS = 1000; // 낙뢰 반복 주기(2000ms 의 약수 — gif 가 안 끊긴다)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var valOut = document.getElementById('cdVal');
// 시드 고정 PRNG — mulberry32
var SEED = 0x517c08;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--bolt-*)에서 getComputedStyle 로 읽는다
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--bolt-ink');
var C_SKY = tokRGB('--bolt-sky');
var C_SLAB = tokRGB('--bolt-slab');
var C_RIDGE = tokRGB('--bolt-ridge');
var C_CORE = tokRGB('--bolt-core');
var C_LILAC = tokRGB('--bolt-lilac');
var C_MINT = tokRGB('--bolt-mint');
var C_NODE = tokRGB('--bolt-node');
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(a, b, k) {
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 60; // reduce 에서 그리는 시각(ms) — 플래시가 살짝 남은 낙뢰 순간
// 실루엣 지형 — 능선 높이 배열. 화면 비율로 정의해 크기와 무관하게 같은 산골짜기가 그려진다
var RIDGE_FR = [
[0.00, 0.66], [0.08, 0.60], [0.18, 0.68], [0.30, 0.58], [0.42, 0.64],
[0.54, 0.56], [0.66, 0.62], [0.78, 0.58], [0.90, 0.63], [1.00, 0.60]
];
var HILL_FR = [
[0.00, 0.80], [0.10, 0.76], [0.24, 0.82], [0.38, 0.74], [0.52, 0.79],
[0.66, 0.73], [0.80, 0.80], [0.92, 0.75], [1.00, 0.78]
];
function ridgePath(fr) {
ctx.beginPath();
ctx.moveTo(0, H);
for (var i = 0; i < fr.length; i++) ctx.lineTo(fr[i][0] * W, fr[i][1] * H);
ctx.lineTo(W, H);
ctx.closePath();
}
// 이번 주기의 낙뢰 — 시드를 주기 시작 시점에 고정해 매 주기 같은 줄기가 떨어진다
var boltSeedBase = SEED;
function boltPoints(x1, y1, x2, y2, disp) {
var pts = [{ x: x1, y: y1 }, { x: x2, y: y2 }];
var amt = disp;
for (var depth = 0; depth < 7; depth++) {
var next = [pts[0]];
for (var i = 0; i < pts.length - 1; i++) {
var a = pts[i], b = pts[i + 1];
var mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
var dx = b.x - a.x, dy = b.y - a.y;
var len = Math.hypot(dx, dy) || 1;
var off = (rand() - 0.5) * 2 * amt;
next.push({ x: mx + (-dy / len) * off, y: my + (dx / len) * off });
next.push(b);
}
pts = next;
amt *= 0.55;
}
return pts;
}
// 낙뢰 봉투 — 0~120ms 스트로크(밝게 유지), 120~320ms 급격히 소멸
function boltAlpha(t) {
if (t < 0 || t > 320) return 0;
if (t < 120) return 1;
return 1 - (t - 120) / 200;
}
var flash = 0; // 전면 플래시 세기 0~1 — 낙뢰 프레임에 1 로 셋, 매 프레임 DECAY 곱해 감쇠
// 밤하늘 별빛 — 낙뢰 플래시가 다 식은 휴지 구간에도 화면이 완전히 죽지 않게 하는 안전판
// (082 잔광은 주기 시작 직후 몇 프레임 안에 다 꺼진다 — 별빛이 그 사이 프레임에도 diff 를 만든다)
// 반짝임은 45ms 계단(scintillation) — 부드러운 사인파는 프레임간 변화가 픽셀 문턱 아래로 내려간다
var stars = [];
function buildStars() {
stars = [];
for (var i = 0; i < 20; i++) {
stars.push({ rx: rand(), ry: rand() * 0.55, ph: (rand() * 97) | 0 });
}
}
// 정수 해시(murmur3 혼합) — (별 번호, 계단) → 0~1 유사난수. 시드 고정이라 렌더링이 결정론적이다
function starHash(a, b) {
var h = (a * 374761393 + b * 668265263) >>> 0;
h = ((h ^ (h >>> 13)) * 1274126177) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function draw(tAbs, t, dt) {
// 플래시 감쇠 — 이전 값에 감쇠율을 곱하는 지수 감쇠가 이 데모의 주제다
flash *= Math.pow(DECAY, dt * 60);
// 낙뢰 순간(주기 시작 0~120ms)엔 플래시가 최대치를 유지한다
if (t < 120) flash = 1;
// 하늘 — 플래시가 켜질수록 깊은 슬레이트가 라일락 광으로 밝아진다
var skyCol = mix(C_SKY, C_LILAC, flash * 0.5);
ctx.fillStyle = rgba(skyCol, 1);
ctx.fillRect(0, 0, W, H);
// 별빛 — 45ms 계단으로 반짝여 플래시가 다 식은 휴지 구간에도 프레임마다 확실히 값이 바뀐다
var starStep = (tAbs / 45) | 0;
for (var si = 0; si < stars.length; si++) {
var st = stars[si];
var sa = 0.14 + 0.62 * starHash(si + st.ph, starStep);
ctx.fillStyle = rgba(C_MINT, sa);
ctx.fillRect(st.rx * W, st.ry * H, 2.2, 2.2);
}
// 플래시 광원 — 낙뢰 상단에서 퍼지는 방사형 그라디언트(전경 번개의 발광원)
if (flash > 0.02) {
var gr = ctx.createRadialGradient(W * 0.5, H * 0.04, 4, W * 0.5, H * 0.04, H * 1.1);
gr.addColorStop(0, rgba(C_CORE, flash * 0.85));
gr.addColorStop(0.35, rgba(C_LILAC, flash * 0.4));
gr.addColorStop(1, rgba(C_LILAC, 0));
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = gr;
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
}
// 실루엣 뒷산 — 플래시에 함께 밝아진다(역광 산골짜기)
ridgePath(RIDGE_FR);
ctx.fillStyle = rgba(mix(C_RIDGE, C_MINT, flash * 0.24), 1);
ctx.fill();
// 낙뢰 줄기 — 주기 시작마다 시드를 되돌려 같은 줄기가 반복 떨어지게 한다
var ba = boltAlpha(t);
if (ba > 0.01) {
prngState = boltSeedBase >>> 0;
var strikeX = W * 0.5 + W * 0.08 * Math.sin(boltSeedBase % 7);
var pts = boltPoints(strikeX, H * 0.02, strikeX + W * 0.06, H * 0.72, DISPLACE_PX);
ctx.globalCompositeOperation = 'lighter';
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
var passes = [[10, 0.15], [6, 0.25], [3.2, 0.42]];
for (var p = 0; p < passes.length; p++) {
ctx.strokeStyle = rgba(C_LILAC, passes[p][1] * ba);
ctx.lineWidth = passes[p][0];
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
ctx.strokeStyle = rgba(C_CORE, Math.min(1, ba * 1.1));
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x, pts[j].y);
ctx.stroke();
// 낙뢰 지점 폭발 광
var gg = ctx.createRadialGradient(pts[pts.length - 1].x, pts[pts.length - 1].y, 1, pts[pts.length - 1].x, pts[pts.length - 1].y, 46);
gg.addColorStop(0, rgba(C_CORE, ba));
gg.addColorStop(1, rgba(C_CORE, 0));
ctx.fillStyle = gg;
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
}
// 앞 언덕 — 실루엣. 플래시에 약간만 밝아진다(뒷산보다 가까워 어둡게 남는다)
ridgePath(HILL_FR);
ctx.fillStyle = rgba(mix(C_SLAB, C_LILAC, flash * 0.16), 1);
ctx.fill();
// 작은 탑 실루엣 — 언덕 능선 위 인공물 한 점
var tx = W * 0.78, ty = H * 0.73;
ctx.fillStyle = rgba(mix(C_SLAB, C_LILAC, flash * 0.2), 1);
ctx.fillRect(tx - 2, ty - 16, 4, 16);
ctx.fillRect(tx - 5, ty - 3, 10, 3);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
buildStars();
}
var prevNow = 0;
function frame(now) {
if (t0 < 0) { t0 = now; prevNow = now; }
var t = (now - t0) % CYCLE_MS;
var dt = Math.min(0.05, (now - prevNow) / 1000);
prevNow = now;
draw(now - t0, t, dt);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, STILL_T, 0.016); });
fit();
if (reduce) {
flash = 0.4;
draw(STILL_T, STILL_T, 0.016);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// StrikeFlash — vanilla 판(run/316/demo/08-lightning-flash-afterglow-fade/index.html) 의 React 포트.
// 중점변위 낙뢰가 떨어지는 순간 전면 플래시 광원을 최대치로 켠 뒤 매 프레임 flash *= DECAY 로
// 지수 감쇠시킨다. 실루엣(뒷산·앞 언덕·작은 탑)이 플래시에 함께 밝아졌다 식는 잔광이 주제다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StrikeFlash.module.scss";
export type StrikeFlashProps = {
duration?: string;
easing?: string;
color?: string;
decay?: number; // 프레임당 플래시 감쇠율 — 1 에 가까울수록 잔광이 오래 남는다
displacePx?: number; // 중점변위 최대 폭 — 늘리면 줄기가 더 거칠어진다
cycleMs?: number; // 낙뢰 반복 주기(2000ms 의 약수 — gif 가 안 끊긴다)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Colors = { ink: RGB; sky: RGB; slab: RGB; ridge: RGB; core: RGB; lilac: RGB; mint: RGB };
type Star = { rx: number; ry: number; ph: number };
const FIELD_LABEL = "스트라이크 플래시 잔광 실험판";
const STILL_T = 60; // reduce 에서 그리는 시각(ms) — 플래시가 살짝 남은 낙뢰 순간
const RIDGE_FR: [number, number][] = [
[0.00, 0.66], [0.08, 0.60], [0.18, 0.68], [0.30, 0.58], [0.42, 0.64],
[0.54, 0.56], [0.66, 0.62], [0.78, 0.58], [0.90, 0.63], [1.00, 0.60],
];
const HILL_FR: [number, number][] = [
[0.00, 0.80], [0.10, 0.76], [0.24, 0.82], [0.38, 0.74], [0.52, 0.79],
[0.66, 0.73], [0.80, 0.80], [0.92, 0.75], [1.00, 0.78],
];
const SEED = 0x517c08;
function makePRNG(seed: number): () => number {
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;
};
}
export default function StrikeFlash({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
decay = 0.95,
displacePx = 30,
cycleMs = 1000,
}: StrikeFlashProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const decayRef = useRef(decay) as Box<number>;
const dispRef = useRef(displacePx) as Box<number>;
const cycleRef = useRef(cycleMs) as Box<number>;
const boltRandRef = useRef(makePRNG(SEED)) as Box<() => number>;
const starsRef = useRef<Star[]>([]) as Box<Star[]>;
const flashRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
function mix(a: RGB, b: RGB, k: number): RGB {
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
}
function ridgePath(ctx: CanvasRenderingContext2D, fr: [number, number][], W: number, H: number): void {
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < fr.length; i++) ctx.lineTo(fr[i][0] * W, fr[i][1] * H);
ctx.lineTo(W, H);
ctx.closePath();
}
// 낙뢰 — 시드를 주기 시작 시점에 고정해 매 주기 같은 줄기가 떨어진다
function boltPoints(rand: () => number, x1: number, y1: number, x2: number, y2: number, disp: number): Pt[] {
let pts: Pt[] = [{ x: x1, y: y1 }, { x: x2, y: y2 }];
let amt = disp;
for (let depth = 0; depth < 7; depth++) {
const next: Pt[] = [pts[0]];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
const dx = b.x - a.x, dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const off = (rand() - 0.5) * 2 * amt;
next.push({ x: mx + (-dy / len) * off, y: my + (dx / len) * off });
next.push(b);
}
pts = next;
amt *= 0.55;
}
return pts;
}
// 낙뢰 봉투 — 0~120ms 스트로크(밝게 유지), 120~320ms 급격히 소멸
function boltAlpha(t: number): number {
if (t < 0 || t > 320) return 0;
if (t < 120) return 1;
return 1 - (t - 120) / 200;
}
// 밤하늘 별빛 — 플래시가 다 식은 휴지 구간에도 화면이 완전히 죽지 않게 하는 안전판
// 반짝임은 45ms 계단(scintillation) — 부드러운 사인파는 프레임간 변화가 픽셀 문턱 아래로 내려간다
function buildStars(): void {
const rand = makePRNG(SEED + 11);
const stars: Star[] = [];
for (let i = 0; i < 20; i++) {
stars.push({ rx: rand(), ry: rand() * 0.55, ph: (rand() * 97) | 0 });
}
starsRef.current = stars;
}
// 정수 해시(murmur3 혼합) — (별 번호, 계단) → 0~1 유사난수. 시드 고정이라 렌더링이 결정론적이다
function starHash(a: number, b: number): number {
let h = (a * 374761393 + b * 668265263) >>> 0;
h = ((h ^ (h >>> 13)) * 1274126177) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function draw(tAbs: number, t: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
flashRef.current *= Math.pow(decayRef.current, dt * 60);
if (t < 120) flashRef.current = 1;
const flash = flashRef.current;
const skyCol = mix(col.sky, col.lilac, flash * 0.5);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = rgba(skyCol, 1);
ctx.fillRect(0, 0, W, H);
const stars = starsRef.current;
const starStep = (tAbs / 45) | 0;
for (let si = 0; si < stars.length; si++) {
const st = stars[si];
const sa = 0.14 + 0.62 * starHash(si + st.ph, starStep);
ctx.fillStyle = rgba(col.mint, sa);
ctx.fillRect(st.rx * W, st.ry * H, 2.2, 2.2);
}
// 플래시 광원 — 낙뢰 상단에서 퍼지는 방사형 그라디언트
if (flash > 0.02) {
const gr = ctx.createRadialGradient(W * 0.5, H * 0.04, 4, W * 0.5, H * 0.04, H * 1.1);
gr.addColorStop(0, rgba(col.core, flash * 0.85));
gr.addColorStop(0.35, rgba(col.lilac, flash * 0.4));
gr.addColorStop(1, rgba(col.lilac, 0));
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = gr;
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "source-over";
}
ridgePath(ctx, RIDGE_FR, W, H);
ctx.fillStyle = rgba(mix(col.ridge, col.mint, flash * 0.24), 1);
ctx.fill();
const ba = boltAlpha(t);
if (ba > 0.01) {
const rand = boltRandRef.current;
// 시드를 되돌려 매 주기 같은 줄기가 반복 떨어지게 한다
boltRandRef.current = makePRNG(SEED);
const strikeX = W * 0.5 + W * 0.08 * Math.sin(SEED % 7);
const pts = boltPoints(boltRandRef.current, strikeX, H * 0.02, strikeX + W * 0.06, H * 0.72, dispRef.current);
ctx.globalCompositeOperation = "lighter";
ctx.lineJoin = "round"; ctx.lineCap = "round";
const passes: [number, number][] = [[10, 0.15], [6, 0.25], [3.2, 0.42]];
for (let p = 0; p < passes.length; p++) {
ctx.strokeStyle = rgba(col.lilac, passes[p][1] * ba);
ctx.lineWidth = passes[p][0];
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
ctx.strokeStyle = rgba(col.core, Math.min(1, ba * 1.1));
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x, pts[j].y);
ctx.stroke();
const tip = pts[pts.length - 1];
const gg = ctx.createRadialGradient(tip.x, tip.y, 1, tip.x, tip.y, 46);
gg.addColorStop(0, rgba(col.core, ba));
gg.addColorStop(1, rgba(col.core, 0));
ctx.fillStyle = gg;
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "source-over";
void rand;
}
ridgePath(ctx, HILL_FR, W, H);
ctx.fillStyle = rgba(mix(col.slab, col.lilac, flash * 0.16), 1);
ctx.fill();
const tx = W * 0.78, ty = H * 0.73;
ctx.fillStyle = rgba(mix(col.slab, col.lilac, flash * 0.2), 1);
ctx.fillRect(tx - 2, ty - 16, 4, 16);
ctx.fillRect(tx - 5, ty - 3, 10, 3);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
buildStars();
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) { t0 = now; prev = now; }
const t = (now - t0) % cycleRef.current;
const dt = Math.min(0.05, (now - prev) / 1000);
prev = now;
draw(now - t0, t, dt);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
decayRef.current = decay;
dispRef.current = displacePx;
cycleRef.current = cycleMs;
colRef.current = {
ink: tokRGB(root, "--bolt-ink"),
sky: tokRGB(root, "--bolt-sky"),
slab: tokRGB(root, "--bolt-slab"),
ridge: tokRGB(root, "--bolt-ridge"),
core: tokRGB(root, "--bolt-core"),
lilac: tokRGB(root, "--bolt-lilac"),
mint: tokRGB(root, "--bolt-mint"),
};
fit();
function onResize(): void { fit(); if (reduced()) draw(STILL_T, STILL_T, 0.016); }
window.addEventListener("resize", onResize);
if (reduced()) {
flashRef.current = 0.4;
draw(STILL_T, STILL_T, 0.016);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decay, displacePx, cycleMs]);
const rootCls = still ? `${styles.cd} ${styles.isStill}` : styles.cd;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 64h-32l-16-24H96L80 64H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-80 104a36 36 0 1 1 36-36a36 36 0 0 1-36 36" opacity=".2" /><path d="M208 56h-27.72l-13.63-20.44A8 8 0 0 0 160 32H96a8 8 0 0 0-6.65 3.56L75.71 56H48a24 24 0 0 0-24 24v112a24 24 0 0 0 24 24h160a24 24 0 0 0 24-24V80a24 24 0 0 0-24-24m8 136a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h32a8 8 0 0 0 6.66-3.56L100.28 48h55.43l13.63 20.44A8 8 0 0 0 176 72h32a8 8 0 0 1 8 8ZM128 88a44 44 0 1 0 44 44a44.05 44.05 0 0 0-44-44m0 72a28 28 0 1 1 28-28a28 28 0 0 1-28 28" /></g></svg>
<span>낙뢰 프레임에 전면 플래시 광원의 세기를 최대로 올린 뒤 매 프레임 이전 값에 감쇠율을 곱합니다 — 실루엣 언덕까지 잠깐 밝아졌다가 사진 플래시처럼 지수로 식는 잔광입니다</span>
</p>
<div className={styles.field} ref={fieldRef} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>감쇠율 <b className={styles.val}>0.95</b> · 잔광 프레임</span>
<span className={styles.metaDim}>fillRect · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 스트라이크 플래시 — 타격 직후 화면 전체가 지수로 식는다 — 측정 누적면적 52.849% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰
이 데모가 속한 글
캔버스 번개 효과 그리기 9종 — 프랙탈 중점변위로 치는 낙뢰번개 효과 그리기는 번개 사진을 붙이는 일이 아니라 곧은 선 하나를 반씩 쪼개는 일입니다. 라이브러리·WebGL 없이 선분을 재귀로 쪼개는 중점변위 하나로 번개 가지가 스스로 갈라집니다. 시작점과 끝점을 이은 선분의 이 글의 데모 전부 보기


