# AI 꾸러미 — 끌기 축 잠금 — Drag axis locking
> 끌기를 시작해 8px 움직인 순간 가로 · 세로 중 많이 움직인 쪽으로 축을 잠가, 손이 비스듬히 휘어도 블록이 다른 줄로 엇나가지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i419

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

## 주문서

### 만들어 줘: 끌기 축 잠금 — Drag axis locking

#### 1. 목표
슬라이드 퍼즐 블록에 끌기 축 잠금을 넣어 줘 — 누른 뒤 8px 움직이는 순간 |dx| ≥ |dy| 면 가로, 아니면 세로로 잠그고 그 축으로만 따라가기. 놓으면 가장 가까운 칸에 스프링으로 착. 잠긴 축은 점선 띠로 보여 주기.

#### 2. 핵심 기술 용어
- **Drag axis locking** — 끌기 축 잠금
- **Drag threshold** — 축을 정하는 문턱 거리 (8px)
- **Grid snapping** — 놓으면 칸에 맞추기
- **Damped spring** — 감쇠 스프링으로 칸에 착

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

#### 4. 조건
- 축은 문턱(기본 8px, 2 ~ 30 조절)을 처음 넘는 순간 한 번만 정하고, 놓을 때까지 바꾸지 않기
- |dx| ≥ |dy| 면 가로 — 같으면 가로
- 판 범위로 자르기 (첫 칸 ~ 마지막 칸)
- 놓으면 반올림한 칸으로 스프링 착 — 순간 이동 아님

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 옆으로 끄는데 손이 아래로 휘면 블록이 기울며 「비스듬히!」, 놓으면 「엇나감 — 다른 줄로」
- 켬: 8px 뒤 「가로 잠금 →」 점선 띠가 생기고 블록이 같은 줄에서만 움직인다
- 「처음 움직임 dx · dy」 숫자로 왜 그 축이 됐는지 보인다

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

## 원리
- 사람 손은 옆으로 끌어도 아래로 조금씩 휜다 — 축이 없으면 블록이 비스듬히 다른 줄로 간다.
- 누른 점에서 움직인 거리가 문턱(8px)을 넘는 순간, 그때까지 많이 움직인 쪽을 축으로 정한다.
- 그 뒤로는 축 성분만 따른다: 가로면 x = 시작 x + dx, y 는 그대로.
- 놓으면 가장 가까운 칸으로 감쇠 스프링(k = 260, 감쇠비 0.72)으로 끌려가 착 붙는다.

