# AI 꾸러미 — 이음새 없는 반복 고리 — Seamless loop (perfect loop)
> 모든 움직임을 시간 p(0~1)의 정수 박자 주기 함수로만 만들어, 마지막 프레임이 첫 프레임과 꼭 같아 끝없이 이어지는 반복 고리를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i120

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

## 주문서

### 만들어 줘: 이음새 없는 반복 고리 — Seamless loop (perfect loop)

#### 1. 목표
로딩 표시 캐릭터를 이음새 없는 반복 고리로 만들어 줘 — 모든 움직임을 시간 p 의 정수 박자 주기 함수로, 끝 = 처음. 분위기는 말랑한 귀여운 캐릭터.

#### 2. 핵심 기술 용어
- **Seamless loop (perfect loop)** — 이음새 없는 반복 애니메이션
- **Periodic functions with integer frequency** — 정수 박자 사인 — p = 0 과 1 에서 같은 값
- **Rotational symmetry** — 회전 대칭 — 세모는 120° 만 돌아도 처음과 같다

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

#### 4. 조건
- 모든 움직임은 p 의 함수로만 — 프레임마다 누적(+=)하는 상태를 두지 않기
- p 에 곱하는 박자는 모두 정수 (회전은 대칭 각도의 정수배)
- 처음 모습(p = 0)을 점선으로 겹쳐 보여, 한 바퀴 끝에 정확히 겹치는지 확인
- 왼쪽 「이어짐」 · 오른쪽 「박자 틀림 → 툭」 을 나란히 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 말랑이는 한 바퀴 끝에 점선(처음 모습)과 딱 겹치고 「이어짐 ✓」 이 보인다
- 오른쪽은 한 바퀴마다 모양 · 점 · 세모가 툭 튀며 「툭!」 이 보인다
- 진행 막대가 끝에서 처음으로 넘어가도 왼쪽 움직임은 끊기지 않는다

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

## 원리
- 한 바퀴 시간 P 를 정하고 p = (시간 / P) 의 소수 부분 (0 → 1). 모든 값은 p 의 함수로만 만든다.
- 사인에 2πp × 정수를 넣으면 p = 0 과 p = 1 이 같은 값 → 끝 = 처음. 견본 모양: r(θ) = R(1 + 0.13·sin(3θ + 2πp) + 0.07·sin(5θ − 4πp)).
- 회전도 한 바퀴(2πp)거나, 회전 대칭을 이용해 줄일 수 있다: 세모는 (2π/3)·p, 점 6개 고리는 2πp.
- 박자를 0.7 처럼 정수가 아니게 주면 p = 1 에서 다른 모습이 되어 한 바퀴마다 「툭」 튄다 (견본 오른쪽).

## 핵심 코드 — 정수 박자 모양 · 회전 (좋은 고리 / 틀린 고리)
(발췌: demos/demosMotionB.ts i120 의 loader() 를 정리)
```ts
const TAU = Math.PI * 2;
let ph = 0;
function frame(dt: number, P = 3) {
  ph += dt / P;
  const p = ph - Math.floor(ph);   // 0 → 1, 끝 = 처음
  // 반지름: θ 와 p 에 모두 정수 박자 → p = 0 과 1 에서 같은 모양
  const m = good ? 1 : 0.7;        // 0.7 = 틀린 박자 (한 바퀴마다 툭)
  const r = (th: number) => R * (1 + 0.13 * Math.sin(3 * th + TAU * p * m) + 0.07 * Math.sin(5 * th - 2 * TAU * p * m));
  g.beginPath();
  for (let i = 0; i <= 64; i++) {
    const th = (i / 64) * TAU, rr = r(th);
    const x = cx + Math.cos(th) * rr, y = cy + Math.sin(th) * rr;
    if (i) g.lineTo(x, y); else g.moveTo(x, y);
  }
  g.closePath(); g.fill();
  // 점 6개 고리: 한 바퀴 = 2π·p. 크기 맥박도 정수 박자(×2)
  const rot = good ? TAU * p : TAU * p * 0.8;
  for (let i = 0; i < 6; i++) {
    const a = rot + (i * TAU) / 6, s = 1 + 0.4 * Math.sin(TAU * p * 2 + i);
    g.beginPath(); g.arc(cx + Math.cos(a) * R * 1.55, cy + Math.sin(a) * R * 1.55, 3.4 * s, 0, TAU); g.fill();
  }
  // 세모: 회전 대칭(120°)이라 한 바퀴에 1/3 만 돌아도 처음과 같다
  const tri = good ? (TAU / 3) * p : (TAU / 3) * p * 1.35;
}
```

