# AI 꾸러미 — 다국어 (원문을 번역 키로) — Source-string-as-key i18n (gettext style)
> 한국어 문장 자체를 번역 열쇠로 써서 $t('시작하기') 가 언어별 사전에서 찾아 바꾸고, 빠진 번역은 원문 그대로 · 게임별 사전은 따로 나눠 불러온다.  
> 견본: https://ai-techstudio.web.app/#t/u74

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

## 주문서

### 만들어 줘: 다국어 (원문을 번역 키로) — Source-string-as-key i18n (gettext style)

#### 1. 목표
게임 안 단추 · 안내 글을 10개 언어로 바꿀 수 있게 해 줘 — 한국어 원문이 번역 열쇠($t('한국어')), 빠진 번역은 원문으로, 게임별 사전은 따로 나눠 그 언어일 때만 불러오기. 언어 단추로 바로 바꾸기.

#### 2. 핵심 기술 용어
- **Source-string-as-key i18n (gettext style)** — 원문이 번역 열쇠
- **Fallback to source text** — 번역이 없으면 원문 그대로
- **Lazy-loaded per-feature dictionaries (dynamic import)** — 게임별 사전을 그 언어일 때만 내려받기
- **Placeholder interpolation ({0} · {name})** — 문장 속 자리에 값 끼우기

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

#### 4. 조건
- 화면에 나오는 모든 글은 t() 를 거친다 (원문 한국어 그대로)
- 사전 불러오기는 하나씩 감싸서 — 하나가 빠져도 사이트 전체가 멈추지 않게
- 자리표시({0} · {name})로 값을 끼우고, 글자 이어 붙이기로 문장을 만들지 않는다
- 언어별 글꼴(일본어 · 번체 중국어)을 그 언어일 때만 더한다

#### 5. 완성 기준 (이게 보이면 성공)
- 주소에 ?lang=en 을 붙이면 같은 단추가 영어로 바뀌고, 10개 언어 모두 확인된다
- 사전에 없는 글은 한국어로 보이고 오류가 없다
- 게임 사전 파일 하나를 지워도 사이트는 열리고 그 게임 글만 한국어다
- 독일어처럼 긴 글에서 단추가 깨지지 않는다 (넘침 맞추기와 함께)

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

## 원리
- 코드에는 $t('시작하기') 처럼 한국어를 그대로 쓴다 — 열쇠 이름을 따로 짓지 않으니 코드만 봐도 무슨 글인지 안다.
- 언어별 사전은 { '시작하기': 'Start' } 꼴. t() 는 사전에 있으면 번역, 없으면 원문(한국어)을 돌려준다 — 빠져도 멈추지 않는다.
- 게임별 사전은 src/i18n/<언어>/<게임>.ts 로 나누고, 목록에 part('게임', () => import(…)) 한 줄. 한국어면 아무것도 안 받는다.
- 사전 하나가 빠지거나 못 받아도 try/catch 로 빈 사전을 돌려줘 그 게임 글만 한국어로 보인다.
- 문장 속 값은 {0} · {name} 자리에 끼운다 — 언어마다 어순이 달라도 자리만 옮기면 된다.

## 핵심 코드 — 원문 열쇠 t() · 게임별 사전 나눠 불러오기
(발췌: src/i18n/index.ts part() · t() · fill() 을 줄여 정리 (견본 demos/demosSystem.ts u74 는 흐름을 그림으로))
```ts
type Lang = 'ko' | 'en' | 'ja' | 'es' | 'pt' | 'zh' | 'fr' | 'de' | 'vi' | 'id';
declare const lang: Lang;                               // 주소 ?lang · 저장값 · 브라우저 언어로 정한 값
declare const BASE: Record<string, string>;             // 큰 사전

/** 사전 하나 — 빠지거나 못 받아도 빈 사전 (그 부분만 한국어) */
async function part(file: string, load: () => Promise<unknown>): Promise<Record<string, string>> {
  if (lang === 'ko') return {};
  try {
    const mod = (await load()) as Record<string, unknown>;
    const dict = Object.values(mod).find((v) => v && typeof v === 'object');   // 내보낸 이름에 기대지 않는다
    return (dict as Record<string, string> | undefined) ?? {};
  } catch (e) {
    console.warn('[i18n] ' + lang + '/' + file + '.ts 사전을 불러오지 못해 이 부분은 한국어로 보여요', e);
    return {};
  }
}

// 새 게임 = 한 줄. import() 경로 모양 그대로 써야 빌드가 언어별 파일을 묶는다
const PARTS = await Promise.all([
  part('abacus', () => import('./' + lang + '/abacus.ts')),
  part('seven', () => import('./' + lang + '/seven.ts')),
]);
const DICT: Record<string, string> = Object.assign({}, ...PARTS, BASE);

function fill(s: string, vars?: Record<string, unknown>): string {
  if (!vars) return s;
  return s.replace(/\{(\w+)\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m));
}

/** 한국어 원문 → 지금 언어 (없으면 원문) */
export function t(ko: string, vars?: Record<string, unknown>): string {
  if (lang === 'ko') return fill(ko, vars);
  return fill(DICT[ko] ?? ko, vars);
}
```

