# AI 꾸러미 — 주사위 던지기 연출 — Predetermined dice roll (scripted outcome)
> 나올 눈을 먼저 정해 두고, 그 눈이 위로 오는 회전을 목표로 포물선 · 통통 세 번 튀기 · 남은 회전 풀기를 겹쳐, 진짜 굴린 듯 정한 눈에 멈추게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u47

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

## 주문서

### 만들어 줘: 주사위 던지기 연출 — Predetermined dice roll (scripted outcome)

#### 1. 목표
주사위 셋 (요트 · 피그)을(를) 던지는 연출을 만들어 줘 — 나올 눈을 먼저 정하고, 포물선으로 날아와 통통 튀고 굴러 그 눈이 위로 오게 멈추게. 분위기는 초록 펠트 탁자.

#### 2. 핵심 기술 용어
- **Predetermined dice roll (scripted outcome)** — 결과를 먼저 정하고 그에 맞게 굴리는 연출
- **Target quaternion + unwinding spin** — 목표 회전에 남은 회전을 (1 − t)² 로 풀어 가기
- **Bounce arcs (decaying hops)** — 점점 낮아지는 튀기 세 번

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

#### 4. 조건
- 결과는 굴리기 전에 정한다 — 연출이 끝난 뒤 결과를 읽지 않기
- 마지막 회전은 반드시 목표 회전과 정확히 같게 (남은 각도가 t = 1 에서 0)
- 튀기는 점점 낮게 세 번, 자리는 감속 곡선으로
- 주사위마다 도는 축 · 걸리는 시간을 조금씩 다르게
- 그림자 원판은 높이에 따라 커지고 옅어지게

#### 5. 완성 기준 (이게 보이면 성공)
- 화면 위에 「정한 눈: 3 · 5 · 2」 가 먼저 뜨고, 주사위 셋이 날아와 통통 튀며 구른 뒤 정확히 그 눈으로 멈춘다
- 멈추면 금빛 고리가 켜지고 「✓ 정한 눈 그대로!」 가 보인다
- 주사위가 공중에 있을 때 바닥 그림자가 작고 옅어진다
- 여러 번 굴려도 모서리로 서거나 비스듬히 멈추지 않는다

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

## 원리
- 눈 v 가 위로 오는 회전(faceUpQuat)을 표로 정해 둔다: 1 그대로 · 6 은 x 180° · 2 / 5 는 z ±90° · 3 / 4 는 x ∓90°, 그 위에 y 축 무작위 돌림(±0.45).
- 매 프레임 회전 = 목표 회전 × (무작위 축으로 남은 각도). 남은 각도 = (1 − t)² × (10 ~ 16 라디안) — 처음엔 마구 돌다 끝에 0 이 되며 정확히 목표.
- 자리: 던진 곳 → 멈출 곳을 1 − (1 − t)² 로 (빠르게 출발해 천천히 도착).
- 높이: 세 번 튀기 — t 0 ~ 0.55 높이 0.8 · 0.55 ~ 0.82 높이 0.25 · 0.82 ~ 1 높이 0.06 의 반사인.
- 주사위마다 걸리는 시간을 조금씩 다르게 (0.8 + 0.05k + 무작위 0.1) 해서 한꺼번에 멈추지 않게.

