# AI 꾸러미 — 타일 충돌 (축마다 따로 밀어내기) — Tile collision (axis-separated resolution)
> 캐릭터를 가로로 먼저 움직여 벽에서 밀어내고 세로로 나중에 움직여 바닥 · 천장에서 밀어내며, 경사는 발 높이를 맞추고 한쪽 발판은 위에서만 밟히게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i283

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

## 주문서

### 만들어 줘: 타일 충돌 (축마다 따로 밀어내기) — Tile collision (axis-separated resolution)

#### 1. 목표
2D 발판 게임 지도에 타일 충돌을 넣어 줘 — 가로로 먼저 움직여 앞쪽 칸 벽에서 밀고, 세로로 나중에 움직여 바닥 · 천장에서 민다. 경사 · 한쪽 발판까지, 화면은 검사한 칸이 깜빡이는 설명 화면.

#### 2. 핵심 기술 용어
- **Tile collision (axis-separated resolution)** — 축마다 따로 움직이고 밀어내기
- **AABB vs tile grid** — 상자 캐릭터와 타일 칸 검사
- **Slope tiles (height function)** — 경사 칸 — 칸 안 x 로 바닥 높이 계산
- **One-way platform (drop-through)** — 아래에서는 통과, 위에서만 밟힘 · 아래키로 내려가기

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

#### 4. 조건
- 가로 먼저 · 세로 나중 — 순서를 지키기
- 가로 검사는 앞쪽 끝 열의 칸, 세로 검사는 발밑(내려갈 때) · 머리 위(올라갈 때) 줄의 칸
- 땅에 서 있을 때 가로 검사 아래 끝을 6 정도 올려, 바닥 칸을 벽으로 착각하지 않게
- 한쪽 발판은 이전 발 높이로 판단, 아래키로 0.25초 통과
- 물리는 고정 간격(1/120초)

#### 5. 완성 기준 (이게 보이면 성공)
- 벽에 붙어 달려도 떨리지 않고 딱 붙어 선다
- 경사를 오르내릴 때 발이 경사면에 붙어 있다 (내리막에서 통통 튀지 않음)
- 한쪽 발판은 아래에서 점프하면 통과하고, 위에서는 밟히고, 아래키로 내려갈 수 있다
- 설명 보기에서 ① 가로 · ② 세로로 검사한 칸이 차례로 표시된다

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

## 원리
- 한 번에 대각으로 움직이고 겹침을 풀면 「벽인지 바닥인지」 헷갈려 모서리에 걸린다. 그래서 축을 나눈다.
- ① 가로: x 만 움직이고, 앞쪽 끝(오른쪽이면 x + 반폭)이 든 칸 열에서 몸 높이에 걸친 칸들을 본다. 벽이면 그 칸 경계에 딱 붙인다.
- ② 세로: 중력을 더해 y 만 움직이고, 내려가는 중이면 발밑 칸, 올라가는 중이면 머리 위 칸을 본다. 바닥이면 y = 칸 윗면 · vy = 0 · 땅 위.
- 경사(/ \)는 칸 안 위치 fx 로 바닥 높이 = 칸 위 + 칸 크기 × (1 − fx) 또는 fx. 땅에 있다가 7 안쪽이면 붙여 줘서 내리막에서 뜨지 않는다.
- 한쪽 발판(-)은 이전 발 높이가 발판 윗면보다 위였을 때만 밟힌다. 아래키를 누르면 0.25초 동안 무시해 내려간다.

