# AI 꾸러미 — 착지 먼지 뭉게 — Landing dust puff
> 블록이 떨어지거나 친구가 폴짝 착지하는 순간 발밑 좌우로 동글동글 먼지가 굴러 나가, 「쿵」 하는 무게가 느껴진다.  
> 견본: https://ai-techstudio.web.app/#t/i214

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

## 주문서

### 만들어 줘: 착지 먼지 뭉게 — Landing dust puff

#### 1. 목표
떨어지는 숫자 블록이 바닥에 닿는 순간 발밑 좌우로 동글동글 먼지가 굴러 나가고, 몸이 납작했다 돌아오게 해 줘. 분위기는 따뜻한 모래색 그림책.

#### 2. 핵심 기술 용어
- **Landing dust puff** — 착지 먼지 뭉게
- **Squash and stretch** — 떨어질 땐 길쭉, 닿으면 납작 — 탄력
- **Damped oscillation** — e^(−at)·cos(bt) 로 출렁이다 잦아들기
- **Game juice** — 작은 연출로 손맛 더하기

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

#### 4. 조건
- 먼지는 좌우 양쪽으로 — 한쪽만 나오면 미끄러진 것처럼 보인다
- 덩이는 빨리 퍼졌다 멈추고(감속), 커지며 옅어진다. 0.7초 안에 사라진다
- 몸 납작함은 출렁이다 잦아드는 감쇠 진동(e^(−at)·cos(bt))으로
- 먼지 양은 조절 값 하나(배율)로 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 나란히 비교에서 왼쪽은 블록이 그냥 멈추고, 오른쪽은 먼지가 좌우로 굴러 나가며 블록이 납작했다 돌아온다
- 폴짝 뛰는 친구도 착지 때 같은 먼지가 조금 작게(0.85배) 나온다
- 「먼지 양」 슬라이더(0.3 ~ 2.5)로 덩이 수가 바로 바뀐다
- 폰에서도 60fps

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

## 원리
- 닿은 순간부터 0.7초 동안 왼쪽 · 오른쪽에 먼지 덩이를 각각 6개쯤 굴려 보낸다.
- 덩이는 (1 − e^(−5t)) 로 빨리 퍼졌다 멈추고, 크기는 0.6 + 1.6t 배로 커지며, 투명도는 (1 − t/0.7)·0.85 로 옅어진다.
- 밝은 덩이 위에 조금 어두운 덩이를 비껴 겹쳐 그려 동글동글 입체감을 낸다.
- 몸은 떨어질 때 살짝 길쭉, 닿으면 sq = e^(−9t)·cos(30t) 로 납작했다 출렁이며 돌아온다 (가로 1 + 0.22sq, 세로 1 − 0.25sq).
- 닿는 0.15초 동안 화면이 위아래로 2픽셀쯤 떨려 무게를 더한다.

## 핵심 코드 — 착지 먼지 덩이 그리기 (a = 닿은 뒤 지난 초)
(발췌: demos/demosJuice.ts D214 puffs() 를 정리)
```ts
const TAU = Math.PI * 2;
let amount = 1; // 먼지 양 배율

function puffs(g: CanvasRenderingContext2D, x: number, y: number, a: number, u: number, seed: number, scale: number): void {
  if (a < 0 || a > 0.7) return;               // 0.7초 동안만
  const n = Math.round(5 * amount) + 1;
  for (const side of [-1, 1]) {                // 왼쪽 · 오른쪽
    for (let i = 0; i < n; i++) {
      const sp = (24 + hash(i, seed + side) * 26) * u * scale;
      const d = sp * (1 - Math.exp(-a * 5));   // 빨리 퍼졌다 멈춤
      const px = x + side * (6 * u * scale + d);
      const py = y - (2 + hash(i, seed + 7) * 6) * u * scale * Math.min(1, a * 4) - a * 6 * u;
      const r = (4 + hash(i, seed + 3) * 5) * u * scale * (0.6 + a * 1.6); // 커지며
      const al = (1 - a / 0.7) * 0.85;                                      // 옅어짐
      g.fillStyle = 'rgba(255,248,235,' + al + ')';
      g.beginPath(); g.arc(px, py, r, 0, TAU); g.fill();
      g.fillStyle = 'rgba(200,180,160,' + al * 0.5 + ')'; // 비껴 겹친 그늘 덩이
      g.beginPath(); g.arc(px + r * 0.2, py + r * 0.3, r * 0.7, 0, TAU); g.fill();
    }
  }
}

// 몸: 닿은 뒤 납작했다 출렁이며 돌아옴
const sq = Math.exp(-a * 9) * Math.cos(a * 30);
const sx = 1 + 0.22 * sq, sy = 1 - 0.25 * sq;
// 화면: 닿은 0.15초만 살짝 떨림
const shake = a >= 0 && a < 0.15 ? Math.sin(a * 120) * 2 * u * (1 - a / 0.15) : 0;
```