## 핵심 코드 — 정한 눈이 위로 오는 회전 + 튀기 · 남은 회전 풀기
(발췌: src/game/games/shared/dice3d.ts faceUpQuat() · DiceSet.roll() · update() 를 정리)
```ts
/** 윗면에 v 가 오게 하는 회전 (yaw 는 무작위로 조금) */
function faceUpQuat(v: number, yaw = (Math.random() - 0.5) * 0.9): THREE.Quaternion {
  const e = new THREE.Euler();
  if (v === 1) e.set(0, 0, 0);
  else if (v === 6) e.set(Math.PI, 0, 0);
  else if (v === 2) e.set(0, 0, Math.PI / 2);
  else if (v === 5) e.set(0, 0, -Math.PI / 2);
  else if (v === 3) e.set(-Math.PI / 2, 0, 0);
  else e.set(Math.PI / 2, 0, 0);
  return new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw).multiply(new THREE.Quaternion().setFromEuler(e));
}

// 굴리기 시작: 주사위 k 마다
rolls.push({
  i, from: start, to: spot.clone(), target: faceUpQuat(values[k]),
  axis: new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize(),
  spin: 10 + Math.random() * 6, t: 0, dur: 0.8 + k * 0.05 + Math.random() * 0.1,
});

// 매 프레임
for (const a of rolls) {
  a.t = Math.min(1, a.t + dt / a.dur);
  const t = a.t, d = dice[a.i];
  d.position.lerpVectors(a.from, a.to, 1 - (1 - t) ** 2);              // 빨리 출발 · 천천히 도착
  const hop = t < 0.55 ? Math.sin((t / 0.55) * Math.PI) * 0.8            // 튀기 세 번, 점점 낮게
    : t < 0.82 ? Math.sin(((t - 0.55) / 0.27) * Math.PI) * 0.25
    : Math.sin(((t - 0.82) / 0.18) * Math.PI) * 0.06;
  d.position.y = a.to.y + hop + (1 - t) * Math.max(0, a.from.y - a.to.y) * (t < 0.55 ? 0.5 : 0);
  const left = (1 - t) ** 2 * a.spin;                                     // 남은 회전 → t = 1 에서 0
  d.quaternion.copy(a.target).multiply(new THREE.Quaternion().setFromAxisAngle(a.axis, left));
}
```

## 흔한 실수 · 확인 목록
- [ ] **무늬 면 순서와 회전 표가 어긋나면 엉뚱한 눈이 위로 온다** — BoxGeometry 재질 순서(+x, −x, +y, −y, +z, −z)에 맞춰 면 그림을 [2, 5, 1, 6, 3, 4] 로 두고, 그 표에 맞춘 faceUpQuat 을 쓴다.
- [ ] **남은 회전을 목표 회전 앞에 곱하면 멈출 때 비스듬하다** — 목표 × 남은 회전 순서로, 남은 각도가 t = 1 에서 정확히 0 이 되게 (1 − t)².
- [ ] **모든 주사위가 같은 시간에 멈추면 기계 같다** — 걸리는 시간을 주사위마다 0.05초 + 무작위 0.1초씩 다르게.

## 완성 기준 체크리스트
- [ ] 화면 위에 「정한 눈: 3 · 5 · 2」 가 먼저 뜨고, 주사위 셋이 날아와 통통 튀며 구른 뒤 정확히 그 눈으로 멈춘다
- [ ] 멈추면 금빛 고리가 켜지고 「✓ 정한 눈 그대로!」 가 보인다
- [ ] 주사위가 공중에 있을 때 바닥 그림자가 작고 옅어진다
- [ ] 여러 번 굴려도 모서리로 서거나 비스듬히 멈추지 않는다

