# AI 꾸러미 — 도장 찍기 연출 (쾅 · 흔들림) — Stamp slam animation
> 도장을 크게 · 흐리게 위에서 0.32초에 내려찍고 닿는 순간 살짝 눌림 · 종이 흔들림 · 잉크 번짐을 붙여서, 판정이 「쾅」 하고 박힌다.  
> 견본: https://ai-techstudio.web.app/#t/u39

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

## 주문서

### 만들어 줘: 도장 찍기 연출 (쾅 · 흔들림) — Stamp slam animation

#### 1. 목표
검사서 통과 · 보류 · 반려 판정에 판정 도장을 쾅 찍는 연출을 만들어 줘 — 위에서 크게 내려오며 또렷해지고, 닿는 순간 눌림 · 종이 흔들림 · 잉크 번짐. 분위기는 나무 책상 위 서류.

#### 2. 핵심 기술 용어
- **Stamp slam animation** — 도장 내려찍기 연출
- **Ease-in drop (scale + blur + drop-shadow)** — 크게 · 흐리게 · 그림자 길게 → 작게 · 또렷하게
- **Impact shake (decaying)** — 닿는 순간 잦아드는 흔들림
- **Ink bleed** — 도장 둘레 잉크 번짐

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

#### 4. 조건
- 내려오기는 끝에서 빨라지는 이징(q²) — 등속이면 「쾅」이 없다
- 닿기 전엔 크게 · 흐리게 · 그림자 길게, 닿으면 크기 1 · 흐림 없음 · 그림자 없음
- 흔들림은 종이(받는 쪽)에, 잦아들게(1 − q), 세기 조절(0 이면 끔)
- 도장 글자 · 테두리는 SVG 로 — 그림에 글씨를 넣지 않는다
- filter: blur 는 내려오는 동안만, 닿으면 none

#### 5. 완성 기준 (이게 보이면 성공)
- 도장이 크고 흐릿하게 내려와 종이에 닿는 순간 또렷해지며 살짝 눌린다
- 닿는 순간 종이가 흔들리고 도장 둘레로 잉크가 번진다
- 반려 · 통과 · 보류가 색을 바꿔 차례로 찍힌다
- 「흔들림 세기」 0 이면 흔들림 없이 찍힌다

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

## 원리
- 내려오기 0.32초: 크기 lerp(2.8, 1, q²) — 처음 느리고 끝에서 빨라져 「쾅」. 회전 −24° → −9°, 흐림 3px → 0, 그림자 거리 30px → 0.
- 닿는 순간(처음 25%): 크기 1 − sin(q/0.25·π)·0.08 로 살짝 눌렸다 펴진다.
- 종이 흔들림 0.5초: 폭 (1 − q)·6·세기, x = sin(90k)·폭, y = cos(70k)·폭·0.7, 회전도 조금. 빨리 잦아든다.
- 잉크 번짐: 도장보다 큰 원(52%)에 도장 색 radial-gradient, 0.6 + 0.6·easeOut(q) 배로 퍼진다.
- 도장 글자 · 테두리는 SVG(원 두 겹 + 글자)로 그려 번역된다. 판정마다 색이 다르다(반려 빨강 · 통과 초록 · 보류 주황).

