# AI 꾸러미 — 플랫폼 점프 손맛 (코요테 · 버퍼 · 가변 점프) — Coyote time
> 발판을 막 벗어나도 0.1초는 점프되고(코요테), 땅에 닿기 0.12초 전에 누른 점프를 기억하며(버퍼), 일찍 떼면 낮게 뛰게(가변 점프) 해 점프 손맛을 살린다.  
> 견본: https://ai-techstudio.web.app/#t/i282

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

## 주문서

### 만들어 줘: 플랫폼 점프 손맛 (코요테 · 버퍼 · 가변 점프) — Coyote time

#### 1. 목표
2D 발판 게임 주인공의 점프에 손맛 세 가지를 넣어 줘 — 코요테 타임 0.1초 · 점프 버퍼 0.12초 · 일찍 떼면 위로 속도 × 0.4. 같은 입력으로 켬/끔을 나란히 비교할 수 있게, 화면은 위아래 켬/끔 비교 화면.

#### 2. 핵심 기술 용어
- **Coyote time** — 발판을 벗어난 뒤 잠깐 점프 허용
- **Jump buffering** — 땅에 닿기 직전에 누른 점프를 기억
- **Variable jump height (jump cut)** — 단추를 일찍 떼면 위로 속도를 잘라 낮게
- **Fixed timestep (1/120 s)** — 물리는 고정 간격으로

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

#### 4. 조건
- 코요테 0.1초 · 버퍼 0.12초 · 가변 점프 × 0.4 를 각각 켬/끔 할 수 있게
- 「누른 순간」은 단추 상태가 끔 → 켬으로 바뀐 순간으로 (누르고 있는 동안 계속 점프 금지)
- 점프할 때 coy · buf 를 0 으로 (두 번 뛰기 방지)
- 물리는 1/120초 고정 간격 — 화면 프레임과 상관없이 같은 높이
- 같은 입력을 켬 · 끔 두 캐릭터에 똑같이 넣어 나란히 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 발판 끝을 지나서 누르면 켬 쪽은 점프(「코요테!」), 끔 쪽은 떨어진다
- 착지 직전에 누르면 켬 쪽은 닿자마자 다시 뛰고(「버퍼!」), 끔 쪽은 그냥 선다
- 짧게 누르면 켬 쪽만 낮게 뛴다(「짧게!」)
- 30fps · 120fps 어디서든 점프 높이가 같다

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

## 원리
- 코요테: 땅에 있으면 coy = 0.1 초, 공중이면 매 걸음 줄인다. coy > 0 이면 공중이어도 점프된다 — 발판 끝에서 「늦게 누른」 점프를 살린다.
- 버퍼: 누른 순간 buf = 0.12 초. buf > 0 인 동안 땅에 닿으면 바로 점프 — 착지 직전 「일찍 누른」 점프를 살린다.
- 가변 점프: 오르는 중(vy < 0)에 단추를 떼면 vy × 0.4 — 짧게 누르면 낮게, 길게 누르면 높게.
- 값: 중력 900 · 점프 300 · 달리기 100 · 떨어지는 속도 최대 520. 물리는 1/120초 고정 간격으로 돌린다.

## 핵심 코드 — 코요테 · 버퍼 · 가변 점프 (고정 간격 한 걸음)
(발췌: demos/demos2dMove.ts heroStep() · PF 를 정리)
```ts
const PF = { G: 900, JUMP: 300, RUN: 100, COY: 0.1, BUF: 0.12, CUT: 0.4 };
interface Hero { x: number; y: number; vx: number; vy: number; ground: boolean; coy: number; buf: number; held: boolean; jumping: boolean }
interface Feel { coyote: boolean; buffer: boolean; variable: boolean }

function heroStep(s: Hero, h: number, left: boolean, right: boolean, jump: boolean, feel: Feel) {
  const edge = jump && !s.held; // 막 누른 순간만
  s.held = jump;
  const tv = ((right ? 1 : 0) - (left ? 1 : 0)) * PF.RUN;
  s.vx += (tv - s.vx) * (1 - Math.pow(0.0001, h)); // 부드럽게 목표 속도로
  if (edge) s.buf = feel.buffer ? PF.BUF : h * 0.5;  // 버퍼 끄면 이번 걸음만
  if (s.ground) s.coy = feel.coyote ? PF.COY : 0;
  else s.coy -= h;
  if (s.buf > 0 && (s.ground || s.coy > 0)) {        // 땅이거나 코요테 안
    s.vy = -PF.JUMP;
    s.ground = false;
    s.coy = 0;
    s.buf = 0;
    s.jumping = true;
  }
  s.buf -= h;
  if (s.jumping && !s.held && s.vy < 0) {            // 오르다 떼면
    if (feel.variable) s.vy *= PF.CUT;
    s.jumping = false;
  }
  if (s.vy >= 0) s.jumping = false;
  s.vy = Math.min(s.vy + PF.G * h, 520);
  moveAndCollide(s, h); // 가로 먼저 · 세로 나중 (i283)
}

// 고정 간격: 프레임이 들쭉날쭉해도 1/120초씩 같은 걸음
let acc = 0;
function frame(dt: number) {
  acc += Math.min(Math.max(dt, 0), 0.1);
  let n = 0;
  while (acc >= 1 / 120 && n < 40) { heroStep(hero, 1 / 120, inp.left, inp.right, inp.jump, feel); acc -= 1 / 120; n++; }
}
```

