# AI 꾸러미 — 화면 방향 고정 · 노치 피하기 — Screen Orientation API (screen.orientation.lock)
> 폰에서 게임을 열 때 전체 화면 + 화면 방향 고정을 걸고, 노치는 safe-area 여백으로 피하고, 높이는 주소창에 맞춰 줄어드는 100dvh 로 잡는다.  
> 견본: https://ai-techstudio.web.app/#t/u71

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

## 주문서

### 만들어 줘: 화면 방향 고정 · 노치 피하기 — Screen Orientation API (screen.orientation.lock)

#### 1. 목표
가로 게임 화면을 폰에서 앱처럼 보이게 해 줘 — 열 때 전체 화면 + 가로 방향 고정, 막힌 브라우저는 「돌려 주세요」 안내, 노치를 피한 HUD, 주소창에 안 잘리는 높이.

#### 2. 핵심 기술 용어
- **Screen Orientation API (screen.orientation.lock)** — 화면 방향 고정 (전체 화면일 때만)
- **env(safe-area-inset-*) + viewport-fit=cover** — 노치 · 둥근 모서리를 피하는 여백
- **Dynamic viewport units (100dvh)** — 주소창이 생기고 사라져도 맞는 높이
- **Fullscreen API (requestFullscreen)** — 전체 화면 들어가기

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

#### 4. 조건
- lock 은 사용자 누름 처리 안에서, requestFullscreen 다음에 — 실패는 try/catch 로 조용히
- 고정을 못 했을 때를 위해 방향이 틀리면 보이는 안내 화면을 꼭 둔다
- viewport-fit=cover 와 env(safe-area-inset-*) 를 함께 (하나만으로는 안 된다)
- 높이는 100dvh, 게임을 닫으면 unlock · 전체 화면 끝내기

#### 5. 완성 기준 (이게 보이면 성공)
- 안드로이드 크롬에서 게임을 열면 전체 화면 + 가로로 고정된다
- 아이폰에서 세로로 들면 「돌려 주세요」 안내가 보이고, 돌리면 사라진다
- 노치 폰에서 HUD 점수 · 메뉴 단추가 노치에 가리지 않는다
- 주소창이 나타나도 아래 「시작」 단추가 잘리지 않는다

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

## 원리
- 방향 고정(screen.orientation.lock)은 대부분 전체 화면일 때만 된다 — 게임을 여는 누름 안에서 requestFullscreen 다음에 부른다.
- 아이폰 사파리처럼 막힌 곳은 실패를 조용히 받고, 방향이 틀리면 「세워 주세요 / 돌려 주세요」 안내 화면이 대신한다.
- meta viewport 에 viewport-fit=cover 를 넣어야 env(safe-area-inset-top) 같은 값이 생긴다. HUD 는 max(12px, env(…)) 만큼 안으로.
- 100vh 는 주소창이 숨은 높이라 주소창이 보이면 아래가 잘린다. 100dvh 는 지금 보이는 높이라 딱 맞는다.

## 핵심 코드 — 열 때 전체 화면 + 방향 고정, 닫을 때 풀기
(발췌: shell/components/GameOverlay.ts lockLandscape() · unlockLandscape() (견본 demos/demosDraw2d.ts u71 은 세 가지를 그림으로))
```ts
// index.html: <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
// CSS: .hud { padding-top: max(12px, env(safe-area-inset-top)); }  .app { height: 100dvh; }

let lockedByUs = false;

/** 게임 여는 단추의 click 안에서 부른다 (사용자 동작이어야 막히지 않는다) */
async function lockLandscape(portrait = false): Promise<void> {
  const phone = matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) <= 900;
  const orientation = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> };
  if (!phone || !orientation?.lock || !document.documentElement.requestFullscreen) return;
  try {
    if (!document.fullscreenElement) {
      await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
      lockedByUs = true;
    }
    await orientation.lock(portrait ? 'portrait' : 'landscape');
  } catch {
    /* 막힌 브라우저(아이폰 사파리 등) — 「돌려 주세요」 안내가 대신한다 */
  }
}

async function unlockLandscape(): Promise<void> {
  try {
    screen.orientation?.unlock?.();
    if (lockedByUs && document.fullscreenElement) await document.exitFullscreen();
  } catch { /* 이미 풀렸다 */ }
  lockedByUs = false;
}
```