## 핵심 코드 — 도장 내려찍기 · 눌림 · 종이 흔들림 · 잉크 번짐 (k = 이번 판정 시작 뒤 초)
(발췌: demos/demosSim.ts u39 update() 를 정리)
```ts
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const easeOut = (k: number) => 1 - Math.pow(1 - k, 3);
const DROP = 0.32;
let shakeAmt = 1;

function stampStep(k: number, stamp: HTMLElement, paper: HTMLElement, ink: HTMLElement): void {
  if (k < DROP) {                                   // 내려오기
    const q = k / DROP;
    stamp.style.transform = 'translate(-50%,-50%) scale(' + lerp(2.8, 1, q * q) + ') rotate(' + lerp(-24, -9, q) + 'deg)';
    stamp.style.opacity = String(Math.min(0.95, q * 2));
    stamp.style.filter = 'blur(' + (1 - q) * 3 + 'px) drop-shadow(0 ' + (1 - q) * 30 + 'px 10px rgba(0,0,0,.4))';
    paper.style.transform = 'rotate(-3deg)';
    ink.style.opacity = '0';
  } else {                                          // 닿은 뒤
    const q = (k - DROP) / 0.5;
    const amp = q < 1 ? (1 - q) * 6 * shakeAmt : 0;
    const sx = Math.sin(k * 90) * amp, sy = Math.cos(k * 70) * amp * 0.7;
    const sq = q < 0.25 ? 1 - Math.sin((q / 0.25) * Math.PI) * 0.08 : 1; // 살짝 눌림
    stamp.style.transform = 'translate(-50%,-50%) scale(' + sq + ') rotate(-9deg)';
    stamp.style.filter = 'none';
    paper.style.transform = 'translate(' + sx + 'px,' + sy + 'px) rotate(' + (-3 + sx * 0.15) + 'deg)';
    ink.style.opacity = String(Math.max(0.4, Math.min(1, 1 - q * 0.5)));
    ink.style.transform = 'translate(-50%,-50%) scale(' + (0.6 + easeOut(Math.min(1, q)) * 0.6) + ')';
  }
}
// 도장 그림: SVG 원 두 겹 + 글자 (판정마다 색) — 반려 #d8283a · 통과 #1a9a4a · 보류 #e08a1a
// ink.style.background = 'radial-gradient(circle, ' + c + '22, ' + c + '00 70%)';
```

## 흔한 실수 · 확인 목록
- [ ] **닿은 뒤에도 blur 를 걸어 두면 글자가 뿌옇고 느리다** — 내려오는 동안만 filter 를 쓰고 닿으면 none.
- [ ] **도장을 흔들면 찍힌 게 아니라 떨리는 것처럼 보인다** — 흔들림은 받는 쪽(종이)에 준다. 도장은 종이 안에 있어 함께 움직인다.
- [ ] **도장 글자를 그림에 넣으면 다른 언어에서 못 쓴다** — 수학 검문소 규칙 — 도장 · 단추 글씨는 전부 SVG 글자로 그려 번역되게 한다.

## 완성 기준 체크리스트
- [ ] 도장이 크고 흐릿하게 내려와 종이에 닿는 순간 또렷해지며 살짝 눌린다
- [ ] 닿는 순간 종이가 흔들리고 도장 둘레로 잉크가 번진다
- [ ] 반려 · 통과 · 보류가 색을 바꿔 차례로 찍힌다
- [ ] 「흔들림 세기」 0 이면 흔들림 없이 찍힌다