## 핵심 코드 — ① 가로 밀어내기 → ② 세로 밀어내기 (+ 한쪽 발판)
(발췌: demos/demos2dMove.ts i283 physics() 를 정리 (경사는 줄임))
```ts
const T = 14, HW = 5, HH = 12, GR = 700; // 칸 크기 · 반폭 · 키 · 중력
// tile(c, r): '#' 벽 · '-' 한쪽 발판 · '.' 빈칸 (밖은 '#')
function physics(p: { x: number; y: number; vx: number; vy: number; ground: boolean; drop: number }, h: number) {
  // ① 가로: x 만 움직이고 앞쪽 끝 열의 칸들
  if (p.vx !== 0) {
    p.x += p.vx * h;
    const top = p.y - HH;
    const bot = p.y - (p.ground ? 6 : 0.5); // 서 있으면 발밑 칸은 빼기
    const lead = p.vx > 0 ? p.x + HW : p.x - HW;
    const c = Math.floor(lead / T);
    for (let r = Math.floor(top / T); r <= Math.floor(bot / T); r++)
      if (tile(c, r) === '#') { p.x = p.vx > 0 ? c * T - HW : (c + 1) * T + HW; break; }
  }
  // ② 세로: 중력 → y 만 움직이고 발밑 · 머리 위 줄
  p.vy = Math.min(p.vy + GR * h, 480);
  const prev = p.y;
  p.y += p.vy * h;
  p.ground = false;
  p.drop -= h;
  const cL = Math.floor((p.x - HW + 0.01) / T), cR = Math.floor((p.x + HW - 0.01) / T);
  if (p.vy >= 0) {
    const r = Math.floor((p.y - 0.001) / T);
    for (let c = cL; c <= cR; c++) {
      const ch = tile(c, r);
      const oneWay = ch === '-' && prev <= r * T + 0.5 && p.drop <= 0; // 위에서 내려올 때만
      if (ch === '#' || oneWay) { p.y = r * T; p.vy = 0; p.ground = true; break; }
    }
  } else {
    const r = Math.floor((p.y - HH) / T);
    for (let c = cL; c <= cR; c++)
      if (tile(c, r) === '#') { p.y = (r + 1) * T + HH; p.vy = 0; break; } // 천장
  }
}
// 아래키 + 한쪽 발판 위면: p.drop = 0.25 (0.25초 동안 통과)
// 경사 칸 바닥 높이: surf = r*T + T * (ch === '/' ? 1 - fx : fx), fx = (x - c*T) / T
```

## 흔한 실수 · 확인 목록
- [ ] **대각으로 한 번에 움직이고 풀면 벽 모서리에 걸려 멈춘다** — 가로 · 세로를 따로 움직이고 따로 민다.
- [ ] **땅에 서서 걸으면 바닥 칸을 벽으로 착각해 멈춘다** — 서 있을 땐 가로 검사 아래 끝을 발 위 6 으로 올린다.
- [ ] **내리막 경사에서 캐릭터가 통통 튄다** — 땅에 있던 걸음이면 경사면이 발 아래 7 안쪽일 때 붙여 준다.
- [ ] **한쪽 발판을 지금 발 높이로만 판단하면 아래에서 뛰어도 걸린다** — 이전 걸음의 발 높이가 발판 윗면 위였을 때만 밟히게 한다.

## 완성 기준 체크리스트
- [ ] 벽에 붙어 달려도 떨리지 않고 딱 붙어 선다
- [ ] 경사를 오르내릴 때 발이 경사면에 붙어 있다 (내리막에서 통통 튀지 않음)
- [ ] 한쪽 발판은 아래에서 점프하면 통과하고, 위에서는 밟히고, 아래키로 내려갈 수 있다
- [ ] 설명 보기에서 ① 가로 · ② 세로로 검사한 칸이 차례로 표시된다

