# AI 꾸러미 — 만화 연출 층 (DOM) — DOM overlay effects (CSS animation)
> 3D 판 위에 HTML 층을 하나 덮고 CSS 로 집중선 · 번쩍 · 컷인 띠 · 별 터짐 글자 · 꽃가루를 띄워서, 결정적 순간을 만화처럼 크게 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/u38

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

## 주문서

### 만들어 줘: 만화 연출 층 (DOM) — DOM overlay effects (CSS animation)

#### 1. 목표
숫자 야구 홈런 순간에 만화 연출 층을 덮어 줘 — 집중선 · 번쩍 · 기술 이름 컷인 띠 · 별 터짐 큰 글자 · 종이 꽃가루를 HTML · CSS 로. 분위기는 밤 야구장 애니메이션.

#### 2. 핵심 기술 용어
- **DOM overlay effects (CSS animation)** — HTML 층 위 연출 — CSS 애니메이션
- **Manga speed lines (repeating-conic-gradient)** — 만화 집중선 — 가운데가 빈 방사형 줄
- **Starburst SVG polygon** — 뾰족뾰족 별 터짐 말풍선
- **Restart CSS animation (reflow)** — 클래스를 뗐다 붙여 애니메이션 다시 시작

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 연출 층은 pointer-events: none — 판 입력을 막지 않는다
- 판 가운데 · 크기를 CSS 변수(--cx · --cy · --fw · --fh)로 넘겨 화면 크기와 상관없이 맞게
- 애니메이션 다시 틀기는 클래스 떼기 → void el.offsetWidth → 붙이기
- 잠깐 쓰는 요소는 수명이 지나면 remove() — 쌓이지 않게
- 글자는 번역 키로(한국어 원문) — 그림에 글씨를 넣지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- 공이 다가오면 집중선 · 기술 이름 컷인 띠, 꽂히면 번쩍 · 「스트라이크!」 · 「볼!」 딱지가 뜬다
- 홈런이면 금빛 무지개 집중선 · 큰 별 터짐 「홈런!」 · 꽃가루가 쏟아진다
- 연출이 떠 있는 동안에도 판을 누를 수 있다
- 폰 가로 화면(844×390)에서 글자가 화면 밖으로 안 나간다

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

## 원리
- 판(캔버스) 위에 position: absolute · pointer-events: none 인 층(.nb-fx)을 덮어, 그 안에 연출 요소를 넣고 뺀다. 판 클릭은 그대로 통과한다.
- 집중선: repeating-conic-gradient 로 판 가운데(--cx · --cy)에서 퍼지는 가는 흰 줄, 가운데는 radial-gradient 마스크로 비운다. 0.12초마다 살짝 커졌다(1.06 ↔ 1.1) 떨린다.
- 별 터짐: SVG 다각형 꼭짓점을 바깥 · 안쪽 반지름으로 번갈아(24 또는 18 갈래) 찍고, 위에 굵은 테두리 글자(-webkit-text-stroke + paint-order)를 얹는다. 튀어나오는 탄성 이징 0.42초.
- 같은 애니메이션을 다시 틀려면 클래스를 뗀 뒤 void el.offsetWidth 로 다시 그리게 하고 붙인다.
- 컷인 · 딱지 · 꽃가루처럼 잠깐 쓰는 요소는 수명(초)을 적어 두고 지나면 지운다. 꽃가루 60조각은 CSS 변수(--dx · --r)로 저마다 다르게.

