# AI 꾸러미 — 흔들흔들 거부 (고개 젓기)
> 안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인  
> 견본: https://ai-techstudio.web.app/#t/i207

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

## 주문서

### 만들어 줘: 흔들흔들 거부 (고개 젓기)

#### 1. 목표
막힌 수에 「흔들흔들 거부 (고개 젓기)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인

#### 2. 핵심 기술 용어
- 흔들흔들 거부 (고개 젓기) — 안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인

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

#### 4. 조건
- 화면 코드와 계산 코드를 나눠서 (계산은 순수 함수)
- 한 프레임 16ms 안 — 오래 걸리는 계산은 나눠서

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「안 되는 입력이면 좌우로 부르르 + 붉은빛 — 감쇠 사인」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i207` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i207
- 쓰면 좋을 때: 쓰면 좋을 곳 — 막힌 수 · 이미 쓴 숫자

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D207 — `src/demos/demosJuice.ts:2402`
```ts
const D207: Demo = {
  kind: 'dom',
  caption: '이미 쓴 숫자 2 를 누르면 — 위는 아무 반응 없음(고장인가?), 아래는 고개 젓듯 부르르 + 붉은빛 + 까닭 말풍선',
  make(box) {
    const N = ['1', '2', '3', '4', '5'];
    const USED = new Set([1, 3]);
    const tiles = (): string => N.map((n, i) => `<div class="t ${USED.has(i) ? 'used' : ''}" data-i="${i}"><span>${n}</span></div>`).join('');
    const root = domRoot(
      box,
      'jx-nope',
      `.jx-nope{background:radial-gradient(120% 90% at 50% 0%,#2f4b7c,#0c1530)}
       .jx-nope .row{position:absolute;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:3cqmin;padding-left:12cqmin}
       .jx-nope .r0{top:8%;height:38%}.jx-nope .r1{top:52%;height:44%}
       .jx-nope .tag{position:absolute;left:4cqmin;top:50%;transform:translateY(-50%);font-size:5.2cqmin;font-weight:800;padding:1cqmin 2.6cqmin;border-radius:9cqmin;background:rgba(10,12,40,.75);color:#a9b3dc}
       .jx-nope .tag.on{background:#ffcd3c;color:#2a1a00}
       .jx-nope .t{position:relative;width:17cqmin;height:20cqmin;border-radius:4.5cqmin;background:linear-gradient(#fff,#e6ecff);box-shadow:0 1.6cqmin 0 #8d9bd0;display:grid;place-items:center;font-size:11cqmin;font-weight:900;color:#3b4a8f;cursor:pointer}
       .jx-nope .t.used{background:linear-gradient(#c9cfe6,#aab2d2);color:#7d86ad;box-shadow:0 1.6cqmin 0 #6f799f}
       .jx-nope .t.used::after{content:'';position:absolute;left:18%;right:18%;top:50%;height:1.2cqmin;background:#7d86ad;transform:rotate(-30deg);border-radius:1cqmin}
       .jx-nope .t.sel{background:linear-gradient(#74c0fc,#339af0);color:#fff;box-shadow:0 1.6cqmin 0 #1864ab}
       .jx-nope .bub{position:absolute;bottom:calc(100% + 3cqmin);left:50%;white-space:nowrap;font-size:4.6cqmin;font-weight:800;padding:1.4cqmin 2.6cqmin;border-radius:3cqmin;background:#ff4d6d;color:#fff;transform:translateX(-50%) scale(0);transform-origin:50% 120%;pointer-events:none;z-index:2}
       .jx-nope .bub::after{content:'';position:absolute;left:50%;top:100%;border:1.6cqmin solid transparent;border-top-color:#ff4d6d;transform:translateX(-50%)}
       .jx-nope .tap{position:absolute;width:6.5cqmin;height:6.5cqmin;border-radius:50%;background:rgba(255,255,255,.8);box-shadow:0 0 0 1.4cqmin rgba(255,255,255,.25);pointer-events:none;z-index:3}`,
      `<div class="row r0"><span class="tag">끔</span>${tiles()}<div class="tap"></div></div>
       <div class="row r1"><span class="tag on">켬</span>${tiles()}<div class="tap"></div></div>`,
    );
    const rows = qa(root, '.row').map((r) => qa(r, '.t'));
    const taps = qa(root, '.tap');
    const onTiles = rows[1]!;
    const bub = document.createElement('div');
    bub.className = 'bub';
    bub.textContent = '이미 쓴 숫자예요';
    onTiles[1]!.appendChild(bub);
    const P = 2.9;
    const REJ = 0.55;
    const OK = 1.75;
    let amp = 1;
    // 직접 누르기 (켬 줄)
    let userRej = -99;
    let userIdx = 1;
    let now = 0;
    const handlers: [HTMLElement, EventListener][] = [];
    onTiles.forEach((el, i) => {
      const f = (e: Event): void => {
        e.preventDefault();
        if (USED.has(i)) {
          userRej = now;
          userIdx = i;
          el.appendChild(bub);
        } else el.classList.toggle('sel');
      };
      el.addEventListener('pointerdown', f);
      handlers.push([el, f]);
    });
    return {
      update(t) {
        now = t;
        const ph = t % P;
        const userMode = t - userRej < 1.2;
        rows.forEach((tl, r) => {
          const on = r === 1;
          // 손가락
          const target = ph < (REJ + OK) / 2 ? 1 : 2;
          const tAt = target === 1 ? REJ : OK;
          const el = tl[target]!;
          const tp = taps[r]!;
          const dn = ph >= tAt && ph < tAt + 0.15;
          tp.style.opacity = userMode && on ? '0' : ph > P - 0.3 ? '0' : '1';
          tp.style.left = `${el.offsetLeft + el.offsetWidth * 0.6}px`;
          tp.style.top = `${el.offsetTop + el.offsetHeight * 0.62}px`;
          tp.style.transition = 'left .35s ease, top .35s ease, opacity .2s';
          tp.style.transform = `translate(-50%,-50%) scale(${dn ? 0.7 : 1})`;
          // 고르기
          const okA = ph - OK;
          if (!(userMode && on)) tl[2]!.classList.toggle('sel', okA >= 0 && ph < P - 0.15);
          if (on) {
            const s = okA >= 0 && okA < 0.5 ? 1 + 0.16 * Math.exp(-okA * 9) * Math.cos(okA * 26) : 1;
            tl[2]!.style.transform = `scale(${s.toFixed(4)})`;
            const ra = userMode ? t - userRej : ph - REJ;
            const ri = userMode ? userIdx : 1;
            tl.forEach((x, i) => {
              if (i === 2) return;
              if (i === ri && ra >= 0 && ra < 0.9) {
                const sh = Math.sin(ra * 48) * 3.2 * amp * Math.exp(-ra * 5.5);
                const rot = Math.sin(ra * 48 + 0.6) * 4 * amp * Math.exp(-ra * 5.5);
                const glowA = Math.exp(-ra * 3.5);
                x.style.transform = `translateX(${sh.toFixed(2)}cqmin) rotate(${rot.toFixed(2)}deg)`;
                x.style.boxShadow = `0 1.6cqmin 0 #6f799f, 0 0 ${(4 * glowA).toFixed(2)}cqmin ${(1.6 * glowA).toFixed(2)}cqmin rgba(255,60,90,${(0.9 * glowA).toFixed(3)})`;
                x.style.background = `linear-gradient(rgba(255,${Math.round(201 - 120 * glowA)},${Math.round(207 - 120 * glowA)},1),#aab2d2)`;
              } else {
                x.style.transform = '';
                x.style.boxShadow = '';
                x.style.background = '';
              }
            });
            const ba = ra;
            const bs = ba >= 0 && ba < 1.1 ? backOut(ba / 0.25) * (1 - smooth(0.85, 1.1, ba)) : 0;
            bub.style.transform = `translateX(-50%) scale(${bs.toFixed(3)})`;
          }
        });
      },
      controls: [rng('흔들림 세기', 0.2, 2.5, 0.1, amp, (v) => (amp = v))],
      dispose() {
        for (const [el, f] of handlers) el.removeEventListener('pointerdown', f);
      },
    };
  },
};
```

## 관련 기술
- 이 효과를 만든 기술: [i423](https://ai-techstudio.web.app/ai/t/i423.md) `i423`
