05グリッチ シャード — 横帯がずれる文字
サンプリングした各行を独立な断片として切り出し、drawImage で異なるオフセットに貼り直します。帯ごとに別のタイミングで跳ね、文字が裂けては再びくっつきます。
使いどころゲーム・ハッキング演出のエラー画面
drawImagegetImageDatarequestAnimationFrame
<main class="stage">
<div class="gg" id="gg">
<p class="gg__hint">
<svg class="gg__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M28 40h84v20H28zM116 40h20v20h-20zM28 76h16v20H28zM64 76h84v20H64zM160 76h40v20h-40zM28 112h56v20H28zM100 112h20v20h-20zM136 112h92v20h-92zM28 148h40v20H28zM80 148h16v20H80zM108 148h80v20h-80zM28 184h120v20H28zM160 184h36v20h-36zM208 184h28v20h-28z"/></g></svg>
<span>言葉を変えると帯の断片が切り直されます</span>
</p>
<div class="gg__field" id="field" role="application" aria-label="グリッチ シャードの実験盤" tabindex="0">
<canvas class="gg__cv" id="cv"></canvas>
<b class="gg__badge" aria-hidden="true" id="badge">0 pts</b>
</div>
<div class="gg__bar">
<label class="gg__dial">単語
<input type="text" id="word" value="SIGNAL" maxlength="12" spellcheck="false" aria-label="単語">
</label>
<label class="gg__dial">シード
<input type="number" id="seed" min="1" max="9999" value="7" aria-label="シード">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 글리치 조각 — 움직임은 전부 캔버스 rAF 루프 안에 있다(@keyframes 0개 — 077-03 패턴).
// 잉크 판 색은 파스텔 변수를 커스텀 프로퍼티로 내려 JS 가 읽어 쓴다. 리터럴 hex 는 없다(BD26).
$glyph-ink: $subject-ink; // 글릿 본색 — 잉크
$glyph-sky: mix($subject-sky, $stage-paper, 42%); // 파스텔 하늘
$glyph-mint: mix($subject-mint, $stage-paper, 42%); // 파스텔 민트
$glyph-peach: mix($subject-orange-deep, $stage-paper, 30%); // 파스텔 복숭아
@include stage($stage-paper);
.gg {
--g0: #{$glyph-ink};
--g1: #{$glyph-sky};
--g2: #{$glyph-mint};
--g3: #{$glyph-peach};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.gg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.gg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $glyph-ink; }
.gg__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .12);
}
.gg__field:focus-visible { outline: 2px solid $glyph-sky; outline-offset: 2px; }
.gg__cv { display: block; width: 100%; height: 100%; }
// 잉크 픽셀(=샘플 픽셀) 수를 화면에 적는다 — 런타임 샘플링이 이 데모의 요점이다
.gg__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.gg__bar { display: flex; align-items: flex-end; gap: $sp-3; }
.gg__dial {
display: flex; flex-direction: column; gap: $sp-1;
font-weight: 700; font-size: 11px; color: $subject-ink;
}
.gg__dial input {
width: 120px; margin: 0; padding: $sp-1 $sp-2;
border: 1px solid rgba($subject-ink, .2); border-radius: $r-sm;
background: $stage-paper; color: $subject-ink;
font-weight: 700; font-size: 12px;
}
.gg__dial input[type="number"] { width: 72px; }
.gg__dial input:focus-visible { outline: 2px solid $glyph-sky; outline-offset: 1px; }
@media (max-width: 340px) {
.gg { gap: $sp-1; }
.gg__field { height: 100px; }
.gg__badge { font-size: 10px; padding: 0 $sp-1; }
.gg__dial input { width: 88px; }
.gg__dial input[type="number"] { width: 56px; }
.gg__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 어긋남 없는 정합 한 장이 남는다.
@media (prefers-reduced-motion: reduce) {
.gg.is-still .gg__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gg {
--g0: #17141a;
--g1: rgb(76.1176470588%, 90.9411764706%, 100%);
--g2: rgb(70.5176470588%, 92.9176470588%, 85.3411764706%);
--g3: rgb(93.6470588235%, 75.6470588235%, 70%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.gg__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: #17141a;
}
.gg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.gg__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.12);
}
.gg__field:focus-visible {
outline: 2px solid rgb(76.1176470588%, 90.9411764706%, 100%);
outline-offset: 2px;
}
.gg__cv {
display: block;
width: 100%;
height: 100%;
}
.gg__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.gg__bar {
display: flex;
align-items: flex-end;
gap: 12px;
}
.gg__dial {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.gg__dial input {
width: 120px;
margin: 0;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 12px;
}
.gg__dial input[type=number] {
width: 72px;
}
.gg__dial input:focus-visible {
outline: 2px solid rgb(76.1176470588%, 90.9411764706%, 100%);
outline-offset: 1px;
}
@media (max-width: 340px) {
.gg {
gap: 4px;
}
.gg__field {
height: 100px;
}
.gg__badge {
font-size: 10px;
padding: 0 4px;
}
.gg__dial input {
width: 88px;
}
.gg__dial input[type=number] {
width: 56px;
}
.gg__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gg.is-still .gg__field {
cursor: default !important;
}
}// 글리치 조각 — 글자를 픽셀로 뜯어 가로 띠 조각으로 재조립한다.
// 1) 오프스크린 캔버스에 fillText 로 단어를 그려 getImageData 로 알파 마스크를 뽑는다
// (이 데모는 마스크를 다시 오프스크린 '잉크 판'에 그려 띠 단위로 뜯어 쓴다)
// 2) 잉크 판을 BH개의 가로 띠로 자르고, 매 프레임 띠마다 drawImage 로 제 갈지길 옮겨 붙인다
// 오프셋 = 사인 흐름 + 행별 シード 잡음 + 주기마다 크게 튀는 킥
// 3) RGB 채널 어긋남(같은 띠를 빨강·청록으로 ±2px)을 얹어 찢어지는 순간을 강조한다
// シード(mulberry32) 가 행 잡음을 정하므로 같은 単語+같은 시드는 같은 흐름이 나온다.
var root = document.getElementById('gg');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var wordIn = document.getElementById('word');
var seedIn = document.getElementById('seed');
var badge = document.getElementById('badge');
var SW = 252, SH = 108; // 샘플 해상도 — 판 비율과 같다
var BH = 3; // 띠 높이(px) — 행 단위 조각
var KICK = 0.8; // 크게 튀는 주기(초)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var ink = null; // 단어를 그린 잉크 판(오프스크린) — 띠의 원본
var bands = 0;
var rowNoise = [];
var targets = 0;
var t0 = 0, raf = 0;
// 색 — scss 가 파스텔 커스텀 프로퍼티(--g0..--g3)로 내려준 넉 색을 읽어 쓴다
var css = getComputedStyle(root);
function hexOf(name) {
var v = css.getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return '#' + v;
}
var cols = [hexOf('--g0'), hexOf('--g1'), hexOf('--g2'), hexOf('--g3')];
// 결정적 난수 — シード 하나가 행 잡음·위상 전부를 정한다
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// ── 1) 글자 → 알파 마스크 → 잉크 판 ────────────────────────────
// 잉크 판은 단어를 원하는 파스텔색으로 그려둔 오프스크린 캔버스다. 띠는 여기서 잘린다.
function buildInk(word) {
var off = document.createElement('canvas');
off.width = SW; off.height = SH;
var oc = off.getContext('2d', { willReadFrequently: true });
oc.textAlign = 'center'; oc.textBaseline = 'middle';
var fs = 200;
oc.font = '900 ' + fs + 'px Arial';
var w = oc.measureText(word).width || 1;
fs = Math.min(fs * ((SW - 8) / w), SH - 6); // 폭·높이 안에서 제일 큰 글자
oc.font = '900 ' + Math.round(fs) + 'px Arial';
oc.fillStyle = '#fff';
oc.fillText(word, SW / 2, SH / 2);
var data = oc.getImageData(0, 0, SW, SH).data;
// 잉크 픽셀 수(=목표점) — probe 계약 노출용
targets = 0;
for (var i = 3; i < data.length; i += 4) if (data[i] > 128) targets++;
// 알파를 그대로 옮겨 잉크 판을 다시 그린다 — 마스크가 색의 모양을 정한다
var plate = document.createElement('canvas');
plate.width = SW; plate.height = SH;
var pc = plate.getContext('2d');
var img = pc.createImageData(SW, SH);
var col = [cols[0], cols[1], cols[2], cols[3]];
for (var p = 0; p < SW * SH; p++) {
var a = data[p * 4 + 3];
if (a <= 128) continue;
// 세로 위치로 부드럽게 섞인 두 파스텔 — 잉크(아래) → 하늘(위)
var mixT = p / SW / SH;
img.data[p * 4] = Math.round(parseInt(col[1].slice(1, 3), 16) * mixT + parseInt(col[0].slice(1, 3), 16) * (1 - mixT));
img.data[p * 4 + 1] = Math.round(parseInt(col[1].slice(3, 5), 16) * mixT + parseInt(col[0].slice(3, 5), 16) * (1 - mixT));
img.data[p * 4 + 2] = Math.round(parseInt(col[1].slice(5, 7), 16) * mixT + parseInt(col[0].slice(5, 7), 16) * (1 - mixT));
img.data[p * 4 + 3] = a;
}
pc.putImageData(img, 0, 0);
return plate;
}
// ── 2)+(3) 単語·시드가 바뀌면 잉크 판과 행 잡음을 다시 만든다 ──
var word = 'SIGNAL', seed = 7;
function resample(nextWord, nextSeed) {
var w = String(nextWord == null ? word : nextWord).trim().toUpperCase();
if (w) word = w; // 빈 문자열이면 직전 값을 유지
var s = Math.floor(Number(nextSeed));
if (s >= 1) seed = s;
ink = buildInk(word);
bands = Math.ceil(SH / BH);
var rnd = mulberry32(seed);
rowNoise = [];
for (var y = 0; y < bands; y++) rowNoise.push({ n1: rnd() * 6.2832, n2: rnd() * 6.2832, sp: 0.6 + rnd() * 0.9 });
window.__sampleInfo = { targets: targets, particles: bands };
badge.textContent = targets + ' pts';
if (reduce) {
paint(0); // 어긋남 없는 정합 한 장
} else {
t0 = performance.now();
}
}
function fit() {
var r = field.getBoundingClientRect();
var d = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * d); cv.height = Math.round(H * d);
ctx.setTransform(d, 0, 0, d, 0, 0);
}
// 띠 하나의 x-오프셋 — 사인 흐름(행별 주파수)에 주기 KICK 초마다 샤프한 킥을 섞는다
function offsetAt(row, t) {
var nz = rowNoise[row];
var o = Math.sin(t * 2.1 * nz.sp + nz.n1) * 4.6 + Math.sin(t * 3.7 * nz.sp + nz.n2) * 2.2;
// 킥 — KICK 초 주기의 좁은 창(0.09s)에서 행 절반이 크게 튄다(시드가 행을 가른다)
var ph = (t % KICK) / KICK;
if (ph < 0.09) {
var kq = Math.sin((ph / 0.09) * Math.PI);
if ((row * 2654435761 + seed) % 2 === 0) o += (nz.n1 > 3.14 ? 1 : -1) * 16 * kq;
}
return o;
}
// 킥이 얼마나 임박했는지(0~1) — 잡음 대시가 킥 직전 밝아진다
function kickNear(t) {
var ph = (t % KICK) / KICK;
return ph < 0.09 ? Math.sin((ph / 0.09) * Math.PI) : 0;
}
function paint(t) {
ctx.clearRect(0, 0, W, H);
if (!ink) return;
var sx = W / SW, sy = H / SH;
var kn = kickNear(t);
ctx.imageSmoothingEnabled = false;
// CRT 스윕 — 주사선 하나가 판을 1.33초에 한 번 훑고 지나간다(위→아래, 두 번째는 아래→위).
// 잉크를 옅게 깐 그라디언트 띠라 화면 전체가 2초 안에 최소 한 번은 숨쉰다.
var sw1 = ((t * 0.75) % 1) * (SH + 36) - 18;
var sw2 = SH - (((t * 0.55) + 0.37) % 1) * (SH + 36) - 18;
var gr = ctx.createLinearGradient(0, (sw1 - 9) * sy, 0, (sw1 + 9) * sy);
gr.addColorStop(0, 'rgba(23,20,26,0)');
gr.addColorStop(0.5, 'rgba(23,20,26,' + (0.07 + 0.03 * kn) + ')');
gr.addColorStop(1, 'rgba(23,20,26,0)');
ctx.fillStyle = gr;
ctx.fillRect(0, (sw1 - 9) * sy, W, 18 * sy);
var gr2 = ctx.createLinearGradient(0, (sw2 - 7) * sy, 0, (sw2 + 7) * sy);
gr2.addColorStop(0, 'rgba(23,20,26,0)');
gr2.addColorStop(0.5, 'rgba(23,20,26,0.05)');
gr2.addColorStop(1, 'rgba(23,20,26,0)');
ctx.fillStyle = gr2;
ctx.fillRect(0, (sw2 - 7) * sy, W, 14 * sy);
// 잡음 대시 — 띠마다 시드가 정한 자리·속도로 흐르는 짧은 신호 표시. 띠와 같은 오프셋으로 밀린다
for (var d = 0; d < bands; d++) {
var dnz = rowNoise[d];
var oD = offsetAt(d, t) * 0.5;
var dx1 = ((dnz.n1 / 6.2832) * SW + t * (6 + dnz.sp * 10)) % (SW + 8) - 4;
var dx2 = ((dnz.n2 / 6.2832) * SW + t * (4 + dnz.sp * 6)) % (SW + 8) - 4;
ctx.globalAlpha = 0.16 + 0.16 * kn;
ctx.fillStyle = cols[0];
ctx.fillRect((dx1 + oD) * sx, (d * BH) * sy, 5.5 * sx, BH * sy * 0.7);
ctx.globalAlpha = 0.10 + 0.12 * kn;
ctx.fillStyle = cols[1];
ctx.fillRect((dx2 + oD) * sx, (d * BH + BH * 0.4) * sy, 3.4 * sx, BH * sy * 0.5);
}
ctx.globalAlpha = 1;
for (var row = 0; row < bands; row++) {
var o = offsetAt(row, t);
var y = row * BH;
var drawY = y + Math.sin(t * 1.6 + row * 0.35) * 1.2; // 띠가 위아래로도 흔들린다
// 본띠 — 잉크 판의 이 행을 오프셋에 그대로 찍는다 (drawImage)
ctx.drawImage(ink, 0, y, SW, BH, (o * 1.0) * sx, drawY * sy, SW * sx, BH * sy + 0.5);
// RGB 어긋남 — 크게 튀는 순간만 복숭아·민트 고스트를 ±2.2px 에 깔아댄다
var aa = Math.abs(o);
if (aa > 4.5) {
var g = Math.min((aa - 4.5) / 8, 1) * 0.6;
ctx.globalAlpha = g;
ctx.drawImage(ink, 0, y, SW, BH, (o * 1.0 - 2.2) * sx, drawY * sy, SW * sx, BH * sy + 0.5);
ctx.drawImage(ink, 0, y, SW, BH, (o * 1.0 + 2.2) * sx, drawY * sy, SW * sx, BH * sy + 0.5);
ctx.globalAlpha = 1;
}
}
ctx.imageSmoothingEnabled = true;
}
function frame(now) {
paint((now - t0) / 1000);
raf = requestAnimationFrame(frame);
}
wordIn.addEventListener('input', function () { resample(wordIn.value, seedIn.value); });
seedIn.addEventListener('input', function () { resample(wordIn.value, seedIn.value); });
window.addEventListener('resize', function () { fit(); if (reduce) paint(0); });
fit();
resample(wordIn.value, seedIn.value);
if (reduce) {
root.classList.add('is-still'); // 루프 없이 정합 한 장
} else {
raf = requestAnimationFrame(frame);
}// GlyphGlitchShard — vanilla 판(run/310/demo/05-glyph-glitch-shard/index.html) 의 React 포트.
// 오프스크린 캔버스에 fillText 로 단어를 그려 getImageData 로 알파 마스크를 뽑고,
// 마스크 모양대로 그린 잉크 판을 가로 띠(BH px)로 잘라 drawImage 로 띠마다 다른 오프셋에 재조립한다.
// 행 잡음은 mulberry32(시드) 로 정한다 — 같은 시드는 같은 흐름.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 는 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlyphGlitchShard.module.scss";
export type GlyphGlitchShardProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type InputEvt = { target: { value: string } };
const SW = 252, SH = 108; // 샘플 해상도 — 판 비율과 같다
const BH = 3; // 띠 높이(px) — 행 단위 조각
const KICK = 0.8; // 크게 튀는 주기(초)
const FIELD_LABEL = "글리치 조각 실험판";
// 결정적 난수 — mulberry32. 같은 시드면 같은 수열을 돌려준다
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type RowNoise = { n1: number; n2: number; sp: number };
type InkResult = { plate: HTMLCanvasElement; targets: number };
// 글자 → 알파 마스크 → 파스텔 잉크 판. 띠는 이 판에서 잘라 나간다
function buildInk(word: string, col0: string, col1: string): InkResult {
const off = document.createElement("canvas");
off.width = SW; off.height = SH;
const oc = off.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D;
oc.textAlign = "center"; oc.textBaseline = "middle";
let fs = 200;
oc.font = "900 " + fs + "px Arial";
const w = oc.measureText(word).width || 1;
fs = Math.min(fs * ((SW - 8) / w), SH - 6); // 폭·높이 안에서 제일 큰 글자
oc.font = "900 " + Math.round(fs) + "px Arial";
oc.fillStyle = "#fff";
oc.fillText(word, SW / 2, SH / 2);
const data = oc.getImageData(0, 0, SW, SH).data;
let targets = 0;
for (let i = 3; i < data.length; i += 4) if (data[i] > 128) targets++;
const plate = document.createElement("canvas");
plate.width = SW; plate.height = SH;
const pc = plate.getContext("2d") as CanvasRenderingContext2D;
const img = pc.createImageData(SW, SH);
const c0 = [parseInt(col0.slice(1, 3), 16), parseInt(col0.slice(3, 5), 16), parseInt(col0.slice(5, 7), 16)];
const c1 = [parseInt(col1.slice(1, 3), 16), parseInt(col1.slice(3, 5), 16), parseInt(col1.slice(5, 7), 16)];
for (let p = 0; p < SW * SH; p++) {
const a = data[p * 4 + 3];
if (a <= 128) continue;
// 세로 위치로 부드럽게 섞인 두 파스텔 — 잉크(아래) → 하늘(위)
const mixT = p / SW / SH;
img.data[p * 4] = Math.round(c1[0] * mixT + c0[0] * (1 - mixT));
img.data[p * 4 + 1] = Math.round(c1[1] * mixT + c0[1] * (1 - mixT));
img.data[p * 4 + 2] = Math.round(c1[2] * mixT + c0[2] * (1 - mixT));
img.data[p * 4 + 3] = a;
}
pc.putImageData(img, 0, 0);
return { plate, targets };
}
// 띠 하나의 x-오프셋 — 사인 흐름(행별 주파수)에 주기 KICK 초마다 샤프한 킥을 섞는다
function offsetAt(row: number, t: number, nz: RowNoise, seed: number): number {
let o = Math.sin(t * 2.1 * nz.sp + nz.n1) * 4.6 + Math.sin(t * 3.7 * nz.sp + nz.n2) * 2.2;
const ph = (t % KICK) / KICK;
if (ph < 0.09) {
const kq = Math.sin((ph / 0.09) * Math.PI);
const h = (Math.imul(row, 2654435761) + seed) % 2;
if (h === 0) o += (nz.n1 > 3.14 ? 1 : -1) * 16 * kq;
}
return o;
}
// 킥이 얼마나 임박했는지(0~1) — 잡음 대시가 킥 직전 밝아진다
function kickNear(t: number): number {
const ph = (t % KICK) / KICK;
return ph < 0.09 ? Math.sin((ph / 0.09) * Math.PI) : 0;
}
export default function GlyphGlitchShard({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#17141a", // vanilla $glyph-ink
}: GlyphGlitchShardProps) {
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 inkRef = useRef<InkResult | null>(null) as Box<InkResult | null>;
const noiseRef = useRef<RowNoise[]>([]) as Box<RowNoise[]>;
const colsRef = useRef<string[]>([]) as Box<string[]>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const stateRef = useRef({ word: "SIGNAL", seed: 7, t0: 0, targets: 0 }) as Box<{ word: string; seed: number; t0: number; targets: number }>;
const rafRef = useRef(0) as Box<number>;
const [count, setCount] = useState(0);
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 hex3to6(v: string): string {
if (v.length === 3) return v.charAt(0) + v.charAt(0) + v.charAt(1) + v.charAt(1) + v.charAt(2) + v.charAt(2);
return v;
}
function colors(): string[] {
const root = rootRef.current;
if (!root) return ["#17141a", "#17141a", "#17141a", "#17141a"];
const one = (n: string) => hex3to6(getComputedStyle(root).getPropertyValue(n).trim());
return [one("--g0"), one("--g1"), one("--g2"), one("--g3")];
}
// 고DPI 대응 — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function paint(t: number): void {
const ctx = ctxRef.current;
const ink = inkRef.current;
const cols = colsRef.current;
const nzs = noiseRef.current;
if (!ctx || !ink) return;
const st = stateRef.current;
const W = sizeRef.current.w, H = sizeRef.current.h;
const bands = Math.ceil(SH / BH);
ctx.clearRect(0, 0, W, H);
const sx = W / SW, sy = H / SH;
const kn = kickNear(t);
ctx.imageSmoothingEnabled = false;
// CRT 스윕 — 주사선 하나가 판을 1.33초에 한 번 훑고 지나간다(위→아래, 두 번째는 아래→위)
const sw1 = ((t * 0.75) % 1) * (SH + 36) - 18;
const sw2 = SH - (((t * 0.55) + 0.37) % 1) * (SH + 36) - 18;
const gr = ctx.createLinearGradient(0, (sw1 - 9) * sy, 0, (sw1 + 9) * sy);
gr.addColorStop(0, "rgba(23,20,26,0)");
gr.addColorStop(0.5, "rgba(23,20,26," + (0.07 + 0.03 * kn) + ")");
gr.addColorStop(1, "rgba(23,20,26,0)");
ctx.fillStyle = gr;
ctx.fillRect(0, (sw1 - 9) * sy, W, 18 * sy);
const gr2 = ctx.createLinearGradient(0, (sw2 - 7) * sy, 0, (sw2 + 7) * sy);
gr2.addColorStop(0, "rgba(23,20,26,0)");
gr2.addColorStop(0.5, "rgba(23,20,26,0.05)");
gr2.addColorStop(1, "rgba(23,20,26,0)");
ctx.fillStyle = gr2;
ctx.fillRect(0, (sw2 - 7) * sy, W, 14 * sy);
// 잡음 대시 — 띠마다 시드가 정한 자리·속도로 흐르는 짧은 신호 표시
for (let d = 0; d < bands; d++) {
const dnz = nzs[d];
if (!dnz) continue;
const oD = offsetAt(d, t, dnz, st.seed) * 0.5;
const dx1 = ((dnz.n1 / 6.2832) * SW + t * (6 + dnz.sp * 10)) % (SW + 8) - 4;
const dx2 = ((dnz.n2 / 6.2832) * SW + t * (4 + dnz.sp * 6)) % (SW + 8) - 4;
ctx.globalAlpha = 0.16 + 0.16 * kn;
ctx.fillStyle = cols[0] || "#17141a";
ctx.fillRect((dx1 + oD) * sx, (d * BH) * sy, 5.5 * sx, BH * sy * 0.7);
ctx.globalAlpha = 0.10 + 0.12 * kn;
ctx.fillStyle = cols[1] || "#17141a";
ctx.fillRect((dx2 + oD) * sx, (d * BH + BH * 0.4) * sy, 3.4 * sx, BH * sy * 0.5);
}
ctx.globalAlpha = 1;
for (let row = 0; row < bands; row++) {
const nz = nzs[row];
if (!nz) continue;
const o = offsetAt(row, t, nz, st.seed);
const y = row * BH;
const drawY = y + Math.sin(t * 1.6 + row * 0.35) * 1.2; // 띠가 위아래로도 흔들린다
// 본띠 — 잉크 판의 이 행을 오프셋에 그대로 찍는다 (drawImage)
ctx.drawImage(ink.plate, 0, y, SW, BH, o * sx, drawY * sy, SW * sx, BH * sy + 0.5);
// RGB 어긋남 — 크게 튀는 순간만 복숭아·민트 고스트를 ±2.2px 에 깔아댄다
const aa = Math.abs(o);
if (aa > 4.5) {
const g = Math.min((aa - 4.5) / 8, 1) * 0.6;
ctx.globalAlpha = g;
ctx.drawImage(ink.plate, 0, y, SW, BH, (o - 2.2) * sx, drawY * sy, SW * sx, BH * sy + 0.5);
ctx.drawImage(ink.plate, 0, y, SW, BH, (o + 2.2) * sx, drawY * sy, SW * sx, BH * sy + 0.5);
ctx.globalAlpha = 1;
}
}
ctx.imageSmoothingEnabled = true;
}
// 단어·시드가 바뀌면 잉크 판과 행 잡음을 다시 만든다(런타임 샘플링)
function resample(nextWord: string, nextSeed: number): void {
const st = stateRef.current;
const cols = colsRef.current;
const w = String(nextWord == null ? st.word : nextWord).trim().toUpperCase();
if (w) st.word = w;
if (nextSeed >= 1) st.seed = Math.floor(nextSeed);
inkRef.current = buildInk(st.word, cols[0] || "#17141a", cols[1] || "#17141a");
st.targets = inkRef.current.targets;
const bands = Math.ceil(SH / BH);
const rnd = mulberry32(st.seed);
const nz: RowNoise[] = [];
for (let y = 0; y < bands; y++) nz.push({ n1: rnd() * 6.2832, n2: rnd() * 6.2832, sp: 0.6 + rnd() * 0.9 });
noiseRef.current = nz;
setCount(st.targets);
if (reduced()) {
paint(0); // 어긋남 없는 정합 한 장
} else {
st.t0 = performance.now();
}
}
function frame(now: number): void {
paint((now - stateRef.current.t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
function onWord(e: InputEvt): void {
resample(e.target.value, stateRef.current.seed);
}
function onSeed(e: InputEvt): void {
const v = Math.floor(Number(e.target.value)) || 1;
resample(stateRef.current.word, v);
}
function onResize(): void {
fit();
if (reduced()) paint(0);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
colsRef.current = colors();
fit();
resample("SIGNAL", 7);
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프를 시작하지 않고 정합 한 장만 그린다
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, still ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M28 40h84v20H28zM116 40h20v20h-20zM28 76h16v20H28zM64 76h84v20H64zM160 76h40v20h-40zM28 112h56v20H28zM100 112h20v20h-20zM136 112h92v20h-92zM28 148h40v20H28zM80 148h16v20H80zM108 148h80v20h-80zM28 184h120v20H28zM160 184h36v20h-36zM208 184h28v20h-28z"/></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} id="cv"></canvas>
<b className={styles.badge} aria-hidden="true">{count} pts</b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>단어
<input type="text" id="word" defaultValue="SIGNAL" maxLength={12} spellCheck={false} aria-label="단어" onInput={onWord} />
</label>
<label className={styles.dial}>시드
<input type="number" id="seed" defaultValue={7} min={1} max={9999} aria-label="시드" onInput={onSeed} />
</label>
</div>
</div>
</main>
);
}05 グリッチ シャード — 横帯がずれる文字 — 計測 累積面積 37.567% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · テキスト アニメーション css 9選 — 文字をキャンバスのピクセルから作り直す


