# AI 꾸러미 — 튀어 오르는 숫자 — Floating combat text (damage numbers)
> +10 · ×2 같은 숫자가 탄성 이징으로 톡 커졌다가 떠오르며 사라지고, 크리티컬은 더 크게 · 흔들리며, 빼기는 떨며 가라앉아 점수 변화를 바로 느끼게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i202

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 튀어 오르는 숫자 — Floating combat text (damage numbers)

#### 1. 목표
점수 +10 · ×2 · 크리티컬 +50 · −3에 튀어 오르는 숫자를 띄워 줘 — 톡 커졌다 떠오르며 사라지고, 크리티컬은 크게 흔들리고, 빼기는 떨며 가라앉게. 분위기는 통통 튀는 아케이드.

#### 2. 핵심 기술 용어
- **Floating combat text (damage numbers)** — 튀어 오르는 숫자
- **easeOutElastic pop** — 탄성 이징 — 톡 넘쳤다 출렁이며 자리
- **Critical hit emphasis** — 크리티컬 — 크게 · 흔들림 · 빛

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 나타남은 탄성 이징(넘쳤다 출렁) — 그냥 크기 1 로 나타나면 밋밋하다
- 좋은 값(+ · ×)은 떠오르고, 나쁜 값(−)은 떨며 가라앉게 — 움직임으로도 구분
- 크리티컬은 크게(× 1.55) + 흔들림 + 빛 + 작은 글씨 이름표
- 굵은 테두리로 바탕과 상관없이 읽히게
- 숫자가 나는 자리의 블록도 함께 찌그러지고 번쩍

#### 5. 완성 기준 (이게 보이면 성공)
- 블록을 누르면 그 위에 +10 이 톡 커졌다 출렁이며 떠오르다 사라진다
- +50 크리티컬은 더 크게 나와 좌우로 떨고 금빛으로 빛난다
- −3 은 빨갛게 떨며 아래로 가라앉는다
- 왼쪽(끔)은 글자가 그냥 떠오르기만 해 차이가 바로 보인다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 크기 = elasticOut(a / 0.45) — 0 에서 1 을 넘쳤다 출렁이며 1 로 (크리티컬은 × 1.55).
- 떠오름 = 36 × easeOut(a / 1.1), 투명도는 0.75 ~ 1.1초에 빠진다 (수명 1.1초).
- 크리티컬: 옆 흔들림 sin(70a)·3·e^(−5a) + 기울기 흔들림, 뒤에 금빛 빛무리 · 작은 불꽃 · 「크리티컬!」.
- 빼기: 조금 올랐다 가라앉고(위로 16 → 아래로 18), 짧게 떤다 — 좋은 일과 나쁜 일의 움직임이 다르다.
- 숫자는 굵은 테두리 + 위아래 그러데이션(초록 · 금 · 빨강)으로 어떤 바탕에서도 읽히게.

## 핵심 코드 — 탄성 이징 크기 · 떠오름 · 크리티컬 흔들림 · 빼기 가라앉음
(발췌: demos/demosJuice.ts D202 의 scene() 을 정리)
```ts
const TAU = Math.PI * 2;
const elasticOut = (k: number) => (k <= 0 ? 0 : k >= 1 ? 1 : Math.pow(2, -10 * k) * Math.sin(((k * 10 - 0.75) * TAU) / 3) + 1);
const easeOut = (k: number) => 1 - Math.pow(1 - Math.max(0, Math.min(1, k)), 3);
const smooth = (e0: number, e1: number, x: number) => { const v = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))); return v * v * (3 - 2 * v); };
const LIFE = 1.1;

// e = { s: '+50', kind: 'n' | 'crit' | 'neg' }, a = 뜬 뒤 시간
const crit = e.kind === 'crit', neg = e.kind === 'neg';
const sc = elasticOut(a / 0.45) * (crit ? 1.55 : 1);
const rise = neg ? 16 * u * easeOut(a / 0.4) - 18 * u * smooth(0.4, LIFE, a) // 빼기는 올랐다 가라앉음
                 : 36 * u * easeOut(a / LIFE);
const alpha = 1 - smooth(0.75, LIFE, a);
let jx = 0, rot = 0;
if (crit) { jx = Math.sin(a * 70) * 3 * u * Math.exp(-a * 5); rot = Math.sin(a * 40) * 0.18 * Math.exp(-a * 4); }
if (neg) jx = Math.sin(a * 60) * 2.5 * u * Math.exp(-a * 6);
g.save();
g.globalAlpha = alpha;
g.translate(x0 + jx, y0 - rise);
g.rotate(rot);
g.scale(sc, sc);
const fill = g.createLinearGradient(0, -10 * u, 0, 10 * u);
fill.addColorStop(0, crit ? '#fff9db' : neg ? '#ffc9d3' : '#ffffff');
fill.addColorStop(1, crit ? '#ffb400' : neg ? '#ff3b5c' : '#8ce99a');
g.font = '900 ' + 15 * u + 'px sans-serif';
g.lineJoin = 'round';
g.strokeStyle = neg ? '#5c0011' : crit ? '#7a3000' : '#0b3d1a'; g.lineWidth = 3.4 * u;
g.strokeText(e.s, 0, 0);                        // 굵은 테두리 먼저
g.fillStyle = fill; g.fillText(e.s, 0, 0);
g.restore();
```

