# AI 꾸러미 — 끌어다 놓기 미리 보기 — Drop preview (ghost / placement shadow)
> 조각을 끄는 동안 칸에 맞춰 반올림한 놓일 자리를 그림자로 미리 그려, 놓을 수 있으면 초록 · 겹치거나 판 밖이면 빨강으로 알려 준다.  
> 견본: https://ai-techstudio.web.app/#t/i424

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

## 주문서

### 만들어 줘: 끌어다 놓기 미리 보기 — Drop preview (ghost / placement shadow)

#### 1. 목표
L 조각 · 블록 퍼즐에 놓일 자리 미리 보기를 넣어 줘 — 끄는 동안 조각의 칸 자리를 반올림해 그림자로 그리고, 칸마다 겹침 · 판 밖을 검사해 되면 초록 · 안 되면 빨강(막힌 칸 표시). 초록에서 놓으면 착, 빨강이면 흔들리며 제자리로. 분위기는 반투명 그림자 (진하기 0.5).

#### 2. 핵심 기술 용어
- **Drop preview (ghost / placement shadow)** — 놓일 자리 미리 보기
- **Grid snap (round to cell)** — 칸에 맞춰 반올림
- **Placement validation** — 놓을 수 있는지 검사 — 겹침 · 판 밖
- **Return-to-origin spring** — 못 놓으면 제자리로 스프링

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

#### 4. 조건
- 놓일 자리 = 조각 자리를 칸 크기로 나눠 반올림 (끄는 그대로가 아니라 칸에 맞춘 자리)
- 미리 보기 검사와 놓을 때 검사가 같은 함수여야 한다 (초록이었는데 안 놓이는 일 금지)
- 되면 초록 · 안 되면 빨강, 막힌 칸은 따로 표시
- 판 밖에서 놓으면 그림자 없이 제자리로
- 그림자 진하기 조절(0.15 ~ 1)

#### 5. 완성 기준 (이게 보이면 성공)
- 끔: 끄는 동안 아무 표시 없고 놓은 뒤에야 「갑자기 돌아감 — 왜?」
- 켬: 판 위에서 놓일 세 칸이 초록 · 빨강 그림자로 보이고 「여기 놓을 수 있어요 / 겹쳐요」
- 초록에서 놓으면 착 붙어 판에 남고, 빨강에서 놓으면 흔들리며 제자리로

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

## 원리
- 끄는 동안 조각 왼쪽 위 자리를 칸 크기로 나눠 반올림 → 놓일 칸 (c, r).
- 조각 모양의 칸마다 (c + dc, r + dr) 가 판 안인지 · 비었는지 본다. 하나라도 막히면 못 놓음.
- 그 칸들에 반투명 그림자를 그린다 — 되면 초록, 안 되면 빨강, 막힌 칸은 더 진하게.
- 놓을 때 같은 검사로: 되면 그 칸으로 스프링(k = 320) 착, 안 되면 흔들고 제자리로.

## 핵심 코드 — 놓일 칸 반올림 + 칸마다 검사 (미리 보기 · 놓기 같은 함수)
(발췌: demos/demosInput.ts i424 evalAt() · up())
```ts
const CS = 42;           // 칸 크기
const NC = 5, NR = 4;    // 판 칸 수
const SHAPE: [number, number][] = [[0, 0], [0, 1], [1, 1]]; // L 조각
const occ = new Map<string, string>(); // 'c,r' → 찬 칸

function evalAt(x: number, y: number): { c: number; r: number; ok: boolean; bad: Set<string> } {
  const c = Math.round((x - GX) / CS); // 칸에 맞춰 반올림
  const r = Math.round((y - GY) / CS);
  const bad = new Set<string>();
  for (const [dc, dr] of SHAPE) {
    const cc = c + dc, rr = r + dr;
    if (cc < 0 || cc >= NC || rr < 0 || rr >= NR || occ.has(cc + ',' + rr)) bad.add(dc + ',' + dr);
  }
  return { c, r, ok: bad.size === 0, bad };
}

// 끄는 동안: 그림자
function drawPreview(g: CanvasRenderingContext2D, a: ReturnType<typeof evalAt>, alpha = 0.5): void {
  for (const [dc, dr] of SHAPE) {
    const blocked = a.bad.has(dc + ',' + dr);
    g.globalAlpha = blocked ? Math.min(1, alpha * 1.6) : alpha;
    g.fillStyle = a.ok ? '#3ddc97' : '#ff5d6c';
    g.fillRect(GX + (a.c + dc) * CS, GY + (a.r + dr) * CS, CS, CS);
  }
  g.globalAlpha = 1;
}

// 놓을 때: 같은 검사
function onUp(): void {
  const a = evalAt(pos.x, pos.y);
  if (a.ok) target = { x: GX + a.c * CS, y: GY + a.r * CS }; // 스프링으로 착 → 다 오면 occ 에 적기
  else { target = { ...HOME }; shake = 0.4; }                 // 흔들며 제자리로
}
```