## 흔한 실수 · 확인 목록
- [ ] **lock 만 부르면 전체 화면이 아니라서 실패한다** — 안드로이드 크롬 등은 전체 화면일 때만 고정된다 — requestFullscreen 을 먼저.
- [ ] **아이폰 사파리는 둘 다 막혀 있다** — 실패를 조용히 받고, 방향이 틀리면 보이는 안내 화면을 둔다.
- [ ] **viewport-fit=cover 없이 env(safe-area-inset-top) 을 쓰면 늘 0 이다** — meta viewport 에 viewport-fit=cover 를 넣는다.
- [ ] **100vh 로 높이를 잡으면 주소창이 보일 때 아래 단추가 잘린다** — 100dvh 를 쓴다.

## 완성 기준 체크리스트
- [ ] 안드로이드 크롬에서 게임을 열면 전체 화면 + 가로로 고정된다
- [ ] 아이폰에서 세로로 들면 「돌려 주세요」 안내가 보이고, 돌리면 사라진다
- [ ] 노치 폰에서 HUD 점수 · 메뉴 단추가 노치에 가리지 않는다
- [ ] 주소창이 나타나도 아래 「시작」 단추가 잘리지 않는다

## 이 기술 정보
- id: `u71` · 분류: 2D · 화면 › CSS · 화면 틀 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 설정 몇 줄. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u71
- 쓰면 좋을 때: 폰에서 가로(또는 세로)로만 놀 수 있는 게임 / 화면 위 · 아래 끝에 HUD · 단추가 붙은 화면
- 쓰지 말 때: 페이지가 열리자마자 lock 부르기 — 사용자 누름 밖이면 막힌다. 게임 여는 단추에서 / PC 에서 전체 화면 강제 — 폰(거친 포인터 · 짧은 변 900 이하)에서만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u71 견본 항목 — `src/demos/demosDraw2d.ts:1805`
```ts
  u71: {
    kind: '2d',
    caption: '① 폰을 눕히면 「세워 주세요」(세로 고정) ② 노치를 피해 HUD 내리기(safe-area) ③ 주소창이 생겨도 100dvh 는 딱 맞음',
    make() {
      const phone = (g: G, cx: number, cy: number, pw: number, ph: number, u: number, notch = true): void => {
        g.fillStyle = '#0b0d18';
        rr(g, cx - pw / 2 - 3 * u, cy - ph / 2 - 3 * u, pw + 6 * u, ph + 6 * u, 8 * u);
        g.fill();
        g.strokeStyle = '#59607e';
        g.lineWidth = 1.2 * u;
        g.stroke();
        if (notch) {
          g.fillStyle = '#0b0d18';
        }
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#2a3a6e', '#121a3a');
          const cw = w / 3;
          const ph = Math.min(h * 0.6, cw * 0.88);
          const pw = ph * 0.48;
          const cy = h * 0.46;
          const labels = ['방향 고정', '노치 피하기', '100dvh'];
          // ① 방향
          {
            const cx = cw / 2;
            const a = (Math.PI / 2) * swing(t, 0.8, 1.6);
            g.save();
            g.translate(cx, cy);
            g.rotate(-a);
            phone(g, 0, 0, pw, ph, u);
            g.save();
            rr(g, -pw / 2, -ph / 2, pw, ph, 5 * u);
            g.clip();
            const gr = g.createLinearGradient(0, -ph / 2, 0, ph / 2);
            gr.addColorStop(0, '#8fd3ff');
            gr.addColorStop(1, '#ffe2f0');
            g.fillStyle = gr;
            g.fillRect(-pw / 2, -ph / 2, pw, ph);
            g.font = `${pw * 0.6}px ${TF}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillStyle = '#ff6fa8';
            g.fillText('7', 0, -ph * 0.05);
            g.fillStyle = '#7b4dff';
            for (let i = 0; i < 3; i++) g.fillRect(-pw * 0.35 + i * pw * 0.26, ph * 0.28, pw * 0.18, pw * 0.18);
            g.restore();
            g.restore();
            if (a > 1.1) {
              const al = clamp01((a - 1.1) / 0.4);
              g.globalAlpha = al;
              g.fillStyle = 'rgba(10,14,40,.82)';
              rr(g, cx - ph / 2, cy - pw / 2, ph, pw, 5 * u);
              g.fill();
              txt(g, '↻', cx, cy - pw * 0.14, 16 * u, '#ffd23f', 'center', F, 900);
              txt(g, '세워 주세요', cx, cy + pw * 0.22, 7.5 * u, '#fff');
              g.globalAlpha = 1;
            }
          }
          // ② 노치
          {
            const cx = cw * 1.5;
            const on = Math.floor(t / 2.2) % 2 === 1;
            phone(g, cx, cy, pw, ph, u);
            const x0 = cx - pw / 2;
            const y0 = cy - ph / 2;
            g.save();
            rr(g, x0, y0, pw, ph, 5 * u);
            g.clip();
            g.fillStyle = '#eaf3ff';
            g.fillRect(x0, y0, pw, ph);
            const safe = ph * 0.09;
            if (on) {
              g.fillStyle = 'rgba(47,191,113,.3)';
              g.fillRect(x0, y0, pw, safe);
              g.fillRect(x0, y0 + ph - safe * 0.6, pw, safe * 0.6);
            }
            const hy = on ? y0 + safe : y0;
            g.fillStyle = '#1c2766';
            g.fillRect(x0, hy, pw, ph * 0.1);
            txt(g, '★ 120', x0 + pw * 0.08, hy + ph * 0.05, 6 * u, '#ffe28a', 'left', F, 900);
            txt(g, '☰', x0 + pw * 0.86, hy + ph * 0.05, 6.5 * u, '#fff', 'center', F, 900);
            g.fillStyle = '#7cc25a';
            rr(g, x0 + pw * 0.1, y0 + ph * 0.3, pw * 0.8, pw * 0.8, 4 * u);
            g.fill();
            // 노치
            g.fillStyle = '#000';
            rr(g, cx - pw * 0.2, y0 - 2 * u, pw * 0.4, safe * 0.7 + 2 * u, safe * 0.35);
            g.fill();
            g.fillStyle = '#333';
            rr(g, cx - pw * 0.18, y0 + ph - safe * 0.32, pw * 0.36, 2 * u, u);
            g.fill();
            g.restore();
            if (!on) {
              g.strokeStyle = '#ff3c50';
              g.lineWidth = 1.6 * u;
              g.beginPath();
              g.arc(cx, y0 + safe * 0.35, pw * 0.3, 0, Math.PI * 2);
              g.stroke();
            }
            pill(g, on ? 'safe-area 켬' : '끔 — 가려짐', cx, cy - ph / 2 - 10 * u, 7 * u, on ? '#2fbf71' : '#ff3c50');
          }
          // ③ dvh
          {
            const cx = cw * 2.5;
            const dvh = Math.floor(t / 3) % 2 === 1;
            const bar = ph * 0.13 * swing(t, 1.3, 1.4);
            phone(g, cx, cy, pw, ph, u);
            const x0 = cx - pw / 2;
            const y0 = cy - ph / 2;
            const boxH = dvh ? ph - bar : ph;
            // 넘친 부분 (화면 밖 유령)
            if (!dvh && bar > 1) {
              g.fillStyle = 'rgba(255,60,80,.35)';
              g.fillRect(x0, y0 + ph, pw, bar);
              g.strokeStyle = '#ff3c50';
              g.setLineDash([3 * u, 2 * u]);
              g.lineWidth = 1.2 * u;
              g.strokeRect(x0, y0 + ph, pw, bar);
              g.setLineDash([]);
            }
            g.save();
            rr(g, x0, y0, pw, ph, 5 * u);
            g.clip();
            g.fillStyle = dvh ? '#d9f7e6' : '#ffe0e4';
            g.fillRect(x0, y0 + bar, pw, boxH);
            g.fillStyle = dvh ? '#2fbf71' : '#ff6fa8';
            const by = y0 + bar + boxH - ph * 0.12;
            rr(g, x0 + pw * 0.15, by, pw * 0.7, ph * 0.08, ph * 0.04);
            g.fill();
            txt(g, '시작', cx, by + ph * 0.04, 6 * u, '#fff', 'center', F, 900);
            // 주소창
            g.fillStyle = '#f2f2f2';
            g.fillRect(x0, y0, pw, bar);
            if (bar > 6 * u) {
              g.fillStyle = '#ccc';
              rr(g, x0 + pw * 0.08, y0 + bar * 0.25, pw * 0.84, bar * 0.5, bar * 0.25);
              g.fill();
            }
            g.restore();
            pill(g, dvh ? 'height: 100dvh' : 'height: 100vh', cx, cy - ph / 2 - 10 * u, 7 * u, dvh ? '#2fbf71' : '#ff3c50');
          }
          for (let i = 0; i < 3; i++) txt(g, labels[i]!, cw * (i + 0.5), Math.min(h * 0.92, cy + ph / 2 + 22 * u), 9 * u, '#fff');
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [고정 크기 화면 확대](https://ai-techstudio.web.app/ai/t/u68.md) `u68`
- 다음에 해 볼 기술: [넘칠 때만 줄이기 (자동 축소)](https://ai-techstudio.web.app/ai/t/u70.md) `u70`
- 참고 문서: [MDN — ScreenOrientation.lock()](https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock) · [MDN — env()](https://developer.mozilla.org/en-US/docs/Web/CSS/env)
