From 0fc7b90a4442155f70903d14b020cd7372de98c7 Mon Sep 17 00:00:00 2001 From: rocknroll17 <53882578+rocknroll17@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:48:49 +0000 Subject: [PATCH] feat(i18n): Korean/English support for the browser demo - docs/i18n.js: dictionary-based i18n (t(), data-i18n, KO/EN toggle, localStorage persistence, browser-language default) - docs/index.html: data-i18n keys on all static text, lang toggle button - docs/game.js: dynamic messages via t(); win/lose overlay now keyed on winner index instead of Korean message substring --- docs/game.js | 107 ++++++++++++++++++---------------- docs/i18n.js | 151 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.html | 104 ++++++++++++++++++--------------- 3 files changed, 266 insertions(+), 96 deletions(-) create mode 100644 docs/i18n.js diff --git a/docs/game.js b/docs/game.js index bd7bd38..70b4771 100644 --- a/docs/game.js +++ b/docs/game.js @@ -63,6 +63,7 @@ const elements = { // `localBus` (an EventTarget) so the unchanged SSE handler bodies below run as-is. const DV = window.DVEngine; +const t = window.I18N.t; // Fresh EventTarget per game (re-created in connectSSE) so a "새 게임" restart // doesn't accumulate duplicate handlers — matching the original fresh-EventSource. let localBus = new EventTarget(); @@ -93,7 +94,7 @@ let session = null; // current LocalSession class LocalSession { constructor(useModel) { this.useModel = useModel; - this.message = '플레이어 대기 중...'; + this.message = t('waitingPlayers'); // human = players[0] (first), ai = players[1] this.human = DV.makeHumanPlayer('human'); this.ai = DV.makeAIPlayer('ai', useModel); @@ -107,9 +108,9 @@ class LocalSession { start() { DV.engineSetup(this.engine); - this.message = '검정 또는 흰색 카드를 뽑으세요.'; + this.message = t('drawPrompt'); // game_start (emit_to_all) — fired after a tick like the server - emitEvent('game_start', { message: '게임이 시작되었습니다!', current_player: 0 }); + emitEvent('game_start', { message: t('gameStarted'), current_player: 0 }); } // ===== _build_state (game_session.py) for the human player ===== @@ -157,14 +158,14 @@ class LocalSession { let message; if (e.game_over) { message = (e.winner && e.winner.id === player.id) - ? '🎉 축하합니다! 당신이 승리했습니다!' - : '😢 아쉽습니다. 상대방이 승리했습니다.'; + ? t('winMsg') + : t('loseMsg'); } else if (forActor) { message = this.message; } else if (myTurn) { message = this.message; } else { - message = '⏳ 상대방의 차례입니다. 기다려주세요.'; + message = t('waitOpponent'); } return { @@ -192,8 +193,8 @@ class LocalSession { const result = DV.engineDraw(e, this.human.id, color); const valid = result.pending_card.valid_positions; this.message = (valid.length !== 1) - ? `카드를 배치할 위치를 선택하세요. (${valid.length}곳 가능)` - : '카드가 자동으로 배치됩니다.'; + ? t('choosePlace', { n: valid.length }) + : t('autoPlace'); const state = this.buildState(true); // DrawEmitter: my_action only emitEvent('my_action', { @@ -212,7 +213,7 @@ class LocalSession { humanPlace(color, number, position) { const e = this.engine; const result = DV.enginePlace(e, this.human.id, color, number, position); - this.message = '상대방 카드를 추측하세요.'; + this.message = t('guessPrompt'); const state = this.buildState(true); // PlaceEmitter: my_action (actor) emitEvent('my_action', { @@ -227,15 +228,14 @@ class LocalSession { humanGuess(position, value) { const e = this.engine; const result = DV.engineGuess(e, this.human.id, position, value); - const valueStr = value === 12 ? '조커' : String(value); if (result.is_correct) { this.message = e.game_over - ? '🎉 정답! 게임 종료! 당신이 승리했습니다!' - : '✅ 정답! 계속 추측하시겠습니까?'; + ? t('correctWinEnd') + : t('correctContinue'); } else { this.message = e.game_over - ? '❌ 틀렸습니다! 카드가 모두 공개되어 게임이 종료됩니다.' - : '❌ 틀렸습니다! 상대방 차례입니다.'; + ? t('wrongGameOver') + : t('wrongOppTurn'); } const state = this.buildState(true); @@ -265,9 +265,9 @@ class LocalSession { const winnerId = e.winner.id; setTimeout(() => { if (winnerId === this.human.id) { - emitEvent('game_over', { winner: e.winner.player_index, message: '🎉 축하합니다! 당신이 승리했습니다!' }); + emitEvent('game_over', { winner: e.winner.player_index, message: t('winMsg') }); } else { - emitEvent('game_over', { winner: e.winner.player_index, message: '😢 아쉽습니다. 다음에 다시 도전하세요!' }); + emitEvent('game_over', { winner: e.winner.player_index, message: t('loseRetry') }); } }, 2000); } else if (!result.is_correct) { @@ -283,7 +283,7 @@ class LocalSession { humanDecision(continueGuessing) { const e = this.engine; DV.engineDecision(e, this.human.id, continueGuessing); - this.message = continueGuessing ? '🎯 계속 추측하세요!' : '턴을 종료했습니다.'; + this.message = continueGuessing ? t('keepGuessing') : t('turnEnded'); const state = this.buildState(true); emitEvent('my_action', { action: 'decision', @@ -343,12 +343,12 @@ class LocalSession { const position = valid.length === 1 ? valid[0] : valid[Math.floor(Math.random() * valid.length)]; const placeResult = DV.enginePlace(e, ai.id, color, pending.value, position); // PlaceEmitter.emit_to_opponent_only -> opponent_action (place) - const colorName = color === 0 ? '검정' : '흰색'; + const colorName = color === 0 ? t('black') : t('white'); emitEvent('opponent_action', { action: 'place', color: placeResult.placed_card.color, position: placeResult.position, - message: `상대방이 ${colorName} 카드를 위치 ${placeResult.position}에 배치했습니다.`, + message: t('oppPlaced', { color: colorName, pos: placeResult.position }), }); } else if (e.phase === DV.Phase.GUESS) { await this.aiGuess(ai); @@ -356,7 +356,7 @@ class LocalSession { const action = await DV.getAction(e, ai.id); const continueGuessing = action.decision === 1; DV.engineDecision(e, ai.id, continueGuessing); - const msg = continueGuessing ? '⏳ 상대방이 계속 추측합니다.' : '상대방이 턴을 종료했습니다.'; + const msg = continueGuessing ? t('oppContinues') : t('oppEndedTurn'); // DecisionEmitter.emit_to_opponent_only -> opponent_action (decision) emitEvent('opponent_action', { action: 'decision', continue: continueGuessing, message: msg }); if (!continueGuessing) { @@ -364,9 +364,7 @@ class LocalSession { setTimeout(() => { emitEvent('turn_change', { your_turn: true, - message: deckEmpty - ? '🎯 당신의 차례입니다! 상대방 카드를 추측하세요.' - : '🎯 당신의 차례입니다! 카드를 뽑으세요.', + message: deckEmpty ? t('yourTurnGuess') : t('yourTurnDraw'), }); }, 2000); } @@ -407,7 +405,7 @@ class LocalSession { await new Promise((resolve) => setTimeout(resolve, 800)); const result = DV.engineGuess(e, ai.id, position, value); - const valueStr = value === 12 ? '조커' : String(value); + const valueStr = value === 12 ? t('joker') : String(value); // GuessEmitter opponent_data (AI is actor -> human gets opponent_action) const oppData = { @@ -415,7 +413,7 @@ class LocalSession { position: result.position, value: value, correct: result.is_correct, - message: `상대방이 위치 ${result.position}을(를) ${valueStr}로 추측했습니다.`, + message: t('oppGuessed', { pos: result.position, val: valueStr }), }; if (result.is_correct && result.card) { oppData.revealed_position = result.position; @@ -433,10 +431,10 @@ class LocalSession { setTimeout(() => { if (winnerId === ai.id) { // AI won -> loser (human) gets defeat - emitEvent('game_over', { winner: e.winner.player_index, message: '😢 아쉽습니다. 다음에 다시 도전하세요!' }); + emitEvent('game_over', { winner: e.winner.player_index, message: t('loseRetry') }); } else { // AI lost -> human gets victory - emitEvent('game_over', { winner: e.winner.player_index, message: '🎉 축하합니다! 당신이 승리했습니다!' }); + emitEvent('game_over', { winner: e.winner.player_index, message: t('winMsg') }); } }, 2000); } else if (!result.is_correct) { @@ -444,9 +442,7 @@ class LocalSession { setTimeout(() => { emitEvent('turn_change', { your_turn: true, - message: deckEmpty - ? '🎯 당신의 차례입니다! 상대방 카드를 추측하세요.' - : '🎯 당신의 차례입니다! 카드를 뽑으세요.', + message: deckEmpty ? t('yourTurnGuess') : t('yourTurnDraw'), }); }, 2000); } @@ -494,10 +490,10 @@ async function localApi(endpoint, method, body) { return { game_id: 'pvp-disabled', player_id: 'pvp-disabled' }; } if (path === '/api/lobby/join') { - throw new Error('PvP는 이 페이지에서 지원되지 않습니다.'); + throw new Error(t('pvpUnsupported')); } if (path === '/api/game/state') { - if (!session) throw new Error('게임이 없습니다.'); + if (!session) throw new Error(t('noGame')); return session.buildState(false); } if (path === '/api/game/draw') { @@ -654,11 +650,11 @@ function connectSSE() { selectedGuessPosition = null; const position = data.position; if (data.correct) { - showMessage('✅ 정답!'); + showMessage(t('correctShort')); highlightOpponentCard(position, 'guessed-correct'); flipOpponentCard(position, data.value); } else { - showMessage('❌ 틀렸습니다!'); + showMessage(t('wrongShort')); shakeOpponentCard(position); if (data.revealed_card) { setTimeout(() => { flipMyCard(data.revealed_card.position); }, 500); @@ -716,7 +712,7 @@ async function createAIGame(useModel = true) { async function joinGame() { const inputGameId = elements.gameIdInput?.value?.trim(); - if (!inputGameId) { showMessage('⚠️ 게임 ID를 입력하세요'); return; } + if (!inputGameId) { showMessage(t('enterGameId')); return; } try { const result = await apiCall('/api/lobby/join', 'POST', { game_id: inputGameId }); if (result) { @@ -768,7 +764,7 @@ async function guess() { if (!gameState || !gameId || !playerId || isLoading) return; const position = selectedGuessPosition; const value = parseInt(elements.guessValue.value); - if (position === null || isNaN(value)) { showMessage('⚠️ 카드와 숫자를 선택하세요'); return; } + if (position === null || isNaN(value)) { showMessage(t('selectCardValue')); return; } if (elements.guessBtn) elements.guessBtn.disabled = true; try { const result = await apiCall('/api/game/guess', 'POST', { game_id: gameId, player_id: playerId, position, value }); @@ -898,7 +894,7 @@ function revealMyCard(position) { } function translatePhase(phase) { - const map = { waiting: '대기중', draw: '뽑기', guess: '추측', decision: '선택', place: '배치' }; + const map = { waiting: t('phaseWaiting'), draw: t('phaseDraw'), guess: t('phaseGuess'), decision: t('phaseDecision'), place: t('phasePlace') }; return map[phase] || phase; } @@ -972,7 +968,7 @@ function renderPlaceSlots() { const drawnCardPreview = document.createElement('div'); drawnCardPreview.className = 'drawn-card-preview'; drawnCardPreview.classList.add(pendingCard.color === 0 ? 'card-black' : 'card-white'); - drawnCardPreview.innerHTML = `뽑은 카드${pendingCard.value === 12 ? '-' : pendingCard.value}`; + drawnCardPreview.innerHTML = `${t('drawnCard')}${pendingCard.value === 12 ? '-' : pendingCard.value}`; elements.placeSlots.appendChild(drawnCardPreview); const separator = document.createElement('div'); separator.className = 'place-separator'; @@ -1042,46 +1038,48 @@ function preparePreShuffledDeck(black, white) { function updateGuessSelect() { if (!elements.guessValue) return; - elements.guessValue.innerHTML = ''; + elements.guessValue.innerHTML = ``; for (let i = 0; i <= 12; i++) { const opt = document.createElement('option'); opt.value = i; - opt.textContent = i === 12 ? '- 조커' : i; + opt.textContent = i === 12 ? t('jokerOption') : i; elements.guessValue.appendChild(opt); } } function showGameOver() { elements.gameOverOverlay?.classList.remove('hidden'); - if (gameState.message?.includes('승리')) { - elements.gameOverTitle.textContent = '🎉 승리!'; + // winner is the player_index (0 = human) — language-independent, unlike + // the old message-substring check which broke under i18n. + if (gameState.winner === 0) { + elements.gameOverTitle.textContent = t('victory'); elements.gameOverTitle.style.color = '#4ecca3'; } else { - elements.gameOverTitle.textContent = '💀 패배'; + elements.gameOverTitle.textContent = t('defeat'); elements.gameOverTitle.style.color = '#e94560'; } - elements.gameOverMessage.textContent = gameState.message || '게임 종료'; + elements.gameOverMessage.textContent = gameState.message || t('gameOverTitle'); } function showGameOverWithData(data) { elements.gameOverOverlay?.classList.remove('hidden'); - if (data.message?.includes('승리')) { - elements.gameOverTitle.textContent = '🎉 승리!'; + if (data.winner === 0) { + elements.gameOverTitle.textContent = t('victory'); elements.gameOverTitle.style.color = '#4ecca3'; } else { - elements.gameOverTitle.textContent = '💀 패배'; + elements.gameOverTitle.textContent = t('defeat'); elements.gameOverTitle.style.color = '#e94560'; } - elements.gameOverMessage.textContent = data.message || '게임 종료'; + elements.gameOverMessage.textContent = data.message || t('gameOverTitle'); showMessage(data.message); } function showDisconnectOverlay(message) { if (eventSource) { eventSource = null; } elements.gameOverOverlay?.classList.remove('hidden'); - elements.gameOverTitle.textContent = '🚪 상대방 퇴장'; + elements.gameOverTitle.textContent = t('oppLeftTitle'); elements.gameOverTitle.style.color = '#ffc107'; - elements.gameOverMessage.textContent = message || '상대방이 게임을 나갔습니다.'; + elements.gameOverMessage.textContent = message || t('oppLeftMsg'); showMessage(message); } @@ -1100,6 +1098,15 @@ document.getElementById('play-vs-random-btn')?.addEventListener('click', () => c elements.gameIdInput?.addEventListener('keypress', (e) => { if (e.key === 'Enter') joinGame(); }); +// Re-render language-dependent dynamic UI when the user toggles KO/EN. +// Static text is handled by i18n.js; this covers the phase badge, guess +// dropdown, and action panels built from JS. +window.addEventListener('dvc:langchange', () => { + // preserveMessage=false so the current game message (reset to the static + // start prompt by applyI18n) is restored from gameState. + if (gameState) updateUI(false, false); +}); + // ============== Title Cards ============== function initTitleCards() { diff --git a/docs/i18n.js b/docs/i18n.js new file mode 100644 index 0000000..4054c82 --- /dev/null +++ b/docs/i18n.js @@ -0,0 +1,151 @@ +/** + * i18n.js — tiny dictionary-based i18n for the static demo (no dependencies). + * + * Usage: + * - Static DOM text: add data-i18n="key" to the element; applyI18n() swaps + * innerHTML from the dictionary on load and on toggle. + * - Dynamic JS text: call I18N.t('key', {param: value}); '{param}' in the + * string is substituted. + * - Language: localStorage 'dvc_lang' > browser language; toggled by the + * #lang-toggle button. Toggling fires a 'dvc:langchange' event so game + * code can re-render dynamic UI. + * + * To add/change copy, edit ONLY the dictionary below — both languages live + * side by side per key so a missing translation is obvious at a glance. + */ +(function () { + 'use strict'; + + const STRINGS = { + // ---- static page (index.html data-i18n keys) ---- + docTitle: { ko: 'DaVinci Code — AI 대전', en: 'DaVinci Code — Play vs AI' }, + aiSection: { ko: '🤖 AI 대전', en: '🤖 Play vs AI' }, + playVsAi: { ko: 'AI와 대전하기', en: 'Play against the AI' }, + rulesTitle: { ko: '📖 게임 규칙', en: '📖 Rules' }, + rule1: { ko: '카드: 검정(0-11) + 하양(0-11) + 조커(-, 각 1장) = 26장', en: 'Cards: black (0-11) + white (0-11) + jokers (-, one each) = 26 cards' }, + rule2: { ko: '정렬: 카드는 항상 숫자 오름차순 (같으면 검정 우선)', en: 'Order: cards always sit in ascending order (black first on ties)' }, + rule3: { ko: '진행: 덱에서 카드를 뽑은 뒤, 상대 숨겨진 카드의 숫자를 추측', en: 'Play: draw a card, then guess the number on one of your opponent\'s hidden cards' }, + rule4: { ko: '성공: 맞히면 상대 카드 공개 + 계속 추측 가능', en: 'Hit: a correct guess reveals the card and you may keep guessing' }, + rule5: { ko: '실패: 틀리면 내가 뽑은 카드가 공개됨', en: 'Miss: a wrong guess reveals the card you drew' }, + rule6: { ko: '승리: 상대 모든 카드를 먼저 공개시키면 승리!', en: 'Win: reveal all of your opponent\'s cards first!' }, + phaseLabel: { ko: '페이즈:', en: 'Phase:' }, + startPrompt: { ko: '게임을 시작하세요', en: 'Start a game' }, + oppHand: { ko: '🎭 상대방 손패', en: '🎭 Opponent\'s hand' }, + myHand: { ko: '👤 나의 손패', en: '👤 My hand' }, + drawTitle: { ko: '🃏 카드 뽑기', en: '🃏 Draw a card' }, + drawHint: { ko: '덱에서 원하는 색상의 카드를 클릭하세요', en: 'Click a deck card of the color you want' }, + jokerPlaceTitle: { ko: '🃏 조커 위치 선택', en: '🃏 Place the joker' }, + jokerPlaceHint: { ko: '조커를 놓을 위치를 클릭하세요 (카드 사이의 슬롯)', en: 'Click a slot between your cards to place the joker' }, + placeTitle: { ko: '📍 카드 배치 위치 선택', en: '📍 Place the card' }, + placeHint: { ko: '카드를 놓을 위치를 클릭하세요', en: 'Click where you want to place the card' }, + guessTitle: { ko: '🔮 카드 추측하기', en: '🔮 Guess a card' }, + guessHint: { ko: '상대 카드를 클릭하여 선택 후 숫자를 맞춰보세요', en: 'Click an opponent card, then pick a number' }, + numberLabel: { ko: '숫자:', en: 'Number:' }, + guessBtn: { ko: '🎯 추측!', en: '🎯 Guess!' }, + decisionTitle: { ko: '🤔 계속할까요?', en: '🤔 Continue?' }, + decisionHint: { ko: '맞았습니다! 계속 추측하시겠습니까?', en: 'Correct! Do you want to keep guessing?' }, + continueBtn: { ko: '✅ 계속 추측', en: '✅ Keep guessing' }, + stopBtn: { ko: '🛑 턴 종료', en: '🛑 End turn' }, + oppTurnTitle: { ko: '🎭 상대방 차례', en: '🎭 Opponent\'s turn' }, + oppActing: { ko: '상대방이 행동 중입니다...', en: 'Opponent is thinking...' }, + gameOverTitle: { ko: '게임 종료', en: 'Game over' }, + resultDefault: { ko: '결과', en: 'Result' }, + newGame: { ko: '🔄 새 게임', en: '🔄 New game' }, + + // ---- dynamic messages (game.js) ---- + waitingPlayers: { ko: '플레이어 대기 중...', en: 'Waiting for players...' }, + drawPrompt: { ko: '검정 또는 흰색 카드를 뽑으세요.', en: 'Draw a black or white card.' }, + gameStarted: { ko: '게임이 시작되었습니다!', en: 'The game has started!' }, + winMsg: { ko: '🎉 축하합니다! 당신이 승리했습니다!', en: '🎉 Congratulations, you win!' }, + loseMsg: { ko: '😢 아쉽습니다. 상대방이 승리했습니다.', en: '😢 You lost — the opponent wins.' }, + loseRetry: { ko: '😢 아쉽습니다. 다음에 다시 도전하세요!', en: '😢 You lost — try again!' }, + waitOpponent: { ko: '⏳ 상대방의 차례입니다. 기다려주세요.', en: '⏳ Opponent\'s turn. Please wait.' }, + choosePlace: { ko: '카드를 배치할 위치를 선택하세요. ({n}곳 가능)', en: 'Choose where to place the card ({n} spots).' }, + autoPlace: { ko: '카드가 자동으로 배치됩니다.', en: 'The card is placed automatically.' }, + guessPrompt: { ko: '상대방 카드를 추측하세요.', en: 'Guess one of your opponent\'s cards.' }, + joker: { ko: '조커', en: 'Joker' }, + correctWinEnd: { ko: '🎉 정답! 게임 종료! 당신이 승리했습니다!', en: '🎉 Correct! Game over — you win!' }, + correctContinue: { ko: '✅ 정답! 계속 추측하시겠습니까?', en: '✅ Correct! Keep guessing?' }, + wrongGameOver: { ko: '❌ 틀렸습니다! 카드가 모두 공개되어 게임이 종료됩니다.', en: '❌ Wrong! All your cards are revealed — game over.' }, + wrongOppTurn: { ko: '❌ 틀렸습니다! 상대방 차례입니다.', en: '❌ Wrong! Opponent\'s turn.' }, + keepGuessing: { ko: '🎯 계속 추측하세요!', en: '🎯 Keep guessing!' }, + turnEnded: { ko: '턴을 종료했습니다.', en: 'You ended your turn.' }, + black: { ko: '검정', en: 'black' }, + white: { ko: '흰색', en: 'white' }, + oppPlaced: { ko: '상대방이 {color} 카드를 위치 {pos}에 배치했습니다.', en: 'Opponent placed a {color} card at position {pos}.' }, + oppContinues: { ko: '⏳ 상대방이 계속 추측합니다.', en: '⏳ Opponent keeps guessing.' }, + oppEndedTurn: { ko: '상대방이 턴을 종료했습니다.', en: 'Opponent ended their turn.' }, + yourTurnGuess: { ko: '🎯 당신의 차례입니다! 상대방 카드를 추측하세요.', en: '🎯 Your turn! Guess an opponent card.' }, + yourTurnDraw: { ko: '🎯 당신의 차례입니다! 카드를 뽑으세요.', en: '🎯 Your turn! Draw a card.' }, + oppGuessed: { ko: '상대방이 위치 {pos}을(를) {val}로 추측했습니다.', en: 'Opponent guessed position {pos} is {val}.' }, + pvpUnsupported: { ko: 'PvP는 이 페이지에서 지원되지 않습니다.', en: 'PvP is not supported on this page.' }, + noGame: { ko: '게임이 없습니다.', en: 'No game in progress.' }, + enterGameId: { ko: '⚠️ 게임 ID를 입력하세요', en: '⚠️ Enter a game ID' }, + selectCardValue: { ko: '⚠️ 카드와 숫자를 선택하세요', en: '⚠️ Select a card and a number' }, + correctShort: { ko: '✅ 정답!', en: '✅ Correct!' }, + wrongShort: { ko: '❌ 틀렸습니다!', en: '❌ Wrong!' }, + phaseWaiting: { ko: '대기중', en: 'Waiting' }, + phaseDraw: { ko: '뽑기', en: 'Draw' }, + phaseGuess: { ko: '추측', en: 'Guess' }, + phaseDecision: { ko: '선택', en: 'Decide' }, + phasePlace: { ko: '배치', en: 'Place' }, + drawnCard: { ko: '뽑은 카드', en: 'Drawn card' }, + selectNumber: { ko: '숫자 선택...', en: 'Pick a number...' }, + jokerOption: { ko: '- 조커', en: '- Joker' }, + victory: { ko: '🎉 승리!', en: '🎉 Victory!' }, + defeat: { ko: '💀 패배', en: '💀 Defeat' }, + oppLeftTitle: { ko: '🚪 상대방 퇴장', en: '🚪 Opponent left' }, + oppLeftMsg: { ko: '상대방이 게임을 나갔습니다.', en: 'The opponent left the game.' }, + }; + + const STORAGE_KEY = 'dvc_lang'; + + function detectLang() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === 'ko' || saved === 'en') return saved; + return (navigator.language || '').toLowerCase().startsWith('ko') ? 'ko' : 'en'; + } + + let lang = detectLang(); + + function t(key, params) { + const entry = STRINGS[key]; + let s = entry ? (entry[lang] || entry.ko) : key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replaceAll('{' + k + '}', String(v)); + } + } + return s; + } + + function applyI18n() { + document.documentElement.lang = lang; + document.title = t('docTitle'); + document.querySelectorAll('[data-i18n]').forEach(el => { + el.innerHTML = t(el.dataset.i18n); + }); + const btn = document.getElementById('lang-toggle'); + if (btn) btn.textContent = lang === 'ko' ? 'EN' : '한국어'; + } + + function setLang(next) { + lang = next; + localStorage.setItem(STORAGE_KEY, lang); + applyI18n(); + window.dispatchEvent(new CustomEvent('dvc:langchange', { detail: { lang } })); + } + + window.I18N = { + t, + get lang() { return lang; }, + setLang, + applyI18n, + }; + + document.addEventListener('DOMContentLoaded', () => { + applyI18n(); + document.getElementById('lang-toggle') + ?.addEventListener('click', () => setLang(lang === 'ko' ? 'en' : 'ko')); + }); +})(); diff --git a/docs/index.html b/docs/index.html index 443b059..4a43efc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -14,9 +14,20 @@ + + +
@@ -37,130 +48,130 @@

DaVinci Code

- +
-

🤖 AI 대전

- +

🤖 AI 대전

+
- +
-

📖 게임 규칙

+

📖 게임 규칙

- + + - +