## 핵심 코드 — 문턱을 넘는 순간 축 정하기 → 그 축으로만
(발췌: demos/demosInput.ts i419 move() · up() · step())
```ts
let lock = 8; // 축을 정하는 문턱 (px)
let drag: { id: number; sx: number; sy: number; bx0: number; by0: number; axis: 'x' | 'y' | null } | null = null;

function onMove(id: number, x: number, y: number): void {
  if (!drag || drag.id !== id) return;
  const dx = x - drag.sx;
  const dy = y - drag.sy;
  if (!drag.axis && Math.hypot(dx, dy) > lock) drag.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'; // 한 번만
  const nx = drag.axis === 'x' ? drag.bx0 + dx : drag.bx0;
  const ny = drag.axis === 'y' ? drag.by0 + dy : drag.by0;
  block = { x: clamp(nx, cx(0), cx(N - 1)), y: clamp(ny, cy(0), cy(N - 1)) };
}

function onUp(): void {
  const col = clamp(Math.round((block.x - cx(0)) / CS), 0, N - 1);
  const row = clamp(Math.round((block.y - cy(0)) / CS), 0, N - 1);
  target = { x: cx(col), y: cy(row) };
  drag = null;
}

// 프레임마다 — 감쇠 스프링으로 칸에 착
function step(dt: number): void {
  if (!target || drag) return;
  const k = 260;
  const c = 2 * Math.sqrt(k) * 0.72; // 감쇠비 0.72 — 살짝 튕기고 멈춤
  vel.x += (k * (target.x - block.x) - c * vel.x) * dt;
  vel.y += (k * (target.y - block.y) - c * vel.y) * dt;
  block = { x: block.x + vel.x * dt, y: block.y + vel.y * dt };
  if (Math.hypot(target.x - block.x, target.y - block.y) < 0.3 && Math.hypot(vel.x, vel.y) < 2) {
    block = { ...target };
    target = null;
    vel = { x: 0, y: 0 };
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **축을 매 move 마다 다시 정하면 끄는 도중 가로 ↔ 세로가 바뀌어 덜컹인다** — 처음 문턱을 넘을 때 한 번만 정한다.
- [ ] **문턱이 너무 작으면(1~2px) 손 떨림으로 엉뚱한 축이 정해진다** — 8px 안팎 — 손 떨림 문턱(i425)과 비슷하게.
- [ ] **차 · 블록처럼 원래 한 축만 되는 것은 축 잠금 대신 그 축 성분만 쓴다** — 빵빵 주차장은 차 방향(horiz)으로 정해진 성분만 읽는다 — 장난감 상자는 처음 움직인 쪽이 축.

## 완성 기준 체크리스트
- [ ] 끔: 옆으로 끄는데 손이 아래로 휘면 블록이 기울며 「비스듬히!」, 놓으면 「엇나감 — 다른 줄로」
- [ ] 켬: 8px 뒤 「가로 잠금 →」 점선 띠가 생기고 블록이 같은 줄에서만 움직인다
- [ ] 「처음 움직임 dx · dy」 숫자로 왜 그 축이 됐는지 보인다

## 이 기술 정보
- id: `i419` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 움직일 때 비교 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i419
- 쓰면 좋을 때: 가로 · 세로로만 움직이는 블록 · 차 / 한 줄을 따라 칠하는 퍼즐
- 쓰지 말 때: 자유롭게 끄는 카드 · 조각 — 대신 놓을 자리 미리 보기(i424) / 대각선도 되는 판 — 축을 넷 이상으로 나누거나 잠그지 않기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i419 — `src/demos/demosInput.ts:833`
```ts
const i419 = demo<P419>({
  caption: '오른쪽으로 끄는데 손이 아래로 휨 — 끔: 블록이 비스듬히 다른 줄로 · 켬: 처음 움직인 쪽(가로)으로 잠금',
  howto: '주황 블록을 잡고 옆으로 끌어 보세요. 일부러 비스듬히 끌어도 켜면 처음 정한 축으로만 움직입니다.',
  onName: '처음 움직인 축으로 잠금',
  cycle: 3.6,
  prm: () => ({ lock: 8 }),
  script: (u) => [
    path(u, [
      [0, 120, 300, 0],
      [0.3, 60, 152, 0],
      [0.5, 60, 152, 1],
      [0.85, 112, 160, 1],
      [1.35, 186, 178, 1],
      [1.75, 243, 192, 1],
      [1.95, 243, 192, 0],
      [3.6, 120, 300, 0],
    ]),
  ],
  create: (env) => {
    const GX = 30;
    const GY = 60;
    const CS = 60;
    const N = 4;
    const cx = (c: number): number => GX + CS / 2 + c * CS;
    const cy = (r: number): number => GY + CS / 2 + r * CS;
    let b = { x: cx(0), y: cy(1) };
    let vel = { x: 0, y: 0 };
    let target: V2 | null = null;
    let drag: { id: number; sx: number; sy: number; bx0: number; by0: number; axis: 'x' | 'y' | null; fdx: number; fdy: number } | null = null;
    let trail: V2[] = [];
    let startRow = 1;
    let startCol = 0;
    let v: Verdict = { text: '블록을 옆으로 끌어요', color: COL.dim };
    let ex: string[] = [];
    const lim = (vv: number, lo: number, hi: number): number => clamp(vv, lo, hi);
    return {
      reset() {
        b = { x: cx(0), y: cy(1) };
        vel = { x: 0, y: 0 };
        target = null;
        drag = null;
        trail = [];
        v = { text: '블록을 옆으로 끌어요', color: COL.dim };
        ex = [];
      },
      down(id, x, y) {
        if (drag) return;
        if (Math.abs(x - b.x) < 34 && Math.abs(y - b.y) < 34) {
          drag = { id, sx: x, sy: y, bx0: b.x, by0: b.y, axis: null, fdx: 0, fdy: 0 };
          startRow = Math.round((b.y - cy(0)) / CS);
          startCol = Math.round((b.x - cx(0)) / CS);
          target = null;
          trail = [{ x, y }];
          v = { text: '끄는 중…', color: COL.blue };
        } else v = { text: '블록을 잡아야 해요', color: COL.warn };
      },
      move(id, x, y) {
        if (!drag || drag.id !== id) return;
        trail.push({ x, y });
        if (trail.length > 80) trail.shift();
        const dx = x - drag.sx;
        const dy = y - drag.sy;
        let nx: number;
        let ny: number;
        if (env.on()) {
          if (!drag.axis && Math.hypot(dx, dy) > env.prm.lock) {
            drag.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
            drag.fdx = dx;
            drag.fdy = dy;
          }
          nx = drag.axis === 'x' ? drag.bx0 + dx : drag.bx0;
          ny = drag.axis === 'y' ? drag.by0 + dy : drag.by0;
        } else {
          nx = drag.bx0 + dx;
          ny = drag.by0 + dy;
          if (!drag.fdx && !drag.fdy && Math.hypot(dx, dy) > 8) {
            drag.fdx = dx;
            drag.fdy = dy;
          }
        }
        b = { x: lim(nx, cx(0), cx(N - 1)), y: lim(ny, cy(0), cy(N - 1)) };
      },
      up(id) {
        if (!drag || drag.id !== id) return;
        const col = clamp(Math.round((b.x - cx(0)) / CS), 0, N - 1);
        const row = clamp(Math.round((b.y - cy(0)) / CS), 0, N - 1);
        target = { x: cx(col), y: cy(row) };
        const ax = drag.axis;
        if (row !== startRow && col !== startCol) v = { text: '엇나감 — 다른 줄로', color: COL.off };
        else if (row === startRow && col === startCol) v = { text: '제자리', color: COL.dim };
        else v = { text: ax ? `${ax === 'x' ? '가로' : '세로'} 줄 그대로 착` : '줄 그대로 착', color: COL.on };
        ex = [`처음 움직임 dx ${Math.round(drag.fdx)} · dy ${Math.round(drag.fdy)}`, env.on() ? `정한 축: ${ax === 'x' ? '가로 →' : ax === 'y' ? '세로 ↓' : '아직 (문턱 전)'}` : '축 없음 — dx · dy 둘 다 따라감'];
        drag = null;
      },
      cancel(id) {
        if (drag && drag.id === id) {
          target = { x: drag.bx0, y: drag.by0 };
          drag = null;
        }
      },
      step(dt) {
        if (drag) {
          const dx = (trail[trail.length - 1]?.x ?? drag.sx) - drag.sx;
          const dy = (trail[trail.length - 1]?.y ?? drag.sy) - drag.sy;
          ex = [
            `움직임 dx ${Math.round(dx)} · dy ${Math.round(dy)}`,
            env.on() ? (drag.axis ? `축 잠금: ${drag.axis === 'x' ? '가로 →' : '세로 ↓'} (문턱 ${env.prm.lock}px 넘음)` : `축 정하는 중 — ${env.prm.lock}px 움직이면`) : '축 없음 — 손 가는 대로',
          ];
        }
        if (target && !drag) {
          const k = 260;
          const c = 2 * Math.sqrt(k) * 0.72;
          vel.x += (k * (target.x - b.x) - c * vel.x) * dt;
          vel.y += (k * (target.y - b.y) - c * vel.y) * dt;
          b = { x: b.x + vel.x * dt, y: b.y + vel.y * dt };
          if (Math.hypot(target.x - b.x, target.y - b.y) < 0.3 && Math.hypot(vel.x, vel.y) < 2) {
            b = { ...target };
            target = null;
            vel = { x: 0, y: 0 };
          }
        }
      },
      draw(g) {
        board(g, GX - 8, GY - 8, CS * N + 16, CS * N + 16);
        for (let r = 0; r < N; r++)
          for (let c = 0; c < N; c++) {
            rr(g, GX + c * CS + 3, GY + r * CS + 3, CS - 6, CS - 6, 8);
            g.fillStyle = (r + c) % 2 ? 'rgba(120,140,220,0.07)' : 'rgba(120,140,220,0.12)';
            g.fill();
          }
        const on = env.on();
        // 잠긴 축 띠
        if (on && drag?.axis) {
          if (drag.axis === 'x') {
            rr(g, GX + 2, drag.by0 - CS / 2 + 4, CS * N - 4, CS - 8, 10);
            g.fillStyle = rgba(COL.on, 0.1);
            g.fill();
            line(g, GX + 8, drag.by0, GX + CS * N - 8, drag.by0, rgba(COL.on, 0.8), 1.6, [6, 5]);
          } else {
            rr(g, drag.bx0 - CS / 2 + 4, GY + 2, CS - 8, CS * N - 4, 10);
            g.fillStyle = rgba(COL.on, 0.1);
            g.fill();
            line(g, drag.bx0, GY + 8, drag.bx0, GY + CS * N - 8, rgba(COL.on, 0.8), 1.6, [6, 5]);
          }
        }
        if (on && drag && !drag.axis) ring(g, drag.sx, drag.sy, env.prm.lock, rgba(COL.on, 0.8), 1.2, [3, 3]);
        // 손 자취
        if (trail.length > 1) {
          g.beginPath();
          trail.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y)));
          g.setLineDash([2, 4]);
          g.lineWidth = 2;
          g.strokeStyle = 'rgba(255,255,255,0.45)';
          g.stroke();
          g.setLineDash([]);
        }
        // 블록
        const rowC = cy(Math.round((b.y - cy(0)) / CS));
        const off = b.y - (drag ? drag.by0 : rowC);
        const bad = !on && drag && Math.abs(off) > 6;
        const tilt = bad ? clamp(off / 60, -1, 1) * 0.28 : 0;
        g.save();
        g.translate(b.x, b.y);
        g.rotate(tilt);
        const S = 50;
        g.shadowColor = 'rgba(0,0,0,0.5)';
        g.shadowBlur = drag ? 16 : 8;
        g.shadowOffsetY = drag ? 8 : 4;
        rr(g, -S / 2, -S / 2, S, S, 10);
        const gr = g.createLinearGradient(0, -S / 2, 0, S / 2);
        gr.addColorStop(0, '#ffb35c');
        gr.addColorStop(1, '#e46a2e');
        g.fillStyle = gr;
        g.fill();
        g.shadowColor = 'transparent';
        rr(g, -S / 2 + 4, -S / 2 + 3, S - 8, S / 2 - 4, 7);
        g.fillStyle = 'rgba(255,255,255,0.18)';
        g.fill();
        rr(g, -S / 2, -S / 2, S, S, 10);
        g.lineWidth = bad ? 2.5 : 1.2;
        g.strokeStyle = bad ? COL.off : 'rgba(255,230,200,0.8)';
        g.stroke();
        // 양쪽 화살 무늬
        const ac = 'rgba(90,40,10,0.75)';
        arrow(g, -4, 0, -16, 0, ac, 2.4);
        arrow(g, 4, 0, 16, 0, ac, 2.4);
        g.restore();
        if (bad) txt(g, '비스듬히!', b.x, b.y - 40, 11, COL.off, 'center', 800);
        if (on && drag?.axis) txt(g, drag.axis === 'x' ? '가로 잠금 →' : '세로 잠금 ↓', b.x, b.y - 40, 11, COL.on, 'center', 800);
        if (target) {
          rr(g, target.x - 27, target.y - 27, 54, 54, 11);
          g.setLineDash([4, 3]);
          g.lineWidth = 1.4;
          g.strokeStyle = 'rgba(255,255,255,0.4)';
          g.stroke();
          g.setLineDash([]);
        }
      },
      verdict: () => v,
      extra: () => ex,
    };
  },
  controls: (p) => [{ type: 'range', label: '축을 정하는 문턱 (px)', min: 2, max: 30, step: 1, value: p.lock, on: (v) => (p.lock = v) }],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425` · [포인터 끌기 · 3D 고르기](https://ai-techstudio.web.app/ai/t/u79.md) `u79`
- 다음에 해 볼 기술: [착 붙기 · 관성 (스냅과 던지기)](https://ai-techstudio.web.app/ai/t/i420.md) `i420` · [끌어다 놓기 미리 보기](https://ai-techstudio.web.app/ai/t/i424.md) `i424`