## 흔한 실수 · 확인 목록
- [ ] **테두리 없는 흰 숫자는 밝은 바탕에서 안 보인다** — 굵은 어두운 테두리를 먼저 그리고 그 위에 채운다 (lineJoin round).
- [ ] **빼기도 위로 떠오르면 좋은 일처럼 보인다** — 빼기는 떨며 가라앉게, 색도 빨강으로.
- [ ] **숫자가 많이 겹쳐 뜨면 하나도 안 읽힌다** — 같은 자리에 연달아 뜨면 합치거나 조금씩 옆으로 비켜 띄운다.

## 완성 기준 체크리스트
- [ ] 블록을 누르면 그 위에 +10 이 톡 커졌다 출렁이며 떠오르다 사라진다
- [ ] +50 크리티컬은 더 크게 나와 좌우로 떨고 금빛으로 빛난다
- [ ] −3 은 빨갛게 떨며 아래로 가라앉는다
- [ ] 왼쪽(끔)은 글자가 그냥 떠오르기만 해 차이가 바로 보인다

## 이 기술 정보
- id: `i202` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 숫자 하나당 글자 · 테두리 몇 번. 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i202
- 쓰면 좋을 때: 점수 · 피해 · 결과값이 바뀌는 순간을 그 자리에서 보여 줄 때 / 어디서 얼마나 얻었는지 알려 줄 때
- 쓰지 말 때: 한 번에 수십 개가 동시에 뜨는 경우 — 겹쳐 안 읽힌다. 대신 합쳐서 하나로 / 정확한 값을 오래 봐야 할 때 — 대신 점수판 숫자 굴러가기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D202 — `src/demos/demosJuice.ts:796`
```ts
const D202: Demo = {
  kind: '2d',
  caption: '+10 · ×2 · 크리티컬 +50 · −3 — 톡 튀어 커졌다 떠오르며 사라짐 (왼쪽은 그냥 글자가 떠오름)',
  make() {
    let compare = true;
    let big = 1;
    const P = 3.2;
    const EV: { t: number; i: number; s: string; kind: 'n' | 'crit' | 'neg' }[] = [
      { t: 0.2, i: 0, s: '+10', kind: 'n' },
      { t: 0.85, i: 1, s: '×2', kind: 'n' },
      { t: 1.5, i: 2, s: '+50', kind: 'crit' },
      { t: 2.3, i: 0, s: '−3', kind: 'neg' },
    ];
    const LIFE = 1.1;
    let T = 0;
    const scene = (g: G, w: number, h: number, on: boolean): void => {
      const u = uh(w, h);
      bg(g, w, h, '#123c5c', '#081a2c');
      const ph = T % P;
      const gy = h * 0.8;
      ground(g, w, h, gy, u);
      const s = 32 * u;
      const xs = [w * 0.2, w * 0.5, w * 0.8];
      const cols = ['#ffa94d', '#69db7c', '#ff6b8a'];
      const labels = ['5', '2', '8'];
      xs.forEach((x, i) => {
        let sq = 1;
        let fl = 0;
        for (const e of EV) {
          const a = ph - e.t;
          if (e.i === i && a >= 0 && a < 0.6) {
            if (on) sq = 1 - 0.22 * Math.exp(-a * 9) * Math.cos(a * 32);
            if (on) fl = clamp01(1 - a / 0.08) * 0.6;
          }
        }
        block(g, x, gy, s, cols[i]!, labels[i]!, { sx: 2 - sq, sy: sq, flash: fl });
      });
      for (const e of EV) {
        const a = ph - e.t;
        if (a < 0 || a > LIFE) continue;
        const x0 = xs[e.i]!;
        const y0 = gy - s - 6 * u;
        // 누른 자리 물결
        ringFx(g, x0, gy - s / 2, a, 0.35, 4 * u, 20 * u, '#ffffff', 2 * u);
        if (!on) {
          g.globalAlpha = 1 - a / LIFE;
          txt(g, e.s, x0, y0 - a * 22 * u, 11 * u, '#fff', 'center', 700);
          g.globalAlpha = 1;
          continue;
        }
        const crit = e.kind === 'crit';
        const neg = e.kind === 'neg';
        const sc = elasticOut(a / 0.45) * (crit ? 1.55 : 1) * big;
        const rise = neg ? 16 * u * easeOut(a / 0.4) - 18 * u * smooth(0.4, LIFE, a) : 36 * u * easeOut(a / LIFE);
        const alpha = 1 - smooth(0.75, LIFE, a);
        let jx = 0;
        let rot = 0;
        if (crit) {
          jx = Math.sin(a * 70) * 3 * u * Math.exp(-a * 5);
          rot = Math.sin(a * 40) * 0.18 * Math.exp(-a * 4);
        }
        if (neg) jx = Math.sin(a * 60) * 2.5 * u * Math.exp(-a * 6);
        g.save();
        g.globalAlpha = alpha;
        g.translate(x0 + jx, y0 - rise);
        g.rotate(rot);
        g.scale(sc, sc);
        const fill = g.createLinearGradient(0, -10 * u, 0, 10 * u);
        if (crit) {
          fill.addColorStop(0, '#fff9db');
          fill.addColorStop(1, '#ffb400');
        } else if (neg) {
          fill.addColorStop(0, '#ffc9d3');
          fill.addColorStop(1, '#ff3b5c');
        } else {
          fill.addColorStop(0, '#ffffff');
          fill.addColorStop(1, '#8ce99a');
        }
        if (crit) {
          g.save();
          g.globalCompositeOperation = 'lighter';
          glow(g, 0, 0, 26 * u, '#ffd43b', 0.55);
          g.restore();
          otext(g, '크리티컬!', 0, -14 * u, 6.5 * u, '#fff', '#c2410c', 1.6 * u);
        }
        otext(g, e.s, 0, 0, 15 * u, fill, neg ? '#5c0011' : crit ? '#7a3000' : '#0b3d1a', 3.4 * u);
        g.restore();
        if (crit) sparks(g, x0, y0 - 10 * u, a, 12, 41, 36 * u, ['#fff3a0', '#ffd43b'], 0.6, 0, 2.4 * u);
      }
    };
    return {
      draw(g, w, h, t) {
        T = t;
        split(g, w, h, compare, scene);
      },
      controls: [tgl('나란히 비교 (끔 | 켬)', compare, (v) => (compare = v)), rng('글자 크기', 0.6, 1.6, 0.05, big, (v) => (big = v))],
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [글자 차례 등장 (키네틱 타이포)](https://ai-techstudio.web.app/ai/t/i90.md) `i90` · [숫자 굴러가기 (계기판)](https://ai-techstudio.web.app/ai/t/i94.md) `i94`
- 다음에 해 볼 기술: [동전 빨려 들기 (UI 로 날아가기)](https://ai-techstudio.web.app/ai/t/i208.md) `i208` · [별점 터짐](https://ai-techstudio.web.app/ai/t/i211.md) `i211`