## 흔한 실수 · 확인 목록
- [ ] **「누르고 있는 동안」을 점프로 치면 착지하자마자 계속 뛴다** — 끔 → 켬으로 바뀐 순간(edge)만 버퍼에 넣는다.
- [ ] **점프 뒤 coy 를 0 으로 안 하면 공중에서 한 번 더 뛴다** — 점프하는 순간 coy · buf 를 둘 다 0 으로.
- [ ] **물리를 화면 프레임마다 dt 로 돌리면 기기마다 점프 높이가 다르다** — 1/120초 고정 간격으로 여러 번 돌린다. 한 프레임에 40걸음 넘으면 쌓인 시간을 버린다.
- [ ] **가변 점프를 떨어지는 중에도 걸면 공중에서 멈칫한다** — 오르는 중(vy < 0)에 뗐을 때만 자른다.

## 완성 기준 체크리스트
- [ ] 발판 끝을 지나서 누르면 켬 쪽은 점프(「코요테!」), 끔 쪽은 떨어진다
- [ ] 착지 직전에 누르면 켬 쪽은 닿자마자 다시 뛰고(「버퍼!」), 끔 쪽은 그냥 선다
- [ ] 짧게 누르면 켬 쪽만 낮게 뛴다(「짧게!」)
- [ ] 30fps · 120fps 어디서든 점프 높이가 같다

