# AI 꾸러미 — 보물 상자 열기 연출
> 흔들 → 빛 새어 나옴 → 뚜껑 펑 → 빛기둥 → 보상  
> 견본: https://ai-techstudio.web.app/#t/i209

이 문서를 코드 도우미(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: `i209` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i209
- 쓰면 좋을 때: 쓰면 좋을 곳 — 단계 통과 보상 · 월드 클리어

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D209 — `src/demos/demosJuice.ts:3238`
```ts
const D209: Demo = {
  kind: '3d',
  caption: '보물 상자 — 덜컹덜컹 흔들 → 틈으로 빛이 샘 → 뚜껑 펑 → 빛기둥 → 금별이 솟아 빙글',
  make() {
    const scene = new THREE.Scene();
    scene.background = skyTex('#3b2a78', '#0d0824');
    const cam = new THREE.PerspectiveCamera(38, 1.6, 0.1, 60);
    cam.position.set(0, 2.5, 7.2);
    cam.lookAt(0, 1.15, 0);
    scene.add(new THREE.HemisphereLight(0xd6ccff, 0x2a1a40, 1.3));
    const key = new THREE.DirectionalLight(0xfff0dd, 2.4);
    key.position.set(3, 6, 5);
    scene.add(key);
    const inner = new THREE.PointLight(0xffc94a, 0, 6, 1.5);
    inner.position.set(0, 1.2, 0.3);
    scene.add(inner);
    // 받침
    const ped = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 2.1, 0.35, 48), new THREE.MeshStandardMaterial({ color: 0x5a4a9a, roughness: 0.7 }));
    ped.position.y = -0.175;
    scene.add(ped);
    const pedTop = new THREE.Mesh(new THREE.CylinderGeometry(1.75, 1.75, 0.04, 48), new THREE.MeshStandardMaterial({ color: 0x7d6bc4, roughness: 0.6 }));
    pedTop.position.y = 0.02;
    scene.add(pedTop);
    // 상자
    const chest = new THREE.Group();
    scene.add(chest);
    const wood = new THREE.MeshStandardMaterial({ color: 0xa0612d, roughness: 0.72 });
    const woodD = new THREE.MeshStandardMaterial({ color: 0x7a4520, roughness: 0.8 });
    const gold = new THREE.MeshStandardMaterial({ color: 0xffc23d, metalness: 0.35, roughness: 0.3, emissive: 0x5a3000, emissiveIntensity: 0.5 });
    const W = 1.7;
    const Hh = 0.95;
    const D = 1.05;
    const base = new THREE.Mesh(new THREE.BoxGeometry(W, Hh, D), wood);
    base.position.y = Hh / 2;
    chest.add(base);
    for (const sx of [-1, 1])
      for (const sz of [-1, 1]) {
        const band = new THREE.Mesh(new THREE.BoxGeometry(0.12, Hh + 0.02, 0.12), gold);
        band.position.set((sx * (W - 0.1)) / 2, Hh / 2, (sz * (D - 0.1)) / 2);
        chest.add(band);
      }
    const rim = new THREE.Mesh(new THREE.BoxGeometry(W + 0.04, 0.1, D + 0.04), gold);
    rim.position.y = Hh - 0.05;
    chest.add(rim);
    for (let i = 0; i < 4; i++) {
      const plank = new THREE.Mesh(new THREE.BoxGeometry(W - 0.2, 0.025, 0.02), woodD);
      plank.position.set(0, 0.18 + i * 0.2, D / 2 + 0.005);
      chest.add(plank);
    }
    const lock = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.36, 0.08), gold);
    lock.position.set(0, Hh - 0.12, D / 2 + 0.04);
    chest.add(lock);
    const keyhole = new THREE.Mesh(new THREE.CircleGeometry(0.05, 16), new THREE.MeshBasicMaterial({ color: 0x2a1500 }));
    keyhole.position.set(0, Hh - 0.12, D / 2 + 0.081);
    chest.add(keyhole);
    // 속 빛
    const glowMat = new THREE.MeshBasicMaterial({ color: 0xffd35a, toneMapped: false });
    const innerPlane = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.16, D - 0.16), glowMat);
    innerPlane.rotation.x = -Math.PI / 2;
    innerPlane.position.y = Hh - 0.02;
    chest.add(innerPlane);
    // 뚜껑 (뒤쪽 경첩)
    const hinge = new THREE.Group();
    hinge.position.set(0, Hh, -D / 2);
    chest.add(hinge);
    const lid = new THREE.Group();
    lid.position.set(0, 0, D / 2);
    hinge.add(lid);
    const lidGeo = new THREE.CylinderGeometry(D / 2, D / 2, W, 32, 1, false, 0, Math.PI);
    lidGeo.rotateZ(Math.PI / 2);
    lidGeo.rotateX(Math.PI / 2);
    const lidM = new THREE.Mesh(lidGeo, wood);
    lid.add(lidM);
    for (const x of [-(W / 2 - 0.08), 0, W / 2 - 0.08]) {
      const bg2 = new THREE.CylinderGeometry(D / 2 + 0.025, D / 2 + 0.025, 0.13, 32, 1, false, 0, Math.PI);
      bg2.rotateZ(Math.PI / 2);
      bg2.rotateX(Math.PI / 2);
      const b = new THREE.Mesh(bg2, gold);
      b.position.x = x;
      lid.add(b);
    }
    // 틈 빛 (뚜껑과 몸통 사이)
    const seamMat = new THREE.MeshBasicMaterial({ color: 0xfff0a0, transparent: true, opacity: 0, toneMapped: false, blending: THREE.AdditiveBlending, depthWrite: false });
    const seam = new THREE.Mesh(new THREE.BoxGeometry(W + 0.06, 0.05, D + 0.06), seamMat);
    seam.position.y = Hh + 0.01;
    chest.add(seam);
    const dot = dotTex();
    const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: dot, color: 0xffc94a, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, opacity: 0 }));
    halo.position.set(0, Hh + 0.2, 0.2);
    scene.add(halo);
    // 빛기둥
    const pGeo = new THREE.CylinderGeometry(0.62, 0.78, 1, 40, 1, true);
    pGeo.translate(0, 0.5, 0);
    const pOut = pillarMat(0xffc94a);
    const pIn = pillarMat(0xfff6c8);
    const pil = new THREE.Mesh(pGeo, pOut);
    const pil2 = new THREE.Mesh(pGeo, pIn);
    pil2.scale.set(0.45, 1, 0.45);
    pil.position.y = pil2.position.y = Hh - 0.05;
    scene.add(pil, pil2);
    // 보상 별
    const star = new THREE.Mesh(starGeo(0.55, 0.22), new THREE.MeshStandardMaterial({ color: 0xffd23f, metalness: 0.3, roughness: 0.25, emissive: 0xffa000, emissiveIntensity: 0.55 }));
    scene.add(star);
    const sp = new Sparks(140);
    scene.add(sp.obj);
    let speed = 1;
    let pillarOn = true;
    let tOff = 0;
    let T = 0;
    const P = 5.2;
    const OPEN = 1.5;
    const cc = new THREE.Color();
    return {
      scene,
      camera: cam,
      update(t) {
        T = t;
        const ph = ((t - tOff) * speed) % P;
        const a = ph - OPEN;
        const end = smooth(4.5, 5.0, ph);
        // 흔들기
        if (a < 0) {
          const k = clamp01(ph / OPEN);
          const amp = k * k * 0.09;
          chest.rotation.z = Math.sin(ph * 38) * amp;
          chest.position.y = Math.max(0, Math.sin(ph * 19)) * amp * 1.2;
          chest.scale.set(1, 1, 1);
          hinge.rotation.x = -Math.max(0, Math.sin(ph * 38 + 1)) * k * k * 0.08;
          seamMat.opacity = smooth(0.4, OPEN, ph) * 0.9;
          halo.material.opacity = smooth(0.5, OPEN, ph) * 0.5;
          halo.scale.setScalar(1.5 + k * 1.5);
          inner.intensity = k * 6;
        } else {
          chest.rotation.z = 0;
          chest.position.y = 0;
          const sq = 0.12 * Math.exp(-a * 8) * Math.cos(a * 26);
          chest.scale.set(1 + sq, 1 - sq, 1 + sq);
          const open = -1.95 * (1 - Math.exp(-a * 7) * Math.cos(a * 13)) * (1 - end);
          hinge.rotation.x = open;
          seamMat.opacity = Math.exp(-a * 4) * 0.9;
          halo.material.opacity = (0.5 + Math.exp(-a * 4) * 0.5) * (1 - end);
          halo.scale.setScalar(3 + Math.exp(-a * 5) * 4);
          inner.intensity = (14 + Math.sin(t * 9) * 1.5) * (1 - end);
        }
        glowMat.color.setRGB(1, 0.83, 0.35).multiplyScalar(0.4 + 1.2 * clamp01(a >= 0 ? 1 - end : ph / OPEN));
        // 기둥
        const pa = a >= 0 && pillarOn ? clamp01(a / 0.3) * (1 - end) : 0;
        const ph2 = a >= 0 ? 0.6 + 5.4 * easeOut(a / 0.35) : 0;
        pil.scale.set(1 + Math.exp(-a * 6) * 0.6, Math.max(0.001, ph2), 1 + Math.exp(-a * 6) * 0.6);
        pil2.scale.set(0.45, Math.max(0.001, ph2), 0.45);
        pOut.uniforms.uA!.value = pa * 0.75;
        pIn.uniforms.uA!.value = pa * 0.9;
        pOut.uniforms.uT!.value = pIn.uniforms.uT!.value = t;
        pil.visible = pil2.visible = pa > 0.001;
        // 별
        if (a > 0.15) {
          const k = (a - 0.15) / 0.8;
          star.visible = true;
          star.position.set(0, lerp(Hh, 2.6, easeOut(k)) + Math.sin(t * 2.5) * 0.06 * clamp01(k - 1), 0.1);
          const s = backOut(k, 2.4) * (1 - end);
          star.scale.setScalar(Math.max(0.001, s));
          star.rotation.y = a * 4 * Math.exp(-a * 0.6) + t * 0.8;
        } else star.visible = false;
        // 입자
        for (let i = 0; i < 140; i++) {
          let x = 0;
          let y = 0;
          let z = 0;
          let al = 0;
          let s = 0;
          if (i < 30 && a < 0) {
            // 틈으로 새는 빛 알갱이
            const life = 0.6;
            const age = (ph + hash(i, 1) * life) % life;
            const th = hash(i, 2) * TAU;
            x = Math.cos(th) * (W / 2) * (0.6 + age);
            z = Math.sin(th) * (D / 2) * (0.6 + age);
            y = Hh + age * 0.8;
            al = smooth(0.3, OPEN, ph) * (1 - age / life);
            s = 0.07;
            cc.set(0xfff0a0);
          } else if (i >= 30 && a >= 0) {
            const j = i - 30;
            const th = hash(j, 3) * TAU;
            const v = 1.2 + hash(j, 4) * 2.4;
            const up = 2 + hash(j, 5) * 4;
            const age = a - hash(j, 6) * 0.25;
            if (age > 0 && age < 2.2) {
              const r = v * (1 - Math.exp(-age * 2));
              x = Math.cos(th) * r;
              z = Math.sin(th) * r * 0.7;
              y = Hh + up * (1 - Math.exp(-age * 1.6)) - age * age * 0.5;
              al = (1 - age / 2.2) * (1 - end) * (0.6 + 0.4 * Math.sin(t * 20 + j));
              s = 0.08 + hash(j, 7) * 0.08;
              cc.setHSL(0.11 + hash(j, 8) * 0.05, 1, 0.6 + hash(j, 9) * 0.3);
            }
          }
          sp.set(i, x, y, z, cc, s, al);
        }
        sp.flush();
      },
      resize(_w, h) {
        sp.fit(h, cam.fov);
      },
      controls: [
        { type: 'button', label: '다시 열기', on: () => (tOff = T) },
        tgl('빛기둥', pillarOn, (v) => (pillarOn = v)),
        rng('빠르기', 0.4, 2, 0.05, speed, (v) => (speed = v)),
      ],
      dispose() {
        disposeScene(scene);
        sp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 이 효과를 만든 기술: [층 쌓기 (핵 · 빛무리 · 불티 · 연기)](https://ai-techstudio.web.app/ai/t/i171.md) `i171`