## 핵심 코드 — 별 터짐 꼭짓점 · 애니메이션 다시 틀기 · 잠깐 쓰는 요소
(발췌: demos/demosSim.ts u38 make() (+ numbaseball/ballfx.css) 를 정리)
```ts
// CSS (요지)
// .nb-fx   { position:absolute; inset:0; pointer-events:none; overflow:hidden; }
// .nb-speed{ position:absolute; inset:0; opacity:0; mix-blend-mode:screen;
//   background: repeating-conic-gradient(from 0deg at var(--cx,50%) var(--cy,50%),
//     rgba(255,255,255,.9) 0deg .6deg, transparent .6deg 3.1deg, rgba(255,255,255,.55) 3.1deg 3.5deg, transparent 3.5deg 7deg);
//   mask: radial-gradient(ellipse calc(var(--fw)*.36) calc(var(--fh)*.34) at var(--cx) var(--cy), transparent 55%, #000 100%); }
// .nb-speed.on { opacity:.45; animation: nb-speed-jitter .12s steps(2) infinite; }

// 별 터짐 다각형: 바깥 · 안쪽 반지름을 번갈아
function burst(spikes: number, outer: number, inner: number): string {
  const pts: string[] = [];
  for (let k = 0; k < spikes * 2; k++) {
    const a = (k / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
    const r = k % 2 ? inner : outer * (0.9 + ((k * 37) % 10) / 100); // 바깥 끝을 조금씩 다르게
    pts.push((Math.cos(a) * r).toFixed(1) + ',' + (Math.sin(a) * r).toFixed(1));
  }
  return pts.join(' ');
}
function showComic(el: HTMLElement, kind: 'hr' | 'out', text: string): void {
  el.className = 'nb-comic ' + kind;
  el.innerHTML = '<svg viewBox="-100 -100 200 200"><polygon points="' + burst(kind === 'hr' ? 24 : 18, 98, kind === 'hr' ? 62 : 70) + '"/></svg><b>' + text + '</b>';
  void el.offsetWidth;         // 다시 그리게 해서 애니메이션을 처음부터
  el.classList.add('on');
}
// 잠깐 쓰는 요소: 수명이 지나면 지운다
const temp: { el: HTMLElement; until: number }[] = [];
function add(fx: HTMLElement, el: HTMLElement, now: number, life: number): void { fx.appendChild(el); temp.push({ el, until: now + life }); }
function sweep(now: number): void {
  for (let i = temp.length - 1; i >= 0; i--) if (temp[i].until < now) { temp[i].el.remove(); temp.splice(i, 1); }
}
```

## 흔한 실수 · 확인 목록
- [ ] **클래스를 다시 붙여도 애니메이션이 처음부터 안 나온다** — 같은 클래스면 브라우저가 무시한다. 떼고 void el.offsetWidth 로 다시 계산시킨 뒤 붙인다.
- [ ] **연출 층이 판 클릭을 먹는다** — 층 전체에 pointer-events: none.
- [ ] **만화 글자를 그림으로 넣으면 번역이 안 된다** — 이 사이트 규칙 — 그림에 글씨를 넣지 않고 HTML · SVG 글씨로, 한국어 원문을 번역 키로.
- [ ] **꽃가루 · 딱지를 지우지 않으면 판이 거듭될수록 느려진다** — 만들 때 수명을 적어 두고 매 프레임 지난 것을 remove().

## 완성 기준 체크리스트
- [ ] 공이 다가오면 집중선 · 기술 이름 컷인 띠, 꽂히면 번쩍 · 「스트라이크!」 · 「볼!」 딱지가 뜬다
- [ ] 홈런이면 금빛 무지개 집중선 · 큰 별 터짐 「홈런!」 · 꽃가루가 쏟아진다
- [ ] 연출이 떠 있는 동안에도 판을 누를 수 있다
- [ ] 폰 가로 화면(844×390)에서 글자가 화면 밖으로 안 나간다