## 이 기술 정보
- id: `i282` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 타이머 두 개와 곱셈 하나. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i282
- 쓰면 좋을 때: 발판 게임 · 점프가 핵심인 게임 / 「눌렀는데 안 뛰었다」는 답답함이 있을 때
- 쓰지 말 때: 점프 높이가 정확히 정해져야 하는 퍼즐 (칸 수 세기) — 가변 점프는 끈다 / 턴제 · 칸 이동 게임 — 손맛 타이머가 필요 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i282 — `src/demos/demos2dMove.ts:697`
```ts
const i282: Demo2D = {
  kind: '2d',
  caption: '같은 입력, 위는 손맛 켬 · 아래는 끔 — 코요테 · 버퍼 · 가변 점프가 성공과 실패를 가른다',
  make() {
    const inp = new Input(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space', 'KeyZ', 'KeyA', 'KeyD', 'KeyW']);
    const feel: Feel = { coyote: true, buffer: true, variable: true };
    const OFF: Feel = { coyote: false, buffer: false, variable: false };
    let debug = false;
    const scns = scnLevels();
    let si = 0;
    let st = 0;
    let heroes: [Hero, Hero] = [newHero(0, 0), newHero(0, 0)];
    const start = (): void => {
      const s = scns[si]!;
      st = 0;
      heroes = [newHero(s.sx, s.sy), newHero(s.sx, s.sy)];
      heroes[0].ground = heroes[1].ground = true;
    };
    start();
    let me = newHero(40, 110);
    let manual = false;
    const step = fixed(1 / 120);
    let time = 0;
    const controls: Control[] = [
      { type: 'toggle', label: '코요테 타임', value: true, on: (v) => (feel.coyote = v) },
      { type: 'toggle', label: '점프 버퍼', value: true, on: (v) => (feel.buffer = v) },
      { type: 'toggle', label: '가변 점프', value: true, on: (v) => (feel.variable = v) },
      { type: 'toggle', label: '충돌 상자 · 타이머 보기', value: false, on: (v) => (debug = v) },
    ];
    const lane = (y0: number, s: Hero, lv: PLevel, label: string, on: boolean, keyOn: boolean, sc: number, x0: number): void => {
      g0!.save();
      g0!.translate(x0, y0);
      g0!.scale(sc, sc);
      rr(g0!, 0, 0, 400, 130, 9);
      g0!.save();
      g0!.clip();
      drawWorldLane(g0!, lv, time, on ? 0 : 3);
      drawHeroLane(g0!, s, on ? '#ffd27a' : '#d9c8ff', on ? '#ff8a3d' : '#8f7ad8', debug);
      g0!.restore();
      g0!.lineWidth = 1.6;
      g0!.strokeStyle = on ? 'rgba(255,200,90,.9)' : 'rgba(180,170,220,.7)';
      rr(g0!, 0, 0, 400, 130, 9);
      g0!.stroke();
      pill(g0!, label, 8, 12, 9, on ? '#ff8a3d' : '#6f6a8f', '#fff', 'left');
      // 점프 글쇠
      rr(g0!, 352, 5, 40, 15, 4);
      g0!.fillStyle = keyOn ? '#ffe066' : 'rgba(255,255,255,.75)';
      g0!.fill();
      g0!.strokeStyle = keyOn ? '#c79200' : 'rgba(60,60,90,.5)';
      g0!.stroke();
      txt(g0!, '점프', 372, 12.8, 8, keyOn ? '#5a3c00' : '#5a5a78', 'center', 800);
      // 결과
      const fail = s.dead !== '' || (!manual && st > scns[si]!.dur - 0.7 && !s.win);
      if (!manual && (s.win || fail)) {
        g0!.globalAlpha = 0.95;
        pill(g0!, s.win ? '성공!' : '실패', 200, 66, 16, s.win ? '#2fbf71' : '#ff4d6d');
        g0!.globalAlpha = 1;
      }
      g0!.restore();
    };
    let g0: G | null = null;
    return {
      controls,
      draw(g, w, h, _t, dt) {
        g0 = g;
        const v = viewOf(w, h);
        inp.sync(g, v);
        time += dt;
        if (v.big && inp.idle() < 0.05 && !manual) {
          manual = true;
          me = newHero(40, 110);
          me.ground = true;
        }
        if (manual && inp.idle() > 8) {
          manual = false;
          start();
        }
        step(dt, (H) => {
          if (manual) {
            heroStep(me, H, inp.key('ArrowLeft', 'KeyA'), inp.key('ArrowRight', 'KeyD'), inp.key('Space', 'ArrowUp', 'KeyZ', 'KeyW'), feel, PLAY);
            if (me.x > 410) me.x = -8;
            if (me.x < -10) me.x = 408;
            if (me.dead && me.deadT > 0.8) {
              me = newHero(40, 110);
              me.ground = true;
            }
            return;
          }
          st += H;
          const s = scns[si]!;
          const j = st >= s.press && st < s.press + s.hold;
          heroStep(heroes[0], H, false, true, j, feel, s.lv);
          heroStep(heroes[1], H, false, true, j, OFF, s.lv);
          if (st > s.dur + 0.5) {
            si = (si + 1) % scns.length;
            start();
          }
        });
        fillBg(g, w, h, '#1d2240', '#2b2f57');
        enter(g, v);
        if (manual) {
          lane(22, me, PLAY, '직접 하기 · ← → 스페이스', true, me.held, 0.66, 8);
          txt(g, '켠 손맛: ' + [feel.coyote ? '코요테' : '', feel.buffer ? '버퍼' : '', feel.variable ? '가변' : ''].filter(Boolean).join(' · ') || '없음', 140, 9, 8, '#ffe8b0');
          txt(g, '구덩이 끝에서 늦게 · 착지 전에 미리 · 가시 밑에서 짧게 눌러 보세요', 140, 117, 7, 'rgba(255,255,255,.75)', 'center', 600);
        } else {
          const s = scns[si]!;
          const j = st >= s.press && st < s.press + s.hold;
          txt(g, s.name, 12, 8.5, 9, '#ffe08a', 'left', 800);
          txt(g, s.tip, 270, 8.5, 7.5, 'rgba(255,255,255,.8)', 'right', 600);
          lane(17, heroes[0], s.lv, '손맛 켬', true, j, 0.6, 20);
          lane(96, heroes[1], s.lv, '손맛 끔', false, j, 0.6, 20);
        }
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [타일 충돌 (축마다 따로 밀어내기)](https://ai-techstudio.web.app/ai/t/i283.md) `i283`
- 다음에 해 볼 기술: [스프라이트 동작 상태기 + 섞기](https://ai-techstudio.web.app/ai/t/i233.md) `i233` · [쓸고 지나는 충돌 (빠른 물체 뚫림 막기)](https://ai-techstudio.web.app/ai/t/i285.md) `i285`
