# AI 꾸러미 — 코드 모양 바꾸기 (같은 낱말 짝짓기 · Magic Move) — Magic Move (Keynote) / shiki-magic-move
> 코드가 바뀔 때 두 판의 낱말을 짝지어 같은 것은 새 자리로 미끄러지고, 없어진 것은 녹고 새것만 피어난다 — 무엇이 바뀌었는지 한눈에.  
> 견본: https://ai-techstudio.web.app/#t/i555

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

## 주문서

### 만들어 줘: 코드 모양 바꾸기 (같은 낱말 짝짓기 · Magic Move) — Magic Move (Keynote) / shiki-magic-move

#### 1. 목표
HP 계산 코드 3단계 (빼기 → 변수 → 함수)을(를) 단계별로 바꾸는 코드 모양 바꾸기 애니메이션을 만들어 줘 — 같은 낱말은 남아서 새 자리로 미끄러지고, 없어진 것은 녹고, 새것만 피어나게. 분위기는 어두운 편집기 · 새것 초록 · 없어진 것 빨강.

#### 2. 핵심 기술 용어
- **Magic Move (Keynote) / shiki-magic-move** — 같은 요소를 짝지어 새 자리로 옮기는 전환
- **Longest common subsequence (LCS) token diff** — 순서를 지키는 가장 긴 공통 낱말 줄 — 짝짓기
- **Monospace grid positioning** — 고정폭 글꼴이라 자리 = (칸 × 글자 폭, 줄 × 줄 높이)
- **Enter / exit / move transitions** — 새것은 나타남 · 없어진 것은 사라짐 · 남은 것은 이동

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 낱말마다 absolute span 을 transform 으로. 실제 작업은 shiki-magic-move 라이브러리가 같은 일을 한다(문법 색까지).

