# AI 꾸러미 — 기기 기울이기 · 흔들기 — DeviceMotionEvent.accelerationIncludingGravity
> 폰은 devicemotion 센서의 중력 방향으로 화면 속 중력을 바꾸고 흔들림을 받아, PC 는 끌기 · 방향키 · 빠른 손짓으로 같은 값을 만들어 준다.  
> 견본: https://ai-techstudio.web.app/#t/u51

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

## 주문서

### 만들어 줘: 기기 기울이기 · 흔들기 — DeviceMotionEvent.accelerationIncludingGravity

#### 1. 목표
구슬 굴리기 퍼즐에 기기 기울이기 · 흔들기를 넣어 줘 — 폰은 devicemotion 의 중력 방향으로 화면 속 중력을 바꾸고, 센서가 없는 PC 는 누른 채 끌기 · 방향키로 기울이고 빠르게 흔드는 손짓으로 흔들기. 세워 든 폰.

#### 2. 핵심 기술 용어
- **DeviceMotionEvent.accelerationIncludingGravity** — 중력을 포함한 기기 가속도 (m/s²)
- **Tilt control** — 기울이기 조작 — 중력 방향으로 굴리기
- **Shake detection** — 흔들기 — 가속도가 크게 튀는 것
- **DeviceMotionEvent.requestPermission (iOS)** — 아이폰은 누름 안에서 센서 허락 받기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 아이폰은 첫 누름 안에서 DeviceMotionEvent.requestPermission() — 몸짓 밖에서 부르면 막힌다
- 부호 맞추기: 안드로이드 −1/9.81, iOS +1/9.81 (세워 들면 (0, −1, 0) 이 되게)
- 센서가 1.5초 안 오면 PC 흉내 — 끌기 300px = 한껏 기울임, 방향키, 놓으면 기본 자세로
- 기울기는 목표로 부드럽게 따라가기 (한 번에 튀지 않게)
- PC 에서 쓰는 방법을 화면에 안내 (「누른 채 끌기 · 방향키」)

#### 5. 완성 기준 (이게 보이면 성공)
- 폰을 기울이면 화면 속 구슬이 그쪽으로 구르고 벽에 닿으면 튕긴다
- 흔들면 구슬이 튀고 「흔들!」 표시
- PC 에서 방향키 · 누른 채 끌기로 같은 기울기, 빠르게 문지르면 흔들기

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

## 원리
- accelerationIncludingGravity 는 m/s² — 9.81 로 나누면 g. 안드로이드 크롬은 유니티와 부호가 반대라 −1/9.81, 아이폰 사파리는 +1/9.81.
- 기울기 = 중력 방향. 화면 속 물체에 그 방향으로 힘을 준다 (견본: gx = sin(기울기) × 2.4).
- 센서가 1.5초 넘게 안 오면 PC 로 보고 흉내를 켠다: 누른 자리에서 끈 쪽(300px 이면 한껏) · 방향키로 기울기.
- 흔들기는 최근 손 자리 3개로 속도 두 개를 구해 그 차이 ÷ 시간 = 가속도, 8000 px/s² 를 1g 로.
- 목표 기울기로 부드럽게 따라간다 (프레임마다 차이의 1/6) — 진짜 폰처럼.

