# AI 꾸러미 — 길게 누르기 · 두 번 누르기 구별 — Gesture disambiguation (tap · double-tap · long-press)
> 누른 시간과 앞 누름과의 간격으로 한 번 · 두 번 · 길게를 가려, 한 칸에서 열기 · ✕ 표시 · 깃발을 따로 할 수 있게 하고 길게 누르는 동안 원 진행을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i421

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

## 주문서

### 만들어 줘: 길게 누르기 · 두 번 누르기 구별 — Gesture disambiguation (tap · double-tap · long-press)

#### 1. 목표
지뢰 찾기 같은 칸 (열기 · ✕ · 깃발)에서 한 번 · 두 번 · 길게 누르기를 구별해 줘 — 500ms 넘게 누르면 길게(누르는 동안 원 진행), 뗀 뒤 300ms 안에 또 누르면 두 번, 아니면 300ms 기다린 뒤 한 번으로 확정. 14px 넘게 움직이면 취소. 화면은 시간 띠로 누른 구간 보여 주기.

#### 2. 핵심 기술 용어
- **Gesture disambiguation (tap · double-tap · long-press)** — 누름 몸짓 구별
- **Long-press threshold** — 길게 누르기 기준 (500ms)
- **Double-tap interval** — 두 번 누르기 간격 (300ms)
- **Movement tolerance (cancel on move)** — 14px 넘게 움직이면 취소

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

#### 4. 조건
- 길게: 누른 채 500ms — 뗄 때가 아니라 그 순간 바로 실행, 누르는 동안 원 진행 표시
- 두 번: 앞 누름이 끝난 뒤 300ms 안에 다음 누름 시작
- 한 번: 두 번 기다림(300ms)이 끝나야 확정 — 두 번을 안 쓰는 칸이면 바로
- 누른 채 14px 넘게 움직이면 취소
- 두 기준(250 ~ 1000ms · 150 ~ 500ms)을 조절할 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 한 번 누르면 잠시 「기다리는 중… (두 번?)」 뒤 칸이 열린다
- 빠르게 두 번 누르면 ✕, 꾹 누르면 원이 차오른 뒤 깃발 + 둥근 메뉴
- 아래 시간 띠에 누른 구간 · 두 번 기다림(노랑) · 길게 기준선(보라)이 보인다
- 끔이면 모두 「누름」 하나로 뒤죽박죽

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

## 원리
- 누르면 시계를 켠다. 떼기 전에 500ms 가 지나면 그 자리에서 「길게」 — 뗄 때를 기다리지 않는다.
- 떼었을 때 길게가 아니면 「한 번 후보」로 300ms 기다린다.
- 그 안에 다시 누르면 「두 번」, 300ms 가 지나면 그제서야 「한 번」 확정 — 그래서 한 번은 조금 늦다.
- 누른 채 14px 넘게 움직이면 끌기로 보고 취소한다.

## 핵심 코드 — 누른 시간 · 간격으로 한 번 · 두 번 · 길게
(발췌: demos/demosInput.ts i421 down · move · up · step)
```ts
let LONG = 0.5; // 초
let DBL = 0.3;
let held: { t0: number; x0: number; y0: number; fired: boolean; moved: boolean } | null = null;
let pendingT: number | null = null; // 「한 번」 후보가 된 때 (뗀 시각)

function down(x: number, y: number, now: number): void {
  held = { t0: now, x0: x, y0: y, fired: false, moved: false };
}
function move(x: number, y: number): void {
  if (held && Math.hypot(x - held.x0, y - held.y0) > 14) held.moved = true; // 끌기 → 취소
}
function up(now: number): void {
  const h = held;
  held = null;
  if (!h || h.fired) return; // 길게는 이미 실행됨
  if (h.moved) { pendingT = null; return; }
  if (pendingT !== null && h.t0 - pendingT <= DBL) {
    pendingT = null;
    fire('double');
  } else pendingT = now; // 두 번인지 기다린다
}
// 프레임마다
function step(now: number): void {
  if (held && !held.fired && !held.moved && now - held.t0 >= LONG) {
    held.fired = true; // 떼기 전에 바로
    pendingT = null;
    fire('long');
  }
  if (pendingT !== null && !held && now - pendingT > DBL) {
    pendingT = null;
    fire('tap'); // 두 번 기다림이 끝나야 한 번 확정
  }
  // 원 진행: held ? (now − held.t0) / LONG : 0
}
```

