# AI 꾸러미 — 단계 이야기 (scrollytelling · 다음 단추) — Step-by-step explainer (scrollytelling)
> 설명을 단계 목록(이름 · 글 · 카메라 자리 · 색)으로 적어 두고 「다음」을 누르면 카메라 · 부품 · 글이 그 단계로 부드럽게 넘어가게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i45

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

## 주문서

### 만들어 줘: 단계 이야기 (scrollytelling · 다음 단추) — Step-by-step explainer (scrollytelling)

#### 1. 목표
엔진 한 바퀴 (흡입 · 압축 · 폭발 · 배기)을(를) 「다음」 단추로 넘기는 단계 설명으로 만들어 줘 — 단계마다 카메라 · 움직임 · 색 · 설명 글이 바뀌고, 단계 점과 이전/다음 단추가 있게. 분위기는 밝은 교과서 그림.

#### 2. 핵심 기술 용어
- **Step-by-step explainer (scrollytelling)** — 단계마다 장면이 진행되는 설명
- **Data-driven steps (step table)** — 단계를 데이터 표로 적고 화면은 표를 읽기
- **Camera tween (lerp to target)** — 카메라를 다음 자리로 부드럽게

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

#### 4. 조건
- 단계는 데이터 표로 — 단계마다 if 로 장면을 새로 만들지 않기
- 단계를 바꾸면 글 · 단계 점 · 이전/다음 단추만 갈아 끼우기 (화면 전체를 다시 만들지 않기)
- 카메라는 목표로 부드럽게 (dt 비례 lerp), 화면이 세로로 좁으면 뒤로 물리기
- 손으로 넘기면 자동 넘김을 잠시(12초) 멈추기
- 단추는 화면 위 HTML 로 (번역 가능한 글)

#### 5. 완성 기준 (이게 보이면 성공)
- 「다음」을 누를 때마다 카메라가 부드럽게 옮겨 가고, 단계 이름 · 설명 글 · 점이 바뀐다
- 흡입에서는 파란 밸브, 배기에서는 빨간 밸브가 열리고, 폭발에서 불꽃이 번쩍인다
- 가만히 두면 단계가 저절로 넘어가고, 단추를 누르면 한동안 멈춰 있다
- 폰 세로 화면에서도 글과 단추가 가리지 않는다

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

## 원리
- 단계 표: { 이름, 설명 글, 카메라 위치, 바라볼 곳, 가스 색, 시작 · 끝 불투명도 } 네 줄. 화면은 지금 단계 번호로 이 표만 읽는다.
- 단계 안 진행 k(0 → 1, 2.4초)로 크랭크를 π·단계 + π·k 만큼 돌려, 네 단계가 이어지면 엔진 두 바퀴(한 순환)가 된다.
- 카메라 위치 · 바라볼 곳은 단계 목표로 매 프레임 lerp(dt × 2.5) — 단계를 바꾸면 부드럽게 날아간다.
- 단계마다 할 일만 덧붙인다: 흡입은 파란 밸브 열기, 배기는 빨간 밸브 열기, 폭발은 불꽃 + 점광원 번쩍.
- 「다음」 · 「이전」을 누르면 12초 동안 자동 넘김을 멈춰 사람이 읽을 시간을 준다.