## 흔한 실수 · 확인 목록
- [ ] **사전 파일 하나가 커밋에서 빠져 외국어 사이트 전체가 「여는 중…」에서 멈췄다** — 사전마다 try/catch 로 감싸 빈 사전을 돌려준다 — 그 게임만 한국어로.
- [ ] **원문 한국어를 조금 고치면 번역이 끊긴다** — 열쇠가 원문이라 원문을 고치면 사전 열쇠도 같이 고친다. 글이 굳은 뒤 한 번에 번역한다.
- [ ] **import() 경로를 변수로 만들면 빌드가 언어별 파일을 못 묶는다** — 경로 모양('./' + 언어 + '/파일.ts')을 그대로 쓴다 — 빌드는 그 모양을 보고 파일을 모은다.
- [ ] **웹 워커(AI)에서 사전을 그대로 묶으면 외국어 방문자가 사전을 두 번 받는다** — 워커용으로는 원문을 그대로 돌려주는 가벼운 t() 를 따로 둔다 (i18n/worker.ts).

## 완성 기준 체크리스트
- [ ] 주소에 ?lang=en 을 붙이면 같은 단추가 영어로 바뀌고, 10개 언어 모두 확인된다
- [ ] 사전에 없는 글은 한국어로 보이고 오류가 없다
- [ ] 게임 사전 파일 하나를 지워도 사이트는 열리고 그 게임 글만 한국어다
- [ ] 독일어처럼 긴 글에서 단추가 깨지지 않는다 (넘침 맞추기와 함께)

## 이 기술 정보
- id: `u74` · 분류: 2D · 화면 › CSS · 화면 틀 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 사전 찾기는 객체 한 번. 외국어 방문자만 그 언어 사전을 받는다 (한국어는 0).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u74
- 쓰면 좋을 때: 글이 많은 게임 · 사이트를 여러 언어로 / 여러 사람(세션)이 동시에 번역을 넣어야 할 때 — 게임별 파일로 부딪힘이 적다
- 쓰지 말 때: 글자를 이어 붙여 문장 만들기('점수: ' + n + '점') — 어순이 다른 언어에서 깨진다. {0} 자리표시로 / 그림 안에 글씨 넣기 — 번역이 안 된다. 글은 코드로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u74 견본 항목 — `src/demos/demosSystem.ts:548`
```ts
  u74: {
    kind: '2d',
    caption: "한국어 글 '시작하기' 가 열쇠 — 사전을 거쳐 같은 단추가 10개 언어로 바뀌어요",
    make() {
      let auto = true;
      let idx = 0;
      let last = 0;
      let bump = 0;
      return {
        draw(g, w, h, t, dt) {
          stage(g, w, h, SKY);
          if (auto && t - last > 1.4) {
            last = t;
            idx = (idx + 1) % LANGS.length;
            bump = 1;
          }
          bump = Math.max(0, bump - dt * 3);
          const [code, word] = LANGS[idx]!;
          // 열쇠
          box(g, 14, 16, 128, 26, 8, '#22305e');
          txt(g, "$t('시작하기')", 78, 29.5, 11, '#ffe28a', 'center', 700, MONO);
          arrow(g, 144, 29, 170, 29, '#3a4f8c', 2);
          // 사전 책
          box(g, 174, 12, 132, 36, 6, '#ff8a6b', '#c95a3a', 2);
          box(g, 180, 16, 120, 28, 4, '#fff6ec');
          txt(g, `사전 ko → ${code}`, 240, 25, 8.5, '#a04a2a', 'center', 800);
          txt(g, `'시작하기': '${word}'`, 240, 37, 8.5, '#3a2a1a', 'center', 700, F);
          arrow(g, 240, 50, 200, 66, '#3a4f8c', 2);
          // 큰 단추
          const s = 1 + bump * 0.12;
          g.save();
          g.translate(160, 96);
          g.scale(s, s);
          const gr = g.createLinearGradient(0, -24, 0, 24);
          gr.addColorStop(0, '#ffe066');
          gr.addColorStop(1, '#ffb22e');
          box(g, -88, -22, 176, 48, 24, '#d98a10');
          box(g, -88, -26, 176, 48, 24, gr, '#d98a10', 2);
          txtFit(g, word, 0, -1, 24, 150, '#5a3200', 800, `${F}`);
          g.restore();
          // 언어 칸
          LANGS.forEach(([c], j) => {
            const x = 16 + j * 29.2;
            const on = j === idx;
            box(g, x, 140, 25, 22, 6, on ? '#5b6cff' : '#ffffff', on ? '#3a49d8' : '#c8d6ee', 1.5);
            txt(g, c, x + 12.5, 151.5, 9.5, on ? '#fff' : '#5a6890', 'center', 800);
          });
          txt(g, '한국어 글 = 번역 열쇠 · 게임별 사전 · 일꾼 안에서도', 160, 182, 9.5, '#3a4f8c', 'center', 700);
        },
        controls: [
          {
            type: 'button',
            label: '다음 언어',
            on: () => {
              idx = (idx + 1) % LANGS.length;
              bump = 1;
            },
          },
          { type: 'toggle', label: '저절로 넘기기', value: true, on: (v) => (auto = v) },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [테마 갈아입히기 (공통 틀 + CSS 변수 옷)](https://ai-techstudio.web.app/ai/t/u61.md) `u61`
- 다음에 해 볼 기술: [넘칠 때만 줄이기 (자동 축소)](https://ai-techstudio.web.app/ai/t/u70.md) `u70`
- 참고 문서: [Godot — Internationalizing games](https://docs.godotengine.org/en/stable/tutorials/i18n/internationalizing_games.html)