## 이 기술 정보
- id: `u38` · 분류: 이펙트 · 연출 › 입자 · 연출 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — CSS 그러데이션 · 변형 애니메이션뿐. 꽃가루 60조각도 GPU 합성으로 가볍다. 다 쓴 요소를 지우지 않으면 쌓인다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u38
- 쓰면 좋을 때: 홈런 · 정답처럼 짧고 결정적인 순간을 크게 / 글자 · 말풍선이 들어가 번역이 필요한 연출 — HTML 글씨는 번역 · 글꼴이 쉽다
- 쓰지 말 때: 늘 떠 있는 표시 — 공통 HUD 가 맞다 / 3D 물체에 붙어 함께 움직여야 하는 것 — 스프라이트(u35)로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u38 견본 항목 — `src/demos/demosSim.ts:2368`
```ts
  u38: {
    kind: 'dom',
    caption: '3D 판 위에 HTML 층 — 집중선 · 컷인 띠 · 스트라이크 딱지 · 별 터짐 「홈런!」 · 종이 꽃가루 (숫자 야구 ballfx.css 그대로)',
    make(box) {
      const wrap = document.createElement('div');
      wrap.style.cssText =
        'position:absolute;inset:0;overflow:hidden;background:radial-gradient(ellipse 80% 60% at 50% 110%, #2f8a44 0 38%, transparent 39%),radial-gradient(circle at 20% 15%, rgba(255,255,230,.5), transparent 12%),radial-gradient(circle at 80% 15%, rgba(255,255,230,.5), transparent 12%),linear-gradient(#081030,#1e2c66 70%,#2a3a70)';
      const ballEl = document.createElement('div');
      ballEl.style.cssText = 'position:absolute;left:50%;top:52%;width:9%;aspect-ratio:1;border-radius:50%;transform:translate(-50%,-50%);background:radial-gradient(circle at 35% 30%,#fff,#e8e2d4 60%,#b8ae9a);box-shadow:0 0 18px rgba(255,255,255,.6)';
      const fx = document.createElement('div');
      fx.className = 'nb-fx';
      fx.innerHTML = '<div class="nb-speed"></div><div class="nb-flash"></div><div class="nb-comic"></div>';
      wrap.append(ballEl, fx);
      box.appendChild(wrap);
      const speed = fx.querySelector('.nb-speed') as HTMLElement;
      const flashEl = fx.querySelector('.nb-flash') as HTMLElement;
      const comic = fx.querySelector('.nb-comic') as HTMLElement;
      const burst = (spikes: number, outer: number, inner: number): string => {
        const pts: string[] = [];
        for (let k = 0; k < spikes * 2; k++) {
          const a = (k / (spikes * 2)) * TAU - Math.PI / 2;
          const r = k % 2 ? inner : outer * (0.9 + ((k * 37) % 10) / 100);
          pts.push(`${(Math.cos(a) * r).toFixed(1)},${(Math.sin(a) * r).toFixed(1)}`);
        }
        return pts.join(' ');
      };
      const temp: { el: HTMLElement; until: number }[] = [];
      let now = 0;
      const add = (el: HTMLElement, life: number): void => {
        fx.appendChild(el);
        temp.push({ el, until: now + life });
      };
      const showComic = (kind: 'hr' | 'out' | 'hit', s: string): void => {
        comic.className = `nb-comic ${kind}`;
        comic.innerHTML = `<svg viewBox="-100 -100 200 200" aria-hidden="true"><polygon points="${burst(kind === 'hr' ? 24 : 18, 98, kind === 'hr' ? 62 : 70)}"/><polygon class="in" points="${burst(kind === 'hr' ? 24 : 18, 80, kind === 'hr' ? 52 : 58)}"/></svg><b>${s}</b>`;
        void comic.offsetWidth;
        comic.classList.add('on');
      };
      const flash = (strong: boolean): void => {
        flashEl.className = 'nb-flash';
        void flashEl.offsetWidth;
        flashEl.className = `nb-flash on${strong ? ' strong' : ''}`;
      };
      const tag = (s: string, kind: 'S' | 'B' | 'X', x: number, y: number): void => {
        const el = document.createElement('div');
        el.className = `nb-tag ${kind} big`;
        el.textContent = s;
        el.style.left = `${x}%`;
        el.style.top = `${y}%`;
        add(el, 1.35);
      };
      const SP = ['fire', 'bolt', 'ghost', 'twist', 'ult'] as const;
      const SPN = { fire: '불꽃 강속구!', bolt: '번개 직구!', ghost: '도깨비 공!', twist: '회오리 볼!', ult: '필살! 무지개 유성구!' };
      let round = 0;
      let phase = -1;
      let disposed = false;
      const LOOP = 7.2;
      return {
        update(t) {
          now = t;
          const W = box.clientWidth;
          const H = box.clientHeight;
          fx.style.setProperty('--fw', `${W}px`);
          fx.style.setProperty('--fh', `${H}px`);
          fx.style.setProperty('--cx', '50%');
          fx.style.setProperty('--cy', '52%');
          const lt = t % LOOP;
          const r = Math.floor(t / LOOP);
          if (r !== round) {
            round = r;
            phase = -1;
          }
          const ph = lt < 1.3 ? 0 : lt < 2.5 ? 1 : lt < 5.0 ? 2 : 3;
          // 공 — 다가오며 커짐
          const bk = lt < 1.3 ? lt / 1.3 : 1;
          ballEl.style.width = `${4 + bk * 10}%`;
          ballEl.style.opacity = ph >= 2 ? '0' : '1';
          if (ph !== phase) {
            phase = ph;
            const sp = SP[round % SP.length]!;
            if (ph === 0) {
              comic.className = 'nb-comic';
              speed.className = `nb-speed on burst ${sp}`;
              const el = document.createElement('div');
              el.className = `nb-cutin ${sp}`;
              const b = document.createElement('b');
              b.textContent = SPN[sp];
              el.appendChild(b);
              add(el, sp === 'ult' ? 1.3 : 0.9);
            } else if (ph === 1) {
              speed.className = 'nb-speed on';
              flash(false);
              tag('스트라이크!', 'S', 50, 40);
              window.setTimeout(() => !disposed && tag('볼!', 'B', 30, 60), 380);
            } else if (ph === 2) {
              speed.className = 'nb-speed on burst gold rainbow';
              flash(true);
              showComic('hr', '홈런!');
              const cf = document.createElement('div');
              cf.className = 'nb-confetti';
              const cols = ['#ff5a7a', '#ffd23a', '#5ab8ff', '#7ae08a', '#ff9a3a', '#c08aff', '#ffffff'];
              for (let k = 0; k < 60; k++) {
                const i = document.createElement('i');
                i.style.left = `${Math.random() * 100}%`;
                i.style.background = cols[k % cols.length]!;
                i.style.width = `${Math.max(5, W / 90)}px`;
                i.style.height = `${Math.max(8, W / 55)}px`;
                i.style.animationDelay = `${Math.random() * 0.7}s`;
                i.style.animationDuration = `${1.6 + Math.random() * 1.2}s`;
                i.style.setProperty('--dx', `${(Math.random() - 0.5) * W * 0.3}px`);
                i.style.setProperty('--r', `${Math.random() * 1080 - 540}deg`);
                cf.appendChild(i);
              }
              add(cf, 3);
            } else {
              speed.className = 'nb-speed';
              showComic('out', '삼진 아웃!');
            }
          }
          for (let i = temp.length - 1; i >= 0; i--)
            if (temp[i]!.until < t) {
              temp[i]!.el.remove();
              temp.splice(i, 1);
            }
        },
        dispose() {
          disposed = true;
          wrap.remove();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [도장 찍기 연출 (쾅 · 흔들림)](https://ai-techstudio.web.app/ai/t/u39.md) `u39` · [극적인 확대 (결정적 순간)](https://ai-techstudio.web.app/ai/t/i428.md) `i428`
- 참고 문서: [MDN — repeating-conic-gradient()](https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/repeating-conic-gradient)