## 흔한 실수 · 확인 목록
- [ ] **프레임마다 각도를 += 로 쌓으면 반복 끝에서 어긋난다** — 모든 값을 p 의 함수로 새로 계산한다 — 쌓인 상태가 없어야 끝 = 처음.
- [ ] **박자 하나만 정수가 아니어도 전체가 툭 튄다** — 사인 · 회전 · 크기 맥박까지 p 에 곱하는 수를 모두 정수로 (견본 오른쪽이 0.7 · 0.8 · 1.35 로 틀린 예).
- [ ] **이징을 p 에 바로 걸면 끝에서 속도가 0 이 되어 멈칫한다** — 이징은 주기 함수 안의 진폭에만 쓰고, 도는 각도는 p 에 정비례로.

## 완성 기준 체크리스트
- [ ] 왼쪽 말랑이는 한 바퀴 끝에 점선(처음 모습)과 딱 겹치고 「이어짐 ✓」 이 보인다
- [ ] 오른쪽은 한 바퀴마다 모양 · 점 · 세모가 툭 튀며 「툭!」 이 보인다
- [ ] 진행 막대가 끝에서 처음으로 넘어가도 왼쪽 움직임은 끊기지 않는다

## 이 기술 정보
- id: `i120` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 주기 함수 몇 개. 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i120
- 쓰면 좋을 때: GIF · 영상 · 썸네일처럼 짧게 계속 도는 움직임 / 로딩 · 대기 표시
- 쓰지 말 때: 물리 시뮬레이션 · 무작위 입자 — 처음으로 돌아오지 않는다. 대신 처음 상태로 섞어 되돌리거나 반복 대신 계속 진행 / 사용자 입력에 반응하는 움직임

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i120 견본 항목 — `src/demos/demosMotionB.ts:1660`
```ts
  i120: {
    kind: '2d',
    caption: '왼쪽은 주기 함수만 써서 끝 = 처음 (이음새 없음) · 오른쪽은 박자가 안 맞아 한 바퀴마다 「툭」 튀어요',
    make() {
      let P = 3;
      const loader = (g: G, cx: number, cy: number, R: number, p: number, good: boolean, u: number): void => {
        // 점선 = 처음(p=0) 모습
        const shape = (pp: number, bad: boolean): ((th: number) => number) => {
          const m = bad ? 0.7 : 1;
          return (th) => R * (1 + 0.13 * Math.sin(3 * th + TAU * pp * m) + 0.07 * Math.sin(5 * th - 2 * TAU * pp * m));
        };
        const path = (fn: (th: number) => number): void => {
          g.beginPath();
          for (let i = 0; i <= 64; i++) {
            const th = (i / 64) * TAU;
            const rad = fn(th);
            if (i) g.lineTo(cx + Math.cos(th) * rad, cy + Math.sin(th) * rad);
            else g.moveTo(cx + Math.cos(th) * rad, cy + Math.sin(th) * rad);
          }
          g.closePath();
        };
        path(shape(0, !good));
        g.setLineDash([3 * u, 3 * u]);
        g.strokeStyle = 'rgba(255,255,255,.35)';
        g.lineWidth = 1.2 * u;
        g.stroke();
        g.setLineDash([]);
        path(shape(p, !good));
        const gr = g.createRadialGradient(cx - R * 0.3, cy - R * 0.3, R * 0.1, cx, cy, R * 1.2);
        gr.addColorStop(0, good ? '#9ff0d0' : '#ffd0a0');
        gr.addColorStop(1, good ? '#2fbf9f' : '#ff8a5b');
        g.fillStyle = gr;
        g.fill();
        // 얼굴
        g.fillStyle = '#1a2050';
        g.beginPath();
        g.arc(cx - R * 0.28, cy - R * 0.08, R * 0.08, 0, TAU);
        g.arc(cx + R * 0.28, cy - R * 0.08, R * 0.08, 0, TAU);
        g.fill();
        g.strokeStyle = '#1a2050';
        g.lineWidth = 1.8 * u;
        g.beginPath();
        g.arc(cx, cy + R * 0.08, R * 0.18, 0.2, Math.PI - 0.2);
        g.stroke();
        // 도는 점 6개 + 세모
        const rot = good ? TAU * p : TAU * p * 0.8;
        for (let i = 0; i < 6; i++) {
          const a = rot + (i * TAU) / 6;
          const s = 1 + 0.4 * Math.sin(TAU * p * 2 + i);
          g.fillStyle = ['#ff7ab6', '#ffd23f', '#7cf0ff'][i % 3]!;
          g.beginPath();
          g.arc(cx + Math.cos(a) * R * 1.55, cy + Math.sin(a) * R * 1.55, 3.4 * u * s, 0, TAU);
          g.fill();
        }
        const tri = good ? (TAU / 3) * p : (TAU / 3) * p * 1.35;
        g.save();
        g.translate(cx, cy - R * 1.95);
        g.rotate(tri);
        g.beginPath();
        for (let i = 0; i < 3; i++) g.lineTo(Math.cos(-Math.PI / 2 + (i * TAU) / 3) * 6 * u, Math.sin(-Math.PI / 2 + (i * TAU) / 3) * 6 * u);
        g.closePath();
        g.fillStyle = '#fff';
        g.fill();
        g.restore();
      };
      let ph = 0;
      return {
        draw(g, w, h, _t, dt) {
          const u = scaleOf(w, h);
          ph += dt / P;
          const p = fract(ph);
          bg(g, w, h, '#22205e', '#0d0c2c');
          const R = Math.min(w * 0.11, h * 0.2);
          for (const good of [true, false]) {
            const cx = good ? w * 0.25 : w * 0.75;
            const cy = h * 0.52;
            loader(g, cx, cy, R, p, good, u);
            // 진행 막대
            const bw = w * 0.34;
            const by = h - 13 * u;
            rr(g, cx - bw / 2, by - 2.5 * u, bw, 5 * u, 3 * u);
            g.fillStyle = 'rgba(255,255,255,.15)';
            g.fill();
            rr(g, cx - bw / 2, by - 2.5 * u, bw * p, 5 * u, 3 * u);
            g.fillStyle = good ? '#44f0a8' : '#ff8a5b';
            g.fill();
            const seam = p < 0.12 || p > 0.97 ? 1 : 0;
            if (seam) {
              if (good) pill(g, '이어짐 ✓', cx, cy - R * 2.45 + 4 * u, 7.5 * u, '#21a07a');
              else {
                txt(g, '툭!', cx + R * 1.3, cy - R * 1.1, 15 * u, '#ff5c6c', 'center', TF, 400);
                g.strokeStyle = 'rgba(255,92,108,.8)';
                g.lineWidth = 2.5 * u;
                g.beginPath();
                g.arc(cx, cy, R * 1.85, 0, TAU);
                g.stroke();
              }
            }
          }
          g.fillStyle = 'rgba(255,255,255,.5)';
          g.fillRect(w / 2 - 1, 0, 2, h);
          pill(g, '이음새 없음', w * 0.25, 13 * u, 8.5 * u, '#21a07a');
          pill(g, '이음새 있음', w * 0.75, 13 * u, 8.5 * u, 'rgba(255,92,108,.9)');
        },
        controls: [{ type: 'range', label: '한 바퀴 (초)', min: 1, max: 6, step: 0.5, value: 3, on: (v) => (P = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [움직이는 오로라 그라디언트](https://ai-techstudio.web.app/ai/t/i110.md) `i110`
- 다음에 해 볼 기술: [스톱모션 · 2프레임에 한 번](https://ai-techstudio.web.app/ai/t/i123.md) `i123` · [생성 패턴 (흐름장 · 보로노이 · 트루쳇)](https://ai-techstudio.web.app/ai/t/i122.md) `i122`