## 이 기술 정보
- id: `u39` · 분류: 이펙트 · 연출 › 입자 · 연출 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — CSS transform · filter 만 바꾼다. 흐림(blur)은 내려오는 0.32초에만 걸고 닿으면 none.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u39
- 쓰면 좋을 때: 판정 · 인증 · 통과처럼 「결정이 내려졌다」를 보여 줄 때 / 손으로 찍는 맛이 필요한 서류 · 검사 게임
- 쓰지 말 때: 자주 반복되는 작은 확인 — 매번 흔들리면 피곤하다. 작은 체크 표시로 / 어지러움에 민감한 화면 — 흔들림 세기를 0 으로 둘 수 있게

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u39 견본 항목 — `src/demos/demosSim.ts:2502`
```ts
  u39: {
    kind: 'dom',
    caption: '왼쪽: 판정 도장이 위에서 쾅 — 종이가 흔들리고 잉크가 번져요 · 오른쪽: 마우스(없으면 저절로)를 따라 겹마다 다르게 움직이는 시차 제목',
    make(box) {
      const wrap = document.createElement('div');
      wrap.style.cssText = 'position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;overflow:hidden;container-type:size;font-family:' + FONT;
      // 왼쪽: 책상 + 서류 + 도장
      const desk = document.createElement('div');
      desk.style.cssText = 'position:relative;overflow:hidden;background:repeating-linear-gradient(95deg,#8a5a34 0 7cqw,#7a4e2c 7cqw 9cqw,#94643c 9cqw 15cqw),#8a5a34';
      const paper = document.createElement('div');
      paper.style.cssText =
        'position:absolute;left:10%;top:9%;width:80%;height:82%;background:#fbf6e8;border-radius:4px;box-shadow:0 6px 14px rgba(0,0,0,.35);transform:rotate(-3deg);background-image:repeating-linear-gradient(#fbf6e8 0 7cqh,#e2dccb 7cqh calc(7cqh + 1px));';
      paper.innerHTML =
        '<div style="position:absolute;left:8%;top:6%;font:800 5cqh/1 ' + TITLE.replace(/"/g, "'") + ';color:#2a2a40">전단 검사서</div><div style="position:absolute;left:8%;top:20%;width:60%;height:2.5cqh;background:#d8cfb6;border-radius:2px"></div><div style="position:absolute;left:8%;top:28%;width:48%;height:2.5cqh;background:#d8cfb6;border-radius:2px"></div><div style="position:absolute;left:8%;top:36%;width:55%;height:2.5cqh;background:#d8cfb6;border-radius:2px"></div>';
      const ink = document.createElement('div');
      ink.style.cssText = 'position:absolute;left:50%;top:62%;width:52%;aspect-ratio:1;transform:translate(-50%,-50%);border-radius:50%;opacity:0';
      const stamp = document.createElement('div');
      stamp.style.cssText = 'position:absolute;left:50%;top:62%;width:46%;aspect-ratio:1;transform:translate(-50%,-50%)';
      const svgOf = (word: string, color: string): string =>
        `<svg viewBox="0 0 100 100" width="100%" height="100%"><g fill="none" stroke="${color}"><circle cx="50" cy="50" r="45" stroke-width="5"/><circle cx="50" cy="50" r="37" stroke-width="2"/></g><text x="50" y="52" text-anchor="middle" dominant-baseline="middle" font-family="'Black Han Sans',sans-serif" font-size="30" fill="${color}">${word}</text><text x="50" y="22" text-anchor="middle" font-size="8" font-weight="800" fill="${color}" font-family="Pretendard,sans-serif">검 문 소</text></svg>`;
      const WORDS: [string, string][] = [
        ['반려', '#d8283a'],
        ['통과', '#1a9a4a'],
        ['보류', '#e08a1a'],
      ];
      paper.append(ink, stamp);
      desk.appendChild(paper);
      // 오른쪽: 시차 층
      const par = document.createElement('div');
      par.style.cssText = 'position:relative;overflow:hidden;background:linear-gradient(#ffb36a,#ff7a8a 45%,#6a4aa0)';
      const layer = (html: string, css: string): HTMLElement => {
        const d = document.createElement('div');
        d.style.cssText = 'position:absolute;inset:-12%;will-change:transform;' + css;
        d.innerHTML = html;
        par.appendChild(d);
        return d;
      };
      const L0 = layer('<div style="position:absolute;left:62%;top:24%;width:18%;aspect-ratio:1;border-radius:50%;background:radial-gradient(#fff6c8,#ffd25a 60%,rgba(255,210,90,0) 72%)"></div>', '');
      const L1 = layer(
        '<svg viewBox="0 0 200 100" preserveAspectRatio="none" style="position:absolute;left:0;bottom:22%;width:100%;height:40%"><path d="M0 100 L0 60 L30 30 L55 55 L85 15 L120 58 L150 28 L200 62 L200 100Z" fill="#8a5ab0"/></svg>',
        '',
      );
      const L2 = layer(
        '<svg viewBox="0 0 200 100" preserveAspectRatio="none" style="position:absolute;left:0;bottom:6%;width:100%;height:36%"><path d="M0 100 L0 70 L20 70 L20 50 L40 50 L40 64 L70 64 L70 36 L84 28 L98 36 L98 66 L130 66 L130 46 L160 46 L160 70 L200 70 L200 100Z" fill="#3a2a60"/><rect x="74" y="44" width="5" height="6" fill="#ffe08a"/><rect x="88" y="44" width="5" height="6" fill="#ffe08a"/><rect x="138" y="54" width="5" height="6" fill="#ffe08a"/></svg>',
        '',
      );
      const L3 = layer(
        `<div style="position:absolute;inset:0;display:grid;place-items:center"><div style="text-align:center;transform:rotate(-4deg)"><div style="font:400 8.5cqh/1 ${TITLE.replace(/"/g, "'")};color:#ffe58a;-webkit-text-stroke:0.8cqh #2a1440;paint-order:stroke fill;text-shadow:0 1cqh 0 #2a1440">수학 검문소</div><div style="margin-top:2cqh;font:800 3cqh/1 ${FONT.replace(/"/g, "'")};color:#fff;letter-spacing:.2em">MATH CHECKPOINT</div></div></div>`,
        '',
      );
      wrap.append(desk, par);
      box.appendChild(wrap);
      let mx = 0;
      let my = 0;
      let lastMove = -10;
      let tt = 0;
      const onMove = (e: PointerEvent): void => {
        const r = par.getBoundingClientRect();
        mx = clamp(((e.clientX - r.left) / r.width) * 2 - 1, -1, 1);
        my = clamp(((e.clientY - r.top) / r.height) * 2 - 1, -1, 1);
        lastMove = tt;
      };
      par.addEventListener('pointermove', onMove);
      let shakeAmt = 1;
      let cur = -1;
      return {
        update(t) {
          tt = t;
          // 도장
          const P = 2.4;
          const k = t % P;
          const n = Math.floor(t / P);
          if (n !== cur) {
            cur = n;
            const [w, c] = WORDS[n % WORDS.length]!;
            stamp.innerHTML = svgOf(w, c);
            ink.style.background = `radial-gradient(circle, ${c}22, ${c}00 70%)`;
          }
          const drop = 0.32;
          if (k < drop) {
            const q = k / drop;
            const s = lerp(2.8, 1, q * q);
            stamp.style.transform = `translate(-50%,-50%) scale(${s}) rotate(${lerp(-24, -9, q)}deg)`;
            stamp.style.opacity = String(clamp(q * 2, 0, 0.95));
            stamp.style.filter = `blur(${(1 - q) * 3}px) drop-shadow(0 ${(1 - q) * 30}px 10px rgba(0,0,0,.4))`;
            paper.style.transform = 'rotate(-3deg)';
            ink.style.opacity = '0';
          } else {
            const q = (k - drop) / 0.5;
            const amp = q < 1 ? (1 - q) * 6 * shakeAmt : 0;
            const sx = Math.sin(k * 90) * amp;
            const sy = Math.cos(k * 70) * amp * 0.7;
            const sq = q < 0.25 ? 1 - Math.sin((q / 0.25) * Math.PI) * 0.08 : 1;
            stamp.style.transform = `translate(-50%,-50%) scale(${sq}) rotate(-9deg)`;
            stamp.style.opacity = k > P - 0.25 ? String(((P - k) / 0.25) * 0.95) : '0.95';
            stamp.style.filter = 'none';
            paper.style.transform = `translate(${sx}px,${sy}px) rotate(${-3 + sx * 0.15}deg)`;
            ink.style.opacity = String(clamp(1 - q * 0.5, 0.4, 1) * (k > P - 0.25 ? (P - k) / 0.25 : 1));
            ink.style.transform = `translate(-50%,-50%) scale(${0.6 + easeOut(q) * 0.6})`;
          }
          // 시차
          let x = mx;
          let y = my;
          if (t - lastMove > 1.5) {
            x = Math.sin(t * 0.8);
            y = Math.sin(t * 1.1) * 0.6;
          }
          const W = par.clientWidth;
          [L0, L1, L2, L3].forEach((el, i) => {
            const f = [0.02, 0.05, 0.09, 0.14][i]! * W;
            el.style.transform = `translate(${-x * f}px, ${-y * f * 0.6}px)`;
          });
        },
        controls: [{ type: 'range', label: '흔들림 세기', min: 0, max: 3, step: 0.1, value: 1, on: (v) => (shakeAmt = v) }],
        dispose() {
          par.removeEventListener('pointermove', onMove);
          wrap.remove();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [만화 연출 층 (DOM)](https://ai-techstudio.web.app/ai/t/u38.md) `u38`
- 다음에 해 볼 기술: [카메라 흔들림 (잡음 기반 · 감쇠)](https://ai-techstudio.web.app/ai/t/i429.md) `i429`