## 흔한 실수 · 확인 목록
- [ ] **한 번 누름을 바로 실행하면 두 번 누를 때 한 번 + 두 번이 함께 일어난다** — 두 번 기다림이 끝난 뒤에 한 번을 확정한다 — 두 번을 안 쓰는 칸만 바로.
- [ ] **길게를 뗄 때 판정하면 「언제 떼야 하지?」 하고 손을 계속 누르고 있다** — 500ms 가 되는 순간 실행하고 진동 · 원 진행으로 알린다.
- [ ] **폰 브라우저의 길게 누르기 메뉴(글자 고르기 · 그림 저장)가 뜬다** — 판 요소에 user-select: none · -webkit-touch-callout: none, contextmenu 이벤트 막기.

## 완성 기준 체크리스트
- [ ] 한 번 누르면 잠시 「기다리는 중… (두 번?)」 뒤 칸이 열린다
- [ ] 빠르게 두 번 누르면 ✕, 꾹 누르면 원이 차오른 뒤 깃발 + 둥근 메뉴
- [ ] 아래 시간 띠에 누른 구간 · 두 번 기다림(노랑) · 길게 기준선(보라)이 보인다
- [ ] 끔이면 모두 「누름」 하나로 뒤죽박죽

## 이 기술 정보
- id: `i421` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 시계 비교 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i421
- 쓰면 좋을 때: 한 칸에 동작이 둘 셋 필요한데 단추를 더 둘 자리가 없을 때 / 폰에서 오른쪽 클릭 대신 길게 누르기
- 쓰지 말 때: 아이 · 처음 하는 사람이 주로 쓰는 게임 — 숨은 몸짓을 모른다 (대신 도구 단추로 바꾸기) / 한 번 누름이 빨라야 하는 게임 — 두 번을 기다리느라 300ms 늦어진다 (두 번을 쓰지 않으면 바로 확정)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i421 — `src/demos/demosInput.ts:1286`
```ts
const i421 = demo<P421>({
  caption: '한 번 · 두 번 · 길게 — 끔: 전부 「누름」으로 뒤죽박죽 · 켬: 시간 · 간격으로 열기 / ✕ / 깃발 구별 (길게는 원 진행)',
  howto: '칸을 한 번 누르면 열기, 빠르게 두 번 누르면 ✕ 표시, 꾹 누르고 있으면 깃발. 아래 시간 띠에 누른 구간이 그려집니다.',
  onName: '누름 시간 · 간격으로 몸짓 구별',
  cycle: 5.2,
  prm: () => ({ long: 500, dbl: 300 }),
  script: (u) => [
    path(u, [
      [0, 150, 300, 0],
      [0.4, 150, 136, 0],
      [0.55, 150, 136, 1],
      [0.67, 150, 136, 0],
      [1.7, 153, 139, 1],
      [1.8, 153, 139, 0],
      [1.92, 153, 139, 1],
      [2.02, 153, 139, 0],
      [3.0, 148, 137, 1],
      [3.95, 148, 137, 0],
      [4.5, 150, 300, 0],
    ]),
  ],
  create: (env) => {
    let open = false;
    let mark: 'none' | 'x' | 'flag' = 'none';
    let held: { id: number; t0: number; x0: number; y0: number; fired: boolean; moved: boolean } | null = null;
    let pendingT: number | null = null;
    let lastUp = -9;
    let gap = 0;
    let presses: { t0: number; t1: number | null }[] = [];
    let log: { text: string; color: string; t: number }[] = [];
    let flash = 0;
    let menuA = 0;
    let v: Verdict = { text: '칸을 눌러 보세요', color: COL.dim };
    const fire = (k: 'tap' | 'double' | 'long' | 'raw'): void => {
      flash = 0.35;
      const now = env.now();
      if (k === 'tap' || k === 'raw') {
        open = !open;
        if (k === 'raw') {
          v = { text: '누름 (구별 없음)', color: COL.off };
          log.push({ text: '누름', color: COL.off, t: now });
        } else {
          v = { text: '한 번 누르기 → 열기', color: COL.on };
          log.push({ text: '한 번', color: COL.on, t: now });
        }
      } else if (k === 'double') {
        mark = mark === 'x' ? 'none' : 'x';
        v = { text: '두 번 누르기 → ✕', color: COL.gold };
        log.push({ text: '두 번', color: COL.gold, t: now });
      } else {
        mark = 'flag';
        menuA = 0.001;
        v = { text: '길게 누르기 → 깃발', color: COL.violet };
        log.push({ text: '길게', color: COL.violet, t: now });
      }
      while (log.length > 5) log.shift();
    };
    const inTile = (x: number, y: number): boolean => Math.abs(x - 150) < 66 && Math.abs(y - 136) < 66;
    return {
      reset() {
        open = false;
        mark = 'none';
        held = null;
        pendingT = null;
        lastUp = -9;
        gap = 0;
        presses = [];
        log = [];
        flash = 0;
        menuA = 0;
        v = { text: '칸을 눌러 보세요', color: COL.dim };
      },
      down(id, x, y) {
        if (held || !inTile(x, y)) return;
        const now = env.now();
        held = { id, t0: now, x0: x, y0: y, fired: false, moved: false };
        gap = now - lastUp;
        presses.push({ t0: now, t1: null });
        if (!env.on()) fire('raw');
      },
      move(id, x, y) {
        if (held && held.id === id && dist(x, y, held.x0, held.y0) > 14) held.moved = true;
      },
      up(id) {
        if (!held || held.id !== id) return;
        const now = env.now();
        const last = presses[presses.length - 1];
        if (last) last.t1 = now;
        lastUp = now;
        const h = held;
        held = null;
        if (!env.on() || h.fired) return;
        if (h.moved) {
          v = { text: '움직여서 취소', color: COL.dim };
          pendingT = null;
          return;
        }
        if (pendingT !== null && h.t0 - pendingT <= env.prm.dbl / 1000) {
          pendingT = null;
          fire('double');
        } else pendingT = now;
      },
      cancel(id) {
        if (held && held.id === id) {
          const last = presses[presses.length - 1];
          if (last) last.t1 = env.now();
          held = null;
        }
      },
      step(dt) {
        const now = env.now();
        flash = Math.max(0, flash - dt);
        if (menuA > 0) menuA = Math.min(1, menuA + dt * 4);
        if (env.on()) {
          if (held && !held.fired && !held.moved && now - held.t0 >= env.prm.long / 1000) {
            held.fired = true;
            pendingT = null;
            fire('long');
          }
          if (pendingT !== null && !held && now - pendingT > env.prm.dbl / 1000) {
            pendingT = null;
            fire('tap');
          }
          if (held && !held.fired && pendingT === null) v = { text: '기다리는 중… (길게?)', color: COL.blue };
          else if (pendingT !== null && !held) v = { text: '기다리는 중… (두 번?)', color: COL.blue };
        }
        presses = presses.filter((p) => p.t1 === null || now - p.t1 < 3.4);
      },
      draw(g) {
        const on = env.on();
        const now = env.now();
        const T = 112;
        const press = held ? 0.94 : 1;
        // 칸
        g.save();
        g.translate(150, 136);
        g.scale(press, press);
        g.shadowColor = 'rgba(0,0,0,0.5)';
        g.shadowBlur = 16;
        g.shadowOffsetY = 6;
        rr(g, -T / 2, -T / 2, T, T, 16);
        const gr = g.createLinearGradient(0, -T / 2, 0, T / 2);
        if (open) {
          gr.addColorStop(0, '#e9eefc');
          gr.addColorStop(1, '#c4cde8');
        } else {
          gr.addColorStop(0, '#4c5ea8');
          gr.addColorStop(1, '#27336c');
        }
        g.fillStyle = gr;
        g.fill();
        g.shadowColor = 'transparent';
        if (!open) {
          rr(g, -T / 2 + 6, -T / 2 + 5, T - 12, T / 2 - 8, 11);
          g.fillStyle = 'rgba(255,255,255,0.12)';
          g.fill();
        } else txt(g, '3', 0, 2, 54, '#3a5bd9', 'center', 800);
        if (flash > 0) {
          rr(g, -T / 2, -T / 2, T, T, 16);
          g.fillStyle = `rgba(255,255,255,${flash})`;
          g.fill();
        }
        if (mark === 'x') {
          cross(g, 0, 0, 30, '#ff5068', 9);
        } else if (mark === 'flag') {
          line(g, -14, 34, -14, -32, '#e7e9f5', 4);
          g.beginPath();
          g.moveTo(-12, -32);
          g.lineTo(28, -20);
          g.lineTo(-12, -6);
          g.closePath();
          g.fillStyle = '#ff4d63';
          g.fill();
        }
        g.restore();
        // 길게 누르기 진행 원
        if (on && held && !held.moved) {
          const f = clamp((now - held.t0) / (env.prm.long / 1000), 0, 1);
          ring(g, held.x0, held.y0, 30, 'rgba(255,255,255,0.15)', 5);
          g.beginPath();
          g.arc(held.x0, held.y0, 30, -Math.PI / 2, -Math.PI / 2 + f * TAU);
          g.lineWidth = 5;
          g.lineCap = 'round';
          g.strokeStyle = f >= 1 ? COL.violet : COL.on;
          g.stroke();
        }
        // 길게 → 둥근 메뉴
        if (menuA > 0 && mark === 'flag') {
          const e = 1 - Math.pow(1 - menuA, 3);
          [-0.5, 0, 0.5].forEach((da, i) => {
            const a = -Math.PI / 2 + da * 1.6;
            const x = 150 + Math.cos(a) * 82 * e;
            const y = 136 + Math.sin(a) * 82 * e;
            disc(g, x, y, 13 * e, 'rgba(25,30,60,0.95)');
            ring(g, x, y, 13 * e, rgba(COL.violet, 0.9), 1.5);
            const lb = ['깃발', '물음', '지움'][i]!;
            if (e > 0.6) txt(g, lb, x, y, 8, COL.text, 'center', 700);
          });
        }
        // 몸짓 기록
        let lx = 26;
        for (const l of log) {
          g.font = `700 10px ${FONT}`;
          const w = g.measureText(l.text).width + 12;
          rr(g, lx, 213, w, 18, 9);
          g.fillStyle = rgba(l.color, 0.16);
          g.fill();
          g.strokeStyle = rgba(l.color, 0.6);
          g.lineWidth = 1;
          g.stroke();
          txt(g, l.text, lx + w / 2, 222.5, 10, l.color, 'center', 700);
          lx += w + 4;
        }
        // 시간 띠 (최근 3.2초)
        const X0 = 22;
        const X1 = 278;
        const Y = 248;
        const H = 26;
        const win = 3.2;
        const tx = (t: number): number => X1 - ((now - t) / win) * (X1 - X0);
        rr(g, X0, Y, X1 - X0, H, 6);
        g.fillStyle = 'rgba(0,0,0,0.3)';
        g.fill();
        g.strokeStyle = 'rgba(160,180,255,0.18)';
        g.stroke();
        g.save();
        rr(g, X0, Y, X1 - X0, H, 6);
        g.clip();
        for (let s = Math.ceil(now - win); s <= now; s += 0.5) line(g, tx(s), Y + 2, tx(s), Y + H - 2, 'rgba(160,180,255,0.08)', 1);
        if (on && pendingT !== null) {
          const a = tx(pendingT);
          const b = tx(pendingT + env.prm.dbl / 1000);
          g.fillStyle = rgba(COL.gold, 0.14);
          g.fillRect(a, Y, b - a, H);
          line(g, b, Y, b, Y + H, rgba(COL.gold, 0.8), 1, [2, 2]);
        }
        for (const p of presses) {
          const a = tx(p.t0);
          const b = tx(p.t1 ?? now);
          rr(g, a, Y + 6, Math.max(3, b - a), H - 12, 4);
          g.fillStyle = on ? COL.on : COL.off;
          g.fill();
        }
        if (on && held) {
          const lt = tx(held.t0 + env.prm.long / 1000);
          line(g, lt, Y, lt, Y + H, rgba(COL.violet, 0.9), 1.4, [3, 2]);
        }
        g.restore();
        txt(g, '누른 구간 · 최근 3초', X0, Y + H + 10, 9, COL.dim, 'left', 600);
        if (on) txt(g, '노랑 = 두 번 기다림', X1, Y + H + 10, 9, rgba(COL.gold, 0.8), 'right', 600);
      },
      verdict: () => v,
      extra: () => [
        `직전 누름과의 간격 ${gap > 5 ? '—' : gap.toFixed(2) + '초'} (두 번 기준 ${(env.prm.dbl / 1000).toFixed(2)}초)`,
        `길게 기준 ${(env.prm.long / 1000).toFixed(2)}초 · 14px 넘게 움직이면 취소`,
        env.on() ? '한 번은 두 번 기다림이 끝나야 확정 (조금 늦음)' : '누르는 즉시 「누름」 — 두 번 · 길게를 모름',
      ],
    };
  },
  controls: (p) => [
    { type: 'range', label: '길게 누르기 기준 (ms)', min: 250, max: 1000, step: 50, value: p.long, on: (v) => (p.long = v) },
    { type: 'range', label: '두 번 누르기 간격 (ms)', min: 150, max: 500, step: 10, value: p.dbl, on: (v) => (p.dbl = v) },
  ],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [손가락 크기 누름 영역](https://ai-techstudio.web.app/ai/t/i418.md) `i418` · [손 떨림 걸러내기 (누름 vs 끌기 문턱)](https://ai-techstudio.web.app/ai/t/i425.md) `i425`
- 다음에 해 볼 기술: [두 손가락 몸짓 (확대 · 회전)](https://ai-techstudio.web.app/ai/t/i422.md) `i422`