## 핵심 코드 — 센서 → g 단위 가속도 · PC 는 끌기 · 방향키 · 손짓 흔들기
(발췌: src/game/games/seven/engine/motion.ts Motion 을 줄임)
```ts
const PX_PER_G = 8000; // 화면 px/초² → g
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent);
let sensorAt = 0;
let accel = { x: 0, y: -1 };          // 결과 (세워 든 폰 = 0, −1)
const tilt = { x: 0, y: -1 };
const samples: { x: number; y: number; t: number }[] = []; // 누른 동안 손 자리

window.addEventListener('devicemotion', (e) => {
  const a = e.accelerationIncludingGravity;
  if (!a || a.x == null || a.y == null) return;
  sensorAt = performance.now();
  const k = ios ? 1 / 9.81 : -1 / 9.81; // 브라우저마다 부호가 반대
  accel = { x: a.x * k, y: a.y * k };
});

// 첫 누름 안에서 — 아이폰 허락
const askPermission = (): void => {
  const D = window.DeviceMotionEvent as unknown as { requestPermission?: () => Promise<string> };
  if (D?.requestPermission) void D.requestPermission().catch(() => undefined);
};

function update(held: boolean, start: { x: number; y: number } | null, keys: Set<string>): void {
  if (performance.now() - sensorAt < 1500) return; // 센서가 있으면 흉내는 쉰다
  let ax = 0, ay = 0;
  if (held && samples.length >= 3) { // 흔들기: 속도 두 개의 차이 ÷ 시간
    const [a, b, c] = samples.slice(-3);
    const t1 = Math.max(1e-3, b.t - a.t), t2 = Math.max(1e-3, c.t - b.t);
    ax = ((c.x - b.x) / t2 - (b.x - a.x) / t1) / ((t1 + t2) / 2) / PX_PER_G;
    ay = ((c.y - b.y) / t2 - (b.y - a.y) / t1) / ((t1 + t2) / 2) / PX_PER_G;
  }
  let want = { x: 0, y: -1 };
  const last = samples[samples.length - 1];
  if (held && start && last) { // 끈 방향 (300px 이면 한껏)
    const dx = (last.x - start.x) / 300, dy = (last.y - start.y) / 300, len = Math.hypot(dx, dy);
    if (len > 0.05) want = { x: dx / Math.max(1, len), y: dy / Math.max(1, len) };
  }
  const kx = (keys.has('ArrowRight') ? 1 : 0) - (keys.has('ArrowLeft') ? 1 : 0);
  const ky = (keys.has('ArrowUp') ? 1 : 0) - (keys.has('ArrowDown') ? 1 : 0);
  if (kx || ky) want = { x: kx / Math.hypot(kx, ky), y: ky / Math.hypot(kx, ky) };
  tilt.x += (want.x - tilt.x) / 6; // 부드럽게 따라가기
  tilt.y += (want.y - tilt.y) / 6;
  accel = { x: tilt.x + ax, y: tilt.y + ay };
}
```

## 흔한 실수 · 확인 목록
- [ ] **아이폰에서 아무 값도 안 온다** — iOS 13+ 는 DeviceMotionEvent.requestPermission() 을 사용자 누름 안에서 불러야 센서가 켜진다.
- [ ] **브라우저마다 가속도 부호가 반대라 구슬이 거꾸로 구른다** — 안드로이드 크롬은 −1/9.81, 아이폰 사파리는 +1/9.81 로 맞춘다 (SEVEN 이식에서 겪음).
- [ ] **PC 에는 센서가 없어 기울이기 레벨을 못 깬다** — 누른 채 끌기 · 방향키 · 빠르게 문지르기로 같은 값을 만든다. 레벨마다 기본 자세(세움/눕힘)도 정한다.
- [ ] **센서 값을 바로 쓰면 손 떨림으로 화면이 덜덜 떤다** — 목표 값으로 프레임마다 조금씩 따라가게(1/6) 부드럽게.

## 완성 기준 체크리스트
- [ ] 폰을 기울이면 화면 속 구슬이 그쪽으로 구르고 벽에 닿으면 튕긴다
- [ ] 흔들면 구슬이 튀고 「흔들!」 표시
- [ ] PC 에서 방향키 · 누른 채 끌기로 같은 기울기, 빠르게 문지르면 흔들기