#### 4. 조건
- 짝짓기는 LCS 로 (같은 글자 낱말을 순서대로) — 단순히 같은 글자를 아무거나 짝지으면 낱말이 엇갈려 날아다닌다
- 짝 있는 낱말은 같은 요소 하나가 이동할 것 (사라졌다 새로 나타나지 않게)
- 시간 나누기: 사라짐(0~35%) · 이동(15~85%) · 나타남(55~100%)
- 고정폭 글꼴, 글자 폭은 실제로 재서 쓸 것 (글꼴마다 다르다)
- 새것은 잠깐 초록, 없어진 것은 빨강으로 물들여 바뀐 곳을 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 「hp = hp - 3」에서 「hp = Math.max(0, hp - dmg)」로 바뀔 때 hp · = · - 는 제자리를 찾아 미끄러지고 Math.max(0, · dmg 만 새로 피어난다
- 없어지는 낱말은 빨갛게 물들며 위로 녹고, 새 낱말은 초록 바탕으로 나타났다가 바탕이 빠진다
- 짝짓기를 끄면 모든 낱말이 통째로 겹쳐 바뀌어, 켠 쪽이 무엇이 바뀌었는지 훨씬 잘 보인다
- 줄 수가 3 → 4 → 6 줄로 바뀔 때 줄 번호도 따라 늘고 준다

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

## 원리
- 두 판을 낱말(이름 · 숫자 · 기호)로 자르고 줄 · 칸을 적어 둔다. 고정폭 글꼴이라 자리 = (칸 × 글자 폭, 줄 × 줄 높이).
- LCS(가장 긴 공통 부분 수열)로 순서를 지키며 같은 글자의 낱말끼리 짝짓는다 — 표 L[i][j] = 같으면 L[i+1][j+1]+1, 아니면 큰 쪽.
- 짝 있는 낱말: 같은 span 하나가 옛 자리 → 새 자리로 이동. 옛 판에만 있는 것: 빨갛게 물들며 녹음(앞 35%). 새 판에만 있는 것: 이동이 거의 끝날 때(55% 뒤) 초록으로 피어남.
- 이동 · 사라짐 · 나타남이 겹치지 않게 시간을 나누면 눈이 「무엇이 남고 무엇이 새로 왔는지」 따라간다. 줄 번호 개수도 함께 늘고 준다.

## 핵심 코드 — 낱말 자르기 + LCS 짝짓기
(발췌: demos/demosMotionD.ts 의 toks() · lcs() 를 정리)
```ts
type Tok = { s: string; line: number; col: number };
function toks(src: string): Tok[] {
  const out: Tok[] = [];
  src.split('\n').forEach((ln, line) => {
    for (const m of ln.matchAll(/[A-Za-z_$][\w$]*|\d+|[^\s\w]/g)) out.push({ s: m[0], line, col: m.index! });
  });
  return out;
}

// 순서를 지키는 가장 긴 공통 부분 수열 — [옛 번호, 새 번호] 짝 목록
function lcs(a: Tok[], b: Tok[]): [number, number][] {
  const L = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
  for (let i = a.length - 1; i >= 0; i--)
    for (let j = b.length - 1; j >= 0; j--)
      L[i][j] = a[i].s === b[j].s ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
  const pairs: [number, number][] = [];
  let i = 0, j = 0;
  while (i < a.length && j < b.length) {
    if (a[i].s === b[j].s) pairs.push([i++, j++]);
    else if (L[i + 1][j] >= L[i][j + 1]) i++;
    else j++;
  }
  return pairs;
}

// 자리 = (칸 × 글자 폭, 줄 × 줄 높이)
const at = (t: Tok) => [X0 + t.col * charW, Y0 + t.line * LH];
```

## 흔한 실수 · 확인 목록
- [ ] **같은 글자를 아무거나 짝지으면 낱말이 엇갈려 날아다닌다** — 「hp」가 여러 번 나오면 첫 것끼리만 짝지어도 순서가 꼬인다. LCS 로 순서를 지키는 짝만 쓴다.
- [ ] **글자 폭을 어림값으로 두면 낱말이 겹치거나 벌어진다** — 고정폭 글꼴도 글꼴마다 폭이 다르다. 숨긴 span 에 M 20개를 넣어 offsetWidth ÷ 20 으로 잰다.
- [ ] **사라짐 · 이동 · 나타남을 동시에 하면 무엇이 새것인지 안 보인다** — 사라짐 먼저, 이동 가운데, 나타남 마지막으로 시간을 나누고 색(빨강 · 초록)으로 한 번 더 알린다.
- [ ] **판이 바뀔 때 화면이 한 번 깜박인다** — 바뀜 s 의 끝 상태와 바뀜 s+1 의 시작 상태가 같아야 한다 — 견본은 바뀜마다 span 묶음을 따로 두고 끝 상태를 그대로 이어 받는다.

## 완성 기준 체크리스트
- [ ] 「hp = hp - 3」에서 「hp = Math.max(0, hp - dmg)」로 바뀔 때 hp · = · - 는 제자리를 찾아 미끄러지고 Math.max(0, · dmg 만 새로 피어난다
- [ ] 없어지는 낱말은 빨갛게 물들며 위로 녹고, 새 낱말은 초록 바탕으로 나타났다가 바탕이 빠진다
- [ ] 짝짓기를 끄면 모든 낱말이 통째로 겹쳐 바뀌어, 켠 쪽이 무엇이 바뀌었는지 훨씬 잘 보인다
- [ ] 줄 수가 3 → 4 → 6 줄로 바뀔 때 줄 번호도 따라 늘고 준다

## 이 기술 정보
- id: `i555` · 분류: 이펙트 · 연출 › 모션 그래픽 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 낱말 span 수십 개의 transform. LCS 는 판마다 한 번(낱말 40개면 표 1600칸).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i555
- 쓰면 좋을 때: 코드 고치기 · 리팩터링 전후 설명 / 강의 · 발표에서 코드를 단계별로 키울 때
- 쓰지 말 때: 완전히 다른 두 코드 — 짝이 없어 그냥 사라졌다 나타남. 대신 장면 전환 / 한 줄이 아주 긴 코드 — 낱말이 멀리 날아 어지럽다. 줄을 나눠 두기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i555 — `src/demos/demosMotionD.ts:429`
```ts
const i555 = {
  kind: 'dom' as const,
  caption: '코드가 바뀔 때 같은 낱말은 남아서 새 자리로 미끄러지고, 없어진 것은 녹고 새것만 피어나요 — 낱말 짝짓기(LCS) + 위치 트윈',
  make(box: HTMLElement) {
    const o = { s: 1, match: true };
    const S = stage(
      box,
      `.mc55-ed{position:absolute;left:10px;top:8px;width:260px;height:159px;border-radius:9px;background:#1e2130;box-shadow:0 6px 18px rgba(0,0,0,.35);overflow:hidden}
      .mc55-tb{position:absolute;left:0;right:0;top:0;height:18px;background:#171a26;font:700 8px/18px ${F};color:#c9d1e8;padding-left:10px}
      .mc55-step{position:absolute;right:10px;top:0;font:800 8px/18px ${F};color:#ffcc66}
      .mc55-g{position:absolute;left:0;width:18px;text-align:right;font:9.5px/14px ${MONO};color:#56607d}
      .mc55-set{position:absolute;inset:0;display:none}
      .mc55-set span{position:absolute;left:0;top:0;font:9.5px/14px ${MONO};white-space:pre;border-radius:2px}
      .mc55-set .k{color:#c792ea}.mc55-set .f{color:#82aaff}.mc55-set .n{color:#f78c6c}.mc55-set .p{color:#89ddff}.mc55-set .i{color:#e6edf3}
      .mc55-m{position:absolute;left:-999px;top:0;font:9.5px ${MONO};white-space:pre}`,
      `<div class="mc55-ed"><div class="mc55-tb">hp.ts<span class="mc55-step"></span></div>
      ${[1, 2, 3, 4, 5, 6].map((i) => `<div class="mc55-g" style="top:${26 + (i - 1) * 14}px">${i}</div>`).join('')}
      <div class="mc55-set"></div><div class="mc55-set"></div><div class="mc55-set"></div><span class="mc55-m">MMMMMMMMMMMMMMMMMMMM</span></div>`,
      'linear-gradient(135deg,#3b4a7a,#1a2140)',
    );
    const cw = (S.st.querySelector<HTMLElement>('.mc55-m')!.offsetWidth || 114) / 20; // 글자 한 칸 폭
    const X0 = 28;
    const Y0 = 26;
    const LH = 14;
    const at = (t: Tok): [number, number] => [X0 + t.col * cw, Y0 + t.line * LH];
    const sets = [...S.st.querySelectorAll<HTMLElement>('.mc55-set')];
    const gut = [...S.st.querySelectorAll<HTMLElement>('.mc55-g')];
    const step = S.st.querySelector<HTMLElement>('.mc55-step')!;
    const nLines = V555.map((v) => v.split('\n').length);
    type Item = { el: HTMLElement; a: [number, number] | null; b: [number, number] | null };
    // 바뀜 s = 판 s → 판 s+1. 짝지은 낱말은 하나의 span 이 옮겨 가고, 나머지는 사라짐 · 나타남
    const items: Item[][] = sets.map((set, sIdx) => {
      const A = toks(V555[sIdx]!);
      const B = toks(V555[(sIdx + 1) % 3]!);
      const pairs = lcs(A, B);
      const ua = new Set(pairs.map((q) => q[0]));
      const ub = new Set(pairs.map((q) => q[1]));
      const list: Item[] = [];
      const mk = (t: Tok): HTMLElement => {
        const el = document.createElement('span');
        el.className = t.kind;
        el.textContent = t.s;
        set.appendChild(el);
        return el;
      };
      pairs.forEach(([i, j]) => list.push({ el: mk(A[i]!), a: at(A[i]!), b: at(B[j]!) }));
      A.forEach((t, i) => ua.has(i) || list.push({ el: mk(t), a: at(t), b: null }));
      B.forEach((t, j) => ub.has(j) || list.push({ el: mk(t), a: null, b: at(t) }));
      return list;
    });
    const tr = (el: HTMLElement, x: number, y: number, op: number, bg: string): void => {
      el.style.transform = `translate(${x.toFixed(2)}px,${y.toFixed(2)}px)`;
      el.style.opacity = op.toFixed(3);
      el.style.background = bg;
    };
    let tt = 0;
    let act = -1;
    return {
      controls: [speedCtl(o), { type: 'toggle', label: '같은 낱말 짝짓기 (끄면 통째로 겹쳐 바뀜)', value: true, on: (v: boolean) => (o.match = v) }] as Control[],
      update(_t: number, dt: number) {
        tt += Math.max(0, Math.min(dt, 0.1)) * o.s;
        const D = 2.7;
        const sIdx = Math.floor(tt / D) % 3;
        const q = tt % D;
        if (sIdx !== act) {
          sets.forEach((st, i) => (st.style.display = i === sIdx ? 'block' : 'none'));
          act = sIdx;
        }
        const k = seg(q, 0, 0.9);
        const mv = inOut(seg(k, 0.15, 0.85));
        const green = 0.3 * (1 - seg(q, 1.0, 2.0));
        for (const it of items[sIdx]!) {
          if (it.a && it.b) {
            if (o.match) tr(it.el, lerp(it.a[0], it.b[0], mv), lerp(it.a[1], it.b[1], mv), 1, 'transparent');
            else if (k < 0.5) tr(it.el, it.a[0], it.a[1], 1 - k * 2, 'transparent');
            else tr(it.el, it.b[0], it.b[1], k * 2 - 1, 'transparent');
          } else if (it.a) {
            const f = seg(k, 0, 0.35);
            tr(it.el, it.a[0], it.a[1] - 3 * f, 1 - f, k > 0 && f < 1 ? 'rgba(255,90,90,.3)' : 'transparent');
          } else if (it.b) {
            const f = seg(k, 0.55, 1);
            tr(it.el, it.b[0], it.b[1] + 3 * (1 - f), f, f > 0 ? `rgba(80,220,120,${(green * Math.min(1, f * 2)).toFixed(3)})` : 'transparent');
          }
        }
        // 줄 번호 개수도 따라감
        const nl = lerp(nLines[sIdx]!, nLines[(sIdx + 1) % 3]!, mv);
        gut.forEach((g, i) => (g.style.opacity = clamp01(nl - i).toFixed(3)));
        step.textContent = `${k < 0.5 ? (sIdx % 3) + 1 : ((sIdx + 1) % 3) + 1}단계 · ${STEP555[k < 0.5 ? sIdx : (sIdx + 1) % 3]}`;
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드 타자 + 문법 색 (사람 박자 · 오타 고치기)](https://ai-techstudio.web.app/ai/t/i554.md) `i554` · [Manim식 수식 변환](https://ai-techstudio.web.app/ai/t/i117.md) `i117`
- 다음에 해 볼 기술: [공유 요소 전환 (FLIP · 목록 → 상세)](https://ai-techstudio.web.app/ai/t/i548.md) `i548` · [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- 참고 문서: [shiki-magic-move](https://github.com/shikijs/shiki-magic-move) · [Wikipedia — Longest common subsequence](https://en.wikipedia.org/wiki/Longest_common_subsequence)