## 흔한 실수 · 확인 목록
- [ ] **미리 보기와 놓기 검사가 다르면 초록인데 안 놓인다** — 같은 evalAt 하나로 둘 다 판정한다.
- [ ] **그림자를 조각 밑에 그리면 손가락 · 조각에 가려 안 보인다** — 그림자는 판 위 칸 자리에, 끄는 조각은 살짝 띄워(그림자 · 크게) 손가락 위쪽에.
- [ ] **손가락 자리 기준으로 반올림하면 잡은 곳에 따라 한 칸씩 어긋난다** — 잡을 때 조각과 손가락의 차이(ox · oy)를 기억해 조각 자리 기준으로 반올림한다.

## 완성 기준 체크리스트
- [ ] 끔: 끄는 동안 아무 표시 없고 놓은 뒤에야 「갑자기 돌아감 — 왜?」
- [ ] 켬: 판 위에서 놓일 세 칸이 초록 · 빨강 그림자로 보이고 「여기 놓을 수 있어요 / 겹쳐요」
- [ ] 초록에서 놓으면 착 붙어 판에 남고, 빨강에서 놓으면 흔들리며 제자리로

## 이 기술 정보
- id: `i424` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 움직일 때 조각 칸 수만큼 검사. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i424
- 쓰면 좋을 때: 조각 · 카드를 판 칸에 놓는 모든 게임 / 놓아 봐야 아는 실수가 잦을 때
- 쓰지 말 때: 자유 자리에 놓는 그림 · 스티커 — 칸이 없으면 그림자 대신 손가락 아래 바로 / 칸이 아주 작은 판 — 그림자가 조각에 가려 안 보인다 (조각을 손가락 위로 띄워 그리기)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i424 — `src/demos/demosInput.ts:2033`
```ts
const i424 = demo<P424>({
  caption: 'L 조각을 끄는 동안 — 끔: 어디 놓일지 모름 · 켬: 놓일 자리를 그림자로 미리 (겹치면 빨강, 되면 초록)',
  howto: '아래 보라 조각을 판 위로 끌어 보세요. 그림자가 초록일 때 놓으면 착, 빨강에서 놓으면 제자리로 돌아갑니다.',
  onName: '놓일 자리 그림자',
  cycle: 4.8,
  prm: () => ({ alpha: 0.5 }),
  script: (u) => [
    path(u, [
      [0, 210, 318, 0],
      [0.3, 129, 243, 0],
      [0.45, 129, 243, 1],
      [1.2, 150, 64, 1],
      [1.8, 152, 62, 1],
      [2.4, 150, 148, 1],
      [3.0, 151, 148, 1],
      [3.05, 151, 148, 0],
      [3.8, 210, 318, 0],
      [4.8, 210, 318, 0],
    ]),
  ],
  create: (env) => {
    const GX = 45;
    const GY = 40;
    const CS = 42;
    const NC = 5;
    const NR = 4;
    const SHAPE: [number, number][] = [
      [0, 0],
      [0, 1],
      [1, 1],
    ];
    const HOME = { x: 108, y: 222 };
    const INIT = ['1,1', '2,1', '3,2', '0,3', '4,0'];
    let occ = new Map<string, string>();
    let pos = { ...HOME };
    let vel = { x: 0, y: 0 };
    let target: V2 | null = null;
    let grab: { id: number; ox: number; oy: number } | null = null;
    let anchor: { c: number; r: number; ok: boolean; bad: Set<string> } | null = null;
    let pending: { c: number; r: number } | null = null;
    let shake = 0;
    let fresh = 1;
    let v: Verdict = { text: '조각을 판으로 끌어요', color: COL.dim };
    let ex: string[] = [];
    const evalAt = (x: number, y: number): { c: number; r: number; ok: boolean; bad: Set<string> } => {
      const c = Math.round((x - GX) / CS);
      const r = Math.round((y - GY) / CS);
      const bad = new Set<string>();
      for (const [dc, dr] of SHAPE) {
        const cc = c + dc;
        const rr0 = r + dr;
        if (cc < 0 || cc >= NC || rr0 < 0 || rr0 >= NR || occ.has(`${cc},${rr0}`)) bad.add(`${dc},${dr}`);
      }
      return { c, r, ok: bad.size === 0, bad };
    };
    const onBoard = (x: number, y: number): boolean => x > GX - CS && x < GX + CS * NC && y > GY - CS && y < GY + CS * NR;
    const block = (g: CanvasRenderingContext2D, x: number, y: number, c1: string, c2: string, s = CS): void => {
      rr(g, x + 2, y + 2, s - 4, s - 4, 7);
      const gr = g.createLinearGradient(0, y, 0, y + s);
      gr.addColorStop(0, c1);
      gr.addColorStop(1, c2);
      g.fillStyle = gr;
      g.fill();
      rr(g, x + 6, y + 5, s - 12, s / 2 - 6, 5);
      g.fillStyle = 'rgba(255,255,255,0.2)';
      g.fill();
    };
    return {
      reset() {
        occ = new Map(INIT.map((k) => [k, 'stone']));
        pos = { ...HOME };
        vel = { x: 0, y: 0 };
        target = null;
        grab = null;
        anchor = null;
        pending = null;
        shake = 0;
        fresh = 1;
        v = { text: '조각을 판으로 끌어요', color: COL.dim };
        ex = [];
      },
      down(id, x, y) {
        if (grab || target) return;
        const inside = SHAPE.some(([dc, dr]) => x > pos.x + dc * CS && x < pos.x + (dc + 1) * CS && y > pos.y + dr * CS && y < pos.y + (dr + 1) * CS);
        if (!inside) return;
        grab = { id, ox: pos.x - x, oy: pos.y - y };
        v = { text: '끄는 중…', color: COL.blue };
      },
      move(id, x, y) {
        if (!grab || grab.id !== id) return;
        pos = { x: x + grab.ox, y: y + grab.oy };
        const a = evalAt(pos.x, pos.y);
        anchor = onBoard(pos.x + CS / 2, pos.y + CS / 2) ? a : null;
        if (anchor) {
          v = env.on() ? (anchor.ok ? { text: '여기 놓을 수 있어요', color: COL.on } : { text: '겹쳐요 — 못 놓아요', color: COL.off }) : { text: '끄는 중… (어디 놓일까?)', color: COL.dim };
          ex = [`놓일 자리: ${anchor.c + 1}칸 ${anchor.r + 1}줄`, anchor.ok ? '세 칸 모두 비어 있음' : `막힌 칸 ${anchor.bad.size}개 (겹침 · 판 밖)`];
        }
      },
      up(id) {
        if (!grab || grab.id !== id) return;
        grab = null;
        const a = onBoard(pos.x + CS / 2, pos.y + CS / 2) ? evalAt(pos.x, pos.y) : null;
        if (a && a.ok) {
          target = { x: GX + a.c * CS, y: GY + a.r * CS };
          pending = { c: a.c, r: a.r };
          v = { text: '착 — 놓았어요', color: COL.on };
        } else {
          target = { ...HOME };
          shake = 0.4;
          v = a ? { text: env.on() ? '빨강에서 놓아 돌아감' : '갑자기 돌아감 — 왜?', color: COL.off } : { text: '판 밖 — 돌아감', color: COL.warn };
        }
        anchor = null;
      },
      cancel(id) {
        if (grab && grab.id === id) {
          grab = null;
          anchor = null;
          target = { ...HOME };
        }
      },
      step(dt) {
        shake = Math.max(0, shake - dt);
        fresh = Math.min(1, fresh + dt * 3);
        if (target && !grab) {
          const k = 320;
          const c = 2 * Math.sqrt(k) * 0.7;
          vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
          vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
          pos = { x: pos.x + vel.x * dt, y: pos.y + vel.y * dt };
          if (dist(pos.x, pos.y, target.x, target.y) < 0.4 && Math.hypot(vel.x, vel.y) < 3) {
            pos = { ...target };
            vel = { x: 0, y: 0 };
            target = null;
            if (pending) {
              for (const [dc, dr] of SHAPE) occ.set(`${pending.c + dc},${pending.r + dr}`, 'piece');
              pending = null;
              pos = { ...HOME };
              fresh = 0;
            }
          }
        }
      },
      draw(g) {
        board(g, GX - 10, GY - 10, CS * NC + 20, CS * NR + 20);
        for (let r = 0; r < NR; r++)
          for (let c = 0; c < NC; c++) {
            const k = `${c},${r}`;
            const x = GX + c * CS;
            const y = GY + r * CS;
            const o = occ.get(k);
            if (o === 'stone') block(g, x, y, '#5c6788', '#3a4362');
            else if (o === 'piece') block(g, x, y, '#b59cff', '#7652e0');
            else {
              rr(g, x + 3, y + 3, CS - 6, CS - 6, 7);
              g.fillStyle = 'rgba(120,140,220,0.1)';
              g.fill();
            }
          }
        // 조각 받침
        rr(g, HOME.x - 10, HOME.y - 8, CS * 2 + 20, CS * 2 + 14, 12);
        g.setLineDash([4, 4]);
        g.lineWidth = 1;
        g.strokeStyle = 'rgba(160,180,255,0.25)';
        g.stroke();
        g.setLineDash([]);
        // 조각
        const sx = shake > 0 ? Math.sin(shake * 60) * 5 * (shake / 0.4) : 0;
        const lifted = !!grab;
        g.save();
        g.globalAlpha = fresh * (lifted && env.on() && anchor ? 0.6 : 1);
        g.translate(pos.x + sx, pos.y - (lifted ? 6 : 0));
        if (lifted) {
          g.fillStyle = 'rgba(0,0,0,0.35)';
          for (const [dc, dr] of SHAPE) {
            rr(g, dc * CS + 6, dr * CS + 12, CS - 4, CS - 4, 7);
            g.fill();
          }
        }
        for (const [dc, dr] of SHAPE) block(g, dc * CS, dr * CS, '#c8b4ff', '#8060ec');
        g.restore();
        // 그림자 미리 보기
        if (env.on() && grab && anchor) {
          const al = env.prm.alpha;
          const col = anchor.ok ? COL.on : COL.off;
          for (const [dc, dr] of SHAPE) {
            const x = GX + (anchor.c + dc) * CS;
            const y = GY + (anchor.r + dr) * CS;
            rr(g, x + 3, y + 3, CS - 6, CS - 6, 7);
            g.fillStyle = rgba(col, al * 0.55);
            g.fill();
            g.lineWidth = 2;
            g.setLineDash([5, 3]);
            g.strokeStyle = rgba(col, Math.min(1, al + 0.35));
            g.stroke();
            g.setLineDash([]);
            if (anchor.bad.has(`${dc},${dr}`)) cross(g, x + CS / 2, y + CS / 2, 8, '#ffffff', 3);
          }
        }
      },
      verdict: () => v,
      extra: () => [...ex, env.on() ? '판 칸에 맞춰 반올림한 자리를 미리 계산해 그림' : '놓아 봐야 결과를 앎'],
    };
  },
  controls: (p) => [{ type: 'range', label: '그림자 진하기', min: 0.15, max: 1, step: 0.05, value: p.alpha, on: (v) => (p.alpha = v) }],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [끌기 축 잠금](https://ai-techstudio.web.app/ai/t/i419.md) `i419` · [착 붙기 · 관성 (스냅과 던지기)](https://ai-techstudio.web.app/ai/t/i420.md) `i420`
- 다음에 해 볼 기술: [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425`