## 이 기술 정보
- id: `u51` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 센서 이벤트는 초당 60번쯤 · 계산 몇 줄. 배터리 영향 작음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u51
- 쓰면 좋을 때: 폰을 기울여 굴리는 퍼즐 · 체험 / 원작(유니티)이 Input.acceleration 을 쓰는 게임을 옮길 때
- 쓰지 말 때: PC 가 주 사용자인 게임에 센서만 — 반드시 PC 대체 조작을 함께 / 가로 고정 화면에서 x · y 를 그대로 쓰기 — 화면 방향(screen.orientation.angle)에 맞게 돌려야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u51 견본 항목 — `src/demos/demosSim.ts:3174`
```ts
  u51: {
    kind: '2d',
    caption: '폰을 기울이면 화면 속 중력 방향이 바뀌어 구슬이 굴러요 · 흔들면 튀어요 — PC 는 방향키 · 누른 채 끌기로 같은 값을 줘요',
    make() {
      const balls = [0, 1, 2].map((i) => ({ x: 0.3 + i * 0.2, y: 0.5, vx: 0, vy: 0, c: ['#ff5a7a', '#ffd23a', '#5ab8ff'][i]! }));
      let shakeK = 1;
      return {
        draw(g, w, h, t, dt) {
          dt = Math.min(dt, 0.05);
          bg(g, w, h, '#cfeaff', '#9fd0f6');
          const cyc = t % 7;
          const shaking = cyc > 5.6 && cyc < 6.5;
          const a = Math.sin(t * 0.9) * 0.5 * (shaking ? 0.2 : 1);
          const jx = shaking ? Math.sin(t * 60) * 6 * shakeK : 0;
          const jy = shaking ? Math.cos(t * 47) * 4 * shakeK : 0;
          // 공 물리 — 폰 좌표 (0..1)
          const G = 2.4;
          const gx = Math.sin(a) * G;
          const gy = Math.cos(a) * G;
          const asp = 0.5;
          for (const b of balls) {
            b.vx += gx * dt;
            b.vy += gy * dt * asp;
            if (shaking && Math.random() < 0.25) {
              b.vx += (Math.random() - 0.5) * 3 * shakeK;
              b.vy += (Math.random() - 0.8) * 2 * shakeK;
            }
            b.vx *= 0.995;
            b.vy *= 0.995;
            b.x += b.vx * dt;
            b.y += b.vy * dt;
            const rx = 0.09;
            const ry = rx * asp * 1.0;
            if (b.x < rx) (b.x = rx), (b.vx = -b.vx * 0.5);
            if (b.x > 1 - rx) (b.x = 1 - rx), (b.vx = -b.vx * 0.5);
            if (b.y < ry) (b.y = ry), (b.vy = -b.vy * 0.5);
            if (b.y > 1 - ry) (b.y = 1 - ry), (b.vy = -b.vy * 0.5);
          }
          for (let i = 0; i < balls.length; i++)
            for (let j = i + 1; j < balls.length; j++) {
              const A = balls[i]!;
              const B = balls[j]!;
              const dx = B.x - A.x;
              const dy = (B.y - A.y) / asp;
              const d = Math.hypot(dx, dy);
              if (d < 0.18 && d > 1e-4) {
                const p = (0.18 - d) / 2 / d;
                A.x -= dx * p;
                B.x += dx * p;
                A.y -= dy * p * asp;
                B.y += dy * p * asp;
              }
            }
          // 폰
          const cx = w * 0.32 + jx;
          const cy = h * 0.52 + jy;
          const ph = Math.min(h * 0.8, w * 0.32 * 2);
          const pw = ph * 0.5;
          g.save();
          g.translate(cx, cy);
          g.rotate(a);
          g.fillStyle = 'rgba(0,0,0,0.15)';
          g.beginPath();
          g.roundRect(-pw / 2 + 6, -ph / 2 + 8, pw, ph, pw * 0.16);
          g.fill();
          g.fillStyle = '#20243a';
          g.beginPath();
          g.roundRect(-pw / 2, -ph / 2, pw, ph, pw * 0.16);
          g.fill();
          const sx = -pw / 2 + pw * 0.07;
          const sy = -ph / 2 + ph * 0.06;
          const sw = pw * 0.86;
          const sh = ph * 0.88;
          const sg = g.createLinearGradient(0, sy, 0, sy + sh);
          sg.addColorStop(0, '#fff6dc');
          sg.addColorStop(1, '#ffe0b0');
          g.fillStyle = sg;
          g.beginPath();
          g.roundRect(sx, sy, sw, sh, pw * 0.08);
          g.fill();
          // 중력 화살표 (화면 속)
          g.save();
          g.translate(0, 0);
          g.rotate(-a);
          g.strokeStyle = 'rgba(120,80,40,0.35)';
          g.fillStyle = 'rgba(120,80,40,0.35)';
          g.lineWidth = pw * 0.05;
          g.beginPath();
          g.moveTo(0, -sh * 0.1);
          g.lineTo(0, sh * 0.16);
          g.stroke();
          g.beginPath();
          g.moveTo(-pw * 0.08, sh * 0.13);
          g.lineTo(pw * 0.08, sh * 0.13);
          g.lineTo(0, sh * 0.24);
          g.fill();
          g.restore();
          for (const b of balls) {
            g.fillStyle = b.c;
            g.beginPath();
            g.arc(sx + b.x * sw, sy + b.y * sh, sw * 0.09, 0, TAU);
            g.fill();
            g.fillStyle = 'rgba(255,255,255,0.6)';
            g.beginPath();
            g.arc(sx + b.x * sw - sw * 0.03, sy + b.y * sh - sw * 0.03, sw * 0.03, 0, TAU);
            g.fill();
          }
          g.restore();
          if (shaking) text(g, '흔들!', cx, cy - ph * 0.58, clamp(h * 0.09, 12, 40), '#ff4a6a', 'center', TITLE, '400');
          // 오른쪽: PC 대신 조작
          const fs = clamp(h * 0.055, 9, 20);
          const rx0 = w * 0.62;
          text(g, `기울기 ${Math.round((a * 180) / Math.PI)}°`, rx0 + w * 0.15, h * 0.16, fs * 1.2, '#20304a', 'center', FONT, '800');
          text(g, 'PC 에서는', rx0 + w * 0.15, h * 0.33, fs, '#40507a', 'center');
          const kw = Math.min(w * 0.085, h * 0.16);
          const key = (x: number, y: number, s: string, on: boolean): void => {
            g.fillStyle = on ? '#ffd23a' : '#ffffff';
            g.strokeStyle = '#20304a';
            g.lineWidth = 2;
            g.beginPath();
            g.roundRect(x - kw / 2, y - kw / 2, kw, kw, kw * 0.2);
            g.fill();
            g.stroke();
            text(g, s, x, y + 1, kw * 0.5, '#20304a', 'center', FONT, '900');
          };
          const ky = h * 0.5;
          const kx = rx0 + w * 0.15;
          key(kx - kw * 1.15, ky, '←', a < -0.08);
          key(kx, ky, '↓', false);
          key(kx + kw * 1.15, ky, '→', a > 0.08);
          // 마우스 끌기 그림
          const my = h * 0.78;
          const mx = kx + Math.sin(t * 0.9) * kw * 1.2;
          g.strokeStyle = 'rgba(32,48,74,0.5)';
          g.setLineDash([4, 4]);
          g.beginPath();
          g.moveTo(kx - kw * 1.3, my);
          g.lineTo(kx + kw * 1.3, my);
          g.stroke();
          g.setLineDash([]);
          g.fillStyle = '#fff';
          g.strokeStyle = '#20304a';
          g.beginPath();
          g.roundRect(mx - kw * 0.3, my - kw * 0.45, kw * 0.6, kw * 0.9, kw * 0.3);
          g.fill();
          g.stroke();
          g.beginPath();
          g.moveTo(mx, my - kw * 0.45);
          g.lineTo(mx, my - kw * 0.1);
          g.stroke();
          text(g, '누른 채 끌기', kx, my + kw * 0.75, fs * 0.85, '#40507a', 'center');
        },
        controls: [{ type: 'range', label: '흔들기 세기', min: 0, max: 3, step: 0.1, value: 1, on: (v) => (shakeK = v) }],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [포인터 끌기 · 3D 고르기](https://ai-techstudio.web.app/ai/t/u79.md) `u79`
- 다음에 해 볼 기술: [두 손가락 몸짓 (확대 · 회전)](https://ai-techstudio.web.app/ai/t/i422.md) `i422`
- 참고 문서: [MDN — DeviceMotionEvent](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent)
