01피해 흡수 전환 — 값이 줄어들면 막대가 먼저 좁아지고 그 뒤를 붉은 잔상이 서서히 따라가며 좁아지는 방식
값이 줄어드는 순간 막대 폭이 먼저 좁아지고, 그 차이만큼 남은 붉은 폭이 뒤늦게 서서히 줄어듭니다.
쓰임전투 화면의 체력 표시, 순간 피해를 강조하는 상태 막대
applyHit()LERP_RATEFLASH_TAU

<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 포함 · 게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식
이 데모가 속한 글
게이지 바 9종 — 게임처럼 채워지는 아홉 가지 방식게이지 바는 값이 오르내릴 때마다 채워진 폭이 바뀌는 막대인데, 아홉 개 중 하나는 값이 줄면 막대가 먼저 좁아지고 그 뒤로 붉은 잔상이 서서히 따라 줄어들며, 하나는 값이 상한을 넘으면 초과분이 반투명한 겹으로 얹 이 글의 데모 전부 보기