## 핵심 코드 — 단계 표 + 단계 바꾸기 + 매 프레임 진행
(발췌: demos/demosStructure.ts i45 make() 를 정리)
```ts
import * as THREE from 'three';
const STEPS = [
  { name: '흡입', text: '피스톤이 내려가며 연료 + 공기를 빨아들여요 (파란 밸브 열림)', cam: new THREE.Vector3(-3.6, 1.6, 7.6), look: new THREE.Vector3(0, 0.05, 0), gas: 0x6fc3ff, o0: 0.12, o1: 0.45 },
  { name: '압축', text: '두 밸브를 닫고 피스톤이 올라가 꽉 눌러요', cam: new THREE.Vector3(0, 2.2, 6.4), look: new THREE.Vector3(0, 0.5, 0), gas: 0x3aa0ff, o0: 0.45, o1: 0.8 },
  { name: '폭발', text: '불꽃! 터지는 힘이 피스톤을 세게 밀어 내려요', cam: new THREE.Vector3(2.4, 2.4, 5.6), look: new THREE.Vector3(0, 0.6, 0), gas: 0xff8a3d, o0: 0.85, o1: 0.4 },
  { name: '배기', text: '빨간 밸브가 열리고 타고 남은 가스를 밀어내요', cam: new THREE.Vector3(3.8, 1.2, 7.4), look: new THREE.Vector3(0, 0.05, 0), gas: 0x9a9aa8, o0: 0.4, o1: 0.06 },
];
let step = 0, stepT = 0, hold = 0;
const go = (k: number) => {
  step = (k + STEPS.length) % STEPS.length;
  stepT = 0;
  chip.textContent = (step + 1) + ' / 4  ' + STEPS[step].name; // 바뀌는 글만 갈아 끼우기
  text.textContent = STEPS[step].text;
  dots.forEach((d, i) => (d.style.background = i === step ? '#ff6b3c' : '#c7d2ee'));
};
nextBtn.onclick = () => { hold = 12; go(step + 1); };
prevBtn.onclick = () => { hold = 12; go(step - 1); };

const camPos = STEPS[0].cam.clone(), camLook = STEPS[0].look.clone();
function update(dt: number) {
  stepT += dt;
  hold = Math.max(0, hold - dt);
  const dur = 2.4;
  if (stepT > dur * 2 + 0.6 && hold <= 0) go(step + 1);         // 자동 넘김
  const k = smooth(Math.min(1, (stepT % (dur + 0.6)) / dur));
  const S = STEPS[step];
  eng.set(Math.PI * step + Math.PI * k);                         // 네 단계 = 크랭크 두 바퀴
  gasMat.color.setHex(S.gas);
  gasMat.opacity = S.o0 + (S.o1 - S.o0) * k;
  const open = (on: boolean) => (on ? Math.sin(k * Math.PI) * 0.14 : 0);
  eng.intake.position.y = valveY - open(step === 0);
  eng.exhaust.position.y = valveY - open(step === 3);
  camPos.lerp(S.cam, Math.min(1, dt * 2.5));                     // 카메라는 부드럽게
  camLook.lerp(S.look, Math.min(1, dt * 2.5));
  cam.position.copy(camPos);
  cam.lookAt(camLook);
}
```

## 흔한 실수 · 확인 목록
- [ ] **단계마다 장면을 새로 만들면 넘길 때마다 깜박이고 느리다** — 장면은 하나로 두고 단계 표의 값(카메라 · 색 · 글)만 바꾼다.
- [ ] **카메라를 단계 자리로 순간 이동시키면 어디가 바뀌었는지 놓친다** — 목표로 dt 비례 lerp 해서 날아가게 한다.
- [ ] **자동 넘김이 계속 돌면 읽던 글이 사라진다** — 사람이 단추를 누르면 한동안 자동 넘김을 멈춘다.

## 완성 기준 체크리스트
- [ ] 「다음」을 누를 때마다 카메라가 부드럽게 옮겨 가고, 단계 이름 · 설명 글 · 점이 바뀐다
- [ ] 흡입에서는 파란 밸브, 배기에서는 빨간 밸브가 열리고, 폭발에서 불꽃이 번쩍인다
- [ ] 가만히 두면 단계가 저절로 넘어가고, 단추를 누르면 한동안 멈춰 있다
- [ ] 폰 세로 화면에서도 글과 단추가 가리지 않는다

