# AI 꾸러미 — 발자국 데칼
> 지나간 자리에 발자국이 남았다 사라짐  
> 견본: https://ai-techstudio.web.app/#t/i215

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

## 주문서

### 만들어 줘: 발자국 데칼

#### 1. 목표
걸은 수 세기에 「발자국 데칼」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 지나간 자리에 발자국이 남았다 사라짐

#### 2. 핵심 기술 용어
- 발자국 데칼 — 지나간 자리에 발자국이 남았다 사라짐

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「지나간 자리에 발자국이 남았다 사라짐」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 지나간 자리에 발자국이 남았다 사라짐


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

## 완성 기준 체크리스트
- [ ] 견본처럼 「지나간 자리에 발자국이 남았다 사라짐」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i215` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 3D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i215
- 쓰면 좋을 때: 쓰면 좋을 곳 — 걸은 수 세기 · 한붓그리기 길

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D215 — `src/demos/demosJuice.ts:3605`
```ts
const D215: Demo = {
  kind: '3d',
  caption: '눈밭을 걷는 친구 — 지나간 자리에 발자국이 콕콕 남았다 천천히 사라진다 (걸음 수도 센다)',
  make() {
    const scene = new THREE.Scene();
    scene.background = skyTex('#9cc8ff', '#e8f3ff');
    scene.fog = new THREE.Fog(0xdcecff, 9, 20);
    const cam = new THREE.PerspectiveCamera(40, 1.6, 0.1, 60);
    cam.position.set(0, 4.6, 5.6);
    cam.lookAt(0, 0, 0.3);
    scene.add(new THREE.HemisphereLight(0xffffff, 0x9fb4d8, 1.7));
    const sun = new THREE.DirectionalLight(0xfff4e0, 2);
    sun.position.set(4, 8, 3);
    scene.add(sun);
    const snow = new THREE.Mesh(new THREE.CircleGeometry(14, 64), new THREE.MeshStandardMaterial({ color: 0xe3ecfb, roughness: 0.95 }));
    snow.rotation.x = -Math.PI / 2;
    scene.add(snow);
    // 눈 더미 · 나무
    const lump = new THREE.SphereGeometry(1, 20, 12);
    const snowM = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.9 });
    const coneG = new THREE.ConeGeometry(0.55, 1.3, 14);
    const pineM = new THREE.MeshStandardMaterial({ color: 0x2f7d5a, roughness: 0.8 });
    const capG = new THREE.ConeGeometry(0.42, 0.5, 14);
    for (let i = 0; i < 9; i++) {
      const an = (i / 9) * TAU + 0.3;
      const r = 4.6 + hash(i, 3) * 1.6;
      const x = Math.cos(an) * r;
      const z = Math.sin(an) * r * 0.75 - 0.5;
      if (i % 2) {
        const m = new THREE.Mesh(lump, snowM);
        m.scale.set(0.6 + hash(i, 4) * 0.5, 0.25, 0.5);
        m.position.set(x, 0, z);
        scene.add(m);
      } else {
        const t = new THREE.Mesh(coneG, pineM);
        t.position.set(x, 0.65, z);
        const c = new THREE.Mesh(capG, snowM);
        c.position.set(x, 1.15, z);
        scene.add(t, c);
      }
    }
    // 친구
    const kid = new THREE.Group();
    kid.scale.setScalar(1.2);
    scene.add(kid);
    const body = new THREE.Mesh(new THREE.SphereGeometry(0.42, 28, 20), new THREE.MeshStandardMaterial({ color: 0xff8a3d, roughness: 0.55 }));
    body.position.y = 0.48;
    body.scale.set(1, 0.92, 1);
    kid.add(body);
    const knit = new THREE.MeshStandardMaterial({ color: 0xe8364a, roughness: 0.8 });
    const cap = new THREE.Mesh(new THREE.SphereGeometry(0.33, 24, 12, 0, TAU, 0, Math.PI / 2), knit);
    cap.position.y = 0.2;
    body.add(cap);
    const brim = new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.065, 10, 28), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.9 }));
    brim.rotation.x = Math.PI / 2;
    brim.position.y = 0.2;
    body.add(brim);
    const pom = new THREE.Mesh(new THREE.SphereGeometry(0.1, 14, 10), brim.material);
    pom.position.y = 0.55;
    body.add(pom);
    const eyeW = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 });
    const eyeB = new THREE.MeshStandardMaterial({ color: 0x1a1033, roughness: 0.2 });
    for (const s of [-1, 1]) {
      const e = new THREE.Mesh(new THREE.SphereGeometry(0.09, 14, 10), eyeW);
      e.position.set(s * 0.14, 0.1, 0.36);
      const p = new THREE.Mesh(new THREE.SphereGeometry(0.05, 12, 8), eyeB);
      p.position.set(0, 0, 0.06);
      e.add(p);
      body.add(e);
    }
    const feet: THREE.Mesh[] = [];
    const footM = new THREE.MeshStandardMaterial({ color: 0x8a3b12, roughness: 0.6 });
    for (const s of [-1, 1]) {
      const f = new THREE.Mesh(new THREE.SphereGeometry(0.13, 14, 10), footM);
      f.scale.set(0.9, 0.55, 1.3);
      f.position.set(s * 0.17, 0.07, 0);
      kid.add(f);
      feet.push(f);
    }
    const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1.1, 1.1), new THREE.MeshBasicMaterial({ map: dotTex(), color: 0x34507a, transparent: true, opacity: 0.35, depthWrite: false }));
    shadow.rotation.x = -Math.PI / 2;
    shadow.position.y = 0.012;
    scene.add(shadow);
    // 발자국
    const printTex = canvasTex(64, 96, (g) => {
      g.fillStyle = '#fff';
      g.beginPath();
      g.ellipse(32, 60, 17, 26, 0, 0, TAU);
      g.fill();
      for (let i = 0; i < 3; i++) {
        g.beginPath();
        g.arc(17 + i * 15, 22 - (i === 1 ? 5 : 0), 7.5, 0, TAU);
        g.fill();
      }
    });
    const printGeo = new THREE.PlaneGeometry(0.2, 0.3);
    printGeo.rotateX(-Math.PI / 2);
    const NP = 60;
    const prints: { m: THREE.Mesh; mat: THREE.MeshBasicMaterial; born: number }[] = [];
    for (let i = 0; i < NP; i++) {
      const mat = new THREE.MeshBasicMaterial({ map: printTex, color: 0x6f8fc8, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 });
      const m = new THREE.Mesh(printGeo, mat);
      m.position.y = 0.004 + i * 0.00002;
      m.visible = false;
      scene.add(m);
      prints.push({ m, mat, born: -99 });
    }
    let pi = 0;
    let dist = 0;
    let side = 1;
    let steps = 0;
    let prev: THREE.Vector3 | null = null;
    let s = 0;
    let fade = 4;
    let walk = 1;
    let decals = true;
    const hud = new Hud();
    const cnv = document.createElement('canvas');
    cnv.width = 300;
    cnv.height = 80;
    const cg = cnv.getContext('2d')!;
    const cTex = new THREE.CanvasTexture(cnv);
    cTex.colorSpace = THREE.SRGBColorSpace;
    hud.addTex(cTex, 300 / 80, 0.15, 0.1, 0.09);
    const drawCount = (n: number): void => {
      cg.clearRect(0, 0, 300, 80);
      rr(cg, 2, 2, 296, 76, 38);
      cg.fillStyle = 'rgba(30,50,110,.85)';
      cg.fill();
      txt(cg, `걸음 ${n}`, 150, 42, 42, '#fff', 'center', 800);
      cTex.needsUpdate = true;
    };
    let shownSteps = -1;
    const path = (u: number, o: THREE.Vector3): THREE.Vector3 => o.set(Math.sin(u) * 2.8, 0, Math.sin(2 * u) * 1.5 + 0.3);
    const p0 = new THREE.Vector3();
    const p1 = new THREE.Vector3();
    return {
      scene,
      camera: cam,
      update(t, dt0) {
        const dt = Math.min(dt0, 0.05);
        s += dt * 0.42 * walk;
        path(s, p0);
        path(s + 0.01, p1);
        const dir = p1.sub(p0).normalize();
        const head = Math.atan2(dir.x, dir.z);
        kid.position.copy(p0);
        kid.rotation.y = head;
        shadow.position.set(p0.x, 0.012, p0.z);
        const cyc = s * 18;
        body.position.y = 0.48 + Math.abs(Math.sin(cyc)) * 0.06;
        body.rotation.z = Math.sin(cyc) * 0.06;
        feet[0]!.position.z = Math.sin(cyc) * 0.17;
        feet[1]!.position.z = -Math.sin(cyc) * 0.17;
        feet[0]!.position.y = 0.07 + Math.max(0, Math.cos(cyc)) * 0.08;
        feet[1]!.position.y = 0.07 + Math.max(0, -Math.cos(cyc)) * 0.08;
        if (prev) dist += prev.distanceTo(p0);
        prev = (prev ?? new THREE.Vector3()).copy(p0);
        const stepLen = (Math.PI / 18) * 2.8 * 0.95;
        while (dist > stepLen * 0.5) {
          dist -= stepLen * 0.5;
          steps++;
          if (decals) {
            const pr = prints[pi]!;
            pi = (pi + 1) % NP;
            const rx = Math.cos(head) * 0.17 * side;
            const rz = -Math.sin(head) * 0.17 * side;
            pr.m.position.x = p0.x + rx;
            pr.m.position.z = p0.z + rz;
            pr.m.rotation.y = head + side * 0.08;
            pr.m.scale.x = side;
            pr.born = t;
            pr.m.visible = true;
          }
          side = -side;
        }
        for (const pr of prints) {
          if (!pr.m.visible) continue;
          const a = t - pr.born;
          const k = a / fade;
          if (k >= 1 || a < 0) {
            pr.m.visible = false;
            continue;
          }
          pr.mat.opacity = 0.75 * Math.min(1, a * 12) * (1 - k * k);
        }
        if (steps !== shownSteps) {
          shownSteps = steps;
          drawCount(steps);
        }
      },
      render(r, w, h) {
        r.render(scene, cam);
        hud.render(r, w, h, true, false);
      },
      controls: [
        tgl('발자국 남기기', decals, (v) => (decals = v)),
        rng('사라지는 시간 (초)', 1, 10, 0.5, fade, (v) => (fade = v)),
        rng('걷는 빠르기', 0.3, 2.5, 0.1, walk, (v) => (walk = v)),
      ],
      dispose() {
        disposeScene(scene);
        hud.dispose();
      },
    };
  },
};
```

## 관련 기술
- 이 효과를 만든 기술: [데칼 (글자 · 경고 표시 · 총알 자국)](https://ai-techstudio.web.app/ai/t/i479.md) `i479`
