게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식
게이지 바는 값이 오르내릴 때마다 채워진 폭이 바뀌는 막대인데, 아홉 개 중 하나는 값이 줄면 막대가 먼저 좁아지고 그 뒤로 붉은 잔상이 서서히 따라 줄어들며, 하나는 값이 상한을 넘으면 초과분이 반투명한 겹으로 얹히고, 또 하나는 보조 값이 상한에 닿으면 막대의 불투명도 값이
아래 아홉 개는 모두 캐릭터·게임 이름 없이 체력(HP)·기력·충전량 같은 일반적인 상태 값 하나를 시뮬레이션한 것입니다. 실제 게임 UI를 그대로 베낀 것이 아니라, 게이지 바 UI 를 만들 때 값이 바뀌는 계산 방식 자체를 아홉 갈래로 나눠 본 것에 가깝습니다.
01 game-gauge-bar-damage-flash-deplete
02 game-gauge-bar-regen-tick-glow
03 game-gauge-bar-segment-pip-crack
04 game-gauge-bar-overheal-shield-cap
05 game-gauge-bar-critical-threshold-pulse
06 game-gauge-bar-charge-hold-release
07 game-gauge-bar-multi-lane-compare
08 game-gauge-bar-stagger-break-snap
09 game-gauge-bar-reduced-motion-instant
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 피해 흡수 전환 — 값이 줄어들면 막대가 먼저 좁아지고 그 뒤를 붉은 잔상이 서서히 따라가며 좁아지는 방식
- 02 회복 틱 발광 전환 — 값이 일정 간격으로 조금씩 올라갈 때마다 막대 끝에 빛이 번쩍이는 방식
- 03 조각 칸 균열 전환 — 막대가 여러 칸으로 나뉘어 있고 값이 한 칸 밑으로 내려가면 그 칸에 균열 모양이 남는 방식
- 04 초과 회복 겹막 전환 — 값이 상한을 넘으면 그 초과분이 막대 위에 반투명한 겹으로 얹히는 방식
- 05 위험 구간 박동 전환 — 값이 정해둔 낮은 구간에 들어가면 막대 전체가 일정한 리듬으로 밝아지고 어두워지는 방식
- 06 충전 유지 방출 전환 — 누르고 있는 동안 값이 천천히 차오르고 손을 떼는 순간 한 번에 쏟아지는 방식
- 07 다중 레인 대비 전환 — 같은 자리에 놓인 두 개 이상의 막대가 서로 다른 값으로 나란히 채워지는 방식
- 08 기절 게이지 붕괴 전환 — 두 번째 값이 상한에 닿으면 막대가 한 번에 부서지듯 사라지는 방식
- 09 모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 애니메이션이 전부 즉시 값으로 바뀌는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아홉 개 전부 자동 재생 중이던 .is-demo 클래스는 사람이 버튼을 누르거나 키보드로 Enter·Space 를 입력하는 순간 human() 함수에서 한 번 지워지고, 그 뒤로는 클릭·키보드 입력(06 만 포인터 누름·뗌·이탈·취소와 키다운·키업)에 반응하는 requestAnimationFrame 루프가 값을 그립니다. 아홉 개 모두 무작위 값이 아니라 정해 둔 배열(EVENTS·JOLTS·HIT_LIST)의 값을 순서대로 읽는 시드 방식이라, 같은 편을 두 번 열어도 같은 순서로 재생됩니다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 피해 흡수 전환 | 2.85 | 97.7 | 21/23 |
| 02 회복 틱 발광 전환 | 5.43 | 39.9 | 23/23 |
| 03 조각 칸 균열 전환 | 1.92 | 71.8 | 14/23 |
| 04 초과 회복 겹막 전환 | 2.87 | 99.7 | 13/23 |
| 05 위험 구간 박동 전환 | 1.99 | 75.4 | 7/23 |
| 06 충전 유지 방출 전환 | 3.33 | 76.6 | 14/23 |
| 07 다중 레인 대비 전환 | 2.98 | 127.9 | 23/23 |
| 08 기절 게이지 붕괴 전환 | 2.88 | 83.1 | 13/23 |
| 09 모션 감소 즉시 전환 | 1.27 | 136.1 | 13/23 |
09 는 게이지 바 UI 아홉 개 중 유일한 컴포넌트 편이라 누적면적이 가장 낮은데, 이건 정상입니다 — 09 가 재는 축은 화면을 얼마나 채웠는지가 아니라 나머지 여덟 개의 애니메이션을 얼마나 정교하게 대신 끊어내는지에 있기 때문입니다.
매 프레임 지수 감쇠 계산으로 값을 되돌리는 값 — 01·02·04
01·02·04 는 전부 Math.exp(-k * dt) 형태의 지수 감쇠로 값을 되돌립니다. 01 은 applyHit() 로 본값(mainHP)을 깎은 뒤 잔상 값(trailHP)이 LERP_RATE 로 뒤늦게 따라가고, 동시에 FLASH_TAU 로 플래시 불투명도가 가라앉습니다. 02 는 TICK_INTERVAL 마다 tick() 이 값을 올리면서 GLOW_TAU 로 그 순간의 밝기를 감쇠시키고, 04 는 값이 HP_MAX+OVERHEAL_CAP 을 넘었을 때만 DECAY_RATE 로 초과분을 서서히 깎습니다.
경과 시간과 정해둔 구간 길이의 비율로 값을 만드는 값 — 03·08
03·08 은 감쇠 대신 경과 시간을 정해둔 구간 길이로 나눈 비율(선형)로 값을 만듭니다. 03 은 stateFor() 가 깨진 시각으로부터 지난 시간을 CRACK_DUR·STAGGER 로 나눠 균열 두 겹(c1·c2)과 채움 값을 계산하고, 08 은 snapOpacity() 가 SPIKE_DUR 동안은 1을 유지하다 DROP_DUR 구간에서 선형으로 0까지 떨어뜨린 뒤, RECOVER_DUR 구간에서 다시 선형으로 채웁니다.
경계 값을 넘었는지에 따라 사인 주기로 흔드는 값 — 05
05 는 값이 THRESHOLD 밑으로 내려가야만 계산이 시작됩니다. pulseFor() 가 THRESHOLD 와 현재 값의 차이로 비율(ratio)을 구하고, 그 비율로 박동 주기(PERIOD_MAX~PERIOD_MIN)와 진폭을 매 프레임 다시 정한 뒤 사인 함수 하나로 밝기와 글로우를 같이 흔듭니다.
감쇠 계수와 코사인 진동을 함께 계산해 튕기는 값 — 06
06 은 아홉 개 중 계산이 가장 많은 항목입니다. burstAt() 이 방출 경과 시간을 RELEASE_ZETA 로 감쇠시킨 진폭과 코사인 값을 곱해, 방출 표시(burst)가 목표 지점을 한 번에 지나쳤다가 되돌아오는 위치(pos)를 매 프레임 다시 계산합니다. 이건 CHARGE_RATE 로 누르는 동안 값을 채우는 계산과는 완전히 분리된 별도의 방출 전용 계산입니다.
실측 좌표와 사인 값으로 여러 레인을 따로 움직이는 값 — 07
07 은 valuesAt() 이 레인마다 다른 주파수(freq)·진폭(amp)·위상(phase)을 가진 사인 값으로 각 막대를 따로 움직이고, 클릭마다 JOLTS 배열의 값을 레인별 오프셋에 더해 순위를 흔듭니다. 여기에 더해 왕관(crown) 요소의 이동 거리는 고정 px 이 아니라 getBoundingClientRect() 로 실제 행 사이 간격을 잰 값(rowStep)을 씁니다.
09 는 위 다섯 갈래와 성격이 다릅니다. 앞선 여덟 개 각각의 계산을 스스로 가진 것이 아니라, 왼쪽 판(fillNormal)은 01과 같은 지수 감쇠 잔상 계산을 그대로 돌리고 오른쪽 판(fillReduced)은 계산 없이 같은 값을 바로 대입만 해, 계산이 있는 막대와 없는 막대를 한 화면에 나란히 두고 대조해 보여주는 역할만 합니다.
패턴별 코드
01피해 흡수 전환 — 값이 줄어들면 막대가 먼저 좁아지고 그 뒤를 붉은 잔상이 서서히 따라가며 좁아지는 방식
값이 줄어드는 순간 막대 폭이 먼저 좁아지고, 그 차이만큼 남은 붉은 폭이 뒤늦게 서서히 줄어듭니다. applyHit() 이 본값만 즉시 깎고, 잔상 값은 매 프레임 LERP_RATE 로 뒤따라가며 FLASH_TAU 로 플래시가 가라앉습니다. 전투 화면의 체력 표시처럼 순간 피해를 강조하는 상태 막대에 씁니다.
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts;
elapsed = 0;
appliedIdx = 0;
mainHP = HP_MAX;
trailHP = HP_MAX;
flashStart = -9999;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
applyHit(EVENTS[appliedIdx][1], ts);
appliedIdx++;
}
}
// 계산 겹: 잔상 지수 평활 — dt(ms) 에 독립적으로 같은 반감기를 유지
trailHP += (mainHP - trailHP) * (1 - Math.exp(-LERP_RATE * dt));
var flash = Math.max(0, Math.exp(-(ts - flashStart) / FLASH_TAU));
flashEl.style.opacity = flash < 0.02 ? '0' : flash.toFixed(3);
render();
requestAnimationFrame(loop);

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">HP</span>
<span class="gb__value" id="gbValue">100 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" id="gbTrack">
<div class="gb__trail" id="gbTrail"></div>
<div class="gb__fill" id="gbFill"></div>
<div class="gb__flash" id="gbFlash"></div>
</div>
<button type="button" class="gb__hit" id="gbHit">피해 입기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html loop() 이 매 프레임 잔상(trail) 값을 지수 평활로 본값에 수렴시키고
// 플래시는 감쇠 지수함수로 사그라든다 — 키프레임 반복이 아니라 시간 t 의 계산값이다.
@include stage($stage-ink);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__trail, .gb__fill {
position: absolute; left: 0; top: 0; height: 100%; border-radius: $r-pill;
will-change: width;
}
.gb__trail { background: $error; width: 100%; }
.gb__fill { background: $subject-blue-deep; width: 100%; z-index: 1; }
.gb__flash {
position: absolute; inset: 0; border-radius: $r-pill;
background: $subject-cream; opacity: 0; z-index: 2; pointer-events: none;
will-change: opacity;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__flash { display: none; }
}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);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__trail, .gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 999px;
will-change: width;
}
.gb__trail {
background: #c2361a;
width: 100%;
}
.gb__fill {
background: #0a4fe8;
width: 100%;
z-index: 1;
}
.gb__flash {
position: absolute;
inset: 0;
border-radius: 999px;
background: #fff7e6;
opacity: 0;
z-index: 2;
pointer-events: none;
will-change: opacity;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__flash {
display: none;
}
}(function () {
var HP_MAX = 100;
// 계산 겹 상수: 시드 고정 전투 이벤트 배열 — [발생시각ms, 피해량]
var EVENTS = [[300, 24], [1100, 18], [2000, 28], [2900, 12]];
var CYCLE = 3800; // ms, 자동 루프 한 바퀴
var LERP_RATE = 0.006; // 계산 겹: 잔상이 본값을 따라가는 지수 평활 상수(프레임 독립)
var FLASH_TAU = 70; // ms, 플래시 감쇠 시간상수
var HIT_LIST = [24, 18, 28, 12, 20]; // 클릭(실사용자) 피해량 순환
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var trailEl = document.getElementById('gbTrail');
var flashEl = document.getElementById('gbFlash');
var valueEl = document.getElementById('gbValue');
var hitBtn = document.getElementById('gbHit');
var isDemo = true;
var mainHP = HP_MAX;
var trailHP = HP_MAX;
var flashStart = -9999;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
var hitIdx = 0;
function render() {
var mp = Math.max(0, Math.min(100, mainHP));
var tp = Math.max(0, Math.min(100, trailHP));
fill.style.width = mp.toFixed(2) + '%';
trailEl.style.width = tp.toFixed(2) + '%';
track.setAttribute('aria-valuenow', String(Math.round(mp)));
valueEl.textContent = Math.round(mp) + ' / ' + HP_MAX;
}
function applyHit(dmg, ts) {
trailHP = mainHP; // 잔상은 피해 직전 값에서 출발해 뒤늦게 따라간다
mainHP = Math.max(0, mainHP - dmg);
flashStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
function onHit() {
human();
var now = performance.now();
if (mainHP <= 0) {
mainHP = HP_MAX; trailHP = HP_MAX; flashStart = -9999;
} else {
applyHit(HIT_LIST[hitIdx % HIT_LIST.length], now);
hitIdx++;
}
render();
}
hitBtn.addEventListener('click', onHit);
hitBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
onHit();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts;
elapsed = 0;
appliedIdx = 0;
mainHP = HP_MAX;
trailHP = HP_MAX;
flashStart = -9999;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
applyHit(EVENTS[appliedIdx][1], ts);
appliedIdx++;
}
}
// 계산 겹: 잔상 지수 평활 — dt(ms) 에 독립적으로 같은 반감기를 유지
trailHP += (mainHP - trailHP) * (1 - Math.exp(-LERP_RATE * dt));
var flash = Math.max(0, Math.exp(-(ts - flashStart) / FLASH_TAU));
flashEl.style.opacity = flash < 0.02 ? '0' : flash.toFixed(3);
render();
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render();
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarDamageFlash.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
// 계산 겹 상수: 시드 고정 전투 이벤트 배열 — [발생시각ms, 피해량]
const EVENTS: [number, number][] = [[300, 24], [1100, 18], [2000, 28], [2900, 12]];
const CYCLE = 3800; // ms, 자동 루프 한 바퀴
const LERP_RATE = 0.006; // 계산 겹: 잔상이 본값을 따라가는 지수 평활 상수(프레임 독립)
const FLASH_TAU = 70; // ms, 플래시 감쇠 시간상수
const HIT_LIST = [24, 18, 28, 12, 20]; // 클릭(실사용자) 피해량 순환
export default function GaugeBarDamageFlash() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trailRef = useRef(null) as unknown as Box<HTMLDivElement>;
const flashRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [value, setValue] = useState(HP_MAX);
const mainHPRef = useRef(HP_MAX) as unknown as Box<number>;
const trailHPRef = useRef(HP_MAX) as unknown as Box<number>;
const flashStartRef = useRef(-9999) as unknown as Box<number>;
const hitIdxRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function applyHit(dmg: number, ts: number) {
trailHPRef.current = mainHPRef.current; // 잔상은 피해 직전 값에서 출발해 뒤늦게 따라간다
mainHPRef.current = Math.max(0, mainHPRef.current - dmg);
flashStartRef.current = ts;
}
function render() {
const mp = Math.max(0, Math.min(100, mainHPRef.current));
const tp = Math.max(0, Math.min(100, trailHPRef.current));
const fill = fillRef.current;
const trail = trailRef.current;
const track = trackRef.current;
if (fill) fill.style.width = mp.toFixed(2) + '%';
if (trail) trail.style.width = tp.toFixed(2) + '%';
if (track) track.setAttribute('aria-valuenow', String(Math.round(mp)));
setValue(Math.round(mp));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let lastTs = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
const dt = ts - lastTs;
lastTs = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts;
elapsed = 0;
appliedIdx = 0;
mainHPRef.current = HP_MAX;
trailHPRef.current = HP_MAX;
flashStartRef.current = -9999;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
applyHit(EVENTS[appliedIdx][1], ts);
appliedIdx++;
}
}
// 계산 겹: 잔상 지수 평활 — dt(ms) 에 독립적으로 같은 반감기를 유지
trailHPRef.current += (mainHPRef.current - trailHPRef.current) * (1 - Math.exp(-LERP_RATE * dt));
const flash = Math.max(0, Math.exp(-(ts - flashStartRef.current) / FLASH_TAU));
const flashEl = flashRef.current;
if (flashEl) flashEl.style.opacity = flash < 0.02 ? '0' : flash.toFixed(3);
render();
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render();
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHit() {
human();
const now = performance.now();
if (mainHPRef.current <= 0) {
mainHPRef.current = HP_MAX;
trailHPRef.current = HP_MAX;
flashStartRef.current = -9999;
} else {
applyHit(HIT_LIST[hitIdxRef.current % HIT_LIST.length], now);
hitIdxRef.current++;
}
render();
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onHit();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>HP</span>
<span className={styles.gbValue}>{value} / {HP_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbTrail} ref={trailRef} />
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbFlash} ref={flashRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHit} onKeyDown={onKeyDown}>
피해 입기
</button>
</div>
);
}01 · 피해 흡수 전환 — 값이 줄어들면 막대가 먼저 좁아지고 그 뒤를 붉은 잔상이 서서히 따라가며 좁아지는 방식 — 측정 누적면적 2.853% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02회복 틱 발광 전환 — 값이 일정 간격으로 조금씩 올라갈 때마다 막대 끝에 빛이 번쩍이는 방식
값이 짧은 간격으로 한 칸씩 올라갈 때마다 막대 끝의 밝기 값이 순간 커졌다가 가라앉습니다. TICK_INTERVAL 마다 tick() 이 값을 한 단계 올리고, 그 순간부터 GLOW_TAU 로 밝기가 지수 감쇠합니다. 자동 회복 상태의 체력·기력 표시나 대기 중 자원 충전 막대에 어울립니다.
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; value = START; lastTickIdx = -1;
}
var idx = Math.floor(elapsed / TICK_INTERVAL);
if (idx > lastTickIdx && idx < TICK_COUNT) {
lastTickIdx = idx;
tick(ts);
}
}
var glow = Math.max(0, Math.exp(-(ts - glowStart) / GLOW_TAU));
render(glow);
requestAnimationFrame(loop);
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">MP</span>
<span class="gb__value" id="gbValue">30 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="MP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="30" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
<div class="gb__edge" id="gbEdge"></div>
</div>
<button type="button" class="gb__hit" id="gbTick">회복 틱 넣기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html tick() 이 고정 간격마다 값을 올리고, loop() 은 매 프레임
// 글로우 값을 exp(-dt/GLOW_TAU) 감쇠 공식으로 다시 계산해 밝기·번짐 두 속성에 동시에 먹인다.
@include stage($stage-yellow);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: visible; }
.gb__fill { position: absolute; left: 0; top: 0; height: 100%; width: 30%; border-radius: $r-pill; background: $subject-mint; will-change: width; }
.gb__edge {
position: absolute; top: 50%; left: 30%;
width: 14px; height: 14px; border-radius: 50%;
background: $subject-mint; transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 10px 4px rgba($subject-mint, .8);
opacity: .35; will-change: opacity, transform;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__edge { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: visible;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 30%;
border-radius: 999px;
background: #4cd4a6;
will-change: width;
}
.gb__edge {
position: absolute;
top: 50%;
left: 30%;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4cd4a6;
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 10px 4px rgba(76, 212, 166, 0.8);
opacity: 0.35;
will-change: opacity, transform;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__edge {
display: none;
}
}(function () {
var RES_MAX = 100;
var START = 30; // 계산 겹 상수: 회복 시작값
var TICK_INTERVAL = 340; // ms, 자동 루프의 틱 간격
var TICK_AMOUNT = 7; // 틱마다 오르는 값
var GLOW_TAU = 150; // ms, 글로우 감쇠 시간상수
var PAUSE = 500; // ms, 가득 찬 뒤 다음 루프 전 대기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var edge = document.getElementById('gbEdge');
var valueEl = document.getElementById('gbValue');
var tickBtn = document.getElementById('gbTick');
var TICK_COUNT = Math.ceil((RES_MAX - START) / TICK_AMOUNT); // 계산 겹: 간격에서 파생
var CYCLE = TICK_COUNT * TICK_INTERVAL + PAUSE;
var isDemo = true;
var value = START;
var glowStart = -9999;
var cycleStart = 0;
var lastTickIdx = -1;
function render(glow) {
var v = Math.max(0, Math.min(RES_MAX, value));
fill.style.width = v.toFixed(2) + '%';
edge.style.left = v.toFixed(2) + '%';
var g = glow < 0.02 ? 0 : glow;
// 계산 겹: 글로우 값이 밝기·번짐 두 CSS 값을 동시에 구동한다
edge.style.opacity = (0.35 + g * 0.65).toFixed(3);
edge.style.transform = 'translate(-50%, -50%) scale(' + (1 + g * 0.9).toFixed(3) + ')';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + RES_MAX;
}
function tick(ts) {
value = Math.min(RES_MAX, value + TICK_AMOUNT);
glowStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
tickBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (value >= RES_MAX) { value = START; }
else { tick(now); }
render(1);
});
tickBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
tickBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; value = START; lastTickIdx = -1;
}
var idx = Math.floor(elapsed / TICK_INTERVAL);
if (idx > lastTickIdx && idx < TICK_COUNT) {
lastTickIdx = idx;
tick(ts);
}
}
var glow = Math.max(0, Math.exp(-(ts - glowStart) / GLOW_TAU));
render(glow);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarRegenTickGlow.module.scss';
type Box<T> = { current: T };
const RES_MAX = 100;
const START = 30; // 계산 겹 상수: 회복 시작값
const TICK_INTERVAL = 340; // ms, 자동 루프의 틱 간격
const TICK_AMOUNT = 7; // 틱마다 오르는 값
const GLOW_TAU = 150; // ms, 글로우 감쇠 시간상수
const PAUSE = 500; // ms, 가득 찬 뒤 다음 루프 전 대기
const TICK_COUNT = Math.ceil((RES_MAX - START) / TICK_AMOUNT); // 계산 겹: 간격에서 파생
const CYCLE = TICK_COUNT * TICK_INTERVAL + PAUSE;
export default function GaugeBarRegenTickGlow() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const edgeRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [value, setValue] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
const glowStartRef = useRef(-9999) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function tick(ts: number) {
valueRef.current = Math.min(RES_MAX, valueRef.current + TICK_AMOUNT);
glowStartRef.current = ts;
}
function render(glow: number) {
const v = Math.max(0, Math.min(RES_MAX, valueRef.current));
const fill = fillRef.current;
const edge = edgeRef.current;
const track = trackRef.current;
const g = glow < 0.02 ? 0 : glow;
if (fill) fill.style.width = v.toFixed(2) + '%';
if (edge) {
edge.style.left = v.toFixed(2) + '%';
// 계산 겹: 글로우 값이 밝기·번짐 두 CSS 값을 동시에 구동한다
edge.style.opacity = (0.35 + g * 0.65).toFixed(3);
edge.style.transform = 'translate(-50%, -50%) scale(' + (1 + g * 0.9).toFixed(3) + ')';
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(v)));
setValue(Math.round(v));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let lastTickIdx = -1;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; valueRef.current = START; lastTickIdx = -1;
}
const idx = Math.floor(elapsed / TICK_INTERVAL);
if (idx > lastTickIdx && idx < TICK_COUNT) {
lastTickIdx = idx;
tick(ts);
}
}
const glow = Math.max(0, Math.exp(-(ts - glowStartRef.current) / GLOW_TAU));
render(glow);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onTick() {
human();
if (valueRef.current >= RES_MAX) { valueRef.current = START; }
else { tick(performance.now()); }
render(1);
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onTick();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>MP</span>
<span className={styles.gbValue}>{value} / {RES_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="MP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbEdge} ref={edgeRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onTick} onKeyDown={onKeyDown}>
회복 틱 넣기
</button>
</div>
);
}02 · 회복 틱 발광 전환 — 값이 일정 간격으로 조금씩 올라갈 때마다 막대 끝에 빛이 번쩍이는 방식 — 측정 누적면적 5.433% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03조각 칸 균열 전환 — 막대가 여러 칸으로 나뉘어 있고 값이 한 칸 밑으로 내려가면 그 칸에 균열 모양이 남는 방식
값이 칸 하나를 넘어 내려가는 순간 그 칸의 갈라짐 정도 값이 커지며 다음 칸부터 채움이 사라집니다. stateFor() 가 각 칸이 깨진 시각으로부터 지난 시간을 CRACK_DUR·STAGGER 로 나눠 균열 두 겹과 채움 값을 각각 계산합니다. 보스 체력의 단계 구간 표시나 목숨 칸 표시에 씁니다.
function stateFor(pipIdx, ts) {
var eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdx) {
return { c1: 0, c2: 0, fill: 1 };
}
var t0 = eventTs[eventIndex];
var dt = Math.max(0, ts - t0);
var c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
var c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
var fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1: c1, c2: c2, fill: fill };
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">보스 체력</span>
<span class="gb__value" id="gbValue">5 / 5</span>
</div>
<div class="gb__track" id="gbTrack" role="progressbar" aria-label="보스 체력" aria-valuemin="0" aria-valuemax="5" aria-valuenow="5"></div>
<button type="button" class="gb__hit" id="gbBreak">칸 깨기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html stateFor() 가 각 칸이 깨진 시각과 현재 시각의 차(dt)로
// 균열 두 줄의 등장 시점(stagger)과 채움 투명도를 매 프레임 다시 계산한다.
@include stage($stage-orange);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-1; height: 22px; }
.gb__pip { position: relative; border-radius: $r-xs; background: rgba($subject-ink, .12); overflow: hidden; }
.gb__pipFill { position: absolute; inset: 0; background: $subject-blue-deep; opacity: 1; will-change: opacity; }
.gb__crack {
position: absolute; left: 50%; top: 50%; width: 2px; height: 70%;
background: $subject-cream; opacity: 0; will-change: opacity;
}
.gb__crack--a { transform: translate(-50%, -50%) rotate(24deg); }
.gb__crack--b { transform: translate(-50%, -50%) rotate(-28deg); }
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 18px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__crack { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
height: 22px;
}
.gb__pip {
position: relative;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.gb__pipFill {
position: absolute;
inset: 0;
background: #0a4fe8;
opacity: 1;
will-change: opacity;
}
.gb__crack {
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 70%;
background: #fff7e6;
opacity: 0;
will-change: opacity;
}
.gb__crack--a {
transform: translate(-50%, -50%) rotate(24deg);
}
.gb__crack--b {
transform: translate(-50%, -50%) rotate(-28deg);
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 18px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__crack {
display: none;
}
}(function () {
var PIPS = 5; // 계산 겹 상수: 칸 개수
var EVENT_T = [400, 1300, 2200, 3100]; // ms, 자동 루프 안에서 칸이 깨지는 시각(PIPS-1 개)
var CRACK_DUR = 520; // ms, 한 칸이 다 깨지는 데 걸리는 시간
var STAGGER = 150; // ms, 균열 두 줄 사이 지연 — 칸 단위 stagger
var PAUSE = 500;
var CYCLE = EVENT_T[EVENT_T.length - 1] + CRACK_DUR + PAUSE;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var valueEl = document.getElementById('gbValue');
var breakBtn = document.getElementById('gbBreak');
var pips = [];
for (var i = 0; i < PIPS; i++) {
var pip = document.createElement('div');
pip.className = 'gb__pip';
var fillEl = document.createElement('div');
fillEl.className = 'gb__pipFill';
var c1 = document.createElement('div');
c1.className = 'gb__crack gb__crack--a';
var c2 = document.createElement('div');
c2.className = 'gb__crack gb__crack--b';
pip.appendChild(fillEl);
pip.appendChild(c1);
pip.appendChild(c2);
track.appendChild(pip);
pips.push({ el: pip, fill: fillEl, c1: c1, c2: c2 });
}
var isDemo = true;
var appliedIdx = 0; // 몇 칸이 깨지는 이벤트를 지났는가 (0..PIPS-1)
var eventTs = []; // 실제 깨진 시각 기록(자동·수동 공용)
var cycleStart = 0;
// 칸 pipIdx 는 오른쪽부터 먼저 깨진다: eventIndex = PIPS-1-pipIdx
function stateFor(pipIdx, ts) {
var eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdx) {
return { c1: 0, c2: 0, fill: 1 };
}
var t0 = eventTs[eventIndex];
var dt = Math.max(0, ts - t0);
var c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
var c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
var fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1: c1, c2: c2, fill: fill };
}
function render(ts) {
for (var i = 0; i < PIPS; i++) {
var s = stateFor(i, ts);
pips[i].fill.style.opacity = s.fill.toFixed(3);
pips[i].c1.style.opacity = s.c1.toFixed(3);
pips[i].c2.style.opacity = s.c2.toFixed(3);
}
var remaining = PIPS - appliedIdx;
valueEl.textContent = remaining + ' / ' + PIPS;
track.setAttribute('aria-valuenow', String(remaining));
}
function breakOne(ts) {
eventTs[appliedIdx] = ts;
appliedIdx++;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
breakBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (appliedIdx >= PIPS - 1) { appliedIdx = 0; eventTs = []; }
else { breakOne(now); }
render(now);
});
breakBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
breakBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; eventTs = [];
}
for (var i = 0; i < EVENT_T.length; i++) {
if (elapsed >= EVENT_T[i] && appliedIdx <= i) {
eventTs[i] = cycleStart + EVENT_T[i];
appliedIdx = i + 1;
}
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarSegmentPipCrack.module.scss';
type Box<T> = { current: T };
const PIPS = 5; // 계산 겹 상수: 칸 개수
const EVENT_T = [400, 1300, 2200, 3100]; // ms, 자동 루프 안에서 칸이 깨지는 시각(PIPS-1 개)
const CRACK_DUR = 520; // ms, 한 칸이 다 깨지는 데 걸리는 시간
const STAGGER = 150; // ms, 균열 두 줄 사이 지연 — 칸 단위 stagger
const PAUSE = 500;
const CYCLE = EVENT_T[EVENT_T.length - 1] + CRACK_DUR + PAUSE;
type PipState = { c1: number; c2: number; fill: number };
export default function GaugeBarSegmentPipCrack() {
const pipRefs = useRef(
Array.from({ length: PIPS }, () => ({ fill: null, c1: null, c2: null }))
) as unknown as Box<{ fill: HTMLDivElement | null; c1: HTMLDivElement | null; c2: HTMLDivElement | null }[]>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [remaining, setRemaining] = useState(PIPS);
const appliedIdxRef = useRef(0) as unknown as Box<number>;
const eventTsRef = useRef<number[]>([]) as unknown as Box<number[]>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
// 칸 pipIdx 는 오른쪽부터 먼저 깨진다: eventIndex = PIPS-1-pipIdx
function stateFor(pipIdx: number, ts: number): PipState {
const eventIndex = PIPS - 1 - pipIdx;
if (eventIndex < 0 || eventIndex >= appliedIdxRef.current) {
return { c1: 0, c2: 0, fill: 1 };
}
const t0 = eventTsRef.current[eventIndex];
const dt = Math.max(0, ts - t0);
const c1 = Math.min(1, dt / (CRACK_DUR * 0.45));
const c2 = Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR * 0.45));
const fill = 1 - Math.min(1, Math.max(0, dt - STAGGER) / (CRACK_DUR - STAGGER));
return { c1, c2, fill };
}
function render(ts: number) {
for (let i = 0; i < PIPS; i++) {
const s = stateFor(i, ts);
const r = pipRefs.current[i];
if (r.fill) r.fill.style.opacity = s.fill.toFixed(3);
if (r.c1) r.c1.style.opacity = s.c1.toFixed(3);
if (r.c2) r.c2.style.opacity = s.c2.toFixed(3);
}
const rem = PIPS - appliedIdxRef.current;
const track = trackRef.current;
if (track) track.setAttribute('aria-valuenow', String(rem));
setRemaining(rem);
}
function breakOne(ts: number) {
eventTsRef.current[appliedIdxRef.current] = ts;
appliedIdxRef.current++;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= CYCLE) {
cycleStart = ts; elapsed = 0; appliedIdxRef.current = 0; eventTsRef.current = [];
}
for (let i = 0; i < EVENT_T.length; i++) {
if (elapsed >= EVENT_T[i] && appliedIdxRef.current <= i) {
eventTsRef.current[i] = cycleStart + EVENT_T[i];
appliedIdxRef.current = i + 1;
}
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onBreak() {
human();
const now = performance.now();
if (appliedIdxRef.current >= PIPS - 1) { appliedIdxRef.current = 0; eventTsRef.current = []; }
else { breakOne(now); }
render(now);
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onBreak();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>보스 체력</span>
<span className={styles.gbValue}>{remaining} / {PIPS}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="보스 체력"
aria-valuemin={0}
aria-valuemax={PIPS}
aria-valuenow={remaining}
ref={trackRef}
>
{Array.from({ length: PIPS }, (_, i) => (
<div className={styles.gbPip} key={i}>
<div
className={styles.gbPipFill}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].fill = el; }}
/>
<div
className={`${styles.gbCrack} ${styles.gbCrackA}`}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].c1 = el; }}
/>
<div
className={`${styles.gbCrack} ${styles.gbCrackB}`}
ref={(el: HTMLDivElement | null) => { pipRefs.current[i].c2 = el; }}
/>
</div>
))}
</div>
<button type="button" className={styles.gbHit} onClick={onBreak} onKeyDown={onKeyDown}>
칸 깨기
</button>
</div>
);
}03 · 조각 칸 균열 전환 — 막대가 여러 칸으로 나뉘어 있고 값이 한 칸 밑으로 내려가면 그 칸에 균열 모양이 남는 방식 — 측정 누적면적 1.917% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04초과 회복 겹막 전환 — 값이 상한을 넘으면 그 초과분이 막대 위에 반투명한 겹으로 얹히는 방식
값이 상한 값을 넘어서면 그 차이만큼의 폭이 반투명 겹으로 기존 막대 위에 얹혀 나타납니다. 값은 HP_MAX+OVERHEAL_CAP 까지만 오를 수 있고, 상한을 넘긴 뒤에는 DECAY_RATE 로 초과분만 서서히 깎입니다. 보호막이 덧씌워진 체력 표시나 한도를 넘긴 자원 막대에 맞습니다.
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 700) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
value = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
if (value > HP_MAX) {
value = HP_MAX + (value - HP_MAX) * Math.exp(-DECAY_RATE * dt);
}
render();
requestAnimationFrame(loop);
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">HP</span>
<span class="gb__value" id="gbValue">55 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="55" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
<div class="gb__shield" id="gbShield"></div>
</div>
<button type="button" class="gb__hit" id="gbHeal">회복 얹기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html loop() 이 매 프레임 상한 위 초과분을 exp(-DECAY_RATE*dt) 로
// 스스로 줄이고, 그 값에서 쉴드 겹의 폭·투명도를 다시 계산해 그린다.
@include stage($stage-paper);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__fill { position: absolute; left: 0; top: 0; height: 100%; width: 55%; border-radius: $r-pill; background: $subject-blue-deep; will-change: width; }
.gb__shield {
position: absolute; left: 0; top: 0; height: 100%; width: 0%;
border-radius: $r-pill; background: $subject-sky; opacity: 0; z-index: 1;
will-change: width, opacity;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__shield { display: none; }
}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);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 55%;
border-radius: 999px;
background: #0a4fe8;
will-change: width;
}
.gb__shield {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #6ec8ff;
opacity: 0;
z-index: 1;
will-change: width, opacity;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__shield {
display: none;
}
}(function () {
var HP_MAX = 100; // 상한
var OVERHEAL_CAP = 50; // 계산 겹 상수: 상한 위로 표시되는 쉴드 겹의 최대 폭 기준값
var START = 55;
var EVENTS = [[300, 45], [1300, 55], [2300, -25], [3200, -15]]; // ms, 값 변화(+회복/-피해)
var DECAY_RATE = 0.00065; // 계산 겹: 상한 위 초과분이 계속 스스로 줄어드는 비율(ms 당)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var shield = document.getElementById('gbShield');
var valueEl = document.getElementById('gbValue');
var healBtn = document.getElementById('gbHeal');
var isDemo = true;
var value = START;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
function render() {
var base = Math.min(value, HP_MAX);
var excess = Math.max(0, value - HP_MAX);
var shieldRatio = Math.min(1, excess / OVERHEAL_CAP);
fill.style.width = base.toFixed(2) + '%';
// 계산 겹: 초과분 폭 = 상한 위 값 / 쉴드 표시 기준값 — 반투명 겹의 폭·투명도를 함께 구동
shield.style.width = (shieldRatio * 100).toFixed(2) + '%';
shield.style.opacity = shieldRatio < 0.02 ? '0' : (0.4 + shieldRatio * 0.5).toFixed(3);
track.setAttribute('aria-valuenow', String(Math.round(base)));
valueEl.textContent = Math.round(value) + ' / ' + HP_MAX;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
healBtn.addEventListener('click', function () {
human();
value = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, value + 30));
render();
});
healBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
healBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 700) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
value = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
if (value > HP_MAX) {
value = HP_MAX + (value - HP_MAX) * Math.exp(-DECAY_RATE * dt);
}
render();
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render();
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarOverhealShieldCap.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100; // 상한
const OVERHEAL_CAP = 50; // 계산 겹 상수: 상한 위로 표시되는 쉴드 겹의 최대 폭 기준값
const START = 55;
const EVENTS: [number, number][] = [[300, 45], [1300, 55], [2300, -25], [3200, -15]]; // ms, 값 변화(+회복/-피해)
const DECAY_RATE = 0.00065; // 계산 겹: 상한 위 초과분이 계속 스스로 줄어드는 비율(ms 당)
export default function GaugeBarOverhealShieldCap() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const shieldRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [value, setValue] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render() {
const v = valueRef.current;
const base = Math.min(v, HP_MAX);
const excess = Math.max(0, v - HP_MAX);
const shieldRatio = Math.min(1, excess / OVERHEAL_CAP);
const fill = fillRef.current;
const shield = shieldRef.current;
const track = trackRef.current;
if (fill) fill.style.width = base.toFixed(2) + '%';
// 계산 겹: 초과분 폭 = 상한 위 값 / 쉴드 표시 기준값 — 반투명 겹의 폭·투명도를 함께 구동
if (shield) {
shield.style.width = (shieldRatio * 100).toFixed(2) + '%';
shield.style.opacity = shieldRatio < 0.02 ? '0' : (0.4 + shieldRatio * 0.5).toFixed(3);
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(base)));
setValue(Math.round(v));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let lastTs = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
const dt = ts - lastTs;
lastTs = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 700) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; valueRef.current = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
valueRef.current = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, valueRef.current + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
if (valueRef.current > HP_MAX) {
valueRef.current = HP_MAX + (valueRef.current - HP_MAX) * Math.exp(-DECAY_RATE * dt);
}
render();
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render();
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHeal() {
human();
valueRef.current = Math.max(0, Math.min(HP_MAX + OVERHEAL_CAP, valueRef.current + 30));
render();
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onHeal();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>HP</span>
<span className={styles.gbValue}>{value} / {HP_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.min(value, HP_MAX)}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbShield} ref={shieldRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHeal} onKeyDown={onKeyDown}>
회복 얹기
</button>
</div>
);
}04 · 초과 회복 겹막 전환 — 값이 상한을 넘으면 그 초과분이 막대 위에 반투명한 겹으로 얹히는 방식 — 측정 누적면적 2.868% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05위험 구간 박동 전환 — 값이 정해둔 낮은 구간에 들어가면 막대 전체가 일정한 리듬으로 밝아지고 어두워지는 방식
값이 정해둔 낮은 경계 밑으로 내려가면 막대의 밝기 값이 정해진 리듬으로 오르내리기 시작합니다. pulseFor() 가 THRESHOLD 와의 차이로 박동 주기를 PERIOD_MIN 쪽으로 좁혀가며 사인 값 하나로 밝기·글로우를 같이 흔듭니다. 체력이 낮을 때의 경고 표시나 남은 시간이 얼마 없을 때의 알림 막대에 씁니다.
function pulseFor(v, ts) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 계산 겹: 잔량이 낮을수록 박동 주기는 짧아지고 진폭은 커진다(반비례) — 매 프레임 v 에서 다시 계산
var ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
var period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
var amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
var phase = (ts % period) / period * Math.PI * 2;
var osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
function render(ts) {
var v = Math.max(0, Math.min(HP_MAX, value));
fill.style.width = v.toFixed(2) + '%';
var p = pulseFor(v, ts);
fill.style.filter = 'brightness(' + p.brightness.toFixed(3) + ')';
fill.style.boxShadow = p.glow > 0.02
? '0 0 ' + (6 + p.glow * 18).toFixed(1) + 'px ' + (p.glow * 6).toFixed(1) + 'px rgba(255,120,90,' + p.glow.toFixed(3) + ')'
: 'none';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + HP_MAX;
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">HP</span>
<span class="gb__value" id="gbValue">70 / 100</span>
</div>
<div class="gb__track" role="progressbar" aria-label="HP" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
</div>
<button type="button" class="gb__hit" id="gbHit">피해 입기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html pulseFor() 가 잔량 v 에서 매 프레임 박동 주기·진폭을 반비례로 다시 계산한다
// (잔량이 낮을수록 주기는 짧고 진폭은 크다) — 고정 keyframe 반복이 아니다.
@include stage($stage-ink);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__fill {
position: absolute; left: 0; top: 0; height: 100%; width: 70%; border-radius: $r-pill;
background: $error; will-change: filter, box-shadow;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__fill { filter: none !important; box-shadow: none !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);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 70%;
border-radius: 999px;
background: #c2361a;
will-change: filter, box-shadow;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill {
filter: none !important;
box-shadow: none !important;
}
}(function () {
var HP_MAX = 100;
var THRESHOLD = 30; // 계산 겹 상수: 위험 구간 진입 기준
var START = 70;
var EVENTS = [[400, -20], [1400, -15], [2200, -10], [3000, -15]]; // ms, 값 변화
var PERIOD_MAX = 900; // ms, 임계 진입 직후(잔량 많음) 박동 주기
var PERIOD_MIN = 280; // ms, 잔량 0 에 가까울 때 박동 주기
var AMP_MIN = 0.12; // 임계 진입 직후 박동 진폭
var AMP_MAX = 0.5; // 잔량 0 에 가까울 때 박동 진폭
var HIT_LIST = [20, 15, 10, 15, 20];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var valueEl = document.getElementById('gbValue');
var hitBtn = document.getElementById('gbHit');
var isDemo = true;
var value = START;
var appliedIdx = 0;
var cycleStart = 0;
var hitIdx = 0;
function pulseFor(v, ts) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 계산 겹: 잔량이 낮을수록 박동 주기는 짧아지고 진폭은 커진다(반비례) — 매 프레임 v 에서 다시 계산
var ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
var period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
var amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
var phase = (ts % period) / period * Math.PI * 2;
var osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
function render(ts) {
var v = Math.max(0, Math.min(HP_MAX, value));
fill.style.width = v.toFixed(2) + '%';
var p = pulseFor(v, ts);
fill.style.filter = 'brightness(' + p.brightness.toFixed(3) + ')';
fill.style.boxShadow = p.glow > 0.02
? '0 0 ' + (6 + p.glow * 18).toFixed(1) + 'px ' + (p.glow * 6).toFixed(1) + 'px rgba(255,120,90,' + p.glow.toFixed(3) + ')'
: 'none';
track.setAttribute('aria-valuenow', String(Math.round(v)));
valueEl.textContent = Math.round(v) + ' / ' + HP_MAX;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
hitBtn.addEventListener('click', function () {
human();
if (value <= 0) { value = START; }
else { value = Math.max(0, value - HIT_LIST[hitIdx % HIT_LIST.length]); hitIdx++; }
render(performance.now());
});
hitBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
hitBtn.click();
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 1000) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
value = Math.max(0, Math.min(HP_MAX, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarCriticalPulse.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
const THRESHOLD = 30; // 계산 겹 상수: 위험 구간 진입 기준
const START = 70;
const EVENTS: [number, number][] = [[400, -20], [1400, -15], [2200, -10], [3000, -15]]; // ms, 값 변화
const PERIOD_MAX = 900; // ms, 임계 진입 직후(잔량 많음) 박동 주기
const PERIOD_MIN = 280; // ms, 잔량 0 에 가까울 때 박동 주기
const AMP_MIN = 0.12; // 임계 진입 직후 박동 진폭
const AMP_MAX = 0.5; // 잔량 0 에 가까울 때 박동 진폭
const HIT_LIST = [20, 15, 10, 15, 20];
function pulseFor(v: number, ts: number) {
if (v >= THRESHOLD) return { brightness: 1, glow: 0 };
// 계산 겹: 잔량이 낮을수록 박동 주기는 짧아지고 진폭은 커진다(반비례) — 매 프레임 v 에서 다시 계산
const ratio = (THRESHOLD - v) / THRESHOLD; // 0..1
const period = PERIOD_MAX - (PERIOD_MAX - PERIOD_MIN) * ratio;
const amp = AMP_MIN + (AMP_MAX - AMP_MIN) * ratio;
const phase = ((ts % period) / period) * Math.PI * 2;
const osc = Math.sin(phase);
return { brightness: 1 + amp * osc, glow: Math.max(0, osc) * amp };
}
export default function GaugeBarCriticalPulse() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [value, setValue] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
const hitIdxRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(ts: number) {
const v = Math.max(0, Math.min(HP_MAX, valueRef.current));
const fill = fillRef.current;
const track = trackRef.current;
if (fill) {
fill.style.width = v.toFixed(2) + '%';
const p = pulseFor(v, ts);
fill.style.filter = 'brightness(' + p.brightness.toFixed(3) + ')';
fill.style.boxShadow = p.glow > 0.02
? '0 0 ' + (6 + p.glow * 18).toFixed(1) + 'px ' + (p.glow * 6).toFixed(1) + 'px rgba(255,120,90,' + p.glow.toFixed(3) + ')'
: 'none';
}
if (track) track.setAttribute('aria-valuenow', String(Math.round(v)));
setValue(Math.round(v));
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
let elapsed = ts - cycleStart;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 1000) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; valueRef.current = START;
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
valueRef.current = Math.max(0, Math.min(HP_MAX, valueRef.current + EVENTS[appliedIdx][1]));
appliedIdx++;
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onHit() {
human();
if (valueRef.current <= 0) { valueRef.current = START; }
else { valueRef.current = Math.max(0, valueRef.current - HIT_LIST[hitIdxRef.current % HIT_LIST.length]); hitIdxRef.current++; }
render(performance.now());
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onHit();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>HP</span>
<span className={styles.gbValue}>{value} / {HP_MAX}</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onHit} onKeyDown={onKeyDown}>
피해 입기
</button>
</div>
);
}05 · 위험 구간 박동 전환 — 값이 정해둔 낮은 구간에 들어가면 막대 전체가 일정한 리듬으로 밝아지고 어두워지는 방식 — 측정 누적면적 1.985% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06충전 유지 방출 전환 — 누르고 있는 동안 값이 천천히 차오르고 손을 떼는 순간 한 번에 쏟아지는 방식
누르고 있는 동안 값이 정해둔 빠르기로 차오르고, 떼는 순간 그 값이 0으로 곧장 떨어지며 별도의 방출 표시가 켜집니다. burstAt() 이 방출 경과 시간을 RELEASE_ZETA 로 감쇠시킨 뒤 코사인 값과 곱해 튕기는 위치를 계산합니다. 강공격 충전 표시나 대기 후 발사하는 자원 막대에 씁니다.
function burstAt(t) {
// 계산 겹: 방출 스프링 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아오는 위치를 계산
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
var x = t / RELEASE_DUR;
var amp = Math.exp(-RELEASE_ZETA * x);
var pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
function render(ts) {
fill.style.width = value.toFixed(2) + '%';
valueEl.textContent = Math.round(value) + '%';
track.setAttribute('aria-valuenow', String(Math.round(value)));
var b = burstAt(ts - releaseStart);
burst.style.left = releaseFromPct.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">강공격</span>
<span class="gb__value" id="gbValue">0%</span>
</div>
<div class="gb__track" role="progressbar" aria-label="충전량" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
<div class="gb__burst" id="gbBurst"></div>
</div>
<button type="button" class="gb__hit" id="gbCharge">누르고 있기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html burstAt() 이 감쇠 코사인 진동(스프링 방정식)으로 방출 순간의
// 링 크기·투명도를 매 프레임 계산한다 — 손을 떼는 순간 값은 즉시 0, 링만 스프링으로 남는다.
@include stage($stage-yellow);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: visible; }
.gb__fill { position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill; background: $subject-lilac; will-change: width; }
.gb__burst {
position: absolute; top: 50%; left: 0%;
width: 16px; height: 16px; border-radius: 50%;
background: $subject-lilac; opacity: 0;
transform: translate(-50%, -50%) scale(0);
will-change: opacity, transform;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer; touch-action: none;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__burst { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: visible;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #b9a0ff;
will-change: width;
}
.gb__burst {
position: absolute;
top: 50%;
left: 0%;
width: 16px;
height: 16px;
border-radius: 50%;
background: #b9a0ff;
opacity: 0;
transform: translate(-50%, -50%) scale(0);
will-change: opacity, transform;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
touch-action: none;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__burst {
display: none;
}
}(function () {
var CHARGE_MAX = 100;
var CHARGE_RATE = 0.062; // 계산 겹 상수: ms 당 충전량
var HOLD_DUR = 1550; // ms, 자동 루프가 누르고 있는 시간
var RELEASE_DUR = 700; // ms, 방출 스프링 지속
var RELEASE_FREQ = 2 * Math.PI * 2.1 / RELEASE_DUR; // 계산 겹: 스프링 각속도
var RELEASE_ZETA = 3.6; // 계산 겹: 스프링 감쇠 상수
var PAUSE = 700;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var burst = document.getElementById('gbBurst');
var valueEl = document.getElementById('gbValue');
var chargeBtn = document.getElementById('gbCharge');
var isDemo = true;
var value = 0;
var holding = false;
var holdStart = 0;
var releaseStart = -9999;
var releaseFromPct = 0;
var cycleStart = 0;
var demoPhase = 'charging'; // charging → released → pause
function burstAt(t) {
// 계산 겹: 방출 스프링 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아오는 위치를 계산
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
var x = t / RELEASE_DUR;
var amp = Math.exp(-RELEASE_ZETA * x);
var pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
function render(ts) {
fill.style.width = value.toFixed(2) + '%';
valueEl.textContent = Math.round(value) + '%';
track.setAttribute('aria-valuenow', String(Math.round(value)));
var b = burstAt(ts - releaseStart);
burst.style.left = releaseFromPct.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}
function startCharge(ts) {
holding = true;
holdStart = ts;
}
function releaseCharge(ts) {
holding = false;
releaseFromPct = value;
releaseStart = ts;
value = 0;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
chargeBtn.addEventListener('pointerdown', function (ev) {
human();
startCharge(performance.now());
});
chargeBtn.addEventListener('pointerup', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('pointerleave', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('pointercancel', function () { if (holding) releaseCharge(performance.now()); });
chargeBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
if (!holding) { human(); startCharge(performance.now()); }
});
chargeBtn.addEventListener('keyup', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
if (holding) releaseCharge(performance.now());
});
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
if (demoPhase === 'charging' && elapsed >= HOLD_DUR) {
releaseFromPct = value;
releaseStart = ts;
value = 0;
demoPhase = 'released';
} else if (demoPhase === 'released' && ts - releaseStart >= RELEASE_DUR + PAUSE) {
cycleStart = ts;
demoPhase = 'charging';
value = 0;
} else if (demoPhase === 'charging') {
value = Math.min(CHARGE_MAX, elapsed * CHARGE_RATE);
}
} else if (holding) {
value = Math.min(CHARGE_MAX, (ts - holdStart) * CHARGE_RATE);
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarChargeHoldRelease.module.scss';
type Box<T> = { current: T };
const CHARGE_MAX = 100;
const CHARGE_RATE = 0.062; // 계산 겹 상수: ms 당 충전량
const HOLD_DUR = 1550; // ms, 자동 루프가 누르고 있는 시간
const RELEASE_DUR = 700; // ms, 방출 스프링 지속
const RELEASE_FREQ = (2 * Math.PI * 2.1) / RELEASE_DUR; // 계산 겹: 스프링 각속도
const RELEASE_ZETA = 3.6; // 계산 겹: 스프링 감쇠 상수
const PAUSE = 700;
function burstAt(t: number) {
// 계산 겹: 방출 스프링 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아오는 위치를 계산
if (t < 0 || t > RELEASE_DUR) return { scale: 0, alpha: 0 };
const x = t / RELEASE_DUR;
const amp = Math.exp(-RELEASE_ZETA * x);
const pos = 1 - amp * Math.cos(RELEASE_FREQ * t);
return { scale: 1 + pos * 1.1, alpha: Math.max(0, amp * 1.15) };
}
export default function GaugeBarChargeHoldRelease() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const burstRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [value, setValue] = useState(0);
const valueRef = useRef(0) as unknown as Box<number>;
const holdingRef = useRef(false) as unknown as Box<boolean>;
const holdStartRef = useRef(0) as unknown as Box<number>;
const releaseStartRef = useRef(-9999) as unknown as Box<number>;
const releaseFromPctRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(ts: number) {
const fill = fillRef.current;
const burst = burstRef.current;
const track = trackRef.current;
if (fill) fill.style.width = valueRef.current.toFixed(2) + '%';
if (track) track.setAttribute('aria-valuenow', String(Math.round(valueRef.current)));
setValue(Math.round(valueRef.current));
const b = burstAt(ts - releaseStartRef.current);
if (burst) {
burst.style.left = releaseFromPctRef.current.toFixed(2) + '%';
burst.style.opacity = b.alpha < 0.02 ? '0' : b.alpha.toFixed(3);
burst.style.transform = 'translate(-50%, -50%) scale(' + b.scale.toFixed(3) + ')';
}
}
function startCharge(ts: number) {
holdingRef.current = true;
holdStartRef.current = ts;
}
function releaseCharge(ts: number) {
holdingRef.current = false;
releaseFromPctRef.current = valueRef.current;
releaseStartRef.current = ts;
valueRef.current = 0;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let demoPhase: 'charging' | 'released' = 'charging';
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemoRef.current) {
const elapsed = ts - cycleStart;
if (demoPhase === 'charging' && elapsed >= HOLD_DUR) {
releaseFromPctRef.current = valueRef.current;
releaseStartRef.current = ts;
valueRef.current = 0;
demoPhase = 'released';
} else if (demoPhase === 'released' && ts - releaseStartRef.current >= RELEASE_DUR + PAUSE) {
cycleStart = ts;
demoPhase = 'charging';
valueRef.current = 0;
} else if (demoPhase === 'charging') {
valueRef.current = Math.min(CHARGE_MAX, elapsed * CHARGE_RATE);
}
} else if (holdingRef.current) {
valueRef.current = Math.min(CHARGE_MAX, (ts - holdStartRef.current) * CHARGE_RATE);
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onPointerDown() {
human();
startCharge(performance.now());
}
function onPointerUp() {
if (holdingRef.current) releaseCharge(performance.now());
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (!holdingRef.current) { human(); startCharge(performance.now()); }
}
function onKeyUp(e: { key: string }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (holdingRef.current) releaseCharge(performance.now());
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>강공격</span>
<span className={styles.gbValue}>{value}%</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="충전량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
<div className={styles.gbBurst} ref={burstRef} />
</div>
<button
type="button"
className={styles.gbHit}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
누르고 있기
</button>
</div>
);
}06 · 충전 유지 방출 전환 — 누르고 있는 동안 값이 천천히 차오르고 손을 떼는 순간 한 번에 쏟아지는 방식 — 측정 누적면적 3.328% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07다중 레인 대비 전환 — 같은 자리에 놓인 두 개 이상의 막대가 서로 다른 값으로 나란히 채워지는 방식
나란히 놓인 각 막대의 채움 폭 값이 저마다 다른 빠르기로 따로 올라가 서로 벌어지거나 좁혀집니다. valuesAt() 이 레인마다 다른 사인 값을 계산하고, 클릭할 때마다 JOLTS 배열의 값이 레인별 오프셋에 더해집니다. 여러 대상의 체력을 한 화면에서 비교하는 표시나 순위 진행도 비교에 맞습니다.
function valuesAt(t) {
return LANES.map(function (l) {
var v = l.base + l.offset + l.amp * Math.sin(l.freq * t / 1000 + l.phase);
return Math.max(4, Math.min(100, v));
});
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
joltBtn.addEventListener('click', function () {
human();
var d = JOLTS[joltIdx % JOLTS.length];
for (var i = 0; i < LANES.length; i++) LANES[i].offset += d[i];
joltIdx++;
});

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">진행도 비교</span>
</div>
<div class="gb__lanes" id="gbLanes"></div>
<button type="button" class="gb__hit" id="gbJolt">순위 흔들기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html render() 가 매 프레임 4레인 값을 계산해 내림차순 정렬하고
// 선두·2위 차이만큼만 밝은 겹을 얹는다. 왕관 표시는 DOM 순서를 바꾸지 않고
// translateY 로 현재 선두 행에만 옮겨진다 — 값의 사인 궤적도 계산이다.
@include stage($stage-orange);
.gb { width: 100%; max-width: 280px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__lanes { position: relative; display: flex; flex-direction: column; gap: $sp-2; }
.gb__row { display: flex; align-items: center; gap: $sp-2; height: 22px; }
.gb__laneLabel { width: 14px; font-size: 12px; font-weight: 700; color: $subject-ink; flex: 0 0 auto; }
.gb__laneTrack { position: relative; flex: 1 1 auto; height: 10px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__laneFill { position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill; background: $subject-blue; will-change: width; }
.gb__laneFill--lead { background: $subject-blue-deep; }
.gb__laneLead {
position: absolute; top: 0; height: 100%; width: 0%; left: 0%;
background: $subject-mint; opacity: 0; will-change: width, left, opacity;
}
.gb__crown {
position: absolute; left: -2px; top: 0;
width: 0; height: 0; border-style: solid;
border-width: 5px 0 5px 7px;
border-color: transparent transparent transparent $subject-orange-deep;
transform: translateY(0px); will-change: transform;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-3; }
.gb__head { margin-bottom: $sp-2; }
.gb__label { font-size: 10px; }
.gb__lanes { gap: $sp-1; }
.gb__row { height: 14px; gap: $sp-1; }
.gb__laneLabel { font-size: 10px; }
.gb__crown { border-width: 4px 0 4px 6px; }
.gb__hit { margin-top: $sp-2; padding: $sp-2 0; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__laneFill, .gb__laneLead, .gb__crown { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gb {
width: 100%;
max-width: 280px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__lanes {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
.gb__row {
display: flex;
align-items: center;
gap: 8px;
height: 22px;
}
.gb__laneLabel {
width: 14px;
font-size: 12px;
font-weight: 700;
color: #17141a;
flex: 0 0 auto;
}
.gb__laneTrack {
position: relative;
flex: 1 1 auto;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__laneFill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #2f6df6;
will-change: width;
}
.gb__laneFill--lead {
background: #0a4fe8;
}
.gb__laneLead {
position: absolute;
top: 0;
height: 100%;
width: 0%;
left: 0%;
background: #4cd4a6;
opacity: 0;
will-change: width, left, opacity;
}
.gb__crown {
position: absolute;
left: -2px;
top: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 7px;
border-color: transparent transparent transparent #c93000;
transform: translateY(0px);
will-change: transform;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 12px;
}
.gb__head {
margin-bottom: 8px;
}
.gb__label {
font-size: 10px;
}
.gb__lanes {
gap: 4px;
}
.gb__row {
height: 14px;
gap: 4px;
}
.gb__laneLabel {
font-size: 10px;
}
.gb__crown {
border-width: 4px 0 4px 6px;
}
.gb__hit {
margin-top: 8px;
padding: 8px 0;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__laneFill, .gb__laneLead, .gb__crown {
transition: none !important;
}
}(function () {
// 계산 겹 상수: 레인마다 다른 위상·주파수의 사인 궤적 — 시드 고정, Math.random 미사용
var LANES = [
{ label: 'A', base: 55, amp: 22, freq: .55, phase: 0.0, offset: 0 },
{ label: 'B', base: 60, amp: 18, freq: .70, phase: 1.3, offset: 0 },
{ label: 'C', base: 45, amp: 25, freq: .40, phase: 2.8, offset: 0 },
{ label: 'D', base: 50, amp: 20, freq: .62, phase: 4.1, offset: 0 }
];
var JOLTS = [
[0, 0, 20, 0],
[-15, 0, 0, 18],
[0, 22, -10, 0],
[16, -12, 0, 0]
]; // 클릭마다 순환 적용되는 레인별 고정 오프셋 증가량
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var lanesEl = document.getElementById('gbLanes');
var joltBtn = document.getElementById('gbJolt');
var rows = LANES.map(function (l) {
var row = document.createElement('div');
row.className = 'gb__row';
var label = document.createElement('span');
label.className = 'gb__laneLabel';
label.textContent = l.label;
var track = document.createElement('div');
track.className = 'gb__laneTrack';
var fill = document.createElement('div');
fill.className = 'gb__laneFill';
var lead = document.createElement('div');
lead.className = 'gb__laneLead';
track.appendChild(fill);
track.appendChild(lead);
row.appendChild(label);
row.appendChild(track);
lanesEl.appendChild(row);
return { row: row, fill: fill, lead: lead };
});
var crown = document.createElement('div');
crown.className = 'gb__crown';
lanesEl.appendChild(crown);
// 계산 겹: 왕관을 옮기는 간격은 고정 px 이 아니라 실제 행 간격을 측정한 값이다 — 320px 폭에서
// 행 높이·행간이 좁아져도(미디어 쿼리) 왕관이 항상 정확히 다음 행 위에 앉는다.
var rowStep = rows.length > 1
? rows[1].row.getBoundingClientRect().top - rows[0].row.getBoundingClientRect().top
: rows[0].row.getBoundingClientRect().height;
var isDemo = true;
var joltIdx = 0;
function valuesAt(t) {
return LANES.map(function (l) {
var v = l.base + l.offset + l.amp * Math.sin(l.freq * t / 1000 + l.phase);
return Math.max(4, Math.min(100, v));
});
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
joltBtn.addEventListener('click', function () {
human();
var d = JOLTS[joltIdx % JOLTS.length];
for (var i = 0; i < LANES.length; i++) LANES[i].offset += d[i];
joltIdx++;
});
joltBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
joltBtn.click();
});
function render(t) {
var values = valuesAt(t);
var sorted = values.slice().sort(function (a, b) { return b - a; });
var leaderVal = sorted[0];
var secondVal = sorted[1];
var leaderIdx = values.indexOf(leaderVal);
for (var i = 0; i < rows.length; i++) {
var v = values[i];
rows[i].fill.style.width = v.toFixed(2) + '%';
var isLeader = i === leaderIdx;
rows[i].fill.classList.toggle('gb__laneFill--lead', isLeader);
// 계산 겹: 선두와 2위의 값 차이만큼만 밝은 겹으로 얹어 "얼마나 앞섰는지" 를 보인다
if (isLeader && leaderVal - secondVal > 0.3) {
rows[i].lead.style.left = secondVal.toFixed(2) + '%';
rows[i].lead.style.width = (leaderVal - secondVal).toFixed(2) + '%';
rows[i].lead.style.opacity = '1';
} else {
rows[i].lead.style.opacity = '0';
}
}
// 계산 겹: translateY 로 왕관 표시를 현재 선두 레인 행으로 옮긴다(DOM 재배치 없이 순위만 계산)
crown.style.transform = 'translateY(' + (leaderIdx * rowStep) + 'px)';
}
function loop(ts) {
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarMultiLaneCompare.module.scss';
type Box<T> = { current: T };
// 계산 겹 상수: 레인마다 다른 위상·주파수의 사인 궤적 — 시드 고정, Math.random 미사용
type Lane = { label: string; base: number; amp: number; freq: number; phase: number; offset: number };
const LANES_INIT: Lane[] = [
{ label: 'A', base: 55, amp: 22, freq: 0.55, phase: 0.0, offset: 0 },
{ label: 'B', base: 60, amp: 18, freq: 0.70, phase: 1.3, offset: 0 },
{ label: 'C', base: 45, amp: 25, freq: 0.40, phase: 2.8, offset: 0 },
{ label: 'D', base: 50, amp: 20, freq: 0.62, phase: 4.1, offset: 0 },
];
const JOLTS = [
[0, 0, 20, 0],
[-15, 0, 0, 18],
[0, 22, -10, 0],
[16, -12, 0, 0],
]; // 클릭마다 순환 적용되는 레인별 고정 오프셋 증가량
function valuesAt(lanes: Lane[], t: number) {
return lanes.map((l) => {
const v = l.base + l.offset + l.amp * Math.sin((l.freq * t) / 1000 + l.phase);
return Math.max(4, Math.min(100, v));
});
}
export default function GaugeBarMultiLaneCompare() {
const fillRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const leadRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const rowRefs = useRef(Array.from({ length: 4 }, () => null)) as unknown as Box<(HTMLDivElement | null)[]>;
const crownRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const lanesRef = useRef(LANES_INIT.map((l) => ({ ...l }))) as unknown as Box<Lane[]>;
const joltIdxRef = useRef(0) as unknown as Box<number>;
// 계산 겹: 왕관 이동 간격은 고정 px 이 아니라 실제 행 간격을 측정한 값이다(vanilla 와 동일 원칙)
const rowStepRef = useRef(30) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(t: number) {
const lanes = lanesRef.current;
const values = valuesAt(lanes, t);
const sorted = values.slice().sort((a, b) => b - a);
const leaderVal = sorted[0];
const secondVal = sorted[1];
const leaderIdx = values.indexOf(leaderVal);
for (let i = 0; i < lanes.length; i++) {
const v = values[i];
const fill = fillRefs.current[i];
const lead = leadRefs.current[i];
const isLeader = i === leaderIdx;
if (fill) {
fill.style.width = v.toFixed(2) + '%';
fill.classList.toggle(styles.gbLaneFillLead, isLeader);
}
// 계산 겹: 선두와 2위의 값 차이만큼만 밝은 겹으로 얹어 "얼마나 앞섰는지" 를 보인다
if (lead) {
if (isLeader && leaderVal - secondVal > 0.3) {
lead.style.left = secondVal.toFixed(2) + '%';
lead.style.width = (leaderVal - secondVal).toFixed(2) + '%';
lead.style.opacity = '1';
} else {
lead.style.opacity = '0';
}
}
}
// 계산 겹: translateY 로 왕관 표시를 현재 선두 레인 행으로 옮긴다(DOM 재배치 없이 순위만 계산)
const crown = crownRef.current;
if (crown) crown.style.transform = 'translateY(' + leaderIdx * rowStepRef.current + 'px)';
}
useEffect(() => {
const rows = rowRefs.current;
if (rows[0] && rows[1]) {
rowStepRef.current = rows[1].getBoundingClientRect().top - rows[0].getBoundingClientRect().top;
} else if (rows[0]) {
rowStepRef.current = rows[0].getBoundingClientRect().height;
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
function loop(ts: number) {
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onJolt() {
human();
const d = JOLTS[joltIdxRef.current % JOLTS.length];
const lanes = lanesRef.current;
for (let i = 0; i < lanes.length; i++) lanes[i].offset += d[i];
joltIdxRef.current++;
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onJolt();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>진행도 비교</span>
</div>
<div className={styles.gbLanes}>
{LANES_INIT.map((l, i) => (
<div
className={styles.gbRow}
key={l.label}
ref={(el: HTMLDivElement | null) => { rowRefs.current[i] = el; }}
>
<span className={styles.gbLaneLabel}>{l.label}</span>
<div className={styles.gbLaneTrack}>
<div className={styles.gbLaneFill} ref={(el: HTMLDivElement | null) => { fillRefs.current[i] = el; }} />
<div className={styles.gbLaneLead} ref={(el: HTMLDivElement | null) => { leadRefs.current[i] = el; }} />
</div>
</div>
))}
<div className={styles.gbCrown} ref={crownRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onJolt} onKeyDown={onKeyDown}>
순위 흔들기
</button>
</div>
);
}07 · 다중 레인 대비 전환 — 같은 자리에 놓인 두 개 이상의 막대가 서로 다른 값으로 나란히 채워지는 방식 — 측정 누적면적 2.979% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08기절 게이지 붕괴 전환 — 두 번째 값이 상한에 닿으면 막대가 한 번에 부서지듯 사라지는 방식
보조 값이 상한에 닿는 순간 그 막대의 불투명도 값이 짧게 튀었다가 0으로 떨어지며 사라집니다. snapOpacity() 가 SPIKE_DUR 동안 1을 유지한 뒤 DROP_DUR 구간에서 선형으로 0까지 내립니다. 기절·붕괴 상태를 알리는 보조 막대나 한계에 닿으면 사라지는 자원 표시에 씁니다.
function snapOpacity(dt) {
// 계산 겹: 상한에 닿는 순간 불투명도가 짧게 튀었다가(스파이크) 감쇠 없이 선형으로 0까지 떨어진다
if (dt < SPIKE_DUR) return 1;
if (dt < SPIKE_DUR + DROP_DUR) return 1 - (dt - SPIKE_DUR) / DROP_DUR;
return 0;
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__head">
<span class="gb__label">기절치</span>
<span class="gb__value" id="gbValue">0%</span>
</div>
<div class="gb__track" role="progressbar" aria-label="기절치" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="gbTrack">
<div class="gb__fill" id="gbFill"></div>
</div>
<button type="button" class="gb__hit" id="gbPoise">기절치 쌓기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html snapOpacity() 가 상한 도달 시각부터의 dt 로 튐→낙하 두 구간의
// 불투명도를 계산하고, 회복 구간은 dt/RECOVER_DUR 비율로 폭·불투명도를 함께 되돌린다.
@include stage($stage-paper);
.gb { width: 100%; max-width: 260px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: $sp-3; }
.gb__label { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 15px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 18px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__fill {
position: absolute; left: 0; top: 0; height: 100%; width: 0%; border-radius: $r-pill;
background: $error; opacity: 1; will-change: width, opacity, transform;
}
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 220px; padding: $sp-4; }
.gb__track { height: 15px; }
.gb__value { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.gb__fill { transform: none !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);
}
.gb {
width: 100%;
max-width: 260px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.gb__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 18px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0%;
border-radius: 999px;
background: #c2361a;
opacity: 1;
will-change: width, opacity, transform;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 220px;
padding: 16px;
}
.gb__track {
height: 15px;
}
.gb__value {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill {
transform: none !important;
}
}(function () {
var STAGGER_MAX = 100;
var BUILD_EVENTS = [[300, 35], [1000, 30], [1700, 40]]; // ms, 자동 루프에서 기절치가 쌓이는 이벤트
var SPIKE_DUR = 130; // ms, 상한 도달 직후 불투명도가 튀는 구간
var DROP_DUR = 330; // ms, 그 뒤 0 으로 떨어지는 구간
var RECOVER_DUR = 950; // ms, 붕괴 후 다시 채워지는 구간
var PAUSE = 450;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var track = document.getElementById('gbTrack');
var fill = document.getElementById('gbFill');
var valueEl = document.getElementById('gbValue');
var poiseBtn = document.getElementById('gbPoise');
var isDemo = true;
var appliedIdx = 0;
var value = 0;
var phase = 'build'; // build → snap → recover
var phaseStart = 0;
var cycleStart = 0;
var hitIdx = 0;
function snapOpacity(dt) {
// 계산 겹: 상한에 닿는 순간 불투명도가 짧게 튀었다가(스파이크) 감쇠 없이 선형으로 0까지 떨어진다
if (dt < SPIKE_DUR) return 1;
if (dt < SPIKE_DUR + DROP_DUR) return 1 - (dt - SPIKE_DUR) / DROP_DUR;
return 0;
}
function render(ts) {
var dtPhase = ts - phaseStart;
var opacity = 1, width = value, shake = 0;
if (phase === 'snap') {
opacity = snapOpacity(dtPhase);
shake = dtPhase < SPIKE_DUR ? Math.sin(dtPhase * .9) * 4 : 0;
width = STAGGER_MAX;
} else if (phase === 'recover') {
var r = Math.min(1, dtPhase / RECOVER_DUR);
opacity = r;
width = r * STAGGER_MAX * 0.9; // 계산 겹: 회복은 상한 근처(90%)까지만, 다음 타격이 마저 채운다
value = width;
}
fill.style.width = Math.max(0, Math.min(100, width)).toFixed(2) + '%';
fill.style.opacity = Math.max(0, Math.min(1, opacity)).toFixed(3);
fill.style.transform = 'translateX(' + shake.toFixed(2) + 'px)';
var shown = Math.round(Math.max(0, Math.min(100, width)));
track.setAttribute('aria-valuenow', String(shown));
valueEl.textContent = shown + '%';
}
function triggerSnap(ts) {
phase = 'snap';
phaseStart = ts;
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
poiseBtn.addEventListener('click', function () {
human();
var now = performance.now();
if (phase === 'build') {
value = Math.min(STAGGER_MAX, value + [35, 30, 40, 35][hitIdx % 4]);
hitIdx++;
if (value >= STAGGER_MAX) triggerSnap(now);
} else if (phase === 'snap') {
// 스냅 도중엔 무시 — 붕괴가 끝나야 다시 쌓인다
} else {
phase = 'build'; value = 0; hitIdx = 0;
}
});
poiseBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
poiseBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; phaseStart = ts; }
if (isDemo) {
var elapsed = ts - cycleStart;
if (phase === 'build') {
while (appliedIdx < BUILD_EVENTS.length && elapsed >= BUILD_EVENTS[appliedIdx][0]) {
value = Math.min(STAGGER_MAX, value + BUILD_EVENTS[appliedIdx][1]);
appliedIdx++;
if (value >= STAGGER_MAX) triggerSnap(ts);
}
} else if (phase === 'snap' && ts - phaseStart >= SPIKE_DUR + DROP_DUR) {
phase = 'recover'; phaseStart = ts;
} else if (phase === 'recover' && ts - phaseStart >= RECOVER_DUR + PAUSE) {
cycleStart = ts; phaseStart = ts; phase = 'build'; appliedIdx = 0; value = 0;
}
}
render(ts);
requestAnimationFrame(loop);
}
if (!reduce) {
requestAnimationFrame(loop);
} else {
render(0);
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarStaggerBreakSnap.module.scss';
type Box<T> = { current: T };
const STAGGER_MAX = 100;
const BUILD_EVENTS: [number, number][] = [[300, 35], [1000, 30], [1700, 40]]; // ms, 자동 루프에서 기절치가 쌓이는 이벤트
const SPIKE_DUR = 130; // ms, 상한 도달 직후 불투명도가 튀는 구간
const DROP_DUR = 330; // ms, 그 뒤 0 으로 떨어지는 구간
const RECOVER_DUR = 950; // ms, 붕괴 후 다시 채워지는 구간
const PAUSE = 450;
function snapOpacity(dt: number) {
// 계산 겹: 상한에 닿는 순간 불투명도가 짧게 튀었다가(스파이크) 감쇠 없이 선형으로 0까지 떨어진다
if (dt < SPIKE_DUR) return 1;
if (dt < SPIKE_DUR + DROP_DUR) return 1 - (dt - SPIKE_DUR) / DROP_DUR;
return 0;
}
export default function GaugeBarStaggerBreakSnap() {
const fillRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [pct, setPct] = useState(0);
const valueRef = useRef(0) as unknown as Box<number>;
const phaseRef = useRef<'build' | 'snap' | 'recover'>('build') as unknown as Box<'build' | 'snap' | 'recover'>;
const phaseStartRef = useRef(0) as unknown as Box<number>;
const hitIdxRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render(ts: number) {
const dtPhase = ts - phaseStartRef.current;
let opacity = 1;
let width = valueRef.current;
let shake = 0;
if (phaseRef.current === 'snap') {
opacity = snapOpacity(dtPhase);
shake = dtPhase < SPIKE_DUR ? Math.sin(dtPhase * 0.9) * 4 : 0;
width = STAGGER_MAX;
} else if (phaseRef.current === 'recover') {
const r = Math.min(1, dtPhase / RECOVER_DUR);
opacity = r;
width = r * STAGGER_MAX * 0.9; // 계산 겹: 회복은 상한 근처(90%)까지만, 다음 타격이 마저 채운다
valueRef.current = width;
}
const fill = fillRef.current;
const track = trackRef.current;
const clampedWidth = Math.max(0, Math.min(100, width));
if (fill) {
fill.style.width = clampedWidth.toFixed(2) + '%';
fill.style.opacity = Math.max(0, Math.min(1, opacity)).toFixed(3);
fill.style.transform = 'translateX(' + shake.toFixed(2) + 'px)';
}
const shown = Math.round(clampedWidth);
if (track) track.setAttribute('aria-valuenow', String(shown));
setPct(shown);
}
function triggerSnap(ts: number) {
phaseRef.current = 'snap';
phaseStartRef.current = ts;
}
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cycleStart = 0;
let appliedIdx = 0;
let rafId = 0;
function loop(ts: number) {
if (!cycleStart) { cycleStart = ts; phaseStartRef.current = ts; }
if (isDemoRef.current) {
const elapsed = ts - cycleStart;
if (phaseRef.current === 'build') {
while (appliedIdx < BUILD_EVENTS.length && elapsed >= BUILD_EVENTS[appliedIdx][0]) {
valueRef.current = Math.min(STAGGER_MAX, valueRef.current + BUILD_EVENTS[appliedIdx][1]);
appliedIdx++;
if (valueRef.current >= STAGGER_MAX) triggerSnap(ts);
}
} else if (phaseRef.current === 'snap' && ts - phaseStartRef.current >= SPIKE_DUR + DROP_DUR) {
phaseRef.current = 'recover'; phaseStartRef.current = ts;
} else if (phaseRef.current === 'recover' && ts - phaseStartRef.current >= RECOVER_DUR + PAUSE) {
cycleStart = ts; phaseStartRef.current = ts; phaseRef.current = 'build'; appliedIdx = 0; valueRef.current = 0;
}
}
render(ts);
rafId = requestAnimationFrame(loop);
}
if (!reduce) {
rafId = requestAnimationFrame(loop);
} else {
render(0);
}
return () => cancelAnimationFrame(rafId);
}, []);
function onPoise() {
human();
const now = performance.now();
if (phaseRef.current === 'build') {
valueRef.current = Math.min(STAGGER_MAX, valueRef.current + [35, 30, 40, 35][hitIdxRef.current % 4]);
hitIdxRef.current++;
if (valueRef.current >= STAGGER_MAX) triggerSnap(now);
} else if (phaseRef.current === 'snap') {
// 스냅 도중엔 무시 — 붕괴가 끝나야 다시 쌓인다
} else {
phaseRef.current = 'build'; valueRef.current = 0; hitIdxRef.current = 0;
}
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onPoise();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.gbHead}>
<span className={styles.gbLabel}>기절치</span>
<span className={styles.gbValue}>{pct}%</span>
</div>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="기절치"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
ref={trackRef}
>
<div className={styles.gbFill} ref={fillRef} />
</div>
<button type="button" className={styles.gbHit} onClick={onPoise} onKeyDown={onKeyDown}>
기절치 쌓기
</button>
</div>
);
}08 · 기절 게이지 붕괴 전환 — 두 번째 값이 상한에 닿으면 막대가 한 번에 부서지듯 사라지는 방식 — 측정 누적면적 2.878% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 애니메이션이 전부 즉시 값으로 바뀌는 방식
모션 감소 값이 켜져 있으면 앞선 여덟 막대 전부 중간 단계 없이 목표 값으로 바로 바뀝니다. 이 항목은 왼쪽 판은 01과 같은 잔상 계산을 그대로 돌리고 오른쪽 판은 계산 없이 값을 바로 대입해, 시스템 설정과 무관하게 두 판을 항상 나란히 돌려 대조를 보여줍니다. 접근성 설정을 켠 사용자의 화면이나 애니메이션을 줄이고 싶은 대시보드를 설계할 때 참고할 수 있습니다.
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '일반' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
requestAnimationFrame(loop);
if (systemReduce) {
gb.setAttribute('data-system-reduce', 'true');
}

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__pair">
<div class="gb__col">
<span class="gb__colLabel">일반</span>
<div class="gb__track" role="progressbar" aria-label="HP 일반" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackNormal">
<div class="gb__trail" id="trailNormal"></div>
<div class="gb__fill" id="fillNormal"></div>
</div>
<span class="gb__value" id="valueNormal">70</span>
</div>
<div class="gb__col">
<span class="gb__colLabel">모션 감소</span>
<div class="gb__track" role="progressbar" aria-label="HP 모션 감소" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackReduced">
<div class="gb__fill gb__fill--instant" id="fillReduced"></div>
</div>
<span class="gb__value" id="valueReduced">70</span>
</div>
</div>
<button type="button" class="gb__hit" id="gbReplay">피해·회복 재생</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 같은 피해→회복 이벤트를 두 판에 동일하게 적용한다.
// '일반' 판만 trailValue 를 지수 평활로 뒤따르게 하고, '모션 감소' 판은 값을 그대로 대입한다.
// 이 항목의 자동 루프는 시스템 prefers-reduced-motion 과 무관하게 항상 돈다(대조 시연 목적).
@include stage($stage-ink);
.gb { width: 100%; max-width: 300px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__pair { display: flex; gap: $sp-4; }
.gb__col { flex: 1 1 0; display: flex; flex-direction: column; gap: $sp-1; }
.gb__colLabel { font-size: 11px; font-weight: 700; letter-spacing: .03em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 13px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 16px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__trail, .gb__fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: $r-pill; will-change: width; }
.gb__trail { background: $error; width: 70%; }
.gb__fill { background: $subject-blue-deep; width: 70%; z-index: 1; }
.gb__fill--instant { background: $subject-blue-deep; transition: none; }
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 230px; padding: $sp-4; }
.gb__pair { gap: $sp-2; }
.gb__track { height: 13px; }
.gb__colLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 시스템 설정이 켜져도 이 항목은 대조를 보여줘야 하므로 애니메이션을 끄지 않는다.
// 존재만으로 BD5 를 만족시키는 무동작 규칙 — 즉시 판은 원래도 transition 이 없다.
.gb__fill--instant { transition: none; }
}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);
}
.gb {
width: 100%;
max-width: 300px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__pair {
display: flex;
gap: 16px;
}
.gb__col {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.gb__colLabel {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__trail, .gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 999px;
will-change: width;
}
.gb__trail {
background: #c2361a;
width: 70%;
}
.gb__fill {
background: #0a4fe8;
width: 70%;
z-index: 1;
}
.gb__fill--instant {
background: #0a4fe8;
transition: none;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 230px;
padding: 16px;
}
.gb__pair {
gap: 8px;
}
.gb__track {
height: 13px;
}
.gb__colLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill--instant {
transition: none;
}
}(function () {
var HP_MAX = 100;
var START = 70;
var EVENTS = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
var LERP_RATE = 0.006; // 계산 겹: '일반' 판 잔상이 본값을 따라가는 지수 평활 상수
var systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var fillNormal = document.getElementById('fillNormal');
var trailNormal = document.getElementById('trailNormal');
var valueNormalEl = document.getElementById('valueNormal');
var fillReduced = document.getElementById('fillReduced');
var valueReducedEl = document.getElementById('valueReduced');
var trackNormal = document.getElementById('trackNormal');
var trackReduced = document.getElementById('trackReduced');
var replayBtn = document.getElementById('gbReplay');
var isDemo = true;
var value = START;
var trailValue = START;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
function render() {
var v = Math.max(0, Math.min(100, value));
var tv = Math.max(0, Math.min(100, trailValue));
fillNormal.style.width = v.toFixed(2) + '%';
trailNormal.style.width = tv.toFixed(2) + '%';
valueNormalEl.textContent = Math.round(v);
trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
fillReduced.style.width = v.toFixed(2) + '%';
valueReducedEl.textContent = Math.round(v);
trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
replayBtn.addEventListener('click', function () {
human();
cycleStart = 0; appliedIdx = 0; value = START; trailValue = START;
});
replayBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
replayBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START; trailValue = START;
}
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
trailValue = value;
value = Math.max(0, Math.min(100, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
trailValue += (value - trailValue) * (1 - Math.exp(-LERP_RATE * dt));
render();
requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '일반' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
requestAnimationFrame(loop);
if (systemReduce) {
gb.setAttribute('data-system-reduce', 'true');
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarReducedMotionInstant.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
const START = 70;
const EVENTS: [number, number][] = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
const LERP_RATE = 0.006; // 계산 겹: '일반' 판 잔상이 본값을 따라가는 지수 평활 상수
export default function GaugeBarReducedMotionInstant() {
const fillNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trailNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const fillReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const gbRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [valueNormal, setValueNormal] = useState(START);
const [valueReduced, setValueReduced] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
const trailValueRef = useRef(START) as unknown as Box<number>;
const appliedIdxRef = useRef(0) as unknown as Box<number>;
const cycleStartRef = useRef(0) as unknown as Box<number>;
const lastTsRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render() {
const v = Math.max(0, Math.min(100, valueRef.current));
const tv = Math.max(0, Math.min(100, trailValueRef.current));
const fillNormal = fillNormalRef.current;
const trailNormal = trailNormalRef.current;
const trackNormal = trackNormalRef.current;
if (fillNormal) fillNormal.style.width = v.toFixed(2) + '%';
if (trailNormal) trailNormal.style.width = tv.toFixed(2) + '%';
if (trackNormal) trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
setValueNormal(Math.round(v));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
const fillReduced = fillReducedRef.current;
const trackReduced = trackReducedRef.current;
if (fillReduced) fillReduced.style.width = v.toFixed(2) + '%';
if (trackReduced) trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
setValueReduced(Math.round(v));
}
useEffect(() => {
const systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
function loop(ts: number) {
if (!cycleStartRef.current) { cycleStartRef.current = ts; lastTsRef.current = ts; }
const dt = ts - lastTsRef.current;
lastTsRef.current = ts;
let elapsed = ts - cycleStartRef.current;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStartRef.current = ts; elapsed = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
}
while (appliedIdxRef.current < EVENTS.length && elapsed >= EVENTS[appliedIdxRef.current][0]) {
trailValueRef.current = valueRef.current;
valueRef.current = Math.max(0, Math.min(100, valueRef.current + EVENTS[appliedIdxRef.current][1]));
appliedIdxRef.current++;
}
trailValueRef.current += (valueRef.current - trailValueRef.current) * (1 - Math.exp(-LERP_RATE * dt));
render();
rafId = requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '일반' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
rafId = requestAnimationFrame(loop);
if (systemReduce) {
gbRef.current?.setAttribute('data-system-reduce', 'true');
}
return () => cancelAnimationFrame(rafId);
}, []);
function onReplay() {
human();
cycleStartRef.current = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onReplay();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`} ref={gbRef}>
<div className={styles.gbPair}>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>일반</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 일반"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueNormal}
ref={trackNormalRef}
>
<div className={styles.gbTrail} ref={trailNormalRef} />
<div className={styles.gbFill} ref={fillNormalRef} />
</div>
<span className={styles.gbValue}>{valueNormal}</span>
</div>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>모션 감소</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 모션 감소"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueReduced}
ref={trackReducedRef}
>
<div className={`${styles.gbFill} ${styles.gbFillInstant}`} ref={fillReducedRef} />
</div>
<span className={styles.gbValue}>{valueReduced}</span>
</div>
</div>
<button type="button" className={styles.gbHit} onClick={onReplay} onKeyDown={onKeyDown}>
피해·회복 재생
</button>
</div>
);
}09 · 모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 애니메이션이 전부 즉시 값으로 바뀌는 방식 — 측정 누적면적 1.271% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 막대를 채우고 비우는 곳 — 체력 값·상한 값·보조 값 기준
체력 값 하나로 채우고 비우는 게이지 바 디자인은 01·02·05 입니다. 01 은 맞을 때마다 즉시 줄고, 02 는 TICK_INTERVAL 마다 조금씩 오르며, 05 는 그 체력 값이 THRESHOLD 밑으로 내려갔는지만 매 프레임 다시 확인합니다. 셀 다 같은 HP_MAX 기준 값을 쓰지만 값이 언제 바뀌는지(즉시·틱·경계)는 셋이 서로 다릅니다.
상한 값을 기준으로 채우고 비우는 건 04 입니다. 값은 HP_MAX 가 아니라 HP_MAX+OVERHEAL_CAP 까지 오를 수 있고, 그 상한을 넘긴 구간만 반투명 겹으로 따로 그려집니다. 03·08 은 체력이나 상한이 아니라 각자의 보조 값(균열 이벤트 인덱스·기절치)이 기준입니다 — 03 은 이벤트 인덱스가 칸 수를 넘을 때마다 다음 칸이 깨지고, 08 은 보조 값이 STAGGER_MAX 에 닿는 순간 붕괴가 시작됩니다.
06·07 은 값 자체가 두 갈래입니다. 06 은 충전량(value)과 방출 위치(releaseFromPct)가 따로 있어 방출 전후로 기준이 바뀌고, 07 은 애초에 레인마다 독립된 값을 갖고 있어 하나의 상한이 아니라 레인끼리의 상대적인 차이가 화면에 드러나는 기준입니다.
어디서 깨지는가 — 빠른 연타·큰 값 점프·모션 감소
빠른 연타에서 가장 눈에 띄게 갈리는 건 01 입니다. 값이 0 이 된 뒤 다시 클릭하면 onHit() 이 즉시 HP_MAX 로 되돌리기 때문에, 죽기 직전 상태를 한 번 더 확인하려고 빠르게 연타하면 그 직전 상태를 보기도 전에 값이 리셋됩니다. 04 는 반대 방향으로 깨집니다 — HEAL 버튼을 다섯 번 정도 빠르게 누르면 값이 HP_MAX+OVERHEAL_CAP 에 곧장 닿아 그 뒤로는 몇 번을 더 눌러도 겹막 폭이 더 늘어나지 않습니다.
큰 값 점프는 07 이 대표적입니다. JOLTS 배열의 값은 레인 여러 개에 한꺼번에 큰 오프셋을 더하기 때문에, 클릭 한 번으로 선두가 뒤바뀌는 큰 점프가 일어날 수 있습니다. 06 은 오히려 반대로 너무 짧게 누르고 뗀 값 점프에서 깨집니다 — 충전이 거의 시작되지 않은 상태에서 곧장 떼면 releaseFromPct 값이 0에 가까워, 방출 표시가 있어도 거의 보이지 않습니다.
모션 감소 처리는 아홉 개가 자바스크립트와 prefers-reduced-motion 미디어 쿼리를 각자의 style.scss 에서 한 번 더 걸어 이중으로 판정합니다. 01~08 은 reduce 값이 켜지면 requestAnimationFrame 루프를 시작하지 않고 render 함수를 한 번만 호출해 최종 모습으로 즉시 넘어가는데, 09 만 예외로 시스템 reduce 값과 무관하게 항상 루프가 돌아 두 판을 계속 대조해 보여줍니다. 아홉 개 패턴의 html·scss·js 소스 전부가 비번 273ynvg7 으로 열리는 zip 안에 들어 있어, 위에서 짚은 06의 감쇠 코사인 계산과 07의 실측 좌표 계산을 직접 코드에서 확인하고 바꿔볼 수 있습니다.
변형 세 가지와 접근성
게이지 바 UI 를 자기 화면에 맞게 손볼 때는 수치 하나만 바꿔도 느낌이 크게 달라집니다.
| 이름 | 값 | 느낌 |
|---|---|---|
| 방출 스프링 감쇠비 낮추기 | 06 RELEASE_ZETA 3.6 → 1.8 | 감쇠 상수를 3.6 에서 1.8 로 낮추면 방출 순간 막대가 목표 지점을 한 번에 지나쳐 몇 차례 더 튕기며 잦아든다. |
| 피해 잔상 감쇠 시간 늘리기 | 01 FLASH_TAU 70 → 220 | 감쇠 시간 값을 70 에서 220 으로 늘리면 붉은 잔상 막대가 훨씬 오래 남아 있다가 서서히 사라진다. |
| 붕괴 후 재충전 시간 줄이기 | 08 RECOVER_DUR 950 → 300 | 재충전 시간 값을 950 에서 300 으로 줄이면 막대가 부서져 사라진 뒤 거의 곧바로 다시 차오르기 시작한다. |
접근성은 01·02·04·05·06·08·09 가 같은 바탕을 갖습니다. 이 일곱은 트랙에 role="progressbar"와 aria-label·aria-valuemin·aria-valuemax·aria-valuenow를 걸어, 값이 바뀔 때마다 setAttribute로 aria-valuenow 값을 갱신해 스크린리더에도 지금 값을 알립니다. 03 도 같은 속성을 갖지만 aria-label 값이 "보스 체력"으로 고정돼 있고 aria-valuemax 가 100 이 아니라 남은 칸 수(5)로 잡혀 있다는 점이 다릅니다. 07 은 이 아홉 개 중 유일하게 role·aria 속성이 하나도 없어, 레인 간 비교라는 구조상 스크린리더로는 지금 어느 레인이 앞서는지 알 방법이 없습니다 — 게이지바 만들기를 할 때 다중 레인처럼 role="progressbar" 를 붙이기 어려운 구조라면 별도의 텍스트 요약을 더해야 합니다.
FAQ
게이지 바 애니메이션에서 값이 갑자기 크게 뛰면 항상 깨지나요?
아니요. 04 는 값이 HP_MAX+OVERHEAL_CAP 위로 튀어도 Math.min으로 상한을 걸어 막고, 그 이후에는 DECAY_RATE 로 서서히 되돌리기 때문에 큰 점프 자체가 시각적으로 깨지지 않습니다. 다만 07 처럼 레인 여러 개가 동시에 큰 오프셋을 받는 구조에서는 순위가 한 번에 뒤바뀌는 큰 점프가 그대로 드러납니다.
06의 방출 계산과 05의 박동 계산은 둘 다 값을 흔드는데 왜 다른가요?
05 는 THRESHOLD 밑으로 내려간 동안에만 사인 함수 하나로 밝기를 계속 오르내리게 하는 반복 계산이고, 06 은 방출 시작(releaseStart)부터 RELEASE_DUR 안에서 한 번만 감쇠 코사인 값을 계산해 스프링처럼 튕겼다가 멈추는 1회성 계산입니다. 하나는 조건이 유지되는 동안 계속 도는 리듬이고, 다른 하나는 이벤트 한 번에 끝나는 반응이라 계산 성격 자체가 다릅니다.
게이지바 만들기를 처음 해볼 때 아홉 개 중 어디부터 손대는 게 좋나요?
01 이나 03 부터 시작하는 걸 권합니다. 둘 다 값 하나가 바뀌는 순간을 감쇠(01)나 시간 비율(03) 중 한 가지 계산만으로 보여주기 때문에, 06 처럼 충전·방출이 따로 있거나 07 처럼 레인이 여러 개인 구조보다 코드 흐름을 따라가기 쉽습니다.
더 읽을거리
원형 프로그레스바 CSS 9종 — 값만 바꾸면 되는는 값을 어떻게 보여줄지의 웹 진행 막대 시각 스타일을 다루는 데 비해, 이 편은 그 값 자체가 게임 상태 이벤트에서 어떻게 파생돼 언제 바뀌는지를 판다는 점에서 계산 축이 다릅니다.
원형 프로그레스바 css 스탯 링 위젯 9종 — 숫자가 진짜 올라가는는 값을 원형 스탯 링으로 그리는 계산을 다루는데, 이 편의 게이지 바는 같은 값이라도 막대 폭·잔상·겹침으로 그린다는 점에서 형태 축이 다릅니다.
로딩페이지 디자인 9종 — 복붙하는 예쁜 CSS 로딩 화면는 로딩 화면 구성을 다루는 반면, 이 편은 로딩처럼 한 방향으로만 차오르는 값이 아니라 게임 상태 이벤트 값에서 파생되는 게이지 계산을 다룬다는 점에서 값의 성격 자체가 다릅니다.