## 이 기술 정보
- id: `i45` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 장면은 하나, 단계마다 바뀌는 것은 카메라 · 색 · 글뿐. 그리기는 단계와 상관없이 같다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i45
- 쓰면 좋을 때: 한 바퀴 · 한 과정을 차례로 설명할 때 (엔진 · 순환 · 실험) / 읽을 글과 그림을 함께 보여 줄 때 / 자동 재생과 손으로 넘기기를 둘 다 쓰고 싶을 때
- 쓰지 말 때: 값 하나로 이어지는 변화 — 대신 슬라이더 연동(i39) / 스크롤이 긴 웹 문서 — 그때는 IntersectionObserver 로 스크롤 단계

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i45 견본 항목 — `src/demos/demosStructure.ts:4823`
```ts
  i45: {
    kind: 'dom',
    caption: '「엔진 한 바퀴」를 4단계로 — 단계마다 카메라 · 밸브 · 가스 색이 바뀌고 설명이 나와요 (자세히 보기에서 「다음」 단추)',
    make(box) {
      ownAcquire();
      const { g, fit } = boxCanvas(box);
      const scene = baseScene('#dfeaff', '#f8fbff');
      const eng = makeEngine();
      eng.root.position.y = -1.3;
      scene.add(eng.root);
      const flash = new THREE.PointLight(0xffa040, 0, 4);
      flash.position.set(0, eng.head - 1.3, 0.6);
      scene.add(flash);
      const cam = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
      const STEPS = [
        { name: '흡입', text: '피스톤이 내려가며 연료 + 공기를 빨아들여요 (파란 밸브 열림)', cam: new THREE.Vector3(-3.6, 1.6, 7.6), look: new THREE.Vector3(0, 0.05, 0), gas: 0x6fc3ff, o0: 0.12, o1: 0.45 },
        { name: '압축', text: '두 밸브를 닫고 피스톤이 올라가 꽉 눌러요', cam: new THREE.Vector3(0, 2.2, 6.4), look: new THREE.Vector3(0, 0.5, 0), gas: 0x3aa0ff, o0: 0.45, o1: 0.8 },
        { name: '폭발', text: '불꽃! 터지는 힘이 피스톤을 세게 밀어 내려요 — 이때만 힘이 생겨요', cam: new THREE.Vector3(2.4, 2.4, 5.6), look: new THREE.Vector3(0, 0.6, 0), gas: 0xff8a3d, o0: 0.85, o1: 0.4 },
        { name: '배기', text: '빨간 밸브가 열리고 피스톤이 타고 남은 가스를 밀어내요', cam: new THREE.Vector3(3.8, 1.2, 7.4), look: new THREE.Vector3(0, 0.05, 0), gas: 0x9a9aa8, o0: 0.4, o1: 0.06 },
      ];
      const ui = document.createElement('div');
      ui.style.cssText = `position:absolute;inset:0;pointer-events:none;font-family:${FONT};color:#1d2a55`;
      ui.innerHTML = `
        <div data-k="chip" style="position:absolute;left:4%;top:5%;padding:.25em .8em;border-radius:999px;background:#ff6b3c;color:#fff;font:400 1.2em ${TITLE}"></div>
        <div data-k="text" style="position:absolute;left:4%;right:4%;bottom:19%;padding:.45em .8em;border-radius:12px;background:rgba(255,255,255,.9);font-weight:700;line-height:1.35;box-shadow:0 2px 10px rgba(0,0,0,.12)"></div>
        <div style="position:absolute;left:4%;right:4%;bottom:4%;display:flex;align-items:center;gap:.6em">
          <button data-k="prev" style="pointer-events:auto;border:0;border-radius:999px;padding:.35em 1em;background:#fff;color:#1d2a55;font:700 1.1em ${FONT};cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.2)">이전</button>
          <div data-k="dots" style="flex:1;display:flex;justify-content:center;gap:.5em"></div>
          <button data-k="next" style="pointer-events:auto;border:0;border-radius:999px;padding:.35em 1em;background:#ff6b3c;color:#fff;font:700 1.1em ${FONT};cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.2)">다음 ▶</button>
        </div>`;
      box.appendChild(ui);
      const q = (k: string): HTMLElement => ui.querySelector<HTMLElement>(`[data-k="${k}"]`)!;
      const dots = STEPS.map(() => {
        const d = document.createElement('span');
        d.style.cssText = 'width:.7em;height:.7em;border-radius:50%;background:#c7d2ee;transition:all .3s';
        q('dots').appendChild(d);
        return d;
      });
      let step = 0;
      let stepT = 0;
      let hold = 0;
      const go = (k: number): void => {
        step = (k + STEPS.length) % STEPS.length;
        stepT = 0;
        q('chip').textContent = `${step + 1} / 4  ${STEPS[step]!.name}`;
        q('text').textContent = STEPS[step]!.text;
        dots.forEach((d, i) => {
          d.style.background = i === step ? '#ff6b3c' : '#c7d2ee';
          d.style.transform = i === step ? 'scale(1.3)' : '';
        });
      };
      q('prev').onclick = (e) => {
        e.stopPropagation();
        hold = 12;
        go(step - 1);
      };
      q('next').onclick = (e) => {
        e.stopPropagation();
        hold = 12;
        go(step + 1);
      };
      go(0);
      const camPos = STEPS[0]!.cam.clone();
      const camLook = STEPS[0]!.look.clone();
      const gasMat = eng.gas.material as THREE.MeshBasicMaterial;
      const sparkMat = eng.spark.material as THREE.MeshBasicMaterial;
      const iy = eng.intake.position.y;
      return {
        update(_t, dt) {
          const { w, h, dpr } = fit();
          ui.style.fontSize = `${clamp(Math.min(w, h * 1.6) * 0.036, 10, 17)}px`;
          stepT += dt;
          hold = Math.max(0, hold - dt);
          const dur = 2.4;
          if (stepT > dur * 2 + 0.6 && hold <= 0) go(step + 1);
          const k = smooth(Math.min(1, (stepT % (dur + 0.6)) / dur));
          const S = STEPS[step]!;
          eng.set(Math.PI * step + Math.PI * k);
          gasMat.color.setHex(S.gas);
          gasMat.opacity = lerp(S.o0, S.o1, k);
          const open = (on: boolean): number => (on ? Math.sin(k * Math.PI) * 0.14 : 0);
          eng.intake.position.y = iy - open(step === 0);
          eng.exhaust.position.y = iy - open(step === 3);
          const boom = step === 2 ? Math.max(0, 1 - k * 4) : 0;
          sparkMat.opacity = boom;
          eng.spark.scale.setScalar(1 + boom * 3);
          flash.intensity = boom * 30;
          camPos.lerp(S.cam, Math.min(1, dt * 2.5));
          camLook.lerp(S.look, Math.min(1, dt * 2.5));
          const aspect = w / h;
          const back = aspect < 1.2 ? 1.2 / aspect : 1;
          cam.position.copy(camLook).addScaledVector(camPos.clone().sub(camLook), back);
          cam.lookAt(camLook.x, camLook.y - 0.75, camLook.z);
          scene.updateMatrixWorld();
          glPaint(g, dpr, scene, cam, 0, 0, w, h);
        },
        controls: [
          { type: 'button', label: '이전 단계', on: () => ((hold = 12), go(step - 1)) },
          { type: 'button', label: '다음 단계', on: () => ((hold = 12), go(step + 1)) },
        ],
        dispose() {
          disposeScene(scene);
          ownRelease();
          box.innerHTML = '';
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [그래프 + 3D 연동 (슬라이더 하나로)](https://ai-techstudio.web.app/ai/t/i39.md) `i39`
- 다음에 해 볼 기술: [설명서 그림체 (액소노메트릭 일러스트)](https://ai-techstudio.web.app/ai/t/i70.md) `i70` · [화이트보드 그리기 애니메이션](https://ai-techstudio.web.app/ai/t/i79.md) `i79`
- 참고 문서: [Bartosz Ciechanowski — Internal Combustion Engine](https://ciechanow.ski/internal-combustion-engine/)