## 흔한 실수 · 확인 목록
- [ ] **먼지를 같은 크기로 두면 종이 오린 것처럼 딱딱하다** — 덩이마다 반지름 · 거리 · 높이를 hash 로 조금씩 다르게, 퍼지며 커지게 한다.
- [ ] **납작함을 한 번 줄였다 펴기만 하면 고무 느낌이 없다** — e^(−9t)·cos(30t) 처럼 두세 번 출렁이다 잦아들게 한다.
- [ ] **먼지를 몸보다 위에 그리면 발을 가린다** — 그림자 → 몸 → 먼지 순서로 그리되 먼지는 발밑 높이(y − 2~8)에서 시작해 몸통을 덮지 않게.

## 완성 기준 체크리스트
- [ ] 나란히 비교에서 왼쪽은 블록이 그냥 멈추고, 오른쪽은 먼지가 좌우로 굴러 나가며 블록이 납작했다 돌아온다
- [ ] 폴짝 뛰는 친구도 착지 때 같은 먼지가 조금 작게(0.85배) 나온다
- [ ] 「먼지 양」 슬라이더(0.3 ~ 2.5)로 덩이 수가 바로 바뀐다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `i214` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 착지 한 번에 원 12~30개를 0.7초 그리는 것뿐이라 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i214
- 쓰면 좋을 때: 블록 놓기 · 캐릭터 착지 · 판에 말 놓기처럼 「닿는 순간」이 있는 곳 / 놓은 것이 맞게 들어갔다는 걸 소리 없이도 알려 주고 싶을 때
- 쓰지 말 때: 아주 자주 착지하는 것(초당 여러 번) — 먼지가 화면을 덮는다. 큰 착지에만 쓰고 작은 걸음엔 덩이 수를 줄인다 / 공중 · 물속 장면 — 먼지 대신 물결 · 거품을 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D214 — `src/demos/demosJuice.ts:1861`
```ts
const D214: Demo = {
  kind: '2d',
  caption: '블록이 툭, 친구가 폴짝 — 떨어지는 순간 발밑 좌우로 동글동글 먼지가 굴러 나간다',
  make() {
    let compare = true;
    let amount = 1;
    const P = 2.2;
    let T = 0;
    const puffs = (g: G, x: number, y: number, a: number, u: number, seed: number, scale: number): void => {
      if (a < 0 || a > 0.7) return;
      const n = Math.round(5 * amount) + 1;
      for (const side of [-1, 1]) {
        for (let i = 0; i < n; i++) {
          const sp = (24 + hash(i, seed + side) * 26) * u * scale;
          const d = sp * (1 - Math.exp(-a * 5));
          const px = x + side * (6 * u * scale + d);
          const py = y - (2 + hash(i, seed + 7) * 6) * u * scale * Math.min(1, a * 4) - a * 6 * u;
          const r = (4 + hash(i, seed + 3) * 5) * u * scale * (0.6 + a * 1.6);
          const al = (1 - a / 0.7) * 0.85;
          g.fillStyle = `rgba(255,248,235,${al})`;
          g.beginPath();
          g.arc(px, py, r, 0, TAU);
          g.fill();
          g.fillStyle = `rgba(200,180,160,${al * 0.5})`;
          g.beginPath();
          g.arc(px + r * 0.2, py + r * 0.3, r * 0.7, 0, TAU);
          g.fill();
        }
      }
    };
    const scene = (g: G, w: number, h: number, on: boolean): void => {
      const u = uh(w, h);
      bg(g, w, h, '#ffe8cc', '#ffc9a0');
      const gy = h * 0.76;
      g.fillStyle = '#d9a46a';
      g.fillRect(0, gy, w, h - gy);
      g.fillStyle = '#b97d43';
      g.fillRect(0, gy, w, 2.5 * u);
      const ph = T % P;
      // 블록
      const L1 = 0.5;
      const bx = w * 0.32;
      const s = 34 * u;
      const a1 = ph - L1;
      let shake = 0;
      if (on && a1 >= 0 && a1 < 0.15) shake = Math.sin(a1 * 120) * 2 * u * (1 - a1 / 0.15);
      g.save();
      g.translate(0, shake);
      if (a1 < 0) {
        const k = clamp01(ph / L1);
        block(g, bx, lerp(-10 * u, gy, k * k), s, '#4dabf7', '6', { sx: on ? 1 - k * k * 0.08 : 1, sy: on ? 1 + k * k * 0.1 : 1 });
      } else {
        const sq = on ? Math.exp(-a1 * 9) * Math.cos(a1 * 30) : 0;
        g.fillStyle = 'rgba(80,40,10,.25)';
        g.beginPath();
        g.ellipse(bx, gy + 1 * u, s * 0.6, 3 * u, 0, 0, TAU);
        g.fill();
        block(g, bx, gy, s, '#4dabf7', '6', { sx: 1 + 0.22 * sq, sy: 1 - 0.25 * sq });
        if (on) puffs(g, bx, gy, a1, u, 3, 1.1);
      }
      // 친구 폴짝
      const L2 = 1.35;
      const cx = w * 0.72;
      const jumpT = 0.55;
      const a2 = ph - L2;
      let cy = gy;
      let sx = 1;
      let sy = 1;
      if (ph > L2 - jumpT && a2 < 0) {
        const k = (ph - (L2 - jumpT)) / jumpT;
        cy = gy - Math.sin(k * Math.PI) * 44 * u;
        if (on) {
          sx = 0.9;
          sy = 1.12;
        }
      } else if (a2 >= 0) {
        const sq = on ? Math.exp(-a2 * 10) * Math.cos(a2 * 28) : 0;
        sx = 1 + 0.3 * sq;
        sy = 1 - 0.3 * sq;
      } else if (on && ph > L2 - jumpT - 0.12) {
        sx = 1.15;
        sy = 0.85;
      }
      g.fillStyle = 'rgba(80,40,10,.25)';
      g.beginPath();
      g.ellipse(cx, gy + 1 * u, 13 * u * (1 - (gy - cy) / (100 * u)), 3 * u, 0, 0, TAU);
      g.fill();
      g.save();
      g.translate(cx, cy);
      g.scale(sx, sy);
      ball(g, 0, -14 * u, 14 * u, '#ff6b8a', '');
      g.fillStyle = '#2b1030';
      g.beginPath();
      g.arc(-4.5 * u, -17 * u, 2 * u, 0, TAU);
      g.arc(4.5 * u, -17 * u, 2 * u, 0, TAU);
      g.fill();
      g.fillStyle = 'rgba(255,255,255,.5)';
      g.beginPath();
      g.ellipse(-7 * u, -22 * u, 3 * u, 2 * u, -0.5, 0, TAU);
      g.fill();
      g.restore();
      if (on) puffs(g, cx, gy, a2, u, 11, 0.85);
      g.restore();
    };
    return {
      draw(g, w, h, t) {
        T = t;
        split(g, w, h, compare, scene);
      },
      controls: [tgl('나란히 비교 (끔 | 켬)', compare, (v) => (compare = v)), rng('먼지 양', 0.3, 2.5, 0.1, amount, (v) => (amount = v))],
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [동전 빨려 들기 (UI 로 날아가기)](https://ai-techstudio.web.app/ai/t/i208.md) `i208`
- 다음에 해 볼 기술: [충격 고리 · 땅 파동](https://ai-techstudio.web.app/ai/t/i178.md) `i178`