## 이 기술 정보
- id: `i283` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 걸음마다 걸친 칸 몇 개만 본다. 고정 간격 1/120초로 돌려도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i283
- 쓰면 좋을 때: 칸(타일) 지도 위를 걷고 뛰는 게임 / 물리 엔진 없이 또렷한 발판 감각이 필요할 때
- 쓰지 말 때: 돌아가는 물체 · 둥근 모양끼리 충돌 — 대신 분리축(i284) · 물리 엔진 / 한 걸음에 칸보다 멀리 가는 빠른 물체 — 칸을 건너뛴다. 대신 쓸기 검사(i285) 또는 걸음을 쪼개기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i283 — `src/demos/demos2dMove.ts:842`
```ts
const i283: Demo2D = {
  kind: '2d',
  caption: '가로로 먼저 움직여 벽에서 밀고(①), 그다음 세로로 움직여 바닥에서 민다(②) — 경사 · 한쪽 발판까지',
  make() {
    const inp = new Input(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space', 'KeyA', 'KeyD', 'KeyW', 'KeyS']);
    const HW = 5;
    const HH = 12;
    const GR = 700;
    const JV = 340;
    const RUN = 72;
    let debug = true;
    let slow = 1;
    const p = { x: 30, y: 126, vx: 0, vy: 0, ground: false, drop: 0, face: 1, sq: 0, onSlope: '' as string, slopeC: 0, slopeR: 0 };
    const ai = { dir: 1, cd: 1, stuck: 0, R: rng(7) };
    let chkX: [number, number, boolean][] = [];
    let chkY: [number, number, boolean][] = [];
    const pushes: { x: number; y: number; dx: number; dy: number; ax: 0 | 1; t: number }[] = [];
    let litX = 0;
    let litY = 0;
    const dust: Part[] = [];
    let mapC: HTMLCanvasElement | null = null;
    const step = fixed(1 / 120);
    const buildMap = (): HTMLCanvasElement => {
      const S = 3;
      const c = mkCanvas(280 * S, 168 * S);
      const g = c.getContext('2d')!;
      g.scale(S, S);
      const R = rng(11);
      const solidish = (ch: string): boolean => ch === '#' || slope3(ch);
      for (let r = 0; r < 12; r++)
        for (let cc = 0; cc < 20; cc++) {
          const ch = tile3(cc, r);
          const x = cc * T3;
          const y = r * T3;
          if (ch === '#') {
            const top = !solidish(tile3(cc, r - 1)) && r > 0;
            const edge = cc === 0 || cc === 19 || r === 0;
            g.fillStyle = edge ? '#4a3a5e' : r >= 9 && !top ? '#7a4c2e' : '#8c5a36';
            g.fillRect(x, y, T3, T3);
            g.fillStyle = edge ? '#574670' : '#9c6840';
            g.fillRect(x + 1, y + 1, T3 - 2, T3 - 2);
            g.fillStyle = 'rgba(0,0,0,.12)';
            g.fillRect(x + 1 + R() * 9, y + 3 + R() * 8, 3, 2);
            g.fillRect(x + 2 + R() * 9, y + 2 + R() * 8, 2, 2);
            if (edge) {
              g.fillStyle = 'rgba(255,255,255,.06)';
              g.fillRect(x + 1, y + 1, T3 - 2, 2);
            }
            if (top) {
              g.fillStyle = '#63d16a';
              g.fillRect(x, y, T3, 4);
              for (let k = 0; k < 4; k++) {
                g.beginPath();
                g.arc(x + 2 + k * 3.4, y + 4, 1.7, 0, Math.PI);
                g.fill();
              }
              g.fillStyle = '#a6f0a0';
              g.fillRect(x, y, T3, 1);
            }
          } else if (slope3(ch)) {
            g.fillStyle = '#9c6840';
            g.beginPath();
            if (ch === '/') {
              g.moveTo(x, y + T3);
              g.lineTo(x + T3, y);
              g.lineTo(x + T3, y + T3);
            } else {
              g.moveTo(x, y);
              g.lineTo(x + T3, y + T3);
              g.lineTo(x, y + T3);
            }
            g.closePath();
            g.fill();
            g.strokeStyle = '#63d16a';
            g.lineWidth = 4;
            g.lineCap = 'round';
            g.beginPath();
            if (ch === '/') {
              g.moveTo(x, y + T3 + 1);
              g.lineTo(x + T3, y + 1);
            } else {
              g.moveTo(x, y + 1);
              g.lineTo(x + T3, y + T3 + 1);
            }
            g.stroke();
            g.lineCap = 'butt';
          } else if (ch === '-') {
            g.fillStyle = '#c98f52';
            rr(g, x, y, T3, 5, 1.5);
            g.fill();
            g.fillStyle = '#e8b878';
            g.fillRect(x + 1, y + 0.7, T3 - 2, 1.2);
            g.strokeStyle = '#7a4f28';
            g.lineWidth = 0.6;
            g.strokeRect(x + 0.3, y + 0.3, T3 - 0.6, 4.4);
            if (tile3(cc - 1, r) !== '-' || tile3(cc + 1, r) !== '-') {
              g.fillStyle = '#7a4f28';
              g.fillRect(x + (tile3(cc - 1, r) !== '-' ? 3 : T3 - 5), y + 5, 2, 5);
            }
          }
        }
      return c;
    };
    const jump = (): void => {
      p.vy = -JV;
      p.ground = false;
      p.sq = -0.3;
    };
    const physics = (H: number, L: boolean, Rr: boolean, J: boolean, D: boolean): void => {
      const dir = (Rr ? 1 : 0) - (L ? 1 : 0);
      if (dir) p.face = dir;
      p.vx = dir * RUN;
      if (J && p.ground) jump();
      if (D && p.ground && tile3(Math.floor(p.x / T3), Math.floor((p.y + 1) / T3)) === '-') p.drop = 0.25;
      p.drop -= H;
      p.sq *= Math.pow(0.002, H);
      // ① 가로
      chkX = [];
      let blocked = false;
      if (p.vx !== 0) {
        p.x += p.vx * H;
        const top = p.y - HH;
        const bot = p.y - (p.ground ? 6 : 0.5);
        const lead = p.vx > 0 ? p.x + HW : p.x - HW;
        const c = Math.floor(lead / T3);
        for (let r = Math.floor(top / T3); r <= Math.floor(bot / T3); r++) {
          const s = tile3(c, r) === '#';
          chkX.push([c, r, s]);
          if (s && !blocked) {
            const nx = p.vx > 0 ? c * T3 - HW : (c + 1) * T3 + HW;
            pushes.push({ x: p.x + (p.vx > 0 ? HW : -HW), y: p.y - HH / 2, dx: nx - p.x - p.vx * 0.06, dy: 0, ax: 0, t: 0 });
            p.x = nx;
            blocked = true;
            litX = 0.35;
          }
        }
      }
      // ② 세로
      chkY = [];
      p.vy = Math.min(p.vy + GR * H, 480);
      const prev = p.y;
      p.y += p.vy * H;
      const was = p.ground;
      p.ground = false;
      p.onSlope = '';
      if (p.vy >= 0) {
        const cc = Math.floor(p.x / T3);
        const r = Math.floor((p.y - 0.001) / T3);
        let best = Infinity;
        for (let rr2 = r - 1; rr2 <= r + 1; rr2++) {
          const ch = tile3(cc, rr2);
          if (!slope3(ch)) continue;
          const s = surf3(ch, cc, rr2, p.x);
          const d = s - p.y;
          if ((d <= 0 && d > -T3) || (was && d < 7)) {
            if (Math.abs(d) < Math.abs(best - p.y)) {
              best = s;
              p.onSlope = ch;
              p.slopeC = cc;
              p.slopeR = rr2;
            }
          }
        }
        if (best !== Infinity) {
          chkY.push([p.slopeC, p.slopeR, true]);
          p.y = best;
          p.vy = 0;
          p.ground = true;
        } else {
          const cL = Math.floor((p.x - HW + 0.01) / T3);
          const cR = Math.floor((p.x + HW - 0.01) / T3);
          for (let c = cL; c <= cR; c++) {
            const ch = tile3(c, r);
            const one = ch === '-' && prev <= r * T3 + 0.5 && p.drop <= 0;
            const s = ch === '#' || one;
            chkY.push([c, r, s]);
            if (s && !p.ground) {
              if (p.y - r * T3 > 0.6) pushes.push({ x: p.x, y: p.y, dx: 0, dy: r * T3 - p.y - 4, ax: 1, t: 0 });
              if (p.y - r * T3 > 0.6) litY = 0.35;
              if (!was && p.vy > 200) {
                p.sq = 0.4;
                for (let k = 0; k < 4; k++) dust.push({ x: p.x, y: r * T3, vx: (k - 1.5) * 18, vy: -10, life: 0.3, max: 0.3, size: 1.8, color: 'rgba(255,255,255,.85)', grav: 20 });
              }
              p.y = r * T3;
              p.vy = 0;
              p.ground = true;
            }
          }
        }
      } else {
        const r = Math.floor((p.y - HH) / T3);
        const cL = Math.floor((p.x - HW + 0.01) / T3);
        const cR = Math.floor((p.x + HW - 0.01) / T3);
        for (let c = cL; c <= cR; c++) {
          const s = tile3(c, r) === '#';
          chkY.push([c, r, s]);
          if (s && p.vy < 0) {
            const ny = (r + 1) * T3 + HH;
            pushes.push({ x: p.x, y: p.y - HH, dx: 0, dy: ny - p.y + 4, ax: 1, t: 0 });
            p.y = ny;
            p.vy = 0;
            litY = 0.35;
          }
        }
      }
      return void blocked;
    };
    const aiStep = (H: number): [boolean, boolean, boolean, boolean] => {
      let J = false;
      let D = false;
      if (p.ground) {
        const ahead = tile3(Math.floor((p.x + ai.dir * (HW + 3)) / T3), Math.floor((p.y - 5) / T3));
        if (ahead === '#') J = true;
        ai.cd -= H;
        if (ai.cd < 0) {
          J = true;
          ai.cd = 1.1 + ai.R() * 1.6;
        }
        if (tile3(Math.floor(p.x / T3), Math.floor((p.y + 1) / T3)) === '-' && ai.R() < 0.006) D = true;
      }
      if (Math.abs(p.vx) < 1 && p.ground) ai.stuck += H;
      else ai.stuck = 0;
      if (p.x < 26) ai.dir = 1;
      if (p.x > 254) ai.dir = -1;
      if (ai.stuck > 0.5) {
        ai.dir = -ai.dir;
        ai.stuck = 0;
      }
      return [ai.dir < 0, ai.dir > 0, J, D];
    };
    let manualT = -1;
    return {
      controls: [
        { type: 'toggle', label: '검사한 칸 · 밀어낸 화살표', value: true, on: (v) => (debug = v) },
        { type: 'range', label: '느리게 보기', min: 0.15, max: 1, step: 0.05, value: 1, on: (v) => (slow = v) },
      ] as Control[],
      draw(g, w, h, _t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        if (!mapC) mapC = buildMap();
        if (v.big && inp.idle() < 6) manualT = 1;
        else manualT = -1;
        step(dt * slow, (H) => {
          const a = manualT > 0 ? ([inp.key('ArrowLeft', 'KeyA'), inp.key('ArrowRight', 'KeyD'), inp.key('ArrowUp', 'Space', 'KeyW'), inp.key('ArrowDown', 'KeyS')] as [boolean, boolean, boolean, boolean]) : aiStep(H);
          physics(H, a[0], a[1], a[2], a[3]);
          for (const q of pushes) q.t += H;
          while (pushes.length && pushes[0]!.t > 0.45) pushes.shift();
          litX -= H;
          litY -= H;
          stepParts(dust, H);
        });
        // 배경
        fillBg(g, w, h, '#2a1f4e', '#5a3e7a');
        enter(g, v);
        const R = rng(3);
        g.fillStyle = 'rgba(255,255,255,.7)';
        for (let i = 0; i < 40; i++) {
          const s = 0.3 + R() * 0.7;
          g.globalAlpha = 0.3 + 0.5 * Math.abs(Math.sin(_t * (0.5 + R()) + i));
          g.fillRect(R() * 280, R() * 110, s, s);
        }
        g.globalAlpha = 1;
        g.fillStyle = '#3d2c63';
        g.beginPath();
        g.moveTo(0, 130);
        for (let x = 0; x <= 280; x += 10) g.lineTo(x, 92 - Math.abs(Math.sin(x * 0.03)) * 30 - Math.sin(x * 0.11) * 4);
        g.lineTo(280, 175);
        g.lineTo(0, 175);
        g.fill();
        g.save();
        g.translate(0, 4);
        g.drawImage(mapC, 0, 0, 280, 168);
        if (debug) {
          for (const [c, r, s] of chkX) {
            g.fillStyle = s ? 'rgba(255,140,40,.45)' : 'rgba(255,140,40,.12)';
            g.fillRect(c * T3, r * T3, T3, T3);
            g.strokeStyle = 'rgba(255,160,60,.9)';
            g.lineWidth = 0.7;
            g.strokeRect(c * T3 + 0.4, r * T3 + 0.4, T3 - 0.8, T3 - 0.8);
          }
          for (const [c, r, s] of chkY) {
            g.fillStyle = s ? 'rgba(40,210,255,.42)' : 'rgba(40,210,255,.1)';
            g.fillRect(c * T3, r * T3, T3, T3);
            g.strokeStyle = 'rgba(80,220,255,.9)';
            g.lineWidth = 0.7;
            g.strokeRect(c * T3 + 0.4, r * T3 + 0.4, T3 - 0.8, T3 - 0.8);
          }
          // 한쪽 발판 표시
          g.fillStyle = 'rgba(255,240,180,.75)';
          for (let r = 0; r < 12; r++)
            for (let c = 0; c < 20; c++)
              if (tile3(c, r) === '-' && (c + r) % 2 === 0) {
                const x = c * T3 + 7;
                const y = r * T3 + 11 + ((_t * 6) % 3);
                g.beginPath();
                g.moveTo(x, y - 3);
                g.lineTo(x + 2, y);
                g.lineTo(x - 2, y);
                g.fill();
              }
        }
        drawParts(g, dust);
        // 캐릭터
        const sq = p.sq;
        blob(g, p.x, p.y, 11 * (1 + sq * 0.5), 12 * (1 - sq * 0.45), p.face, '#ffc58a', '#ff7b54', 'ok', true);
        if (debug) {
          g.strokeStyle = '#fff';
          g.lineWidth = 0.8;
          g.setLineDash([2, 1.5]);
          g.strokeRect(p.x - HW, p.y - HH, HW * 2, HH);
          g.setLineDash([]);
          for (const q of pushes) {
            const k = 1 - q.t / 0.45;
            g.globalAlpha = k;
            if (q.ax === 0) arrow(g, q.x, q.y, q.x + Math.sign(q.dx) * 9, q.y, '#ffa040', 1.6, 4);
            else arrow(g, q.x, q.y, q.x, q.y + Math.sign(q.dy) * 9, '#40d8ff', 1.6, 4);
            g.globalAlpha = 1;
          }
          if (p.onSlope) {
            const n = p.onSlope === '/' ? [-1, -1] : [1, -1];
            const l = Math.SQRT1_2 * 12;
            arrow(g, p.x, p.y, p.x + n[0]! * l, p.y + n[1]! * l, '#b6ff6a', 1.3, 3.5);
          }
        }
        g.restore();
        // 범례
        const chip = (x: number, s: string, on: boolean, c: string): void => {
          g.globalAlpha = on ? 1 : 0.55;
          pill(g, s, x, 10, 7, on ? c : 'rgba(20,14,40,.75)', '#fff', 'left');
          g.globalAlpha = 1;
        };
        chip(18, '① 가로 이동 → 밀기', litX > 0, '#ff8a2a');
        chip(98, '② 세로 이동 → 밀기', litY > 0, '#16b6e8');
        chip(178, p.onSlope ? '경사: 발 높이 맞춤' : '경사 · 한쪽 발판', !!p.onSlope, '#5fbf3a');
        if (v.big) txt(g, manualT > 0 ? '← → 걷기 · ↑ 점프 · ↓ 발판 내려가기' : '글쇠(← → ↑ ↓)로 직접 움직여 보세요', 140, 171, 6, 'rgba(255,255,255,.7)', 'center', 600);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 다음에 해 볼 기술: [플랫폼 점프 손맛 (코요테 · 버퍼 · 가변 점프)](https://ai-techstudio.web.app/ai/t/i282.md) `i282` · [분리축 충돌 (SAT · 다각형)](https://ai-techstudio.web.app/ai/t/i284.md) `i284`