## 이 기술 정보
- id: `u47` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 물리 없이 식 몇 개. 주사위 무늬는 캔버스 6장(256px)을 처음에 한 번 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u47
- 쓰면 좋을 때: 결과가 게임 규칙(서버 · 무작위 표)으로 이미 정해진 주사위 게임 / 물리로 굴리면 결과가 기울어지거나 모서리에 서는 문제를 피하고 싶을 때
- 쓰지 말 때: 주사위가 서로 부딪히고 튀는 모습 자체가 볼거리인 장면 — 대신 물리 엔진 + 결과 면 바꿔 끼우기 / 결과를 사용자가 「진짜 무작위」 로 확인해야 할 때 — 결과는 따로 공정하게 뽑고 연출만 이것으로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u47 견본 항목 — `src/demos/demosStructure.ts:2390`
```ts
  u47: {
    kind: '3d',
    caption: '먼저 정한 눈(위 글씨) 그대로 — 포물선으로 날아와 통통 튀고 굴러 그 눈이 위로 오게 멈춰요 (요트 · 피그 주사위)',
    make() {
      const scene = baseScene('#0f3d2e', '#1f6b4c');
      const felt = new THREE.Mesh(new THREE.PlaneGeometry(9, 6), std(0x1f7a4d, 0.95));
      felt.rotation.x = -Math.PI / 2;
      scene.add(felt);
      const rimMat = std(0x7a4a2a, 0.5);
      for (const [x, z, sx, sz] of [
        [0, -3, 9.4, 0.4],
        [0, 3, 9.4, 0.4],
        [-4.5, 0, 0.4, 6],
        [4.5, 0, 0.4, 6],
      ] as [number, number, number, number][]) {
        const b = new THREE.Mesh(new RoundedBoxGeometry(sx, 0.4, sz, 2, 0.1), rimMat);
        b.position.set(x, 0.2, z);
        scene.add(b);
      }
      const shadowTex = (() => {
        const c = document.createElement('canvas');
        c.width = c.height = 64;
        const g = c.getContext('2d')!;
        const gr = g.createRadialGradient(32, 32, 2, 32, 32, 32);
        gr.addColorStop(0, 'rgba(0,0,0,0.55)');
        gr.addColorStop(1, 'rgba(0,0,0,0)');
        g.fillStyle = gr;
        g.fillRect(0, 0, 64, 64);
        return new THREE.CanvasTexture(c);
      })();
      const dice = new DiceSet(scene, null, 3, 0.75);
      const shadows = dice.meshes.map(() => {
        const s = new THREE.Mesh(new THREE.PlaneGeometry(1.1, 1.1), new THREE.MeshBasicMaterial({ map: shadowTex, transparent: true, depthWrite: false }));
        s.rotation.x = -Math.PI / 2;
        s.position.y = 0.01;
        scene.add(s);
        return s;
      });
      const spots = [-1.3, 0, 1.3].map((x) => new THREE.Vector3(x, 0.375, 0.3));
      dice.meshes.forEach((_, i) => dice.set(i, i + 1, spots[i]!));
      const cam = new THREE.PerspectiveCamera(38, 1, 0.1, 60);
      const hud = new Hud();
      let want = [1, 2, 3];
      let done = true;
      let rollK = -1;
      return {
        scene,
        camera: cam,
        update(t, dt) {
          const k = Math.floor(t / 3.2);
          if (k !== rollK) {
            rollK = k;
            want = [0, 1, 2].map(() => 1 + Math.floor(Math.random() * 6));
            done = false;
            dice.meshes.forEach((_, i) => dice.highlight(i, false));
            dice.roll([0, 1, 2], want, spots, () => {
              done = true;
              dice.meshes.forEach((_, i) => dice.highlight(i, true));
            }, new THREE.Vector3(-3.4, 1.8, 2.2));
          }
          dice.update(dt);
          dice.meshes.forEach((m, i) => {
            shadows[i]!.position.x = m.position.x;
            shadows[i]!.position.z = m.position.z;
            const k2 = clamp(1 - (m.position.y - 0.375) / 2, 0.3, 1);
            shadows[i]!.scale.setScalar(1.3 - k2 * 0.3);
            (shadows[i]!.material as THREE.MeshBasicMaterial).opacity = k2;
          });
        },
        resize(w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(2.4 / tn, 3.4 / (tn * (w / h)));
          cam.position.set(0, d * 0.8, d * 0.62);
          cam.lookAt(0, 0, 0.1);
        },
        render(r, w, h) {
          r.render(scene, cam);
          hud.draw(r, w, h, `${w}x${h}${want.join()}${done}`, (g) => {
            const s = fsz(w, h);
            pill(g, w / 2, s * 0.6, done ? `✓ 정한 눈 ${want.join(' · ')} 그대로!` : `정한 눈: ${want.join(' · ')}`, s, done ? 'rgba(255,200,60,0.95)' : 'rgba(0,0,0,0.55)', done ? '#3a2200' : '#fff', 'center');
          });
        },
        dispose() {
          dice.dispose();
          shadowTex.dispose();
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36` · [스프링 · 예비 동작 · 찌그러짐](https://ai-techstudio.web.app/ai/t/i102.md) `i102`
- 다음에 해 볼 기술: [별점 터짐](https://ai-techstudio.web.app/ai/t/i211.md) `i211` · [착지 먼지 뭉게](https://ai-techstudio.web.app/ai/t/i214.md) `i214`
