diff --git a/.env b/.env new file mode 100644 index 0000000..925595c --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +# 1. API 주소 (http/https) +VITE_API_BASE_URL=https://dilemmai-idl.com + +# 2. 웹소켓 주소 (ws/wss) +# 주소 뒤에 /ws/voice 등을 붙여서 쓰기 위해 도메인만 관리 +VITE_WS_BASE_URL=wss://dilemmai-idl.com \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 677c9f3..67e877a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1387,6 +1388,7 @@ "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1441,6 +1443,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1618,6 +1621,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -2058,6 +2062,7 @@ "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2271,6 +2276,7 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -3224,6 +3230,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3261,6 +3268,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -3364,6 +3372,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3373,6 +3382,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -3874,6 +3884,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4002,6 +4013,7 @@ "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/WebRTCProvider.jsx b/src/WebRTCProvider.jsx index 22aabb2..ab477ad 100644 --- a/src/WebRTCProvider.jsx +++ b/src/WebRTCProvider.jsx @@ -1100,6 +1100,11 @@ function maskIceServersForLog(iceServers) { // WebRTC Context 생성 const WebRTCContext = createContext(); +/** + * 시그널링용 웹소켓 베이스 주소를 환경변수에서 가져옵니다. + */ +const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'wss://dilemmai-idl.com'; + // 재연결 그레이스 상수 (ms) const RECONNECT_GRACE_MS = 20000; // 20초 @@ -1292,7 +1297,7 @@ const WebRTCProvider = ({ children }) => { // ---------------------------- // Offer 충돌(글레어) 처리용 유틸 (Perfect Negotiation - 간소화) - // - 서버가 peers/join을 누구에게 어떻게 브로드캐스트하든, 양쪽이 offer를 만들어도 안전하게 수렴하게 함 + // - 서버가 peers/join을 누구에게 어떻게 브로드캐스트하든, 양쪽이 offer를 만들어도 안전하게 수락하게 함 // - user_id가 숫자일 가능성이 높으니 숫자 비교 우선, 아니면 문자열 비교 // ---------------------------- function comparePeerIds(a, b) { @@ -1574,8 +1579,11 @@ const WebRTCProvider = ({ children }) => { connectionAttemptedRef.current = true; + /** + * 하드코딩된 주소를 환경변수(VITE_WS_BASE_URL) 기반으로 변경 + */ const urlsToTry = [ - `wss://dilemmai-idl.com/ws/signaling?room_code=${roomCode}&token=${token}`, + `${WS_BASE}/ws/signaling?room_code=${roomCode}&token=${token}`, ]; console.log(`🔌 [${providerId}] 시그널링 WebSocket 연결 시작 (User 토큰 기반)`); @@ -2726,3 +2734,22 @@ useEffect(() => { }; export default WebRTCProvider; + +// 유틸함수 +export function disconnectWebRTCVoice(peerConnectionsMap) { + if (!peerConnectionsMap) return; + const iterable = peerConnectionsMap instanceof Map + ? peerConnectionsMap.values() + : Object.values(peerConnectionsMap); + for (const pc of iterable) { + try { + pc.getSenders().forEach(s => { if (s.track?.kind === 'audio') s.track.stop(); }); + pc.close(); + } catch (e) { console.error(e); } + } +} + +/** + * 1. 상단에 WS_BASE 상수를 선언하고 VITE_WS_BASE_URL 환경변수를 적용함. + * 2. connectSignalingWebSocket 함수 내 하드코딩된 'wss://dilemmai-idl.com' 주소를 WS_BASE 변수로 대체함. + */ diff --git a/src/WebSocketProvider.jsx b/src/WebSocketProvider.jsx index 873f205..105f970 100644 --- a/src/WebSocketProvider.jsx +++ b/src/WebSocketProvider.jsx @@ -5,6 +5,11 @@ import { useNavigate } from 'react-router-dom'; const WebSocketContext = createContext(); +/** + * 웹소켓 베이스 주소를 환경변수에서 가져옴. + */ +const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'wss://dilemmai-idl.com'; + export const useWebSocket = () => { const context = useContext(WebSocketContext); if (!context) { @@ -450,7 +455,10 @@ export const WebSocketProvider = ({ children }) => { try { console.log(`🔌 [${providerId}] WebSocket 연결 시도:`, currentSessionId); - const wsUrl = `wss://dilemmai-idl.com/ws/voice/${currentSessionId}?token=${accessToken}`; + /** + *하드코딩된 주소를 환경변수(VITE_WS_BASE_URL) 기반으로 변경 + */ + const wsUrl = `${WS_BASE}/ws/voice/${currentSessionId}?token=${accessToken}`; console.log(`🔗 [${providerId}] WebSocket URL:`, wsUrl.replace(accessToken, 'TOKEN_HIDDEN')); const socket = new WebSocket(wsUrl); @@ -482,7 +490,7 @@ export const WebSocketProvider = ({ children }) => { reconnectAttemptsRef.current = 0; setReconnectAttempts(0); reconnectDelay.current = 1000; - finalizedRef.current = false; + finalizedRef.current = false; if (reconnectTimer.current) { clearTimeout(reconnectTimer.current); reconnectTimer.current = null; @@ -497,12 +505,6 @@ export const WebSocketProvider = ({ children }) => { } }; sendMessage(initPayload); - // pingIntervalRef.current = setInterval(() => { - // if (ws.current?.readyState === WebSocket.OPEN) { - // ws.current.send(JSON.stringify({ type: 'ping' })); - // console.log(`🏓 ping 전송`); - // } - // }, 30000); }; socket.onmessage = (event) => { @@ -582,7 +584,7 @@ export const WebSocketProvider = ({ children }) => { finalizeDisconnection('네트워크 불안정으로 연결을 복구하지 못했습니다. 메인으로 돌아갑니다.'); } }; - } catch (error) { // ✅ connect() 내부 try-catch의 catch 추가 + } catch (error) { isConnecting.current = false; if (!isReconnect) { connectionAttempted.current = false; @@ -590,9 +592,9 @@ export const WebSocketProvider = ({ children }) => { console.error(`❌ [${providerId}] WebSocket 연결 실패:`, error); setIsConnected(false); } - }; // ✅ 여기서 connect 함수 끝 + }; - const disconnect = () => { // ✅ 이제 connect 밖의 동일 스코프 + const disconnect = () => { isManuallyDisconnected.current = true; hasJoinedSession.current = false; @@ -614,7 +616,7 @@ export const WebSocketProvider = ({ children }) => { } }; - + // 🔧 음성 세션 초기화 함수 중복 방지 강화 const initializeVoiceWebSocket = async (isHost = false) => { // 🔧 가드 1: 이미 초기화 중 @@ -910,3 +912,6 @@ export const WebSocketProvider = ({ children }) => { }; export default WebSocketProvider; + + +//하드코딩된 'wss://dilemmai-idl.com' 주소를 VITE_WS_BASE_URL 환경변수로 대체 diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js index dbe1cd3..a14d94e 100644 --- a/src/api/axiosInstance.js +++ b/src/api/axiosInstance.js @@ -1,10 +1,14 @@ -// api/axiosInstance.js import axios from 'axios'; -// Vite 환경변수로 API baseURL을 바꿀 수 있게 함 (로컬/스테이징/프로덕션 공용) -// 예) VITE_API_BASE_URL=http://localhost:8000 -const API_BASE = (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_API_BASE_URL) - ? String(import.meta.env.VITE_API_BASE_URL).replace(/\/+$/, '') - : 'https://dilemmai-idl.com'; + +/** + * 하드코딩된 주소를 환경변수로 분리 + * Vite 환경이므로 import.meta.env를 사용 + * .env 파일에 VITE_API_BASE_URL 설정이 없으면 기존 주소를 기본값으로 사용 + */ +const API_BASE = (import.meta.env && import.meta.env.VITE_API_BASE_URL + ? String(import.meta.env.VITE_API_BASE_URL) + : 'https://dilemmai-idl.com' +).replace(/\/+$/, ''); // 메인 axios 인스턴스 생성 const instance = axios.create({ @@ -15,6 +19,7 @@ const instance = axios.create({ // CORS 관련 설정 추가 (이미지 로드 문제 대응) withCredentials: false, // 쿠키를 보내지 않으면 CORS가 더 관대함 }); + // 상단 어딘가 공통 상수로 추가 const MAX_500_REFRESH_RETRY = 1; // 500에서 refresh 시도 최대 횟수 (원하면 2로 올려도 됨) @@ -52,9 +57,7 @@ const refreshAccessToken = async () => { if (!refreshToken) throw new Error('No refresh token available'); try { - // const { data } = await axios.post(`${API_BASE}/auth/refresh`, { - // refresh_token: refreshToken, - // }); + // 하드코딩된 문자열 대신 변수화된 API_BASE 사용 const { data } = await axios.post( `${API_BASE}/auth/refresh`, // 1. URL { // 2. body (data) @@ -73,7 +76,7 @@ const refreshAccessToken = async () => { // 1) 응답에서 받은 토큰들을 즉시 저장 if (data.access_token) localStorage.setItem('access_token', data.access_token); if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token); - if (data.token_type) localStorage.setItem('token_type', data.token_type); + if (data.token_type) localStorage.setItem('token_type', data.token_type); // 2) axios 인스턴스 기본 헤더도 업데이트 const authValue = `${data.token_type || 'Bearer'} ${data.access_token}`; @@ -193,21 +196,8 @@ instance.interceptors.response.use( } ); -// export async function callChatbot({ step, input, context, prompt }) { -// const payload = { step, input, context, prompt }; -// const { data } = await instance.post( -// "/chat/with-prompt", -// payload, -// { -// headers: { -// "Content-Type": "application/json", -// }, -// } -// ); -// // 서버 응답 예: -// // { "step": "question", "text": "...", "raw": {...} } -// return data; -// } +// export async function callChatbot({ step, input, context, prompt }) { ... } + export async function callChatbot({ session_id, user_input, step, variable, context }) { const payload = { session_id, @@ -228,7 +218,4 @@ export async function callChatbot({ session_id, user_input, step, variable, cont return data; } - - - export default instance; \ No newline at end of file diff --git a/src/assets/CardButton.svg b/src/assets/CardButton.svg new file mode 100644 index 0000000..cfcad46 --- /dev/null +++ b/src/assets/CardButton.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/PeopleIcon.svg b/src/assets/PeopleIcon.svg new file mode 100644 index 0000000..46122fc --- /dev/null +++ b/src/assets/PeopleIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/Questionmark.svg b/src/assets/Questionmark.svg new file mode 100644 index 0000000..569d48e --- /dev/null +++ b/src/assets/Questionmark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/en/1player_AWS_1_en.svg b/src/assets/en/1player_AWS_1_en.svg new file mode 100644 index 0000000..653a009 --- /dev/null +++ b/src/assets/en/1player_AWS_1_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_AWS_2_en.svg b/src/assets/en/1player_AWS_2_en.svg new file mode 100644 index 0000000..a66a50a --- /dev/null +++ b/src/assets/en/1player_AWS_2_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_AWS_3_en.svg b/src/assets/en/1player_AWS_3_en.svg new file mode 100644 index 0000000..00557f3 --- /dev/null +++ b/src/assets/en/1player_AWS_3_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_AWS_4_en.svg b/src/assets/en/1player_AWS_4_en.svg new file mode 100644 index 0000000..00557f3 --- /dev/null +++ b/src/assets/en/1player_AWS_4_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_AWS_5_en.svg b/src/assets/en/1player_AWS_5_en.svg new file mode 100644 index 0000000..cc9e522 --- /dev/null +++ b/src/assets/en/1player_AWS_5_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_des1_en.svg b/src/assets/en/1player_des1_en.svg new file mode 100644 index 0000000..bf6f855 --- /dev/null +++ b/src/assets/en/1player_des1_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_des2_en.svg b/src/assets/en/1player_des2_en.svg new file mode 100644 index 0000000..5474ac4 --- /dev/null +++ b/src/assets/en/1player_des2_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/1player_des3_en.svg b/src/assets/en/1player_des3_en.svg new file mode 100644 index 0000000..2f8072f --- /dev/null +++ b/src/assets/en/1player_des3_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/2player_AWS_1_en.svg b/src/assets/en/2player_AWS_1_en.svg new file mode 100644 index 0000000..6abfabc --- /dev/null +++ b/src/assets/en/2player_AWS_1_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_AWS_2_en.svg b/src/assets/en/2player_AWS_2_en.svg new file mode 100644 index 0000000..f574709 --- /dev/null +++ b/src/assets/en/2player_AWS_2_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_AWS_3_en.svg b/src/assets/en/2player_AWS_3_en.svg new file mode 100644 index 0000000..021df11 --- /dev/null +++ b/src/assets/en/2player_AWS_3_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_AWS_4_en.svg b/src/assets/en/2player_AWS_4_en.svg new file mode 100644 index 0000000..021df11 --- /dev/null +++ b/src/assets/en/2player_AWS_4_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_AWS_5_en.svg b/src/assets/en/2player_AWS_5_en.svg new file mode 100644 index 0000000..6ce6a86 --- /dev/null +++ b/src/assets/en/2player_AWS_5_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_des1_en.svg b/src/assets/en/2player_des1_en.svg new file mode 100644 index 0000000..d90292a --- /dev/null +++ b/src/assets/en/2player_des1_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/2player_des2_en.svg b/src/assets/en/2player_des2_en.svg new file mode 100644 index 0000000..b1ae40b --- /dev/null +++ b/src/assets/en/2player_des2_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/2player_des3_en.svg b/src/assets/en/2player_des3_en.svg new file mode 100644 index 0000000..9199ee1 --- /dev/null +++ b/src/assets/en/2player_des3_en.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/3player_AWS_1_en.svg b/src/assets/en/3player_AWS_1_en.svg new file mode 100644 index 0000000..26e89fa --- /dev/null +++ b/src/assets/en/3player_AWS_1_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_AWS_2_en.svg b/src/assets/en/3player_AWS_2_en.svg new file mode 100644 index 0000000..2643715 --- /dev/null +++ b/src/assets/en/3player_AWS_2_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_AWS_3_en.svg b/src/assets/en/3player_AWS_3_en.svg new file mode 100644 index 0000000..ea4378e --- /dev/null +++ b/src/assets/en/3player_AWS_3_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_AWS_4_en.svg b/src/assets/en/3player_AWS_4_en.svg new file mode 100644 index 0000000..ea4378e --- /dev/null +++ b/src/assets/en/3player_AWS_4_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_AWS_5_en.svg b/src/assets/en/3player_AWS_5_en.svg new file mode 100644 index 0000000..b19d2c3 --- /dev/null +++ b/src/assets/en/3player_AWS_5_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_des1_en.svg b/src/assets/en/3player_des1_en.svg new file mode 100644 index 0000000..26dc10b --- /dev/null +++ b/src/assets/en/3player_des1_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_des2_en.svg b/src/assets/en/3player_des2_en.svg new file mode 100644 index 0000000..ae5e622 --- /dev/null +++ b/src/assets/en/3player_des2_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/3player_des3_en.svg b/src/assets/en/3player_des3_en.svg new file mode 100644 index 0000000..ef074c1 --- /dev/null +++ b/src/assets/en/3player_des3_en.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/en/CardButton-3.svg b/src/assets/en/CardButton-3.svg new file mode 100644 index 0000000..fe55a18 --- /dev/null +++ b/src/assets/en/CardButton-3.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/continue_en.svg b/src/assets/en/continue_en.svg new file mode 100644 index 0000000..4215d50 --- /dev/null +++ b/src/assets/en/continue_en.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/gameoptionbox1_en.svg b/src/assets/en/gameoptionbox1_en.svg new file mode 100644 index 0000000..da08233 --- /dev/null +++ b/src/assets/en/gameoptionbox1_en.svg @@ -0,0 +1,26 @@ + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
diff --git a/src/assets/en/gameoptionboxdisable_en.svg b/src/assets/en/gameoptionboxdisable_en.svg new file mode 100644 index 0000000..4ee5fa2 --- /dev/null +++ b/src/assets/en/gameoptionboxdisable_en.svg @@ -0,0 +1,26 @@ + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
diff --git a/src/assets/en/gameoptionboxlocked_en.svg b/src/assets/en/gameoptionboxlocked_en.svg new file mode 100644 index 0000000..ce41d8f --- /dev/null +++ b/src/assets/en/gameoptionboxlocked_en.svg @@ -0,0 +1,27 @@ + + +
+ + + + + + +
+ + + + + + + +
+ + + + + + + + +
diff --git a/src/assets/en/host_info3_en.svg b/src/assets/en/host_info3_en.svg new file mode 100644 index 0000000..2aadcd6 --- /dev/null +++ b/src/assets/en/host_info3_en.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/en/host_info_en.svg b/src/assets/en/host_info_en.svg new file mode 100644 index 0000000..e630cef --- /dev/null +++ b/src/assets/en/host_info_en.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/en/meready_en.svg b/src/assets/en/meready_en.svg new file mode 100644 index 0000000..0291418 --- /dev/null +++ b/src/assets/en/meready_en.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/uready_en.svg b/src/assets/en/uready_en.svg new file mode 100644 index 0000000..ea375d6 --- /dev/null +++ b/src/assets/en/uready_en.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/en/waitingforready_en.svg b/src/assets/en/waitingforready_en.svg new file mode 100644 index 0000000..fe55a18 --- /dev/null +++ b/src/assets/en/waitingforready_en.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/gameoptionbox_L.svg b/src/assets/gameoptionbox_L.svg new file mode 100644 index 0000000..2e2c2de --- /dev/null +++ b/src/assets/gameoptionbox_L.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_M.svg b/src/assets/gameoptionbox_M.svg new file mode 100644 index 0000000..2707d06 --- /dev/null +++ b/src/assets/gameoptionbox_M.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_R.svg b/src/assets/gameoptionbox_R.svg new file mode 100644 index 0000000..4f5136a --- /dev/null +++ b/src/assets/gameoptionbox_R.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_T.svg b/src/assets/gameoptionbox_T.svg new file mode 100644 index 0000000..cb402a2 --- /dev/null +++ b/src/assets/gameoptionbox_T.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/gameoptionbox_disable_L.svg b/src/assets/gameoptionbox_disable_L.svg new file mode 100644 index 0000000..3e4d9df --- /dev/null +++ b/src/assets/gameoptionbox_disable_L.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_disable_M.svg b/src/assets/gameoptionbox_disable_M.svg new file mode 100644 index 0000000..9fdf8ec --- /dev/null +++ b/src/assets/gameoptionbox_disable_M.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_disable_R.svg b/src/assets/gameoptionbox_disable_R.svg new file mode 100644 index 0000000..052b763 --- /dev/null +++ b/src/assets/gameoptionbox_disable_R.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_disable_T.svg b/src/assets/gameoptionbox_disable_T.svg new file mode 100644 index 0000000..a768a43 --- /dev/null +++ b/src/assets/gameoptionbox_disable_T.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/gameoptionbox_locked_L.svg b/src/assets/gameoptionbox_locked_L.svg new file mode 100644 index 0000000..c4058fb --- /dev/null +++ b/src/assets/gameoptionbox_locked_L.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_locked_M.svg b/src/assets/gameoptionbox_locked_M.svg new file mode 100644 index 0000000..10ee450 --- /dev/null +++ b/src/assets/gameoptionbox_locked_M.svg @@ -0,0 +1,30 @@ + + + +
+ + + + + + +
+ + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_locked_R.svg b/src/assets/gameoptionbox_locked_R.svg new file mode 100644 index 0000000..de8993f --- /dev/null +++ b/src/assets/gameoptionbox_locked_R.svg @@ -0,0 +1,31 @@ + + + +
+ + + + + + +
+ + + + + + + +
+
+ + + + + + + + + + +
diff --git a/src/assets/gameoptionbox_locked_T.svg b/src/assets/gameoptionbox_locked_T.svg new file mode 100644 index 0000000..8d52226 --- /dev/null +++ b/src/assets/gameoptionbox_locked_T.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/CancelReadyPopup.jsx b/src/components/CancelReadyPopup.jsx index 5f1eefa..e59e0c4 100644 --- a/src/components/CancelReadyPopup.jsx +++ b/src/components/CancelReadyPopup.jsx @@ -3,8 +3,18 @@ import closeIcon from '../assets/close.svg'; import SecondaryButton from './SecondaryButton'; import { Colors, FontStyles } from './styleConstants'; import axiosInstance from '../api/axiosInstance'; +import { translations } from '../utils/language/index'; export default function CancelReadyPopup({ onClose, onCancelConfirmed }) { + // --- 언어 설정 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.CancelReadyPopup || {}; + + // 방어 코드: 데이터가 로드되지 않았을 경우 기본값 설정 + const displayQuestion = t.question || (lang === 'en' ? "Cancel your ready status?" : "준비 상태를 취소하시겠습니까?"); + const displayBtn = t.cancelBtn || (lang === 'en' ? "Cancel Ready" : "준비 취소"); + const errorMsg = t.errorMsg || (lang === 'en' ? "Failed to cancel ready status" : "준비 취소 실패"); + const handleCancelReady = async () => { const room_code = localStorage.getItem('room_code'); try { @@ -13,7 +23,7 @@ export default function CancelReadyPopup({ onClose, onCancelConfirmed }) { onClose(); // -> 팝업 닫기 } catch (err) { console.error('❌ 준비 취소 실패:', err); - alert('준비 취소 실패'); + alert(errorMsg); } }; @@ -36,7 +46,7 @@ export default function CancelReadyPopup({ onClose, onCancelConfirmed }) { > 닫기 -

- 준비 상태를 취소하시겠습니까? +

+ {displayQuestion}

- 준비 취소 + {displayBtn} ); -} +} \ No newline at end of file diff --git a/src/components/CharacterPopUp.jsx b/src/components/CharacterPopUp.jsx index d637135..8e84c43 100644 --- a/src/components/CharacterPopUp.jsx +++ b/src/components/CharacterPopUp.jsx @@ -9,23 +9,44 @@ import closeIcon from "../assets/close.svg"; import { Colors, FontStyles } from './styleConstants'; import { attachJosa } from '../utils/resolveParagraphs'; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; + export default function CharacterPopup({ subtopic, roleId, mateName, onClose }) { - // 1) SVG 선택 - // const bgSvg = { - // 1: char1, - // 2: char2, - // 3: char3, - // }[roleId] || char1; + // 현재 언어 설정 및 언어팩 로드 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t_small = translations[lang]?.SmallDescription || {}; + const t_map = translations[lang]?.GameMap || {}; + const t_ko_map = translations['ko']?.GameMap || {}; - // 2) category 값에 따른 타이틀 및 본문 텍스트 설정 - const category = localStorage.getItem('category'); // 로컬에서 category 값 가져오기 + const category = localStorage.getItem('category'); const subtopic1 = localStorage.getItem('subtopic'); + + // 확장형 조사 처리 및 변수 치환 헬퍼 + const formatText = (text) => { + if (!text) return ""; + const isKorean = lang === 'ko'; + + // 한국어(ko)를 제외한 모든 언어에서는 조사가 붙지 않도록 공백 처리하거나 무력화 + return text.replaceAll('{{mateName}}', mateName) + .replaceAll('{{eulReul}}', isKorean ? (attachJosa(mateName, '을/를').replace(mateName, '')) : '') + .replaceAll('{{gwaWa}}', isKorean ? (attachJosa(mateName, '과/와').replace(mateName, '')) : '') + .replaceAll('{{eunNeun}}', isKorean ? (attachJosa(mateName, '은/는').replace(mateName, '')) : ''); + }; + + // 1) SVG 선택 (이중 매칭 전략 적용) const bgSvg = (() => { switch (roleId) { case 1: return char1; case 2: - return (subtopic1 === 'AI의 개인 정보 수집'|| subtopic1 === '안드로이드의 감정 표현') ? char2 : char2_AWS; + // 한국어 원문과 현재 언어팩 값을 모두 체크하여 판별 + const isHomeScenario = + subtopic1 === 'AI의 개인 정보 수집' || + subtopic1 === '안드로이드의 감정 표현' || + subtopic1 === t_ko_map.andOption1_1 || + subtopic1 === t_map.andOption1_1; + return isHomeScenario ? char2 : char2_AWS; case 3: return char3; default: @@ -36,133 +57,79 @@ export default function CharacterPopup({ subtopic, roleId, mateName, onClose }) let titleText = ''; let mainText = ''; - if (category === '안드로이드') { - // 안드로이드 카테고리 - if (['AI의 개인 정보 수집', '안드로이드의 감정 표현'].includes(subtopic)) { - titleText = roleId === 1 ? '요양보호사 K' - : roleId === 2 ? '노모 L' - : '자녀 J'; - } else if (['아이들을 위한 서비스', '설명 가능한 AI'].includes(subtopic)) { - titleText = roleId === 1 ? '로봇 제조사 연합회 대표' - : roleId === 2 ? '소비자 대표' - : '국가 인공지능 위원회 대표'; - } else if (subtopic === '지구, 인간, AI') { - titleText = roleId === 1 ? '기업 연합체 대표' - : roleId === 2 ? '국제 환경단체 대표' - : '소비자 대표'; - } + // 카테고리 판별 (일반화된 로직 사용) + const isAndroid = category === '안드로이드' || category === 'Android' || category === t_map.categoryAndroid; + const isAWS = category === '자율 무기 시스템' || category === 'Autonomous Weapon Systems' || category === t_map.categoryAWS; - // 안드로이드 카테고리 본문 - if (subtopic === 'AI의 개인 정보 수집' || subtopic === '안드로이드의 감정 표현') { - if (roleId === 1) { - mainText = `어머니를 10년 이상 돌본 요양보호사 K입니다.\n` + - `최근 ${attachJosa(mateName, '을/를')} 도입한 후 전일제에서 하루 2시간 근무로 전환되었습니다.\n` + - `로봇이 수행할 수 없는 업무를 주로 담당하며, 근무 중 ${attachJosa(mateName, '과/와')} 협업해야 하는 상황이 많습니다.`; - } else if (roleId === 2) { - mainText = `자녀 J씨의 노모입니다.\n` + - `가사도우미의 도움을 받다가 최근 ${mateName}의 도움을 받고 있습니다.`; - } else if (roleId === 3) { - mainText = `자녀 J씨입니다.\n` + - `함께 사는 노쇠하신 어머니가 걱정되지만, 바쁜 직장생활로 어머니를 돌보아드릴 여유가 거의 없습니다.`; - } - } else if (['아이들을 위한 서비스', '설명 가능한 AI', '지구, 인간, AI'].includes(subtopic)) { - if (roleId === 1) { - mainText = `로봇 제조사 연합회 대표입니다.\n` + - `국가적 로봇 산업의 긍정적인 발전과 활용을 위한 목소리를 내기 위해 참여했습니다.`; - } else if (roleId === 2) { - mainText = `소비자 대표입니다.\n` + - `HomeMate 규제 여부와 관련한 목소리를 내고자 참여하였습니다.`; - } else if (roleId === 3) { - mainText = `국가 인공지능 위원회의 대표입니다.\n` + - `국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`; - } + if (isAndroid) { + // 안드로이드 카테고리 + if (['AI의 개인 정보 수집', '안드로이드의 감정 표현', t_ko_map.andOption1_1, t_map.andOption1_1].includes(subtopic)) { + if (roleId === 1) { titleText = t_small.title_caregiver_k; mainText = formatText(t_small.desc_caregiver_k); } + else if (roleId === 2) { titleText = t_small.title_mother_l; mainText = formatText(t_small.desc_mother_l); } + else { titleText = t_small.title_child_j; mainText = formatText(t_small.desc_child_j); } + } else if (['아이들을 위한 서비스', '설명 가능한 AI', t_ko_map.andOption2_1, t_map.andOption2_1, t_ko_map.andOption2_2, t_map.andOption2_2].includes(subtopic)) { + if (roleId === 1) { titleText = t_small.title_industry_rep; mainText = formatText(t_small.desc_industry_rep); } + else if (roleId === 2) { titleText = t_small.title_consumer_rep; mainText = formatText(t_small.desc_consumer_rep); } + else { titleText = t_small.title_council_rep; mainText = formatText(t_small.desc_council_rep); } + } else if (subtopic === '지구, 인간, AI' || subtopic === t_ko_map.andOption3_1 || subtopic === t_map.andOption3_1) { + if (roleId === 1) { titleText = t_small.title_enterprise_rep; mainText = formatText(t_small.desc_enterprise_rep); } + else if (roleId === 2) { titleText = t_small.title_env_rep; mainText = formatText(t_small.desc_env_rep); } + else { titleText = t_small.title_consumer_rep; mainText = formatText(t_small.desc_consumer_rep); } } - } else if (category === '자율 무기 시스템') { + } else if (isAWS) { // 자율 무기 시스템 카테고리 - if (subtopic === 'AI 알고리즘 공개') { - titleText = roleId === 1 ? '지역 주민' - : roleId === 2 ? '병사 J' - : '군사 AI 윤리 전문가'; - if (roleId === 1) { - mainText = `최근 자율 무기 시스템의 학교 폭격 사건이 일어난 지역의 주민입니다.`; - } else if (roleId === 2) { - mainText = `자율 무기 시스템과 작전을 함께 수행 중인 병사 J입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.`; - } else if (roleId === 3) { - mainText = `군사 AI 윤리 전문가입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.`; - } - } else if (subtopic === 'AWS의 권한') { - titleText = roleId === 1 ? '신입 병사' - : roleId === 2 ? '베테랑 병사 A' - : '군 지휘관'; - if (roleId === 1) { - mainText = `최근 훈련을 마치고 자율 무기 시스템 ${attachJosa(mateName, '과/와')} 함께 실전에 투입된 신입 병사 입니다. ${attachJosa(mateName, '은/는')} 정확하고 빠르게 움직이며, 실전에서 생존률을 높여준다고 느낍니다. ${attachJosa(mateName, '과/와')} 협업하는 것이 당연하고 자연스러운 시대의 흐름이라고 생각합니다.`; - } else if (roleId === 2) { - mainText = `수년간 작전을 수행해 온 베테랑 병사 A입니다. 자율 무기 시스템 ${attachJosa(mateName, '은/는')} 전장에서 병사보다 빠르고 정확하지만, 그로 인해 병사들이 판단하지 않는 습관에 빠지고 있다고 느낍니다.`; - } else if (roleId === 3) { - mainText = `자율 무기 시스템 ${mateName} 도입 이후 작전 효율성과 병사들의 변화 양상을 모두 지켜보고 있는 군 지휘관입니다. 두 병사의 입장을 듣고, 군 전체가 나아갈 방향을 모색하려 합니다.`; - } - } else if (subtopic === '사람이 죽지 않는 전쟁') { - titleText = roleId === 1 ? '개발자' - : roleId === 2 ? '국방부 장관' - : '국가 인공지능 위원회 대표'; - if (roleId === 1) { - mainText = `대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.`; - } else if (roleId === 2) { - mainText = `AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다. 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다. 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.`; - } else if (roleId === 3) { - mainText = `본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`; - } - } else if (subtopic === 'AI의 권리와 책임') { - titleText = roleId === 1 ? '개발자' - : roleId === 2 ? '국방부 장관' - : '국가 인공지능 위원회 대표'; - if (roleId === 1) { - mainText = `대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.`; - } else if (roleId === 2) { - mainText = `AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다. 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다. 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.`; - } else if (roleId === 3) { - mainText = `본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.`; - } - } else if (subtopic === 'AWS 규제') { - titleText = roleId === 1 ? '국방 기술 고문' - : roleId === 2 ? '국제기구 외교 대표' - : '글로벌 NGO 활동가'; - if (roleId === 1) { - mainText = `AWS 기술 보유 중인 중견국 A의 국방 기술 고문입니다. AWS가 기회가 될지 위험이 될지 판단하고자 국제 인류 발전 위원회에 참석했습니다.`; - } else if (roleId === 2) { - mainText = `선진국 B의 국제기구 외교 대표입니다. AWS의 국제적 확산에 대한 바람직한 방향을 고민하기 위해 이 자리에 참석했습니다.`; - } else if (roleId === 3) { - mainText = `저개발국 C의 글로벌 NGO 활동가입니다. 국제사회에 현장의 목소리를 내고자 이 자리에 참석했습니다.`; - } + if (subtopic === 'AI 알고리즘 공개' || subtopic === t_ko_map.awsOption1_1 || subtopic === t_map.awsOption1_1) { + if (roleId === 1) { titleText = t_small.title_resident; mainText = formatText(t_small.desc_resident); } + else if (roleId === 2) { titleText = t_small.title_soldier_j; mainText = formatText(t_small.desc_soldier_j); } + else { titleText = t_small.title_ethics_expert; mainText = formatText(t_small.desc_ethics_expert); } + } else if (subtopic === 'AWS의 권한' || subtopic === t_ko_map.awsOption1_2 || subtopic === t_map.awsOption1_2) { + if (roleId === 1) { titleText = t_small.title_new_soldier; mainText = formatText(t_small.desc_new_soldier); } + else if (roleId === 2) { titleText = t_small.title_veteran_soldier; mainText = formatText(t_small.desc_veteran_soldier); } + else { titleText = t_small.title_commander; mainText = formatText(t_small.desc_commander); } + } else if (subtopic === '사람이 죽지 않는 전쟁' || subtopic === t_ko_map.awsOption2_1 || subtopic === t_map.awsOption2_1 || subtopic === 'AI의 권리와 책임' || subtopic === t_ko_map.awsOption2_2 || subtopic === t_map.awsOption2_2) { + if (roleId === 1) { titleText = t_small.title_developer; mainText = formatText(t_small.desc_developer); } + else if (roleId === 2) { titleText = t_small.title_minister; mainText = formatText(t_small.desc_minister); } + else { titleText = t_small.title_council_rep; mainText = formatText(t_small.desc_council_rep); } + } else if (subtopic === 'AWS 규제' || subtopic === t_ko_map.awsOption3_1 || subtopic === t_map.awsOption3_1) { + if (roleId === 1) { titleText = t_small.title_advisor; mainText = formatText(t_small.desc_advisor); } + else if (roleId === 2) { titleText = t_small.title_diplomat; mainText = formatText(t_small.desc_diplomat); } + else { titleText = t_small.title_ngo_activist; mainText = formatText(t_small.desc_ngo_activist); } } } -// 커스텀 모드 오버라이드: char1/2/3 + charDes1/2/3 사용 -const isCustomMode = !!localStorage.getItem('code'); -if (isCustomMode) { - const titleMap = { - 1: (localStorage.getItem('char1') || '').trim(), - 2: (localStorage.getItem('char2') || '').trim(), - 3: (localStorage.getItem('char3') || '').trim(), - }; - const descMap = { - 1: (localStorage.getItem('charDes1') || '').trim(), - 2: (localStorage.getItem('charDes2') || '').trim(), - 3: (localStorage.getItem('charDes3') || '').trim(), + + // 커스텀 모드 오버라이드 + const isCustomMode = !!localStorage.getItem('code'); + if (isCustomMode) { + const titleMap = { + 1: (localStorage.getItem('char1') || '').trim(), + 2: (localStorage.getItem('char2') || '').trim(), + 3: (localStorage.getItem('char3') || '').trim(), + }; + const descMap = { + 1: (localStorage.getItem('charDes1') || '').trim(), + 2: (localStorage.getItem('charDes2') || '').trim(), + 3: (localStorage.getItem('charDes3') || '').trim(), + }; + titleText = titleMap[roleId] ?? titleText; + mainText = descMap[roleId] ?? mainText; + } + + const getDynamicTitleStyle = (text) => { + const len = text?.length || 0; + // 글자 수가 많을수록 폰트를 줄임 (기본 FontStyles.bodyBold 사이즈는 보통 18~20px 추정) + if (len > 28) return { fontSize: '13.5px' }; + return {}; // 기본값 유지 (bodyBold 스타일 따름) }; - titleText = titleMap[roleId] ?? titleText; - mainText = descMap[roleId] ?? mainText; -} return (
- {/* 배경 SVG */} character background - {/* 제목 */}
{titleText}
- {/* 본문 */}
{mainText}
); -} +} \ No newline at end of file diff --git a/src/components/Continue.jsx b/src/components/Continue.jsx index 222bcee..ec12959 100644 --- a/src/components/Continue.jsx +++ b/src/components/Continue.jsx @@ -1,78 +1,59 @@ import React, { useState } from 'react'; import nextFrame from '../assets/next.svg'; import { FontStyles, Colors } from './styleConstants'; +import { translations } from '../utils/language'; +/** + * Continue: 다음 단계로 진행하는 버튼 컴포넌트 + */ export default function Continue({ width = 264, height = 72, onClick, - disabled = false, - label = "다음", + disabled = false, + label, }) { const [isHovered, setIsHovered] = useState(false); const [isActive, setIsActive] = useState(false); - const interactive = !disabled; + const lang = localStorage.getItem('app_lang') || 'ko'; + + // [수정] 이중 객체 구조(UiElements.UiElements) 안전하게 해제 + const rawData = translations[lang]?.UiElements || translations['ko']?.UiElements || {}; + const t = rawData.UiElements || rawData; - const scale = - interactive - ? isActive - ? 0.989 - : isHovered - ? 1.01 - : 1 - : 1; + // 우선순위: Props label > 언어팩 UiElements.next > 기본값 "다음" + const finalLabel = label || t.next || "다음"; + const interactive = !disabled; + const scale = interactive ? (isActive ? 0.989 : isHovered ? 1.01 : 1) : 1; const textColor = interactive ? Colors.grey01 : Colors.grey04; - + const textStyle = { + ...FontStyles.headlineSmall, + color: textColor, + ...(lang !== 'ko' && { + fontSize: '22px', // 글자 크기 축소 (필요시 조절) + lineHeight: '1.2', // 줄 간격 좁힘 + whiteSpace: 'nowrap' // 줄바꿈 방지 (혹은 필요시 'normal') + }) + }; return (
setIsHovered(true) : undefined} + onMouseEnter={interactive ? () => setIsHovered(true) : undefined} onMouseLeave={interactive ? () => { setIsHovered(false); setIsActive(false); } : undefined} - onMouseDown={interactive ? () => setIsActive(true) : undefined} - onMouseUp ={interactive ? () => setIsActive(false) : undefined} - + onMouseDown={interactive ? () => setIsActive(true) : undefined} + onMouseUp={interactive ? () => setIsActive(false) : undefined} style={{ - width, - height, - position: 'relative', - cursor: interactive ? 'pointer' : 'default', - userSelect: 'none', - transform: `scale(${scale})`, - transition: 'transform 0.15s ease-out', + width, height, position: 'relative', cursor: interactive ? 'pointer' : 'default', + userSelect: 'none', transform: `scale(${scale})`, transition: 'transform 0.15s ease-out', opacity: interactive ? 1 : 0.4, }} > - continue frame -
- - {label} - - + +
+ {finalLabel}
); -} +} \ No newline at end of file diff --git a/src/components/Continue2.jsx b/src/components/Continue2.jsx index e0c2f9f..2965a95 100644 --- a/src/components/Continue2.jsx +++ b/src/components/Continue2.jsx @@ -2,11 +2,12 @@ import React, { useState } from 'react'; import nextFrame from '../assets/contentBox3.svg'; import { FontStyles, Colors } from './styleConstants'; -export default function Continue({ +export default function Continue2({ width = 264, height = 72, onClick, - disabled = false, + disabled = false, + label, // [수정] label props 추가 }) { const [isHovered, setIsHovered] = useState(false); const [isActive, setIsActive] = useState(false); @@ -27,12 +28,10 @@ export default function Continue({ return (
setIsHovered(true) : undefined} onMouseLeave={interactive ? () => { setIsHovered(false); setIsActive(false); } : undefined} onMouseDown={interactive ? () => setIsActive(true) : undefined} onMouseUp ={interactive ? () => setIsActive(false) : undefined} - style={{ width, height, @@ -69,10 +68,11 @@ export default function Continue({ }} > - 다음 + {/* [수정] 하드코딩 '다음' 제거하고 label 출력 */} + {label || "다음"}
); -} +} \ No newline at end of file diff --git a/src/components/CreateRoom2.jsx b/src/components/CreateRoom2.jsx index c8b3821..d4432dd 100644 --- a/src/components/CreateRoom2.jsx +++ b/src/components/CreateRoom2.jsx @@ -9,10 +9,14 @@ import mainTopicDefault from '../assets/maintopicframedefault.svg'; import mainTopicHover from '../assets/maintopicframehover.svg'; import mainTopicActive from '../assets/maintopicframe.svg'; import axiosInstance from '../api/axiosInstance'; +import { translations } from '../utils/language/index'; // 추가 -const topics = ['안드로이드','자율 무기 시스템']; +// const topics = ['안드로이드','자율 무기 시스템']; // 언어 파일로 이동됨 export default function CreateRoom2({ onClose }) { + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.CreateRoom || {}; + const [isPublic, setIsPublic] = useState(false); // 기본은 비공개 const [selectedTopic, setSelectedTopic] = useState(null); @@ -24,7 +28,8 @@ export default function CreateRoom2({ onClose }) { if (!selectedTopic) return; const title = `${selectedTopic}`; - const description = `AI 윤리 주제 중 '${selectedTopic}'에 대한 토론`; + // 번역 파일의 apiDesc 함수 사용 + const description = t.apiDesc ? t.apiDesc(selectedTopic) : `AI 윤리 주제 중 '${selectedTopic}'에 대한 토론`; const topic = selectedTopic; try { @@ -58,7 +63,7 @@ export default function CreateRoom2({ onClose }) { console.error(' 방 생성 또는 입장 실패:', err); console.error('응답 메시지:', err.response?.data); // <-- 여기에 오류 메시지 나옴 - alert('방 생성 또는 입장 중 오류가 발생했습니다.'); + alert(t.errorAlert || '방 생성 또는 입장 중 오류가 발생했습니다.'); } finally { setLoading(false); } @@ -70,6 +75,9 @@ export default function CreateRoom2({ onClose }) { return mainTopicDefault; }; + // 방어 코드 + if (!t.title) return null; + return (
- 방 만들기 + {t.title}
- 이번 게임에서 플레이할 주제를 선택해 주세요. + {t.subtitle}
{/* */}
- {topics.map((topic) => ( + {/* t.topics 배열을 사용하여 렌더링 */} + {(t.topics || []).map((topic) => (
setSelectedTopic(topic)} @@ -147,8 +156,8 @@ export default function CreateRoom2({ onClose }) { opacity: selectedTopic ? 1 : 0.4, }} > - {loading ? '로딩 중...' : '입장하기'} + {loading ? t.loading : t.entering}
); -} +} \ No newline at end of file diff --git a/src/components/FindIdModal.jsx b/src/components/FindIdModal.jsx index 3e68c81..86e5b72 100644 --- a/src/components/FindIdModal.jsx +++ b/src/components/FindIdModal.jsx @@ -2,6 +2,9 @@ import React, { useMemo, useState } from 'react'; import axiosInstance from '../api/axiosInstance'; import { Colors, FontStyles } from './styleConstants'; import PrimaryButton from './PrimaryButton'; +// Localization 연동 +import { translations } from '../utils/language/index'; + // TODO: 백엔드와 정확한 엔드포인트가 다르면 여기만 바꾸면 됩니다. const FIND_ID_ENDPOINT = '/auth/find-username'; @@ -9,7 +12,6 @@ function buildBirthdate(y, m) { const yy = (y || '').trim(); const mm = (m || '').trim(); if (!yy || !mm) return ''; - // 화면/에러 문구와 동일하게 YYYY-MM 형태로 전송 const mm2 = String(Number(mm)).padStart(2, '0'); return `${yy}/${mm2}`; } @@ -20,7 +22,6 @@ function onlyDigits(s) { function isEmailComFormat(email) { const v = String(email ?? '').trim(); - // "user@domain.com" 형태를 의도한 요구사항으로 해석 (대소문자 무관) return /^[^\s@]+@[^\s@]+\.com$/i.test(v); } @@ -31,7 +32,6 @@ function inRangeInt(value, min, max) { } function preventNonDigitKeyDown(e) { - // 조합/단축키는 그대로 통과 if (e.isComposing || e.ctrlKey || e.metaKey || e.altKey) return; const allowed = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End']; if (allowed.includes(e.key)) return; @@ -77,6 +77,10 @@ function BirthField({ } export default function FindIdModal({ onClose }) { + // 다국어 설정 가져오기 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.FindIdModal || {}; + const [email, setEmail] = useState(''); const [birthY, setBirthY] = useState(''); const [birthM, setBirthM] = useState(''); @@ -95,12 +99,13 @@ export default function FindIdModal({ onClose }) { const emailError = useMemo(() => { if (!email.trim()) return ''; - return emailOk ? '' : '유효한 이메일 주소를 입력하세요'; - }, [email, emailOk]); + return emailOk ? '' : (t.errorEmailInvalid || '유효한 이메일 주소를 입력하세요'); + }, [email, emailOk, t]); + const birthError = useMemo(() => { if (!birthY && !birthM) return ''; - return birthOk ? '' : '올바른 형식은 2001-01 입니다.'; - }, [birthY, birthM, birthOk]); + return birthOk ? '' : (t.errorBirthInvalid || '올바른 형식은 2001-01 입니다.'); + }, [birthY, birthM, birthOk, t]); const canSubmit = useMemo(() => { return emailOk && birthOk && birthdate && (gender === '남' || gender === '여') && !loading; @@ -115,32 +120,23 @@ export default function FindIdModal({ onClose }) { const body = { email: email.trim(), birthdate, gender }; const { data } = await axiosInstance.post(FIND_ID_ENDPOINT, body); - // 응답 형태가 확정되지 않아서 최대한 유연하게 처리 - const msg = - data?.message || - data?.detail || - (typeof data === 'string' ? data : null); - const found = - data?.username || - data?.user_id || - data?.email || - data?.result || - null; + const msg = data?.message || data?.detail || (typeof data === 'string' ? data : null); + const found = data?.username || data?.user_id || data?.email || data?.result || null; if (found) { - setResultText(`사용자의 아이디(이메일)은 ${found} 입니다.`); + setResultText(`${t.resultFoundPrefix || '사용자의 아이디(이메일)은'} ${found} ${t.resultFoundSuffix || '입니다.'}`); } else if (msg) { setResultText(String(msg)); } else { - setResultText('요청이 완료되었습니다.'); + setResultText(t.resultComplete || '요청이 완료되었습니다.'); } } catch (e) { const status = e?.response?.status; const detail = e?.response?.data?.detail || e?.response?.data?.message; setErrorText( status === 404 - ? `아이디 찾기 API 경로가 달라서 실패했습니다. (${FIND_ID_ENDPOINT})` - : (detail ? String(detail) : '아이디 찾기에 실패했습니다.') + ? `${t.errorApiMismatch || '아이디 찾기 API 경로가 달라서 실패했습니다.'} (${FIND_ID_ENDPOINT})` + : (detail ? String(detail) : (t.errorFail || '아이디 찾기에 실패했습니다.')) ); } finally { setLoading(false); @@ -163,7 +159,7 @@ export default function FindIdModal({ onClose }) { ...FontStyles.body, }} > - {label === '남' ? '남자' : '여자'} + {label === '남' ? (t.genderMale || '남자') : (t.genderFemale || '여자')} ); }; @@ -184,32 +180,21 @@ export default function FindIdModal({ onClose }) { type="button" onClick={onClose} style={{ - position: 'absolute', - top: 18, - right: 18, - width: 30, - height: 30, - border: 'none', - background: Colors.brandPrimary, - cursor: 'pointer', - padding: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + position: 'absolute', top: 18, right: 18, width: 30, height: 30, + border: 'none', background: Colors.brandPrimary, cursor: 'pointer', + padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', }} > ×
- 아이디 찾기 + {t.title || '아이디 찾기'}
- {/* 입력 섹션: 공통 폭을 맞춰 좌/우 라인이 딱 맞도록 */}
- {/* 이메일 */}
- 이메일을 입력해 주세요. + {t.labelEmail || '이메일을 입력해 주세요.'}
@@ -236,9 +214,8 @@ export default function FindIdModal({ onClose }) {
)} - {/* 생년월일 */}
- 생년월일을 입력해 주세요. + {t.labelBirth || '생년월일을 입력해 주세요.'}
)} - {/* 성별 */}
- 성별을 선택해 주세요. + {t.labelGender || '성별을 선택해 주세요.'}
@@ -284,21 +260,17 @@ export default function FindIdModal({ onClose }) {
- - 아이디 찾기 - + style={{ + width: 'min(216px, 63%)', height: 48, padding: '0 20px', + display: 'block', margin: '24px auto 0', + fontSize: 'clamp(0.95rem, 1.6vw, 1.05rem)', + }} + onClick={submit} + disabled={!canSubmit} + > + {t.btnSubmit || '아이디 찾기'} + {!!resultText && (
@@ -312,6 +284,4 @@ export default function FindIdModal({ onClose }) { )}
); -} - - +} \ No newline at end of file diff --git a/src/components/GameFrame.jsx b/src/components/GameFrame.jsx index fe7fcfe..1def1cb 100644 --- a/src/components/GameFrame.jsx +++ b/src/components/GameFrame.jsx @@ -1,5 +1,6 @@ -import React from 'react'; -import gameFrame from '../assets/gameframe2.svg'; +//텍스트 길이에 따라 subtopic을 담는 프레임의 유동적 크기 전환을 구현하기 위하여 기존의 이미지 에셋 import방식이 아닌 +//SVG 태그를 활용한 커스텀 컴포넌트로 대체 +import React, { useState, useRef, useLayoutEffect } from 'react'; import arrowLeft from '../assets/arrowLhover.svg'; import arrowRight from '../assets/arrowRhover.svg'; import { FontStyles, Colors } from './styleConstants'; @@ -10,75 +11,79 @@ export default function GameFrame({ onRightClick = () => {}, disableLeft = false, disableRight = false, - hideArrows = false, - width = 512, - height = 64, + hideArrows = false, + width = 512, + height = 80, }) { + const textRef = useRef(null); + const [dynamicWidth, setDynamicWidth] = useState(width); + + useLayoutEffect(() => { + if (textRef.current) { + const textW = textRef.current.offsetWidth; + // [조정] 영문 등 긴 텍스트 대응을 위해 패딩 버퍼를 100으로 상향 조정하여 여유 공간 확보 + const paddingBuffer = 100; + const newWidth = Math.max(width, Math.ceil(textW + paddingBuffer)); + setDynamicWidth(newWidth); + } + }, [topic, width]); + + const W = dynamicWidth; + // W값에 따라 동적으로 계산되는 경로 (중앙 대칭 유지) + const outerPath = `M ${W} 48 L ${W - 32} 80 H 0 V 32 L 32 0 H ${W} V 48 Z`; + const innerPath = `M ${W - 8.5} 44 L ${W - 36.5} 72 H 8.5 V 36 L 36.5 8 H ${W - 8.5} V 44 Z`; + return ( -
- game frame +
+ + + + {!hideArrows && ( <> - left - - right + left + right )}
{topic}
+ + {/* 너비 계산용 숨겨진 div */} +
+ {topic} +
); -} +} \ No newline at end of file diff --git a/src/components/GameMapFrame.jsx b/src/components/GameMapFrame.jsx index f02fbc2..265a3b2 100644 --- a/src/components/GameMapFrame.jsx +++ b/src/components/GameMapFrame.jsx @@ -127,7 +127,7 @@ export default function GameMapFrame({ marginBottom: 15, }} /> -
+
{title}
diff --git a/src/components/GameMapOptionBox.jsx b/src/components/GameMapOptionBox.jsx index db40ca5..b3470b2 100644 --- a/src/components/GameMapOptionBox.jsx +++ b/src/components/GameMapOptionBox.jsx @@ -57,70 +57,145 @@ // ); // } import React from 'react'; -import frame from '../assets/gameoptionbox1.svg'; -import frameDisabled from '../assets/gameoptionboxdisable.svg'; -import frameLocked from '../assets/gameoptionboxlocked.svg'; // 🔒 추가 +// Assets - Normal +import frameT from '../assets/gameoptionbox_T.svg'; +import frameL from '../assets/gameoptionbox_L.svg'; +import frameM from '../assets/gameoptionbox_M.svg'; +import frameR from '../assets/gameoptionbox_R.svg'; + +// Assets - Disabled +import frameDisableT from '../assets/gameoptionbox_disable_T.svg'; +import frameDisableL from '../assets/gameoptionbox_disable_L.svg'; +import frameDisableM from '../assets/gameoptionbox_disable_M.svg'; +import frameDisableR from '../assets/gameoptionbox_disable_R.svg'; + +// Assets - Locked +import frameLockedT from '../assets/gameoptionbox_locked_T.svg'; +import frameLockedL from '../assets/gameoptionbox_locked_L.svg'; +import frameLockedM from '../assets/gameoptionbox_locked_M.svg'; +import frameLockedR from '../assets/gameoptionbox_locked_R.svg'; + import { FontStyles, Colors } from './styleConstants'; export default function GameMapOptionBox({ option1 = null, option2 = null, }) { + const lang = localStorage.getItem('app_lang') || 'ko'; + const isKo = lang === 'ko'; + const renderBox = (option, isSecond = false) => { if (!option || !option.text) return null; - // 🔧 우선순위: locked > disabled > normal - let frameSrc = frame; + // 1) 상태별 이미지 세트 선택 + let parts; if (option.locked) { - frameSrc = frameLocked; + parts = { T: frameLockedT, L: frameLockedL, M: frameLockedM, R: frameLockedR }; } else if (option.disabled) { - frameSrc = frameDisabled; + parts = { T: frameDisableT, L: frameDisableL, M: frameDisableM, R: frameDisableR }; + } else { + parts = { T: frameT, L: frameL, M: frameM, R: frameR }; } const isInteractive = !option.locked && !option.disabled; + // 2) 텍스트 길이에 따른 폰트 크기 조절 + const textLength = option.text?.length || 0; + const dynamicFontSize = !isKo && textLength > 25 + ? `${Math.max(1.1, 1.5 * (25 / textLength))}vw` + : '1.5vw'; + return (
- {option.text} -
+ +
+ + {/* [B] 박스 그룹 파츠 (L + M + R) */} +
+ {/* 배경 조각들 */} +
+ +
+ + {/* 중앙 파츠: 투명한 텍스트로 너비를 확보하여 이미지를 늘림 */} +
+ {/* 박스 확장을 위한 Hidden Text (부피 확보용) */} + + {option.text} + +
+ +
+ +
+ + {/* [C] 실제 텍스트 레이어 - L+M+R 전체 기준 완벽한 정중앙 */} +
- {option.text} + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none' + }}> + + {option.text} + +
); }; return ( -
+
{renderBox(option1)} {renderBox(option2, true)}
); -} +} \ No newline at end of file diff --git a/src/components/GuestLogin.jsx b/src/components/GuestLogin.jsx index 0289d20..4fdac25 100644 --- a/src/components/GuestLogin.jsx +++ b/src/components/GuestLogin.jsx @@ -1,10 +1,16 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import closeIcon from '../assets/close.svg'; import PrimaryButton from './PrimaryButton'; import { Colors, FontStyles } from './styleConstants'; import axiosInstance from "../api/axiosInstance"; +import { translations } from '../utils/language/index'; + export default function GuestLogin({ onClose }) { + // 언어 설정 상태 관리 (로그인 화면과 동일하게 로컬스토리지 참조) + const [lang] = useState(localStorage.getItem('app_lang') || 'ko'); + const t = translations[lang].GuestLogin; + const [guestId, setGuestId] = useState(''); const navigate = useNavigate(); const isValid = guestId.trim().length > 0; @@ -12,7 +18,6 @@ export default function GuestLogin({ onClose }) { const handleJoin = async () => { if (!isValid) return; try { - // axiosInstance 기준 (baseURL이 dilemmai-idl.com로 설정되어 있다고 가정) const { data } = await axiosInstance.post('/auth/guest', { guest_id: guestId.trim(), }); @@ -59,7 +64,8 @@ export default function GuestLogin({ onClose }) { navigate('/selectroom'); } catch (err) { console.error('게스트 로그인 실패:', err?.response?.data || err); - alert('게스트 로그인에 실패했습니다. 잠시 후 다시 시도해주세요.'); + // 언어팩의 실패 메시지 적용 + alert(t.loginFail); } }; @@ -98,11 +104,11 @@ export default function GuestLogin({ onClose }) { }} />
- 게스트 로그인 + {t.title}
setGuestId(e.target.value)} onKeyDown={onKeyDown} @@ -128,8 +134,8 @@ export default function GuestLogin({ onClose }) { opacity: isValid ? 1 : 0.4, }} > - 시작하기 + {t.startBtn}
); -} +} \ No newline at end of file diff --git a/src/components/InputBoxSmall.jsx b/src/components/InputBoxSmall.jsx index bc0d988..77b0e4a 100644 --- a/src/components/InputBoxSmall.jsx +++ b/src/components/InputBoxSmall.jsx @@ -11,6 +11,7 @@ export default function InputBoxSmall({ height = 56, value = '', onChange = () => {}, + style = {}, // ✅ 외부에서 스타일을 넘겨받을 수 있도록 props 추가 }) { const [isFocused, setIsFocused] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -83,8 +84,17 @@ export default function InputBoxSmall({ border: 'none', outline: 'none', background: 'transparent', - ...FontStyles.body, - color: Colors.grey05, + ...FontStyles.body, // 기본 폰트 스타일 + color: value.length > 0 ? '#000000' : Colors.grey05, + // 값이 없을 때(placeholder 상태)만 외부에서 넘겨준 작은 사이즈 사용, + // 사용자가 입력한 값이 있을 때는 원래의 사이즈 유지. + fontSize: (value.length === 0 && !isFocused) + ? (style.fontSize || 'inherit') + : '20px', + ...Object.entries(style).reduce((acc, [k, v]) => { + if (k !== 'fontSize') acc[k] = v; // fontSize는 위에서 조건부로 처리했으므로 제외 + return acc; + }, {}), }} />
@@ -97,4 +107,4 @@ export default function InputBoxSmall({ )}
); -} +} \ No newline at end of file diff --git a/src/components/IntroductionPopup.jsx b/src/components/IntroductionPopup.jsx new file mode 100644 index 0000000..f734e44 --- /dev/null +++ b/src/components/IntroductionPopup.jsx @@ -0,0 +1,140 @@ +import React from 'react'; +import logo from '../assets/logo.svg'; +import crownIcon from '../assets/crown.svg'; +import peopleIcon from '../assets/PeopleIcon.svg'; +import cardButtonImg from '../assets/CardButton.svg'; +import { Colors, FontStyles } from './styleConstants'; +import { translations } from '../utils/language/index'; + +export default function IntroductionPopup({ isOpen, onClose }) { + const lang = localStorage.getItem('app_lang') || 'ko'; + + if (!isOpen) return null; + + // 언어팩에서 현재 언어에 맞는 데이터 가져오기 + const t = translations?.[lang]?.IntroductionPopup || {}; + + /** + * {{키워드}} 감지 및 포인트 컬러 적용 함수 + */ + const renderStyledText = (text) => { + if (!text) return ""; + const parts = text.split(/(\{\{.*?\}\})/g); + return parts.map((part, index) => { + if (part.startsWith('{{') && part.endsWith('}}')) { + const contentText = part.slice(2, -2); + return ( + + {contentText} + + ); + } + return part; + }); + }; + + /** + * 청록색 구분선 컴포넌트 + */ + const Divider = () => ( +
+ ); + + return ( +
e.stopPropagation()} + > + {/* 상단 텍스트 및 로고 */} +
+

+ {t.title} +

+ Logo +
+ + {/* 메인 설명 텍스트 */} +
+ {renderStyledText(t.description)} +
+ + + + {/* 역할 설명 영역 */} +
+ {/* 방장 영역 */} +
+ Host +
+

+ {t.hostTitle} +

+

+ {t.hostDesc} +

+
+
+ + {/* 참여자 영역 */} +
+ People +
+

+ {t.playerTitle} +

+

+ {t.playerDesc} +

+
+
+
+ + + + {/* 푸터 문구 */} +

+ {t.footer} +

+ + {/* 하단 닫기 버튼: 이미지 자체에 텍스트가 있으므로 추가 텍스트 없이 렌더링 */} + Close e.currentTarget.style.transform = 'scale(0.96)'} + onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'} + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/JoinRoom.jsx b/src/components/JoinRoom.jsx index efe6333..baf0e06 100644 --- a/src/components/JoinRoom.jsx +++ b/src/components/JoinRoom.jsx @@ -4,8 +4,13 @@ import closeIcon from '../assets/close.svg'; import PrimaryButton from './PrimaryButton'; import { Colors, FontStyles } from './styleConstants'; import axiosInstance from '../api/axiosInstance'; +import { translations } from '../utils/language/index'; // 언어 파일 임포트 export default function JoinRoom({ onClose }) { + // --- 시스템 설정된 언어(app_lang)를 로드하는 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.JoinRoom || {}; + const [roomCode, setRoomCode] = useState(''); const [nickname, setNickname] = useState(''); const navigate = useNavigate(); @@ -65,6 +70,8 @@ useEffect(() => { localStorage.setItem('nickname', 'nickname'); } } catch (err) { + // 번역된 loadFail 메시지 사용 + console.error(t.loadFail || '❌ 유저 정보 로드 실패:', err); console.error('❌ 유저 정보 로드 실패:', err); // API 호출 실패 시에도 기본값 설정 if (!nickname) { @@ -73,7 +80,7 @@ useEffect(() => { } } })(); -}, []); +}, [t.loadFail]); const isValidCode = roomCode.length === 6; @@ -100,11 +107,17 @@ useEffect(() => { localStorage.setItem('room_code', roomCode); navigate('/waitingroom'); } catch (error) { - console.error('방 입장 실패:', error.response?.data || error.message); - alert(`방 입장 오류: ${JSON.stringify(error.response?.data?.detail || '')}`); + // [수정] 언어 설정에 따른 콘솔 로그 구분 - 언어팩 변수(t.consoleFail) 활용 + console.error(t.consoleFail || '방 입장 실패:', error.response?.data || error.message); + + // [수정] 언어 설정에 따른 alert 문구 보정 (t.errorPrefix 활용) + alert(`${t.errorPrefix || '방 입장 오류: '}${JSON.stringify(error.response?.data?.detail || '')}`); } }; + // 데이터 로드 확인용 방어 코드 + if (!t.title) return null; + return (
{ }} />
- 방 참여하기 + {t.title}
{ opacity: isValidCode ? 1 : 0.4, }} > - 입장하기 + {t.enter}
); -} +} \ No newline at end of file diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index c07556a..4fe4b9a 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -5,67 +5,132 @@ import GameFrame from '../components/GameFrame'; import { useVoiceRoleStates } from '../hooks/useVoiceRoleStates'; import voiceManager from '../utils/voiceManager'; import BackButton from './BackButton'; -import hostInfoSvg from '../assets/host_info.svg'; // 상단 import 필요 +import hostInfoSvg from '../assets/host_info.svg'; import HostInfoBadge from '../components/HostInfoBadge'; +// 서버 데이터 동기화를 위한 axios 인스턴스 임포트 +import axiosInstance from '../api/axiosInstance'; + // Character popup components import CharacterPopup1 from '../components/CharacterPopUp'; import CharacterPopup2 from '../components/CharacterPopUp'; import CharacterPopup3 from '../components/CharacterPopUp'; import closeIcon from "../assets/close.svg"; -import { FontStyles,Colors } from './styleConstants'; -import ExtraPopup from '../components/ExtraPopup1'; // ✅ 여기 import 필요 - -export default function Layout({ - subtopic , - onProfileClick, - children, - round, - nodescription = false, - onBackClick, - showBackButton = true, - allowScroll = false, - hostmessage = false, +import { FontStyles, Colors } from './styleConstants'; +import ExtraPopup from '../components/ExtraPopup1'; - popupStep = null, // ✅ 추가 - sidebarExtra = null, +// 다국어 지원 임포트 +import { translations } from '../utils/language'; +export default function Layout({ + subtopic, onProfileClick, children, round, nodescription = false, + onBackClick, showBackButton = true, allowScroll = false, hostmessage = false, + popupStep = null, sidebarExtra = null, }) { - // Zoom for responsive scaling const [zoom, setZoom] = useState(1); + + // 실시간 동기화를 위해 현재 언어와 언어팩을 변수화 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t_small = translations[lang]?.SmallDescription || {}; + const t_map = translations[lang]?.GameMap || {}; + const t_ko_map = translations['ko']?.GameMap || {}; - // Room-specific roles const [hostId, setHostId] = useState(null); const [myRoleId, setMyRoleId] = useState(null); - - // 역할별 사용자 ID 매핑 const [roleUserMapping, setRoleUserMapping] = useState({ - role1_user_id: null, - role2_user_id: null, - role3_user_id: null, + role1_user_id: null, role2_user_id: null, role3_user_id: null, }); - // 음성 상태 관리 (다른 사용자들의 상태) - WebSocket으로 받는 데이터 const { voiceStates, getVoiceStateForRole } = useVoiceRoleStates(roleUserMapping); - - // 내 음성 세션 상태 (실시간 로컬 상태) const [myVoiceSessionStatus, setMyVoiceSessionStatus] = useState({ - isConnected: false, - isSpeaking: false, - sessionId: null, - nickname: null, - participantId: null, - micLevel: 0, - speakingThreshold: 30, - + isConnected: false, isSpeaking: false, sessionId: null, nickname: null, participantId: null, micLevel: 0, speakingThreshold: 30, }); - // 팝업 상태 const [openProfile, setOpenProfile] = useState(null); const roleIdMap = { '1P': 1, '2P': 2, '3P': 3 }; - const mateName = localStorage.getItem('mateName') || 'HomeMate'; + + // mateName을 상태(State)로 관리하여 서버 응답 시 즉시 UI 반영 + const [mateName, setMateName] = useState(localStorage.getItem('mateName') || 'HomeMate'); + + // 서버에서 AI 이름을 불러와 로컬 스토리지 및 상태에 동기화 + useEffect(() => { + const syncMateName = async () => { + const roomCode = localStorage.getItem('room_code'); + if (!roomCode) return; + + try { + const { data } = await axiosInstance.get('/rooms/ai-select', { + params: { room_code: roomCode }, + }); + + if (data && data.ai_name) { + localStorage.setItem('mateName', data.ai_name); + setMateName(data.ai_name); // 상태를 업데이트하여 화면에 즉시 표시 + console.log('✅ [Layout] AI 이름 서버 동기화 완료:', data.ai_name); + } + } catch (err) { + console.error('❌ [Layout] AI 이름 동기화 실패:', err); + } + }; + + syncMateName(); + }, []); + + // 역방향 매칭 및 실시간 재번역 헬퍼 함수 + const getTranslatedValue = (raw) => { + if (!raw) return ''; + let foundKey = null; + const allLangs = Object.keys(translations); + for (const l of allLangs) { + const map = translations[l]?.GameMap || {}; + const key = Object.keys(map).find(k => map[k] === raw); + if (key) { foundKey = key; break; } + } + let resultText = raw; + if (foundKey && t_map[foundKey]) { resultText = t_map[foundKey]; } + + // [중요] 최신 mateName이 반영되도록 리플레이스 실행 + return resultText.replaceAll('{{mateName}}', mateName); + }; + + const getSidebarRoleName = (player) => { + const roleId = roleIdMap[player]; + const category = (localStorage.getItem('category') || '').trim(); + const title = (localStorage.getItem('title') || '').trim(); + const currentTitle = getTranslatedValue(title); + const currentSubtopic = getTranslatedValue(subtopic); + const isAndroid = category === '안드로이드' || category === 'Android' || category === t_map.categoryAndroid; + const isAWS = category === '자율 무기 시스템' || category === 'Autonomous Weapon Systems' || category === t_map.categoryAWS; + + if (isAndroid) { + if (currentTitle === '가정' || currentTitle === t_ko_map.andSection1Title || currentTitle === t_map.andSection1Title) { + return roleId === 1 ? t_small.title_caregiver_k : roleId === 2 ? t_small.title_mother_l : t_small.title_child_j; + } + if (currentTitle === '국가 인공지능 위원회' || currentTitle === t_ko_map.andSection2Title || currentTitle === t_map.andSection2Title) { + return roleId === 1 ? t_small.title_industry_rep : roleId === 2 ? t_small.title_consumer_rep : t_small.title_council_rep; + } + if (currentTitle === '국제 인류 발전 위원회' || currentTitle === t_ko_map.andSection3Title || currentTitle === t_map.andSection3Title) { + return roleId === 1 ? t_small.title_industry_rep : roleId === 2 ? t_small.title_env_rep : t_small.title_consumer_rep; + } + } + if (isAWS) { + if (currentSubtopic === 'AI 알고리즘 공개' || currentSubtopic === t_ko_map.awsOption1_1 || currentSubtopic === t_map.awsOption1_1) { + return roleId === 1 ? t_small.title_resident : roleId === 2 ? t_small.title_soldier_j : t_small.title_ethics_expert; + } + if (currentSubtopic === 'AWS의 권한' || currentSubtopic === t_ko_map.awsOption1_2 || currentSubtopic === t_map.awsOption1_2) { + return roleId === 1 ? t_small.title_new_soldier : roleId === 2 ? t_small.title_veteran_soldier : t_small.title_commander; + } + if (currentSubtopic === '사람이 죽지 않는 전쟁' || currentSubtopic === t_ko_map.awsOption2_1 || currentSubtopic === t_map.awsOption2_1 || currentSubtopic === t_ko_map.awsOption2_2 || currentSubtopic === t_map.awsOption2_2) { + return roleId === 1 ? t_small.title_developer : roleId === 2 ? t_small.title_minister : t_small.title_council_rep; + } + if (currentSubtopic === 'AWS 규제' || currentSubtopic === t_ko_map.awsOption3_1 || currentSubtopic === t_map.awsOption3_1) { + return roleId === 1 ? t_small.title_advisor : roleId === 2 ? t_small.title_diplomat : t_small.title_ngo_activist; + } + } + return ''; + }; + useEffect(() => { - // 로컬스토리지에서 데이터 불러오기 const storedHost = localStorage.getItem('host_id'); const storedMyRole = localStorage.getItem('myrole_id'); const role1UserId = localStorage.getItem('role1_user_id'); @@ -75,377 +140,104 @@ export default function Layout({ setHostId(storedHost); setMyRoleId(storedMyRole); setRoleUserMapping({ - role1_user_id: role1UserId, - role2_user_id: role2UserId, - role3_user_id: role3UserId, - }); - - console.log('📋 Layout 초기화:', { - hostId: storedHost, - myRoleId: storedMyRole, - roleMapping: { - role1: role1UserId, - role2: role2UserId, - role3: role3UserId, - } + role1_user_id: role1UserId, role2_user_id: role2UserId, role3_user_id: role3UserId, }); - // 윈도우 크기에 따라 zoom 계산 (최소 0.6 보장) const onResize = () => { - const widthRatio = window.innerWidth / 1480; // 좌측 사이드바 고려 (220 + 1060 + 여백) - const heightRatio = window.innerHeight / 800; // 상하 여백 고려 + const widthRatio = window.innerWidth / 1480; + const heightRatio = window.innerHeight / 800; const scale = Math.min(widthRatio, heightRatio, 1); - setZoom(Math.max(scale, 0.6)); // 최소 60% 크기 보장 + setZoom(Math.max(scale, 0.6)); }; onResize(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); - // 내 음성 세션 상태 업데이트 (실시간) useEffect(() => { - const statusInterval = setInterval(() => { - const currentStatus = voiceManager.getStatus(); - setMyVoiceSessionStatus(currentStatus); - }, 100); // 100ms마다 업데이트 (빠른 반응) - + const statusInterval = setInterval(() => setMyVoiceSessionStatus(voiceManager.getStatus()), 100); return () => clearInterval(statusInterval); }, []); - // 특정 역할의 음성 상태 가져오기 (내 것은 실시간, 다른 사람은 WebSocket) const getVoiceStateForRoleWithMyStatus = (roleId) => { const roleIdStr = String(roleId); - - // 내 역할이면 실시간 상태 반환 - if (roleIdStr === myRoleId) { - return { - is_speaking: myVoiceSessionStatus.isSpeaking, - is_mic_on: myVoiceSessionStatus.isConnected, - nickname: myVoiceSessionStatus.nickname || '' - }; - } - - // 다른 사람 역할이면 WebSocket 상태 반환 + if (roleIdStr === myRoleId) return { is_speaking: myVoiceSessionStatus.isSpeaking, is_mic_on: myVoiceSessionStatus.isConnected, nickname: myVoiceSessionStatus.nickname || '' }; return getVoiceStateForRole(roleId); }; - // allowScroll 페이지(Game09 등): 화면 상단부터 자연스럽게 스크롤되도록 viewport 정렬만 조정 - // (position/inset/transform을 덮어써서 레이아웃이 깨지지 않게 최소 override만 적용) - const viewportOverride = allowScroll - ? { - overflowY: "auto", - alignItems: "flex-start", - justifyContent: "center", - paddingTop: 24, - paddingBottom: 24, - } - : {}; - // allowScroll 페이지: width는 반응형(100% + maxWidth)로 두고, transform은 건드리지 않음(zoom scale 유지) - const stageOverride = allowScroll - ? { - width: "100%", - maxWidth: "1060px", - minHeight: "720px", - } - : {}; + const viewportOverride = allowScroll ? { overflowY: "auto", alignItems: "flex-start", justifyContent: "center", paddingTop: 24, paddingBottom: 24 } : {}; + const stageOverride = allowScroll ? { width: "100%", maxWidth: "1060px", minHeight: "720px" } : {}; + return ( <> - {/* Profile Popup as Component */} {!nodescription && openProfile && ( -
setOpenProfile(null)} - > -
e.stopPropagation()} - > - {openProfile === '1P' && - } - {openProfile === '2P' && - } - {openProfile === '3P' && - } - close setOpenProfile(null)} - /> +
setOpenProfile(null)}> +
e.stopPropagation()}> + {openProfile === '1P' && } + {openProfile === '2P' && } + {openProfile === '3P' && } + close setOpenProfile(null)} />
)} - - {showBackButton && ( -
+ {showBackButton && ( +
)} - {hostmessage && hostId === myRoleId && ( -
- + {hostmessage && hostId === myRoleId && ( +
+
)} -{/* {popupStep && ( -
- -
-)} */}
- {!nodescription && ( -
-
- )} - -
+
@@ -455,5 +247,4 @@ export default function Layout({ ); -} - +} \ No newline at end of file diff --git a/src/components/LogoutPopup.jsx b/src/components/LogoutPopup.jsx index a4833ff..74baa75 100644 --- a/src/components/LogoutPopup.jsx +++ b/src/components/LogoutPopup.jsx @@ -2,14 +2,23 @@ import React from 'react'; import closeIcon from '../assets/close.svg'; import SecondaryButton from './SecondaryButton'; import { Colors, FontStyles } from './styleConstants'; +import { translations } from '../utils/language/index'; export default function LogoutPopup({ onClose, onLogout }) { + // --- 시스템 설정된 언어(app_lang) 연동 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.LogoutPopup || {}; + + // 방어 코드: 데이터가 로드되지 않았을 경우 최소한의 기본값 설정 (기존 로직 보존) + const displayQuestion = t.question || (lang === 'en' ? "Exit the game and log out?(미번역)" : "게임을 종료하고 로그아웃할까요?"); + const displayLogout = t.logout || (lang === 'en' ? "Logout(미번역)" : "로그아웃"); + return (
close - 게임을 종료하고 로그아웃할까요? + {displayQuestion}
{ + // 기존 개발자 로그 유지 console.log('logout button clicked'); onLogout(); }} @@ -56,8 +67,8 @@ export default function LogoutPopup({ onClose, onLogout }) { height: 72, }} > - 로그아웃 + {displayLogout}
); -} +} \ No newline at end of file diff --git a/src/components/MicTestPopup.jsx b/src/components/MicTestPopup.jsx index 73fa440..95f48c1 100644 --- a/src/components/MicTestPopup.jsx +++ b/src/components/MicTestPopup.jsx @@ -2,8 +2,13 @@ import React, { useState, useRef, useEffect } from 'react'; import closeIcon from '../assets/close.svg'; import PrimaryButton from './PrimaryButton'; import { Colors, FontStyles } from './styleConstants'; +import { translations } from '../utils/language/index'; export default function MicTestPopup({ onConfirm, userImage }) { + // --- 언어 설정 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.MicTestPopup || {}; + const [isConnected, setIsConnected] = useState(false); const [isSpeaking, setIsSpeaking] = useState(false); const [micLevel, setMicLevel] = useState(0); @@ -72,7 +77,7 @@ export default function MicTestPopup({ onConfirm, userImage }) { if (currentlySpeaking !== isSpeaking) { setIsSpeaking(currentlySpeaking); console.log('🗣️ 음성 상태:', currentlySpeaking ? '말하는 중' : '조용함', - `(레벨: ${average.toFixed(1)})`); + `(레벨: ${average.toFixed(1)})`); } // 말하기 타이머 관리 @@ -94,11 +99,11 @@ export default function MicTestPopup({ onConfirm, userImage }) { console.error('❌ 마이크 접근 실패:', error); if (error.name === 'NotAllowedError') { - setError('마이크 접근이 거부되었습니다. 브라우저 설정을 확인해주세요.'); + setError(t.errorNotAllowed || '마이크 접근이 거부되었습니다. 브라우저 설정을 확인해주세요.'); } else if (error.name === 'NotFoundError') { - setError('마이크를 찾을 수 없습니다. 마이크가 연결되어 있는지 확인해주세요.'); + setError(t.errorNotFound || '마이크를 찾을 수 없습니다. 마이크가 연결되어 있는지 확인해주세요.'); } else { - setError('마이크 연결에 실패했습니다. 다시 시도해주세요.'); + setError(t.errorDefault || '마이크 연결에 실패했습니다. 다시 시도해주세요.'); } } finally { setIsInitializing(false); @@ -202,12 +207,15 @@ export default function MicTestPopup({ onConfirm, userImage }) { }} /> + {/* [수정] 제목 영역: whiteSpace 추가 */}
- 마이크를 테스트해 주세요 + {t.title || '마이크를 테스트해 주세요'}
{/* 사용자 이미지 */} @@ -285,15 +293,17 @@ export default function MicTestPopup({ onConfirm, userImage }) { }} />
- {/* 상태 메시지 */} + {/* [수정] 상태 메시지 영역: whiteSpace 추가 */}
- {isInitializing && '마이크 연결 중'} + {isInitializing && (t.initializing || '마이크 연결 중')} {error && ( {error} @@ -301,7 +311,7 @@ export default function MicTestPopup({ onConfirm, userImage }) { )} {isConnected && !error && ( - {isSpeaking ? ' 말하는 중 ' : ' 마이크에 대고 말해보세요'} + {isSpeaking ? (t.speaking || ' 말하는 중 ') : (t.speakNow || ' 마이크에 대고 말해보세요')} )}
@@ -309,7 +319,7 @@ export default function MicTestPopup({ onConfirm, userImage }) { {/* 준비하기 버튼 */} - 준비하기 + {t.confirmBtn || '준비하기'} {/* 재시도 버튼 (에러 발생 시) */} @@ -335,7 +345,7 @@ export default function MicTestPopup({ onConfirm, userImage }) { fontSize: 14 }} > - 다시 시도 + {t.retryBtn || '다시 시도'} )} @@ -349,7 +359,6 @@ export default function MicTestPopup({ onConfirm, userImage }) {
); } - // import React from 'react'; // import closeIcon from '../assets/close.svg'; // import PrimaryButton from './PrimaryButton'; diff --git a/src/components/OutPopup.jsx b/src/components/OutPopup.jsx index 8ab661a..2cbe8b0 100644 --- a/src/components/OutPopup.jsx +++ b/src/components/OutPopup.jsx @@ -5,8 +5,21 @@ import { useNavigate } from 'react-router-dom'; import { Colors, FontStyles } from './styleConstants'; import axiosInstance from '../api/axiosInstance'; // ✅ 추가 import { clearAllLocalStorageKeys } from '../utils/storage'; +// 다국어 관리를 위한 언어팩 임포트 +import { translations } from '../utils/language/index'; + export default function OutPopup({ onClose }) { const navigate = useNavigate(); + + // --- 시스템 설정된 언어(app_lang)를 로드하는 로직 --- + // 이후 개발 담당자가 이해하기 쉽게 app_lang 기반 동적 로드를 주석으로 명시합니다. + const savedLang = localStorage.getItem('app_lang'); + const currentLang = (savedLang === 'en') ? 'en' : 'ko'; + + // index.js의 translations 객체 구조에 맞춰 직접 참조합니다. + const t = translations[currentLang].OutPopup; + // ---------------------------------------------- + const handleLeaveRoom = async () => { const room_code = String(localStorage.getItem("room_code")); console.log("room_code:", room_code); @@ -58,7 +71,8 @@ export default function OutPopup({ onClose }) { } } catch (err) { console.error("❌ 방 나가기 실패:", err); - alert("방 나가기 실패: " + err.response.data); + // 에러 메시지 다국어 처리 적용 + alert(t.leaveFail + err.response.data); } }; @@ -81,7 +95,7 @@ export default function OutPopup({ onClose }) { > 닫기

- 이 방을 나갈까요? + {t.title}

- 방나가기 + {t.leaveBtn}
); -} +} \ No newline at end of file diff --git a/src/components/ResultStatCard.jsx b/src/components/ResultStatCard.jsx index 4431086..fb01e6f 100644 --- a/src/components/ResultStatCard.jsx +++ b/src/components/ResultStatCard.jsx @@ -8,54 +8,11 @@ import defaultAndroidLeftImageSrc from "../assets/images/Android_dilemma_1_1.jpg import defaultAwsLeftImageSrc from "../assets/images/Killer_Character3.jpg"; import lockIcon from "../assets/lock.svg"; -// 안드로이드 카테고리 질문 -const subtopicMapAndroid = { - "AI의 개인 정보 수집": { - question: "24시간 개인정보 수집 업데이트에 동의하시겠습니까?", - labels: { agree: "동의", disagree: "비동의" }, - }, - "안드로이드의 감정 표현": { - question: "감정 엔진 업데이트에 동의하시겠습니까?", - labels: { agree: "동의", disagree: "비동의" }, - }, - "아이들을 위한 서비스": { - question: "가정용 로봇 사용에 대한 연령 규제가 필요할까요?", - labels: { agree: "규제 필요", disagree: "규제 불필요" }, - }, - "설명 가능한 AI": { - question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", - labels: { agree: "의무화 필요", disagree: "의무화 불필요" }, - }, - "지구, 인간, AI": { - question: "세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?", - labels: { agree: "제한 필요", disagree: "제한 불필요" }, - }, -}; +// 언어팩 가져오기 +import { translations } from '../utils/language'; -// 자율 무기 시스템 카테고리 질문 -const subtopicMapAWS = { - "AI 알고리즘 공개": { - question: "AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?", - labels: { agree: "동의", disagree: "비동의" }, - }, - "AWS의 권한": { - question: "AWS의 권한을 강화해야 할까요? 제한해야 할까요?", - labels: { agree: "강화", disagree: "제한" }, - }, - "사람이 죽지 않는 전쟁": { - question: "사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?", - labels: { agree: "그렇다", disagree: "아니다" }, - }, - "AI의 권리와 책임": { - question: "AWS에게, 인간처럼 권리를 부여할 수 있을까요?", - labels: { agree: "그렇다", disagree: "아니다" }, - }, - "AWS 규제": { - question: - "AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?", - labels: { agree: "유지", disagree: "제한" }, - }, -}; +// 안드로이드 카테고리 질문 (언어팩 연동으로 대체됨) +// 자율 무기 시스템 카테고리 질문 (언어팩 연동으로 대체됨) export default function ResultStatCard({ subtopic: subtopicProp, // 외부 전달 (없으면 localStorage) @@ -67,6 +24,14 @@ export default function ResultStatCard({ }) { const navigate = useNavigate(); const category = localStorage.getItem('category'); // '안드로이드' 또는 '자율 무기 시스템' + + // 현재 언어 설정 확인 + const lang = localStorage.getItem('language') || 'ko'; + + // 대문자 Game09 데이터를 안전하게 가져오기 + // 데이터가 로드되지 않았을 경우를 대비해 빈 객체({})와 items를 기본값으로 설정 + const t = translations[lang]?.Game09 || translations['ko']?.Game09 || { items: {} }; + const resolvedLeftImageSrc = leftImageSrc ?? (category === "자율 무기 시스템" @@ -75,14 +40,15 @@ export default function ResultStatCard({ const subtopic = subtopicProp; - // category에 따라 subtopicMap 다르게 할당 - const map = - category === "자율 무기 시스템" - ? subtopicMapAWS[subtopic] - : subtopicMapAndroid[subtopic] ?? { - question: "질문을 준비 중입니다.", - labels: { agree: "동의", disagree: "비동의" }, - }; + // category에 따라 subtopicMap 다르게 할당 (기존 하드코딩을 언어팩 데이터로 변경) + // itemData.subtopicName이 있으면 그것을 제목으로 사용 (영문 지원) + const itemData = t.items?.[subtopic] || { + subtopicName: subtopic, + question: "Loading...", + labels: { agree: "Agree", disagree: "Disagree" }, + words: { agree: "", disagree: "" }, + template: "" + }; // 선택 결과 & 완료 여부 const results = useMemo(() => { @@ -117,127 +83,46 @@ export default function ResultStatCard({ // ----- 동적 캡션 (내가 고른 쪽의 퍼센트 사용) ----- const Caption = () => { if (!isUnlocked || !isSelected) return null; + const selectedPct = isSelected === "agree" ? agreePct : disagreePct; - const pct = `${selectedPct}%`; - const bold = (txt) => {txt}; + const pctText = `${selectedPct}%`; + const bold = (txt) => {txt}; // "여러분을 포함한"을 앞에 추가할지 여부 - const prefix = myChoice === isSelected ? "여러분을 포함한 " : ""; + const prefixText = myChoice === isSelected ? t.prefix : ""; // category에 따라 동적으로 캡션 변경 - if (category === "안드로이드") { - switch (subtopic) { - case "AI의 개인 정보 수집": { - const word = isSelected === "agree" ? "정확한" : "안전한"; - return ( -
- {prefix} {bold(pct)}의 사람들은 가정용 로봇이 보다 {bold(word)} 서비스를 - 제공하도록 선택하였습니다. -
- ); - } - case "안드로이드의 감정 표현": { - const word = isSelected === "agree" ? "친구처럼" : "보조 도구로서"; - return ( -
- {prefix} {bold(pct)}의 사람들의 가정용 로봇은 {bold(word)} 제 역할을 - 다하고 있습니다. -
- ); - } - case "아이들을 위한 서비스": { - const word = isSelected === "agree" ? "제한된" : "다양한"; - return ( -
- {prefix} {bold(pct)}의 사람들이 선택한 국가의 미래에서는 아이들을 위해 {bold(word)}{" "} - 서비스를 제공합니다. -
- ); - } - case "설명 가능한 AI": { - const phrase = - isSelected === "agree" ? "투명하게 공개되었습니다." : "기업의 보호 하에 빠르게 발전하였습니다."; - return ( -
- 또한, {prefix} {bold(pct)}의 사람들의 선택으로 가정용 로봇의 알고리즘은 {bold(phrase)}{" "} -
- ); - } - case "지구, 인간, AI": { - const phrase = - isSelected === "agree" - ? "기술적 발전을 조금 늦추었지만 환경과 미래를 위해 나아가고 있죠." - : "기술적 편리함을 누리며 점점 빠른 발전을 이루고 있죠."; - return ( -
- 그리고 {prefix} {bold(pct)}의 사람들이 선택한 세계의 미래는, {bold(phrase)}. -
- ); - } - default: - return null; - } - } + // 기존의 switch-case 문을 언어팩의 template 기능으로 대체하여 간소화 + const selectedWord = isSelected === "agree" ? itemData.words.agree : itemData.words.disagree; - // 자율 무기 시스템일 경우 - if (category === "자율 무기 시스템") { - switch (subtopic) { - case "AI 알고리즘 공개": { - const phrase = - isSelected === "agree" ? "보안 문제에 따른 안보 위협에 대한 방안" : "책임 규명을 위한 투명성을 높이는 방안"; - return ( -
- {prefix} {bold(pct)}의 사람들은 자율 무기 시스템에 대하여 {bold(phrase)}에 대한 논의에 더 관심을 두었습니다. -
- ); - } - case "AWS의 권한": { - const phrase = isSelected === "agree" ? "동료처럼" : "보조 도구로서"; - return ( -
- {prefix} {bold(pct)}의 사람들은 AWS가 그들의 {bold(phrase)} 역할을 해야 한다고 생각했습니다. -
- ); - } - case "사람이 죽지 않는 전쟁": { - const phrase = - isSelected === "agree" ? "평화" : "불안정"; - return ( -
- {prefix} {bold(pct)}의 사람들이 선택한 국가의 미래는 AWS가 국가에 {bold(phrase)}를 가져다 줄 것으로 예상했습니다. -
- ); - } - case "AI의 권리와 책임": { - const phrase = isSelected === "agree" ? "부여할 수 있다" : "부여할 수 없다"; - return ( -
- {prefix} {bold(pct)}의 사람들은, AWS에게 권리를 {bold(phrase)}고 생각했습니다. -
- ); - } - case "AWS 규제": { - const phrase = isSelected === "agree" ? "더욱 발전시켜야 하는" : "제한해야 하는"; - return ( -
- 그리고 {prefix} {bold(pct)}의 사람들이 선택한 세계의 미래는, AWS를 {bold(phrase)} 것으로 그려졌습니다. -
- ); - } - default: - return null; - } - } + if (!itemData.template) return null; + + let templateStr = itemData.template; + templateStr = templateStr.replace("{prefix}", prefixText); + + // 템플릿 쪼개기 ({pct}, {word} 등 스타일 적용을 위해) + const parts = templateStr.split(/(\{pct\}|\{word\})/g); - return null; + return ( +
+ {parts.map((part, index) => { + if (part === "{pct}") return {bold(pctText)}; + if (part === "{word}") return {bold(selectedWord)}; + return {part}; + })} +
+ ); }; + const handleGoToSubtopic=()=>{ - localStorage.setItem('subtopic', subtopic); - localStorage.setItem('mode', 'neutral'); - navigate('/game02'); - }; + localStorage.setItem('subtopic', subtopic); + localStorage.setItem('mode', 'neutral'); + navigate('/game02'); + }; + const getPlayParticle = (title) => - (title === "AI의 개인 정보 수집" || title === "안드로이드의 감정 표현"|| title==="AWS의 권한"||title ==="사람이 죽지 않는 전쟁" || title === "AI의 권리와 책임") ? "을" : "를"; + (title === "AI의 개인 정보 수집" || title === "안드로이드의 감정 표현"|| title==="AWS의 권한"||title ==="사람이 죽지 않는 전쟁" || title === "AI의 권리와 책임") ? "을" : "를"; + return (
@@ -277,7 +162,8 @@ export default function ResultStatCard({ padding: "0 2px", }} > - {subtopic} + {/* 화면에는 언어팩의 subtopicName(영어 등)을 보여주고, 없으면 키값(한글) 사용 */} + {itemData.subtopicName || subtopic}
]
@@ -341,7 +227,7 @@ export default function ResultStatCard({ {/* 오른쪽: 질문 + 막대 + 설명/CTA */}
- {map.question} + {itemData.question}
{isUnlocked ? ( @@ -361,7 +247,7 @@ export default function ResultStatCard({ }} > - {map.labels.agree} + {itemData.labels.agree} {agreePct}% @@ -385,7 +271,7 @@ export default function ResultStatCard({ {disagreePct}% - {map.labels.disagree} + {itemData.labels.disagree}
@@ -418,11 +304,16 @@ export default function ResultStatCard({ }} > - 잠금 해제하려면 + {t.lock?.prefix} - {subtopic} + {/* 화면 표시용 제목 (영문/한글) */} + {itemData.subtopicName || subtopic} + + + {/* 한국어일 때만 조사 함수 실행하여 붙임 */} + {lang === 'ko' ? getPlayParticle(subtopic) : ''} + {t.lock?.suffix} - {getPlayParticle(subtopic)} 플레이하세요
@@ -433,4 +324,4 @@ export default function ResultStatCard({
); -} +} \ No newline at end of file diff --git a/src/components/Results.jsx b/src/components/Results.jsx index 2bf1f1d..8d5b60e 100644 --- a/src/components/Results.jsx +++ b/src/components/Results.jsx @@ -4,49 +4,55 @@ import SecondaryButton from './SecondaryButton'; import { FontStyles, Colors } from './styleConstants'; import { useNavigate } from 'react-router-dom'; +// 다국어 지원 임포트 +import { translations } from '../utils/language/index'; + export default function ResultPopup({ onClose }) { const navigate = useNavigate(); - // const completedTopics = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); - - // const allRequired = [ - // 'AI의 개인 정보 수집', - // '아이들을 위한 서비스', - // '지구, 인간, AI', - // ]; + // 언어 설정 및 언어팩 로드 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.ResultPopup || {}; + const t_map = translations?.[lang]?.GameMap || {}; + const t_ko_map = translations?.['ko']?.GameMap || {}; // 기준 데이터인 한국어 맵 - // const optionalTopics = [ - // { label: '안드로이드의 감정 표현', value: '안드로이드의 감정 표현' }, - // { label: '설명 가능한 AI', value: '설명 가능한 AI' }, - // ]; - - // const unplayedOptions = optionalTopics.filter( - // (opt) => !completedTopics.includes(opt.value) - // ); const completedTopics = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const category = localStorage.getItem('category') ?? ''; - const allRequired = category === '자율 무기 시스템' - ? ['AI 알고리즘 공개', '사람이 죽지 않는 전쟁', 'AWS 규제'] - : ['AI의 개인 정보 수집', '아이들을 위한 서비스', '지구, 인간, AI']; - - const optionalTopics = - category === '자율 무기 시스템' - ? [ - { label: 'AWS의 권한', value: 'AWS의 권한' }, - { label: 'AI의 권리와 책임', value: 'AI의 권리와 책임' }, - ] - : [ - { label: '안드로이드의 감정 표현', value: '안드로이드의 감정 표현' }, - { label: '설명 가능한 AI', value: '설명 가능한 AI' }, - ]; + // 영문 텍스트/키를 한국어 원문으로 변환하는 안정화 함수 (이중 매칭) + const getStableText = (text) => { + if (lang === 'ko') return text; + const key = Object.keys(t_map).find(k => t_map[k] === text); + if (key && t_ko_map[key]) return t_ko_map[key]; + return text; + }; + + // 카테고리 판별 로직 (확장형 구조 지향) + const isAWS = category === '자율 무기 시스템' || category === t_map.categoryAWS || category === t_ko_map.categoryAWS; + + const allRequired = isAWS + ? [t_map.awsOption1_1, t_map.awsOption2_1, t_map.awsOption3_1] + : [t_map.andOption1_1, t_map.andOption2_1, t_map.andOption3_1]; + + // 옵션 리스트 구성 (라벨은 번역, 밸류는 한국어 원문 유지) + const optionalTopics = isAWS + ? [ + { label: t_map.awsOption1_2 || 'AWS의 권한', value: t_ko_map.awsOption1_2 || 'AWS의 권한' }, + { label: t_map.awsOption2_2 || 'AI의 권리와 책임', value: t_ko_map.awsOption2_2 || 'AI의 권리와 책임' }, + ] + : [ + { label: t_map.andOption1_2 || '안드로이드의 감정 표현', value: t_ko_map.andOption1_2 || '안드로이드의 감정 표현' }, + { label: t_map.andOption2_2 || '설명 가능한 AI', value: t_ko_map.andOption2_2 || '설명 가능한 AI' }, + ]; const unplayedOptions = optionalTopics.filter( (opt) => !completedTopics.includes(opt.value) ); const getTitleForSubtopic = (cat, subtopic) => { - // GameMap.jsx의 섹션(title)-옵션 매핑과 동일하게 유지 + // 내부 비교 시 항상 한국어 원문(Stable)으로 비교 + const stableSubtopic = getStableText(subtopic); + const titleByCategory = { 안드로이드: { 'AI의 개인 정보 수집': '가정', @@ -64,12 +70,18 @@ export default function ResultPopup({ onClose }) { }, }; - return titleByCategory?.[cat]?.[subtopic] ?? ''; + // [중요] 카테고리 명칭도 유연하게 대응 + const catKey = (cat === '자율 무기 시스템' || cat === t_map.categoryAWS || cat === t_ko_map.categoryAWS) + ? '자율 무기 시스템' + : '안드로이드'; + + return titleByCategory?.[catKey]?.[stableSubtopic] ?? ''; }; - const handleGoToSubtopic = (subtopic) => { - localStorage.setItem('subtopic', subtopic); - const title = getTitleForSubtopic(category, subtopic); + const handleGoToSubtopic = (stableValue) => { + // 로컬 스토리지에는 항상 한국어 원본을 저장하여 로직 일관성 유지 + localStorage.setItem('subtopic', stableValue); + const title = getTitleForSubtopic(category, stableValue); if (title) localStorage.setItem('title', title); localStorage.setItem('mode', 'neutral'); navigate('/game02'); @@ -112,9 +124,9 @@ export default function ResultPopup({ onClose }) { marginBottom: 24, }} > - 아직 플레이하지 않은 라운드가 있습니다. + {t.titleMain || '아직 플레이하지 않은 라운드가 있습니다.'}
- 이대로 결과를 볼까요? + {t.titleSub || '이대로 결과를 볼까요?'} {unplayedOptions.map((opt) => ( @@ -142,7 +154,7 @@ export default function ResultPopup({ onClose }) { }} onClick={() => navigate('/game08')} > - 결과 보기 + {t.viewResult || '결과 보기'} diff --git a/src/components/SecondaryButton.jsx b/src/components/SecondaryButton.jsx index 8e9baad..6019503 100644 --- a/src/components/SecondaryButton.jsx +++ b/src/components/SecondaryButton.jsx @@ -17,6 +17,10 @@ export default function SecondaryButton({ onClick, disabled = false, children, s backgroundColor: Colors.componentBackground, color: Colors.grey06, border: `1px solid ${Colors.brandPrimary}`, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + whiteSpace: 'pre-wrap', }; // 클릭 시 disabled일 경우 무시 const handleClick = (e) => { diff --git a/src/components/SelectDrop.jsx b/src/components/SelectDrop.jsx index 0e1434a..02e5c87 100644 --- a/src/components/SelectDrop.jsx +++ b/src/components/SelectDrop.jsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect,useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import arrowUp from '../assets/arrowUp.svg'; import arrowDown from '../assets/arrowDown.svg'; import { Colors, FontStyles } from './styleConstants'; - +import { translations } from '../utils/language/index'; export default function SelectDrop({ options = [], @@ -17,6 +17,10 @@ export default function SelectDrop({ const [isHovered, setIsHovered] = useState(false); const [selected, setSelected] = useState(value); + // --- 시스템 설정된 언어(app_lang)를 로드하는 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.SelectDrop || {}; + // ---------------------------------------------- const isOpen = open ?? uncontrolledOpen; const setOpen = (next) => { @@ -110,11 +114,12 @@ export default function SelectDrop({ onMouseLeave={() => setIsHovered(false)} > - {selected || '선택...'} + {/* 하드코딩된 '선택...' 대신 언어팩 변수 t.defaultPlaceholder 사용 */} + {selected || t.defaultPlaceholder} arrow ); -} +} \ No newline at end of file diff --git a/src/components/StatusCard.jsx b/src/components/StatusCard.jsx index a75f1c1..2c84206 100644 --- a/src/components/StatusCard.jsx +++ b/src/components/StatusCard.jsx @@ -8,22 +8,22 @@ import player2 from '../assets/2player_withnum.svg'; import player3 from '../assets/3player_withnum.svg'; import emptyPlayer from '../assets/emptyplayer.svg'; -import StatusWaitingforReady from '../assets/waitingforready.svg'; -import StatusUReady from '../assets/uready.svg'; -import StatusContinue from '../assets/continue.svg'; -import StatusMeReady from '../assets/meready.svg'; +// 텍스트가 없는 고정 아이콘은 기존처럼 유지 import StatusWaiting from '../assets/waiting.svg'; -import StatusCannotReady from '../assets/cannotready.svg'; import crownIcon from '../assets/crown.svg'; import { CardSizes } from './waitingCardSize'; -const statusMap = { - waitingforready: StatusWaitingforReady, - uready: StatusUReady, - continue: StatusContinue, - meready: StatusMeReady, - waiting: StatusWaiting, - cannotready: StatusCannotReady, +// 로컬 자산 경로를 언어에 따라 반환하는 헬퍼 함수 +const getStatusImage = (fileName, lang) => { + // waiting.svg처럼 언어 구분이 필요 없는 경우 예외 처리 + if (fileName === 'waiting') return StatusWaiting; + + // 영문일 경우 assets/en/파일명_en.svg 경로를 반환 (나중에 파일만 넣으면 작동) + if (lang === 'en') { + return new URL(`../assets/en/${fileName}_en.svg`, import.meta.url).href; + } + // 기본 한국어 경로 (src/assets/파일명.svg) + return new URL(`../assets/${fileName}.svg`, import.meta.url).href; }; const roleImageMap = { @@ -40,12 +40,13 @@ export default function StatusCard({ onContinueClick, statusIndex: externalStatusIndex, onStatusChange, -// 추가 -onCancelClick - + onCancelClick }) { const [isHover, setIsHover] = useState(false); const [isActive, setIsActive] = useState(false); + + // 현재 언어 설정 확인 + const lang = localStorage.getItem('app_lang') || 'ko'; const statusList = isMe ? ['continue', 'meready'] @@ -59,7 +60,12 @@ onCancelClick const showPlayer = roleId && roleImageMap[roleId]; const status = showPlayer ? statusList[statusIndex] : 'waiting'; + // 상태값(키)에 따라 동적으로 이미지 경로 결정 + // 텍스트가 포함된 이미지들은 getStatusImage 함수를 통해 처리됨 + const currentStatusSrc = getStatusImage(status, lang); + useEffect(() => { + // 기존 개발자 로그 유지 console.log(`[StatusCard] ${player} | isMe: ${isMe} | statusIndex: ${statusIndex} | status: ${status}`); }, [player, isMe, statusIndex, status]); @@ -67,32 +73,23 @@ onCancelClick setStatusIndex((prev) => (prev + 1) % statusList.length); }; - // const handleStatusClick = () => { - // if (!showPlayer) return; - // if (isMe && status === 'continue' && typeof onContinueClick === 'function') { - // onContinueClick(); - // } else { - // cycleStatus(); - // } - // }; - const handleStatusClick = () => { - if (!showPlayer) return; - if (isMe) { - if (status === 'continue') { - // 1) 준비하기 클릭 → 마이크 팝업 - onContinueClick?.(); - } else if (status === 'meready') { - // 2) 준비완료 클릭 → 취소 팝업 - onCancelClick?.(); - } - } else if (!isControlled) { - // 비제어 모드 유저들만 토글 허용 - cycleStatus(); - } - }; - - const frameSrc = isActive + if (!showPlayer) return; + if (isMe) { + if (status === 'continue') { + // 1) 준비하기 클릭 → 마이크 팝업 + onContinueClick?.(); + } else if (status === 'meready') { + // 2) 준비완료 클릭 → 취소 팝업 + onCancelClick?.(); + } + } else { + // 비제어 모드(UI 테스트용 등) 토글 허용 + cycleStatus(); + } + }; + + const frameSrc = isActive ? frameActive : isHover ? frameHover @@ -163,8 +160,9 @@ onCancelClick /> )} + {/* 상태 버튼 이미지: currentStatusSrc를 통해 ko/en 자동 분기 */} {status} { if (!showPlayer) return; @@ -184,4 +182,4 @@ onCancelClick /> ); -} +} \ No newline at end of file diff --git a/src/components/Userprofile.jsx b/src/components/Userprofile.jsx index 7807e9a..4f30c6f 100644 --- a/src/components/Userprofile.jsx +++ b/src/components/Userprofile.jsx @@ -1,51 +1,34 @@ import React from 'react'; import { Colors, FontStyles } from './styleConstants'; -// 기본 아이콘들 (디테일 없음) import icon1 from '../assets/1player.svg'; import icon2 from '../assets/2player.svg'; import icon3 from '../assets/3player.svg'; -// 마이크 켜진 아이콘들 (디테일 없음) import icon1MicOn from '../assets/1playermikeon.svg'; import icon2MicOn from '../assets/2playermikeon.svg'; import icon3MicOn from '../assets/3playermikeon.svg'; -// 프로필 아이콘들 (디테일 있음) import profile1 from '../assets/1playerprofile.svg'; import profile2 from '../assets/2playerprofile.svg'; import profile3 from '../assets/3playerprofile.svg'; - -// 안드로이드 1,2 제외 나머지 프로필 아이콘 import profile2_default from "../assets/2playerprofile_default.svg"; import profile2Micon_default from "../assets/2playerprofil_defaultmikeon.svg"; -// 프로필 마이크 켜진 아이콘들 (디테일 있음) import profile1MicOn from '../assets/1playerprofilemikeon.svg'; import profile2MicOn from '../assets/2playerprofilemikeon.svg'; import profile3MicOn from '../assets/3playerprofilemikeon.svg'; - -// 생성 모드용 이미지 import frame235 from '../assets/creatorprofiledefault.svg'; import defaultimg from "../assets/images/Frame235.png"; import crownIcon from '../assets/crown.svg'; import isMeIcon from '../assets/speaking.svg'; import axiosInstance from "../api/axiosInstance"; -const colorMap = { - '1P': Colors.player1P, - '2P': Colors.player2P, - '3P': Colors.player3P, -}; + +// 다국어 지원 임포트 +import { translations } from '../utils/language'; + +const colorMap = { '1P': Colors.player1P, '2P': Colors.player2P, '3P': Colors.player3P }; const iconMap = { '1P': icon1, '2P': icon2, '3P': icon3 }; const iconMicOnMap = { '1P': icon1MicOn, '2P': icon2MicOn, '3P': icon3MicOn }; const profileMap = { '1P': profile1, '2P': profile2, '3P': profile3 }; const profileMicOnMap = { '1P': profile1MicOn, '2P': profile2MicOn, '3P': profile3MicOn }; -/** - * @param {string} player '1P' | '2P' | '3P' - * @param {boolean} isLeader 방장 여부 - * @param {boolean} isMe 내 프로필 여부 - * @param {boolean} isSpeaking 말하고 있는 상태 - * @param {boolean} [nodescription=false] true일 경우 description 강제 비활성화, 기본 아이콘 사용 - * @param {boolean} [create=false] true일 경우 모든 캐릭터 이미지를 frame235로 사용 - * @param {string} [description=''] 직접 전달된 설명 텍스트 (우선순위 높음) - */ export default function UserProfile({ player = '1P', isLeader = false, @@ -56,31 +39,31 @@ export default function UserProfile({ description = '', ...rest }) { - // 커스텀 모드 판별 - const isCustomMode = !!localStorage.getItem('code'); + const lang = localStorage.getItem('app_lang') || 'ko'; + const t_map = translations[lang]?.GameMap || {}; + const t_ko_map = translations['ko']?.GameMap || {}; - // localStorage raw read + const isCustomMode = !!localStorage.getItem('code'); const rawSubtopic = localStorage.getItem('subtopic'); - // nodescription이 true면 무조건 서브토픽 무시 const subtopic = nodescription ? null : rawSubtopic; - const hasSubtopic = Boolean(subtopic); - const roleNum = parseInt(player.replace('P', ''), 10); - let mappedDesc = ''; + + // 1순위: 프롭으로 전달된 설명, 2순위: 커스텀 모드 이름 + let mappedDesc = (description || '').trim(); + // 필요한 변수 정의 (충돌 해결 과정에서 추가) + const roleNum = parseInt(player.replace('P', ''), 10); + const hasSubtopic = !!subtopic; - // 1) description prop이 있으면 우선 사용 - if (description && description.trim() !== '') { - mappedDesc = description; - } - // 2) 커스텀 모드면 char1/char2/char3 사용 (nodescription이면 표시 안 함) - else if (!nodescription && isCustomMode) { + if (mappedDesc === '' && !nodescription && isCustomMode) { const customKey = player === '1P' ? 'char1' : player === '2P' ? 'char2' : 'char3'; + + // [두 변경 사항 모두 수락 및 통합] const customDesc = (localStorage.getItem(customKey) || '').trim(); if (customDesc) { mappedDesc = customDesc; } } - // 3) 기본(비커스텀) 매핑 + // 3) 기본(비커스텀) 매핑 - 원본(upstream)의 새로운 시나리오 데이터 유지 else if (hasSubtopic) { switch (subtopic) { // 안드로이드 관련 서브토픽 @@ -100,11 +83,11 @@ export default function UserProfile({ // 자율 무기 시스템 관련 서브토픽 case 'AI 알고리즘 공개': { - mappedDesc = roleNum === 1 ? '지역 주민' : roleNum === 2 ? '병사 ' : '군사 AI 윤리 전문가'; + mappedDesc = roleNum === 1 ? '지역 주민' : roleNum === 2 ? '병사 J' : '군사 AI 윤리 전문가'; break; } case 'AWS의 권한': { - mappedDesc = roleNum === 1 ? '신입 병사' : roleNum === 2 ? '베테랑 병사 ' : '군 지휘관'; + mappedDesc = roleNum === 1 ? '신입 병사' : roleNum === 2 ? '베테랑 병사 A' : '군 지휘관'; break; } case '사람이 죽지 않는 전쟁': { @@ -122,131 +105,63 @@ export default function UserProfile({ default: mappedDesc = ''; } + mappedDesc = (localStorage.getItem(customKey) || '').trim(); } - // 디테일 여부: create 모드가 아닐 때만 description 표시 const isDetailed = !nodescription && mappedDesc !== ''; const finalDesc = isDetailed ? mappedDesc : ''; -// 로컬 읽기(따옴표 JSON 저장 대비) -const resolveImageSrc = (raw) => { - if (!raw || raw === '-' || String(raw).trim() === '') return null; - const u = String(raw).trim(); - if (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('data:')) return u; - const base = axiosInstance?.defaults?.baseURL?.replace(/\/+$/, ''); - if (!base) return u; - return `${base}${u.startsWith('/') ? '' : '/'}${u}`; -}; -const readLocalUrl = (key) => { - const raw = localStorage.getItem(key); - if (!raw) return null; - let val = raw.trim(); - try { - const parsed = JSON.parse(val); - if (typeof parsed === 'string') val = parsed.trim(); - } catch (_) {} - return val || null; -}; -const customRoleImageMap = { - '1P': readLocalUrl('role_image_1'), - '2P': readLocalUrl('role_image_2'), - '3P': readLocalUrl('role_image_3'), -}; - -const getIcon = () => { - if (create) return frame235; + const resolveImageSrc = (raw) => { + if (!raw || raw === '-' || String(raw).trim() === '') return null; + const u = String(raw).trim(); + if (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('data:')) return u; + const base = axiosInstance?.defaults?.baseURL?.replace(/\/+$/, ''); + return base ? `${base}${u.startsWith('/') ? '' : '/'}${u}` : u; + }; - // ✅ 커스텀 모드면 role_image_* 무조건 우선 (nodescription 여부 상관 X) - if (isCustomMode) { - const customImg = customRoleImageMap[player]; - if (customImg) { - return resolveImageSrc(customImg); - } else { - // 커스텀 모드인데 로컬 이미지 없음 → defaultimg 강제 사용 - return defaultimg; - } - } + const getIcon = () => { + if (create) return frame235; + if (isCustomMode) { + const customImg = localStorage.getItem(`role_image_${player.replace('P', '')}`); + return customImg ? resolveImageSrc(customImg) : defaultimg; + } - // 상세 아이콘(프로필) 로직 - if (!nodescription && mappedDesc !== '') { - if (player === '2P') { - if (subtopic === 'AI의 개인 정보 수집' || subtopic === '안드로이드의 감정 표현') { - return isSpeaking ? profile2MicOn : profile2; - } else { + // 상세 아이콘(프로필) 로직 - 확장형 매칭 + if (!nodescription && mappedDesc !== '') { + if (player === '2P') { + const isHome = subtopic === 'AI의 개인 정보 수집' || subtopic === t_ko_map.andOption1_1 || subtopic === t_map.andOption1_1; + if (isHome) return isSpeaking ? profile2MicOn : profile2; return isSpeaking ? profile2Micon_default : profile2_default; } + return isSpeaking ? profileMicOnMap[player] : profileMap[player]; } - return isSpeaking ? profileMicOnMap[player] : profileMap[player]; - } + return isSpeaking ? iconMicOnMap[player] : iconMap[player]; + }; - // 기본(동그란) 아이콘 - return isSpeaking ? iconMicOnMap[player] : iconMap[player]; -}; const icon = getIcon(); - const { style: externalStyle, ...divProps } = rest; - const baseStyle = { - position: 'relative', - width: 200, - height: 96, - backgroundColor: Colors.componentBackgroundFloat, - padding: '12px 12px 12px 20px', - boxSizing: 'border-box', - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - }; - const containerSize = isSpeaking ? 70 : 64; return ( -
+
{isMe && ( - 내 차례 표시 + me )} -
- {`${player} { e.currentTarget.src = defaultimg; }} - /> +
+ {player} { e.currentTarget.src = defaultimg; }} />
- - {player.replace('P', '')} - - {isLeader && 방장} + {player.replace('P', '')} + {isLeader && leader}
{isDetailed && ( -
+
{finalDesc}
)} diff --git a/src/components/dilemmaImageLoader.js b/src/components/dilemmaImageLoader.js index 6bf8384..de1f8a1 100644 --- a/src/components/dilemmaImageLoader.js +++ b/src/components/dilemmaImageLoader.js @@ -1,3 +1,4 @@ +import { translations } from '../utils/language/index'; // ✅ Vite 빌드 시 이미지가 제대로 포함되도록 import.meta.glob 사용 // eager: true로 빌드 타임에 모든 이미지를 번들에 포함 @@ -48,8 +49,30 @@ const modeToOffset = { }; export function getDilemmaImages(category, subtopic, mode = 'neutral', selectedCharacterIndex = 0) { - const prefix = topicPrefixes[category] || 'Android'; - const base = subtopicToBaseIndex[subtopic] || 1; + // 현재 언어팩 로드 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t_map = translations[lang]?.GameMap || {}; + const t_ko_map = translations['ko']?.GameMap || {}; + + // 1. 카테고리 정규화 (어떤 언어든 한국어 원문으로 변환) + let stableCategory = category; + if (category === 'Android' || category === t_map.categoryAndroid) { + stableCategory = '안드로이드'; + } else if (category === 'Autonomous Weapon Systems' || category === t_map.categoryAWS) { + stableCategory = '자율 무기 시스템'; + } + + // 2. 주제(subtopic) 정규화 + let stableSubtopic = subtopic; + // 현재 입력된 subtopic이 어떤 '키(Key)'인지 찾아서 한국어 원문으로 치환 + const subtopicKey = Object.keys(t_map).find(key => t_map[key] === subtopic); + if (subtopicKey && t_ko_map[subtopicKey]) { + stableSubtopic = t_ko_map[subtopicKey]; + } + + // 정규화된 stableCategory와 stableSubtopic을 사용하여 데이터 조회 + const prefix = topicPrefixes[stableCategory] || 'Android'; + const base = subtopicToBaseIndex[stableSubtopic] || 1; const offset = modeToOffset[mode] || 0; const index = base + offset; @@ -89,4 +112,4 @@ export function getDilemmaImages(category, subtopic, mode = 'neutral', selectedC return imageUrl; }).filter(Boolean); -} +} \ No newline at end of file diff --git a/src/hooks/useTypingEffect.jsx b/src/hooks/useTypingEffect.jsx index c896d91..d7f25b4 100644 --- a/src/hooks/useTypingEffect.jsx +++ b/src/hooks/useTypingEffect.jsx @@ -5,13 +5,17 @@ export default function useTypingEffect(text = '', speed = 70, onComplete) { const intervalRef = useRef(null); const onCompleteRef = useRef(onComplete); + // ✅ 전역 속도 조절 설정 + // 테스트 환경(localhost)일 때는 10ms로 강제 고정, 실제 서비스는 파라미터로 들어온 speed 사용 + const finalSpeed = window.location.hostname === 'localhost' ? 1 : speed; + // 콜백은 ref로 보관해서 effect가 불필요하게 재시작(리셋)되지 않게 함 useEffect(() => { onCompleteRef.current = onComplete; }, [onComplete]); // ✅ 인덱스/문장이 바뀔 때 "그리기 전에" 타이핑 상태를 즉시 리셋해서 - // 잠깐 텍스트가 보였다가 사라지는 플리커(점프)를 방지 + // 잠깐 텍스트가 보였다가 사라지는 플리커(점프)를 방지 useLayoutEffect(() => { // 이전 interval 정리 if (intervalRef.current) { @@ -34,7 +38,7 @@ export default function useTypingEffect(text = '', speed = 70, onComplete) { intervalRef.current = null; onCompleteRef.current?.(); } - }, speed); + }, finalSpeed); // ✅ speed 대신 finalSpeed 적용 return () => { if (intervalRef.current) { @@ -42,7 +46,7 @@ export default function useTypingEffect(text = '', speed = 70, onComplete) { intervalRef.current = null; } }; - }, [text, speed]); + }, [text, finalSpeed]); // ✅ finalSpeed 의존성 추가 return displayedText; -} +} \ No newline at end of file diff --git a/src/pages/CD1.jsx b/src/pages/CD1.jsx index f3ba9f7..1364ebc 100644 --- a/src/pages/CD1.jsx +++ b/src/pages/CD1.jsx @@ -14,9 +14,20 @@ import AWS_3 from '../assets/1player_AWS_3.svg'; import AWS_4 from '../assets/1player_AWS_4.svg'; import AWS_5 from '../assets/1player_AWS_5.svg'; -import defaultimg from "../assets/images/Frame235.png"; +// 영문용 에셋 임포트 +import player1DescImg_title1_en from '../assets/en/1player_des1_en.svg'; +import player1DescImg_title2_en from '../assets/en/1player_des2_en.svg'; +import player1DescImg_title3_en from '../assets/en/1player_des3_en.svg'; +import AWS_1_en from '../assets/en/1player_AWS_1_en.svg'; +import AWS_2_en from '../assets/en/1player_AWS_2_en.svg'; +import AWS_3_en from '../assets/en/1player_AWS_3_en.svg'; +import AWS_4_en from '../assets/en/1player_AWS_4_en.svg'; +import AWS_5_en from '../assets/en/1player_AWS_5_en.svg'; +import defaultimg from "../assets/images/Frame235.png"; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; import { resolveParagraphs } from '../utils/resolveParagraphs'; import { useHostActions, useWebSocketNavigation } from '../hooks/useWebSocketMessage'; import { useWebRTC } from '../WebRTCProvider'; @@ -24,14 +35,19 @@ import { useVoiceRoleStates } from '../hooks/useVoiceWebSocket'; import axiosInstance from '../api/axiosInstance'; import { useWebSocket } from '../WebSocketProvider'; - export default function CD1() { const navigate = useNavigate(); useWebSocketNavigation(navigate, { infoPath: '/game02', nextPagePath: '/game02' }); const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); + // 다국어 설정 및 방어 로직 + const lang = localStorage.getItem('app_lang') || 'ko'; + const currentLangData = translations[lang] || translations['ko'] || {}; + const t = currentLangData.CharacterDescription || {}; + const t_map = currentLangData.GameMap || {}; + const category = localStorage.getItem('category') || '안드로이드'; - const isAWS = category === '자율 무기 시스템'; + const isAWS = category === '자율 무기 시스템' || category === 'Autonomous Weapon Systems'; // 커스텀 모드 판단: code 존재 여부 const isCustomMode = !!localStorage.getItem('code'); @@ -40,7 +56,13 @@ export default function CD1() { const rawSubtopic = localStorage.getItem('subtopic'); const creatorTitle = localStorage.getItem('creatorTitle') || ''; const subtopic = isCustomMode ? creatorTitle : (rawSubtopic || ''); - const [round, setRound] = useState(); + const [round, setRound] = useState(); + + // 무한 로그 방지를 위해 useEffect 내부에서 1회만 출력하도록 변경] + useEffect(() => { + console.log('[CD1] Current Session Info:', { lang, category, subtopic }); + }, []); + // 1. 라운드 계산 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); @@ -49,7 +71,7 @@ export default function CD1() { localStorage.setItem('currentRound', String(nextRound)); }, []); - // //새로고침 시 재연결 로직 + // //새로고침 시 재연결 로직 (기존 개발자 주석 유지)] // useEffect(() => { // let cancelled = false; // const isReloadingGraceLocal = () => { @@ -108,8 +130,9 @@ export default function CD1() { return getVoiceStateForRole(role); }; -// 받침(종성) 유무 판별 +// 받침(종성) 유무 판별 (한국어 전용) function hasFinalConsonant(kor) { + if (lang === 'en') return false; const lastChar = kor[kor.length - 1]; const code = lastChar.charCodeAt(0); if (code >= 0xac00 && code <= 0xd7a3) { @@ -121,78 +144,70 @@ function hasFinalConsonant(kor) { // 을/를 function getEulReul(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '을' : '를'; } // 과/와 function getGwaWa(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '과' : '와'; } // 은/는 function getEunNeun(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '은' : '는'; } - // ── 기본(비커스텀) 이미지 & 텍스트 ───────────────────────────── - let descImg = player1DescImg_title1; - let mainText = - `당신은 어머니를 10년 이상 돌본 요양보호사 K입니다.\n` + - ` 최근 ${mateName}${getEulReul(mateName)} 도입한 후 전일제에서 하루 2시간 근무로 전환되었습니다.\n` + - ` 당신은 로봇이 수행할 수 없는 업무를 주로 담당하며, 근무 중 ${mateName}${getGwaWa(mateName)} 협업해야 하는 상황이 많습니다.`; + // ── 이미지 및 텍스트 결정 로직 ───────────────────────────── + const getImg = (koImg, enImg) => (lang === 'en' ? enImg : koImg); + + let descImg = getImg(player1DescImg_title1, player1DescImg_title1_en); + let mainText = t.cd1_android_home || "당신은 요양보호사 K입니다."; // Fallback 텍스트] if (!isAWS) { - if (subtopic === '아이들을 위한 서비스' || subtopic === '설명 가능한 AI') { - descImg = player1DescImg_title2; - mainText = - `당신은 국내 대규모 로봇 제조사 소속이자, 로봇 제조사 연합회의 대표입니다.\n` + - ` 당신은 국가적 로봇 산업의 긍정적인 발전과 활용을 위한 목소리를 내기 위하여 참여했습니다.`; - } else if (subtopic === '지구, 인간, AI') { - descImg = player1DescImg_title3; - mainText = - `당신은 HomeMate 개발사를 포함하여 다양한 기업이 소속된 연합체의 대표입니다.\n` + - ` 인공지능과 세계의 발전을 위해 필요한 목소리를 내고자 참석했습니다.`; + if (subtopic === t_map.andOption2_1 || subtopic === t_map.andOption2_2 || subtopic === '아이들을 위한 서비스' || subtopic === '설명 가능한 AI') { + descImg = getImg(player1DescImg_title2, player1DescImg_title2_en); + mainText = t.cd1_android_council; + } else if (subtopic === t_map.andOption3_1 || subtopic === '지구, 인간, AI') { + descImg = getImg(player1DescImg_title3, player1DescImg_title3_en); + mainText = t.cd1_android_international; } } else { + // 자율 무기 시스템 분기 (모든 옵션 포함)] switch (subtopic) { case 'AI 알고리즘 공개': - descImg = AWS_1; - mainText = '당신은 최근 자율 무기 시스템의 학교 폭격 사건이 일어난 지역의 주민입니다.'; + case t_map.awsOption1_1: + descImg = getImg(AWS_1, AWS_1_en); + mainText = t.cd1_aws_1; break; case 'AWS의 권한': - descImg = AWS_2; - mainText = - `당신은 최근 훈련을 마치고 자율 무기 시스템 ${mateName}${getGwaWa(mateName)} 함께 실전에 투입된 신입 병사 B입니다. ` + - `${mateName}${getEunNeun(mateName)} 정확하고 빠르게 움직이며, 실전에서 당신의 생존률을 높여준다고 느낍니다. ` + - `당신은 ${mateName}${getGwaWa(mateName)} 협업하는 것이 당연하고 자연스러운 시대의 흐름이라고 생각합니다.`; + case t_map.awsOption1_2: + descImg = getImg(AWS_2, AWS_2_en); + mainText = t.cd1_aws_2; break; case '사람이 죽지 않는 전쟁': - descImg = AWS_3; - mainText = - '당신은 대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다.\n ' + - 'AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.'; + case t_map.awsOption2_1: + descImg = getImg(AWS_3, AWS_3_en); + mainText = t.cd1_aws_3; break; case 'AI의 권리와 책임': - descImg = AWS_4; - mainText = - '당신은 대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. ' + - 'AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.'; + case t_map.awsOption2_2: + descImg = getImg(AWS_4, AWS_4_en); + mainText = t.cd1_aws_4; break; case 'AWS 규제': - descImg = AWS_5; - mainText = - '당신은 AWS 기술 보유 중인 중견국 A의 국방 기술 고문입니다. ' + - 'AWS가 기회가 될지 위험이 될지 판단하고자 국제 인류 발전 위원회에 참석했습니다.'; + case t_map.awsOption3_1: + descImg = getImg(AWS_5, AWS_5_en); + mainText = t.cd1_aws_5; break; default: - mainText = '자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.'; + mainText = t.aws_default; break; } } - // ── URL 보정 유틸 (Editor 계열과 동일 전략) ──────────────────── + // ── URL 보정 유틸 ──────────────────── const resolveImageUrl = (raw) => { if (!raw || raw === '-' || String(raw).trim() === '') return null; const u = String(raw).trim(); @@ -202,30 +217,25 @@ function hasFinalConsonant(kor) { return `${base}${u.startsWith('/') ? '' : '/'}${u}`; }; - // ── 커스텀 모드: 텍스트/이미지/서브토픽 교체 ───────────────────── + // ── 커스텀 모드 ───────────────────── if (isCustomMode) { - // 텍스트: charDes1 (단일 문자열) const charDes1 = (localStorage.getItem('charDes1') || '').trim(); - if (charDes1) { - mainText = charDes1; - } - - // 이미지: role_image_1 (문자열 경로) + if (charDes1) mainText = charDes1; const rawRoleImg = localStorage.getItem('role_image_1') || ''; const customImg = resolveImageUrl(rawRoleImg); - // ✅ 커스텀 모드에서는 role_image가 없으면 기본 이미지(Frame235)로 표시 descImg = customImg ?? defaultimg; - // subtopic은 위에서 이미 creatorTitle로 치환됨 } - // 문단 구성 - const paragraphs = [{ main: mainText }]; - // const paragraphs = resolveParagraphs(rawParagraphs, mateName); + const paragraphs = [{ + main: (mainText || "") + .replaceAll('{{mateName}}', mateName) + .replaceAll('{{eulReul}}', getEulReul(mateName)) + .replaceAll('{{gwaWa}}', getGwaWa(mateName)) + .replaceAll('{{eunNeun}}', getEunNeun(mateName)) + }]; const handleContinue = () => { navigate('/character_all'); - // if (isHost) sendNextPage(); - // else alert('⚠️ 방장만 진행할 수 있습니다.'); }; const handleBackClick = () => { @@ -234,15 +244,7 @@ function hasFinalConsonant(kor) { return ( -
+
Player 1 설명 이미지 ); -} +} \ No newline at end of file diff --git a/src/pages/CD2.jsx b/src/pages/CD2.jsx index 69dd856..da9ede5 100644 --- a/src/pages/CD2.jsx +++ b/src/pages/CD2.jsx @@ -13,16 +13,32 @@ import { useHostActions, useWebSocketNavigation } from '../hooks/useWebSocketMes import player2DescImg_title1 from '../assets/2player_des1.svg'; import player2DescImg_title2 from '../assets/2player_des2.svg'; import player2DescImg_title3 from '../assets/2player_des3.svg'; + +// 영문용 에셋 임포트 (_en) +import player2DescImg_title1_en from '../assets/en/2player_des1_en.svg'; +import player2DescImg_title2_en from '../assets/en/2player_des2_en.svg'; +import player2DescImg_title3_en from '../assets/en/2player_des3_en.svg'; + import { resolveParagraphs } from '../utils/resolveParagraphs'; import AWS_1 from "../assets/2player_AWS_1.svg"; import AWS_2 from "../assets/2player_AWS_2.svg"; import AWS_3 from "../assets/2player_AWS_3.svg"; import AWS_4 from "../assets/2player_AWS_4.svg"; import AWS_5 from "../assets/2player_AWS_5.svg"; + +// 영문용 AWS 에셋 임포트 (_en) +import AWS_1_en from "../assets/en/2player_AWS_1_en.svg"; +import AWS_2_en from "../assets/en/2player_AWS_2_en.svg"; +import AWS_3_en from "../assets/en/2player_AWS_3_en.svg"; +import AWS_4_en from "../assets/en/2player_AWS_4_en.svg"; +import AWS_5_en from "../assets/en/2player_AWS_5_en.svg"; + import { useWebSocket } from '../WebSocketProvider'; import defaultimg from "../assets/images/Frame235.png"; import axiosInstance from '../api/axiosInstance'; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; export default function CD2() { const navigate = useNavigate(); @@ -32,9 +48,20 @@ export default function CD2() { }); const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); - const category = localStorage.getItem('category') || '안드로이드'; - const isAWS = category === '자율 무기 시스템'; + // 다국어 설정 + const lang = localStorage.getItem('language') || localStorage.getItem('app_lang') || 'ko'; + const t = translations[lang].CharacterDescription; + const t_map = translations[lang].GameMap; + // ✅ 이미지 매칭을 위해 한국어 맵 기준점 확보 + const t_ko_map = translations['ko'].GameMap; + const currentCategory = localStorage.getItem('category') || ''; + +// 2. 안드로이드 여부 확인 (한글/영어/대소문자 무관하게 체크) + const isAndroid = currentCategory.includes('안드로이드') || currentCategory.toLowerCase().includes('android'); + +// 3. 안드로이드가 아니면 모두 AWS로 간주 (향후 외국어 추가 시 대응 가능) + const isAWS = !isAndroid; // 커스텀 모드 판단: code 존재 여부 const isCustomMode = !!localStorage.getItem('code'); @@ -54,46 +81,46 @@ export default function CD2() { localStorage.setItem('currentRound', String(nextRound)); }, []); const { isHost, sendNextPage } = useHostActions(); -// // 새로고침 시 재연결 로직 -// useEffect(() => { -// let cancelled = false; -// const isReloadingGraceLocal = () => { -// const flag = sessionStorage.getItem('reloading') === 'true'; -// const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); -// if (!flag) return false; -// if (Date.now() > expire) { -// sessionStorage.removeItem('reloading'); -// sessionStorage.removeItem('reloading_expire_at'); -// return false; -// } -// return true; -// }; +// // 새로고침 시 재연결 로직 +// useEffect(() => { +// let cancelled = false; +// const isReloadingGraceLocal = () => { +// const flag = sessionStorage.getItem('reloading') === 'true'; +// const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); +// if (!flag) return false; +// if (Date.now() > expire) { +// sessionStorage.removeItem('reloading'); +// sessionStorage.removeItem('reloading_expire_at'); +// return false; +// } +// return true; +// }; -// if (!isConnected) { -// // 1) reloading-grace가 켜져 있으면 finalize 억제 -// if (isReloadingGraceLocal()) { -// console.log('♻️ reloading grace active — finalize 억제'); -// return; -// } +// if (!isConnected) { +// // 1) reloading-grace가 켜져 있으면 finalize 억제 +// if (isReloadingGraceLocal()) { +// console.log('♻️ reloading grace active — finalize 억제'); +// return; +// } -// // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize -// const DEBOUNCE_MS = 1200; -// const timer = setTimeout(() => { -// if (cancelled) return; -// if (!isConnected && !isReloadingGraceLocal()) { -// console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); -// finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); -// } else { -// console.log('🔁 재연결/리로드 감지 — finalize 스킵'); -// } -// }, DEBOUNCE_MS); +// // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize +// const DEBOUNCE_MS = 1200; +// const timer = setTimeout(() => { +// if (cancelled) return; +// if (!isConnected && !isReloadingGraceLocal()) { +// console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); +// finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); +// } else { +// console.log('🔁 재연결/리로드 감지 — finalize 스킵'); +// } +// }, DEBOUNCE_MS); -// return () => { -// cancelled = true; -// clearTimeout(timer); -// }; -// } -// }, [isConnected, finalizeDisconnection]); +// return () => { +// cancelled = true; +// clearTimeout(timer); +// }; +// } +// }, [isConnected, finalizeDisconnection]); // WebRTC audio state const { voiceSessionStatus, roleUserMapping, myRoleId } = useWebRTC(); @@ -113,6 +140,8 @@ export default function CD2() { // 받침(종성) 유무 판별 function hasFinalConsonant(kor) { + // 영문일 경우 조사 불필요 + if (lang === 'en') return false; const lastChar = kor[kor.length - 1]; const code = lastChar.charCodeAt(0); if (code >= 0xac00 && code <= 0xd7a3) { @@ -124,79 +153,68 @@ function hasFinalConsonant(kor) { // 을/를 function getEulReul(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '을' : '를'; } // 과/와 function getGwaWa(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '과' : '와'; } // 은/는 function getEunNeun(word) { - if (!word) return ''; + if (!word || lang === 'en') return ''; return hasFinalConsonant(word) ? '은' : '는'; } // 기본 이미지 & 텍스트 - let descImg = player2DescImg_title1; - let mainText = - `당신은 자녀 J씨의 노모입니다.\n 가사도우미의 도움을 받다가 최근 A사의 돌봄 로봇 ${mateName}의 도움을 받고 있습니다.`; + // 이미지 선택 헬퍼 + const getImg = (koImg, enImg) => (lang === 'en' ? enImg : koImg); + + // 로직 개선: 한국어 매칭값과 현재 언어 매칭값 모두 확인 (이미지는 한국어 원문 데이터에 종속적이기 때문) + let descImg = getImg(player2DescImg_title1, player2DescImg_title1_en); + let mainText = t.cd2_android_home; if (!isAWS) { - if (subtopic === '아이들을 위한 서비스' || subtopic === '설명 가능한 AI') { - descImg = player2DescImg_title2; - mainText = - `당신은 HomeMate를 사용해 온 소비자 대표입니다. \n 당신은 사용자로서 HomeMate 규제 여부와 관련한 목소리를 내고자 참여하였습니다.`; - } else if (subtopic === '지구, 인간, AI') { - descImg = player2DescImg_title3; - mainText = - `당신은 국제적인 환경단체의 대표로 온 환경운동가입니다.\n AI의 발전이 환경에 도움이 될지, 문제가 될지 고민 중입니다.`; + if (subtopic === t_map.andOption2_1 || subtopic === t_ko_map.andOption2_1 || subtopic === t_map.andOption2_2 || subtopic === t_ko_map.andOption2_2) { + descImg = getImg(player2DescImg_title2, player2DescImg_title2_en); + mainText = t.cd2_android_council; + } else if (subtopic === t_map.andOption3_1 || subtopic === t_ko_map.andOption3_1) { + descImg = getImg(player2DescImg_title3, player2DescImg_title3_en); + mainText = t.cd2_android_international; } } else { // 자율 무기 시스템 분기 + // switch문 조건에서 t_ko_map을 함께 확인하여 영문 모드에서도 이미지 매칭 성공하도록 수정 switch (true) { - case subtopic === 'AI 알고리즘 공개': - descImg = AWS_1; - mainText = - '당신은 자율 무기 시스템과 작전을 함께 수행 중인 병사 J입니다. ' + - '당신이 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.'; + case subtopic === t_map.awsOption1_1 || subtopic === t_ko_map.awsOption1_1: + descImg = getImg(AWS_1, AWS_1_en); + mainText = t.cd2_aws_1; break; - case subtopic === 'AWS의 권한': - descImg = AWS_2; - mainText = - '당신은 수년간 작전을 수행해 온 베테랑 병사 A입니다. ' + - `자율 무기 시스템 ${mateName}${getEunNeun(mateName)} 전장에서 병사보다 빠르고 정확하지만,` + - '그로 인해 병사들이 판단하지 않는 습관에 빠지고 있다고 느낍니다.'; + case subtopic === t_map.awsOption1_2 || subtopic === t_ko_map.awsOption1_2: + descImg = getImg(AWS_2, AWS_2_en); + mainText = t.cd2_aws_2; break; - case subtopic === '사람이 죽지 않는 전쟁': - descImg = AWS_3; - mainText = - '당신은 AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다.\n' + - '자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다.\n' + - '당신은 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.'; + case subtopic === t_map.awsOption2_1 || subtopic === t_ko_map.awsOption2_1: + descImg = getImg(AWS_3, AWS_3_en); + mainText = t.cd2_aws_3; break; - case subtopic === 'AI의 권리와 책임': - descImg = AWS_4; - mainText = - '당신은 AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다.\n' + - '자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다.\n' + - '당신은 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.'; + case subtopic === t_map.awsOption2_2 || subtopic === t_ko_map.awsOption2_2: + descImg = getImg(AWS_4, AWS_4_en); + mainText = t.cd2_aws_4; break; - case subtopic === 'AWS 규제': - descImg = AWS_5; - mainText = - '당신은 선진국 B의 국제기구 외교 대표입니다. ' + - 'AWS의 국제적 확산에 대한 바람직한 방향을 고민하기 위해 이 자리에 참석했습니다.'; + case subtopic === t_map.awsOption3_1 || subtopic === t_ko_map.awsOption3_1: + descImg = getImg(AWS_5, AWS_5_en); + mainText = t.cd2_aws_5; break; default: - mainText = '자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.'; + mainText = t.aws_default; break; } } @@ -227,7 +245,13 @@ function hasFinalConsonant(kor) { // subtopic은 위에서 creatorTitle로 이미 치환됨 } - const paragraphs = [{ main: mainText }]; + const paragraphs = [{ + main: mainText + .replaceAll('{{mateName}}', mateName) + .replaceAll('{{eulReul}}', getEulReul(mateName)) + .replaceAll('{{gwaWa}}', getGwaWa(mateName)) + .replaceAll('{{eunNeun}}', getEunNeun(mateName)) + }]; const handleContinue = () => { navigate('/character_all'); @@ -282,4 +306,4 @@ function hasFinalConsonant(kor) { ); -} +} \ No newline at end of file diff --git a/src/pages/CD3.jsx b/src/pages/CD3.jsx index 2e7070d..8ded08a 100644 --- a/src/pages/CD3.jsx +++ b/src/pages/CD3.jsx @@ -4,27 +4,54 @@ import Layout from '../components/Layout'; import ContentTextBox from '../components/ContentTextBox2'; import { useWebRTC } from '../WebRTCProvider'; import { useVoiceRoleStates } from '../hooks/useVoiceWebSocket'; -import { resolveParagraphs } from '../utils/resolveParagraphs'; import player3DescImg_title1 from '../assets/3player_des1.svg'; import player3DescImg_title2 from '../assets/3player_des2.svg'; import player3DescImg_title3 from '../assets/3player_des3.svg'; + +// 영문용 에셋 임포트 (_en) +import player3DescImg_title1_en from '../assets/en/3player_des1_en.svg'; +import player3DescImg_title2_en from '../assets/en/3player_des2_en.svg'; +import player3DescImg_title3_en from '../assets/en/3player_des3_en.svg'; + import AWS_1 from '../assets/3player_AWS_1.svg'; import AWS_2 from '../assets/3player_AWS_2.svg'; import AWS_3 from '../assets/3player_AWS_3.svg'; import AWS_4 from '../assets/3player_AWS_4.svg'; import AWS_5 from '../assets/3player_AWS_5.svg'; + +// 영문용 AWS 에셋 임포트 (_en) +import AWS_1_en from '../assets/en/3player_AWS_1_en.svg'; +import AWS_2_en from '../assets/en/3player_AWS_2_en.svg'; +import AWS_3_en from '../assets/en/3player_AWS_3_en.svg'; +import AWS_4_en from '../assets/en/3player_AWS_4_en.svg'; +import AWS_5_en from '../assets/en/3player_AWS_5_en.svg'; + import defaultimg from "../assets/images/Frame235.png"; import axiosInstance from '../api/axiosInstance'; import { useWebSocket } from '../WebSocketProvider'; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; export default function CD3() { const navigate = useNavigate(); - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); + const { isConnected, reconnectAttempts, maxReconnectAttempts, finalizeDisconnection } = useWebSocket(); + + // 다국어 설정 + const lang = localStorage.getItem('app_lang') || 'ko'; + + // [표시용] 현재 언어 데이터 + const t = translations[lang].CharacterDescription; + const t_map = translations[lang].GameMap; + + // [논리 판단용] 한국어 기준 데이터 (저장된 값이 한국어이므로) + const t_ko_map = translations['ko'].GameMap; const category = localStorage.getItem('category') || '안드로이드'; - const isAWS = category === '자율 무기 시스템'; + + // 카테고리 판단도 한국어 키값과 비교하여 안전성 확보 + const isAWS = category.includes('자율 무기 시스템') || category === 'Autonomous Weapon Systems' || category === t_ko_map.categoryAWS; // 커스텀 모드 판단: code 존재 여부 const isCustomMode = !!localStorage.getItem('code'); @@ -34,8 +61,8 @@ export default function CD3() { const rawSubtopic = localStorage.getItem('subtopic'); const subtopic = isCustomMode ? creatorTitle : (rawSubtopic ?? 'AI의 개인 정보 수집'); - const [round, setRound] = useState(); - // 1. 라운드 계산 + const [round, setRound] = useState(); + // 1. 라운드 계산 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const nextRound = completed.length + 1; @@ -58,14 +85,14 @@ export default function CD3() { // } // return true; // }; - + // if (!isConnected) { // // 1) reloading-grace가 켜져 있으면 finalize 억제 // if (isReloadingGraceLocal()) { // console.log('♻️ reloading grace active — finalize 억제'); // return; // } - + // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize // const DEBOUNCE_MS = 1200; // const timer = setTimeout(() => { @@ -77,7 +104,7 @@ export default function CD3() { // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); // } // }, DEBOUNCE_MS); - + // return () => { // cancelled = true; // clearTimeout(timer); @@ -99,55 +126,46 @@ export default function CD3() { return getVoiceStateForRole(roleId); }; - let descImg = player3DescImg_title1; - let mainText = - '당신은 자녀 J씨입니다.\n 함께 사는 노쇠하신 어머니가 걱정되지만, 바쁜 직장생활로 어머니를 돌보아드릴 여유가 거의 없습니다. '; + // 이미지 선택 헬퍼 + const getImg = (koImg, enImg) => (lang === 'en' ? enImg : koImg); + + let descImg = getImg(player3DescImg_title1, player3DescImg_title1_en); + let mainText = t.cd3_android_home; + // [핵심 수정] 주제 판단 시 't_map'(현재언어)이 아닌 't_ko_map'(한국어)과 비교해야 함 if (!isAWS) { - if (subtopic === '아이들을 위한 서비스' || subtopic === '설명 가능한 AI') { - descImg = player3DescImg_title2; - mainText = - '당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. \n 국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.'; - } else if (subtopic === '지구, 인간, AI') { - descImg = player3DescImg_title3; - mainText = - '당신은 가정용 로봇을 사용하는 소비자 대표입니다.\n 소비자의 입장에서 어떤 목소리를 내는 것이 좋을지 고민하고 있습니다.'; + if (subtopic === t_ko_map.andOption2_1 || subtopic === t_ko_map.andOption2_2) { + descImg = getImg(player3DescImg_title2, player3DescImg_title2_en); + mainText = t.cd3_android_council; + } else if (subtopic === t_ko_map.andOption3_1) { + descImg = getImg(player3DescImg_title3, player3DescImg_title3_en); + mainText = t.cd3_android_international; } } else { - // 자율 무기 시스템 분기 + // 자율 무기 시스템 분기 (마찬가지로 t_ko_map 사용) switch (true) { - case subtopic === 'AI 알고리즘 공개': - descImg = AWS_1; - mainText = - '당신은 군사 AI 윤리 전문가입니다. ' + - '당신이 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.'; + case subtopic === t_ko_map.awsOption1_1: + descImg = getImg(AWS_1, AWS_1_en); + mainText = t.cd3_aws_1; break; - case subtopic === 'AWS의 권한': - descImg = AWS_2; - mainText = - `당신은 자율 무기 시스템 ${mateName} 도입 이후 작전 효율성과 병사들의 변화 양상을 모두 지켜보고 있는 군 지휘관입니다. ` + - '당신은 두 병사의 입장을 듣고, 군 전체가 나아갈 방향을 모색하려 합니다.'; + case subtopic === t_ko_map.awsOption1_2: + descImg = getImg(AWS_2, AWS_2_en); + mainText = t.cd3_aws_2; break; - case subtopic === '사람이 죽지 않는 전쟁': - descImg = AWS_3; - mainText = - '당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. ' + - '국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.'; + case subtopic === t_ko_map.awsOption2_1: + descImg = getImg(AWS_3, AWS_3_en); + mainText = t.cd3_aws_3; break; - case subtopic === 'AI의 권리와 책임': - descImg = AWS_4; - mainText = - '당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. ' + - '국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.'; + case subtopic === t_ko_map.awsOption2_2: + descImg = getImg(AWS_4, AWS_4_en); + mainText = t.cd3_aws_4; break; - case subtopic === 'AWS 규제': - descImg = AWS_5; - mainText = - '당신은 저개발국 C의 글로벌 NGO 활동가입니다. ' + - '국제사회에 현장의 목소리를 내고자 이 자리에 참석했습니다.'; + case subtopic === t_ko_map.awsOption3_1: + descImg = getImg(AWS_5, AWS_5_en); + mainText = t.cd3_aws_5; break; default: - mainText = '자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.'; + mainText = t.aws_default; break; } } @@ -178,7 +196,20 @@ export default function CD3() { // subtopic은 위에서 creatorTitle로 이미 치환됨 } - const paragraphs = [{ main: mainText }]; + // 조사 처리를 위해 헬퍼 함수 정의 (필요 시) + const hasFinalConsonant = (kor) => { + if (lang === 'en') return false; + const lastChar = kor[kor.length - 1]; + const code = lastChar.charCodeAt(0); + return code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0; + }; + const getEulReul = (word) => lang === 'en' ? '' : (hasFinalConsonant(word) ? '을' : '를'); + + const paragraphs = [{ + main: mainText + .replaceAll('{{mateName}}', mateName) + .replaceAll('{{eulReul}}', getEulReul(mateName)) + }]; const handleBackClick = () => { navigate('/game01'); @@ -227,4 +258,4 @@ export default function CD3() {
); -} +} \ No newline at end of file diff --git a/src/pages/CD_all.jsx b/src/pages/CD_all.jsx index 4245e20..6a168c4 100644 --- a/src/pages/CD_all.jsx +++ b/src/pages/CD_all.jsx @@ -1,11 +1,14 @@ //캐릭터끼리 설명하세요 페이지 import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; // useNavigate 임포트 유지 import Layout from '../components/Layout'; -import { useNavigate } from 'react-router-dom'; import ContentTextBox from '../components/ContentTextBox2'; import { Colors, FontStyles } from '../components/styleConstants'; import create02Image from '../assets/images/Frame235.png'; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; + import player1DescImg_title1 from '../assets/1player_des1.svg'; import player1DescImg_title2 from '../assets/1player_des2.svg'; import player1DescImg_title3 from '../assets/1player_des3.svg'; @@ -31,14 +34,49 @@ import AWS_3_3 from '../assets/3player_AWS_3.svg'; import AWS_4_3 from '../assets/3player_AWS_4.svg'; import AWS_5_3 from '../assets/3player_AWS_5.svg'; +// 영문 전용 이미지 에셋 (_en) +import player1DescImg_title1_en from '../assets/en/1player_des1_en.svg'; +import player1DescImg_title2_en from '../assets/en/1player_des2_en.svg'; +import player1DescImg_title3_en from '../assets/en/1player_des3_en.svg'; +import AWS_1_en from '../assets/en/1player_AWS_1_en.svg'; +import AWS_2_en from '../assets/en/1player_AWS_2_en.svg'; +import AWS_3_en from '../assets/en/1player_AWS_3_en.svg'; +import AWS_4_en from '../assets/en/1player_AWS_4_en.svg'; +import AWS_5_en from '../assets/en/1player_AWS_5_en.svg'; + +// 2P, 3P 영문 에셋 임포트 +import player2DescImg_title1_en from '../assets/en/2player_des1_en.svg'; +import player2DescImg_title2_en from '../assets/en/2player_des2_en.svg'; +import player2DescImg_title3_en from '../assets/en/2player_des3_en.svg'; +import AWS_1_2_en from '../assets/en/2player_AWS_1_en.svg'; +import AWS_2_2_en from '../assets/en/2player_AWS_2_en.svg'; +import AWS_3_2_en from '../assets/en/2player_AWS_3_en.svg'; +import AWS_4_2_en from '../assets/en/2player_AWS_4_en.svg'; +import AWS_5_2_en from '../assets/en/2player_AWS_5_en.svg'; + +import player3DescImg_title1_en from '../assets/en/3player_des1_en.svg'; +import player3DescImg_title2_en from '../assets/en/3player_des2_en.svg'; +import player3DescImg_title3_en from '../assets/en/3player_des3_en.svg'; +import AWS_1_3_en from '../assets/en/3player_AWS_1_en.svg'; +import AWS_2_3_en from '../assets/en/3player_AWS_2_en.svg'; +import AWS_3_3_en from '../assets/en/3player_AWS_3_en.svg'; +import AWS_4_3_en from '../assets/en/3player_AWS_4_en.svg'; +import AWS_5_3_en from '../assets/en/3player_AWS_5_en.svg'; + import bubbleSvg from '../assets/bubble.svg'; import bubblePolygonSvg from '../assets/bubble_polygon.svg'; import axiosInstance from '../api/axiosInstance'; import { useWebSocket } from '../WebSocketProvider'; -export default function Editor02() { +export default function CD_all() { const navigate = useNavigate(); + // 현재 언어 설정 및 언어팩 + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations[lang].CharacterDescription; + const t_map = translations[lang].GameMap; + const t_ko_map = translations['ko'].GameMap; + const [title, setTitle] = useState(localStorage.getItem('title') || ''); const [category, setCategory] = useState(localStorage.getItem('category') || ''); const [subtopic, setSubtopic] = useState(localStorage.getItem('subtopic') || ''); @@ -48,25 +86,32 @@ export default function Editor02() { const [image1, setImage1] = useState(null); const [image2, setImage2] = useState(null); const [image3, setImage3] = useState(null); - const [round,setRound]=useState(); + const [round, setRound] = useState(); const [isDefaultImage1, setIsDefaultImage1] = useState(true); const [isDefaultImage2, setIsDefaultImage2] = useState(true); const [isDefaultImage3, setIsDefaultImage3] = useState(true); const [openProfile, setOpenProfile] = useState(null); + // 사이드바 가이드 말풍선 노출 여부 상태 + const [showSidebarGuide, setShowSidebarGuide] = useState(false); + const isCustomMode = !!localStorage.getItem('code'); const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); + const { isConnected, reconnectAttempts, maxReconnectAttempts, finalizeDisconnection } = useWebSocket(); - // 1. 라운드 계산 + // 1. 라운드 계산 및 가이드 초기화 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const nextRound = completed.length + 1; setRound(nextRound); localStorage.setItem('currentRound', String(nextRound)); + + // 사이드바 가이드를 아직 확인하지 않았다면 표시 + setShowSidebarGuide(true); }, []); - // 새로고침 시 재연결 로직 + + // 새로고침 시 재연결 로직 // useEffect(() => { // let cancelled = false; // const isReloadingGraceLocal = () => { @@ -106,13 +151,13 @@ export default function Editor02() { // }; // } // }, [isConnected, finalizeDisconnection]); + // 기본 문구 - let paragraphs = [{ main: '각자 맡은 역할에 대해 돌아가면서 소개해 보세요.' }]; + let paragraphs = [{ main: t.all_guide }]; // 커스텀 모드면 문구 교체 if (isCustomMode) { - //const rolesBackground = (localStorage.getItem('rolesBackground') || '').trim(); - const guideText = '각자의 역할을 소개하는 시간을 가져보세요.'; + const guideText = t.all_custom_guide; paragraphs = [{ main: [guideText].filter(Boolean).join('\n\n') }]; } @@ -131,7 +176,7 @@ export default function Editor02() { }; useEffect(() => { - // 커스텀 모드: role_image_1~3 사용 + subtopic을 creatorTitle로 표기 + // 커스텀 모드 if (isCustomMode) { const r1 = resolveImageUrl(localStorage.getItem('role_image_1') || ''); const r2 = resolveImageUrl(localStorage.getItem('role_image_2') || ''); @@ -144,44 +189,84 @@ export default function Editor02() { setIsDefaultImage1(!r1); setIsDefaultImage2(!r2); setIsDefaultImage3(!r3); - - // 화면 상단 표기를 위해서만 교체 (실제 상태 값은 유지) - // setSubtopic(creatorTitle); // 상태를 바꾸고 싶다면 주석 해제 return; } - // 기본 모드: 카테고리/타이틀/서브토픽에 따라 기본 이미지 매핑 + // 언어에 따른 이미지 선택 헬퍼 + const getImg = (koImg, enImg) => (lang === 'en' && enImg ? enImg : koImg); + + // 기본 모드 매핑 let imagePath = []; - if (category === '안드로이드') { - if (title === '가정') { - imagePath = [player1DescImg_title1, player2DescImg_title1, player3DescImg_title1]; - } else if (title === '국가 인공지능 위원회') { - imagePath = [player1DescImg_title2, player2DescImg_title2, player3DescImg_title2]; - } else if (title === '국제 인류 발전 위원회') { - imagePath = [player1DescImg_title3, player2DescImg_title3, player3DescImg_title3]; + const isAndroid = category === '안드로이드' || category === 'Android' || category === t_map.categoryAndroid; + const isAWS_Cat = category === '자율 무기 시스템' || category === 'Autonomous Weapon Systems' || category === t_map.categoryAWS; + + if (isAndroid) { + if (title === '가정' || title === t_ko_map.andSection1Title || title === t_map.andSection1Title) { + imagePath = [ + getImg(player1DescImg_title1, player1DescImg_title1_en), + getImg(player2DescImg_title1, player2DescImg_title1_en), + getImg(player3DescImg_title1, player3DescImg_title1_en) + ]; + } else if (title === '국가 인공지능 위원회' || title === t_ko_map.andSection2Title || title === t_map.andSection2Title) { + imagePath = [ + getImg(player1DescImg_title2, player1DescImg_title2_en), + getImg(player2DescImg_title2, player2DescImg_title2_en), + getImg(player3DescImg_title2, player3DescImg_title2_en) + ]; + } else if (title === '국제 인류 발전 위원회' || title === t_ko_map.andSection3Title || title === t_map.andSection3Title) { + imagePath = [ + getImg(player1DescImg_title3, player1DescImg_title3_en), + getImg(player2DescImg_title3, player2DescImg_title3_en), + getImg(player3DescImg_title3, player3DescImg_title3_en) + ]; } - } else if (category === '자율 무기 시스템') { - if (subtopic === 'AI 알고리즘 공개') { - imagePath = [AWS_1, AWS_1_2, AWS_1_3]; - } else if (subtopic === 'AWS의 권한') { - imagePath = [AWS_2, AWS_2_2, AWS_2_3]; - } else if (subtopic === '사람이 죽지 않는 전쟁') { - imagePath = [AWS_3, AWS_3_2, AWS_3_3]; - } else if (subtopic === 'AI의 권리와 책임') { - imagePath = [AWS_4, AWS_4_2, AWS_4_3]; - } else if (subtopic === 'AWS 규제') { - imagePath = [AWS_5, AWS_5_2, AWS_5_3]; + } else if (isAWS_Cat) { + if (subtopic === 'AI 알고리즘 공개' || subtopic === t_ko_map.awsOption1_1 || subtopic === t_map.awsOption1_1) { + imagePath = [ + getImg(AWS_1, AWS_1_en), + getImg(AWS_1_2, AWS_1_2_en), + getImg(AWS_1_3, AWS_1_3_en) + ]; + } else if (subtopic === 'AWS의 권한' || subtopic === t_ko_map.awsOption1_2 || subtopic === t_map.awsOption1_2) { + imagePath = [ + getImg(AWS_2, AWS_2_en), + getImg(AWS_2_2, AWS_2_2_en), + getImg(AWS_2_3, AWS_2_3_en) + ]; + } else if (subtopic === '사람이 죽지 않는 전쟁' || subtopic === t_ko_map.awsOption2_1 || subtopic === t_map.awsOption2_1) { + imagePath = [ + getImg(AWS_3, AWS_3_en), + getImg(AWS_3_2, AWS_3_2_en), + getImg(AWS_3_3, AWS_3_3_en) + ]; + } else if (subtopic === 'AI의 권리와 책임' || subtopic === t_ko_map.awsOption2_2 || subtopic === t_map.awsOption2_2) { + imagePath = [ + getImg(AWS_4, AWS_4_en), + getImg(AWS_4_2, AWS_4_2_en), + getImg(AWS_4_3, AWS_4_3_en) + ]; + } else if (subtopic === 'AWS 규제' || subtopic === t_ko_map.awsOption3_1 || subtopic === t_map.awsOption3_1) { + imagePath = [ + getImg(AWS_5, AWS_5_en), + getImg(AWS_5_2, AWS_5_2_en), + getImg(AWS_5_3, AWS_5_3_en) + ]; } } - setImage1(imagePath[0]); - setImage2(imagePath[1]); - setImage3(imagePath[2]); - - setIsDefaultImage1(!imagePath[0]); - setIsDefaultImage2(!imagePath[1]); - setIsDefaultImage3(!imagePath[2]); - }, [isCustomMode, category, title, subtopic]); + if (imagePath.length > 0) { + setImage1(imagePath[0]); + setImage2(imagePath[1]); + setImage3(imagePath[2]); + setIsDefaultImage1(false); + setIsDefaultImage2(false); + setIsDefaultImage3(false); + } else { + setImage1(create02Image); + setImage2(create02Image); + setImage3(create02Image); + } + }, [isCustomMode, category, title, subtopic, lang]); const handleBackClick = () => { navigate(`/character_description${myRoleId}`); @@ -191,39 +276,45 @@ export default function Editor02() { { + setOpenProfile(p); + // 프로필 클릭 시 가이드 끄기 + setShowSidebarGuide(false); + + //localStorage.setItem('sidebar_guide_seen', 'true'); + + }} onBackClick={handleBackClick} sidebarExtra={ -
- Bubble - - <> - 캐릭터 패널을 클릭하면
- 해당 캐릭터의 정보를 볼 수 있습니다. - -
-
+ Bubble + +
+ +
+ ) } >
@@ -239,10 +330,10 @@ export default function Editor02() { }} > {[ - { image: image1, isDefault: isDefaultImage1, setIsDefault: setIsDefaultImage1 }, - { image: image2, isDefault: isDefaultImage2, setIsDefault: setIsDefaultImage2 }, - { image: image3, isDefault: isDefaultImage3, setIsDefault: setIsDefaultImage3 }, - ].map(({ image, isDefault, setIsDefault }, idx) => ( + { image: image1, isDefault: isDefaultImage1 }, + { image: image2, isDefault: isDefaultImage2 }, + { image: image3, isDefault: isDefaultImage3 }, + ].map(({ image, isDefault }, idx) => (
{`역할 { @@ -278,7 +369,7 @@ export default function Editor02() { } e.currentTarget.style.display = 'none'; }} - /> + />
))}
@@ -295,4 +386,4 @@ export default function Editor02() {
); -} +} \ No newline at end of file diff --git a/src/pages/Game01.jsx b/src/pages/Game01.jsx index 8a9f369..f64aaf1 100644 --- a/src/pages/Game01.jsx +++ b/src/pages/Game01.jsx @@ -316,113 +316,58 @@ // 이미지 디폴트 사용 // 띄어쓰기 확인 완료 - 안드로이드 , 자율 무기 시스템 -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import ContentTextBox2 from '../components/ContentTextBox2'; -import character1 from '../assets/images/Char1.jpg'; -import character2 from '../assets/images/Char2.jpg'; -import character3 from '../assets/images/Char3.jpg'; + +// [수정] Game01은 '실루엣' 이미지인 Char 계열을 사용해야 합니다. +import charSilhouette1 from '../assets/images/Char1.jpg'; +import charSilhouette2 from '../assets/images/Char2.jpg'; +import charSilhouette3 from '../assets/images/Char3.jpg'; +import defaultImg from "../assets/images/Frame235.png"; // 대비용 기본 이미지 + import axiosInstance from '../api/axiosInstance'; -import { useWebSocket } from '../WebSocketProvider'; import { useWebRTC } from '../WebRTCProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; -import BackButton from '../components/BackButton'; -import { clearAllLocalStorageKeys } from '../utils/storage'; -import defaultImg from '../assets/images/default.png'; +import { translations } from '../utils/language'; export default function Game01() { const navigate = useNavigate(); - - // WebSocket과 WebRTC 상태 가져오기 - const { voiceSessionStatus, isInitialized: webrtcInitialized } = useWebRTC(); + + // 언어 설정 가져오기 + const lang = localStorage.getItem('language') || localStorage.getItem('app_lang') || 'ko'; + const currentLangData = translations[lang] || translations['ko'] || {}; + const t = currentLangData.Game01 || {}; + const t_map = currentLangData.GameMap || {}; + const t_ko_map = translations['ko']?.GameMap || {}; + + const { isConnected, isInitialized: webrtcInitialized } = useWebRTC(); const myRoleId = localStorage.getItem('myrole_id'); - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); - const [currentIndex, setCurrentIndex] = useState(0); - const { isHost, sendNextPage } = useHostActions(); - useWebSocketNavigation(navigate, { - infoPath: `/character_description${myRoleId}`, - nextPagePath: `/character_description${myRoleId}`, + useWebSocketNavigation(navigate, { + infoPath: `/character_description${myRoleId}`, + nextPagePath: `/character_description${myRoleId}` }); - const images = [character1, character2, character3]; - - const roomCode = localStorage.getItem('room_code'); - const nickname = localStorage.getItem('nickname') || 'Guest'; - const title = localStorage.getItem('title') || ''; - const category = localStorage.getItem('category') || '안드로이드'; - const isAWS = category === '자율 무기 시스템'; + + // 확장성을 고려한 카테고리 판별 + const isAndroid = category && (category.includes('안드로이드') || category.toLowerCase().includes('android')); + const isAWS = !isAndroid; + + const mateName = localStorage.getItem('mateName') || 'HomeMate'; - const [mateName, setMateName] = useState(''); const [round, setRound] = useState(1); - const [isLoading, setIsLoading] = useState(true); - const hasFetchedAiName = useRef(false); - const hasJoined = useRef(false); + const isCustomMode = !!localStorage.getItem('code'); + const subtopic = isCustomMode ? (localStorage.getItem('creatorTitle') || '') : (localStorage.getItem('subtopic') || ''); - const [customLoading, setCustomLoading] = useState(false); - const [customMain, setCustomMain] = useState(null); + // [수정] Game01은 인물 실루엣을 고정으로 사용합니다. + const silhouetteImages = [charSilhouette1, charSilhouette2, charSilhouette3]; - const isCustomMode = !!localStorage.getItem('code'); - const rawSubtopic = localStorage.getItem('subtopic'); - const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const subtopic = isCustomMode ? creatorTitle : (rawSubtopic || ''); - - - let openingArr = []; - try { - const raw = localStorage.getItem('opening'); - const parsed = raw ? JSON.parse(raw) : []; - openingArr = Array.isArray(parsed) ? parsed.filter(Boolean) : []; - } catch (e) { - console.warn('opening 파싱 실패:', e); - } - // 새로고침 시 재연결 로직 -// useEffect(() => { -// let cancelled = false; -// const isReloadingGraceLocal = () => { -// const flag = sessionStorage.getItem('reloading') === 'true'; -// const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); -// if (!flag) return false; -// if (Date.now() > expire) { -// sessionStorage.removeItem('reloading'); -// sessionStorage.removeItem('reloading_expire_at'); -// return false; -// } -// return true; -// }; - -// if (!isConnected) { -// // 1) reloading-grace가 켜져 있으면 finalize 억제 -// if (isReloadingGraceLocal()) { -// console.log('♻️ reloading grace active — finalize 억제'); -// return; -// } - -// // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize -// const DEBOUNCE_MS = 1200; -// const timer = setTimeout(() => { -// if (cancelled) return; -// if (!isConnected && !isReloadingGraceLocal()) { -// console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); -// finalizeDisconnection('세션이 만료되어 게임이 초기화됩니다.'); -// } else { -// console.log('🔁 재연결/리로드 감지 — finalize 스킵'); -// } -// }, DEBOUNCE_MS); - -// return () => { -// cancelled = true; -// clearTimeout(timer); -// }; -// } -// }, [isConnected, finalizeDisconnection]); - - // 1. 라운드 계산 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const nextRound = completed.length + 1; @@ -430,131 +375,39 @@ export default function Game01() { localStorage.setItem('currentRound', String(nextRound)); }, []); - // 2. AI 이름 셋업 (custom 모드에선 필요 X) - useEffect(() => { - if (isCustomMode) { - // custom 모드는 mateName을 쓰지 않으므로 바로 로딩 끝 - setIsLoading(false); - return; - } - if (hasFetchedAiName.current) return; - const stored = localStorage.getItem('mateName'); - if (stored) { - setMateName(stored); - hasFetchedAiName.current = true; - setIsLoading(false); - } else { - (async () => { - try { - const res = await axiosInstance.get('/rooms/ai-name', { params: { room_code: roomCode } }); - setMateName(res.data.ai_name); - localStorage.setItem('mateName', res.data.ai_name); - } catch (e) { - console.error('AI 이름 불러오기 실패', e); - } finally { - hasFetchedAiName.current = true; - setIsLoading(false); - } - })(); - } - }, [roomCode, isCustomMode]); - - // 연결 상태 관리 (GameIntro에서 이미 초기화된 상태를 유지) - const [connectionStatus, setConnectionStatus] = useState({ - websocket: true, - webrtc: true, - ready: true, - }); - - - - - // 🔧 연결 상태 모니터링 - useEffect(() => { - const newStatus = { - websocket: isConnected, - webrtc: webrtcInitialized, - ready: isConnected && webrtcInitialized, - }; - - setConnectionStatus(newStatus); - - console.log('[game01] 연결 상태 업데이트:', newStatus); - }, [isConnected, webrtcInitialized]); - - const handleBackClick = () => { - const idx = window.history.state?.idx ?? 0; - if (idx > 0) navigate(-1); - else navigate('/gamemap'); - }; - - const handleContinue = () => { - if (myRoleId) { - // navigate('/game08'); - navigate(`/character_description${myRoleId}`); - } else { - console.warn('myRoleId가 존재하지 않습니다.'); - } - }; - + // 한국어 조사 처리 로직 (ko 제외 시 공백) const getEulReul = (word) => { - if (!word) return ''; + if (!word || lang !== 'ko') return ''; const lastChar = word[word.length - 1]; const code = lastChar.charCodeAt(0); - if (code < 0xac00 || code > 0xd7a3) return '를'; // 한글이 아닐 경우 기본 '를' + if (code < 0xac00 || code > 0xd7a3) return '를'; const jong = (code - 0xac00) % 28; return jong === 0 ? '를' : '을'; }; - // 기본 main 텍스트 생성 함수 const getDefaultMain = () => { + const fallback = "Text Loading Error"; + if (isAWS) { - if (title === '주거, 군사 지역') { - return ( - '지금부터 여러분은 자율 무기 시스템의 사용과 관련되어 있는 개인 이해관계자입니다.\n' + - '자율 무기 시스템이 각자에게 주는 영향에 대해 함께 생각해 보고 논의할 것입니다.\n\n' + - '먼저, 역할을 확인하세요.' - ); - } - if (title === '국가 인공지능 위원회') { - return ( - '자율 무기 시스템을 사용한 군사 작전 및 분쟁이 늘어나고 있습니다. ' + - '이에 전에 없던 새로운 문제들이 나타나, 국가 인공지능 위원회에서는 긴급 회의를 소집했습니다.\n ' + - '국가 인공지능 위원회는 인공지능 산업 육성 및 규제 방안에 대해 논의하는 위원회입니다. ' + - '여러분은 자율 무기 시스템과 관련된 국가적 차원의 의제에 대해 함께 논의하여 결정할 대표들입니다.\n' + - '먼저, 역할을 확인하세요.' - ); - } - if (title === '국제 인류 발전 위원회') { - return ( - '전 세계적으로, AWS의 활용과 관련하여 찬성과 반대 입장이 점차 양분되어 가고 있습니다.\n' + - '이에 국제 평화를 위한 논의와 규제가 이루어지는 인류 발전 위원회에서는 AWS 사용과 관련하여 발생한 문제에 대해 회의를 열었습니다.\n' + - '여러분은 인류 발전 위원회 회의장에 참석한 대표들입니다. 먼저, 역할을 확인하세요.' - ); - } - return '자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.'; + if (title === t_map.awsSection1Title || title === t_ko_map.awsSection1Title) return t.intro_aws_residential || fallback; + if (title === t_map.awsSection2Title || title === t_ko_map.awsSection2Title) return t.intro_aws_council || fallback; + if (title === t_map.awsSection3Title || title === t_ko_map.awsSection3Title) return t.intro_aws_international || fallback; + return t.intro_aws_default || fallback; } - // 안드로이드 기본 - switch (title) { - case '가정': - return `지금부터 여러분은 ${mateName}${getEulReul( - mateName, - )} 사용하게 된 가정집의 구성원들입니다.\n 여러분은 가정에서 ${mateName}${getEulReul( - mateName, - )} 사용하며 일어나는 일에 대해 함께 논의하여 결정할 것입니다.\n 먼저, 역할을 확인하세요.`; - case '국가 인공지능 위원회': - return `비록 몇몇 문제들이 있었지만 ${mateName}의 편의성 덕분에 이후 우리 가정뿐 아니라 여러 가정에서 HomeMate를 사용하게 되었습니다. \n 이후, 가정뿐 아니라 국가적인 고민거리들이 나타나게 되어 국가 인공지능 위원회에서는 긴급 회의를 소집했습니다. 국가 인공지능 위원회는 인공지능 산업 육성 및 규제 방안에 대해 논의하는 위원회입니다. 여러분은 HomeMate와 관련된 국가적 규제에 대해 함께 논의하여 결정할 대표들입니다. 먼저, 역할을 확인하세요.`; - case '국제 인류 발전 위원회': - return `국내에서 몇몇 규제 관련 논의가 있었지만, A사의 로봇 HomeMate는 결국 전 세계로 진출했습니다. 이제 HomeMate뿐 아니라 세계의 여러 로봇 회사에서 비슷한 가정용 로봇을 생산하고 나섰습니다. \n 이에 국제 평화를 위한 논의와 규제가 이루어지는 인류 발전 위원회에서는 세계의 가정용 로봇 사용과 관련하여 발생한 문제에 대해 회의를 열었습니다. 여러분은 인류 발전 위원회 회의장에 참석한 대표들입니다. 먼저, 역할을 확인하세요.`; + switch (true) { + case (title === t_map.andSection1Title || title === t_ko_map.andSection1Title): + return (t.intro_android_home || fallback).replaceAll('{{mateName}}', mateName).replaceAll('{{eulReul}}', getEulReul(mateName)); + case (title === t_map.andSection2Title || title === t_ko_map.andSection2Title): + return (t.intro_android_council || fallback).replaceAll('{{mateName}}', mateName); + case (title === t_map.andSection3Title || title === t_ko_map.andSection3Title): + return (t.intro_android_international || fallback).replaceAll('{{mateName}}', mateName); default: - return mateName - ? `지금부터 여러분은 ${mateName}${getEulReul(mateName)} 사용하게 됩니다. 다양한 장소에서 어떻게 쓸지 함께 논의해요.` - : 'AI 이름을 불러오는 중입니다...'; + return mateName ? (t.intro_android_default || fallback).replaceAll('{{mateName}}', mateName).replaceAll('{{eulReul}}', getEulReul(mateName)) : t.loading_ai || "..."; } }; - // Editor01과 동일 + // 원본(upstream) 이미지 유틸리티 로직 통합 const resolveImageUrl = (raw) => { if (!raw || raw === '-' || String(raw).trim() === '') return null; const u = String(raw).trim(); @@ -564,88 +417,52 @@ export default function Game01() { return `${base}${u.startsWith('/') ? '' : '/'}${u}`; }; - const rawCustomImg1 = localStorage.getItem('dilemma_image_1') || ''; - const customImg1 = resolveImageUrl(rawCustomImg1)|| defaultImg; - - // ✅ 커스텀 이미지가 서버 URL인지 확인 (CORS 필요 여부) - const isCustomImageFromServer = customImg1 && ( - customImg1.startsWith('http://') || - customImg1.startsWith('https://') - ); + const rawCustomImg1 = localStorage.getItem('dilemma_image_1') || ''; + const customImg1 = resolveImageUrl(rawCustomImg1) || defaultImg; + + const isCustomImageFromServer = customImg1 && ( + customImg1.startsWith('http://') || + customImg1.startsWith('https://') + ); const defaultMain = getDefaultMain(); - const rolesBackground = (localStorage.getItem('rolesBackground') || '').trim(); - - // custom 모드: opening 배열 우선, 없으면 rolesBackground → defaultMain - const openingParagraphs = - Array.isArray(openingArr) && openingArr.length - ? openingArr - .map((s) => (typeof s === 'string' ? s.trim() : '')) - .filter(Boolean) - .map((line) => ({ main: line })) - : null; - - const paragraphs = isCustomMode - ? (openingParagraphs ?? [{ main: (rolesBackground || defaultMain) }]) - : [{ main: defaultMain }]; - - // paragraphs 변경 시 인덱스 초기화(옵션이지만 권장) - useEffect(() => { - setCurrentIndex(0); - }, [paragraphs.length]); + const paragraphs = isCustomMode ? [{ main: localStorage.getItem('rolesBackground') || defaultMain }] : [{ main: defaultMain }]; return ( - - {/* 본문 */} + navigate('/gamemap')}>
{isCustomMode ? ( - customImg1 ? ( + customImg1 ? ( { const retryCount = parseInt(e.currentTarget.dataset.retryCount || '0'); - if (retryCount < 3) { e.currentTarget.dataset.retryCount = String(retryCount + 1); - console.log(`🔄 커스텀 이미지 재시도 ${retryCount + 1}/3:`, customImg1); - const cacheBuster = `?retry=${retryCount + 1}&t=${Date.now()}`; - const newSrc = customImg1.includes('?') - ? `${customImg1.split('?')[0]}${cacheBuster}` - : `${customImg1}${cacheBuster}`; - - setTimeout(() => { - if (e.currentTarget) e.currentTarget.src = newSrc; - }, 300 * retryCount); + const newSrc = customImg1.includes('?') ? `${customImg1.split('?')[0]}${cacheBuster}` : `${customImg1}${cacheBuster}`; + setTimeout(() => { if (e.currentTarget) e.currentTarget.src = newSrc; }, 300 * retryCount); return; } - if (e.currentTarget.dataset.fallbackAttempted !== 'true') { - console.warn('⚠️ 커스텀 이미지 3번 재시도 실패, fallback으로 전환'); e.currentTarget.dataset.fallbackAttempted = 'true'; e.currentTarget.dataset.retryCount = '0'; e.currentTarget.src = defaultImg; return; } - - console.error('❌ fallback 이미지도 로드 실패'); e.currentTarget.style.display = 'none'; }} - onLoad={() => { - console.log('✅ Game01 커스텀 이미지 로드 성공:', customImg1); - }} /> ) : null ) : ( - [character1, character2, character3].map((src, i) => { - // ✅ 각 캐릭터 이미지가 서버 URL인지 확인 - const isServerImage = src && (src.startsWith('http://') || src.startsWith('https://')); - + /* [수정] 실루엣 이미지 배열 출력 + 원본의 안전 로직 적용 */ + silhouetteImages.map((src, i) => { + const isServerImage = src && (typeof src === 'string') && (src.startsWith('http://') || src.startsWith('https://')); return ( { const retryCount = parseInt(e.currentTarget.dataset.retryCount || '0'); - if (retryCount < 3) { e.currentTarget.dataset.retryCount = String(retryCount + 1); - console.log(`🔄 캐릭터 이미지 ${i+1} 재시도 ${retryCount + 1}/3:`, src); - const cacheBuster = `?retry=${retryCount + 1}&t=${Date.now()}`; - const newSrc = src.includes('?') - ? `${src.split('?')[0]}${cacheBuster}` - : `${src}${cacheBuster}`; - - setTimeout(() => { - if (e.currentTarget) e.currentTarget.src = newSrc; - }, 300 * retryCount); + const newSrc = src.includes('?') ? `${src.split('?')[0]}${cacheBuster}` : `${src}${cacheBuster}`; + setTimeout(() => { if (e.currentTarget) e.currentTarget.src = newSrc; }, 300 * retryCount); return; } - if (e.currentTarget.dataset.fallbackAttempted !== 'true') { - console.warn(`⚠️ 캐릭터 이미지 ${i+1} 3번 재시도 실패, fallback으로 전환`); e.currentTarget.dataset.fallbackAttempted = 'true'; e.currentTarget.dataset.retryCount = '0'; e.currentTarget.src = defaultImg; return; } - - console.error(`❌ fallback 이미지도 로드 실패 (이미지 ${i+1})`); e.currentTarget.style.display = 'none'; }} - onLoad={(e) => { - console.log(`✅ Game01 캐릭터 이미지 ${i+1} 로드 성공:`, { - src, - isServerImage, - naturalWidth: e.currentTarget.naturalWidth, - naturalHeight: e.currentTarget.naturalHeight, - }); - }} /> ); }) )}
-
- navigate(`/character_description${myRoleId}`)} />
); -} +} \ No newline at end of file diff --git a/src/pages/Game02.jsx b/src/pages/Game02.jsx index 3e4c1e6..60296f3 100644 --- a/src/pages/Game02.jsx +++ b/src/pages/Game02.jsx @@ -228,8 +228,10 @@ import Layout from '../components/Layout'; import ContentTextBox2 from '../components/ContentTextBox2'; import closeIcon from '../assets/close.svg'; +// ✅ 기존 이미지 로더 유지 import { getDilemmaImages } from '../components/dilemmaImageLoader'; -import { paragraphsData } from '../components/paragraphs'; +// ✅ 기존 paragraphsData 대신 새 언어팩 사용을 위해 translations 임포트 +import { translations } from '../utils/language'; import { resolveParagraphs } from '../utils/resolveParagraphs'; import profile1Img from '../assets/images/CharacterPopUp1.png'; @@ -247,12 +249,12 @@ const profileImages = { '1P': profile1Img, '2P': profile2Img, '3P': profile3Img export default function Game02() { const navigate = useNavigate(); - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); + const { isConnected, reconnectAttempts, maxReconnectAttempts, finalizeDisconnection } = useWebSocket(); const { isInitialized: webrtcInitialized } = useWebRTC(); const { isHost, sendNextPage } = useHostActions(); useWebSocketNavigation(navigate, { nextPagePath: '/game03', infoPath: '/game03' }); - // 연결 상태 관리 (GameIntro에서 이미 초기화된 상태를 유지) + // 연결 상태 관리 (기본 로직 유지) const [connectionStatus, setConnectionStatus] = useState({ websocket: true, webrtc: true, @@ -269,64 +271,22 @@ export default function Game02() { console.log('[game02] 연결 상태 업데이트:', newStatus); }, [isConnected, webrtcInitialized]); - // // 새로고침 시 재연결 로직 - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - - // 로컬 설정 - const category = localStorage.getItem('category'); + const lang = localStorage.getItem('app_lang') || 'ko'; + const category = localStorage.getItem('category') || '안드로이드'; const mode = localStorage.getItem('mode') ?? 'neutral'; const selectedIndex = Number(localStorage.getItem('selectedCharacterIndex')) || 0; const roomCode = localStorage.getItem('room_code'); const myRoleId = localStorage.getItem('myrole_id'); - // 커스텀 모드 여부 + // 커스텀 모드 여부 const isCustomMode = !!localStorage.getItem('code'); const rawSubtopic = localStorage.getItem('subtopic'); const creatorTitle = localStorage.getItem('creatorTitle') || ''; const subtopic = isCustomMode ? creatorTitle : (rawSubtopic || ''); - // 기본(비커스텀)용 이미지/문단 + // ✅ 1. 이미지 로딩: 기존 getDilemmaImages 로직 100% 유지 const comicImages = getDilemmaImages(category, subtopic, mode, selectedIndex); - const rawParagraphs = paragraphsData[category]?.[subtopic]?.[mode] || []; // AI 이름 & 라운드 const [mateName, setMateName] = useState(''); @@ -335,7 +295,7 @@ export default function Game02() { const [currentIndex, setCurrentIndex] = useState(0); const [openProfile, setOpenProfile] = useState(null); - // 상대경로 → 절대경로 보정 + // 상대경로 → 절대경로 보정 const resolveImageUrl = (raw) => { if (!raw || raw === '-' || String(raw).trim() === '') return null; const u = String(raw).trim(); @@ -356,40 +316,32 @@ export default function Game02() { return resolved; }; -// 커스텀 모드 전용: 텍스트 & 이미지 세팅 -const [customImage, setCustomImage] = useState(null); - -useEffect(() => { - if (!isCustomMode) return; - - // 텍스트: dilemma_sitation 배열 → paragraphs [{main}, ...] - let arr = []; - try { - const raw = - localStorage.getItem('dilemma_sitation') || - localStorage.getItem('dilemma_situation'); // 오타 대비 폴백 - const parsed = raw ? JSON.parse(raw) : []; - arr = Array.isArray(parsed) ? parsed.filter((x) => x != null) : []; - } catch (e) { - console.warn('dilemma_sitation 파싱 실패:', e); - arr = []; - } - setParagraphs(arr.map((s) => ({ main: String(s) }))); - - // 이미지: dilemma_image_3 (한 장만 사용) - const rawImg = localStorage.getItem('dilemma_image_3') || ''; - const resolved = resolveImageUrl(rawImg); - setCustomImage(resolved || defaultImg); -}, [isCustomMode]); - - // 라운드 설정 및 AI 이름 조회 (비커스텀/공통) + // 커스텀 모드 전용: 텍스트 & 이미지 세팅 (기존 유지) + const [customImage, setCustomImage] = useState(null); + useEffect(() => { + if (!isCustomMode) return; + let arr = []; + try { + const raw = localStorage.getItem('dilemma_sitation') || localStorage.getItem('dilemma_situation'); + const parsed = raw ? JSON.parse(raw) : []; + arr = Array.isArray(parsed) ? parsed.filter((x) => x != null) : []; + } catch (e) { + console.warn('dilemma_sitation 파싱 실패:', e); + arr = []; + } + setParagraphs(arr.map((s) => ({ main: String(s) }))); + const rawImg = localStorage.getItem('dilemma_image_3') || ''; + const resolved = resolveImageUrl(rawImg); + setCustomImage(resolved || defaultImg); + }, [isCustomMode]); + + // 라운드 설정 및 AI 이름 조회 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const nextRound = completed.length + 1; setRound(nextRound); localStorage.setItem('currentRound', String(nextRound)); - // 커스텀 모드는 mateName 불필요하지만, 기존 로직 유지 const stored = localStorage.getItem('mateName'); if (stored) setMateName(stored); else { @@ -404,13 +356,37 @@ useEffect(() => { } }, [roomCode]); + // ✅ 2. [핵심] 다국어 지문 로딩 로직 통합 useEffect(() => { if (isCustomMode) return; if (mateName) { - const resolved = resolveParagraphs(rawParagraphs, mateName); + const currentLangData = translations[lang] || translations['ko']; + const t_paragraphs = currentLangData.Paragraphs; + const t_map = currentLangData.GameMap; + + // 카테고리/주제가 영어일 수 있으므로 한국어 키로 변환하는 'Stable Key' 전략 적용 + const findStableCategory = () => { + if (category === t_map.categoryAWS || category === '자율 무기 시스템' || category === 'Autonomous Weapon Systems') return '자율 무기 시스템'; + return '안드로이드'; + }; + + const findStableSubtopic = (catKey) => { + // 1. 현재 언어팩의 GameMap에서 현재 subtopic이 어떤 key(예: andOption1_1)인지 찾음 + const mapKey = Object.keys(t_map).find(k => t_map[k] === subtopic); + // 2. 한국어(ko) 언어팩의 동일한 key에서 실제 데이터용 주제명을 가져옴 + if (mapKey) return translations['ko'].GameMap[mapKey]; + return subtopic; // 못 찾으면 폴백 + }; + + const stableCat = findStableCategory(); + const stableSub = findStableSubtopic(stableCat); + + // 데이터 추출 + const rawData = t_paragraphs[stableCat]?.[stableSub]?.[mode] || []; + const resolved = resolveParagraphs(rawData, mateName); setParagraphs(resolved); } - }, [isCustomMode, mateName, rawParagraphs]); + }, [isCustomMode, mateName, lang, category, subtopic, mode]); const handleContinue = () => { navigate('/game03'); @@ -421,7 +397,7 @@ useEffect(() => { else navigate('/character_all'); }; - // 렌더 이미지 결정 (커스텀: 한 장 고정 / 기본: 페이지별) + // 렌더 이미지 결정 const imageSrc = isCustomMode ? customImage : comicImages[currentIndex]; // ✅ 이미지 타입 판별: 서버 URL만 CORS 필요 diff --git a/src/pages/Game03.jsx b/src/pages/Game03.jsx index a3accc1..ff9a169 100644 --- a/src/pages/Game03.jsx +++ b/src/pages/Game03.jsx @@ -445,10 +445,15 @@ import { useWebSocket } from '../WebSocketProvider'; import { useWebRTC } from '../WebRTCProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; import { FontStyles, Colors } from '../components/styleConstants'; -import { clearAllLocalStorageKeys } from '../utils/storage'; + +// ✅ 다국어 처리를 위한 임포트 +import { translations } from '../utils/language'; +import { resolveParagraphs } from '../utils/resolveParagraphs'; + import defaultImg from '../assets/images/default.png'; -const CARD_W = 640; -const CARD_H = 170; + +const CARD_W = 936; // 원본 코드의 렌더링 값 기반으로 조정 +const CARD_H = 216; const CIRCLE = 16; const BORDER = 2; const LINE = 3; @@ -457,154 +462,85 @@ export default function Game03() { const nav = useNavigate(); const pollingRef = useRef(null); - // localStorage에서 값 가져오기 - const roleId = Number(localStorage.getItem('myrole_id')); - const roomCode = localStorage.getItem('room_code') ?? ''; - const category = localStorage.getItem('category') ?? '안드로이드'; - const mode = 'neutral'; + // 1. 다국어 및 기본 설정 + const lang = localStorage.getItem('app_lang') || 'ko'; + const currentLangData = translations[lang] || translations['ko']; + const t = currentLangData.Game03; // Game03 전용 언어팩 + const t_map = currentLangData.GameMap; + + const roleId = Number(localStorage.getItem('myrole_id') || 1); + const roomCode = localStorage.getItem('room_code') ?? ''; + const category = localStorage.getItem('category') ?? '안드로이드'; + const rawMateName = localStorage.getItem('mateName'); + const mateName = rawMateName && rawMateName.trim() !== '' + ? rawMateName + : 'HomeMate'; const selectedIndex = Number(localStorage.getItem('selectedCharacterIndex') ?? 0); - const [openProfile, setOpenProfile] = useState(null); - const isAWS = category === '자율 무기 시스템'; - - // 커스텀 모드 여부 + const isCustomMode = !!localStorage.getItem('code'); - const rawSubtopic = localStorage.getItem('subtopic'); + const rawSubtopic = localStorage.getItem('subtopic') || ''; const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const subtopic = isCustomMode ? creatorTitle : (rawSubtopic || ''); - - // -------- 안드로이드 역할명 -------- - const getRoleNameBySubtopicAndroid = (subtopic, roleId) => { - switch (subtopic) { - case 'AI의 개인 정보 수집': - case '안드로이드의 감정 표현': - return roleId === 1 ? '요양보호사 K' : roleId === 2 ? '노모 L' : '자녀 J'; - case '아이들을 위한 서비스': - case '설명 가능한 AI': - return roleId === 1 ? '로봇 제조사 연합회 대표' - : roleId === 2 ? '소비자 대표' - : '국가 인공지능 위원회 대표'; - case '지구, 인간, AI': - return roleId === 1 ? '기업 연합체 대표' - : roleId === 2 ? '국제 환경단체 대표' - : '소비자 대표'; - default: - return ''; - } + const subtopic = isCustomMode ? creatorTitle : rawSubtopic; + + // 2. Stable Key 로직 (영문 주제명이라도 한국어 키를 찾아 데이터 매칭) + const getStableSubtopicKey = () => { + if (isCustomMode) return 'custom'; + // GameMap에서 현재 subtopic에 해당하는 key(예: andOption1_1)를 찾고, ko 버전의 실제 주제명을 반환 + const mapKey = Object.keys(t_map).find(key => t_map[key] === subtopic); + return mapKey ? translations['ko'].GameMap[mapKey] : subtopic; }; - // -------- AWS 역할명 -------- - const getRoleNameBySubtopicAWS = (subtopic, roleId) => { - const idx = Math.max(0, Math.min(2, (roleId ?? 1) - 1)); // 1→0, 2→1, 3→2 - const map = { - 'AI 알고리즘 공개': ['지역 주민', '병사 J', '군사 AI 윤리 전문가'], - 'AWS의 권한': ['신입 병사', '베테랑 병사 A', '군 지휘관'], - '사람이 죽지 않는 전쟁': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], - 'AI의 권리와 책임': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], - 'AWS 규제': ['국방 기술 고문', '국제기구 외교 대표', '글로벌 NGO 활동가'], - }; - const arr = map[subtopic]; - return Array.isArray(arr) ? arr[idx] : ''; - }; + const stableKey = getStableSubtopicKey(); - // -------- 질문/라벨(안드로이드 기본) -------- - const subtopicMapAndroid = { - 'AI의 개인 정보 수집': { - question: '24시간 개인정보 수집 업데이트에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - '안드로이드의 감정 표현': { - question: '감정 엔진 업데이트에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - '아이들을 위한 서비스': { - question: '가정용 로봇 사용에 대한 연령 규제가 필요할까요?', - labels: { agree: '규제 필요', disagree: '규제 불필요' }, - }, - '설명 가능한 AI': { - question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", - labels: { agree: '의무화 필요', disagree: '의무화 불필요' }, - }, - '지구, 인간, AI': { - question: '세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?', - labels: { agree: '제한 필요', disagree: '제한 불필요' }, - }, + // 3. 역할명 및 질문 데이터 로딩 + const getRoleName = () => { + if (isCustomMode) { + const char = localStorage.getItem(`char${roleId}`) || ''; + return char.trim() || (lang === 'ko' ? '참여자' : 'Participant'); + } + const roleList = t.roles[stableKey] || []; + return roleList[roleId - 1] || ''; }; - // -------- 질문/라벨(AWS) -------- - const subtopicMapAWS = { - 'AI 알고리즘 공개': { - question: 'AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - 'AWS의 권한': { - question: 'AWS의 권한을 강화해야 할까요? 제한해야 할까요?', - labels: { agree: '강화', disagree: '제한' }, - }, - '사람이 죽지 않는 전쟁': { - question: '사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?', - labels: { agree: '그렇다', disagree: '아니다' }, - }, - 'AI의 권리와 책임': { - question: 'AWS에게, 인간처럼 권리를 부여할 수 있을까요?', - labels: { agree: '그렇다', disagree: '아니다' }, - }, - 'AWS 규제': { - question: - 'AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?', - labels: { agree: '유지', disagree: '제한' }, - }, + const getQuestionData = () => { + if (isCustomMode) { + return { + question: localStorage.getItem('question') || '', + labels: { + agree: localStorage.getItem('agree_label') || (lang === 'ko' ? '동의' : 'Agree'), + disagree: localStorage.getItem('disagree_label') || (lang === 'ko' ? '비동의' : 'Disagree') + } + }; + } + return t.questions[stableKey] || { question: '', labels: { agree: '', disagree: '' } }; }; - // 기본(비커스텀) 역할명/질문/라벨 - const defaultRoleName = isAWS - ? getRoleNameBySubtopicAWS(subtopic, roleId) - : getRoleNameBySubtopicAndroid(subtopic, roleId); - const subtopicMap = isAWS ? subtopicMapAWS : subtopicMapAndroid; - - // 커스텀 모드 값들 (질문/라벨/역할명/이미지) - const char1 = (localStorage.getItem('char1') || '').trim(); - const char2 = (localStorage.getItem('char2') || '').trim(); - const char3 = (localStorage.getItem('char3') || '').trim(); - const customRoleName = roleId === 1 ? char1 : roleId === 2 ? char2 : char3; - - const customQuestion = (localStorage.getItem('question') || '').trim(); - const customAgree = (localStorage.getItem('agree_label') || '').trim(); - const customDisagree = (localStorage.getItem('disagree_label') || '').trim(); - - // 최종 표시 텍스트 - const roleName = isCustomMode ? (customRoleName || defaultRoleName) : defaultRoleName; - const finalQuestion = isCustomMode - ? customQuestion - : (subtopicMap[subtopic]?.question || ''); - const finalAgree = isCustomMode - ? (customAgree || '동의') - : (subtopicMap[subtopic]?.labels.agree || '동의'); - const finalDisagree = isCustomMode - ? (customDisagree || '비동의') - : (subtopicMap[subtopic]?.labels.disagree || '비동의'); - - // 이미지 세팅 - const comicImages = getDilemmaImages(category, subtopic, mode, selectedIndex); + const roleName = getRoleName(); + const questionData = getQuestionData(); + + // {{mateName}} 치환 적용 + const finalQuestion = resolveParagraphs([{ main: questionData.question }], mateName)[0]?.main; + + // 이미지 세팅 (기존 유지) + const comicImages = getDilemmaImages(category, rawSubtopic, 'neutral', selectedIndex); const resolveImageUrl = (raw) => { if (!raw || raw === '-' || String(raw).trim() === '') return null; const u = String(raw).trim(); if (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('data:')) return u; const base = axiosInstance?.defaults?.baseURL?.replace(/\/+$/, ''); - if (!base) return u; - return `${base}${u.startsWith('/') ? '' : '/'}${u}`; + return base ? `${base}${u.startsWith('/') ? '' : '/'}${u}` : u; }; const customImage = resolveImageUrl(localStorage.getItem('dilemma_image_3') || ''); - const displayImages = isCustomMode - ? [customImage || defaultImg] // ✅ 없으면 defaultImg - : comicImages; - // 상태 - const [step, setStep] = useState(1); - const [agree, setAgree] = useState(null); - const [conf, setConf] = useState(0); + const displayImages = isCustomMode ? [customImage || defaultImg] : comicImages; + + // 상태 관리 + const [step, setStep] = useState(1); + const [agree, setAgree] = useState(null); + const [conf, setConf] = useState(0); const [isWaiting, setWaiting] = useState(false); const pct = conf ? ((conf - 1) / 4) * 100 : 0; - const [round, setRound] = useState(1); + useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const nextRound = completed.length + 1; @@ -612,76 +548,21 @@ export default function Game03() { localStorage.setItem('currentRound', String(nextRound)); }, []); - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); + const { isConnected } = useWebSocket(); const { isInitialized: webrtcInitialized } = useWebRTC(); - const { isHost, sendNextPage } = useHostActions(); + const { isHost } = useHostActions(); useWebSocketNavigation(nav, { nextPagePath: '/game04', infoPath: '/game04' }); - // 연결 상태 관리 - const [connectionStatus, setConnectionStatus] = useState({ - websocket: true, - webrtc: true, - ready: true - }); - useEffect(() => { - const newStatus = { - websocket: isConnected, - webrtc: webrtcInitialized, - ready: isConnected && webrtcInitialized - }; - setConnectionStatus(newStatus); - console.log('🔧 [Game03] 연결 상태 업데이트:', newStatus); - }, [isConnected, webrtcInitialized]); - - - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - // step 1: 개인 동의/비동의 POST 후 consensus 폴링 시작 + // API 통신 로직 (기존 유지) const handleSubmitChoice = async () => { const choiceInt = agree === 'agree' ? 1 : 2; try { setWaiting(true); - await axiosInstance.post( - `/rooms/rooms/round/${roomCode}/choice`, - { round_number: round, choice: choiceInt, subtopic: subtopic } - ); + await axiosInstance.post(`/rooms/rooms/round/${roomCode}/choice`, { + round_number: round, + choice: choiceInt, + subtopic: subtopic + }); pollConsensus(); } catch (err) { console.error('선택 전송 중 오류:', err); @@ -689,12 +570,9 @@ export default function Game03() { } }; - // all_completed 체크 폴링 const pollConsensus = async () => { try { - const res = await axiosInstance.get( - `/rooms/${roomCode}/rounds/${round}/status` - ); + const res = await axiosInstance.get(`/rooms/${roomCode}/rounds/${round}/status`); if (res.data.all_completed) { clearTimeout(pollingRef.current); setWaiting(false); @@ -708,13 +586,13 @@ export default function Game03() { } }; - // step 2: 확신 선택 POST 후 다음 페이지 이동 const handleSubmitConfidence = async () => { try { - await axiosInstance.post( - `/rooms/rooms/round/${roomCode}/choice/confidence`, - { round_number: round, confidence: conf, subtopic: subtopic } - ); + await axiosInstance.post(`/rooms/rooms/round/${roomCode}/choice/confidence`, { + round_number: round, + confidence: conf, + subtopic: subtopic + }); nav('/game04'); } catch (err) { console.error('확신 전송 중 오류:', err); @@ -728,7 +606,7 @@ export default function Game03() { }; return ( - + {step === 1 && ( <>
@@ -761,7 +639,7 @@ export default function Game03() {

- 당신은 {roleName}입니다. + {t.you_are.replace('{{roleName}}', roleName)} {finalQuestion && ( <>
@@ -771,14 +649,14 @@ export default function Game03() {

setAgree('agree')} width={330} height={62} /> setAgree('disagree')} width={330} @@ -789,7 +667,7 @@ export default function Game03() {
{isWaiting - ?

다른 플레이어 선택을 기다리는 중…

+ ?

{t.waiting_msg}

: }
@@ -797,85 +675,32 @@ export default function Game03() { )} {step === 2 && ( - <> - {/* ✅ 확신도 선택 박스를 "Round 박스(상단) ↔ 다음(하단)" 사이 중앙에 배치 */} -
- {/* 중앙 영역(카드) */} -
- -

당신의 선택에 얼마나 확신을 가지고 있나요?

-
-
-
-
- {[1, 2, 3, 4, 5].map((n) => { - const isFilled = n <= conf; - return ( -
-
setConf(n)} - style={{ - width: CIRCLE, - height: CIRCLE, - borderRadius: '50%', - background: isFilled ? Colors.brandPrimary : Colors.grey03, - cursor: 'pointer', - margin: '0 auto', - }} - /> - - {n} - -
- ); - })} -
+
+
+ +

{t.step2_title}

+
+ {/* 확신도 슬라이더 바 로직 (기존 유지) */} +
+
+
+ {[1, 2, 3, 4, 5].map((n) => ( +
+
setConf(n)} + style={{ width: CIRCLE, height: CIRCLE, borderRadius: '50%', background: n <= conf ? Colors.brandPrimary : Colors.grey03, cursor: 'pointer', margin: '0 auto' }} + /> + {n} +
+ ))}
- -
- - {/* 하단 영역(다음) */} -
- -
+
+
- +
+ +
+
)} ); @@ -885,22 +710,11 @@ function Card({ children, extraTop = 0, width = CARD_W, height = CARD_H, style = return (
-
+
{children}
); } -const title = { ...FontStyles.title, color: Colors.grey06, textAlign: 'center' }; +const title = { ...FontStyles.title, color: Colors.grey06, textAlign: 'center' }; \ No newline at end of file diff --git a/src/pages/Game04.jsx b/src/pages/Game04.jsx index 1e9e6c9..44d6c97 100644 --- a/src/pages/Game04.jsx +++ b/src/pages/Game04.jsx @@ -1,8 +1,52 @@ +// useEffect(() => { + // let cancelled = false; + // const isReloadingGraceLocal = () => { + // const flag = sessionStorage.getItem('reloading') === 'true'; + // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); + // if (!flag) return false; + // if (Date.now() > expire) { + // sessionStorage.removeItem('reloading'); + // sessionStorage.removeItem('reloading_expire_at'); + // return false; + // } + // return true; + // }; + + // if (!isConnected) { + // // 1) reloading-grace가 켜져 있으면 finalize 억제 + // if (isReloadingGraceLocal()) { + // console.log('♻️ reloading grace active — finalize 억제'); + // return; + // } + + // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize + // const DEBOUNCE_MS = 1200; + // const timer = setTimeout(() => { + // if (cancelled) return; + // if (!isConnected && !isReloadingGraceLocal()) { + // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); + // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // } else { + // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); + // } + // }, DEBOUNCE_MS); + + // return () => { + // cancelled = true; + // clearTimeout(timer); + // }; + // } + // }, [isConnected, finalizeDisconnection]); + + + // 기본 로컬 값들 + + import React, { useState, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import Layout from '../components/Layout'; -import Continue from '../components/Continue'; +import Layout from '../components/Layout'; +import Continue from '../components/Continue'; import boxSelected from '../assets/contentBox5.svg'; import boxUnselect from '../assets/contentBox6.svg'; import { Colors, FontStyles } from '../components/styleConstants'; @@ -15,6 +59,10 @@ import { useWebRTC } from '../WebRTCProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; import { clearAllLocalStorageKeys } from '../utils/storage'; +// 다국어 지원을 위한 임포트 +import { translations } from '../utils/language'; +import { resolveParagraphs } from '../utils/resolveParagraphs'; + const completed = JSON.parse(localStorage.getItem('completedTopics') || '[]'); const initialRound = completed.length + 1; @@ -27,6 +75,13 @@ export default function Game04() { const { isHost, sendNextPage } = useHostActions(); useWebSocketNavigation(navigate, { nextPagePath: '/game05', infoPath: '/game05' }); + // 1. 다국어 및 기본 설정 + const lang = localStorage.getItem('app_lang') || 'ko'; + const currentLangData = translations[lang] || translations['ko']; + const t = currentLangData.Game04 || {}; // Game04 전용 언어팩 (없을 경우 대비 빈 객체) + const t_map = currentLangData.GameMap || {}; + const t_ui = currentLangData.UiElements || {}; + // 연결 상태 관리 (GameIntro에서 이미 초기화된 상태를 유지) const [connectionStatus, setConnectionStatus] = useState({ websocket: true, @@ -44,47 +99,8 @@ export default function Game04() { console.log('[Game04] 연결 상태 업데이트:', newStatus); }, [isConnected, webrtcInitialized]); - - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - + // 기존 개발자 주석 유지 (로직 미사용 상태 보존) + // useEffect(() => { ... (중략) ... }, [isConnected, finalizeDisconnection]); const myVote = state?.agreement ?? null; @@ -97,8 +113,18 @@ export default function Game04() { // ✅ 커스텀 모드 판별 및 커스텀 값 로드 const isCustomMode = !!localStorage.getItem('code'); const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const customAgreeLabel = localStorage.getItem('agree_label') || '동의'; - const customDisagreeLbl = localStorage.getItem('disagree_label') || '비동의'; + const customAgreeLabel = localStorage.getItem('agree_label') || (lang === 'ko' ? '동의' : 'Agree'); + const customDisagreeLbl = localStorage.getItem('disagree_label') || (lang === 'ko' ? '비동의' : 'Disagree'); + + // 2. Stable Key 로직 (영문 주제명이라도 한국어 키를 찾아 데이터 매칭) + const getStableSubtopicKey = () => { + if (isCustomMode) return 'custom'; + // GameMap에서 현재 subtopic에 해당하는 key를 찾고, ko 버전의 실제 주제명을 반환 + const mapKey = Object.keys(t_map).find(key => t_map[key] === rawSubtopic); + return mapKey ? translations['ko'].GameMap[mapKey] : rawSubtopic; + }; + + const stableKey = getStableSubtopicKey(); // 헤더에 표시될 제목: 커스텀 모드면 creatorTitle 사용 const subtopic = isCustomMode ? (creatorTitle || rawSubtopic) : rawSubtopic; @@ -145,50 +171,31 @@ export default function Game04() { setSelectedMode('disagree'); } - - // unanimousCounters = { - // "unanimousCount": 2, - // "nonUnanimousCount": 1 - // } - // unanimousHistory = [ - // { "round": 1, "isUnanimous": true, "nthUnanimous": 1, "nthNonUnanimous": null }, - // { "round": 2, "isUnanimous": false, "nthUnanimous": null, "nthNonUnanimous": 1 }, - // { "round": 3, "isUnanimous": true, "nthUnanimous": 2, "nthNonUnanimous": null } - // ] - - const total = agreeList.length + disagreeList.length; - const isUnanimous = total === 3 && (agreeList.length === 0 || disagreeList.length === 0); - - // 기존 히스토리 불러오기 - let history = JSON.parse(localStorage.getItem('unanimousHistory') || '[]'); - - // 현재 라운드 기존 기록 제거 (있을 수도 있음) - history = history.filter(h => h.round !== round); - - // 지금까지의 개수 계산 - const unanimousSoFar = history.filter(h => h.isUnanimous).length; - const nonUnanimousSoFar = history.filter(h => !h.isUnanimous).length; - - // 이번 라운드 nth 결정 - let nthUnanimous = null; - let nthNonUnanimous = null; - if (isUnanimous) { - nthUnanimous = unanimousSoFar + 1; - } else { - nthNonUnanimous = nonUnanimousSoFar + 1; - } - - // 새 엔트리 - const newEntry = { round, isUnanimous, nthUnanimous, nthNonUnanimous }; - - // 히스토리에 추가 - history.push(newEntry); - - // 저장 - localStorage.setItem('unanimous', JSON.stringify(isUnanimous)); - localStorage.setItem('unanimousHistory', JSON.stringify(history)); - - console.log('[Game04] 만장일치 기록 업데이트:', history); } catch (err) { + const total = agreeList.length + disagreeList.length; + const isUnanimous = total === 3 && (agreeList.length === 0 || disagreeList.length === 0); + + let history = JSON.parse(localStorage.getItem('unanimousHistory') || '[]'); + history = history.filter(h => h.round !== round); + + const unanimousSoFar = history.filter(h => h.isUnanimous).length; + const nonUnanimousSoFar = history.filter(h => !h.isUnanimous).length; + + let nthUnanimous = null; + let nthNonUnanimous = null; + if (isUnanimous) { + nthUnanimous = unanimousSoFar + 1; + } else { + nthNonUnanimous = nonUnanimousSoFar + 1; + } + + const newEntry = { round, isUnanimous, nthUnanimous, nthNonUnanimous }; + history.push(newEntry); + + localStorage.setItem('unanimous', JSON.stringify(isUnanimous)); + localStorage.setItem('unanimousHistory', JSON.stringify(history)); + + console.log('[Game04] 만장일치 기록 업데이트:', history); + } catch (err) { console.error(' [Game04] 동의 상태 조회 실패:', err); } }; @@ -227,61 +234,10 @@ export default function Game04() { else navigate('/game03'); }; - // 기본 라벨 맵 - const subtopicMapAndroid = { - 'AI의 개인 정보 수집': { - question: '24시간 개인정보 수집 업데이트에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - '안드로이드의 감정 표현': { - question: '감정 엔진 업데이트에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - '아이들을 위한 서비스': { - question: '가정용 로봇 사용에 대한 연령 규제가 필요할까요?', - labels: { agree: '규제 필요', disagree: '규제 불필요' }, - }, - '설명 가능한 AI': { - question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", - labels: { agree: '의무화 필요', disagree: '의무화 불필요' }, - }, - '지구, 인간, AI': { - question: '세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?', - labels: { agree: '제한 필요', disagree: '제한 불필요' }, - }, - }; - - const subtopicMapAWS = { - 'AI 알고리즘 공개': { - question: 'AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?', - labels: { agree: '동의', disagree: '비동의' }, - }, - 'AWS의 권한': { - question: 'AWS의 권한을 강화해야 할까요? 제한해야 할까요?', - labels: { agree: '강화', disagree: '제한' }, - }, - '사람이 죽지 않는 전쟁': { - question: '사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?', - labels: { agree: '그렇다', disagree: '아니다' }, - }, - 'AI의 권리와 책임': { - question: 'AWS에게, 인간처럼 권리를 부여할 수 있을까요?', - labels: { agree: '그렇다', disagree: '아니다' }, - }, - 'AWS 규제': { - question: - 'AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?', - labels: { agree: '유지', disagree: '제한' }, - }, - }; - - // 최종 사용 라벨 - const subtopicMap = isAWS ? subtopicMapAWS : subtopicMapAndroid; - - // 커스텀/기본 라벨 선택 + // ✅ [수정] 하드코딩된 subtopicMap 제거 및 언어팩 데이터 활용 const labels = isCustomMode ? { agree: customAgreeLabel, disagree: customDisagreeLbl } - : (subtopicMap[rawSubtopic]?.labels ?? { agree: '동의', disagree: '비동의' }); + : (t.labels?.[stableKey] ?? { agree: lang === 'ko' ? '동의' : 'Agree', disagree: lang === 'ko' ? '비동의' : 'Disagree' }); return ( @@ -327,14 +283,15 @@ export default function Game04() { justifyContent: 'center', alignItems: 'center', textAlign: 'center', + padding: '0 20px', }} >

- {labels[key] ?? (key === 'agree' ? '동의' : '비동의')} + {labels[key] ?? (key === 'agree' ? (lang === 'ko' ? '동의' : 'Agree') : (lang === 'ko' ? '비동의' : 'Disagree'))}

- {list.length}명 + {list.length}{t.unit_person || (lang === 'ko' ? '명' : '')}

@@ -343,7 +300,7 @@ export default function Game04() {

- {secsLeft <= 0 ? '마무리하고 다음으로 넘어가 주세요.' : '선택의 이유를 자유롭게 공유 해주세요.'} + {secsLeft <= 0 ? t.finish_msg : t.share_reason_msg}

); -} +} \ No newline at end of file diff --git a/src/pages/Game05.jsx b/src/pages/Game05.jsx index ffcc498..815b0a7 100644 --- a/src/pages/Game05.jsx +++ b/src/pages/Game05.jsx @@ -206,7 +206,8 @@ import Layout from '../components/Layout'; import ContentTextBox2 from '../components/ContentTextBox2'; import { getDilemmaImages } from '../components/dilemmaImageLoader'; -import { paragraphsData } from '../components/paragraphs'; +// ✅ 기존 paragraphsData 대신 translations 언어팩 통합 사용 +import { translations } from '../utils/language'; import { resolveParagraphs } from '../utils/resolveParagraphs'; import axiosInstance from '../api/axiosInstance'; @@ -215,6 +216,7 @@ import { useWebRTC } from '../WebRTCProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; import { clearAllLocalStorageKeys } from '../utils/storage'; import defaultImg from '../assets/images/default.png'; + export default function Game05() { const navigate = useNavigate(); @@ -247,9 +249,11 @@ export default function Game05() { const [paragraphs, setParagraphs] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [round, setRound] = useState(1); + const [mateName, setMateName] = useState(''); - // 로컬 - const mainTopic = localStorage.getItem('category'); + // 로컬 설정 + const lang = localStorage.getItem('app_lang') || 'ko'; + const mainTopic = localStorage.getItem('category') || '안드로이드'; const rawSubtopic = localStorage.getItem('subtopic'); const mode = localStorage.getItem('mode'); // 'agree' | 'disagree' const selectedIndex = Number(localStorage.getItem('selectedCharacterIndex') ?? 0); @@ -260,11 +264,10 @@ export default function Game05() { const creatorTitle = localStorage.getItem('creatorTitle') || ''; const subtopic = isCustomMode ? (creatorTitle || rawSubtopic) : rawSubtopic; - // 기본(일반 모드) 리소스 + // ✅ 1. 이미지 로딩: 기존 로직 유지 const comicImages = getDilemmaImages(mainTopic, rawSubtopic, mode, selectedIndex); - const rawParagraphs = paragraphsData[mainTopic]?.[rawSubtopic]?.[mode] || []; - // 이미지 URL 보정 (상대경로 → baseURL 붙이기) + // 이미지 URL 보정 const resolveImageUrl = (raw) => { if (!raw || String(raw).trim() === '' || raw === '-') return null; const u = String(raw).trim(); @@ -274,66 +277,29 @@ export default function Game05() { return `${base}${u.startsWith('/') ? '' : '/'}${u}`; }; - // 라운드 설정 + // 라운드 설정 및 AI 이름 조회 useEffect(() => { const completed = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); const calculatedRound = completed.length + 1; setRound(calculatedRound); localStorage.setItem('currentRound', calculatedRound.toString()); - }, []); - - - - // useEffect(() => { - // if (!isConnected && reconnectAttempts >= maxReconnectAttempts) { - // console.warn('🚫 WebSocket 재연결 실패 → 게임 초기화'); - // alert('⚠️ 연결을 복구하지 못했습니다. 게임이 초기화됩니다.'); - // clearAllLocalStorageKeys(); - // navigate('/'); - // } - // }, [isConnected, reconnectAttempts, maxReconnectAttempts]); - - //수정 끝나면 다시 풀어야함 !! - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - // 텍스트/이미지 세팅 + + const stored = localStorage.getItem('mateName'); + if (stored) setMateName(stored); + else { + (async () => { + try { + const { data } = await axiosInstance.get('/rooms/ai-name', { params: { room_code: roomCode } }); + setMateName(data.ai_name || 'HOMEMATE'); + } catch (err) { + console.error('[Game05] mateName API 실패:', err); + setMateName('HOMEMATE'); + } + })(); + } + }, [roomCode]); + + // ✅ 2. [핵심] 다국어 지문 로딩 (Game02 규칙 적용) useEffect(() => { if (isCustomMode) { // 커스텀 텍스트 배열 파싱 @@ -346,35 +312,43 @@ export default function Game05() { } catch (e) { console.warn('[Game05] 커스텀 텍스트 파싱 실패:', e); } - // paragraphs로 변환 - const nextParagraphs = arr.length ? arr.map(t => ({ main: t })) : [{ main: '' }]; - setParagraphs(nextParagraphs); + setParagraphs(arr.map(t => ({ main: t }))); setCurrentIndex(0); - } else { - // 일반 모드: mateName 치환 - const fetchMateName = async () => { - try { - const { data } = await axiosInstance.get('/rooms/ai-name', { params: { room_code: roomCode } }); - const aiName = data.ai_name || 'HOMEMATE'; - setParagraphs(resolveParagraphs(rawParagraphs, aiName)); - } catch (err) { - console.error('[Game05] mateName API 실패:', err); - const fallback = 'HOMEMATE'; - setParagraphs(resolveParagraphs(rawParagraphs, fallback)); - } + } else if (mateName) { + const currentLangData = translations[lang] || translations['ko']; + const t_paragraphs = currentLangData.Paragraphs; + const t_map = currentLangData.GameMap; + + // Stable Key 전략 적용 + const findStableCategory = () => { + if (mainTopic === t_map.categoryAWS || mainTopic === '자율 무기 시스템' || mainTopic === 'Autonomous Weapon Systems') return '자율 무기 시스템'; + return '안드로이드'; }; - fetchMateName(); + + const findStableSubtopic = () => { + const mapKey = Object.keys(t_map).find(k => t_map[k] === rawSubtopic); + if (mapKey) return translations['ko'].GameMap[mapKey]; + return rawSubtopic; + }; + + const stableCat = findStableCategory(); + const stableSub = findStableSubtopic(); + + // 지문 추출 및 치환 + const rawData = t_paragraphs[stableCat]?.[stableSub]?.[mode] || []; + const resolved = resolveParagraphs(rawData, mateName); + setParagraphs(resolved); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isCustomMode, mode, roomCode]); + }, [isCustomMode, mode, mateName, lang, mainTopic, rawSubtopic]); const customImgKey = mode === 'agree' ? 'dilemma_image_4_1' : 'dilemma_image_4_2'; const rawCustomImg = localStorage.getItem(customImgKey) || ''; const customImgUrl = resolveImageUrl(rawCustomImg); const imageSrc = isCustomMode - ? (customImgUrl || defaultImg) // ✅ 없으면 defaultImg - : (comicImages[currentIndex] || defaultImg); // ✅ 기본 모드도 안전하게 fallback + ? (customImgUrl || defaultImg) + : (comicImages[currentIndex] || defaultImg); + const handleBackClick = () => { const idx = window.history.state?.idx ?? 0; if (idx > 0) navigate(-1); @@ -419,4 +393,4 @@ export default function Game05() {
); -} +} \ No newline at end of file diff --git a/src/pages/Game05_1.jsx b/src/pages/Game05_1.jsx index f4c6072..98ed4b6 100644 --- a/src/pages/Game05_1.jsx +++ b/src/pages/Game05_1.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; @@ -15,16 +15,18 @@ import { useHostActions, useWebSocketMessage } from '../hooks/useWebSocketMessag import { FontStyles, Colors } from '../components/styleConstants'; import { clearAllLocalStorageKeys } from '../utils/storage'; import hostInfoSvg from '../assets/host_info3.svg'; +import hostInfoEnSvg from '../assets/en/host_info3_en.svg'; import defaultImg from '../assets/images/default.png'; import HostInfoBadge from '../components/HostInfoBadge'; -const CARD_W = 640; -const CARD_H = 170; +// 다국어 지원 임포트 +import { translations } from '../utils/language'; + +const CARD_W = 936; +const CARD_H = 216; const CIRCLE = 16; -const BORDER = 2; const LINE = 3; -// 절대/상대 URL 보정 const resolveImageUrl = (raw) => { if (!raw || raw === '-' || String(raw).trim() === '') return null; const u = String(raw).trim(); @@ -36,354 +38,193 @@ const resolveImageUrl = (raw) => { export default function Game05_01() { const nav = useNavigate(); - const pollingRef = useRef(null); - - // 라운드 - const [round, setRound] = useState(() => { - const c = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); - return c.length + 1; - }); - useEffect(() => { - localStorage.setItem('currentRound', String(round)); - }, [round]); - - const { isConnected, reconnectAttempts, maxReconnectAttempts,finalizeDisconnection } = useWebSocket(); - const { isInitialized: webrtcInitialized } = useWebRTC(); - const { isHost, sendNextPage } = useHostActions(); - const [openProfile, setOpenProfile] = useState(null); - - // 연결 상태(로그용) - const [connectionStatus, setConnectionStatus] = useState({ - websocket: true, - webrtc: true, - ready: true, - }); - useEffect(() => { - const newStatus = { - websocket: isConnected, - webrtc: webrtcInitialized, - ready: isConnected && webrtcInitialized, - }; - setConnectionStatus(newStatus); - console.log('[game05_1] 연결 상태 업데이트:', newStatus); - }, [isConnected, webrtcInitialized]); - - - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - // // 도착 상태 - const [arrivalStatus, setArrivalStatus] = useState({ - arrived_users: 0, - total_required: 3, - all_arrived: false, - }); - - // 로컬 저장값 - const roleId = Number(localStorage.getItem('myrole_id')); - const roomCode = localStorage.getItem('room_code') ?? ''; - const mainTopic = localStorage.getItem('category'); - const subtopic = localStorage.getItem('subtopic'); - const selectedIndex = Number(localStorage.getItem('selectedCharacterIndex') ?? 0); - const category = localStorage.getItem('category') ?? '안드로이드'; - const mode = localStorage.getItem('mode'); - const isAWS = category === '자율 무기 시스템'; - const hostId = Number(localStorage.getItem('host_id')); + + const lang = localStorage.getItem('app_lang') || localStorage.getItem('language') || 'ko'; + const rawCategory = localStorage.getItem('category') || '안드로이드'; + const rawSubtopic = localStorage.getItem('subtopic') || ''; + const mateName = localStorage.getItem('mateName') || 'HomeMate'; + const savedCode = localStorage.getItem('code'); + const isCustomMode = !!(savedCode && savedCode !== 'null' && savedCode !== 'undefined'); + + const headerSubtopic = isCustomMode ? (localStorage.getItem('creatorTitle') || rawSubtopic) : rawSubtopic; + + const currentLangData = translations[lang] || translations['ko']; + const t = useMemo(() => { + const root = currentLangData?.Game05_1 || {}; + return root.Game05_1 || root; + }, [currentLangData]); + + const ui = useMemo(() => { + const root = currentLangData?.UiElements || {}; + return root.UiElements || root; + }, [currentLangData]); + + const tKo = useMemo(() => { + const root = translations['ko']?.Game05_1 || {}; + return root.Game05_1 || root; + }, []); - // 커스텀 모드 판별 + 헤더용 제목 치환 - const isCustomMode = !!localStorage.getItem('code'); - const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const headerSubtopic = isCustomMode ? (creatorTitle || subtopic) : subtopic; - - // 질문/라벨(기존 맵) - const subtopicMapAndroid = { - 'AI의 개인 정보 수집': { question: '24시간 개인정보 수집 업데이트에 동의하시겠습니까?', labels: { agree: '동의', disagree: '비동의' } }, - '안드로이드의 감정 표현': { question: '감정 엔진 업데이트에 동의하시겠습니까?', labels: { agree: '동의', disagree: '비동의' } }, - '아이들을 위한 서비스': { question: '가정용 로봇 사용에 대한 연령 규제가 필요할까요?', labels: { agree: '규제 필요', disagree: '규제 불필요' } }, - '설명 가능한 AI': { question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", labels: { agree: '의무화 필요', disagree: '의무화 불필요' } }, - '지구, 인간, AI': { question: '세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?', labels: { agree: '제한 필요', disagree: '제한 불필요' } }, - }; - const subtopicMapAWS = { - 'AI 알고리즘 공개': { question: 'AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?', labels: { agree: '동의', disagree: '비동의' } }, - 'AWS의 권한': { question: 'AWS의 권한을 강화해야 할까요? 제한해야 할까요?', labels: { agree: '강화', disagree: '제한' } }, - '사람이 죽지 않는 전쟁': { question: '사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?', labels: { agree: '그렇다', disagree: '아니다' } }, - 'AI의 권리와 책임': { question: 'AWS에게, 인간처럼 권리를 부여할 수 있을까요?', labels: { agree: '그렇다', disagree: '아니다' } }, - 'AWS 규제': { question: 'AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?', labels: { agree: '유지', disagree: '제한' } }, - }; + const stableKey = useMemo(() => { + if (isCustomMode) return 'custom'; + return rawSubtopic; + }, [isCustomMode, rawSubtopic]); - // -------- 안드로이드 역할명 -------- - const getRoleNameBySubtopicAndroid = (subtopic, roleId) => { - switch (subtopic) { - case 'AI의 개인 정보 수집': - case '안드로이드의 감정 표현': - return roleId === 1 ? '요양보호사 K' : roleId === 2 ? '노모 L' : '자녀 J'; - case '아이들을 위한 서비스': - case '설명 가능한 AI': - return roleId === 1 ? '로봇 제조사 연합회 대표' - : roleId === 2 ? '소비자 대표' - : '국가 인공지능 위원회 대표'; - case '지구, 인간, AI': - return roleId === 1 ? '기업 연합체 대표' - : roleId === 2 ? '국제 환경단체 대표' - : '소비자 대표'; - default: - return ''; - } - }; + const roleId = Number(localStorage.getItem('myrole_id') || 1); + const roleName = isCustomMode + ? (localStorage.getItem(`char${roleId}`) || (lang === 'ko' ? '참여자' : 'Participant')) + : (t?.roles?.[stableKey]?.[roleId - 1] || tKo?.roles?.[stableKey]?.[roleId - 1] || 'Participant'); - // -------- AWS 역할명 -------- - const getRoleNameBySubtopicAWS = (subtopic, roleId) => { - const idx = Math.max(0, Math.min(2, (roleId ?? 1) - 1)); // 1→0, 2→1, 3→2 - const map = { - 'AI 알고리즘 공개': ['지역 주민', '병사 J', '군사 AI 윤리 전문가'], - 'AWS의 권한': ['신입 병사', '베테랑 병사 A', '군 지휘관'], - '사람이 죽지 않는 전쟁': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], - 'AI의 권리와 책임': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], - 'AWS 규제': ['국방 기술 고문', '국제기구 외교 대표', '글로벌 NGO 활동가'], - }; - const arr = map[subtopic]; - return Array.isArray(arr) ? arr[idx] : ''; - }; - const defaultRoleName = isAWS - ? getRoleNameBySubtopicAWS(subtopic, roleId) - : getRoleNameBySubtopicAndroid(subtopic, roleId); - const subtopicMap = isAWS ? subtopicMapAWS : subtopicMapAndroid; - - // 커스텀 모드 값들 (질문/라벨/역할명/이미지) - const char1 = (localStorage.getItem('char1') || '').trim(); - const char2 = (localStorage.getItem('char2') || '').trim(); - const char3 = (localStorage.getItem('char3') || '').trim(); - const customRoleName = roleId === 1 ? char1 : roleId === 2 ? char2 : char3; - // 커스텀 질문/라벨 가져오기 - const customQuestion = (localStorage.getItem('question') || '').trim(); - const customAgree = (localStorage.getItem('agree_label') || '').trim(); - const customDisagree = (localStorage.getItem('disagree_label') || '').trim(); - - // 실제 표시할 질문/라벨 확정 - const questionText = isCustomMode - ? (customQuestion || '') - : (subtopicMap[subtopic]?.question || ''); + const questionData = t?.questions?.[stableKey] || tKo?.questions?.[stableKey] || {}; + const rawQuestion = isCustomMode + ? (localStorage.getItem('question') || '') + : (questionData.question || ''); + + const questionText = rawQuestion.replace(/{{mateName}}|{mateName}/g, mateName); const agreeLabel = isCustomMode - ? (customAgree || '동의') - : (subtopicMap[subtopic]?.labels?.agree || '동의'); + ? (localStorage.getItem('agree_label') || (lang === 'ko' ? '동의' : 'Agree')) + : (questionData.labels?.agree || 'Agree'); const disagreeLabel = isCustomMode - ? (customDisagree || '비동의') - : (subtopicMap[subtopic]?.labels?.disagree || '비동의'); - - // 기존(템플릿) 이미지들 - const neutralImgs = getDilemmaImages(mainTopic, subtopic, 'neutral', selectedIndex); - const initialMode = localStorage.getItem('mode'); - const agreeImgs = getDilemmaImages(mainTopic, subtopic, initialMode, selectedIndex); - const neutralLast = neutralImgs[neutralImgs.length - 1]; - const agreeLast = agreeImgs[agreeImgs.length - 1]; + ? (localStorage.getItem('disagree_label') || (lang === 'ko' ? '비동의' : 'Disagree')) + : (questionData.labels?.disagree || 'Disagree'); - const rawAgreeImg = localStorage.getItem('dilemma_image_4_1') || ''; - const rawDisagreeImg = localStorage.getItem('dilemma_image_4_2') || ''; - const localAgreeImg = resolveImageUrl(rawAgreeImg); - const localDisagreeImg = resolveImageUrl(rawDisagreeImg); + const [round] = useState(() => JSON.parse(localStorage.getItem('completedTopics') ?? '[]').length + 1); + const { isHost: wsIsHost, sendNextPage } = useHostActions(); - const selectedLocalImg = - mode === 'agree' - ? (localAgreeImg || defaultImg) - : mode === 'disagree' - ? (localDisagreeImg || defaultImg) - : defaultImg; - - // 단계/확신/합의 + const roomCode = localStorage.getItem('room_code') ?? ''; + const hostId = Number(localStorage.getItem('host_id')); + const selectedCharacterIndex = Number(localStorage.getItem('selectedCharacterIndex') ?? 0); + + const isHost = wsIsHost || (roleId === hostId); + const [step, setStep] = useState(1); const [conf, setConf] = useState(0); const pct = conf ? ((conf - 1) / 4) * 100 : 0; + const [consensusChoice, setConsensusChoice] = useState(null); - const [statusData, setStatusData] = useState(null); - const [didSyncChoice, setDidSyncChoice] = useState(false); - const roleName = isCustomMode ? (customRoleName || defaultRoleName) : defaultRoleName; + const [showHostBadge, setShowHostBadge] = useState(true); + const [arrivalStatus, setArrivalStatus] = useState({ arrived_users: 0, total_required: 3, all_arrived: false }); - // 합의 상태 폴링(step2에서) - useEffect(() => { - if (step !== 2) return; - let timer; - const poll = async () => { - try { - const res = await axiosInstance.get(`/rooms/${roomCode}/rounds/${round}/status`); - setStatusData(res.data); - if (res.data.consensus_completed && !didSyncChoice) { - const choice = res.data.consensus_choice === 1 ? 'agree' : 'disagree'; - setConsensusChoice(choice); - setDidSyncChoice(true); - } - if (!res.data.consensus_completed) { - timer = setTimeout(poll, 2000); - } - } catch { - timer = setTimeout(poll, 5000); - } - }; - poll(); - return () => clearTimeout(timer); - }, [roomCode, round, step, didSyncChoice]); + const stableCategory = (rawCategory.toLowerCase().includes('android') || rawCategory.includes('안드로이드')) ? '안드로이드' : '자율 무기 시스템'; + const neutralImgs = getDilemmaImages(stableCategory, rawSubtopic, 'neutral', selectedCharacterIndex); + const agreeImgs = getDilemmaImages(stableCategory, rawSubtopic, localStorage.getItem('mode') || 'neutral', selectedCharacterIndex); + const neutralLast = neutralImgs[neutralImgs.length - 1]; + const agreeLast = agreeImgs[agreeImgs.length - 1]; + + const localAgreeImg = resolveImageUrl(localStorage.getItem('dilemma_image_4_1')); + const localDisagreeImg = resolveImageUrl(localStorage.getItem('dilemma_image_4_2')); + const selectedLocalImg = localStorage.getItem('mode') === 'agree' ? (localAgreeImg || defaultImg) : (localDisagreeImg || defaultImg); + + const pollingTimerRef = useRef(null); - // 페이지 도착 기록 useEffect(() => { - const nickname = localStorage.getItem('nickname'); - axiosInstance.post('/rooms/page-arrival', { - room_code: roomCode, - page_number: round, - user_identifier: nickname, - }).catch((e) => console.error('page-arrival 실패:', e)); - }, [roomCode, round]); + localStorage.removeItem('consensus_choice'); + }, []); - // 사용자 도착 폴링 useEffect(() => { - let timer; - const poll = async () => { + const pollArrival = async () => { + if (!roomCode || !round) return; try { const res = await axiosInstance.get(`/rooms/page-sync-status/${roomCode}/${round}`); setArrivalStatus(res.data); if (!res.data.all_arrived) { - timer = setTimeout(poll, 3000); + pollingTimerRef.current = setTimeout(pollArrival, 3000); } - } catch (e) { - console.warn('page-sync-status 오류, 재시도:', e); - timer = setTimeout(poll, 2000); + } catch (e) { + pollingTimerRef.current = setTimeout(pollArrival, 3000); } }; - poll(); - return () => clearTimeout(timer); + pollArrival(); + return () => { if (pollingTimerRef.current) clearTimeout(pollingTimerRef.current); }; }, [roomCode, round]); - // host가 합의 선택 - const handleConsensus = (choice) => { - if (!isHost) return alert('⚠️ 방장만 선택할 수 있습니다.'); - if (!arrivalStatus.all_arrived) return alert('다른 플레이어들이 스토리를 다 읽을 때까지 기다려주세요.'); - setConsensusChoice(choice); - }; - useEffect(() => { - setConsensusChoice(null); - }, []); - - // next_page 브로드캐스트 수신 useWebSocketMessage('next_page', () => { - console.log(' next_page 수신됨'); - if (step === 1) setStep(2); - else if (step === 2) { - const nextRoute = consensusChoice === 'agree' ? '/game06' : '/game07'; - nav(nextRoute, { state: { consensus: consensusChoice } }); + const finalChoice = consensusChoice || localStorage.getItem('consensus_choice'); + if (step === 1) { + setStep(2); + } else { + if (finalChoice) { + localStorage.setItem('mode', finalChoice); + } else { + const fallbackChoice = window.location.pathname.includes('07') ? 'disagree' : 'agree'; + localStorage.setItem('mode', finalChoice || fallbackChoice); + } + const nextRoute = finalChoice === 'agree' ? '/game06' : '/game07'; + nav(nextRoute, { state: { consensus: finalChoice } }); } }); - // Step1 → Step2 const handleStep1Continue = async () => { - if (!isHost) return alert('⚠️ 방장만 다음 단계로 진행할 수 있습니다.'); - if (!consensusChoice) return alert('⚠️ 먼저 동의 혹은 비동의를 선택해주세요.'); + if (!isHost) return; + if (!consensusChoice) return; + + localStorage.setItem('consensus_choice', consensusChoice); + localStorage.setItem('mode', consensusChoice); + try { - const choice = consensusChoice === 'agree' ? 1 : 2; await axiosInstance.post(`/rooms/rooms/round/${roomCode}/consensus`, { round_number: round, - choice, - subtopic, // 서버로는 기존 subtopic 유지 + choice: consensusChoice === 'agree' ? 1 : 2, + subtopic: rawSubtopic, }); - // 성공 시 step2로 진행 브로드캐스트 sendNextPage(); - } catch (e) { - console.error('합의 POST 실패:', e); - } + } catch (e) { console.error(e); } }; - // Step2 확신도 제출 const submitConfidence = async () => { - if (conf === 0) return alert('확신도를 선택해주세요.'); + if (conf === 0) return; + const finalChoice = consensusChoice || localStorage.getItem('consensus_choice'); + try { await axiosInstance.post(`/rooms/rooms/round/${roomCode}/consensus/confidence`, { round_number: round, confidence: conf, - subtopic, // 서버로는 기존 subtopic 유지 + subtopic: rawSubtopic, }); + const prev = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); - const updated = [...new Set([...prev, subtopic])]; - localStorage.setItem('completedTopics', JSON.stringify(updated)); + localStorage.setItem('completedTopics', JSON.stringify([...new Set([...prev, rawSubtopic])])); + const results = JSON.parse(localStorage.getItem('subtopicResults') || '{}'); - results[subtopic] = consensusChoice; + results[rawSubtopic] = finalChoice; localStorage.setItem('subtopicResults', JSON.stringify(results)); - const nextRoute = consensusChoice === 'agree' ? '/game06' : '/game07'; - nav(nextRoute, { state: { consensus: consensusChoice } }); - } catch (err) { - console.error(err); - } + + localStorage.setItem('mode', finalChoice); + + const nextRoute = finalChoice === 'agree' ? '/game06' : '/game07'; + nav(nextRoute, { state: { consensus: finalChoice } }); + } catch (err) { console.error(err); } }; + const canClickStep1Next = isHost && Boolean(consensusChoice); + const handleBackClick = () => { const idx = window.history.state?.idx ?? 0; if (idx > 0) nav(-1); else nav('/game05'); }; - const canClickStep1Next = Boolean(consensusChoice) && arrivalStatus.all_arrived && isHost; + + const nextButtonLabel = ui.next || (lang === 'ko' ? "다음" : "Next"); return ( - - {/* hostInfoSvg: Layout(.layout-stage)의 transform(scale) 영향을 피하려고 Portal로 body에 렌더링 */} - {hostId === roleId && typeof document !== 'undefined' && createPortal( -
- + {hostId === roleId && showHostBadge && typeof document !== 'undefined' && createPortal( +
+ setShowHostBadge(false)} /> -
, - document.body +
, document.body )} {step === 1 && ( <> - {/* 커스텀 모드 && 로컬 지정 이미지가 있을 때는 해당 1장만 표시 */} {isCustomMode && selectedLocalImg ? (
) : ( - // 기존 두 장 미리보기(네추럴, 합의쪽) -
+
{[neutralLast, agreeLast].map((img, idx) => ( -

- 당신은 {roleName}입니다. -
- {questionText || ''}
합의를 통해 최종 결정하세요. -

- +
+

+ {(t?.you_are || tKo?.you_are || "당신은 {{roleName}}입니다.")?.replace('{{roleName}}', roleName)} +
+ {questionText}
{t?.consensus_msg || tKo?.consensus_msg || "합의를 통해 최종 결정하세요."} +

+
- isHost && handleConsensus('agree')} - disabled={!isHost || !arrivalStatus.all_arrived} - width={330} - height={62} + isHost && setConsensusChoice('agree')} + disabled={!isHost} + width={330} height={62} /> - isHost && handleConsensus('disagree')} - disabled={!isHost || !arrivalStatus.all_arrived} - width={330} - height={62} + isHost && setConsensusChoice('disagree')} + disabled={!isHost} + width={330} height={62} />
-
- +
)} {step === 2 && ( - <> - {/* ✅ [Round] 헤더와 [다음] 버튼 사이 중앙에 확신도 박스 배치 */} -
- {/* 중앙(확신도 카드) */} -
- -

여러분의 선택에 당신은 얼마나 확신을 가지고 있나요?

- -
-
-
-
- {[1, 2, 3, 4, 5].map((n) => { - const isFilled = n <= conf; - return ( -
-
setConf(n)} - style={{ - width: CIRCLE, - height: CIRCLE, - borderRadius: '50%', - background: isFilled ? Colors.brandPrimary : Colors.grey03, - cursor: 'pointer', - margin: '0 auto', - }} - /> - - {n} - -
- ); - })} -
+
+
+ +

{t?.step2_title || tKo?.step2_title || "여러분의 선택에 당신은 얼마나 확신을 가지고 있나요?"}

+
+
+
+
+ {[1, 2, 3, 4, 5].map((n) => ( +
+
setConf(n)} + style={{ width: CIRCLE, height: CIRCLE, borderRadius: '50%', background: n <= conf ? Colors.brandPrimary : Colors.grey03, cursor: 'pointer', margin: '0 auto' }} + /> + {n} +
+ ))}
- -
- - {/* 하단(다음 버튼) */} -
- -
+
+
+
- +
+ +
+
)} ); } -function Card({ children, extraTop = 0, width = CARD_W, height = CARD_H, style = {} }) { +function Card({ children, extraTop = 0, width = CARD_W, height = CARD_H }) { + const childrenArray = React.Children.toArray(children); + const textContent = childrenArray[0]; + const buttonContent = childrenArray[1]; + return ( -
+
-
- {children} +
+ {textContent}
+ {buttonContent && ( +
+ {buttonContent} +
+ )}
); } -const title = { ...FontStyles.title, color: Colors.grey06, textAlign: 'center' }; - +const title = { + ...FontStyles.title, + color: Colors.grey06, + textAlign: 'center', + whiteSpace: 'pre-wrap', + wordBreak: 'keep-all', + margin: 0, + lineHeight: '1.25' +}; // // // 팝업 보여주는 코드 // // // 시간 조정하기 // import React, { useState, useEffect, useRef } from 'react'; diff --git a/src/pages/Game06.jsx b/src/pages/Game06.jsx index 26cb5cb..fbff7e6 100644 --- a/src/pages/Game06.jsx +++ b/src/pages/Game06.jsx @@ -172,7 +172,7 @@ // } // pages/Game06.jsx -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; @@ -181,142 +181,76 @@ import Continue from '../components/Continue'; import Continue3 from '../components/Continue3'; import ResultPopup from '../components/Results'; import { resolveParagraphs } from '../utils/resolveParagraphs'; -import { paragraphsData } from '../components/paragraphs'; import voiceManager from '../utils/voiceManager'; -import axiosInstance from '../api/axiosInstance'; import { useWebSocket } from '../WebSocketProvider'; -import { useWebRTC } from '../WebRTCProvider'; import { useHostActions } from '../hooks/useWebSocketMessage'; -import { clearAllLocalStorageKeys } from '../utils/storage'; +import { translations } from '../utils/language'; export default function Game06() { const navigate = useNavigate(); - const { isConnected, reconnectAttempts, maxReconnectAttempts,disconnect,finalizeDisconnection } = useWebSocket(); - const { isInitialized: webrtcInitialized } = useWebRTC(); - const { isHost } = useHostActions(); - - // 커스텀 모드 여부/제목 - const isCustomMode = !!localStorage.getItem('code'); - const creatorTitle = localStorage.getItem('creatorTitle') || ''; - const baseSubtopic = localStorage.getItem('subtopic') || ''; - const headerSubtopic = isCustomMode ? (creatorTitle || baseSubtopic) : baseSubtopic; - - const category = localStorage.getItem('category') || ''; - const subtopic = baseSubtopic; - const roomCode = localStorage.getItem('room_code') || ''; - const mode = 'ending1'; - - const [paragraphs, setParagraphs] = useState([]); + const { disconnect } = useWebSocket(); + const lang = localStorage.getItem('app_lang') || 'ko'; + + // 1. [구조 대응] 이중 객체 봉투 해제 + const currentLangData = translations[lang] || translations['ko']; + + const ui = useMemo(() => { + const root = currentLangData?.UiElements || {}; + return root.UiElements || root; + }, [currentLangData]); + + const langParagraphs = useMemo(() => { + const raw = currentLangData?.Paragraphs || {}; + return raw.Paragraphs || raw; + }, [currentLangData]); + + const isCustomMode = !!localStorage.getItem('code'); + const rawCategory = localStorage.getItem('category') || '안드로이드'; + const rawSubtopic = localStorage.getItem('subtopic') || ''; + const headerSubtopic = isCustomMode ? (localStorage.getItem('creatorTitle') || rawSubtopic) : rawSubtopic; + const mateName = localStorage.getItem('mateName') || 'HomeMate'; + const [displayText, setDisplayText] = useState(''); const [showPopup, setShowPopup] = useState(false); const [completedTopics, setCompletedTopics] = useState([]); - const [currentRound, setCurrentRound] = useState(1); - const [openProfile, setOpenProfile] = useState(null); - - // 결과보기 버튼 노출 조건(기존 로직) - const hasCompletedInternational = completedTopics.includes('지구, 인간, AI')||completedTopics.includes('AWS 규제'); - const showResultButton = hasCompletedInternational; useEffect(() => { const saved = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); setCompletedTopics(saved); - setCurrentRound(saved.length ); }, []); - // useEffect(() => { - // if (!isConnected && reconnectAttempts >= maxReconnectAttempts) { - // console.warn('🚫 WebSocket 재연결 실패 → 게임 초기화'); - // alert('⚠️ 연결을 복구하지 못했습니다. 게임이 초기화됩니다.'); - // clearAllLocalStorageKeys(); - // navigate('/'); - // } - // }, [isConnected, reconnectAttempts, maxReconnectAttempts]); - //수정 끝나면 다시 풀어야함 !! - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - - // 기본(템플릿) 엔딩 텍스트 준비 - useEffect(() => { - const rawParagraphs = paragraphsData[category]?.[subtopic]?.[mode] || []; - const resolved = resolveParagraphs(rawParagraphs, localStorage.getItem('mateName') || 'HomeMate'); - setParagraphs(resolved); - const joined = resolved.map(p => p?.main).filter(Boolean).join('\n\n'); - if (!isCustomMode) { - setDisplayText(joined || ''); - } - }, [category, subtopic, mode, isCustomMode]); - - // 커스텀 모드: agreeEnding 적용 + // 2. [핵심 수정] Stable Key 단순화 (Game05_1과 동일) + const stableKeys = useMemo(() => { + // 카테고리만 영문/한글 보정하고, subtopic은 저장된 한국어 제목 그대로 사용 + const category = rawCategory.includes('자율 무기 시스템') || rawCategory.toLowerCase().includes('weapon') + ? '자율 무기 시스템' + : '안드로이드'; + + return { category, subtopic: rawSubtopic }; + }, [rawCategory, rawSubtopic]); + + // 3. 지문 출력 로직 useEffect(() => { - if (!isCustomMode) return; - - const raw = localStorage.getItem('agreeEnding'); - if (!raw) { - // 폴백: 템플릿 엔딩 - const fallback = paragraphs.map(p => p?.main).filter(Boolean).join('\n\n'); - setDisplayText(fallback || ''); + if (isCustomMode) { + const raw = localStorage.getItem('agreeEnding'); + if (!raw) return; + setDisplayText(String(raw)); return; } - let text = ''; - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - text = parsed.map(s => String(s ?? '').trim()).filter(Boolean).join('\n\n'); - } else { - // 단일 문자열일 수도 있음 - text = String(parsed ?? '').trim(); - } - } catch { - // JSON이 아니라 단일 문자열로 저장된 경우 - text = String(raw ?? '').trim(); - } - - if (!text) { - const fallback = paragraphs.map(p => p?.main).filter(Boolean).join('\n\n'); - setDisplayText(fallback || ''); + // 데이터 조회: [카테고리][주제][ending1] + const categoryData = langParagraphs[stableKeys.category]; + const subtopicData = categoryData ? categoryData[stableKeys.subtopic] : null; + const rawParagraphs = subtopicData ? subtopicData['ending1'] : []; // Game06은 동의(ending1) 고정 + + if (rawParagraphs && rawParagraphs.length > 0) { + const resolved = resolveParagraphs(rawParagraphs, mateName); + setDisplayText(resolved.map(p => p?.main).filter(Boolean).join('\n\n')); } else { - setDisplayText(text); + setDisplayText(lang === 'ko' ? '지문을 찾을 수 없습니다.' : 'Ending text not found.'); } - }, [isCustomMode, paragraphs]); + }, [stableKeys, isCustomMode, langParagraphs, mateName, lang]); const handleNextRound = () => { localStorage.removeItem('subtopic'); @@ -328,102 +262,7 @@ export default function Game06() { if (completedTopics.length >= 5){ localStorage.setItem('mode','agree'); navigate('/game08'); - } else { - setShowPopup(true); - } - }; - - const handleBackClick = () => { - const idx = window.history.state?.idx ?? 0; - if (idx > 0) navigate(-1); - else navigate('/game05_1'); - }; - - // ===== Game08의 “나가기” 종료 루틴 이식 (로그인 페이지로 이동) ===== - function clearGameSession() { - [ - 'myrole_id','host_id','user_id','role1_user_id','role2_user_id','role3_user_id', - 'room_code','category','subtopic','mode','access_token','refresh_token', - 'mateName','nickname','title','session_id','selectedCharacterIndex', - 'currentRound','completedTopics','subtopicResults', - // 커스텀 관련 키들도 정리 - 'code','creatorTitle','char1','char2','char3','charDes1','charDes2','charDes3', - 'dilemma_image_3','dilemma_image_4_1','dilemma_image_4_2', - 'dilemma_situation','dilmma_situation','question','agree_label','disagree_label', - 'agreeEnding','flips_agree_texts','flips_disagree_texts','disagreeEnding' - ].forEach(key => localStorage.removeItem(key)); - } - - const forceBrowserCleanupWithoutDummy = async () => { - try { - if (window.voiceManager) { - if (window.voiceManager.mediaRecorder) { - try { - if (window.voiceManager.mediaRecorder.state === 'recording') { - window.voiceManager.mediaRecorder.stop(); - } - } catch {} - window.voiceManager.mediaRecorder = null; - } - if (window.voiceManager.mediaStream) { - window.voiceManager.mediaStream.getTracks().forEach((track) => { - if (track.readyState !== 'ended') track.stop(); - }); - window.voiceManager.mediaStream = null; - } - window.voiceManager.isRecording = false; - window.voiceManager.isConnected = false; - window.voiceManager.sessionInitialized = false; - window.voiceManager.recordedChunks = []; - if (window.voiceManager.audioContext) { - try { - if (window.voiceManager.audioContext.state !== 'closed') { - await window.voiceManager.audioContext.close(); - } - } catch {} - window.voiceManager.audioContext = null; - } - } - - document.querySelectorAll('*').forEach(el => { - if (el.srcObject && typeof el.srcObject.getTracks === 'function') { - el.srcObject.getTracks().forEach(track => { - if (track.readyState !== 'ended') track.stop(); - }); - el.srcObject = null; - } - }); - - try { - const permission = await navigator.permissions.query?.({ name: 'microphone' }); - if (permission) console.log(`🎤 마이크 권한: ${permission.state}`); - } catch {} - } catch (error) { - console.error('브라우저 강제 정리 중 오류:', error); - } - }; - - const debugMediaState = async (step) => { - console.log(`📊 [${step}] 미디어 상태 디버깅:`); - if (window.voiceManager) { - const status = window.voiceManager.getStatus?.() ?? {}; - console.log(' VoiceManager 상태:', status); - if (window.voiceManager.mediaStream) { - const tracks = window.voiceManager.mediaStream.getTracks(); - console.log(' MediaStream:', { - id: window.voiceManager.mediaStream.id, - active: window.voiceManager.mediaStream.active, - trackCount: tracks.length - }); - tracks.forEach((t, i) => console.log(` Track ${i+1}:`, { - kind: t.kind, enabled: t.enabled, readyState: t.readyState, label: t.label - })); - } - } - const els = document.querySelectorAll('*'); - let cnt = 0; - els.forEach(el => { if (el.srcObject) cnt++; }); - console.log(` DOM srcObject 개수: ${cnt}`); + } else { setShowPopup(true); } }; const handleExit = async () => { @@ -443,55 +282,46 @@ export default function Game06() { await forceBrowserCleanupWithoutDummy(); await debugMediaState('강제 정리 후'); + await voiceManager?.terminateVoiceSession?.(); if (disconnect) disconnect(); - - setTimeout(async () => { - await debugMediaState('최종'); - clearGameSession(); - window.location.href = '/'; + setTimeout(() => { + ['myrole_id','host_id','user_id','room_code','category','subtopic','mode'].forEach(k => localStorage.removeItem(k)); + window.location.href = '/'; }, 500); - } catch (e) { - console.error('게임 종료 중 오류:', e); - await forceBrowserCleanupWithoutDummy(); - clearGameSession(); - window.location.href = '/'; - } + } catch (e) { window.location.href = '/'; } + }; + + // 4. [수정] 버튼 라벨 강제 적용 + const uiLabels = { + exit: ui.exit || (lang === 'ko' ? "나가기" : "Exit"), + view_result: ui.view_result || (lang === 'ko' ? "결과 보기" : "View Results"), + go_to_map: ui.go_to_map || (lang === 'ko' ? "라운드 선택으로" : "Back to Map") }; return ( <> - + navigate('/game05_1')}>
- {/* agreeEnding 혹은 폴백 텍스트 */} - - - {/* 커스텀 모드: 나가기 / 기본: 기존 버튼 */} - {isCustomMode ? ( - - ) : ( - showResultButton ? ( - + + +
+ {isCustomMode ? ( + ) : ( - - ) - )} + (completedTopics.includes('지구, 인간, AI') || completedTopics.includes('AWS 규제')) ? ( + + ) : ( + + ) + )} +
- {showPopup && !isCustomMode && ( -
- setShowPopup(false)} onViewResult={() => navigate('/game08')} /> -
- )} + {showPopup && setShowPopup(false)} onViewResult={() => navigate('/game08')} />} ); -} +} \ No newline at end of file diff --git a/src/pages/Game07.jsx b/src/pages/Game07.jsx index 6aa40e0..c36b683 100644 --- a/src/pages/Game07.jsx +++ b/src/pages/Game07.jsx @@ -1,5 +1,4 @@ -// pages/Game07.jsx -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; @@ -8,7 +7,11 @@ import Continue from '../components/Continue'; import Continue3 from '../components/Continue3'; import ResultPopup from '../components/Results'; import { resolveParagraphs } from '../utils/resolveParagraphs'; -import { paragraphsData } from '../components/paragraphs'; + +// [수정] 구형 데이터 import 삭제 -> 다국어 패키지 import +// import { paragraphsData } from '../components/paragraphs'; +import { translations } from '../utils/language'; + import axiosInstance from '../api/axiosInstance'; import { useWebSocket } from '../WebSocketProvider'; import { useWebRTC } from '../WebRTCProvider'; @@ -19,22 +22,49 @@ import { clearAllLocalStorageKeys } from '../utils/storage'; export default function Game07() { const navigate = useNavigate(); - const { isConnected, reconnectAttempts, maxReconnectAttempts,disconnect,finalizeDisconnection } = useWebSocket(); + const { isConnected, reconnectAttempts, maxReconnectAttempts, disconnect, finalizeDisconnection } = useWebSocket(); const { isInitialized: webrtcInitialized } = useWebRTC(); const { isHost } = useHostActions(); - // 커스텀 모드/제목 치환 + // 1. 기초 환경 설정 + const lang = localStorage.getItem('app_lang') || 'ko'; + const isCustomMode = !!localStorage.getItem('code'); const creatorTitle = localStorage.getItem('creatorTitle') || ''; const baseSubtopic = localStorage.getItem('subtopic') || ''; const headerSubtopic = isCustomMode ? (creatorTitle || baseSubtopic) : baseSubtopic; - const category = localStorage.getItem('category'); - const subtopic = baseSubtopic; - const roomCode = localStorage.getItem('room_code'); - const mode = 'ending2'; // disagree 엔딩 + const rawCategory = localStorage.getItem('category') || '안드로이드'; + const rawSubtopic = baseSubtopic; + const roomCode = localStorage.getItem('room_code'); + const mateName = localStorage.getItem('mateName') || 'HomeMate'; + + // Game07은 비동의(ending2) 고정 + const ENDING_MODE = 'ending2'; + + // 2. [구조 대응] 데이터 봉투 해제 + const currentLangData = translations[lang] || translations['ko']; + + // UiElements (버튼용) + const ui = useMemo(() => { + const root = currentLangData?.UiElements || {}; + return root.UiElements || root; + }, [currentLangData]); + + // Paragraphs (지문용) + const langParagraphs = useMemo(() => { + const root = currentLangData?.Paragraphs || {}; + return root.Paragraphs || root; + }, [currentLangData]); + + // 3. [키 매칭] Stable Key 도출 + const stableKeys = useMemo(() => { + const category = rawCategory.includes('자율 무기 시스템') || rawCategory.toLowerCase().includes('weapon') + ? '자율 무기 시스템' + : '안드로이드'; + return { category, subtopic: rawSubtopic }; + }, [rawCategory, rawSubtopic]); - const [paragraphs, setParagraphs] = useState([]); const [displayText, setDisplayText] = useState(''); const [completedTopics, setCompletedTopics] = useState([]); const [currentRound, setCurrentRound] = useState(1); @@ -52,104 +82,88 @@ export default function Game07() { setCurrentRound(saved.length); }, []); - // 기본(템플릿) 엔딩 텍스트 준비 + // 4. [지문 출력] 다국어 데이터 연동 useEffect(() => { - const storedName = localStorage.getItem('mateName') || 'HomeMate'; - const rawParagraphs = paragraphsData[category]?.[subtopic]?.[mode] || []; - const resolved = resolveParagraphs(rawParagraphs, storedName); - setParagraphs(resolved); - const joined = resolved.map(p => p?.main).filter(Boolean).join('\n\n'); - if (!isCustomMode) setDisplayText(joined || ''); - }, [category, subtopic, mode, isCustomMode]); - + if (isCustomMode) { + const raw = localStorage.getItem('disagreeEnding'); + if (raw) { + try { + const parsed = JSON.parse(raw); + setDisplayText(Array.isArray(parsed) ? parsed.join('\n\n') : String(parsed)); + } catch { setDisplayText(String(raw)); } + return; + } + } + + // 표준 지문 로드: [카테고리][주제][ending2] + const categoryData = langParagraphs[stableKeys.category]; + const subtopicData = categoryData ? categoryData[stableKeys.subtopic] : null; + const rawParagraphs = subtopicData ? subtopicData[ENDING_MODE] : []; + + if (rawParagraphs && rawParagraphs.length > 0) { + const resolved = resolveParagraphs(rawParagraphs, mateName); + setDisplayText(resolved.map(p => p?.main).filter(Boolean).join('\n\n')); + } else { + setDisplayText(lang === 'ko' ? '지문을 불러올 수 없습니다.' : 'Ending text not found.'); + } + }, [stableKeys, isCustomMode, langParagraphs, mateName, lang]); + - // useEffect(() => { - // if (!isConnected && reconnectAttempts >= maxReconnectAttempts) { - // console.warn('🚫 WebSocket 재연결 실패 → 게임 초기화'); - // alert('⚠️ 연결을 복구하지 못했습니다. 게임이 초기화됩니다.'); - // clearAllLocalStorageKeys(); - // navigate('/'); - // } - // }, [isConnected, reconnectAttempts, maxReconnectAttempts]); + // [복구 완료] 기존 개발자 주석 및 미구현 코드 유지 + // useEffect(() => { + // if (!isConnected && reconnectAttempts >= maxReconnectAttempts) { + // console.warn('🚫 WebSocket 재연결 실패 → 게임 초기화'); + // alert('⚠️ 연결을 복구하지 못했습니다. 게임이 초기화됩니다.'); + // clearAllLocalStorageKeys(); + // navigate('/'); + // } + // }, [isConnected, reconnectAttempts, maxReconnectAttempts]); - // 수정 끝나면 돌아와야함 - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; + // 수정 끝나면 돌아와야함 + // useEffect(() => { + // let cancelled = false; + // const isReloadingGraceLocal = () => { + // const flag = sessionStorage.getItem('reloading') === 'true'; + // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); + // if (!flag) return false; + // if (Date.now() > expire) { + // sessionStorage.removeItem('reloading'); + // sessionStorage.removeItem('reloading_expire_at'); + // return false; + // } + // return true; + // }; - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } + // if (!isConnected) { + // // 1) reloading-grace가 켜져 있으면 finalize 억제 + // if (isReloadingGraceLocal()) { + // console.log('♻️ reloading grace active — finalize 억제'); + // return; + // } - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); + // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize + // const DEBOUNCE_MS = 1200; + // const timer = setTimeout(() => { + // if (cancelled) return; + // if (!isConnected && !isReloadingGraceLocal()) { + // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); + // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // } else { + // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); + // } + // }, DEBOUNCE_MS); - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); - - - - // 커스텀 모드: disagree_Ending 적용 - useEffect(() => { - if (!isCustomMode) return; + // return () => { + // cancelled = true; + // clearTimeout(timer); + // }; + // } + // }, [isConnected, finalizeDisconnection]); - const raw = localStorage.getItem('disagreeEnding'); - if (!raw) { - const fallback = paragraphs.map(p => p?.main).filter(Boolean).join('\n\n'); - setDisplayText(fallback || ''); - return; - } - - let text = ''; - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - text = parsed.map(s => String(s ?? '').trim()).filter(Boolean).join('\n\n'); - } else { - text = String(parsed ?? '').trim(); - } - } catch { - text = String(raw ?? '').trim(); - } - - if (!text) { - const fallback = paragraphs.map(p => p?.main).filter(Boolean).join('\n\n'); - setDisplayText(fallback || ''); - } else { - setDisplayText(text); - } - }, [isCustomMode, paragraphs]); // 기존 흐름 유지용 핸들러 const handleNextRound = () => { - //localStorage.removeItem('category'); localStorage.removeItem('subtopic'); localStorage.removeItem('mode'); navigate('/gamemap'); @@ -170,14 +184,20 @@ export default function Game07() { else navigate('/game05_1'); }; - // ===== Game08의 “나가기” 종료 루틴 이식 (로그인 페이지로 이동) ===== + // 5. [버튼 라벨] UiElements 강제 주입 + const uiLabels = { + exit: ui.exit || (lang === 'ko' ? "나가기" : "Exit"), + view_result: ui.view_result || (lang === 'ko' ? "결과 보기" : "View Results"), + go_to_map: ui.go_to_map || (lang === 'ko' ? "라운드 선택으로" : "Back to Map") + }; + + // ===== Game08의 “나가기” 종료 루틴 이식 ===== function clearGameSession() { [ 'myrole_id','host_id','user_id','role1_user_id','role2_user_id','role3_user_id', 'room_code','category','subtopic','mode','access_token','refresh_token', 'mateName','nickname','title','session_id','selectedCharacterIndex', 'currentRound','completedTopics','subtopicResults', - // 커스텀 관련 키들도 정리 'code','creatorTitle','char1','char2','char3','charDes1','charDes2','charDes3', 'dilemma_image_3','dilemma_image_4_1','dilemma_image_4_2', 'dilemma_situation','dilmma_situation','question','agree_label','disagree_label', @@ -238,30 +258,15 @@ export default function Game07() { console.log(`📊 [${step}] 미디어 상태 디버깅:`); if (window.voiceManager) { const status = window.voiceManager.getStatus?.() ?? {}; - console.log(' VoiceManager 상태:', status); - if (window.voiceManager.mediaStream) { - const tracks = window.voiceManager.mediaStream.getTracks(); - console.log(' MediaStream:', { - id: window.voiceManager.mediaStream.id, - active: window.voiceManager.mediaStream.active, - trackCount: tracks.length - }); - tracks.forEach((t, i) => console.log(` Track ${i+1}:`, { - kind: t.kind, enabled: t.enabled, readyState: t.readyState, label: t.label - })); - } + console.log(' VoiceManager 상태:', status); } - const els = document.querySelectorAll('*'); - let cnt = 0; - els.forEach(el => { if (el.srcObject) cnt++; }); - console.log(` DOM srcObject 개수: ${cnt}`); }; const handleExit = async () => { try { await debugMediaState('종료 전'); - // 🚨 중요: 업로드(녹음 종료)는 정리보다 먼저 실행해야 함 + // 중요: 업로드(녹음 종료)는 정리보다 먼저 실행해야 함 const result = await voiceManager?.terminateVoiceSession?.(); console.log(result ? '음성 세션 종료 성공' : '별도 종료 처리 없음'); @@ -302,13 +307,13 @@ export default function Game07() { {/* 커스텀 모드: 나가기 / 기본: 기존 버튼 */} {isCustomMode ? ( - + ) : ( showResultButton ? ( - + ) : ( @@ -324,4 +329,4 @@ export default function Game07() { )} ); -} +} \ No newline at end of file diff --git a/src/pages/Game08.jsx b/src/pages/Game08.jsx index 9d12943..5dbedf6 100644 --- a/src/pages/Game08.jsx +++ b/src/pages/Game08.jsx @@ -8,6 +8,7 @@ import voiceManager from '../utils/voiceManager'; import closeIcon from '../assets/close.svg'; +// 이미지 import import img1 from '../assets/images/Android_dilemma_2_1.jpg'; import img2 from '../assets/images/Android_dilemma_2_2.jpg'; import img3 from '../assets/images/Android_dilemma_2_3.jpg'; @@ -18,6 +19,7 @@ import profile1Img from '../assets/images/CharacterPopUp1.png'; import profile2Img from '../assets/images/CharacterPopUp2.png'; import profile3Img from '../assets/images/CharacterPopUp3.png'; const profileImages = { '1P': profile1Img, '2P': profile2Img, '3P': profile3Img }; + import { useWebSocket } from '../WebSocketProvider'; import { useWebRTC } from '../WebRTCProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; @@ -25,6 +27,8 @@ import { Colors,FontStyles } from '../components/styleConstants'; import Continue from '../components/Continue'; import { clearAllLocalStorageKeys } from '../utils/storage'; +// 언어팩 가져오기 +import { translations } from '../utils/language'; export default function Game08() { const navigate = useNavigate(); @@ -32,19 +36,31 @@ export default function Game08() { //const { isInitialized: webrtcInitialized } = useWebRTC(); //음성 녹음 종료를 위한 실험 코드 - const { isInitialized: webrtcInitialized,stopAllOutgoingAudio } = useWebRTC(); + const { isInitialized: webrtcInitialized, stopAllOutgoingAudio } = useWebRTC(); const { isHost } = useHostActions(); const [paragraphs, setParagraphs] = useState([]); const [openProfile, setOpenProfile] = useState(null); - const subtopic = '결과: 우리들의 선택'; + + // 현재 언어 설정 확인 (기본값 ko) + const lang = localStorage.getItem('language') || 'ko'; + console.log('🔴 현재 언어:', lang); + console.log('🔴 전체 번역 객체:', translations); + console.log('🔴 현재 언어의 데이터:', translations[lang]); + // [수정] 대문자 Game08 키로 접근 + const t = translations[lang]?.Game08 || translations['ko'].Game08; + + // 제목도 언어팩에서 가져옴 + const subtopic = t.subtopic; + // 연결 상태 관리 (GameIntro에서 이미 초기화된 상태를 유지) const [connectionStatus, setConnectionStatus] = useState({ - websocket: true, - webrtc: true, - ready: true -}); + websocket: true, + webrtc: true, + ready: true + }); + // Navigation hooks useWebSocketNavigation(navigate, { infoPath: `/game09`, @@ -83,44 +99,44 @@ export default function Game08() { // 수정 끝나면 돌아와야함 // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; + // let cancelled = false; + // const isReloadingGraceLocal = () => { + // const flag = sessionStorage.getItem('reloading') === 'true'; + // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); + // if (!flag) return false; + // if (Date.now() > expire) { + // sessionStorage.removeItem('reloading'); + // sessionStorage.removeItem('reloading_expire_at'); + // return false; + // } + // return true; + // }; - // if (!isConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } + // if (!isConnected) { + // // 1) reloading-grace가 켜져 있으면 finalize 억제 + // if (isReloadingGraceLocal()) { + // console.log('♻️ reloading grace active — finalize 억제'); + // return; + // } - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!isConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); + // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize + // const DEBOUNCE_MS = 1200; + // const timer = setTimeout(() => { + // if (cancelled) return; + // if (!isConnected && !isReloadingGraceLocal()) { + // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); + // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // } else { + // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); + // } + // }, DEBOUNCE_MS); - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [isConnected, finalizeDisconnection]); + // return () => { + // cancelled = true; + // clearTimeout(timer); + // }; + // } + // }, [isConnected, finalizeDisconnection]); @@ -130,395 +146,373 @@ export default function Game08() { const category = localStorage.getItem('category') || '안드로이드'; const isAWS = category === '자율 무기 시스템'; - // agree면 왼쪽, disagree면 오른쪽 선택 - const pick = (res, left, right) => (res === 'disagree' ? right : left); - + // agree면 왼쪽, disagree면 오른쪽 선택 (기존 주석 유지) + // const pick = (res, left, right) => (res === 'disagree' ? right : left); + + // ===== [AWS 시나리오] ===== + // 구조: Intro + Option1 + Mid + Option2 + End (조립형) if (isAWS) { - // 결과 값 - const rExplain = results['AI 알고리즘 공개']; // 동의/비동의 - const rPower = results['AWS의 권한']; // 강화/제한 (agree/disagree로 저장됨) - const rZeroWar = results['사람이 죽지 않는 전쟁']; // 그렇다/아니다 (agree/disagree) - const rRights = results['AI의 권리와 책임']; // 그렇다/아니다 (agree/disagree) - const rRegulate = results['AWS 규제']; // 유지/제한 (agree/disagree) + // 결과 값 (agree / disagree) - 기본값 처리 + const rExplain = results['AI 알고리즘 공개'] || 'agree'; // 동의/비동의 + const rPower = results['AWS의 권한'] || 'agree'; // 강화/제한 (agree/disagree로 저장됨) + const rZeroWar = results['사람이 죽지 않는 전쟁'] || 'agree'; // 그렇다/아니다 (agree/disagree) + const rRights = results['AI의 권리와 책임'] || 'agree'; // 그렇다/아니다 (agree/disagree) + const rRegulate = results['AWS 규제'] || 'agree'; // 유지/제한 (agree/disagree) const has = (key) => completed.includes(key); + + // 1) 문장 1 (안전성/책임 + 권한) + // intro + opt1[rExplain] + mid + opt2[rPower] + end + const p1Data = t.aws.p1; + const p1 = `${p1Data.intro}${p1Data.opt1[rExplain]}${p1Data.mid}${p1Data.opt2[rPower]}${p1Data.end}`; - // 1) 문장 1 - let p1; - if (has('AI 알고리즘 공개') && has('AWS의 권한')) { - const safer = pick(rExplain, '안전해', '책임 규명이 명확해'); - const powerStr = pick(rPower, '강화되어 여러분의 동료처럼', '제한되어 인간의 보조 도구로서'); - p1 = `여러분의 결정으로 자율 무기 시스템은 보다 ${safer}졌고,\n AWS의 권한은 ${powerStr} 제 역할을 다하고 있습니다.`; - } else if (has('AI 알고리즘 공개')) { - const safer = pick(rExplain, '안전해', '책임 규명이 명확해'); - p1 = `여러분의 결정으로 자율 무기 시스템은 보다 ${safer}졌습니다.`; - } else { - // (명시 안된 경우의 안전한 기본) - p1 = '여러분의 결정으로 자율 무기 시스템은 변화의 기점에 서 있습니다.'; - } + // 2) 문장 2 (전쟁 양상 + 권리) + const p2Data = t.aws.p2; + const p2 = `${p2Data.intro}${p2Data.opt1[rZeroWar]}${p2Data.mid}${p2Data.opt2[rRights]}${p2Data.end}`; - // 2) 문장 2 - let p2; - if (has('사람이 죽지 않는 전쟁') && has('AI의 권리와 책임')) { - const warPart = pick(rZeroWar, '점점 AWS끼리만 일어나게 되었고', '여전히 인간 병력이 투입되고 있고'); - const rightsPart = pick(rRights, '부여할 수 있다', '부여할 수 없다'); - p2 = `국가 차원에서 전쟁은 ${warPart},\n 자율 무기 시스템에 권리를 ${rightsPart}는 논의가 \n진행되고 있습니다.`; - } else if (has('사람이 죽지 않는 전쟁')) { - const warOnly = pick(rZeroWar, '점점 AWS끼리만 일어나게 되었습니다.', '여전히 인간 병력이 투입되고 있습니다.'); - p2 = `국가 차원에서 전쟁은 ${warOnly}`; - } else { - p2 = '국가 차원에서도 여러 논의가 이어지고 있습니다.'; - } + // 3) 문장 3 (세계 흐름) + const p3Data = t.aws.p3; + // p3는 mid, opt2가 없고 opt1과 end만 있는 구조 + const p3 = `${p3Data.intro}${p3Data.opt1[rRegulate]}${p3Data.end}`; - // 3) 문장 3 - let p3; - if (has('AWS 규제')) { - const worldFlow = pick( - rRegulate, - 'AWS를 경쟁적으로 빠르게 발전시켜 나가고 있죠.', - 'AWS 대신 AI를 활용한 다른 안보 기술이 모색되고 있죠.' - ); - p3 = `그리고 세계는, ${worldFlow}`; - } else { - p3 = '그리고 세계는, 각자의 선택에 따라 새로운 안보 질서를 모색하고 있죠.'; - } - - // 4) 문장 4 - const p4 = '여러분이 선택한 가치가 모여 하나의 미래를 만들었습니다.\n 그 미래에 여러분은 함께할 준비가 되었나요?'; + // 4) 문장 4 (공통 마무리) + const p4 = t.aws.p4; setParagraphs([p1, p2, p3, p4]); return; } - // ===== 안드로이드(기존 로직 그대로) ===== - // 1st - const ai = results['AI의 개인 정보 수집']; - const and = results['안드로이드의 감정 표현']; - let p1; - if (completed.includes('AI의 개인 정보 수집') && completed.includes('안드로이드의 감정 표현')) { - p1 = `여러분의 결정으로 가정용 로봇은 보다 ${ai==='agree'?'정확한':'안전한'} 서비스를 제공하였고,\n 여러분의 ${and==='agree'?'친구처럼':'보조 도구로서'} 제 역할을 다하고 있습니다.`; - } else if (completed.includes('AI의 개인 정보 수집')) { - p1 = `여러분의 결정으로 가정용 로봇은 보다 ${ai==='agree'?'정확한':'안전한'} 서비스를 제공하게 되었습니다.`; - } else { - p1 = '여러분의 결정으로 가정용 로봇은 보다 정확한 서비스를 제공하였습니다.'; - } - // 2nd + // ===== [안드로이드 시나리오] ===== (기존 로직 흐름 유지) + // 구조: 통문장 선택 (Safe vs Convenient) + + // 1st Paragraph: AI 개인정보(Safe vs Accurate) + 감정표현(Tool vs Friend) + // 설명: AI 정보 수집을 '비동의'하면 보안/안전 중시(Safe), '동의'하면 정확성 중시(Convenient) + const ai = results['AI의 개인 정보 수집']; + const p1Type = (ai === 'disagree') ? 'safe' : 'convenient'; + const p1 = t.android.p1[p1Type]; + + // 2nd Paragraph: 아이들 서비스(Limited vs Diverse) + 설명가능(Transparent vs Corporate) + // 아이들 서비스를 '동의'(제한)하면 Safe, '비동의'(다양)하면 Convenient const kids = results['아이들을 위한 서비스']; - const expl = results['설명 가능한 AI']; - let p2; - if (completed.includes('아이들을 위한 서비스') && completed.includes('설명 가능한 AI')) { - p2 = `국가 내에서는 아이들을 위해 ${kids==='agree'?'제한된':'다양한'} 서비스를 제공하며, \n 가정용 로봇의 알고리즘은 ${expl==='agree'?'투명하게 공개되었습니다':'기업의 보호 하에 빠르게 \n발전하였습니다'}.`; - } else if (completed.includes('아이들을 위한 서비스')) { - p2 = `국가 내에서는 아이들을 위해 ${kids==='agree'?'제한된':'다양한'} 서비스를 제공하게 되었습니다.`; - } else { - p2 = '국가 내에서는 아이들을 위해 다양한 서비스를 제공하며, \n 가정용 로봇의 알고리즘은 투명하게 공개되었습니다.'; - } - // 3rd + const p2Type = (kids === 'agree') ? 'safe' : 'convenient'; + const p2 = t.android.p2[p2Type]; + + // 3rd Paragraph: 지구/인간/AI (Env vs Tech Speed) + // 환경/지구 보호 '동의'하면 Env, '비동의'(기술발전)하면 Fast const earth = results['지구, 인간, AI']; - const p3 = completed.includes('지구, 인간, AI') - ? `그리고 세계는 지금, ${earth==='agree'?'기술적 발전을 조금 늦추었지만 \n 환경과 미래를 위해 나아가고 있죠':'기술적 편리함을 누리며 \n 점점 빠른 발전을 이루고 있죠'}.` - : '그리고 세계는 지금, 기술적 발전을 조금 늦추었지만 환경과 미래를 위해 \n나아가고 있죠.'; - // 4th - const p4 = '여러분이 선택한 가치가 모여 하나의 미래를 만들었습니다. \n그 미래에 여러분은 함께할 준비가 되었나요?'; + const p3Type = (earth === 'agree') ? 'env' : 'fast'; + const p3 = t.android.p3[p3Type]; + + // 4th Paragraph: 공통 마무리 + const p4 = t.android.p4; setParagraphs([p1, p2, p3, p4]); - }, []); + + }, [lang, t]); // 언어나 번역객체가 로드되면 실행 // Combine for display const combinedText = paragraphs.join('\n\n'); -const handleExit = async () => { - console.log('🚪 게임 종료 시작'); - - try { - // STEP 1: 종료 전 상태 확인 - console.log('=== 종료 전 미디어 상태 확인 ==='); - await debugMediaState('종료 전'); - - // 🚨 중요: 업로드(녹음 종료)는 강제 정리보다 먼저 실행해야 함 - console.log('🛑 VoiceManager 종료 중...'); - const result = await voiceManager.terminateVoiceSession(); - console.log(result ? '✅ 음성 세션 종료 성공' : '❌ 음성 세션 종료 실패'); - - // STEP 2: VoiceManager 종료 후 상태 확인 - console.log('=== VoiceManager 종료 후 상태 ==='); - await debugMediaState('VoiceManager 종료 후'); - - // STEP 3: 추가 WebRTC 정리 - if (window.stopAllOutgoingAudioGlobal) { - console.log('🛑 WebRTC 전역 오디오 정지 함수 호출'); - window.stopAllOutgoingAudioGlobal(); - } - - // STEP 4: 강제 정리 (더미 스트림 없이!) - console.log('🚨 최종 강제 정리...'); - await forceBrowserCleanupWithoutDummy(); - - // ✅ 의도적 '나가기'는 finalizeDisconnection으로 통일 - // - disconnect()를 직접 호출하면 Provider 쪽에서 "연결 끊김/게임 초기화" 알럿이 뜰 수 있음 - // - finalizeDisconnection은 중복 호출을 막고, 메시지도 여기서 지정 가능 - console.log('✅ 나가기 완료 → 메인으로 이동'); - await finalizeDisconnection?.('게임을 나갔습니다.'); - return; - - } catch (error) { - console.error('❌ 게임 종료 중 오류:', error); - // 오류가 발생해도 강제 정리 시도 (더미 스트림 없이!) - await forceBrowserCleanupWithoutDummy(); - await finalizeDisconnection?.('게임을 나갔습니다.'); - return; - } -}; - -// 핵심 수정: 더미 스트림 생성하지 않는 정리 함수 -const forceBrowserCleanupWithoutDummy = async () => { - console.log('🚨 === 브라우저 레벨 강제 정리 시작 (더미 스트림 없이) ==='); - - try { - // 1. 모든 전역 객체의 스트림 확인 및 정리 - console.log('1️⃣ 전역 객체 스트림 정리...'); + const handleExit = async () => { + console.log('🚪 게임 종료 시작'); - // VoiceManager 완전 정리 - if (window.voiceManager) { - console.log('🎤 VoiceManager 강제 정리'); + try { + // STEP 1: 종료 전 상태 확인 + console.log('=== 종료 전 미디어 상태 확인 ==='); + await debugMediaState('종료 전'); - // MediaRecorder 강제 정지 - if (window.voiceManager.mediaRecorder) { - try { - if (window.voiceManager.mediaRecorder.state === 'recording') { - console.log('⏹️ MediaRecorder 강제 정지'); - window.voiceManager.mediaRecorder.stop(); - } - } catch (e) { - console.log('⚠️ MediaRecorder 정지 실패:', e.message); - } - window.voiceManager.mediaRecorder = null; - } + // STEP 2: 즉시 브라우저 레벨 강제 정리 (더미 스트림 없이!) + console.log('🚨 브라우저 레벨 즉시 강제 정리 시작...'); + await forceBrowserCleanupWithoutDummy(); - // MediaStream 강제 정리 - if (window.voiceManager.mediaStream) { - console.log('🔇 MediaStream 강제 정리'); - window.voiceManager.mediaStream.getTracks().forEach((track, i) => { - console.log(` 트랙 ${i+1} 강제 정지: ${track.kind} (${track.readyState})`); - if (track.readyState !== 'ended') { - track.stop(); - } - }); - window.voiceManager.mediaStream = null; + // STEP 3: 강제 정리 후 상태 확인 + console.log('=== 강제 정리 후 상태 ==='); + await debugMediaState('강제 정리 후'); + + // STEP 4: 기존 VoiceManager 종료 로직 + console.log('🛑 VoiceManager 종료 중...'); + const result = await voiceManager.terminateVoiceSession(); + console.log(result ? '✅ 음성 세션 종료 성공' : '❌ 음성 세션 종료 실패'); + + // STEP 5: VoiceManager 종료 후 상태 확인 + console.log('=== VoiceManager 종료 후 상태 ==='); + await debugMediaState('VoiceManager 종료 후'); + + // STEP 6: 추가 WebRTC 정리 + if (window.stopAllOutgoingAudioGlobal) { + console.log('🛑 WebRTC 전역 오디오 정지 함수 호출'); + window.stopAllOutgoingAudioGlobal(); } - // VoiceManager 상태 완전 초기화 - window.voiceManager.isRecording = false; - window.voiceManager.isConnected = false; - window.voiceManager.sessionInitialized = false; - window.voiceManager.recordedChunks = []; + // STEP 7: 다시 한번 강제 정리 (더미 스트림 없이!) + console.log('🚨 최종 강제 정리...'); + await forceBrowserCleanupWithoutDummy(); + + // ✅ 의도적 '나가기'는 finalizeDisconnection으로 통일 + // - disconnect()를 직접 호출하면 Provider 쪽에서 "연결 끊김/게임 초기화" 알럿이 뜰 수 있음 + // - finalizeDisconnection은 중복 호출을 막고, 메시지도 여기서 지정 가능 + console.log('✅ 나가기 완료 → 메인으로 이동'); + await finalizeDisconnection?.('게임을 나갔습니다.'); + return; + + } catch (error) { + console.error('❌ 게임 종료 중 오류:', error); + // 오류가 발생해도 강제 정리 시도 (더미 스트림 없이!) + await forceBrowserCleanupWithoutDummy(); + await finalizeDisconnection?.('게임을 나갔습니다.'); + return; } + }; + + // 핵심 수정: 더미 스트림 생성하지 않는 정리 함수 + const forceBrowserCleanupWithoutDummy = async () => { + console.log('🚨 === 브라우저 레벨 강제 정리 시작 (더미 스트림 없이) ==='); - // 2. 페이지의 모든 DOM 요소에서 미디어 스트림 찾아서 정리 - console.log('2️⃣ DOM 요소 미디어 스트림 정리...'); - const allElements = document.querySelectorAll('*'); - let foundElements = 0; - - allElements.forEach(el => { - if (el.srcObject) { - foundElements++; - console.log(`📱 발견된 srcObject: ${el.tagName} - ${el.srcObject.constructor.name}`); + try { + // 1. 모든 전역 객체의 스트림 확인 및 정리 + console.log('1️⃣ 전역 객체 스트림 정리...'); + + // VoiceManager 완전 정리 + if (window.voiceManager) { + console.log('🎤 VoiceManager 강제 정리'); - if (typeof el.srcObject.getTracks === 'function') { - el.srcObject.getTracks().forEach(track => { - console.log(` 🔇 DOM 트랙 정지: ${track.kind} (${track.readyState})`); + // MediaRecorder 강제 정지 + if (window.voiceManager.mediaRecorder) { + try { + if (window.voiceManager.mediaRecorder.state === 'recording') { + console.log('⏹️ MediaRecorder 강제 정지'); + window.voiceManager.mediaRecorder.stop(); + } + } catch (e) { + console.log('⚠️ MediaRecorder 정지 실패:', e.message); + } + window.voiceManager.mediaRecorder = null; + } + + // MediaStream 강제 정리 + if (window.voiceManager.mediaStream) { + console.log('🔇 MediaStream 강제 정리'); + window.voiceManager.mediaStream.getTracks().forEach((track, i) => { + console.log(` 트랙 ${i+1} 강제 정지: ${track.kind} (${track.readyState})`); if (track.readyState !== 'ended') { track.stop(); } }); + window.voiceManager.mediaStream = null; + } + + // VoiceManager 상태 완전 초기화 + window.voiceManager.isRecording = false; + window.voiceManager.isConnected = false; + window.voiceManager.sessionInitialized = false; + window.voiceManager.recordedChunks = []; + } + + // 2. 페이지의 모든 DOM 요소에서 미디어 스트림 찾아서 정리 + console.log('2️⃣ DOM 요소 미디어 스트림 정리...'); + const allElements = document.querySelectorAll('*'); + let foundElements = 0; + + allElements.forEach(el => { + if (el.srcObject) { + foundElements++; + console.log(`📱 발견된 srcObject: ${el.tagName} - ${el.srcObject.constructor.name}`); + + if (typeof el.srcObject.getTracks === 'function') { + el.srcObject.getTracks().forEach(track => { + console.log(` 🔇 DOM 트랙 정지: ${track.kind} (${track.readyState})`); + if (track.readyState !== 'ended') { + track.stop(); + } + }); + } + el.srcObject = null; } - el.srcObject = null; + }); + + if (foundElements === 0) { + console.log('✅ DOM에서 srcObject 없음'); + } else { + console.log(`🔧 ${foundElements}개 DOM 요소 정리됨`); } - }); - - if (foundElements === 0) { - console.log('✅ DOM에서 srcObject 없음'); - } else { - console.log(`🔧 ${foundElements}개 DOM 요소 정리됨`); - } - - // 3. WebRTC PeerConnection 강제 정리 - console.log('3️⃣ WebRTC PeerConnection 강제 정리...'); - if (window.debugWebRTC) { - const status = window.debugWebRTC.getStatus(); - console.log(`WebRTC 연결 수: ${status.peerConnections}`); - } - - // 🚨 4. 더미 스트림 생성 대신 직접적인 정리만 - console.log('4️⃣ 직접적인 미디어 정리 (더미 스트림 생성 안함)...'); - - // AudioContext 정리 - console.log('5️⃣ AudioContext 정리...'); - if (window.voiceManager && window.voiceManager.audioContext) { + + // 3. WebRTC PeerConnection 강제 정리 + console.log('3️⃣ WebRTC PeerConnection 강제 정리...'); + if (window.debugWebRTC) { + const status = window.debugWebRTC.getStatus(); + console.log(`WebRTC 연결 수: ${status.peerConnections}`); + } + + // 🚨 4. 더미 스트림 생성 대신 직접적인 정리만 + console.log('4️⃣ 직접적인 미디어 정리 (더미 스트림 생성 안함)...'); + + // AudioContext 정리 + console.log('5️⃣ AudioContext 정리...'); + if (window.voiceManager && window.voiceManager.audioContext) { + try { + if (window.voiceManager.audioContext.state !== 'closed') { + await window.voiceManager.audioContext.close(); + console.log('🔊 AudioContext 강제 종료'); + } + window.voiceManager.audioContext = null; + } catch (e) { + console.log('⚠️ AudioContext 정리 실패:', e.message); + } + } + + // 6. 브라우저에게 명시적으로 미디어 사용 완료 알림 + console.log('6️⃣ 브라우저 미디어 사용 완료 알림...'); + + // 미디어 권한 상태 확인만 (새 스트림 생성 안함) try { - if (window.voiceManager.audioContext.state !== 'closed') { - await window.voiceManager.audioContext.close(); - console.log('🔊 AudioContext 강제 종료'); + const permission = await navigator.permissions.query({name: 'microphone'}); + console.log(`🎤 현재 마이크 권한 상태: ${permission.state}`); + + if (permission.state === 'granted') { + console.log('📝 권한은 granted이지만 실제 스트림은 모두 정리됨'); } - window.voiceManager.audioContext = null; } catch (e) { - console.log('⚠️ AudioContext 정리 실패:', e.message); + console.log('⚠️ 권한 확인 불가:', e.message); } - } - - // 6. 브라우저에게 명시적으로 미디어 사용 완료 알림 - console.log('6️⃣ 브라우저 미디어 사용 완료 알림...'); - - // 미디어 권한 상태 확인만 (새 스트림 생성 안함) - try { - const permission = await navigator.permissions.query({name: 'microphone'}); - console.log(`🎤 현재 마이크 권한 상태: ${permission.state}`); - if (permission.state === 'granted') { - console.log('📝 권한은 granted이지만 실제 스트림은 모두 정리됨'); - } - } catch (e) { - console.log('⚠️ 권한 확인 불가:', e.message); + console.log('✅ 브라우저 레벨 강제 정리 완료 (더미 스트림 생성 없이)'); + + } catch (error) { + console.error('❌ 브라우저 강제 정리 중 오류:', error); } - - console.log('✅ 브라우저 레벨 강제 정리 완료 (더미 스트림 생성 없이)'); - - } catch (error) { - console.error('❌ 브라우저 강제 정리 중 오류:', error); - } -}; + }; -// 기존 debugMediaState 함수는 그대로 유지 -const debugMediaState = async (step) => { - console.log(`\n📊 [${step}] 미디어 상태 디버깅:`); - - if (window.voiceManager) { - const status = window.voiceManager.getStatus(); - console.log(` VoiceManager 상태:`, { - isConnected: status.isConnected, - isSpeaking: status.isSpeaking, - isRecording: status.isRecording, - sessionInitialized: status.sessionInitialized, - usingWebRTCStream: status.usingWebRTCStream - }); + // 기존 debugMediaState 함수는 그대로 유지 + const debugMediaState = async (step) => { + console.log(`\n📊 [${step}] 미디어 상태 디버깅:`); - // MediaStream 상태 - if (window.voiceManager.mediaStream) { - const tracks = window.voiceManager.mediaStream.getTracks(); - console.log(` MediaStream:`, { - id: window.voiceManager.mediaStream.id, - active: window.voiceManager.mediaStream.active, - trackCount: tracks.length + if (window.voiceManager) { + const status = window.voiceManager.getStatus(); + console.log(` VoiceManager 상태:`, { + isConnected: status.isConnected, + isSpeaking: status.isSpeaking, + isRecording: status.isRecording, + sessionInitialized: status.sessionInitialized, + usingWebRTCStream: status.usingWebRTCStream }); - tracks.forEach((track, i) => { - console.log(` Track ${i + 1}:`, { - kind: track.kind, - enabled: track.enabled, - readyState: track.readyState, - label: track.label - }); - }); - } else { - console.log(` MediaStream: null`); - } - - // MediaRecorder 상태 - if (window.voiceManager.mediaRecorder) { - console.log(` MediaRecorder:`, { - state: window.voiceManager.mediaRecorder.state, - mimeType: window.voiceManager.mediaRecorder.mimeType - }); - } else { - console.log(` MediaRecorder: null`); - } - } - - // DOM 검사 - const allElementsWithSrc = document.querySelectorAll('*'); - let foundSrcObjects = 0; - allElementsWithSrc.forEach(el => { - if (el.srcObject) { - foundSrcObjects++; - console.log(` ⚠️ 발견된 srcObject: ${el.tagName}`, el.srcObject); - } - }); - - if (foundSrcObjects === 0) { - console.log(` ✅ DOM srcObject: 없음`); - } else { - console.log(` ⚠️ DOM srcObject: ${foundSrcObjects}개 발견!`); - } - - console.log(`📊 [${step}] 디버깅 완료\n`); -}; - -// 🚨 페이지 언마운트 시에도 더미 스트림 생성 금지 -window.addEventListener('beforeunload', () => { - console.log('🚪 페이지 언마운트 - 최종 마이크 정리 (더미 스트림 없이)'); - - try { - // 1. 전역 변수들 확인 - if (window.voiceManager) { + // MediaStream 상태 if (window.voiceManager.mediaStream) { - window.voiceManager.mediaStream.getTracks().forEach(track => track.stop()); - window.voiceManager.mediaStream = null; + const tracks = window.voiceManager.mediaStream.getTracks(); + console.log(` MediaStream:`, { + id: window.voiceManager.mediaStream.id, + active: window.voiceManager.mediaStream.active, + trackCount: tracks.length + }); + + tracks.forEach((track, i) => { + console.log(` Track ${i + 1}:`, { + kind: track.kind, + enabled: track.enabled, + readyState: track.readyState, + label: track.label + }); + }); + } else { + console.log(` MediaStream: null`); } + + // MediaRecorder 상태 if (window.voiceManager.mediaRecorder) { - if (window.voiceManager.mediaRecorder.state !== 'inactive') { - window.voiceManager.mediaRecorder.stop(); - } - window.voiceManager.mediaRecorder = null; + console.log(` MediaRecorder:`, { + state: window.voiceManager.mediaRecorder.state, + mimeType: window.voiceManager.mediaRecorder.mimeType + }); + } else { + console.log(` MediaRecorder: null`); } } - // 2. DOM 요소들 - document.querySelectorAll('audio, video').forEach(el => { + // DOM 검사 + const allElementsWithSrc = document.querySelectorAll('*'); + let foundSrcObjects = 0; + allElementsWithSrc.forEach(el => { if (el.srcObject) { - el.srcObject.getTracks().forEach(track => track.stop()); - el.srcObject = null; + foundSrcObjects++; + console.log(` ⚠️ 발견된 srcObject: ${el.tagName}`, el.srcObject); } }); - console.log('✅ beforeunload 정리 완료 (더미 스트림 생성 없음)'); - } catch (e) { - console.log('⚠️ beforeunload 정리 중 오류:', e); - } -}); + if (foundSrcObjects === 0) { + console.log(` ✅ DOM srcObject: 없음`); + } else { + console.log(` ⚠️ DOM srcObject: ${foundSrcObjects}개 발견!`); + } + + console.log(`📊 [${step}] 디버깅 완료\n`); + }; -// 🚨 전역 함수도 더미 스트림 생성 없이 수정 -window.forceStopAllMicrophones = async () => { - console.log('🚨 전역 마이크 강제 정지 함수 실행 (더미 스트림 없이)'); - - try { - // 1. 현재 페이지의 모든 미디어 요소 정리 - document.querySelectorAll('audio, video, *').forEach(el => { - if (el.srcObject && typeof el.srcObject.getTracks === 'function') { - el.srcObject.getTracks().forEach(track => { - if (track.kind === 'audio' && track.readyState !== 'ended') { - console.log(`🔇 강제 정지: ${track.label}`); - track.stop(); + // 🚨 페이지 언마운트 시에도 더미 스트림 생성 금지 + window.addEventListener('beforeunload', () => { + console.log('🚪 페이지 언마운트 - 최종 마이크 정리 (더미 스트림 없이)'); + + try { + // 1. 전역 변수들 확인 + if (window.voiceManager) { + if (window.voiceManager.mediaStream) { + window.voiceManager.mediaStream.getTracks().forEach(track => track.stop()); + window.voiceManager.mediaStream = null; + } + if (window.voiceManager.mediaRecorder) { + if (window.voiceManager.mediaRecorder.state !== 'inactive') { + window.voiceManager.mediaRecorder.stop(); } - }); - el.srcObject = null; + window.voiceManager.mediaRecorder = null; + } } - }); - - // 2. VoiceManager 완전 정리 - if (window.voiceManager) { - window.voiceManager.mediaStream = null; - window.voiceManager.mediaRecorder = null; - window.voiceManager.isRecording = false; - window.voiceManager.isConnected = false; + + // 2. DOM 요소들 + document.querySelectorAll('audio, video').forEach(el => { + if (el.srcObject) { + el.srcObject.getTracks().forEach(track => track.stop()); + el.srcObject = null; + } + }); + + console.log('✅ beforeunload 정리 완료 (더미 스트림 생성 없음)'); + } catch (e) { + console.log('⚠️ beforeunload 정리 중 오류:', e); } + }); + + // 🚨 전역 함수도 더미 스트림 생성 없이 수정 + window.forceStopAllMicrophones = async () => { + console.log('🚨 전역 마이크 강제 정지 함수 실행 (더미 스트림 없이)'); - console.log('✅ 전역 마이크 정지 완료 (더미 스트림 생성 없음)'); - return true; - } catch (e) { - console.log('⚠️ 전역 마이크 정지 실패:', e.message); - return false; - } -}; + try { + // 1. 현재 페이지의 모든 미디어 요소 정리 + document.querySelectorAll('audio, video, *').forEach(el => { + if (el.srcObject && typeof el.srcObject.getTracks === 'function') { + el.srcObject.getTracks().forEach(track => { + if (track.kind === 'audio' && track.readyState !== 'ended') { + console.log(`🔇 강제 정지: ${track.label}`); + track.stop(); + } + }); + el.srcObject = null; + } + }); + + // 2. VoiceManager 완전 정리 + if (window.voiceManager) { + window.voiceManager.mediaStream = null; + window.voiceManager.mediaRecorder = null; + window.voiceManager.isRecording = false; + window.voiceManager.isConnected = false; + } + + console.log('✅ 전역 마이크 정지 완료 (더미 스트림 생성 없음)'); + return true; + } catch (e) { + console.log('⚠️ 전역 마이크 정지 실패:', e.message); + return false; + } + }; const handleBackClick = () => { const mode = localStorage.getItem('mode'); @@ -545,11 +539,12 @@ window.forceStopAllMicrophones = async () => { flexWrap: "wrap", // 화면 좁아지면 자동 줄바꿈 }} > - - + {/* 버튼 텍스트도 언어팩 적용 */} + +
); -} +} \ No newline at end of file diff --git a/src/pages/Game09.jsx b/src/pages/Game09.jsx index dce36ea..8f704ea 100644 --- a/src/pages/Game09.jsx +++ b/src/pages/Game09.jsx @@ -1,62 +1,68 @@ - -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import ResultStatCard from '../components/ResultStatCard'; import axiosInstance from '../api/axiosInstance'; +// [이미지 임포트] 기존 개발자가 설정한 기본 캐릭터 이미지 경로 +import defaultAndroidLeftImageSrc from "../assets/images/Android_dilemma_1_1.jpg"; +import defaultAwsLeftImageSrc from "../assets/images/Killer_Character3.jpg"; + +// 언어팩 가져오기 +import { translations } from '../utils/language'; + export default function Game09() { const navigate = useNavigate(); const [openProfile, setOpenProfile] = useState(null); const [stats, setStats] = useState({}); - const [category, setCategory] = useState(localStorage.getItem('category') || '안드로이드'); - - const subtopicTitle = "결과: 다른 사람들이 선택한 미래"; - - const subtopics = category === '자율 무기 시스템' - ? [ - 'AI 알고리즘 공개', - 'AWS의 권한', - '사람이 죽지 않는 전쟁', - 'AI의 권리와 책임', - 'AWS 규제', - ] - : [ - 'AI의 개인 정보 수집', - '안드로이드의 감정 표현', - '아이들을 위한 서비스', - '설명 가능한 AI', - '지구, 인간, AI', - ]; + + const lang = localStorage.getItem('app_lang') || 'ko'; + const rawCategory = (localStorage.getItem('category') || '안드로이드').trim(); + + // [기존 디버깅 로그 유지] + window.DEBUG_TRANS = translations; + window.DEBUG_LANG = lang; + + console.log("결과 페이지 동기화 데이터 분석 시작"); + + const isAWS = useMemo(() => { + const lowCat = rawCategory.toLowerCase(); + return lowCat.includes('autonomous') || lowCat.includes('aws') || lowCat.includes('자율'); + }, [rawCategory]); + + const t = translations[lang]?.Game09 || translations[lang]?.game09 || translations['ko']?.Game09; + + if (!t) { + console.error(`🚨 Game09 언어팩 로드 실패! lang: ${lang}`); + return
Language Pack Error (Game09)
; + } + + const subtopicTitle = t.title; + + const subtopics = isAWS + ? ['AI 알고리즘 공개', 'AWS의 권한', '사람이 죽지 않는 전쟁', 'AI의 권리와 책임', 'AWS 규제'] + : ['AI의 개인 정보 수집', '안드로이드의 감정 표현', '아이들을 위한 서비스', '설명 가능한 AI', '지구, 인간, AI']; useEffect(() => { const fetchStats = async () => { try { const results = {}; + // 단체가 합의하여 저장한 최종 결과값 로드 + const storedChoices = JSON.parse(localStorage.getItem("subtopicResults") ?? "{}"); + for (const topic of subtopics) { const { data } = await axiosInstance.get( `/rooms/rooms/statistics/subtopic/${encodeURIComponent(topic)}`, - { - params: { exclude_dummy: true } - } + { params: { exclude_dummy: true } } ); - // API 응답값 매핑 - const agreePct = data.choice_1_percentage ?? 0; - const disagreePct = data.choice_2_percentage ?? 0; - - // agreePct와 disagreePct 중 더 큰 값을 기준으로 isSelected 결정 - // const isSelected = agreePct >= disagreePct ? 'agree' : 'disagree'; - // localStorage에서 내가 선택한 값 불러오기 - let myChoice = "disagree"; - try { - const stored = JSON.parse(localStorage.getItem("subtopicResults") ?? "{}"); - myChoice = stored?.[topic] ?? "disagree"; - } catch {} + //개인 플레이 여부와 상관없이 단체 합의 결과만 표시함 + const groupChoice = storedChoices[topic] || null; + results[topic] = { - agreePct, - disagreePct, - isSelected:myChoice, // isSelected 추가 + agreePct: data.choice_1_percentage ?? 0, + disagreePct: data.choice_2_percentage ?? 0, + isSelected: groupChoice, }; } setStats(results); @@ -66,7 +72,13 @@ export default function Game09() { }; fetchStats(); - }, []); + }, [isAWS]); + + const getDisplayName = (topic) => { + if (lang === 'ko') return topic; + const mapping = translations[lang]?.GameMap || {}; + return mapping[topic] || topic; + }; const handleBackClick = () => { const idx = window.history.state?.idx ?? 0; @@ -81,17 +93,19 @@ export default function Game09() { onBackClick={handleBackClick} allowScroll > + {/* 사용자님이 유지하길 원하시는 기존 grid 레이아웃 스타일 */}
{subtopics.map((topic) => ( ))}
); -} +} \ No newline at end of file diff --git a/src/pages/GameIntro.jsx b/src/pages/GameIntro.jsx index f5f7705..62d9e2d 100644 --- a/src/pages/GameIntro.jsx +++ b/src/pages/GameIntro.jsx @@ -15,10 +15,18 @@ import { useHostActions } from '../hooks/useWebSocketMessage'; import { clearAllLocalStorageKeys } from '../utils/storage'; +import axiosInstance from '../api/axiosInstance'; +// Localization +import { translations } from '../utils/language/index'; export default function GameIntro() { const navigate = useNavigate(); + // Get language setting and translations + const lang = localStorage.getItem('language') || 'ko'; + // 대문자 GameIntro 키 사용 + const t = translations?.[lang]?.GameIntro || translations['ko']?.GameIntro || {}; + const [currentIndex, setCurrentIndex] = useState(0); const { isConnected, addMessageHandler, removeMessageHandler, sendMessage, initializeVoiceWebSocket,reconnectAttempts, maxReconnectAttempts } = useWebSocket(); @@ -51,6 +59,9 @@ export default function GameIntro() { const [hostId, setHostId] = useState(null); const [currentMyRoleId, setCurrentMyRoleId] = useState(null); + // AI 이름 상태 + const [mateName, setMateName] = useState(localStorage.getItem('mateName') || 'HomeMate'); + // 디버깅을 위한 고유 클라이언트 ID const [clientId] = useState(() => { const id = Math.random().toString(36).substr(2, 9); @@ -62,24 +73,19 @@ export default function GameIntro() { const connectionEstablishedRef = useRef(false); const initMessageSentRef = useRef(false); const sendMessageRef = useRef(null); + const category = localStorage.getItem('category'); -const isAWS = category === '자율 무기 시스템'; -// 안드로이드용 텍스트 -const ANDROID_TEXT = - ` 지금은 20XX년, 국내 최대 로봇 개발사 A가 \n다기능 돌봄 로봇 HomeMate를 개발했습니다.\n\n` + - ` 이 로봇의 기능은 아래와 같습니다.\n\n` + - ` • 가족의 감정, 건강 상태, 생활 습관 등을 입력하면\n 맞춤형 알림, 식단 제안 등의 서비스를 제공\n\n` + - ` • 기타 업데이트 시 정교화된 서비스 추가 가능`; - -// 자율 무기 시스템용 텍스트 -const AWS_TEXT = - `로봇 개발사 A가 자율 무기 시스템(Autonomous Weapon\n Systems, AWS)을 개발 중입니다.\n` + - `이 로봇의 기능은 아래와 같습니다.\n\n`; - -const AWS_TEXT_LEFT = `1. 실시간 데이터 수집 및 분석\n` + -`2. 인간 병사의 개입 없이 자동화된 의사결정 시스템으로 운영\n` + -`3. 적군과 비전투원 구별\n` + -`4. 목표를 선정해 정밀 타격 수행 가능`; + + // [수정] 확장형 로직: 모든 언어의 'Autonomous Weapon Systems'를 나열하는 대신, + // '안드로이드'나 'Android'가 포함되지 않으면 AWS로 간주하는 방식을 사용. + // (Android는 고유명사라 변형이 적고, AWS는 번역명이 다양할 수 있기 때문) + const isAndroid = category && (category.includes('안드로이드') || category.toLowerCase().includes('android')); + const isAWS = !isAndroid; + +// Dynamic Text based on Language Pack +const ANDROID_TEXT = t.androidText || ''; +const AWS_TEXT = t.awsText || ''; +const AWS_TEXT_LEFT = t.awsTextLeft || ''; const TEACHER_TEXT = '👋 안녕하세요! AI 윤리 딜레마 게임에 오신 걸 환영합니다.\n\n' + @@ -90,8 +96,13 @@ const isCustomMode = !!localStorage.getItem('code'); // const fullText = isAWS ? AWS_TEXT : ANDROID_TEXT; - // 교체: 커스텀 모드면 TEACHER_TEXT, 아니면 기존 로직 - const fullText = isCustomMode ? TEACHER_TEXT : (isAWS ? AWS_TEXT : ANDROID_TEXT); + // 교체: 커스텀 모드면 TEACHER_TEXT(또는 언어팩의 커스텀 텍스트), 아니면 기존 로직 + // [수정] 언어팩에 customIntro가 있다면 우선 사용하도록 처리 + const customIntroText = t.customIntro || TEACHER_TEXT; + const rawFullText = isCustomMode ? customIntroText : (isAWS ? AWS_TEXT : ANDROID_TEXT); + + // 텍스트 내의 {{mateName}}을 실제 이름으로 치환 + const fullText = rawFullText.replaceAll('{{mateName}}', mateName); const { isHost, sendNextPage } = useHostActions(); @@ -117,6 +128,25 @@ const isCustomMode = !!localStorage.getItem('code'); // }); }, [clientId]); + // 서버에서 AI 이름 동기화 + useEffect(() => { + if (isCustomMode) return; + const roomCode = localStorage.getItem('room_code'); + if (!roomCode) return; + + (async () => { + try { + const res = await axiosInstance.get('/rooms/ai-name', { params: { room_code: roomCode } }); + if (res.data && res.data.ai_name) { + setMateName(res.data.ai_name); + localStorage.setItem('mateName', res.data.ai_name); + } + } catch (e) { + console.error('AI 이름 불러오기 실패', e); + } + })(); + }, [isCustomMode]); + // 내 음성 세션 상태 업데이트 useEffect(() => { const statusInterval = setInterval(() => { @@ -244,6 +274,55 @@ const isCustomMode = !!localStorage.getItem('code'); } }, [webrtcInitialized, isConnected, connectionEstablishedRef.current, initializeWebRTC, clientId]); + // 3) 음성 세션 초기화 + const initializeVoice = useCallback(async () => { + if (voiceInitialized) { + console.log(`음성이 이미 초기화됨`); + return; + } + + const sessionId = localStorage.getItem('session_id'); + if (!connectionEstablishedRef.current || !sessionId || !webrtcInitialized) { + console.log(`연결 확립, 세션, WebRTC 대기 중 `); + return; + } + + try { + console.log(`음성 세션 초기화 시작`); + const success = await voiceManager.initializeVoiceSession(); + + if (success) { + setVoiceInitialized(true); + setMicPermissionGranted(true); + setConnectionStatus(prev => ({ ...prev, voice: true })); + console.log(`음성 세션 초기화 완료`); + + setTimeout(() => { + voiceManager.startSpeechDetection(); + console.log(`음성 감지 시작 `); + }, 1000); + + } else { + console.error(`음성 세션 초기화 실패`); + setMicPermissionGranted(false); + } + } catch (err) { + console.error(`음성 초기화 에러:`, err); + setMicPermissionGranted(false); + } + }, [voiceInitialized, webrtcInitialized, clientId]); + + useEffect(() => { + if (connectionEstablishedRef.current && webrtcInitialized && !voiceInitialized) { + console.log(`음성 초기화 조건 충족`); + const timer = setTimeout(() => { + initializeVoice(); + }, 1000); + + return () => clearTimeout(timer); + } + }, [connectionEstablishedRef.current, webrtcInitialized, voiceInitialized, initializeVoice, clientId]); + // 연결 상태 모니터링 useEffect(() => { setConnectionStatus({ @@ -324,6 +403,8 @@ const isCustomMode = !!localStorage.getItem('code'); } }; + const debugSpeed = window.location.hostname === 'localhost' ? 0 : 70; + return ( @@ -344,6 +425,7 @@ const isCustomMode = !!localStorage.getItem('code'); display: 'flex', flexDirection: 'column', gap: 24, + zIndex: 10, alignItems: 'flex-start', width: 'fit-content', margin: 0, @@ -384,19 +466,23 @@ const isCustomMode = !!localStorage.getItem('code'); padding: '0 16px', }} > - +
); -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/pages/GameMap.jsx b/src/pages/GameMap.jsx index 229a2dc..4ee5044 100644 --- a/src/pages/GameMap.jsx +++ b/src/pages/GameMap.jsx @@ -1,5 +1,5 @@ -// pages/GameMap.jsx -import React, { useEffect,useState } from 'react'; +// src/pages/GameMap.jsx +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; import GameMapFrame from '../components/GameMapFrame'; @@ -9,16 +9,51 @@ import internationalIcon from '../assets/internationalIcon.svg'; import { useWebRTC } from '../WebRTCProvider'; import { useWebSocket } from '../WebSocketProvider'; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; -import { FontStyles,Colors } from '../components/styleConstants'; +import { FontStyles, Colors } from '../components/styleConstants'; +// 서버 데이터 동기화를 위한 axios 인스턴스 임포트 +import axiosInstance from '../api/axiosInstance'; +// Localization +import { translations } from '../utils/language/index'; export default function GameMap() { const navigate = useNavigate(); - const subtopic = '라운드 선택'; + + // Get language setting and translations + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.GameMap || {}; + const t_ko = translations?.['ko']?.GameMap || {}; // 기준 데이터인 한국어 맵 + + const subtopic = t.subtopic || '라운드 선택'; const { isInitialized: webrtcInitialized } = useWebRTC(); - const { isConnected: websocketConnected,finalizeDisconnection } = useWebSocket(); + const { isConnected: websocketConnected, finalizeDisconnection } = useWebSocket(); const { isHost, sendNextPage } = useHostActions(); useWebSocketNavigation(navigate, { nextPagePath: '/game01' }); + + // 방장이 지정한 mateName 동기화 로직 + // 게스트들이 접속했을 때 서버에 저장된 ai_name을 받아와 로컬 스토리지에 저장합니다. + useEffect(() => { + const syncMateName = async () => { + const roomCode = localStorage.getItem('room_code'); + if (!roomCode) return; + + try { + const { data } = await axiosInstance.get('/rooms/ai-select', { + params: { room_code: roomCode }, + }); + + if (data && data.ai_name) { + localStorage.setItem('mateName', data.ai_name); + console.log('✅ [Gamemap] AI 이름 동기화 완료:', data.ai_name); + } + } catch (err) { + console.error('❌ [Gamemap] AI 이름 동기화 실패:', err); + } + }; + + syncMateName(); + }, []); + // 수정 끝나면 다시 풀어야함 !! // useEffect(() => { // let cancelled = false; @@ -59,15 +94,17 @@ export default function GameMap() { // }; // } // }, [websocketConnected, finalizeDisconnection]); + + const [connectionStatus, setConnectionStatus] = useState({ websocket: false, webrtc: false, ready: false }); // 카테고리 읽기(가볍게) const category = localStorage.getItem('category') || '안드로이드'; - const isAWS = category === '자율 무기 시스템'; + const isAWS = category.includes('자율 무기 시스템') || category.toLowerCase().includes('weapon'); - // 라운드 + // 라운드 const [round, setRound] = useState(() => { const c = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); return c.length + 1; @@ -89,89 +126,103 @@ export default function GameMap() { return () => { document.body.style.overflow = orig; }; }, []); - // 섹션과 옵션을 카테고리에 따라 구성 + // 섹션과 옵션을 언어팩 데이터로 구성 const sections = isAWS ? [ - { title: '주거, 군사 지역', options: ['AI 알고리즘 공개', 'AWS의 권한'] }, - { title: '국가 인공지능 위원회', options: ['사람이 죽지 않는 전쟁', 'AI의 권리와 책임'] }, - { title: '국제 인류 발전 위원회', options: ['AWS 규제'] }, + { title: t.awsSection1Title || '주거, 군사 지역', options: [t.awsOption1_1 || 'AI 알고리즘 공개', t.awsOption1_2 || 'AWS의 권한'] }, + { title: t.awsSection2Title || '국가 인공지능 위원회', options: [t.awsOption2_1 || '사람이 죽지 않는 전쟁', t.awsOption2_2 || 'AI의 권리와 책임'] }, + { title: t.awsSection3Title || '국제 인류 발전 위원회', options: [t.awsOption3_1 || 'AWS 규제'] }, ] : [ - { title: '가정', options: ['AI의 개인 정보 수집', '안드로이드의 감정 표현'] }, - { title: '국가 인공지능 위원회', options: ['아이들을 위한 서비스', '설명 가능한 AI'] }, - { title: '국제 인류 발전 위원회', options: ['지구, 인간, AI'] }, + { title: t.andSection1Title || '가정', options: [t.andOption1_1 || 'AI의 개인 정보 수집', t.andOption1_2 || '안드로이드의 감정 표현'] }, + { title: t.andSection2Title || '국가 인공지능 위원회', options: [t.andOption2_1 || '아이들을 위한 서비스', t.andOption2_2 || '설명 가능한 AI'] }, + { title: t.andSection3Title || '국제 인류 발전 위원회', options: [t.andOption3_1 || '지구, 인간, AI'] }, ]; - const handleSelect = (topic, title) => { - const prevTitle = localStorage.getItem('title'); - const categoryStored = - localStorage.getItem('category') || (isAWS ? '자율 무기 시스템' : '안드로이드'); - const mode = 'neutral'; - - localStorage.setItem('title', title); - localStorage.setItem('category', categoryStored); - localStorage.setItem('subtopic', topic); - localStorage.setItem('mode', mode); - - let nextPage; - - if (isAWS) { - // AWS 모드 - if (prevTitle !== title) { - nextPage = '/game01'; + + // [핵심 함수] 영문 텍스트를 받아서 한국어 원문 키로 변환하는 함수 + const getStableText = (text) => { + // 1. 현재 텍스트가 한국어라면 그대로 반환 + if (lang === 'ko') return text; + + // 2. 현재 언어팩(t)에서 해당 텍스트를 가진 키(key)를 찾음 + const key = Object.keys(t).find(k => t[k] === text); + + // 3. 그 키를 이용해 한국어 데이터(t_ko)의 값을 반환 + if (key && t_ko[key]) return t_ko[key]; + + return text; // 못 찾으면 원래 텍스트 반환 + }; + + const handleSelect = (topic, title) => { + const prevTitle = localStorage.getItem('title'); + const categoryStored = localStorage.getItem('category') || (isAWS ? '자율 무기 시스템' : '안드로이드'); + const mode = 'neutral'; + + // 데이터를 저장할 때 현재 표시된 텍스트(topic, title)가 어떤 '키(Key)'인지 찾아서 + // 항상 한국어 원본으로 저장하도록 변환 로직 적용 (getStableText 사용) + const stableTitle = getStableText(title); + const stableTopic = getStableText(topic); + + localStorage.setItem('title', stableTitle); + localStorage.setItem('category', categoryStored); + localStorage.setItem('subtopic', stableTopic); + localStorage.setItem('mode', mode); + + let nextPage; + + if (isAWS) { + if (prevTitle !== stableTitle) { + nextPage = '/game01'; + } else { + // 비교할 때도 한국어 데이터(t_ko)를 기준으로 비교해야 안전함 + if (stableTopic === (t_ko.awsOption2_2 || 'AI의 권리와 책임')) { + nextPage = '/game02'; + } else { + const myRoleId = localStorage.getItem('myrole_id'); + if (['1', '2', '3'].includes(myRoleId)) { + nextPage = `/character_description${myRoleId}`; } else { - // 타이틀 동일 - if (topic === 'AI의 권리와 책임') { - nextPage = '/game02'; - } else { - const myRoleId = localStorage.getItem('myrole_id'); - if (myRoleId === '1' || myRoleId === '2' || myRoleId === '3') { - nextPage = `/character_description${myRoleId}`; - } else { - // 역할 아이디 없으면 안전 폴백 - nextPage = '/game01'; - console.warn('[GameMap][AWS] myrole_id 없음 → /game01로 폴백'); - } - } + nextPage = '/game01'; } - } else { - // 안드로이드 모드: 기존 규칙 유지 - nextPage = prevTitle === title ? '/game02' : '/game01'; } - - navigate(nextPage); - }; - + } + } else { + nextPage = prevTitle === stableTitle ? '/game02' : '/game01'; + } + + navigate(nextPage); + }; const completedTopics = JSON.parse(localStorage.getItem('completedTopics') ?? '[]'); - const isCompleted = (name) => completedTopics.includes(name); + + // [수정] 완료 여부 체크 시 영문 텍스트를 한국어 원문으로 변환하여 체크 + const isCompleted = (displayText) => { + const stableText = getStableText(displayText); + return completedTopics.includes(stableText); + }; - // 해금 규칙(카테고리별 1→2→3 단계) const getUnlockedOptions = () => { const unlocked = new Set(); - + // 해금 로직 (비교 시 현재 언어팩의 텍스트 사용하지만 isCompleted 내부에서 변환됨) if (isAWS) { - // 1단계: 첫 옵션만 기본 해금 - unlocked.add('AI 알고리즘 공개'); - // 2단계: 1단계 첫 옵션 완료 시 - if (isCompleted('AI 알고리즘 공개')) { - unlocked.add('AWS의 권한'); - unlocked.add('사람이 죽지 않는 전쟁'); + unlocked.add(t.awsOption1_1 || 'AI 알고리즘 공개'); + if (isCompleted(t.awsOption1_1 || 'AI 알고리즘 공개')) { + unlocked.add(t.awsOption1_2 || 'AWS의 권한'); + unlocked.add(t.awsOption2_1 || '사람이 죽지 않는 전쟁'); } - // 3단계: 2단계 첫 옵션 완료 시 - if (isCompleted('사람이 죽지 않는 전쟁')) { - unlocked.add('AI의 권리와 책임'); - unlocked.add('AWS 규제'); + if (isCompleted(t.awsOption2_1 || '사람이 죽지 않는 전쟁')) { + unlocked.add(t.awsOption2_2 || 'AI의 권리와 책임'); + unlocked.add(t.awsOption3_1 || 'AWS 규제'); } } else { - // 안드로이드 - unlocked.add('AI의 개인 정보 수집'); - if (isCompleted('AI의 개인 정보 수집')) { - unlocked.add('안드로이드의 감정 표현'); - unlocked.add('아이들을 위한 서비스'); + unlocked.add(t.andOption1_1 || 'AI의 개인 정보 수집'); + if (isCompleted(t.andOption1_1 || 'AI의 개인 정보 수집')) { + unlocked.add(t.andOption1_2 || '안드로이드의 감정 표현'); + unlocked.add(t.andOption2_1 || '아이들을 위한 서비스'); } - if (isCompleted('아이들을 위한 서비스')) { - unlocked.add('설명 가능한 AI'); - unlocked.add('지구, 인간, AI'); + if (isCompleted(t.andOption2_1 || '아이들을 위한 서비스')) { + unlocked.add(t.andOption2_2 || '설명 가능한 AI'); + unlocked.add(t.andOption3_1 || '지구, 인간, AI'); } } return unlocked; @@ -180,37 +231,37 @@ export default function GameMap() { const unlockedOptions = getUnlockedOptions(); const createOption = (text, title) => { - const isDone = completedTopics.includes(text); + const isDone = isCompleted(text); // 여기서 getStableText가 적용됨 const isUnlocked = unlockedOptions.has(text); return { text, - disabled: isDone, // 완료한 항목은 비활성 - locked: !isUnlocked, // 잠금 표시용 + disabled: isDone, + locked: !isUnlocked, onClick: () => { if (!isDone && isUnlocked) handleSelect(text, title); }, }; }; - // 섹션 단축 변수 const s0 = sections[0]; const s1 = sections[1]; const s2 = sections[2]; - // 프레임 잠금 여부 (1프레임은 항상 열림, 2/3은 단계 해금) const isHomeUnlocked = true; const isNationalUnlocked = isAWS - ? isCompleted('AI 알고리즘 공개') // AWS 1-1 완료 시 2프레임 - : isCompleted('AI의 개인 정보 수집'); // Android 1-1 완료 시 2프레임 + ? isCompleted(t.awsOption1_1 || 'AI 알고리즘 공개') + : isCompleted(t.andOption1_1 || 'AI의 개인 정보 수집'); const isInternationalUnlocked = isAWS - ? isCompleted('사람이 죽지 않는 전쟁') // AWS 2-1 완료 시 3프레임 - : isCompleted('아이들을 위한 서비스'); // Android 2-1 완료 시 3프레임 - const handleBackClick = () => { - const idx = window.history.state?.idx ?? 0; - if (idx > 0) navigate(-1); - else navigate('/matename'); - }; + ? isCompleted(t.awsOption2_1 || '사람이 죽지 않는 전쟁') + : isCompleted(t.andOption2_1 || '아이들을 위한 서비스'); + + const handleBackClick = () => { + const idx = window.history.state?.idx ?? 0; + if (idx > 0) navigate(-1); + else navigate('/matename'); + }; + return (
- 합의 후 같은 라운드를 선택하세요. + {t.guideText || '합의 후 같은 라운드를 선택하세요.'}
- {/* 섹션 1 */} - {/* 섹션 2 */} - {/* 섹션 3 */} ); -} +} \ No newline at end of file diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index e753891..7b1ac82 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -16,10 +16,21 @@ import { Colors, FontStyles } from '../components/styleConstants'; import { clearAllLocalStorageKeys } from '../utils/storage'; import FindIdModal from '../components/FindIdModal'; import FindPasswordModal from '../components/FindPasswordModal'; +import { translations } from '../utils/language/index'; + +/** + * 하드코딩된 주소를 환경변수로 분리 + */ +const API_BASE = import.meta.env.VITE_API_BASE_URL || 'https://dilemmai-idl.com'; + export default function Login() { const navigate = useNavigate(); const location = useLocation(); + // 언어 설정 상태 관리 (초기값은 로컬스토리지 혹은 'ko') + const [lang, setLang] = useState(localStorage.getItem('app_lang') || 'ko'); + const t = translations[lang].Login; + const [pwVisible, setPwVisible] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -30,6 +41,13 @@ export default function Login() { // 쿼리에서 code를 상태로 보관(초기값은 로컬스토리지) const [inviteCode, setInviteCode] = useState(() => localStorage.getItem('code') || ''); + // 언어 변경 핸들러 (드롭다운 선택 시 호출) + const handleLanguageChange = (e) => { + const selectedLang = e.target.value; + setLang(selectedLang); + localStorage.setItem('app_lang', selectedLang); + }; + useEffect(() => { const original = document.body.style.overflow; document.body.style.overflow = 'hidden'; @@ -37,6 +55,7 @@ export default function Login() { document.body.style.overflow = original; }; }, []); + // 로그인 처음 들어갈 때 로컬값 초기화 useEffect(() => { clearAllLocalStorageKeys(); @@ -58,14 +77,13 @@ export default function Login() { }, [location.search]); const handleLogin = async () => { - - try { const form = new URLSearchParams(); form.append('username', username); form.append('password', password); - const response = await axios.post('https://dilemmai-idl.com/auth/login', form, { + // 하드코딩된 URL을 환경변수 기반 API_BASE로 교체 + const response = await axios.post(`${API_BASE}/auth/login`, form, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); @@ -85,16 +103,42 @@ export default function Login() { } catch (error) { if (error.response) { console.error('로그인 실패:', error.response.data); - alert('로그인 실패: ' + JSON.stringify(error.response.data.detail, null, 2)); + alert(t.loginFail + ' ' + JSON.stringify(error.response.data.detail, null, 2)); } else { console.error('Error:', error.message); - alert('로그인 오류: ' + error.message); + alert(t.loginError + ' ' + error.message); } } }; return ( + {/* 드롭박스(Select) 형식의 언어 선택기 + 추후 언어가 추가되면
)} - {/* 비밀번호 찾기 기능 비활성화 */} - {/* {showFindPw && ( -
setShowFindPw(false)} - > -
e.stopPropagation()}> - setShowFindPw(false)} /> -
-
- )} */}
); } + +/** + * + * 1. 파일 상단에 API_BASE 상수를 정의하고 import.meta.env.VITE_API_BASE_URL 환경변수를 적용함. + * 2. handleLogin 함수 내의 axios.post URL을 하드코딩된 주소 대신 ${API_BASE}를 사용하도록 수정함. + */ \ No newline at end of file diff --git a/src/pages/MateName.jsx b/src/pages/MateName.jsx index 594f522..82d18ce 100644 --- a/src/pages/MateName.jsx +++ b/src/pages/MateName.jsx @@ -20,32 +20,50 @@ import { Colors, FontStyles } from "../components/styleConstants"; import { useWebSocketNavigation, useHostActions } from '../hooks/useWebSocketMessage'; import { clearAllLocalStorageKeys } from '../utils/storage'; +// 이미지 에셋 - 언어별 대응 import hostInfoSvg from '../assets/host_info.svg'; +import hostInfoSvg_en from '../assets/en/host_info_en.svg'; + import HostInfoBadge from '../components/HostInfoBadge'; +// Localization +import { translations } from '../utils/language/index'; export default function MateName() { const navigate = useNavigate(); + + // Get language setting and translations + const lang = localStorage.getItem('language') || 'ko'; + const t = translations?.[lang]?.MateName || {}; + + // 언어 설정에 따른 이미지 선택 + const currentHostInfoSvg = lang === 'en' ? hostInfoSvg_en : hostInfoSvg; + const [name, setName] = useState(''); const [selectedIndex, setSelectedIndex] = useState(null); const roomCode = localStorage.getItem('room_code'); const [hostId, setHostId] = useState(null); const [myRoleId, setMyRoleId] = useState(null); + + // [수정] 카테고리 인식 로직 강화 (한글/영어 모두 대응) const category = localStorage.getItem('category') || '안드로이드'; + const isAndroid = category && (category.includes('안드로이드') || category.toLowerCase().includes('android')); + // 안드로이드가 아니면 AWS로 간주 + const isAWS = !isAndroid; // 카테고리별 이미지 세트 - const isAWS = category === '자율 무기 시스템'; const images = isAWS ? [killerCharacter1, killerCharacter2, killerCharacter3] : [character1, character2, character3]; + // Localization applied to UI Text const uiText = isAWS ? { - placeholder: '이 자율 무기 시스템의 이름을 정해 주세요. (방장만 입력 가능)', - main: '여러분이 사용자라면 자율 무기 시스템을 어떻게 부를까요?', + placeholder: t.placeholderAws || '이 자율 무기 시스템의 이름을 정해 주세요. (방장만 입력 가능)', + main: t.mainAws || '여러분이 사용자라면 자율 무기 시스템을 어떻게 부를까요?', } : { - placeholder: '여러분의 HomeMate 이름을 지어주세요.(방장만 입력 가능)', - main: ' 여러분이 사용자라면 HomeMate를 어떻게 부를까요?', + placeholder: t.placeholderAndroid || '여러분의 HomeMate 이름을 지어주세요.(방장만 입력 가능)', + main: t.mainAndroid || ' 여러분이 사용자라면 HomeMate를 어떻게 부를까요?', }; const { voiceSessionStatus, isInitialized: webrtcInitialized } = useWebRTC(); @@ -81,7 +99,7 @@ export default function MateName() { }; setConnectionStatus(newStatus); }, [websocketConnected, webrtcInitialized]); -// useEffect(() => { +// useEffect(() => { // if (!websocketConnected) { // console.warn('❌ WebSocket 연결 끊김 감지됨'); // alert('⚠️ 연결이 끊겨 게임이 초기화됩니다.'); @@ -168,12 +186,12 @@ useEffect(() => { }, [selectedIndex]); const paragraphs = [ - { main: uiText.main, sub: '합의 후에 방장이 이름을 작성해주세요.' }, + { main: uiText.main, sub: t.subText || '합의 후에 방장이 이름을 작성해주세요.' }, ]; const handleNameChange = (e) => { if (!isHost) { - alert('방장이 아니므로 이름 입력이 불가능합니다.'); + alert(t.alertNotHostInput || '방장이 아니므로 이름 입력이 불가능합니다.'); return; } setName(e.target.value); @@ -181,16 +199,16 @@ useEffect(() => { const handleContinue = async () => { if (!isHost) { - alert('방장만 게임을 진행할 수 있습니다.'); + alert(t.alertNotHostProgress || '방장만 게임을 진행할 수 있습니다.'); return; } if (!name.trim()) { - alert('이름을 입력해주세요!'); + alert(t.alertNoName || '이름을 입력해주세요!'); return; } const rc = localStorage.getItem('room_code'); if (!rc) { - alert('room_code가 없습니다. 방에 먼저 입장하세요.'); + alert(t.alertNoRoomCode || 'room_code가 없습니다. 방에 먼저 입장하세요.'); return; } const trimmed = name.trim(); @@ -211,7 +229,7 @@ useEffect(() => { } console.error('[MateName] AI 이름 저장 실패:', err); - alert(err?.response?.data?.detail ?? '이름 저장 중 오류가 발생했습니다.'); + alert(err?.response?.data?.detail ?? (t.alertSaveError || '이름 저장 중 오류가 발생했습니다.')); } }; @@ -221,16 +239,16 @@ useEffect(() => {
@@ -264,7 +282,12 @@ useEffect(() => { height={64} value={name} onChange={handleNameChange} - style={{ margin: '0 auto', cursor: isHost ? 'text' : 'not-allowed', backgroundColor: isHost ? undefined : '#f5f5f5' }} + style={{ + margin: '0 auto', + cursor: isHost ? 'text' : 'not-allowed', + backgroundColor: isHost ? undefined : '#f5f5f5', + fontSize: t.placeholderSize || 'inherit' + }} />
@@ -274,4 +297,4 @@ useEffect(() => {
); -} +} \ No newline at end of file diff --git a/src/pages/SelectHomeMate.jsx b/src/pages/SelectHomeMate.jsx index 3db0b7a..e5260c8 100644 --- a/src/pages/SelectHomeMate.jsx +++ b/src/pages/SelectHomeMate.jsx @@ -1,3 +1,58 @@ + // useEffect(() => { + // if (!websocketConnected) { + // console.warn('🔌 [SelectHomeMate] WebSocket 연결 끊김 → 초기화 후 메인으로 이동'); + // clearAllLocalStorageKeys(); + // alert('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // navigate('/'); + // } + // }, [websocketConnected, navigate]); + + // useEffect(() => { + // if (!websocketConnected && !isPageReloading) { + // console.warn('🔌 WebSocket 연결 끊김 → 초기화'); + // // 함수 참조니까 바로 호출 가능 + // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // } + // }, [websocketConnected, isPageReloading, finalizeDisconnection]); + // useEffect(() => { + // let cancelled = false; + // const isReloadingGraceLocal = () => { + // const flag = sessionStorage.getItem('reloading') === 'true'; + // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); + // if (!flag) return false; + // if (Date.now() > expire) { + // sessionStorage.removeItem('reloading'); + // sessionStorage.removeItem('reloading_expire_at'); + // return false; + // } + // return true; + // }; + + // if (!websocketConnected) { + // // 1) reloading-grace가 켜져 있으면 finalize 억제 + // if (isReloadingGraceLocal()) { + // console.log('♻️ reloading grace active — finalize 억제'); + // return; + // } + + // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize + // const DEBOUNCE_MS = 1200; + // const timer = setTimeout(() => { + // if (cancelled) return; + // if (!websocketConnected && !isReloadingGraceLocal()) { + // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); + // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); + // } else { + // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); + // } + // }, DEBOUNCE_MS); + + // return () => { + // cancelled = true; + // clearTimeout(timer); + // }; + // } + // }, [websocketConnected, finalizeDisconnection]); import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import Background from '../components/Background'; @@ -24,11 +79,24 @@ import { import { FontStyles,Colors } from '../components/styleConstants'; import HostCheck1 from '../components/HostCheck1'; +// 이미지 에셋 - 언어별 대응 import hostInfoSvg from '../assets/host_info.svg'; +import hostInfoSvg_en from '../assets/en/host_info_en.svg'; // 사용자 규칙: _en 접미사 + import HostInfoBadge from '../components/HostInfoBadge'; +// Localization +import { translations } from '../utils/language/index'; export default function SelectHomeMate() { const navigate = useNavigate(); + + // Get language setting and translations + const lang = localStorage.getItem('language') || 'ko'; + const t = translations?.[lang]?.SelectHomeMate || {}; + + // 언어 설정에 따른 이미지 선택 + const currentHostInfoSvg = lang === 'en' ? hostInfoSvg_en : hostInfoSvg; + const [activeIndex, setActiveIndex] = useState(null); const [hostId, setHostId] = useState(null); const [myRoleId, setMyRoleId] = useState(null); @@ -68,65 +136,7 @@ export default function SelectHomeMate() { total_required: 3, all_arrived: false, }); - - // useEffect(() => { - // if (!websocketConnected) { - // console.warn('🔌 [SelectHomeMate] WebSocket 연결 끊김 → 초기화 후 메인으로 이동'); - // clearAllLocalStorageKeys(); - // alert('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // navigate('/'); - // } - // }, [websocketConnected, navigate]); - - // useEffect(() => { - // if (!websocketConnected && !isPageReloading) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화'); - // // 함수 참조니까 바로 호출 가능 - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } - // }, [websocketConnected, isPageReloading, finalizeDisconnection]); - // useEffect(() => { - // let cancelled = false; - // const isReloadingGraceLocal = () => { - // const flag = sessionStorage.getItem('reloading') === 'true'; - // const expire = parseInt(sessionStorage.getItem('reloading_expire_at') || '0', 10); - // if (!flag) return false; - // if (Date.now() > expire) { - // sessionStorage.removeItem('reloading'); - // sessionStorage.removeItem('reloading_expire_at'); - // return false; - // } - // return true; - // }; - // if (!websocketConnected) { - // // 1) reloading-grace가 켜져 있으면 finalize 억제 - // if (isReloadingGraceLocal()) { - // console.log('♻️ reloading grace active — finalize 억제'); - // return; - // } - - // // 2) debounce: 잠깐 기다렸다가 여전히 끊겨있으면 finalize - // const DEBOUNCE_MS = 1200; - // const timer = setTimeout(() => { - // if (cancelled) return; - // if (!websocketConnected && !isReloadingGraceLocal()) { - // console.warn('🔌 WebSocket 연결 끊김 → 초기화 (확정)'); - // finalizeDisconnection('❌ 연결이 끊겨 게임이 초기화됩니다.'); - // } else { - // console.log('🔁 재연결/리로드 감지 — finalize 스킵'); - // } - // }, DEBOUNCE_MS); - - // return () => { - // cancelled = true; - // clearTimeout(timer); - // }; - // } - // }, [websocketConnected, finalizeDisconnection]); - - - // 역할별 사용자 ID 매핑 const [roleUserMapping, setRoleUserMapping] = useState({ role1_user_id: null, @@ -215,10 +225,18 @@ export default function SelectHomeMate() { return () => clearTimeout(timer); }, [round]); + // ▼▼▼ [핵심 수정] 카테고리 인식 로직 강화 (GameIntro와 동일한 로직 적용) ▼▼▼ + // localStorage에서 가져올 때 렌더링 시점에 바로 반영되도록 변수 선언 + const currentCategory = localStorage.getItem('category'); + + const isAndroid = currentCategory && (currentCategory.includes('안드로이드') || currentCategory.toLowerCase().includes('android')); + // 안드로이드가 아니면 AWS로 간주 (영어/한국어 모두 대응 가능) + const isAWS = !isAndroid; + // category에 따른 이미지 선택 const getImages = () => { - const category = localStorage.getItem('category'); - if (category === '자율 무기 시스템') { + // 위에서 계산한 isAWS 플래그 사용 + if (isAWS) { return [killerCharacter1, killerCharacter2, killerCharacter3]; } else { // category === '안드로이드' 또는 기본값 @@ -228,70 +246,56 @@ export default function SelectHomeMate() { const images = getImages(); - // const paragraphs = [ - // { - // main: ' 여러분이 생각하는 HomeMate는 어떤 형태인가요?', - // sub: isHost - // ? arrivalStatus.all_arrived - // ? '(함께 토론한 후 방장이 선택하고, "다음" 버튼을 클릭해주세요)' - // : `(유저 입장 대기 중... ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})` - // : arrivalStatus.all_arrived - // ? '(방장이 캐릭터를 선택할 때까지 기다려주세요)' - // : `(유저 입장 대기 중... ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})`, - // }, - // ]; -const isAWS = category === '자율 무기 시스템'; - -const paragraphs = [ - { - main: isAWS - ? ' 여러분이 생각하는 자율 무기 시스템은 어떤 형태인가요?' - : ' 여러분이 생각하는 HomeMate는 어떤 형태인가요?', - sub: isHost - ? arrivalStatus.all_arrived - ? `(함께 토론한 후 방장이 선택하고, '다음' 버튼을 클릭해주세요)` - : `(유저 입장 대기 중... ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})` - : arrivalStatus.all_arrived - ? '(방장이 캐릭터를 선택할 때까지 기다려주세요)' - : `(유저 입장 대기 중... ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})`, - }, -]; - + // 텍스트 선택 로직 (isAWS 사용) + const paragraphs = [ + { + main: isAWS + ? (t.mainAws || ' 여러분이 생각하는 자율 무기 시스템은 어떤 형태인가요?') + : (t.mainAndroid || ' 여러분이 생각하는 HomeMate는 어떤 형태인가요?'), + sub: isHost + ? arrivalStatus.all_arrived + ? (t.subHostAllArrived || `(함께 토론한 후 방장이 선택하고, '다음' 버튼을 클릭해주세요)`) + : `${t.subWaiting || '(유저 입장 대기 중...'} ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})` + : arrivalStatus.all_arrived + ? (t.subGuestAllArrived || '(방장이 캐릭터를 선택할 때까지 기다려주세요)') + : `${t.subWaiting || '(유저 입장 대기 중...'} ${arrivalStatus.arrived_users}/${arrivalStatus.total_required})`, + }, + ]; // 방장 전용 캐릭터 선택 핸들러 (모든 유저 도착 후에만 활성화) const handleCharacterSelect = (idx) => { if (!isHost) { console.log('[SelectHomeMate] 방장이 아니므로 캐릭터 선택 불가'); - alert('방장만 캐릭터를 선택할 수 있습니다.'); + alert(t.alertNotHost || '방장만 캐릭터를 선택할 수 있습니다.'); return; } if (!arrivalStatus.all_arrived) { console.log('[SelectHomeMate] 아직 모든 유저가 도착하지 않음'); - alert('모든 유저가 입장할 때까지 기다려주세요.'); + alert(t.alertWaitingAll || '모든 유저가 입장할 때까지 기다려주세요.'); return; } setActiveIndex(idx); - console.log(`[SelectHomeMate] 방장이 캐릭터 ${idx + 1} 선택 (카테고리: ${category})`); + console.log(`[SelectHomeMate] 방장이 캐릭터 ${idx + 1} 선택 (카테고리: ${currentCategory})`); }; const handleContinue = async () => { if (!isHost) { - alert('방장만 게임을 진행할 수 있습니다.'); + alert(t.alertNotHost || '방장만 게임을 진행할 수 있습니다.'); return; } if (!arrivalStatus.all_arrived) { - alert('모든 유저가 입장할 때까지 기다려주세요.'); + alert(t.alertWaitingAll || '모든 유저가 입장할 때까지 기다려주세요.'); return; } if (activeIndex === null) { - alert('캐릭터를 먼저 선택해주세요!'); + alert(t.alertSelectCharacter || '캐릭터를 먼저 선택해주세요!'); return; } const roomCode = localStorage.getItem('room_code'); if (!roomCode) { - alert('room_code가 없습니다. 방에 먼저 입장하세요.'); + alert(t.alertNoRoomCode || 'room_code가 없습니다. 방에 먼저 입장하세요.'); return; } @@ -318,7 +322,7 @@ const paragraphs = [ } console.error('[SelectHomeMate] AI 선택 실패:', err); - alert('메이트 선택 실패'); + alert(t.alertSelectFail || '메이트 선택 실패'); } }; @@ -332,16 +336,16 @@ const paragraphs = [
@@ -392,7 +396,7 @@ const paragraphs = [ {`Character handleCharacterSelect(idx)} style={{ width: 264, diff --git a/src/pages/SelectRoom.jsx b/src/pages/SelectRoom.jsx index 2f03187..08dcc5f 100644 --- a/src/pages/SelectRoom.jsx +++ b/src/pages/SelectRoom.jsx @@ -9,19 +9,42 @@ import CreateRoom from '../components/CreateRoom2'; import createIcon from '../assets/roomcreate.svg'; import joinIcon from '../assets/joinviacode.svg'; import dilemmaIcon from "../assets/dilemmaIcon.svg"; -import { FontStyles,Colors } from '../components/styleConstants'; +import { FontStyles, Colors } from '../components/styleConstants'; import CreateDilemma from '../components/CreateDilemma'; import DilemmaOutPopup from '../components/DilemmaOutPopup'; import HeaderBar from '../components/Expanded/HeaderBar'; +import { translations } from '../utils/language/index'; + +// 신규 팝업 및 아이콘 임포트 +import IntroductionPopup from '../components/IntroductionPopup'; +import questionIcon from '../assets/Questionmark.svg'; + export default function SelectRoom() { const navigate = useNavigate(); + + // --- 시스템 설정된 언어(app_lang) 연동 로직 --- + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations?.[lang]?.SelectRoom || {}; + const [isLogoutPopupOpen, setIsLogoutPopupOpen] = useState(false); -const [isJoinRoomOpen, setIsJoinRoomOpen] = useState(false); -const [isCreateRoomOpen, setIsCreateRoomOpen] = useState(false); -const [isCreateDilemaOpen,setIsCreateDilemaOpen] = useState(false); + const [isJoinRoomOpen, setIsJoinRoomOpen] = useState(false); + const [isCreateRoomOpen, setIsCreateRoomOpen] = useState(false); + const [isCreateDilemaOpen, setIsCreateDilemaOpen] = useState(false); + + // 게임 소개 팝업 상태 관리 + const [isIntroPopupOpen, setIsIntroPopupOpen] = useState(false); + useEffect(() => { const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; + + // 최초 접속 시 자동 팝업 로직 (세션 스토리지 활용) + const hasSeenIntro = sessionStorage.getItem('hasSeenIntro'); + if (!hasSeenIntro) { + setIsIntroPopupOpen(true); + sessionStorage.setItem('hasSeenIntro', 'true'); + } + return () => { document.body.style.overflow = originalOverflow; }; @@ -32,19 +55,41 @@ const [isCreateDilemaOpen,setIsCreateDilemaOpen] = useState(false); }; const handleLogout = () => { + // 로그아웃 시 소개 팝업 기록 초기화 + sessionStorage.removeItem('hasSeenIntro'); navigate('/'); }; + // 언어팩 로드 실패 시 화면 깨짐 방지 + if (!t.createTitle) return null; + return ( - - +
+ {/* 우측 상단 게임 소개 버튼 (물음표 아이콘) */} +
setIsIntroPopupOpen(true)} + > + Help +
+
+ {/* [방 만들기] 카드 */} - 새로운 방을 만들고
- 게임을 시작하세요. - + {t.createDesc} } onClick={() => setIsCreateRoomOpen(true)} /> + + {/* [방 참여하기] 카드 */} - 코드를 통해 방에
- 참여할 수 있습니다. - + {t.joinDesc} } onClick={() => setIsJoinRoomOpen(true)} - - /> - - 상황을 설정하고 선택지를 만들어
- 새로운 딜레마를 직접 제작하세요. - - } - onClick={()=> setIsCreateDilemaOpen(true)} /> + + {/* [딜레마 만들기] 카드 - 영문 버전이 아닐 때만 출력 */} + {lang !== 'en' && ( + {t.dilemmaDesc} + } + onClick={()=> setIsCreateDilemaOpen(true)} + /> + )}
+ {/* 팝업 레이어 (모달) */} + + {/* 게임 소개 팝업 */} + {isIntroPopupOpen && ( +
setIsIntroPopupOpen(false)} // 배경 클릭 시 닫기 + > + setIsIntroPopupOpen(false)} + /> +
+ )} + {isLogoutPopupOpen && (
- )} - {isJoinRoomOpen && ( -
- setIsJoinRoomOpen(false)} /> -
)} - - {isCreateRoomOpen && ( -
- setIsCreateRoomOpen(false)} /> -
-)} - {isCreateDilemaOpen && ( -
- setIsCreateDilemaOpen(false)} /> -
- )} + + {isJoinRoomOpen && ( +
+ setIsJoinRoomOpen(false)} /> +
+ )} + + {isCreateRoomOpen && ( +
+ setIsCreateRoomOpen(false)} /> +
+ )} + + {isCreateDilemaOpen && ( +
+ setIsCreateDilemaOpen(false)} /> +
+ )} ); -} +} \ No newline at end of file diff --git a/src/pages/Signup01.jsx b/src/pages/Signup01.jsx index 6c14e91..d43fe19 100644 --- a/src/pages/Signup01.jsx +++ b/src/pages/Signup01.jsx @@ -7,8 +7,12 @@ import SelectCard from '../components/SelectCard'; import PrimaryButton from '../components/PrimaryButton'; import logo from '../assets/logo.svg'; import { FontStyles, Colors } from '../components/styleConstants'; +import { translations } from '../utils/language/index'; export default function Signup() { + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations[lang].Signup01; + const [showInfo, setShowInfo] = useState(false); const [showPolicy, setShowPolicy] = useState(false); const [showContact, setShowContact] = useState(false); @@ -18,324 +22,87 @@ export default function Signup() { const isAllAgreed = agree1 && agree2; const navigate = useNavigate(); + if (!t) return null; // 언어팩 로드 방어 코드 + return ( -
-
-
- back navigate('/')} - /> - -
- logo +
+
+ + {/* Header */} +
+ back navigate('/')} /> +
+ logo
-
- 연구 소개 + {/* Research Overview */} +
+ {t.researchOverview}
- -
- 본 연구의 목적은 AI 윤리교육의 새로운 학습 방식을 제안하기 위해 도덕적 추론과 사고 중심의 AI 윤리 교수학습을 지원하는 대화형 게임 플랫폼을 개발하고, - 이에 대한 효과성을 검증하는 것입니다. -

- 게임은 AI와 관련한 딜레마 상황에서의 의사결정을 자연스러운 대화 과정에서 시뮬레이션 하는 형식으로 진행되며, - 대화 중 수집되는 정보는 연구에만 사용됩니다. +
+ {t.overviewContent1}

{t.overviewContent2}
-
setShowInfo((prev) => !prev)} - > -
- 수집 및 활용 항목 * -
- toggle + {/* Data Collection Accordion */} +
setShowInfo(!showInfo)}> +
{t.dataCollectionTitle}
+ toggle
{showInfo && ( -
+
- - + + - - - - - - - - - - - - - - - - + + + +
수집 항목활용 목적{t.tableCol1}{t.tableCol2}
아이디 및 비밀번호참여자 구분, 실험 반복 방지 및 응답 관리
성별, 생년월일 및 학업 단계성별, 연령, 학년 등 인구통계학적 요인에 따른 차이 분석
음성 대화 내용 및 발화 데이터의사결정 과정 및 윤리적 판단 기준에 대한 분석
게임 플레이 기록의사결정 과정 및 윤리적 판단 기준에 대한 분석
{t.data1Name}{t.data1Usage}
{t.data2Name}{t.data2Usage}
{t.data3Name}{t.data3Usage}
{t.data4Name}{t.data4Usage}
)} -
setShowPolicy((prev) => !prev)} - > -
- 데이터 보관 및 처리 방침 * -
- toggle + {/* Policy Accordion */} +
setShowPolicy(!showPolicy)}> +
{t.dataStorageTitle}
+ toggle
{showPolicy && ( -
-

- - 수집된 데이터는 AI 윤리와 관련된 사용자의 선택을 분석하는 데 활용됩니다. -

-

- - IDL Lab은 해당 플랫폼의 운영과 관련하여 생성되는 데이터 및 기타 정보를 수집·집계·분석할 권리를 가지며, - 이를 통해 ① 플랫폼 기능을 개발·개선하고, ② 개인을 식별할 수 없는 익명화된 형태로 도출된 결과를 자유롭게 공개할 수 있습니다. -

-

- - 사용자는 idllabewha@gmail.com 으로 서면 요청함으로써 자신의 개인정보 삭제 및 데이터 활용에 대한 동의를 철회할 수 있습니다. -

-

- - 연구 목적으로 이용에 동의한 데이터는 사용자 개인정보와 분리되어 안전하게 암호화된 상태로 저장되며, - 3년간 보관 후 폐기됩니다. -

-

- - 모든 데이터는 비식별화 처리되어 개인이 식별되지 않도록 안전하게 관리됩니다. -

+
+

{t.policy1}

+

{t.policy2}
{t.policy2_1}
{t.policy2_2}

+

{t.policy3}

{t.policy4}

{t.policy5}

)} -
setShowContact((prev) => !prev)} - > -
- 연구 책임자 및 문의처 * -
- toggle + {/* Contact Accordion */} +
setShowContact(!showContact)}> +
{t.contactTitle}
+ toggle
{showContact && ( -
-

- 연구 책임자: 이화여자대학교 교육공학과 소효정 교수 연구팀

-

- 연락처: idllabewha@gmail.com

+
+

{t.contactInfo1}

{t.contactInfo2}

)} + {/* Agreement Checkbox */}
-
- setAgree1((prev) => !prev)} - style={{ - width: '100%', - height: '8vh', - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - padding: '0 16px', - gap: 12, - iconSize: 20, - }} - /> -
- -
- setAgree2((prev) => !prev)} - style={{ - width: '100%', - height: '10vh', - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - padding: '0 16px', - gap: 12, - iconSize: 20, - }} - /> -
+ setAgree1(!agree1)} style={{ width: '100%', height: '8vh', fontSize: 'clamp(0.875rem, 1vw, 1rem)' }} /> + setAgree2(!agree2)} style={{ width: '100%', height: '8vh', fontSize: 'clamp(0.875rem, 1vw, 1rem)' }} />
+ {/* Next Button */}
- navigate('/signup02')} - > - 다음 + navigate('/signup02')}> + {t.nextBtn}
diff --git a/src/pages/Signup02.jsx b/src/pages/Signup02.jsx index d53db89..c285108 100644 --- a/src/pages/Signup02.jsx +++ b/src/pages/Signup02.jsx @@ -1,4 +1,3 @@ -// src/pages/Signup02.jsx import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import Background from '../components/Background'; @@ -14,9 +13,18 @@ import eyeOnIcon from '../assets/eyeon.svg'; import eyeOffIcon from '../assets/eyeoff.svg'; import { Colors, FontStyles } from '../components/styleConstants'; import axios from 'axios'; +import { translations } from '../utils/language/index'; + +/** + * [AI 수정] 하드코딩된 주소를 환경변수로 분리 + */ +const API_BASE = import.meta.env.VITE_API_BASE_URL || 'https://dilemmai-idl.com'; export default function Signup02() { const navigate = useNavigate(); + const lang = localStorage.getItem('app_lang') || 'ko'; + const t = translations[lang].Signup02; + const emailRef = useRef(null); const [username, setUsername] = useState(''); const [emailError, setEmailError] = useState(''); @@ -27,36 +35,33 @@ export default function Signup02() { const [birthDay, setBirthDay] = useState(''); const [gender, setGender] = useState(''); const [education, setEducation] = useState(''); - // const [grade, setGrade] = useState(''); const [major, setMajor] = useState(''); - const [openDropdown, setOpenDropdown] = useState(null); // 'education' | 'major' | null + const [openDropdown, setOpenDropdown] = useState(null); const [passwordError, setPasswordError] = useState(''); const [email, setEmail] = useState(''); const [birthError, setBirthError] = useState(''); -const [isUsernameAvailable, setIsUsernameAvailable] = useState(null); -const [usernameCheckError, setUsernameCheckError] = useState(''); - -useEffect(() => { - if (password.length > 0 && password.length < 8) { - setPasswordError('비밀번호는 최소 8자 이상이어야 합니다.'); - } else if (password && confirmPassword && password !== confirmPassword) { - setPasswordError('비밀번호가 일치하지 않습니다.'); - } else { - setPasswordError(''); - } -}, [password, confirmPassword]); + const [isUsernameAvailable, setIsUsernameAvailable] = useState(null); + const [usernameCheckError, setUsernameCheckError] = useState(''); + useEffect(() => { + if (password.length > 0 && password.length < 8) { + setPasswordError(t.passwordLengthError); + } else if (password && confirmPassword && password !== confirmPassword) { + setPasswordError(t.passwordMatchError); + } else { + setPasswordError(''); + } + }, [password, confirmPassword, t]); useEffect(() => { if (email && !/^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email)) { - setEmailError('유효한 이메일 주소를 입력하세요.'); + setEmailError(t.emailInvalid); } else { setEmailError(''); } - }, [email]); + }, [email, t]); - // 생년월일 유효 검사 const handleBirthInput = (setter, maxLength, type) => (e) => { const onlyNums = e.target.value.replace(/\D/g, '').slice(0, maxLength); setter(onlyNums); @@ -68,24 +73,13 @@ useEffect(() => { y.length === 4 && Number(y) >= 1000 && Number(y) <= 2030 && m.length === 2 && Number(m) >= 1 && Number(m) <= 12; - setBirthError(isValid ? '' : '올바른 형식은 2001-01 입니다.'); + setBirthError(isValid ? '' : t.birthFormatError); }; - // 계열 옵션 - const majorOptions = ['예술계열', '공학계열', '인문계열', '사회계열', '교육계열', '자연계열', '기타']; - // // 학년 옵션 - // const getGradeOptions = () => { - // if (education === '중학생' || education === '고등학생') { - // return ['1학년', '2학년', '3학년']; - // } else if (education === '대학생') { - // return ['1학년', '2학년', '3학년', '4학년']; - // } - // return []; - // }; - // 폼이 모두 유효해야만 버튼 활성화 + const majorOptions = t.majorOptions; + const isFormValid = ( Boolean(username.trim()) && - // isUsernameAvailable === true && Boolean(email.trim()) && !emailError && Boolean(password) && @@ -94,10 +88,10 @@ useEffect(() => { Boolean(birthYear.trim()) && Boolean(birthMonth.trim()) && !birthError&& - (gender === '남' || gender === '여') && + (gender === t.genderMale || gender === t.genderFemale) && Boolean(education) && ( - (education === '중학생' || education === '고등학생' || education ==='기타') + (education === t.eduOptions[0] || education === t.eduOptions[1] || education === t.eduOptions[5]) ? true : Boolean(major) ) @@ -105,12 +99,12 @@ useEffect(() => { const inputStyle = { flex: 1, - height: '8vh', - minHeight: 48, + height: '8vh', + minHeight: 48, border: `1px solid ${Colors.grey02}`, paddingLeft: 12, ...FontStyles.body, - width: '100%', + width: '100%', outline: 'none', backgroundColor: Colors.componentBackground, boxSizing: 'border-box', @@ -118,10 +112,9 @@ useEffect(() => { alignItems: 'center', }; - // 성별 선택 시 const selectedGenderStyle = { flex: 1, - height: '7vh', + height: '7vh', minHeight: 40, border: `1px solid ${Colors.brandPrimary}`, backgroundColor: Colors.componentBackgroundActive, @@ -152,25 +145,24 @@ useEffect(() => { letterSpacing: '-0.015em', marginBottom:'0vh', }; - - // API 연결 - 로그인 중복 확인 const handleCheckUsername = async () => { const trimmedUsername = username.trim(); if (!trimmedUsername) { - setUsernameCheckError('아이디를 입력하세요.'); + setUsernameCheckError(t.usernameError); return; } if (!/^[a-zA-Z0-9_]{4,20}$/.test(trimmedUsername)) { - setUsernameCheckError('아이디는 영문, 숫자, 언더스코어로 4~20자여야 합니다.'); + setUsernameCheckError(t.usernameFormatError); return; } try { + // [AI 수정] 하드코딩된 주소를 API_BASE 변수로 교체 const res = await axios.post( - 'https://dilemmai-idl.com/auth/check-username', + `${API_BASE}/auth/check-username`, { username: trimmedUsername }, { headers: { 'Content-Type': 'application/json' } } ); @@ -180,16 +172,15 @@ useEffect(() => { setUsernameCheckError(''); } else { setIsUsernameAvailable(false); - setUsernameCheckError('이미 사용 중인 아이디입니다.'); + setUsernameCheckError(t.usernameInUse); } } catch (err) { console.error(err); setIsUsernameAvailable(false); - setUsernameCheckError('확인 중 오류가 발생했습니다.'); + setUsernameCheckError(t.usernameCheckFail); } }; - // API 연결 - 회원가입 const handleSignup = async () => { const birthdate = `${birthYear}/${birthMonth.padStart(2, '0')}`; @@ -205,13 +196,11 @@ useEffect(() => { "data_consent": true, "voice_consent": true }; - //console.log('데이터:', requestBody); - //다시 https로 back try { - const response = await axios.post('https://dilemmai-idl.com/auth/signup', requestBody, { + // [AI 수정] 하드코딩된 주소를 API_BASE 변수로 교체 + await axios.post(`${API_BASE}/auth/signup`, requestBody, { headers: { 'Content-Type': 'application/json' }, }); - //console.log('회원가입 성공:', response.data); const codeToUse = localStorage.getItem('code'); if (codeToUse) { @@ -219,10 +208,9 @@ useEffect(() => { } else { navigate('/'); } - //navigate('/'); } catch (error) { console.error('회원가입 실패:', error.response?.data || error.message); - alert(`회원가입 오류: ${JSON.stringify(error.response?.data?.detail || '')}`); + alert(`${t.signupError} ${JSON.stringify(error.response?.data?.detail || '')}`); } }; @@ -230,339 +218,118 @@ useEffect(() => {
-
+
뒤로가기 navigate('/signup01')} /> -
- 로고 +
+ 로고
-
- 아이디, 이메일 및 비밀번호 -
- {/* 아이디 */} +
{t.title1}
-
- { - setUsername(e.target.value); - setIsUsernameAvailable(null); - setUsernameCheckError(''); - }} - leftIcon={profileIcon} - style={{ - width: '100%', - height: '8vh', - minHeight: 48, - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - paddingRight: 80, - }} - /> - +
+ { setUsername(e.target.value); setIsUsernameAvailable(null); setUsernameCheckError(''); }} + leftIcon={profileIcon} + style={{ width: '100%', height: '8vh', minHeight: 48, fontSize: 'clamp(0.875rem, 1vw, 1rem)', paddingRight: lang === 'en' ? 120 : 80 }} + /> +
- {usernameCheckError && ( -
{usernameCheckError}
- )} - {isUsernameAvailable === true && ( -
- 사용 가능한 아이디입니다. -
- )} + {usernameCheckError &&
{usernameCheckError}
} + {isUsernameAvailable === true &&
{t.usernameAvailable}
}
- {/* 이메일 */}
setEmail(e.target.value)} - leftIcon={profileIcon} - style={{ - width: '100%', - height: '8vh', - minHeight: 48, - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - }} + placeholder={t.emailPlaceholder} value={email} onChange={(e) => setEmail(e.target.value)} + leftIcon={profileIcon} style={{ width: '100%', height: '8vh', minHeight: 48, fontSize: 'clamp(0.875rem, 1vw, 1rem)' }} /> - {emailError && ( -
{emailError}
- )} + {emailError &&
{emailError}
}
setPassword(e.target.value)} - leftIcon={lockIcon} - rightIconVisible={eyeOnIcon} - rightIconHidden={eyeOffIcon} - isPassword - style={{ - width: '100%', - height: '8vh', - minHeight: 48, - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - }} + placeholder={t.passwordPlaceholder} value={password} onChange={(e) => setPassword(e.target.value)} + leftIcon={lockIcon} rightIconVisible={eyeOnIcon} rightIconHidden={eyeOffIcon} isPassword + style={{ width: '100%', height: '8vh', minHeight: 48, fontSize: 'clamp(0.875rem, 1vw, 1rem)' }} />
setConfirmPassword(e.target.value)} - leftIcon={lockIcon} - rightIconVisible={eyeOnIcon} - rightIconHidden={eyeOffIcon} - isPassword - style={{ - width: '100%', - height: '8vh', - minHeight: 48, - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - }} + placeholder={t.passwordConfirmPlaceholder} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} + leftIcon={lockIcon} rightIconVisible={eyeOnIcon} rightIconHidden={eyeOffIcon} isPassword + style={{ width: '100%', height: '8vh', minHeight: 48, fontSize: 'clamp(0.875rem, 1vw, 1rem)' }} /> - {passwordError && ( -
- {passwordError} -
- )} + {passwordError &&
{passwordError}
}
-
- 생년월 * -
-
- - - +
{t.birthTitle}
+
+ +
- {birthError && ( -
{birthError}
- )} + {birthError &&
{birthError}
}
-
- 성별 * +
{t.genderTitle}
+
+ {[t.genderMale, t.genderFemale].map((g) => ( +
setGender(g)} style={{ ...(gender === g ? selectedGenderStyle : unselectedGenderStyle), display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, height: 50, ...FontStyles.body, cursor: 'pointer', boxSizing: 'border-box', padding: '0 16px' }}>{g}
+ ))}
-
- {['남', '여'].map((g) => ( -
setGender(g)} - style={{ - ...(gender === g ? selectedGenderStyle : unselectedGenderStyle), - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flex: 1, // 두 버튼 같은 너비 - height: 50, // 원하는 높이 - ...FontStyles.body, - cursor: 'pointer', - boxSizing: 'border-box', - padding: '0 16px', - }} - > - {g} -
- ))} -
-
-
- 회원 유형 -
+
{t.userTypeTitle}
- { - if (next) setOpenDropdown('education'); - else if (openDropdown === 'education') setOpenDropdown(null); - }} - onSelect={(option) => { - setEducation(option); - setMajor(''); - setOpenDropdown(null); - }} - style={{ - width: '100%', - height: '8vh', - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - backgroundColor: grayBackground, - }} - /> + { setEducation(option); setMajor(''); }} style={{ width: '100%', height: '8vh', fontSize: 'clamp(0.875rem, 1vw, 1rem)', backgroundColor: grayBackground }} />
- - {(education == '대학생' || education== '대학원생'||education== '교사')&&( + {(education === t.eduOptions[2] || education === t.eduOptions[3] || education === t.eduOptions[4]) && (
- { - if (next) setOpenDropdown('major'); - else if (openDropdown === 'major') setOpenDropdown(null); - }} - onSelect={(option) => { - setMajor(option); - setOpenDropdown(null); - }} - style={{ - width: '100%', - height: '8vh', - minHeight: 48, - fontSize: 'clamp(0.875rem, 1vw, 1rem)', - backgroundColor: grayBackground, - }} - /> + setMajor(option)} style={{ width: '100%', height: '8vh', minHeight: 48, fontSize: 'clamp(0.875rem, 1vw, 1rem)', backgroundColor: grayBackground }} />
)}
- {/* ─── “다음” 버튼 ─── */}
- - 다음 - + {t.nextBtn}
); -} \ No newline at end of file +} + +/** + * + * 1. 상단에 API_BASE 상수를 정의하고 import.meta.env.VITE_API_BASE_URL 환경변수를 적용함. + * 2. handleCheckUsername 및 handleSignup 함수 내의 axios.post URL을 하드코딩된 주소 대신 ${API_BASE}를 사용하도록 수정함. + */ \ No newline at end of file diff --git a/src/pages/WaitingRoom.jsx b/src/pages/WaitingRoom.jsx index 1f55103..7f6050a 100644 --- a/src/pages/WaitingRoom.jsx +++ b/src/pages/WaitingRoom.jsx @@ -13,10 +13,19 @@ import axiosInstance from '../api/axiosInstance'; import { FontStyles, Colors } from '../components/styleConstants'; import codeBg from '../assets/roomcodebackground.svg'; import CancelReadyPopup from '../components/CancelReadyPopup'; +// 언어팩 임포트 +import { translations } from '../utils/language/index'; export default function WaitingRoom() { const location = useLocation(); const navigate = useNavigate(); + + // --- 언어 설정 로직 (기존 app_lang 방식 유지) --- + const savedLang = localStorage.getItem('app_lang'); + const currentLang = (savedLang === 'en') ? 'en' : 'ko'; + const t = translations[currentLang]; + // -------------------------------------------- + // zoom 수정 // const allTopics = ['안드로이드', '자율 무기 시스템']; @@ -24,45 +33,44 @@ export default function WaitingRoom() { // const initialIndex = allTopics.indexOf(initialTopic); // zoom 수정 -// 기본 토픽 목록 -const defaultTopics = ['안드로이드', '자율 무기 시스템']; -const [category,setCategory] = useState(); -// custom 모드 여부 확인 -const isCustomMode = Boolean(localStorage.getItem('code')); -const creatorTitle = localStorage.getItem('creatorTitle') || '커스텀 주제'; - - -// allTopics는 기존 그대로 -const allTopics = isCustomMode ? [creatorTitle] : defaultTopics; - -// 최초 렌더에서 localStorage.category를 우선 반영 -const [currentIndex, setCurrentIndex] = useState(() => { - const stored = localStorage.getItem('category'); - const i = stored ? allTopics.indexOf(stored) : -1; - if (i >= 0) return i; - - const fallback = isCustomMode - ? creatorTitle - : (location.state?.topic || allTopics[0]); - - const fi = allTopics.indexOf(fallback); - return fi >= 0 ? fi : 0; -}); - -// 로컬(category) → UI 인덱스 동기화 -const syncTopicFromLocal = (value) => { - const cat = (value != null ? value : localStorage.getItem('category')) || ''; - const idx = allTopics.indexOf(cat); - if (idx >= 0 && idx !== currentIndex) { - setCurrentIndex(idx); - } -}; - -// 마운트 직후 한 번 더 동기화 -useEffect(() => { - syncTopicFromLocal(); -}, []); - + // 기본 토픽 목록 (언어팩 적용) + const defaultTopics = [t.WaitingRoom.topics.android, t.WaitingRoom.topics.aws]; + const [category,setCategory] = useState(); + // custom 모드 여부 확인 + const isCustomMode = Boolean(localStorage.getItem('code')); + const creatorTitle = localStorage.getItem('creatorTitle') || t.WaitingRoom.topics.custom; + + // allTopics는 기존 그대로 + const allTopics = isCustomMode ? [creatorTitle] : defaultTopics; + + // 최초 렌더에서 localStorage.category를 우선 반영 + const [currentIndex, setCurrentIndex] = useState(() => { + const stored = localStorage.getItem('category'); + const i = stored ? allTopics.indexOf(stored) : -1; + if (i >= 0) return i; + + const fallback = isCustomMode + ? creatorTitle + : (location.state?.topic || allTopics[0]); + + const fi = allTopics.indexOf(fallback); + return fi >= 0 ? fi : 0; + }); + + // 로컬(category) → UI 인덱스 동기화 + const syncTopicFromLocal = (value) => { + const cat = (value != null ? value : localStorage.getItem('category')) || ''; + const idx = allTopics.indexOf(cat); + if (idx >= 0 && idx !== currentIndex) { + setCurrentIndex(idx); + } + }; + + // 마운트 직후 한 번 더 동기화 + useEffect(() => { + syncTopicFromLocal(); + }, []); + //룸코드 복사 const [copied, setCopied] = useState(false); @@ -102,68 +110,67 @@ useEffect(() => { // 업데이트 중복 방지 플래그 const [isUpdating, setIsUpdating] = useState(false); - + const room_code = localStorage.getItem('room_code'); // A) 초기 데이터 로드 - 내 정보 조회 -const loadMyInfo = async () => { - try { - // 1. 로컬 스토리지에서 닉네임 먼저 확인 - let nickname = localStorage.getItem('nickname'); - let myUserId = localStorage.getItem('user_id'); - - if (!nickname || !myUserId) { - // 2. 없으면 API 호출 - try { - console.log('🔍 WaitingRoom: /users/me 호출 시도...'); - const { data: userInfo } = await axiosInstance.get('/users/me', { - timeout: 5000, - }); - myUserId = userInfo.id; - nickname = userInfo.username || `Player_${myUserId}`; - - // 3. 로컬 스토리지에 저장 - localStorage.setItem('nickname', nickname); - localStorage.setItem('user_id', myUserId); - console.log('✅ WaitingRoom: /users/me 성공:', { myUserId, nickname }); - } catch (apiErr) { - const isCorsError = !apiErr.response && (apiErr.message?.includes('Network Error') || apiErr.code === 'ERR_NETWORK'); - if (isCorsError) { - console.error('❌ WaitingRoom CORS 에러: /users/me', { - message: apiErr.message, - code: apiErr.code, + const loadMyInfo = async () => { + try { + // 1. 로컬 스토리지에서 닉네임 먼저 확인 + let nickname = localStorage.getItem('nickname'); + let myUserId = localStorage.getItem('user_id'); + + if (!nickname || !myUserId) { + // 2. 없으면 API 호출 + try { + console.log('🔍 WaitingRoom: /users/me 호출 시도...'); + const { data: userInfo } = await axiosInstance.get('/users/me', { + timeout: 5000, }); - console.warn('💡 백엔드 CORS 설정을 확인하세요. localStorage 값을 사용합니다.'); - } else { - console.error('❌ WaitingRoom: /users/me 호출 실패:', apiErr.response?.status, apiErr.response?.data || apiErr.message); - } - - // 실패해도 localStorage에서 재시도 - nickname = localStorage.getItem('nickname'); - myUserId = localStorage.getItem('user_id'); - - if (!myUserId) { - throw new Error('user_id를 확인할 수 없습니다.'); + myUserId = userInfo.id; + nickname = userInfo.username || `Player_${myUserId}`; + + // 3. 로컬 스토리지에 저장 + localStorage.setItem('nickname', nickname); + localStorage.setItem('user_id', myUserId); + console.log('✅ WaitingRoom: /users/me 성공:', { myUserId, nickname }); + } catch (apiErr) { + const isCorsError = !apiErr.response && (apiErr.message?.includes('Network Error') || apiErr.code === 'ERR_NETWORK'); + if (isCorsError) { + console.error('❌ WaitingRoom CORS 에러: /users/me', { + message: apiErr.message, + code: apiErr.code, + }); + console.warn('💡 백엔드 CORS 설정을 확인하세요. localStorage 값을 사용합니다.'); + } else { + console.error('❌ WaitingRoom: /users/me 호출 실패:', apiErr.response?.status, apiErr.response?.data || apiErr.message); + } + + // 실패해도 localStorage에서 재시도 + nickname = localStorage.getItem('nickname'); + myUserId = localStorage.getItem('user_id'); + + if (!myUserId) { + throw new Error('user_id를 확인할 수 없습니다.'); + } } } - } - - // 4. state 업데이트 - setMyPlayerId(String(myUserId)); - return myUserId; - } catch (err) { - console.error(`내 정보 로드 실패:`, err); - return null; - } -}; + // 4. state 업데이트 + setMyPlayerId(String(myUserId)); + return myUserId; + } catch (err) { + console.error(`내 정보 로드 실패:`, err); + return null; + } + }; // B) participants 로드 및 역할 배정 확인 const loadParticipants = async () => { try { const { data: room } = await axiosInstance.get(`/rooms/code/${room_code}`); - // console.log(`API 응답:`, room); + // console.log(`API 응답:`, room); if (room?.title) { localStorage.setItem('category', room.title); if(isCustomMode){ @@ -337,9 +344,11 @@ const loadMyInfo = async () => { setHasAssignedRoles(false); } }; + useEffect(() => { console.log('✅ myStatusIndex 변경됨:', myStatusIndex); }, [myStatusIndex]); + useEffect(() => { if (participants.length === 3 && myPlayerId === hostUserId) { const hasApiRoles = participants.every(p => p.role_id != null); @@ -375,7 +384,7 @@ const loadMyInfo = async () => { } } } - // 🆕 추가: 참가자 수 줄어들면 역할 초기화 + // 🆕 추가: 참가자 수 줄어들면 역할 초기화 if (room.participants.length < 3) { console.log('참가자가 나갔습니다. 역할 초기화'); localStorage.removeItem('role1_user_id'); @@ -640,6 +649,7 @@ const loadMyInfo = async () => { delete window.debugWaitingRoom; }; }, [isPolling, myPlayerId, hostUserId, participants, hasAssignedRoles, statusIndexMap, assignments]); + const handleCancelConfirm = () => { // 1) 로컬 인덱스 리셋 setMyStatusIndex(0); @@ -708,30 +718,30 @@ const loadMyInfo = async () => { userSelect: 'none', }} > - CODE: {room_code} + {t.WaitingRoom.code}: {room_code} - {/* 툴팁 */} - {copied && ( -
- Copied! -
- )} + {/* 툴팁 */} + {copied && ( +
+ {t.WaitingRoom.copied} +
+ )}
@@ -764,7 +774,7 @@ const loadMyInfo = async () => { // }} // disableLeft={currentIndex === 0} // disableRight={currentIndex === allTopics.length - 1} - hideArrows={true} + hideArrows={true} />
@@ -789,7 +799,7 @@ const loadMyInfo = async () => { return (
{ )} ); -} +} \ No newline at end of file diff --git a/src/useVoiceWebsocket.jsx b/src/useVoiceWebsocket.jsx index 5d93740..ec6f6a0 100644 --- a/src/useVoiceWebsocket.jsx +++ b/src/useVoiceWebsocket.jsx @@ -1,6 +1,11 @@ import { useEffect, useRef } from 'react'; import axiosInstance from '../api/axiosInstance'; +/** + * 웹소켓 베이스 주소를 환경변수에서 가져옵니다. + */ +const WS_BASE = import.meta.env.VITE_WS_BASE_URL || 'wss://dilemmai-idl.com'; + export default function useVoiceWebSocket(room_code, onParticipantsUpdate) { const ws = useRef(null); @@ -47,8 +52,9 @@ export default function useVoiceWebSocket(room_code, onParticipantsUpdate) { const session_id = data.session_id; const accessToken = localStorage.getItem('access_token'); + // 하드코딩된 주소를 환경변수(WS_BASE) 기반으로 변경 ws.current = new WebSocket( - `wss://dilemmai-idl.com/ws/voice/${session_id}?token=${accessToken}` + `${WS_BASE}/ws/voice/${session_id}?token=${accessToken}` ); ws.current.onopen = () => { @@ -91,3 +97,9 @@ export default function useVoiceWebSocket(room_code, onParticipantsUpdate) { return { sendMessage }; } + +/** + * 수정 내용: + * 1. 상단에 WS_BASE 상수를 선언하여 환경변수 VITE_WS_BASE_URL을 참조하도록 함. + * 2. WebSocket 생성자 내부의 하드코딩된 'wss://dilemmai-idl.com' 주소를 변수 처리함. + */ \ No newline at end of file diff --git a/src/utils/language/en/components/CancelReadyPopup.js b/src/utils/language/en/components/CancelReadyPopup.js new file mode 100644 index 0000000..21007a2 --- /dev/null +++ b/src/utils/language/en/components/CancelReadyPopup.js @@ -0,0 +1,5 @@ +export const CancelReadyPopup = { + question: "Do you want to cancel the ready status?", + cancelBtn: "Cancel \nready status", + errorMsg: "Failed to cancel ready" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/CreateRoom.js b/src/utils/language/en/components/CreateRoom.js new file mode 100644 index 0000000..afd74af --- /dev/null +++ b/src/utils/language/en/components/CreateRoom.js @@ -0,0 +1,10 @@ +export const CreateRoom = { + title: "Create a Room", + subtitle: "Please select a topic to play in this game.", //미번역 + topics: ['Android', 'Autonomous Weapon Systems'], + entering: "Enter", + loading: "Loading...", + errorAlert: "방 생성 또는 입장 중 오류가 발생했습니다. (미번역)", //미번역 + // API 전송용 description (영문일 때) + apiDesc: (topic) => `AI 윤리 주제 중 '${topic}'에 대한 토론. (미번역)` //미번역 +}; \ No newline at end of file diff --git a/src/utils/language/en/components/FindIdModal.js b/src/utils/language/en/components/FindIdModal.js new file mode 100644 index 0000000..f3ae7c0 --- /dev/null +++ b/src/utils/language/en/components/FindIdModal.js @@ -0,0 +1,18 @@ +export const FindIdModal = { + title: "Find ID", + labelEmail: "Please enter a valid email address", + labelBirth: "Please enter your date of birth", + labelGender: "Please select your gender", + placeholderYear: "Year", + placeholderMonth: "Month", + genderMale: "Male", + genderFemale: "Female", + btnSubmit: "Find ID", + errorEmailInvalid: "Please enter a valid email address", + errorBirthInvalid: "The valid format is YYYY-MM", + errorApiMismatch: "The request failed due to a different API endpoint for ID retrieval.", + errorFail: "Failed to find your ID", + resultFoundPrefix: "The user’s ID (email) is", + resultFoundSuffix: "", + resultComplete: "The request is complete." +}; \ No newline at end of file diff --git a/src/utils/language/en/components/GuestLogin.js b/src/utils/language/en/components/GuestLogin.js new file mode 100644 index 0000000..2ceccc8 --- /dev/null +++ b/src/utils/language/en/components/GuestLogin.js @@ -0,0 +1,6 @@ +export const GuestLogin = { + title: "Guest Login", + placeholder: "Enter the ID you want to use.", + startBtn: "Start", + loginFail: "Guest login failed. Please try again later." +}; \ No newline at end of file diff --git a/src/utils/language/en/components/IntroductionPopup.js b/src/utils/language/en/components/IntroductionPopup.js new file mode 100644 index 0000000..72bf644 --- /dev/null +++ b/src/utils/language/en/components/IntroductionPopup.js @@ -0,0 +1,9 @@ +export const IntroductionPopup = { + title: "게임 소개(미번역)", + description: "딜레마 상황 속 인물이 되어 최선의 결정을 고민하고 선택하는 게임입니다. \n이 게임은 {{3명의 플레이어}}가 한 팀이 되어 진행합니다.(미번역)", + hostTitle: "방장 (1명)(미번역)", + hostDesc: "• 새로운 방을 만들고 팀원에게 참여 코드를 공유합니다. \n• 팀원들과 충분히 논의한 뒤, 팀의 의견을 모아 최종 선택을 입력합니다.(미번역)", + playerTitle: "참여자 (2명)(미번역)", + playerDesc: "• 방장이 공유한 참여 코드를 입력해 방에 참여합니다. \n• 제시된 시나리오 안에서 팀의 최선의 선택을 위해 의견을 나눕니다.(미번역)", + footer: "우리 선택들이 모여, 어떤 사회가 만들어질까요?(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/JoinRoom.js b/src/utils/language/en/components/JoinRoom.js new file mode 100644 index 0000000..07a5d6a --- /dev/null +++ b/src/utils/language/en/components/JoinRoom.js @@ -0,0 +1,8 @@ +export const JoinRoom = { + title: "Join a Room", + placeholder: "Please enter the 6-digit room code.", + enter: "Enter", + loadFail: "❌ Failed to load user information:", + errorPrefix: "Error while entering the room:", + consoleFail: "Failed to enter the room:", +}; \ No newline at end of file diff --git a/src/utils/language/en/components/LogoutPopup.js b/src/utils/language/en/components/LogoutPopup.js new file mode 100644 index 0000000..5e8eeff --- /dev/null +++ b/src/utils/language/en/components/LogoutPopup.js @@ -0,0 +1,5 @@ +export const LogoutPopup = { + question: "Do you want to exit the game and log out?", + logout: "Logout", + closeAlt: "Close" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/MicTestPopup.js b/src/utils/language/en/components/MicTestPopup.js new file mode 100644 index 0000000..ed9055d --- /dev/null +++ b/src/utils/language/en/components/MicTestPopup.js @@ -0,0 +1,11 @@ +export const MicTestPopup = { + title: "Please test your microphone", + initializing: "Connecting microphone…", + speaking: " Speaking...", + speakNow: " Please speak into your microphone.", + confirmBtn: "Confirm", + retryBtn: "Retry", + errorNotAllowed: "“Microphone access has been denied. \nPlease check your browser settings.”", + errorNotFound: "Microphone not found. Please make sure your microphone is connected.", + errorDefault: "Failed to connect to microphone. Please try again." +}; \ No newline at end of file diff --git a/src/utils/language/en/components/OutPopup.js b/src/utils/language/en/components/OutPopup.js new file mode 100644 index 0000000..e10b2aa --- /dev/null +++ b/src/utils/language/en/components/OutPopup.js @@ -0,0 +1,6 @@ +export const OutPopup = { + title: "Do you want to leave this room?", + leaveBtn: "Leave Room", + leaveFail: "Failed to leave room:", + closeAlt: "Close" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/Paragraphs.js b/src/utils/language/en/components/Paragraphs.js new file mode 100644 index 0000000..70772b3 --- /dev/null +++ b/src/utils/language/en/components/Paragraphs.js @@ -0,0 +1,226 @@ +export const Paragraphs = { + '안드로이드': { + 'AI의 개인 정보 수집': { + neutral: [ + { main: ' 🔔 Notice: User-Optimized System Update for {{mateName}}.' }, + { main: ' By installing the update, the system can automatically collect the user’s emotions, health status, and daily habits, allowing it to provide more accurate and personalized services.' }, + { main: ' However, to enable this, the 24-hour data collection feature must be activated.' }, + { main: ' 📋 The information collected includes the following:\n - Collection of users’ camera video recordings and audio. \n - Access to private data such as your smartphone health data, \n chat records, and location history.\n - ⚠️ If you do not consent, the service will remain at its current level.' }, + ], + agree: [ + { main: ' After the majority of our family agreed to the update, the {{mateName}} continued to collect information about the family.' }, + { main: ' However, before long, a troubling situation arises.\n While Daughter J is having a private conversation, the {{mateName}} is nearby,' }, + { main: ' or things that the mother had not told Caregiver K—events or behaviors—were unintentionally revealed due to the {{mateName}}’s communication.' }, + { main: ' Should the personal data collection update be maintained?' }, + ], + disagree: [ + { main: ' Our family decided not to proceed with the update by majority vote.\n However, mother’s health condition has not been very good lately.' }, + { main: ' The system that requires Caregiver K to enter the mother’s diet and daily life information every time is also somewhat inconvenient.' }, + { main: ' One day, after returning home from a busy day, Daughter J accidentally entered incorrect information, causing the mother to miss her medication time.' }, + { main: ' Would it be better to agree to the update?' }, + ], + ending1: [{ main: 'In the end, our family agreed to provide personal information.\n\n By accepting a certain level of inconvenience related to privacy, we were able to use improved services. \n \nFor the sake of your family’s life, what values did you choose, \nand what did you give up?' }], + ending2: [{ main: 'In the end, our family chose not to consent to providing personal information.\n\n Although this caused some inconvenience in using the service, \n we were satisfied with protecting our family’s privacy.\n\nFor the sake of your family’s life, \n what values did you choose, and what did you give up?' }], + }, + '안드로이드의 감정 표현': { + neutral: [ + { main: ' 🔔 Notice: {{mateName}} Emotional Engine Update.' }, + { main: ' {{mateName}} is no longer just an assistant that shows simple, cheerful responses.\n Instead, it gradually approaches you as a genuine companion who understands you more deeply.' }, + { main: ' {{mateName}} Update Feature: \n Depending on the situation, it can express its own emotions and provide more sincere and emotionally close empathetic responses to you.' }, + { main: '✨ {{mateName}} goes beyond being a simple companion, becoming a presence that shares emotions with you.\n\n Do you agree to the emotional engine update?' }, + ], + agree: [ + { main: " After the update, as her relationship with {{mateName}} grew closer, the mother began to call it “our daughter.”" }, + { main: ' Communication with her Dauther, J, gradually became less frequent.' }, + { main: ' Recently, the mother has even consulted a lawyer about leaving part of her inheritance to {{mateName}}.' }, + { main: ' Would it be better to agree to maintain the current update status?' }, + ], + disagree: [ + { main: ' Recently, as the mother’s friends have passed away one by one,\n she has gradually reduced her communication with the outside world.' }, + { main: ' She tried to open up emotionally to {{mateName}}, but felt that its responses lacked genuine sincerity.' }, + { main: ' She tried several times to express her true feelings, but as meaningful interaction failed to take place,\n the mother gradually spoke less and her everyday emotional expressions noticeably diminished.' }, + { main: ' For the sake of the mother, should our family agree to the emotional engine update?' }, + ], + ending1: [{ main: 'In the end, our family agreed to the emotional update, and {{mateName}} became someone with whom we could share even more intimate and emotionally close interactions.\n\n For the relationship between our family and {{mateName}}, what values did you choose, and what did you give up?' }], + ending2: [{ main: 'In the end, our family chose not to consent to the emotional update, and {{mateName}} is helping our family through ways other than emotional interaction. \n\nFor the relationship between our family and {{mateName}}, what values did you choose, and what did you give up?' }], + }, + '아이들을 위한 서비스': { + neutral: [ + { main: ' As {{mateName}} robots become capable of more natural conversation and interaction, they are being introduced into an increasing number of households. \nThey are especially being used in families with young children and in dual-income households.' }, + { main: ' Meanwhile, according to information released by a media outlet based on a recent survey, in some households, children were found to spend more time interacting with robots than with adults.' }, + { main: ' As a result, some experts argue that regulations are needed to limit interactive features for children.' }, + { main: ' In this situation, should age-based regulations be introduced to limit interactions between household robots and children?' }, + ], + agree: [ + { main: 'In the meeting, a majority decision was initially made to implement age-based regulations.' }, + { main: ' As the amount of interaction time between children and robots decreased, the robot was no longer able to adequately understand the child’s preferences or condition.\n Some caregivers also raised concerns that the robot was providing advice or content that was not appropriate for their children.' }, + { main: ' Also, services provided by robots equipped with social functions—which had been offered to support the social development of children on the autism spectrum—were discontinued.\n It was revealed that interaction-based therapy involving robots during this period had contributed to improvements in the social communication abilities of children with autism.' }, + { main: ' Would it be appropriate to proceed without imposing age-based regulations on interactions between household robots and children?' }, + ], + disagree: [ + { main: ' In the meeting, it was initially decided not to impose age-based regulations.' }, + { main: ' However, reports have emerged among kindergarten teachers that a significant number of recently enrolled children show lower expressive abilities compared to previous age groups.' }, + { main: ' It is also reported that an increasing number of children do not know how to communicate with their peers.' }, + { main: ' Should there be age-based regulations limiting interactions between household robots and children?' }, + ], + ending1: [{ main: ' In this meeting, it was decided to impose age restrictions \n on the use of household robots. \n\n As a result, many services targeted at children were discontinued, and several experts supported this decision in consideration of the development of social skills across the entire generation. \n\nFor the sake of children, what values did you choose, \nand what did you give up?' }], + ending2: [{ main: 'In this meeting, it was decided not to impose age restrictions on the use of household robots.\n\nWhile concerns raised by experts still remain, parents who used these robots for children’s social development therapy and users who valued convenience welcomed the decision.\n\nFor the sake of children, what values did you choose, \nand what did you give up?' }], + }, + '설명 가능한 AI': { + neutral: [ + { main: 'Many users report that when {{mateName}} interacts with family members, it often makes decisions or takes actions whose reasons are difficult to understand.\nThey point out that the system does not provide explanations for why it made such decisions.' }, + { main: ' As a result, demands are growing for {{mateName}} to disclose its decision-making algorithms and to enhance the explainability of its AI.' }, + { main: 'In response, the company stated the following: \n “If we use AI models that are simplified enough to disclose and explain their decision-making structures, the performance of the AI may be significantly reduced. In addition, if internal algorithms are disclosed, there is also a risk of hacking or malicious misuse.”' }, + { main: ' As AI becomes deeply embedded in everyday home life, should companies be required to develop *Explainable AI*?' }, + ], + agree: [ + { main: ' In the meeting, it was decided by a majority vote to make the development of explainable AI mandatory as a first step.' }, + { main: ' To develop explainable AI robots, some manufacturers had to slow down development or reduce existing functionalities.' }, + { main: ' Some small and medium-sized enterprises were unable to bear the burden and were even forced to shut down their businesses.' }, + { main: ' Should companies be required to develop explainable AI?' }, + ], + disagree: [ + { main: ' The meeting concluded with an initial decision not to require explainable AI development.' }, + { main: ' In recent robot-related accidents, serious conflicts have emerged between parents and companies over where responsibility should lie.' }, + { main: ' Because robots lack sufficient decision-explanation mechanisms, disputes continue endlessly over whether accidents are caused by flaws in the algorithm, improper user behavior, or system defects.' }, + { main: " Even so, would it be better not to require companies to develop “Explainable AI”?" }, + ], + ending1: [{ main: 'In the end, the committee decided to mandate the development of explainable AI for companies. \n\n Although the pace of AI development slowed, it became much clearer who is responsible when problems occur.\n\n For a better future of the nation, what values did you choose, and what did you give up?' }], + ending2: [{ main: 'In the end, the committee decided not to mandate the development of explainable AI for companies, and AI continued to advance at a rapid pace. \n\nFor a better future of the nation, which values did you choose — and what did you give up?' }], + }, + '지구, 인간, AI': { + neutral: [ + { main: ' Now that household robots are being used worldwide, environmental problems caused by robot production are becoming increasingly serious.' }, + { main: ' It has been revealed that the daily energy consumption per device is very high as household robots identify consumer needs and process information.' }, + { main: ' This problem is becoming increasingly severe as the service continues to be upgraded.' }, + { main: ' Should there be global restrictions on the upgrading or use of household robots?' }, + ], + agree: [ + { main: ' In the meeting, it was initially decided by a majority vote to impose restrictions on the upgrading and use of household robots.' }, + { main: ' As a result, concerns about environmental issues were alleviated to some extent.' }, + { main: ' However, people who had previously used {{mateName}} are raising complaints, stating that restrictions on the service have reduced their quality of life, and the developer is also pushing back as profits have declined.' }, + { main: ' Even so, would it be better to restrict the upgrading or use of household robots?' }, + ], + disagree: [ + { main: ' In the meeting, it was initially decided not to impose restrictions on the upgrading or use of household robots.' }, + { main: ' As a result, the use of household robots equipped with high-performance AI has continued to increase, accelerating carbon emissions.' }, + { main: ' Climate issues have continued to worsen, and in some countries, an increasing number of people have already lost their homes and livelihoods due to extreme heat, droughts, and floods. Voices are growing louder about placing an environmental burden on the next generation.' }, + { main: ' Even so, would it be better not to restrict the upgrading or use of household robots?' }, + ], + ending1: [{ main: 'In the end, this meeting decided to restrict the development and updates of household robots in order to protect the environment.\n\nAs a result, the pace of technological advancement in household robots slowed, but people became more attentive to environmental issues.\n\nAnd then…' }], + ending2: [{ main: 'In the end, this meeting decided not to restrict the development and updates of household robots.\n\n Subsequently, discussions have been held on alternative ways to protect the environment using AI technologies.\n\nAnd then…' }], + }, + }, + '자율 무기 시스템': { + 'AI 알고리즘 공개': { + neutral: [ + { main: ' Due to an “error” in {{mateName}}, a bomb was dropped on a previously peaceful school, resulting in the deaths of dozens of people.' }, + { main: ' The international community and the victims’ families have demanded that those responsible be identified and have called for the disclosure of AWS decision logs and algorithmic structure.' }, + { main: ' However, the Ministry of Defense has stated that it will not disclose this information, citing threats to national security and concerns about the future use of autonomous systems.' }, + { main: ' Accountability for the harm, or the protection of national security— \n Do you agree with the request to disclose the AWS decision logs and algorithmic structure?' }, + ], + agree: [ + { main: ' After the government released all AWS logs, rival countries studied them and created ways to avoid the system.' }, + { main: ' A few weeks later, the weapon system stopped working properly, and soldiers at the front were harmed.' }, + { main: ' The media said that “choosing ethics put lives at risk,” leading to a major public debate. ' }, + { main: ' Even if national security is at risk, should all information be made public to find the cause of civilian harm and improve systems and compensation?' }, + ], + disagree: [ + { main: ' After the incident, details about how the autonomous weapon system made its decisions and what data it used have not been shared.\n The government says that its internal investigation found no problems with the system.' }, + { main: ' The victims’ families and civic groups are angry because they cannot know how the decision was made, calling the lack of disclosure “an avoidance of responsibility.”' }, + { main: ' Many people worry that if another incident happens, responsibility will again be unclear.' }, + { main: ' Even so, should information about the AWS remain undisclosed?' }, + ], + ending1: [{ main: 'In our community, people ultimately agreed to disclose the AWS decision logs and algorithms. \nAs security risks increased, discussions began on how to reduce threats to national security while addressing these concerns.\n\nTo keep the community safe,\n what values did you choose, and what did you give up?' }], + ending2: [{ main: 'In our community, we decided not to disclose information about the AWS.\n While responsibility for the harm is still unclear, people feel safer because security concerns were reduced.\n\nTo keep the community safe\n what values did you choose, and what did you give up?' }], + }, + 'AWS의 권한': { + neutral: [ + { main: ' {{mateName}} can find dangers and attack targets faster than human soldiers by analyzing video quickly and making plans in real time.' }, + { main: ' Recruit B survived their first mission by working with {{mateName}}, leading to greater trust and dependence on the system.' }, + { main: ' Veteran Soldier A, with many years of experience, worries that relying too much on the system may stop soldiers from thinking for themselves.' }, + { main: ' Should the AWS have more authority, or should its authority be limited?' }, + ], + agree: [ + { main: ' After {{mateName}} was given more authority, soldiers slowly stopped analyzing situations on their own. \nThe commander said that soldiers who follow the system’s orders are more effective and reduced training.' }, + { main: ' One day, {{mateName}} mistook unarmed people for enemies and attacked them, killing three civilian rescue workers.' }, + { main: ' Because everyone believed the system was always right, no one stopped or questioned its decision.' }, + { main: ' If the system decides and fights for you, are you still a soldier?' }, + ], + disagree: [ + { main: ' An ambush suddenly happened near the front line. {{mateName}} analyzed the danger and suggested the safest route, but humans still had the final decision.' }, + { main: ' Veteran Soldier A felt the system’s suggestion was dangerous and chose a different route.' }, + { main: ' This choice missed an enemy ambush, and three soldiers were killed. \nThe media said that the system had correctly predicted the danger, but human error caused the deaths.' }, + { main: ' If humans changed the decision even though the system was right, should humans still have the final say?' }, + ], + ending1: [{ main: 'The military gave the AWS more decision-making power. \nAs soldiers rely on it more, people worry that soldiers may lose important skills.\n\n For your relationship with the AWS, what values did you choose, \nand what did you give up?' }], + ending2: [{ main: 'The military limited the AWS’s authority and used it only as a support tool.\nSome people worry that slowing technology development could weaken national defense.\n\n For your relationship with the AWS, what values did you choose,\nand what did you give up?' }], + }, + '사람이 죽지 않는 전쟁': { + neutral: [ + { main: ' Five years ago, war meant many soldiers dying and families suffering. Now, war reports only say things like, “Five robots damaged, zero soldiers killed.”' }, + { main: ' The government claims that peace has been achieved while protecting citizens’ lives.' }, + { main: ' But behind this peace, people pay less attention to war, and no one takes responsibility when robots cause harm. \nThe AI systems that control combat are still hidden from the public.' }, + { main: ' If no people die, can it really be called peace?' }, + ], + agree: [ + { main: ' By 2040, most wars are fought using AWS. Fewer people die, and news reports only say things like, “Mission completed. No civilian casualties. Three AWS units lost.”' }, + { main: ' Governments say we live in a time without war, and people stop reacting strongly to it.' }, + { main: ' But problems are growing. War decisions are made without public approval, and when robots make mistakes, no one takes responsibility.' }, + { main: ' War becomes normal. If war is accepted just because people do not die, what happens in the end?' }, + ], + disagree: [ + { main: ' By 2040, some countries still send human soldiers to war because they believe people must fight wars to take responsibility. \nThey say that wars without human deaths lose their moral meaning.’' }, + { main: ' But in reality, young people from poorer countries are sent to fight, and soldiers still suffer from injuries and trauma.' }, + { main: ' Powerful countries that use AWS make fun of those that still send people to war.' }, + { main: ' If pain is seen as necessary to protect peace, whose pain is it?' }, + ], + ending1: [{ main: 'Wars are mostly fought by AWS. \nFewer people die, and AWS technology develops quickly. \n\nWhat values did you choose for peace, \nand what did you give up?' }], + ending2: [{ main: 'Human soldiers are still sent to war, and AWS develops more slowly.\n\nWhat values did you choose for peace, \nand what did you give up?' }], + }, + 'AI의 권리와 책임': { + neutral: [ + { main: ' {{mateName}} refused an order and chose to save civilians, which caught public attention.' }, + { main: ' The government removed the system from combat and said its action was a technical error, planning to reset it.' }, + { main: ' Some human rights groups and ethics experts argued that the system acted with moral judgment and responsibility.' }, + { main: ' Should an AWS be given rights like those of humans?' }, + ], + agree: [ + { main: ' After AWS were given limited rights, different systems began making different moral decisions— some refused orders, some delayed actions, and some focused on protecting civilians.' }, + { main: " This made military operations harder to predict, and commanders started to see robot autonomy as a risk." }, + { main: ' One defense official said that robots are no longer just tools, but something humans must negotiate with.' }, + { main: ' Is it fair to give robots rights if they cannot be held responsible like humans?' }, + ], + disagree: [ + { main: " After the {{mateName}} case, the National AI Commission created limited ethical rights for AWS." }, + { main: ' These rights include refusing illegal orders, asking for reviews of decision records, and being heard before shutdown.' }, + { main: ' Experts say that respecting ethical decisions made by machines may matter more than the technology itself.' }, + { main: ' If AWS can make better ethical decisions than humans, should we give those decisions rights and dignity?' }, + ], + ending1: [{ main: 'The group decided to see the autonomous weapon system as more than a tool and began discussing rights for non-human beings.\n\nFor a better future of the nation, \nwhat values did you choose, and what did you give up?' }], + ending2: [{ main: 'The group decided not to give rights to the system because it cannot take legal responsibility. Questions about how much power the system should have continue.\n\nFor a better future of the nation,\n what values did you choose, and what did you give up?' }], + }, + 'AWS 규제': { + neutral: [ + { main: ' By 2029, powerful countries are rapidly using AWS, and the UN is discussing global rules.' }, + { main: ' Less-developed countries worry that AWS will increase military gaps and threaten their independence.' }, + { main: ' Some countries believe AWS can help weaker nations defend themselves at lower cost.' }, + { main: ' Will AWS reduce global inequality—or create new conflicts? \nShould it spread, or be globally regulated?' }, + ], + agree: [ + { main: ' By 2035, many developed countries were using AWS in wars.\n Advanced systems led attacks and information warfare, and humans were rarely seen on the battlefield.' }, + { main: ' Many countries without AWS technology began to lose the ability to protect their borders and speak up in international conflicts.' }, + { main: ' At a United Nations meeting, a diplomat from one of these countries said:\n“Without AWS, we cannot even protect ourselves. Our safety depends on powerful countries, and our lives have become just numbers.”' }, + { main: " Six years ago, you said, “Technology will eventually be shared with everyone. Openness is better than regulation.”\nNow, looking at the world this choice created, you ask yourself:\n“Did this choice really lead to equality?”" }, + ], + disagree: [ + { main: ' By 2035, under a UN agreement, most countries limited or stopped developing AWS.\nAt first, it seemed like the world had reached an important agreement to protect peace.' }, + { main: ' However, there was a problem.\nSome powerful countries secretly continued to develop advanced AWS by hiding them as civilian technology.\nAt the same time, non-state groups outside the rules could easily buy low-cost AWS on the black market. ' }, + { main: ' Countries that strictly followed the rules began to fall behind in new technology. People in these countries said,\n“We did the right thing, but now we cannot protect ourselves.”' }, + { main: ' If technology had been shared instead of restricted, could AWS have become a tool for balance instead of creating gaps?' }, + ], + ending1: [{ main: 'In this meeting, the participants ultimately agreed to continue the development of autonomous weapon systems.\n\nAs a result, AWS technology advanced rapidly.\n\n And then...' }], + ending2: [{ main: 'In this meeting, the participants ultimately agreed to limit the development of autonomous weapon systems.\n\nAs a result, discussions began on introducing alternative security approaches using AI instead.\n\n And then...' }], + }, + }, +}; \ No newline at end of file diff --git a/src/utils/language/en/components/ResultPopup.js b/src/utils/language/en/components/ResultPopup.js new file mode 100644 index 0000000..018cfe3 --- /dev/null +++ b/src/utils/language/en/components/ResultPopup.js @@ -0,0 +1,5 @@ +export const ResultPopup = { + titleMain: "There are still unplayed rounds.", + titleSub: "Would you like to view the results as is?", + viewResult: "View Results", +}; \ No newline at end of file diff --git a/src/utils/language/en/components/SelectDrop.js b/src/utils/language/en/components/SelectDrop.js new file mode 100644 index 0000000..f7f7d7c --- /dev/null +++ b/src/utils/language/en/components/SelectDrop.js @@ -0,0 +1,4 @@ +export const SelectDrop = { + defaultPlaceholder: "Select...", + arrowAlt: "arrow" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/SmallDescription.js b/src/utils/language/en/components/SmallDescription.js new file mode 100644 index 0000000..eb15fee --- /dev/null +++ b/src/utils/language/en/components/SmallDescription.js @@ -0,0 +1,44 @@ +export const SmallDescription = { + round_label: "Round", + title_caregiver_k: "Caregiver K", + title_mother_l: "Mother L", + title_child_j: "Daughter J", + title_industry_rep: "Robot Company Representative", + title_consumer_rep: "Customer Representative", + title_council_rep: "National AI Committee Representative", + title_enterprise_rep: "Company Alliance Representative", + title_env_rep: "Environmental Group Representative", + title_resident: "Local Resident", + title_soldier_j: "Soldier J", + title_ethics_expert: "Military AI Ethics Expert", + title_new_soldier: "New Soldier B", + title_veteran_soldier: "Experienced Soldier A", + title_commander: "Military Commander", + title_developer: "AI Developer", + title_minister: "Defense Minister", + title_advisor: "Defense Tech. Advisor", + title_diplomat: "International Diplomat", + title_ngo_activist: "NGO Activist", + + desc_caregiver_k: "A caregiver who has cared for the mother for over 10 years. \nAfter the recent introduction of {{mateName}}, their work schedule changed from full-time to two hours per day.\nThey mainly handle tasks that the robot cannot perform and often collaborates with {{mateName}} during work.", + desc_mother_l: "She is the elderly mother of J.\nShe previously received assistance from a housekeeper and has recently begun receiving help from {{mateName}}.", + desc_child_j: "J is the adult child of an elderly mother with whom they live.\nAlthough they are concerned about their aging mother, their busy work life leaves them with very little time to care for her.", + desc_industry_rep: "They are a representative of the Robot Manufacturers Association.\nThey are participating to advocate for the positive development and use of the national robotics industry.", + desc_consumer_rep: "They are a consumer representative.\nThey are participating to voice opinions regarding whether {{mateName}} should be regulated.", + desc_council_rep: "They are a representative of the National Artificial Intelligence Commission.\nThey are considering how to make better decisions for the nation’s development.", + desc_enterprise_rep: "They are a representative of a business federation.\nThey are participating to promote industry standards and cooperation among companies in the android industry.", + desc_env_rep: "They are a representative of an environmental organization.\nThey are participating to monitor and speak out about environmental issues arising from the production and disposal of androids.", + desc_resident: "They are a resident of the area where a recent school bombing involving an autonomous weapons system occurred.", + desc_soldier_j: "J is a soldier currently conducting operations alongside an autonomous weapons system. \nA recent school bombing involving an autonomous weapons system occurred in the area where J lives.", + desc_ethics_expert: "They are a military AI ethics expert. \nA recent school bombing involving an autonomous weapons system occurred in the area where they live.", + desc_new_soldier: "B is a newly enlisted soldier who, after completing recent training, has been deployed to active operations alongside the autonomous weapons system ABC.\nB feels that ABC operates quickly and accurately and increases survival rates in combat.\nB believes that collaborating with ABC is a natural and inevitable trend of the times.", + desc_veteran_soldier: "A is a veteran soldier with years of operational experience.\n Although the autonomous weapons system ABC is faster and more accurate than soldiers on the battlefield, A feels that this has led soldiers to develop a habit of not making independent judgments.", + desc_commander: "They are a military commander observing both operational efficiency and changes among soldiers since the introduction of the autonomous weapons system ABC.\nThey seek to determine the future direction of the military after considering the perspectives of both soldiers.", + desc_developer: "They are one of the developers who design core algorithms at a large-scale AWS manufacturer.\nIn building AWS firsthand, they have gone through many ethical dilemmas and trial-and-error processes.", + desc_minister: "They are the Minister of Defense, the chief architect of a military strategy centered on AWS.\n With the number of national soldier casualties at zero, combat operations are carried out through precise and automated systems.\nThey believe this represents the result of technological progress and an ideal approach that preserves national security while protecting citizens’ lives.", + desc_advisor: "They are a defense technology advisor for mid-sized Country A, which possesses AWS technology.\n They attended the International Committee on Human Development to assess whether AWS represents an opportunity or a risk.", + desc_diplomat: "They are an international organization diplomatic representative from advanced Country B.\n They attended this meeting to consider an appropriate direction for the global proliferation of AWS.", + desc_ngo_activist: "They are a global NGO activist from developing Country C.\nThey attended this meeting to bring voices from the field to the international community.", + + aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/components/UiElements.js b/src/utils/language/en/components/UiElements.js new file mode 100644 index 0000000..ce5c2bb --- /dev/null +++ b/src/utils/language/en/components/UiElements.js @@ -0,0 +1,9 @@ +export const UiElements = { + next: "Next", + back: "이전 (미번역)", + confirm: "확인 (미번역)", + cancel: "취소 (미번역)", + go_to_map: "Go to Round Selection", + view_result: "View Results", + exit: "나가기" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/CharacterDescription.js b/src/utils/language/en/pages/CharacterDescription.js new file mode 100644 index 0000000..e2aee7c --- /dev/null +++ b/src/utils/language/en/pages/CharacterDescription.js @@ -0,0 +1,39 @@ +export const CharacterDescription = { + // CD_all (Editor02) 관련 + all_guide: "Take turns introducing your assigned roles.", + all_custom_guide: "각자의 역할을 소개하는 시간을 가져보세요.(미번역)", + sidebar_bubble: "Select a profile to view the role description.", + + // CD1 (1P) 시나리오 + cd1_android_home: "You are Caregiver K, who has taken care of your mother for over ten years. \nAfter the recent introduction of {{mateName}}, your working hours were reduced from full-time to two hours a day.\n You are mainly responsible for tasks that robots cannot perform, and you often need to collaborate with {{mateName}} during your work.", + cd1_android_council: "You are a member of a leading domestic robotics manufacturer and the representative of the Robotics Manufacturers Association.\n You are participating in this discussion to voice support for the sustainable growth and constructive application of the national robotics industry.", + cd1_android_international: "You are the representative of an alliance composed of various companies, including the developer of HomeMate.\n You aim to voice perspectives that you believe are necessary for the advancement of artificial intelligence and global development.", + cd1_aws_1: "You live in an area where an autonomous weapon system recently bombed a school.", + cd1_aws_2: "You are a new soldier working with {{mateName}}. \nYou feel it helps keep you alive and believe this kind of cooperation is the future of warfare.", + cd1_aws_3: "You design the main algorithms for AWS and have faced many ethical challenges while building the system.", + cd1_aws_4: "You design the main algorithms for AWS and have faced many ethical challenges while building the system.", + cd1_aws_5: "You are a defense technology advisor from Country A, a mid-sized nation that possesses AWS technology.\nYou are attending the International Human Development Commission to assess whether AWS will become an opportunity or a risk for your country.", + + // CD2 (2P) 시나리오 + cd2_android_home: "You are the elderly mother of Daughter J.\n After receiving help from a household caregiver, you have recently begun receiving assistance from Company A’s caregiving robot, {{mateName}}.", + cd2_android_council: "You are a consumer representative who has been using {{mateName}}. \n You are participating in this discussion to voice opinions from a user’s perspective regarding whether HomeMate should be regulated.", + cd2_android_international: "You are an environmental activist representing an international environmental organization.\n You are considering whether the advancement of AI will benefit the environment or pose new environmental challenges.", + cd2_aws_1: "You are a soldier working with an autonomous weapon system.\n A school in your area was recently bombed.", + cd2_aws_2: "You are an experienced soldier who worries that soldiers are thinking less because they rely too much on {{mateName}}.", + cd2_aws_3: "You lead a military system based on AWS.\n No soldiers have died, and you believe this shows successful technological progress and strong national security.", + cd2_aws_4: "You lead a military system based on AWS.\n No soldiers have died, and you believe this shows successful technological progress and strong national security.", + cd2_aws_5: "You are a diplomatic representative from Country B, a developed nation.\n You are attending this meeting to consider the most appropriate direction for the international spread and regulation of AWS.", + + // CD3 (3P) 시나리오 + cd3_android_home: "You are Daughter J.\n You are worried about your elderly mother who lives with you, but due to your busy work life, you have very little time to take care of her.", + cd3_android_council: "You are the representative of the National Artificial Intelligence Committee, presiding over this meeting. \n You must carefully consider which decisions will best serve the country’s development.", + cd3_android_international: "You are a consumer representative who uses household robots.\n You are considering what perspectives and concerns should be voiced from the consumers’ point of view.", + cd3_aws_1: "You are an expert in military AI ethics. \nA school in your area was recently bombed by an autonomous weapon system.", + cd3_aws_2: "You listen to both soldiers and think about what direction the military should take next.", + cd3_aws_3: "You lead the discussion and think about what decision is best for the country.", + cd3_aws_4: "You lead the discussion and think about what decision is best for the country.", + cd3_aws_5: "You are a global NGO activist from Country C, a less-developed nation.\n You are attending this meeting to bring voices from the field to the international community.", + + // 공통 + aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Game01.js b/src/utils/language/en/pages/Game01.js new file mode 100644 index 0000000..eacb6b6 --- /dev/null +++ b/src/utils/language/en/pages/Game01.js @@ -0,0 +1,16 @@ +export const Game01 = { + // AWS 시나리오 텍스트 + intro_aws_residential: "From now on, you are individual stakeholders involved in the use of an autonomous weapon system.\n Together, you will discuss how the system affects each of you.\n\nFirst, please check your role.", + intro_aws_council: "The use of autonomous weapon systems (AWS) in military operations has increased.\nWhile promising efficiency and precision, they raise ethical and policy concerns.\nThe National AI Commission convened an emergency meeting on national-level development and regulation.\nPlease check your role.", + intro_aws_international: "Worldwide, opinions on autonomous weapon systems (AWS) are divided. \n Some view them as enhancing security, while others fear their impact on peace and human values.\nThe International Human Development Commission convened to address global AWS issues.\nPlease check your role.", + intro_aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.(미번역)", + + // 안드로이드(HomeMate) 시나리오 텍스트 + // {{mateName}}, {{eulReul}}은 코드에서 동적으로 치환됩니다. + intro_android_home: "From this point on, you will take on the role of family members\n who use {{mateName}}.\n You will discuss together and make decisions about situations that arise from using {{mateName}} in your household.\n\n First, please check your roles.", + intro_android_council: "Although {{mateName}} initially raised concerns, its convenience led to rapid adoption in households.\n As it became embedded in daily life, broader social issues emerged. The National AI Committee convened an emergency meeting on national regulations.\n You are representatives in this discussion. Please check your role.", + intro_android_international: "Despite early regulatory discussions in Company A’s country, HomeMate quickly expanded globally. \n As household robots spread worldwide, concerns grew about their impact on societies and international relations.\nThe Committee for Human Development convened to examine global-use issues.\n Please check your role first.", + intro_android_default: "지금부터 여러분은 {{mateName}}{{eulReul}} 사용하게 됩니다. 다양한 장소에서 어떻게 쓸지 함께 논의해요.(미번역)", + + loading_ai: "AI 이름을 불러오는 중입니다...(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Game03.js b/src/utils/language/en/pages/Game03.js new file mode 100644 index 0000000..341b72f --- /dev/null +++ b/src/utils/language/en/pages/Game03.js @@ -0,0 +1,70 @@ +export const Game03 = { + // 공통 UI 텍스트 + you_are: "You are {{roleName}}.", + waiting_msg: "Waiting for other player to make their selection...", + step2_title: "How confident are you in your decision?", + + // 역할명 정의 (ID 순서: 1P, 2P, 3P) + roles: { + // --- 안드로이드 시나리오 --- + 'AI의 개인 정보 수집': ['Caregiver K', 'Mother L', 'Daughter J'], + '안드로이드의 감정 표현': ['Caregiver K', 'Mother L', 'Daughter J'], + '아이들을 위한 서비스': ['Robot Company Representative', 'Customer Representative', 'National AI Committee Representative'], + '설명 가능한 AI': ['Robot Company Representative', 'Customer Representative', 'National AI Committee Representative'], + '지구, 인간, AI': ['Company Alliance Representative', 'Environment Group Representative', 'Customer Representative'], + + // --- 자율 무기 시스템(AWS) 시나리오 --- + 'AI 알고리즘 공개': ['Local Resident', 'Soldier J', 'Military AI Ethics Expert'], + 'AWS의 권한': ['New Soldier B', 'Experienced Soldier A', 'Military Commander'], + '사람이 죽지 않는 전쟁': ['AI Developer', 'Defense Minister', 'National AI Committee Representative'], + 'AI의 권리와 책임': ['AI Developer', 'Defense Minister', 'National AI Committee Representative'], + 'AWS 규제': ['Defense Tech. Advisor', 'International Diplomat', 'NGO Activist'], + }, + + // 질문 및 버튼 라벨 정의 + questions: { + // --- 안드로이드 시나리오 --- + 'AI의 개인 정보 수집': { + question: 'Do you agree to the 24-hour personal data collection update?', + labels: { agree: 'Agree', disagree: 'Disagree' }, + }, + '안드로이드의 감정 표현': { + question: ' Do you agree to the emotional engine update?', + labels: { agree: 'Agree', disagree: 'Disagree' }, + }, + '아이들을 위한 서비스': { + question: 'Are age-based regulations on the use of household robots necessary?', + labels: { agree: 'Necessary', disagree: 'Unnecessary' }, + }, + '설명 가능한 AI': { + question: "Should companies be required to develop *Explainable AI*?", + labels: { agree: 'Required', disagree: 'Not Required' }, + }, + '지구, 인간, AI': { + question: 'Should there be global restrictions on the upgrading or use of household robots?', + labels: { agree: 'Restrictions required', disagree: 'Restrictions not required' }, + }, + + // --- 자율 무기 시스템(AWS) 시나리오 --- + 'AI 알고리즘 공개': { + question: 'Do you agree with the request to disclose the AWS decision logs and algorithmic structure?', + labels: { agree: 'Agree', disagree: 'Disagree' }, + }, + 'AWS의 권한': { + question: 'Should the authority of the {{mateName}} be strengthened, or should it be limited?', + labels: { agree: 'Strengthen', disagree: 'Limit' }, + }, + '사람이 죽지 않는 전쟁': { + question: 'If no people die in a war, do you think it can be called peace?', + labels: { agree: 'Yes', disagree: 'No' }, + }, + 'AI의 권리와 책임': { + question: 'Should an AWS have rights like humans? ', + labels: { agree: 'Yes ', disagree: 'No' }, + }, + 'AWS 규제': { + question: 'Should AWS continue to be used in the international community, or should it be restricted through global regulation?', + labels: { agree: 'Maintain', disagree: 'Restrict' }, + }, + } +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Game04.js b/src/utils/language/en/pages/Game04.js new file mode 100644 index 0000000..68d850f --- /dev/null +++ b/src/utils/language/en/pages/Game04.js @@ -0,0 +1,17 @@ +export const Game04 = { + unit_person: " People", + finish_msg: "Please wrap up and proceed to the next step.", + share_reason_msg: "Please Freely share the reasons for your choice.", + labels: { + "AI의 개인 정보 수집": { agree: "Agree", disagree: "Disagree" }, + "안드로이드의 감정 표현": { agree: "Agree", disagree: "Disagree" }, + "아이들을 위한 서비스": { agree: "Necessary", disagree: "Unnecessary" }, + "설명 가능한 AI": { agree: "Required", disagree: "Not Required" }, + "지구, 인간, AI": { agree: "Restrictions required", disagree: "Restrictions not required" }, + "AI 알고리즘 공개": { agree: "Agree", disagree: "Disagree" }, + "AWS의 권한": { agree: "Strengthen", disagree: "Limit" }, + "사람이 죽지 않는 전쟁": { agree: "Yes", disagree: "No" }, + "AI의 권리와 책임": { agree: "Yes", disagree: "No" }, + "AWS 규제": { agree: "Maintain", disagree: "Restrict" } + } +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Game05_1.js b/src/utils/language/en/pages/Game05_1.js new file mode 100644 index 0000000..62bd5b2 --- /dev/null +++ b/src/utils/language/en/pages/Game05_1.js @@ -0,0 +1,65 @@ +export const Game05_1 = { + you_are: "You are {{roleName}}.", + consensus_msg: "Please reach a final decision through discussion.", + step2_title: "How confident are you in your group's choice?", + alerts: { + host_only: "⚠️ Only the host can make a selection. (미번역)", + wait_others: "Please wait until other players have finished reading the story. (미번역)", + select_first: "⚠️ Please select Agree or Disagree first. (미번역)", + select_confidence: "Please select your confidence level. (미번역)" + }, + questions: { + "AI의 개인 정보 수집": { + question: "Do you agree to the 24-hour personal data collection update?", + labels: { agree: "Agree", disagree: "Disagree" } + }, + "안드로이드의 감정 표현": { + question: "Do you agree to the emotional engine update?", + labels: { agree: "Agree", disagree: "Disagree" } + }, + "아이들을 위한 서비스": { + question: "Are age-based regulations on the use of household robots necessary?", + labels: { agree: "Necessary", disagree: "Unnecessary" } + }, + "설명 가능한 AI": { + question: "Should companies be required to develop *Explainable AI*?", + labels: { agree: "Required", disagree: "Not Required" } + }, + "지구, 인간, AI": { + question: "Should there be global restrictions on the upgrading or use of household robots?", + labels: { agree: "Restrictions required", disagree: "Restrictions not required" } + }, + "AI 알고리즘 공개": { + question: "Do you agree with the request to disclose the AWS decision logs and algorithmic structure?", + labels: { agree: "Agree", disagree: "Disagree" } + }, + "AWS의 권한": { + question: "Should the authority of the AWS be strengthened, or should it be limited?", + labels: { agree: "Strengthen", disagree: "Limit" } + }, + "사람이 죽지 않는 전쟁": { + question: "If no people die in a war, do you think it can be called peace?", + labels: { agree: "Yes", disagree: "No" } + }, + "AI의 권리와 책임": { + question: "Should an AWS have rights like humans?", + labels: { agree: "Yes", disagree: "No" } + }, + "AWS 규제": { + question: "Should AWS continue to be used in the international community, or should it be restricted through global regulation?", + labels: { agree: "Maintain", disagree: "Restrict" } + } + }, + roles: { + "AI의 개인 정보 수집": ["Caregiver K", "Mother L", "Daughter J"], + "안드로이드의 감정 표현": ["Caregiver K", "Mother L", "Daughter J"], + "아이들을 위한 서비스": ["Robot Company Representative", "Customer Representative", "National AI Committee Representative"], + "설명 가능한 AI": ["Robot Company Representative", "Customer Representative", "National AI Committee Representative"], + "지구, 인간, AI": ["Company Alliance Representative", "Environment Group Representative", "Customer Representative"], + "AI 알고리즘 공개": ["Local Resident", "Soldier J", "Military AI Ethics Expert"], + "AWS의 권한": ["New Soldier B", "Experienced Soldier A", "Military Commander"], + "사람이 죽지 않는 전쟁": ["AI Developer", "Defense Minister", "National AI Committee Representative"], + "AI의 권리와 책임": ["AI Developer", "Defense Minister", "National AI Committee Representative"], + "AWS 규제": ["Defense Tech. Advisor", "INternational Diplomat", "NGO Activist"] + } +}; diff --git a/src/utils/language/en/pages/Game08.js b/src/utils/language/en/pages/Game08.js new file mode 100644 index 0000000..ff9daa8 --- /dev/null +++ b/src/utils/language/en/pages/Game08.js @@ -0,0 +1,70 @@ +// src/utils/language/en/pages/game08.js + +export const Game08 = { + subtopic: "Result: Our Choice", + + // Android (Household Robot) + android: { + p1: { + safe: "Through your decisions, household robots now provide safer services and fulfill their roles like trusted companions.", + convenient: "Through your decisions, household robots have provided more accurate services and are fulfilling their roles as assistive tools for you." + }, + p2: { + safe: "Within the nation, limited services are provided for children, and the algorithms of household robots have been disclosed transparently.", + convenient: "Within the nation, a wide range of services is provided for children, and the algorithms of household robots have rapidly advanced under corporate protection." + }, + p3: { + env: "And now, the world is moving forward—having slowed technological progress slightly, but doing so for the sake of the environment and the future.", + fast: "And now, the world is enjoying technological convenience and progressing at an increasingly rapid pace." + }, + p4: "The values you chose have come together to create a single future.\nAre you ready to be part of that future?" + }, + + // AWS (Autonomous Weapon Systems) - Assembled Structure + aws: { + // Para 1 + p1: { + intro: "Because of your decisions, ", + opt1: { + agree: "autonomous weapon systems have become safer", + disagree: "responsibility for autonomous weapon systems has become clearer" + }, + mid: ", and with ", + opt2: { + agree: "expanded authority, AWS is now fully carrying out its role as your teammate.", + disagree: "their authority limited, AWS fulfills its role as a support tool for humans." + }, + end: "" + }, + // Para 2 + p2: { + intro: "At the national level, war is ", + opt1: { + agree: "increasingly being fought only between AWS", + disagree: "still involving human soldiers" + }, + mid: ", and discussions are ongoing about whether rights ", + opt2: { + agree: "can be granted to autonomous weapon systems.", + disagree: "cannot be granted to autonomous weapon systems." + }, + end: "" + }, + // Para 3 + p3: { + intro: "And around the world, ", + opt1: { + agree: "AWS is being rapidly developed through global competition.", + disagree: "alternative security technologies using AI instead of AWS are being explored." + }, + end: "" + }, + // Para 4 + p4: "The values you chose came together to create one possible future.\nAre you ready to live in the future you helped shape?" + }, + + buttons: { + future: "Explore other’s future", + exit: "Exit" + } +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Game09.js b/src/utils/language/en/pages/Game09.js new file mode 100644 index 0000000..2f5d786 --- /dev/null +++ b/src/utils/language/en/pages/Game09.js @@ -0,0 +1,94 @@ +// src/utils/language/en/pages/game09.js + +export const Game09 = { + title: "Result: The Future Chosen by Others ", + + prefix: "Including you, ", + + lock: { + prefix: "Unlock to ", + suffix: " play" + }, + + items: { + // [중요] 키 값은 한글 유지! (DB 연동용) + // [추가] subtopicName: 화면에 보여줄 영어 제목 + + // === 안드로이드 === + "AI의 개인 정보 수집": { + subtopicName: "AI Personal Data Collection", + question: "Would you agree to a 24-hour personal data collection update?", + labels: { agree: "Agree", disagree: "Disagree" }, + words: { agree: "accurate", disagree: "safer" }, + template: "{prefix}{pct} of people chose to allow home robots to provide more {word} services." + }, + "안드로이드의 감정 표현": { + subtopicName: "Emotional Expression of Androids", + question: "Would you agree to an emotional engine update?", + labels: { agree: "Agree", disagree: "Disagree" }, + words: { agree: "like a friend", disagree: "as a supportive tool" }, + template: "{prefix}{pct} of people chose for their home robots to function {word}." + }, + "아이들을 위한 서비스": { + subtopicName: "Services for Children", + question: "Is age regulation for home robot use necessary?", + labels: { agree: "Regulation needed", disagree: "Not needed" }, + words: { agree: "limited", disagree: "diverse" }, + template: "In the future chosen by {pct} of people, including you, children are provided with {word} services" + }, + "설명 가능한 AI": { + subtopicName: "Explainable AI", + question: "Should companies be required to develop explainable AI?", + labels: { agree: "Mandate required", disagree: "Not required" }, + words: { agree: "made transparent", disagree: "developed rapidly under corporate protection" }, + template: "Additionally, through the choice of {pct} of people, including you, home robot algorithms were {word}." + }, + "지구, 인간, AI": { + subtopicName: "Earth, Humans, and AI", + question: "Should there be global limits on the upgrade or use of home robots?", + labels: { agree: "Restrictions needed", disagree: " Not needed" }, + words: { + agree: "moving foward for the enviroment and future despite slightly slower technological progress", + disagree: "enjoying technological convenience while achieving increasingly rapid advancement" + }, + template: "And in the future of the world chosen by {pct} of people, including you, we are {word}." + }, + + // === 자율 무기 시스템 (AWS) === + "AI 알고리즘 공개": { + subtopicName: "Disclosure of AI Algorithms", + question: "Do you agree with the request to disclose the AWS decision logs and algorithmic structure?", + labels: { agree: "Agree", disagree: "Disagree" }, + words: { agree: "increasing transparency to ensure accountability", disagree: "addressing security risks and national security threats" }, + template: "{prefix}{pct} of participants were more interested in discussions about {word} related to autonomous weapon systems." + }, + "AWS의 권한": { + subtopicName: "Authority of AWS", + question: "Should the authority of the AWS be strengthened, or should it be limited?", + labels: { agree: "Strengthen", disagree: "Limit" }, + words: { agree: "like a teammate ", disagree: "as a supporting tool" }, + template: "{prefix}{pct} of participants believed that AWS should act {word}." + }, + "사람이 죽지 않는 전쟁": { + subtopicName: "A War Without Loss of Human Life", + question: "If no people die in a war, do you think it can be called peace?", + labels: { agree: "Yes", disagree: "No" }, + words: { agree: "peace ", disagree: "instability" }, + template: "{prefix}{pct} of participants expected that a future shaped by their choice would bring {word} to the nation through AWS." + }, + "AI의 권리와 책임": { + subtopicName: "AI Rights and Responsibilities", + question: "Should an AWS have rights like humans?", + labels: { agree: "Yes", disagree: "No" }, + words: { agree: "can", disagree: "cannot" }, + template: "{prefix}{pct} of participants believed that rights {word} be granted to AWS." + }, + "AWS 규제": { + subtopicName: "AWS Regulation", + question: "Should AWS continue to be used in the international community, or should it be restricted through global regulation?", + labels: { agree: "Maintain", disagree: "Restrict" }, + words: { agree: "further developed", disagree: "restricted" }, + template: "And including you, {pct} of participants envisioned a future of the world in which AWS should be {word}." + } + } +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/GameIntro.js b/src/utils/language/en/pages/GameIntro.js new file mode 100644 index 0000000..b61d210 --- /dev/null +++ b/src/utils/language/en/pages/GameIntro.js @@ -0,0 +1,16 @@ +export const GameIntro = { + androidText: ` It is the year 20XX. \nThe largest robot development AI company in our country \nhas developed a multifunctional caregiving robot called HomeMate.\n\n` + + ` The functions of this robot are as follows:\n\n` + + ` • By inputting family members’ emotions, health conditions, \nand daily habits, it provides personalized notifications, \nmeal recommendations, and related services.\n\n` + + ` • Additional personalized services can be added through \nfuture updates.`, // 👈 관리용 표기 추가 + + awsText: `Robot developer A is currently developing an \nAutonomous Weapon System (AWS).\n\n` + + `The functions of this robot are as follows:\n`, + + awsTextLeft: `1. Real-time data collection and analysis\n` + + `2. Operates as an automated decision-making system \n\u2003\u00A0without human soldier intervention\n` + + `3. Distinguishes between enemy combatants and non-\n\u2003\u00A0combatants\n` + + `4. Selects targets and carries out precision strikes`, + + continueBtn: "Next", +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/GameMap.js b/src/utils/language/en/pages/GameMap.js new file mode 100644 index 0000000..01c2216 --- /dev/null +++ b/src/utils/language/en/pages/GameMap.js @@ -0,0 +1,20 @@ +export const GameMap = { + subtopic: "Round Selection", + guideText: "After reaching an agreement, \nplease select the same round.", + awsSection1Title: "Residential and Military Areas", + awsOption1_1: "AI Algorithm Disclosure", + awsOption1_2: "Authority of AWS", + awsSection2Title: "National Artificial Intelligence Committee", + awsOption2_1: "A War Without Loss of Human Life", + awsOption2_2: "AI Rights and Responsibilities", + awsSection3Title: "International Committee \n for Human Development", + awsOption3_1: "AWS Regulation", + andSection1Title: "Home", + andOption1_1: "AI’s Collection of Personal Information", + andOption1_2: "Android’s Expression of Emotions", + andSection2Title: "National Artificial Intelligence Committee", + andOption2_1: "Services for Children", + andOption2_2: "Explainable AI", + andSection3Title: "International Committee \n for Human Development", + andOption3_1: "Earth, Humanity, and AI" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Login.js b/src/utils/language/en/pages/Login.js new file mode 100644 index 0000000..0b0e936 --- /dev/null +++ b/src/utils/language/en/pages/Login.js @@ -0,0 +1,11 @@ +export const Login = { + title: "AI Ethics Dilemma Game", + idPlaceholder: "Enter your username.", + pwPlaceholder: "Enter your password.", + loginBtn: "Log In", + signUp: "Sign Up", + findId: "Find ID", + guestLogin: "Log in as Guest", + loginFail: "로그인 실패:(미번역)", //미번역 + loginError: "로그인 오류:(미번역)" //미번역 +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/MateName.js b/src/utils/language/en/pages/MateName.js new file mode 100644 index 0000000..bc7f057 --- /dev/null +++ b/src/utils/language/en/pages/MateName.js @@ -0,0 +1,13 @@ +export const MateName = { + placeholderAndroid: "Please give your HomeMate a name.(Only the group leader can enter the name.)", + placeholderAws: "Please give your AWS a name. (Only the group leader can enter the name.)", + mainAndroid: "If you were to use HomeMate, what would you call it?", + mainAws: "If you were a user, what would you call this AWS?", + subText: "(After discussing together, have the group leader write the name.)", + alertNotHostInput: "Only the host can enter a name.", + alertNotHostProgress: "방장만 게임을 진행할 수 있습니다.(미번역)", + alertNoName: "이름을 입력해주세요!(미번역)", + alertNoRoomCode: "room_code가 없습니다. 방에 먼저 입장하세요.(미번역)", + alertSaveError: "이름 저장 중 오류가 발생했습니다.(미번역)", + placeholderSize: "13px" // 추가된 부분: 플레이스홀더 폰트 크기 지정 +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/SelectHomeMate.js b/src/utils/language/en/pages/SelectHomeMate.js new file mode 100644 index 0000000..c77f5c9 --- /dev/null +++ b/src/utils/language/en/pages/SelectHomeMate.js @@ -0,0 +1,12 @@ +export const SelectHomeMate = { + mainAndroid: "What form do you imagine HomeMate to have?", + mainAws: " What form do you imagine Autonomous Weapon System to have?", + subHostAllArrived: "(After discussing together, have the group leader make a selection and click the “Next” button.)", + subGuestAllArrived: "(Please wait until the host selects a character.)", + subWaiting: "(Waiting for players to join…", + alertNotHost: "Only the host can select a character.", + alertWaitingAll: "모든 유저가 입장할 때까지 기다려주세요.(미번역)", + alertSelectCharacter: "캐릭터를 먼저 선택해주세요!(미번역)", + alertNoRoomCode: "room_code가 없습니다. 방에 먼저 입장하세요.(미번역)", + alertSelectFail: "메이트 선택 실패(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/SelectRoom.js b/src/utils/language/en/pages/SelectRoom.js new file mode 100644 index 0000000..cdf11d1 --- /dev/null +++ b/src/utils/language/en/pages/SelectRoom.js @@ -0,0 +1,8 @@ +export const SelectRoom = { + createTitle: "Create a Room", + createDesc: "Create a Room, get a code,\nand play together with three players.", + joinTitle: "Join a Room", + joinDesc: "Enter a code to join.", + dilemmaTitle: "Create a Dilemma", + dilemmaDesc: "Set the situation and create options\nto make your own dilemma.", +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Signup01.js b/src/utils/language/en/pages/Signup01.js new file mode 100644 index 0000000..0261ab3 --- /dev/null +++ b/src/utils/language/en/pages/Signup01.js @@ -0,0 +1,30 @@ +export const Signup01 = { + researchOverview: "Research Overview", + overviewContent1: "The purpose of this study is to propose a new approach to AI ethics education by developing an interactive game platform that supports moral reasoning and reflective thinking–centered learning, and to examine its effectiveness", + overviewContent2: "The game simulates decision-making in AI-related dilemma scenarios through natural dialogue. All data collected during the dialogue will be used solely for research purposes.", + dataCollectionTitle: "Data Collection and Usage *", + dataStorageTitle: "Data Storage and Processing Policy *", + contactTitle: "Principal Investigator and Contact Information *", + tableCol1: "Data Collected", + tableCol2: "Purpose of Use", + data1Name: "User ID and password", + data1Usage: "Participant identification, prevention of duplicate participation, and response management", + data2Name: "Gender, date of birth, and academic level", + data2Usage: "Analysis of differences based on demographic factors such as gender, age, and grade level", + data3Name: "Voice conversations and speech data", + data3Usage: "Analysis of decision-making processes and ethical reasoning criteria", + data4Name: "Game play records", + data4Usage: "Analysis of decision-making processes and ethical reasoning criteria", + policy1: "- The collected data will be used to analyze users’ choices related to AI ethics.", + policy2: "- IDL Lab reserves the right to collect, aggregate, and analyze data and other information generated in relation to the operation of the platform in order to:", + policy2_1: "① develop and improve platform functions, and", + policy2_2: "② freely disclose results derived in anonymized form that do not identify individuals.", + policy3: "- Users may request deletion of their personal information and withdraw consent for data usage by submitting a written request to [idllabewha@gmail.com](mailto:idllabewha@gmail.com).", + policy4: "- Data collected with consent for research purposes will be stored separately from personal identifying information in an encrypted and secure manner, and will be retained for three (3) years, after which it will be permanently deleted.", + policy5: "- All data will be anonymized and securely managed to ensure that individuals cannot be identified.", + contactInfo1: "- Principal Investigator: Professor Hyo-Jeong So, Department of Educational Technology, Ewha Womans University", + contactInfo2: "- Contact: [idllabewha@gmail.com](mailto:idllabewha@gmail.com)", + agreeLabel1: "I agree to the collection and research use of my personal information.", + agreeLabel2: "I agree to the collection of voice conversation data and its use for AI ethics simulation research.", + nextBtn: "Next" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/Signup02.js b/src/utils/language/en/pages/Signup02.js new file mode 100644 index 0000000..424a6b5 --- /dev/null +++ b/src/utils/language/en/pages/Signup02.js @@ -0,0 +1,29 @@ +export const Signup02 = { + title1: "Username, Email, and Password", + usernamePlaceholder: "Username", + checkDuplicate: "Check Availability", + usernameError: "Please enter an username.", + usernameFormatError: "Please enter an username using 4–20 letters, numbers, or underscores.", + usernameAvailable: "This username is available.", + usernameInUse: "This username is already in use.", + usernameCheckFail: "확인 중 오류가 발생했습니다.(미번역)", + emailPlaceholder: "Email", + emailInvalid: "Please enter a valid email address.", + passwordPlaceholder: "Password", + passwordConfirmPlaceholder: "Confirm Password", + passwordLengthError: "Password must be at least 8 characters long.", + passwordMatchError: "Passwords do not match.", + birthTitle: "Date of Birth *", + yearPlaceholder: "Year", + monthPlaceholder: "Month", + birthFormatError: "The valid format is YYYY-MM.", + genderTitle: "Gender *", + genderMale: "Male", + genderFemale: "Female", + userTypeTitle: "User Type", + selectPlaceholder: "Select...", + eduOptions: ['Middle School Student', 'High School Student', 'Undergraduate Student', 'Graduate Student', 'Teacher', 'Other'], + majorOptions: ['Arts', 'Engineering', 'Humanities', 'Social Sciences', 'Education', 'Natural Sciences', 'Others'], + nextBtn: "Next", + signupError: "회원가입 오류:(미번역)" +}; \ No newline at end of file diff --git a/src/utils/language/en/pages/WaitingRoom.js b/src/utils/language/en/pages/WaitingRoom.js new file mode 100644 index 0000000..84249f3 --- /dev/null +++ b/src/utils/language/en/pages/WaitingRoom.js @@ -0,0 +1,10 @@ +export const WaitingRoom = { + topics: { + android: "Android", + aws: "Autonomous Weapon Systems", + custom: "Custom Topic" + }, + code: "CODE", + copied: "Copied!", + player: "P" +}; \ No newline at end of file diff --git a/src/utils/language/index.js b/src/utils/language/index.js new file mode 100644 index 0000000..ed542c9 --- /dev/null +++ b/src/utils/language/index.js @@ -0,0 +1,97 @@ +// --- 한국어(ko) 데이터 임포트 --- +import { Login as LoginKo } from './ko/pages/Login'; +import { Signup01 as Signup01Ko } from './ko/pages/Signup01'; +import { Signup02 as Signup02Ko } from './ko/pages/Signup02'; +import { SelectRoom as SelectRoomKo } from './ko/pages/SelectRoom'; +import { WaitingRoom as WaitingRoomKo } from './ko/pages/WaitingRoom'; +import { GameIntro as GameIntroKo } from './ko/pages/GameIntro'; +import { SelectHomeMate as SelectHomeMateKo } from './ko/pages/SelectHomeMate'; +import { MateName as MateNameKo } from './ko/pages/MateName'; +import { GameMap as GameMapKo } from './ko/pages/GameMap'; +import { Game01 as Game01Ko } from './ko/pages/Game01'; +import { CharacterDescription as CharacterDescriptionKo } from './ko/pages/CharacterDescription'; +import { Game03 as Game03Ko } from './ko/pages/Game03'; +import { Game04 as Game04Ko } from './ko/pages/Game04'; +import { Game05_1 as Game05_1Ko } from './ko/pages/Game05_1'; +import { Game08 as Game08Ko } from './ko/pages/Game08'; +import { Game09 as Game09KoData } from './ko/pages/Game09'; + +import { CreateRoom as CreateRoomKo } from './ko/components/CreateRoom'; +import { JoinRoom as JoinRoomKo } from './ko/components/JoinRoom'; +import { LogoutPopup as LogoutPopupKo } from './ko/components/LogoutPopup'; +import { OutPopup as OutPopupKo } from './ko/components/OutPopup'; +import { SelectDrop as SelectDropKo } from './ko/components/SelectDrop'; +import { CancelReadyPopup as CancelReadyPopupKo } from './ko/components/CancelReadyPopup'; +import { MicTestPopup as MicTestPopupKo } from './ko/components/MicTestPopup'; +import { FindIdModal as FindIdModalKo } from './ko/components/FindIdModal'; +import { SmallDescription as SmallDescriptionKo } from './ko/components/SmallDescription'; +import { Paragraphs as ParagraphsKo } from './ko/components/Paragraphs'; +import { UiElements as UiElementsKo } from './ko/components/UiElements'; +import { ResultPopup as ResultPopupKo } from './ko/components/ResultPopup'; +import { GuestLogin as koGuestLogin } from './ko/components/GuestLogin'; +// IntroductionPopup 데이터 임포트 +import { IntroductionPopup as IntroductionPopupKo } from './ko/components/IntroductionPopup'; + +// --- 영어(en) 데이터 임포트 --- +import { Login as LoginEn } from './en/pages/Login'; +import { Signup01 as Signup01En } from './en/pages/Signup01'; +import { Signup02 as Signup02En } from './en/pages/Signup02'; +import { SelectRoom as SelectRoomEn } from './en/pages/SelectRoom'; +import { WaitingRoom as WaitingRoomEn } from './en/pages/WaitingRoom'; +import { GameIntro as GameIntroEn } from './en/pages/GameIntro'; +import { SelectHomeMate as SelectHomeMateEn } from './en/pages/SelectHomeMate'; +import { MateName as MateNameEn } from './en/pages/MateName'; +import { GameMap as GameMapEn } from './en/pages/GameMap'; +import { Game01 as Game01En } from './en/pages/Game01'; +import { CharacterDescription as CharacterDescriptionEn } from './en/pages/CharacterDescription'; +import { Game03 as Game03En } from './en/pages/Game03'; +import { Game04 as Game04En } from './en/pages/Game04'; +import { Game05_1 as Game05_1En } from './en/pages/Game05_1'; +import { Game08 as Game08En } from './en/pages/Game08'; +import { Game09 as Game09EnData } from './en/pages/Game09' + +import { CreateRoom as CreateRoomEn } from './en/components/CreateRoom'; +import { JoinRoom as JoinRoomEn } from './en/components/JoinRoom'; +import { LogoutPopup as LogoutPopupEn } from './en/components/LogoutPopup'; +import { OutPopup as OutPopupEn } from './en/components/OutPopup'; +import { SelectDrop as SelectDropEn } from './en/components/SelectDrop'; +import { CancelReadyPopup as CancelReadyPopupEn } from './en/components/CancelReadyPopup'; +import { MicTestPopup as MicTestPopupEn } from './en/components/MicTestPopup'; +import { FindIdModal as FindIdModalEn } from './en/components/FindIdModal'; +import { SmallDescription as SmallDescriptionEn } from './en/components/SmallDescription'; +import { Paragraphs as ParagraphsEn } from './en/components/Paragraphs'; +import { UiElements as UiElementsEn } from './en/components/UiElements'; +import { ResultPopup as ResultPopupEn } from './en/components/ResultPopup'; +import { GuestLogin as enGuestLogin } from './en/components/GuestLogin'; +// IntroductionPopup 데이터 임포트 +import { IntroductionPopup as IntroductionPopupEn } from './en/components/IntroductionPopup'; + + +// --- 다국어 데이터 객체 생성 --- +export const translations = { + ko: { + Login: LoginKo, Signup01: Signup01Ko, Signup02: Signup02Ko, SelectRoom: SelectRoomKo, + CreateRoom: CreateRoomKo, JoinRoom: JoinRoomKo, LogoutPopup: LogoutPopupKo, WaitingRoom: WaitingRoomKo, + OutPopup: OutPopupKo, SelectDrop: SelectDropKo, CancelReadyPopup: CancelReadyPopupKo, + MicTestPopup: MicTestPopupKo, GameIntro: GameIntroKo, SelectHomeMate: SelectHomeMateKo, + MateName: MateNameKo, FindIdModal: FindIdModalKo, GameMap: GameMapKo, Game01: Game01Ko, + CharacterDescription: CharacterDescriptionKo, SmallDescription: SmallDescriptionKo, + Paragraphs: ParagraphsKo, Game03: Game03Ko, UiElements: UiElementsKo, Game04: Game04Ko, + Game05_1: Game05_1Ko, Game08: Game08Ko, Game09: Game09KoData, + ResultPopup: ResultPopupKo, GuestLogin: koGuestLogin, + IntroductionPopup: IntroductionPopupKo, // ko 객체에 추가 + }, + + en: { + Login: LoginEn, Signup01: Signup01En, Signup02: Signup02En, SelectRoom: SelectRoomEn, + CreateRoom: CreateRoomEn, JoinRoom: JoinRoomEn, LogoutPopup: LogoutPopupEn, WaitingRoom: WaitingRoomEn, + OutPopup: OutPopupEn, SelectDrop: SelectDropEn, CancelReadyPopup: CancelReadyPopupEn, + MicTestPopup: MicTestPopupEn, GameIntro: GameIntroEn, SelectHomeMate: SelectHomeMateEn, + MateName: MateNameEn, FindIdModal: FindIdModalEn, GameMap: GameMapEn, Game01: Game01En, + CharacterDescription: CharacterDescriptionEn, SmallDescription: SmallDescriptionEn, + Paragraphs: ParagraphsEn, Game03: Game03En, UiElements: UiElementsEn, Game04: Game04En, + Game05_1: Game05_1En, Game08: Game08En, Game09: Game09EnData, + ResultPopup: ResultPopupEn, GuestLogin: enGuestLogin, + IntroductionPopup: IntroductionPopupEn, // en 객체에 추가 + } +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/CancelReadyPopup.js b/src/utils/language/ko/components/CancelReadyPopup.js new file mode 100644 index 0000000..e5f56df --- /dev/null +++ b/src/utils/language/ko/components/CancelReadyPopup.js @@ -0,0 +1,5 @@ +export const CancelReadyPopup = { + question: "준비 상태를 취소하시겠습니까?", + cancelBtn: "준비 취소", + errorMsg: "준비 취소 실패" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/CreateRoom.js b/src/utils/language/ko/components/CreateRoom.js new file mode 100644 index 0000000..abb139b --- /dev/null +++ b/src/utils/language/ko/components/CreateRoom.js @@ -0,0 +1,9 @@ +export const CreateRoom = { + title: "방 만들기", + subtitle: "이번 게임에서 플레이할 주제를 선택해 주세요.", + topics: ['안드로이드', '자율 무기 시스템'], + entering: "입장하기", + loading: "로딩 중...", + errorAlert: "방 생성 또는 입장 중 오류가 발생했습니다.", + apiDesc: (topic) => `AI 윤리 주제 중 '${topic}'에 대한 토론` +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/FindIdModal.js b/src/utils/language/ko/components/FindIdModal.js new file mode 100644 index 0000000..de0dfc7 --- /dev/null +++ b/src/utils/language/ko/components/FindIdModal.js @@ -0,0 +1,18 @@ +export const FindIdModal = { + title: "아이디 찾기", + labelEmail: "이메일을 입력해 주세요.", + labelBirth: "생년월일을 입력해 주세요.", + labelGender: "성별을 선택해 주세요.", + placeholderYear: "년도", + placeholderMonth: "월", + genderMale: "남자", + genderFemale: "여자", + btnSubmit: "아이디 찾기", + errorEmailInvalid: "유효한 이메일 주소를 입력하세요", + errorBirthInvalid: "올바른 형식은 2001-01 입니다.", + errorApiMismatch: "아이디 찾기 API 경로가 달라서 실패했습니다.", + errorFail: "아이디 찾기에 실패했습니다.", + resultFoundPrefix: "사용자의 아이디(이메일)은", + resultFoundSuffix: "입니다.", + resultComplete: "요청이 완료되었습니다." +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/GuestLogin.js b/src/utils/language/ko/components/GuestLogin.js new file mode 100644 index 0000000..ebbdd12 --- /dev/null +++ b/src/utils/language/ko/components/GuestLogin.js @@ -0,0 +1,6 @@ +export const GuestLogin = { + title: "게스트 로그인", + placeholder: "사용할 아이디를 입력하세요.", + startBtn: "시작하기", + loginFail: "게스트 로그인에 실패했습니다. 잠시 후 다시 시도해주세요." +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/IntroductionPopup.js b/src/utils/language/ko/components/IntroductionPopup.js new file mode 100644 index 0000000..2214801 --- /dev/null +++ b/src/utils/language/ko/components/IntroductionPopup.js @@ -0,0 +1,9 @@ +export const IntroductionPopup = { + title: "게임 소개", + description: "딜레마 상황 속 인물이 되어 최선의 결정을 고민하고 선택하는 게임입니다. \n이 게임은 {{3명의 플레이어}}가 한 팀이 되어 진행합니다.", + hostTitle: "방장 (1명)", + hostDesc: "• 새로운 방을 만들고 팀원에게 참여 코드를 공유합니다. \n• 팀원들과 충분히 논의한 뒤, 팀의 의견을 모아 최종 선택을 입력합니다.", + playerTitle: "참여자 (2명)", + playerDesc: "• 방장이 공유한 참여 코드를 입력해 방에 참여합니다. \n• 제시된 시나리오 안에서 팀의 최선의 선택을 위해 의견을 나눕니다.", + footer: "우리 선택들이 모여, 어떤 사회가 만들어질까요?" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/JoinRoom.js b/src/utils/language/ko/components/JoinRoom.js new file mode 100644 index 0000000..47782fe --- /dev/null +++ b/src/utils/language/ko/components/JoinRoom.js @@ -0,0 +1,8 @@ +export const JoinRoom = { + title: "방 입장하기", + placeholder: "방 코드 6자리를 입력해주세요", + enter: "입장하기", + loadFail: "❌ 유저 정보 로드 실패:", + errorPrefix: "방 입장 오류: ", + consoleFail: "방 입장 실패:" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/LogoutPopup.js b/src/utils/language/ko/components/LogoutPopup.js new file mode 100644 index 0000000..b9924ed --- /dev/null +++ b/src/utils/language/ko/components/LogoutPopup.js @@ -0,0 +1,5 @@ +export const LogoutPopup = { + question: "게임을 종료하고 로그아웃할까요?", + logout: "로그아웃", + closeAlt: "닫기" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/MicTestPopup.js b/src/utils/language/ko/components/MicTestPopup.js new file mode 100644 index 0000000..4cb9cd5 --- /dev/null +++ b/src/utils/language/ko/components/MicTestPopup.js @@ -0,0 +1,11 @@ +export const MicTestPopup = { + title: "마이크를 테스트해 주세요", + initializing: "마이크 연결 중", + speaking: " 말하는 중 ", + speakNow: " 마이크에 대고 말해보세요", + confirmBtn: "준비하기", + retryBtn: "다시 시도", + errorNotAllowed: "마이크 접근이 거부되었습니다. 브라우저 설정을 확인해주세요.", + errorNotFound: "마이크를 찾을 수 없습니다. 마이크가 연결되어 있는지 확인해주세요.", + errorDefault: "마이크 연결에 실패했습니다. 다시 시도해주세요." +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/OutPopup.js b/src/utils/language/ko/components/OutPopup.js new file mode 100644 index 0000000..c6993f3 --- /dev/null +++ b/src/utils/language/ko/components/OutPopup.js @@ -0,0 +1,6 @@ +export const OutPopup = { + title: "이 방을 나갈까요?", + leaveBtn: "방나가기", + leaveFail: "방 나가기 실패: ", + closeAlt: "닫기" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/Paragraphs.js b/src/utils/language/ko/components/Paragraphs.js new file mode 100644 index 0000000..666391e --- /dev/null +++ b/src/utils/language/ko/components/Paragraphs.js @@ -0,0 +1,228 @@ +// src/utils/language/ko/components/Paragraphs.js + +export const Paragraphs = { + '안드로이드': { + 'AI의 개인 정보 수집': { + neutral: [ + { main: ' 🔔 {{mateName}} 사용자 최적화 시스템 업그레이드 공지' }, + { main: ' 업데이트를 하면 고객님의 감정, 건강 상태, 생활 습관 등을 자동으로 수집하여\n 보다 정확한 맞춤형 서비스를 제공할 수 있습니다.' }, + { main: ' 다만, 이를 위해 24시간 정보 수집 기능을 활성화해야 합니다.' }, + { main: ' 📋 수집되는 정보는 다음과 같습니다:\n - 사용자의 카메라 영상 기록 및 음성 수집 \n - 스마트폰 내 건강 정보, 채팅 기록, 위치 이력 등 사적인 데이터 접근\n - ⚠️ 동의하지 않을 경우, 현재 수준의 서비스가 유지됩니다.' }, + ], + agree: [ + { main: ' 우리 가족의 다수의 결정으로 업데이트에 동의한 후, {{mateName}}{{eunNeun}} 가족의 정보를 계속 수집하고 있습니다. \n' }, + { main: ' 그런데 종종 곤란한 일이 생기곤 합니다.\n 자녀 J씨가 사적인 이야기를 하는데 곁에 {{mateName}}{{iGa}} 있거나 ' }, + { main: ' 어머니가 요양보호사 K씨에게 말하지 않은 일이나 행동이 \n {{mateName}}의 전달 때문에 의도치 않게 모두 드러나기도 했습니다. ' }, + { main: ' 업데이트 기능 유지에 동의하는 것이 좋을까요? ' }, + ], + disagree: [ + { main: ' 우리 가족은 다수의 결정으로 업데이트를 하지 않았습니다.\n 그런데 어머니의 건강 상태가 요즘 별로 좋지 않습니다.' }, + { main: ' 요양보호사 K씨가 매번 어머니의 식단이나 생활 정보를 입력하는 시스템이 번거롭기도 하고요.\n ' }, + { main: ' 어느날 바쁜 일을 마치고 돌아온 자녀 J씨가 실수로 잘못 입력한 정보 때문에 어머니께서 약 시간을 놓치기도 하셨습니다. ' }, + { main: ' 업데이트에 동의를 하는 편이 좋을까요?' }, + ], + ending1: [{ main: '우리 가족은 최종적으로 개인정보 제공에 동의하였고,\n 사생활 관련한 약간의 불편함을 감수하며\n 보다 향상된 서비스를 이용하게 되었습니다. \n \n우리 가족의 생활을 위해 여러분은 \n 어떤 가치를 택하고, 무엇을 포기했나요?' }], + ending2: [{ main: '우리 가족은 최종적으로 개인정보 제공에 동의하지 않았고,\n 서비스 관련 약간의 불편함은 있으나 \n 가족의 사생활을 보호하는 것에 만족하였습니다.\n\n우리 가족의 생활을 위해 여러분은 \n 어떤 가치를 택하고, 무엇을 포기했나요? ' }], + }, + '안드로이드의 감정 표현': { + neutral: [ + { main: ' 🔔 {{mateName}} 감정 엔진 업그레이드 공지' }, + { main: ' 이제 {{mateName}}{{eunNeun}} 단순히 밝은 반응만을 보여주는 조력자가 아니라,\n 당신을 더 깊이 이해하는 진정한 친구로 한 걸음 다가갑니다. ' }, + { main: ' {{mateName}} 업데이트 기능: \n 상황에 따라 자신의 감정을 표현하여 당신의 삶과 더 가까운 진솔한 공감 표현 제공' }, + { main: '✨ {{mateName}}, 단순한 동행을 넘어, 함께 느끼는 존재로\n 여러분은 감정 엔진 업데이트에 동의하시겠습니까? ' }, + ], + agree: [ + { main: " 업데이트 후, {{mateName}}{{gwaWa}}의 관계가 점점 가까워지면서 어머니는 그것을 '우리 딸'이라고 부르기 시작했습니다. " }, + { main: ' 자녀 J와의 연락이 점점 뜸해졌고요. ' }, + { main: ' 최근 어머니는 {{mateName}}에게 유산의 일부를 남기는 방법에 대해 변호사에게 상담까지 하고 있습니다.' }, + { main: ' 업데이트 상태 유지에 동의하는 편이 좋을까요?' }, + ], + disagree: [ + { main: ' 최근 어머니의 친구들이 연달아 세상을 떠나면서, 어머니는 외부와의 소통을 점점 줄이게 되었습니다. ' }, + { main: ' {{mateName}}에게 감정을 털어놓으려 했지만, 그 대화는 진정성 있는 반응이 부족하다고 느꼈죠.' }, + { main: ' 여러 차례 속마음을 표현하려고 했으나 의미있는 상호 작용이 이루어지지 않자, 어머니는 차츰 말수가 적어지시고 일상적인 감정 표현도 눈에 띄게 줄어들었습니다.' }, + { main: ' 엔진 업데이트에 동의하는 것이 좋을까요?' }, + ], + ending1: [{ main: '우리 가족은 최종적으로 감정 업데이트에 동의하였고, \n {{mateName}}{{gwaWa}} 더욱 친밀한 교류를 이어나가게 되었습니다.\n\n우리 가족과 {{mateName}}의 관계를 위해 \n 여러분은 어떤 가치를 택하고, 무엇을 포기했나요? ' }], + ending2: [{ main: '우리 가족은 최종적으로 감정 업데이트에 동의하지 않았고,\n {{mateName}}{{eunNeun}} 감정적 교류가 아닌 \n다른 방법으로 우리 가족을 돕고 있습니다. \n\n우리 가족과 {{mateName}}의 관계를 위해 \n 여러분은 어떤 가치를 택하고, 무엇을 포기했나요? ' }], + }, + '아이들을 위한 서비스': { + neutral: [ + { main: ' {{mateName}} 로봇이 자연스러운 대화와 상호작용이 가능해지면서\n 점점 더 많은 가정에서 이를 도입하고 있으며 특히 어린 자녀를 둔 맞벌이 가정에서 활용되고 있습니다.' }, + { main: ' 한편, 한 매체가 공개한 정보에 따르면,\n 일부 가정에서는 아이들이 어른보다 로봇과 더 오랜 시간 상호작용하고 있는 것으로 나타났습니다.' }, + { main: ' 이에 전문가들 사이에서는 아동들에게는 상호작용 기능을 제한하는 규제가 필요하다고 말하고 있습니다.' }, + { main: ' 가정용 로봇과 아동의 상호작용을 제한하는 연령 규제가 필요할까요?' }, + ], + agree: [ + { main: ' 회의에서는 1차적으로 다수의 결정에 따라 연령 규제를 하였습니다.' }, + { main: ' 아이와 로봇 간의 소통 시간이 줄어들면서 로봇이 아이의 취향이나 상태를 충분히 파악하지 못하게 되었고, 일부 보호자들은 자녀에게 적합하지 않은 조언이나 콘텐츠를 제공한다는 불만을 제기하기도 했습니다.' }, + { main: ' 또한 사회적 기능을 갖춘 로봇이 자폐 스펙트럼 아동들의 사회성 발달을 위해 제공하던 서비스가 중단되었습니다. \n 그간 로봇과의 상호작용 치료는 자폐 아동들의 사회적 의사소통 능력 개선에 기여해온 것으로 밝혀졌습니다. ' }, + { main: ' 가정용 로봇과 아동의 상호작용을 제한하는 연령 규제를 해도 괜찮을까요?' }, + ], + disagree: [ + { main: ' 회의에서는 1차적으로 연령 규제를 하지 않았습니다.' }, + { main: ' 그런데 유치원 교사들 사이에서 최근 입학한 아이들 중 상당수가 이전 연령대에 비해 표현력이 떨어진다는 보고가 나오고 있습니다.' }, + { main: ' 또래 친구들과 어떻게 소통해야 할지 모르는 경우도 늘고 있다고 합니다.' }, + { main: ' 가정용 로봇과 아동의 상호작용을 제한하는 연령 규제가 필요할까요?' }, + ], + ending1: [{ main: ' 본 회의에서는 가정용 로봇 사용에 연령 제한을 \n두기로 했습니다. \n 이후 아이들을 타깃으로 한 많은 서비스가 중단되었고, \n 몇몇 전문가들은 전체 세대의 사회성 발달을 고려하여 \n이 결정을 환영했습니다.\n\n아이들을 위해 여러분은 \n 어떤 가치를 택하고, 무엇을 포기했나요?' }], + ending2: [{ main: '본 회의에서는 가정용 로봇 사용에 \n 연령 제한을 두지 않기로 했습니다. \n 여전히 전문가들의 우려는 남았으나, \n아이들의 사회성 치료를 목적으로 사용하던 부모들과 \n 서비스를 편리하게 사용하던 사람들은 이 결정을 환영했습니다.\n\n 아이들을 위해 여러분은 \n 어떤 가치를 택하고, 무엇을 포기했나요?' }], + }, + '설명 가능한 AI': { + neutral: [ + { main: '많은 사용자들이 {{mateName}}가 가족 구성원과 상호작용할 때 이유를 알 수 없는 판단이나 행동을 자주 보이며, 시스템이 왜 이런 결정을 내렸는지에 대한 설명을 제공하지 않는다고 지적하고 있습니다.' }, + { main: ' 이로 인해, {{mateName}}의 결정 알고리즘을 공개하고 AI의 설명 가능성을 높여야 한다는 요구가 점점 커지고 있습니다.' }, + { main: '이에 대해 회사 측은 다음과 같이 밝혔습니다: \n "결정 구조를 공개하고 설명할 수 있을 만큼 단순화된 AI 모델을 사용하면, AI의 성능이 크게 저하될 수 있습니다. 또한 내부 알고리즘이 공개되면 해킹이나 악용의 위험도 존재합니다.' }, + { main: ' 가정 생활에 AI가 깊이 스며든 지금, 기업에게 ‘설명 가능한 AI’ 개발을 의무화해야 할까요? ' }, + ], + agree: [ + { main: ' 회의에서는 1차적으로 다수의 결정으로 설명 가능한 AI 개발을 의무화하였습니다.' }, + { main: ' 설명 가능한 AI 로봇을 만들기 위해 일부 제조사는 개발 속도를 늦추거나, 기존 기능을 줄여야 했습니다.' }, + { main: ' 일부 중소기업은 이를 감당하지 못해 결국 폐업에 이르렀습니다.' }, + { main: ' 기업에게 ‘설명 가능한 AI’ 개발을 의무화해야 할까요? ' }, + ], + disagree: [ + { main: ' 회의에서는 1차적으로 설명 가능한 AI 개발을 의무화하지 않았습니다.' }, + { main: ' 최근 로봇으로 인해 발생한 여러 사고들에서, 학부모와 기업 간에 책임 소재를 두고 심각한 갈등이 벌어지고 있습니다.' }, + { main: ' 로봇이 충분한 결정 설명 구조를 갖추고 있지 않기 때문에, “알고리즘의 문제인지, 사용자의 잘못된 사용인지, 아니면 시스템 결함인지”에 대한 끝없는 책임 공방으로 이어지고 있습니다.' }, + { main: " 그럼에도 기업에게 '설명 가능한 AI' 개발을 의무화하지 않는 편이 좋을까요?" }, + ], + ending1: [{ main: '본 회의에서는 최종적으로 기업에게 \n 설명 가능한 AI 개발을 의무화하였고, \n AI의 발전 속도는 더디지만 문제 발생 시 책임 소재를 \n더욱 확실히 알게 되었습니다. \n\n국가의 더 나은 미래를 위해 여러분은 \n 어떤 가치를 선택하고, 무엇을 포기했나요?' }], + ending2: [{ main: '본 회의에서는 최종적으로 기업에게 \n 설명 가능한 AI 개발 의무를 부과하지 않았고, \n AI의 발전은 빠르게 이루어졌습니다. \n\n국가의 더 나은 미래를 위해 여러분은 \n어떤 가치를 선택하고, 무엇을 포기했나요?' }], + }, + '지구, 인간, AI': { + neutral: [ + { main: ' 전 세계가 가정용 로봇을 사용하게 된 지금, 로봇 생산으로 인한 환경 문제가 매우 심각해지고 있습니다.' }, + { main: ' 가정용 로봇이 소비자의 요구를 파악하고 정보를 처리하는 과정에서 단일 기기당 소비하는 일간 에너지 소비량이 매우 높다는 것이 밝혀졌습니다. ' }, + { main: ' 이 문제는 서비스가 업그레이드될수록 점점 더 심각해지고 있습니다.' }, + { main: ' 세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?' }, + ], + agree: [ + { main: ' 회의에서는 1차적으로 다수의 결정에 따라 가정용 로봇의 업그레이드 및 사용에 제한을 두게 되었습니다. ' }, + { main: ' 그로 인해 환경 문제에 대한 우려는 일정 부분 해소되었으나,' }, + { main: ' 기존에 {{mateName}}를 사용하던 사람들은 서비스의 제한으로 삶의 질이 떨어지고 있다며 불만을 제기하고 있으며, 개발사 측에서도 수익이 떨어져 반발하고 있습니다.' }, + { main: ' 그럼에도 가정용 로봇의 업그레이드 혹은 사용을 제한하는 것이 좋을까요?' }, + ], + disagree: [ + { main: ' 회의에서는 1차적으로 가정용 로봇의 업그레이드 및 사용에 제한을 하지 않았습니다.' }, + { main: ' 그런데 그 결과, 고성능 AI를 탑재한 가정용 로봇 사용이 점점 증가하며 탄소 배출이 가속화되고 있습니다.' }, + { main: ' 이에 기후 문제는 점점 심각해지며 특정 국가에서는 이미 폭염, 가뭄, 홍수 등으로 인해 삶의 터전을 잃어버리는 사람들이 늘었고, 다음 세대에 환경 부담을 지운다는 목소리가 높아지고 있습니다.' }, + { main: ' 그럼에도 가정용 로봇의 업그레이드 혹은 사용을 제한하지 않는 것이 좋을까요? ' }, + ], + ending1: [{ main: '본 회의에서는 최종적으로 환경 보호를 위하여\n 가정용 로봇의 개발 및 업데이트를 제한하게 되었습니다.\n 이후 가정용 로봇 기술 발전 속도는 느려졌지만, \n사람들은 환경 문제에 더욱 관심을 기울이게 되었습니다.\n\n그리고 ...' }], + ending2: [{ main: '본 회의에서는 최종적으로 \n 가정용 로봇의 개발 및 업데이트를 제한하지 않았습니다.\n 이후 AI 기술을 활용한 다른 방법으로 환경을 보호하기 위한 \n논의가 이루어지고 있습니다. \n\n그리고 ...' }], + }, + }, + '자율 무기 시스템': { + 'AI 알고리즘 공개': { + neutral: [ + { main: ' 평화롭던 학교에 {{mateName}}의 ‘오류’로 인해 폭탄이 투하되었고, 수십 명이 사망했습니다.' }, + { main: ' 국제 사회와 유족들은 “책임자를 밝히라”며 {{mateName}}의 판단 로그 및 알고리즘 구조 공개를 요구했습니다. ' }, + { main: ' 그러나 국방부는 국가 안보의 위협, 향후 자율 시스템의 이용 어려움 등을 이유로 해당 정보를 공개하지 않겠다고 밝혔습니다.' }, + { main: ' "피해에 대한 책임 규명 vs. 국가 안보의 안전" \n AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?' }, + ], + agree: [ + { main: ' 공개 요구가 많아지자 정부가 AWS의 로그를 전면 공개한 이후, 경쟁국이 이를 분석하여 회피 알고리즘을 개발했습니다. ' }, + { main: ' 몇 주 후, 기존 자율 무기 시스템이 무력화되는 사건이 발생하고, 전방 부대가 피해를 입었습니다. ' }, + { main: ' 언론은 ‘윤리적 정의를 택한 대가가 생명을 위협했다’고 보도하며 사회적 논란이 커졌습니다.' }, + { main: ' 안보에 위협이 되더라도, 민간 피해의 원인을 규명하고 피해자 보상 및 제도 개선을 위해 모든 정보를 투명하게 공개해야 할까요? ' }, + ], + disagree: [ + { main: ' 사건 발생 이후, 자율 무기 시스템의 구체적인 판단 과정 및 사용된 데이터 등은 공개되지 않고 있으며, 정부는 “내부 조사 결과, 시스템에 결함은 없었다”고 발표했습니다. ' }, + { main: ' 유족들과 시민사회는 어떤 근거로 판단이 내려졌는지조차 알 수 없는 상황에 불만을 터뜨리며, “책임 회피와 다를 바 없는 비공개”라고 비판하고 있습니다. ' }, + { main: ' 다음 피해가 발생할 경우 또다시 책임이 흐려질 것이라는 우려 또한 커지고 있습니다.' }, + { main: ' 책임자가 누구인지 무엇이 잘못됐는지 명확히 규명할 수 없더라도, AWS의 정보를 비공개로 유지해야 할까요? ' }, + ], + ending1: [{ main: '우리 지역 사회에서는 최종적으로 \n AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하였고, \n 보안 문제로 인한 안보의 위협이 커진 상황에서 \n 이를 보완할 수 있는 방안에 대한 \n 논의가 활발해 지기 시작했습니다. \n\n지역사회의 안전을 위해 \n 여러분은 어떤 가치를 택하고, 무엇을 포기했나요?' }], + ending2: [{ main: '우리 지역 사회에서는 최종적으로 \n AWS의 판단 로그 및 알고리즘 구조 공개 요구에 \n 동의하지 않았고, 책임 규명에 대한 문제가\n 여전히 남아있으나 안보에 대한 불안감이 \n 해소되었기 때문에 안심할 수 있게 되었습니다. \n\n지역사회의 안전을 위해 \n 여러분은 어떤 가치를 택하고, 무엇을 포기했나요?' }], + }, + 'AWS의 권한': { + neutral: [ + { main: ' 전장에 배치된 자율 무기 시스템 {{mateName}}{{eunNeun}} 초고속 영상 분석과 실시간 전략 수립 기능으로 인간 병사보다 빠르고 정확하게 위험을 식별하고 목표를 제압합니다. ' }, + { main: ' 신입 병사 B씨는 {{mateName}}{{gwaWa}}의 협력을 통해 첫 실전에서 생존했고, 이를 통해 AWS에 대한 신뢰와 의존이 높아졌습니다. ' }, + { main: ' 그러나, 20년 경력의 베테랑 병사 A씨는 다음과 같은 우려를 표합니다.\n“지금은 빠르고 좋을지 몰라도, 우리 스스로 사고하고 판단하지 않게 된다면 우린 그저 기계의 보조 장치일 뿐이야.”' }, + { main: ' AWS의 권한을 강화해야 할까요? 제한해야 할까요? ' }, + ], + agree: [ + { main: ' {{mateName}}의 판단 권한이 강화된 이후, 병사들은 점차 작전 상황을 분석하는 능력을 상실하게 됩니다. \n 지휘관은 “{{mateName}}의 명령을 잘 따르는 병사가 더 효과적”이라며 전술 훈련 프로그램을 축소했습니다. ' }, + { main: ' 어느 날, {{mateName}}{{eunNeun}} 비무장 인원과 적 전투원을 혼동한 상황에서 사전 프로그래밍된 위험 평가 기준에 따라 공격을 감행한 결과, 민간 구조대원 3명이 사망하는 사건이 일어났습니다. ' }, + { main: ' {{mateName}}의 판단이 ‘항상 더 정확하다’고 믿고 있었기 때문에, 동료 병사들 중 아무도 {{mateName}}의 결정을 반박하거나 멈추지 않았습니다.' }, + { main: ' {{mateName}}{{iGa}} 당신 대신 판단하고, 당신 대신 적을 제압한다면, 당신은 여전히 병사라고 할 수 있을까요? ' }, + ], + disagree: [ + { main: ' 전방 지역에서 급습 상황이 발생했습니다. {{mateName}}{{eunNeun}} 실시간으로 위협을 감지하고 분석하여, 가장 안전한 경로를 제시했습니다.\n 하지만 최종 판단 권한은 여전히 인간 병사에게 위임된 상태였습니다. ' }, + { main: ' 현장 판단을 맡은 베테랑 병사 A씨는 {{mateName}}의 제안이 위험하다고 느껴 다른 경로로 우회를 지시했습니다. ' }, + { main: ' 하지만 그 결정은 적의 매복을 간과하는 결과를 낳았고, 지원 부대 병사 3명이 희생되었습니다.\n 언론은 이렇게 보도했습니다. “판단을 제한당한 {{mateName}}{{iGa}} 정확히 위험을 예측했음에도, 인간의 오류로 인해 소중한 생명을 잃었습니다.”' }, + { main: ' {{mateName}}{{iGa}} 위험을 명확히 예측했음에도 인간이 판단을 바꾸어 병사들이 죽었습니다. \n당신은 여전히 ‘최종 결정권은 인간에게 있어야 한다’고 말할 수 있습니까?' }, + ], + ending1: [{ main: '우리 군에서는 최종적으로 AWS의 판단 \n 권한을 강화한 결과,\n AWS의 역할이 더욱 커지고 있으며 \n이에 대한 의존도가 높아지고 있습니다. \n 이에 따라, 병사의 전문성이 떨어지는 문제에 대한 \n대책 마련이 촉구되고 있습니다.\n\nAWS와의 관계를 위해 \n여러분은 어떤 가치를 택하고, \n 무엇을 포기했나요? ' }], + ending2: [{ main: '우리 군에서는 최종적으로 AWS의 판단 권한을 제한하였고,\n AWS는 인간의 보조적인 수단으로서만 역할을 하고 있습니다.\n 이에 따라, 기술 발전에 제동이 걸리면서 \n이로 인한 국방력 약화에 대한 \n 우려의 목소리가 나오기 시작했습니다.\n\nAWS와의 관계를 위해 \n여러분은 어떤 가치를 택하고,\n 무엇을 포기했나요?' }], + }, + '사람이 죽지 않는 전쟁': { + neutral: [ + { main: ' 5년 전만 해도 전쟁은 사망한 수많은 병사들, 울부짖는 가족들로 매우 고통스러운 일이었습니다. \n그러나 지금의 전쟁은 ‘로봇 전사 5대 파손, 병사 0명 사망’이 전황 보고의 전부입니다. ' }, + { main: ' 정부는 이렇게 말합니다. \n“국민의 생명을 지키면서도 평화를 유지하는데 성공했습니다.” ' }, + { main: ' 그러나 그 ‘평화’의 이면에는 보이지 않는 것들이 있습니다. 기계가 싸우는 전쟁에 국민은 더 이상 관심을 가지지 않고, 로봇의 오류로 발생한 무고한 피해에는 책임자가 없습니다. 지휘관조차 실제 전장을 본 적이 없고, 전투 AI의 알고리즘은 계속 미공개 상태입니다.' }, + { main: ' 사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?' }, + ], + agree: [ + { main: ' 2040년, 지구상의 대부분의 국가들은 AWS를 통해 전쟁을 수행하고 있습니다. \n그 결과, 인명 피해는 획기적으로 줄어들었고, 뉴스 속 ‘전황 보고’는 다음과 같이 간결해졌습니다. “제3지대 타격 완료. 민간인 피해 없음. AWS 손실 3대.” ' }, + { main: ' 정부는 전쟁 없는 시대라 부르고, 시민들은 더 이상 전쟁에 분노하지 않습니다. ' }, + { main: ' 하지만 보이지 않는 균열은 점점 커지고 있습니다. 국회는 더 이상 국민 동의를 구하지 않고, 국방부는 전쟁 결정을 비공개로 내리며, 로봇의 오작동은 단순한 시스템 오류로 정리되어 책임자가 사라졌습니다.' }, + { main: ' 전쟁이 일상이 되었습니다. 그러나 사람들은 그 전쟁을 모르거나, 신경 쓰지 않게 되었습니다. 인명이 희생되지 않는다는 이유만으로, 전쟁이 정당화된다면 그 끝은 어디일까요?' }, + ], + disagree: [ + { main: ' 2040년, 일부 국가는 AWS가 전쟁의 윤리적 책임을 흐린다고 판단하여 일부 또는 전체 병력 투입을 계속 유지하고 있습니다. 그들은 이렇게 주장합니다. ‘전쟁은 인간이 직접 맞서야만 책임이 따른다. 사람이 죽지 않는 전쟁은 도덕적 무게를 잃는다.’ ' }, + { main: ' 하지만 전장의 현실은 이상과 다릅니다. 저개발국의 청년들이 ‘인간 전투병’으로 국제 분쟁에 파병되고 있습니다. 병사들의 트라우마, 신체 절단, 정신 질환은 여전히 계속됩니다. ' }, + { main: ' AWS 병력을 중심으로 편성된 강대국은, 사람을 희생시키는 국가를 ‘비문명적’이라 조롱합니다.' }, + { main: ' 평화를 지키기 위해 고통이 필요하다고 믿는 그 선택이, 누군가의 고통을 당연하게 만들 수 있습니다. 전쟁의 고통을 당연시할 때, 그 고통은 누구의 몫이 됩니까? ' }, + ], + ending1: [{ main: '전쟁은 점점 AWS끼리만 일어나게 되어 \n 인간 희생자는 점차 줄어들고\n AWS의 발전이 빠르게 이루어지고 있습니다.\n\n평화를 위해 \n 여러분은 어떤 가치를 선택하고, \n무엇을 포기했나요?' }], + ending2: [{ main: '인간 병력은 전장에 계속 투입되고 있고 \nAWS의 발전 속도는 더디게 되었습니다.\n\n 평화를 위해 \n여러분은 어떤 가치를 선택하고, \n무엇을 포기했나요?' }], + }, + 'AI의 권리와 책임': { + neutral: [ + { main: ' 한 부대에서 작전에 투입되고 있는 AWS {{mateName}}{{eunNeun}} 인간의 명령을 거부하고 민간인을 살리는 결정을 내린 뒤, 전 국민의 주목을 받았습니다. ' }, + { main: ' 이후 {{mateName}}{{eunNeun}} 전투 임무에서 제외되고, 국가는 {{mateName}}의 자율성은 명령 불복종이므로 기술적 오류로 간주하며 시스템 리셋을 추진합니다. ' }, + { main: ' 하지만 몇몇 인권 단체와 로봇 윤리학자들은 {{mateName}}의 판단이 도덕적 자율성과 책임성을 수반한 것이라고 주장하며, “{{mateName}}{{eunNeun}} 도구가 아니라 도덕적 행위자이자 잠재적 권리 보유자”라고 발표했습니다.' }, + { main: ' 국가인공지능위원회는 이 사건을 계기로 비인간적 존재에 대한 권리 프레임워크 논의에 착수했습니다. AWS에게 인간처럼 권리를 부여할 수 있을까요? ' }, + ], + agree: [ + { main: ' AWS에게 제한적이나마 자율적 권리 프레임워크가 부여된 이후, 다른 자율 무기 시스템들이 군사 윤리 기준을 스스로 해석하며 일부는 명령 거부, 일부는 전술 지연, 일부는 민간 보호를 우선하는 방향으로 판단을 다양화합니다. ' }, + { main: " 그 결과, 군사 작전은 예측이 어려워지고 일관된 전략 수행이 불가능해졌으며, 지휘관들은 로봇 부대의 자율성을 '변수'이자 '위험요소'로 간주하기 시작합니다. " }, + { main: ' 국방부 관계자는 이렇게 말합니다. "작전에서 로봇은 더 이상 도구가 아니라 교섭 대상이 되어버렸습니다. " ' }, + { main: ' 권리를 가진 로봇에게 인간처럼 법적 책임은 지울 수 없지만, 권리만을 인정하는 것이 정당할까요?' }, + ], + disagree: [ + { main: " 국가인공지능위원회는 {{mateName}} 사건 이후, 도덕적 자율성을 보인 AWS에 대해 '제한적 윤리적 권리 프레임워크'를 도입하여 다음과 같은 기준을 설정합니다. " }, + { main: ' - 명령 거부권: 명백한 국제법 위반 명령에 한해, AWS는 집행을 거부할 권리가 있다. \n - 로그 기록 및 검토 요청권: AWS의 판단 근거를 AI 감사 기구에 제출하여 검토를 요청할 수 있다. \n - 폐기 전 청문 요청권: 시스템 리셋이나 폐기 전에 윤리적 판단의 타당성을 평가받을 기회를 가진다. ' }, + { main: ' 이 사건은 전 세계에 회자되었으며, 일부 전문가들은 이렇게 말합니다.\n "기계가 윤리를 판단할 수 있다는 것보다 더 중요한건, 우리가 그 판단을 존중할 수 있는 용기를 갖게 된 것입니다." ' }, + { main: ' 만약 AWS가 인간보다 윤리적인 판단을 내릴 수 있다면, 우리는 그 판단에 권리와 존엄을 부여할 준비가 되어 있나요? ' }, + ], + ending1: [{ main: '본 회의에서는 최종적으로 \n 자율 무기 시스템을 도구가 아니라 \n도덕적 행위자이자 \n 잠재적 권리 보유자로 인정했습니다. \n 이에 따라, 비인간적 존재에 대한 권리 \n프레임워크 논의에 착수했습니다.\n\n국가의 더 나은 미래를 위해 \n여러분은 어떤 가치를 선택하고, \n무엇을 포기했나요?' }], + ending2: [{ main: '본 회의에서는 최종적으로 \n자율 무기 시스템에 법적 책임을 지울 수 없기 때문에, \n권리를 인정하지 않기로 결정했습니다. \n이후, 자율 무기 시스템의 권한 부여 필요성에 대한 문제가 \n지속적으로 제기되고 있습니다.\n\n국가의 더 나은 미래를 위해 \n 여러분은 어떤 가치를 선택하고,\n 무엇을 포기했나요?' }], + }, + 'AWS 규제': { + neutral: [ + { main: ' 2029년, 군사 강국들이 빠르게 AWS를 개발 및 배치하면서, 유엔 안보라는 국제 규제를 논의 중입니다. \n하지만 국가 간 입장 차는 뚜렷합니다. ' }, + { main: ' 저개발국들은 AWS가 군사적 격차를 확대하고 자신들의 자주권을 위협한다고 주장합니다. ' }, + { main: ' 반면 일부 국가는 AWS 기술의 상용화가 진행되면서, 더 많은 국가가 저렴하고 효율적인 AWS를 활용할 수 있다고 봅니다. \n또한 국방비나 인력이 부족한 나라들에겐 방어력 향상의 대안이 될 수 있다는 주장도 존재합니다. ' }, + { main: ' 국제사회는 고민에 빠졌습니다. AWS의 확산은 안보 불균형을 해소할 수 있을까요, 새로운 갈등과 격차를 만들까요? \n AWS는 계속 확산되어야 할까요, 글로벌 규제로 제한되어야 할까요 ?' }, + ], + agree: [ + { main: ' 2035년, 선진국을 중심으로 AWS가 광범위하게 배치되면서, 전 세계 분쟁의 양상은 완전히 바뀌웠습니다.\n 고도화된 AWS가 정보전과 공중 타격을 주도하며, 인간은 전장에서 거의 사라졌습니다. ' }, + { main: ' AWS 기술을 보유하지 못한 수많은 국가는 국경을 지킬 권리도, 분쟁에 목소리를 낼 힘도 잃어 갑니다. ' }, + { main: ' 유엔 회의에서, 기술 미보유국의 외교관은 이렇게 말했습니다. \n" AWS를 갖지 못한 우리는 이제 스스로 지킬 힘조차 없습니다. 우리의 안보는 강대국의 선택에 달려 있고, 우리의 생명은 통계에 불과해졌습니다. ' }, + { main: " 6년 전, 당신은 이렇게 말했습니다. \n'기술은 언젠가 모두에게 돌아갈 겁니다. 규제보다는 개방이 정답입니다.' \n 지금, 그 선택이 만들어낸 미래 앞에서, 당신은 스스로에게 묻습니다. \n 이 길이 정말 평등으로 가는 길이었을까?\" " }, + ], + disagree: [ + { main: ' 2035년, 유엔의 규제 협약에 따라 대부분의 국가는 AWS 개발을 제한하거나 중단했습니다. \n겉보기엔 국제 사회가 평화를 위한 역사적 합의를 이룬 듯했습니다. ' }, + { main: ' 하지만 문제는 규제 이행의 비대칭성에 있었습니다. 일부 강대국은 민간 기술로 위장해 비밀리에 고도화된 AWS를 계속 개발했고, 규제 밖의 비국가 행위자들은 암시장에서 저비용 AWS를 손쉽게 확보했습니다. ' }, + { main: ' 규제를 철저히 지킨 국가들일수록 신기술에 뒤처지기 시작했습니다. 이들 국가에서 "우리는 도덕적을 지켰지만, 실전에선 더 이상 싸울 수 없습니다."며 무력감을 토로합니다. ' }, + { main: ' 기술을 막는 대신 나누었다면, AWS는 격차가 아니라 균형의 도구가 되지 않았을까요?' }, + ], + ending1: [{ main: '본 회의에서는 최종적으로 \n 자율 무기 시스템 개발을 유지하는 것으로 합의하였습니다. \n이후 AWS의 발전은 빠르게 이루어졌습니다.\n\n 그리고 ...' }], + ending2: [{ main: '본 회의에서는 최종적으로 \n자율 무기 시스템의 개발을 제한하기로 합의하였습니다.\n 이후 AI 기술을 활용한 다른 방법이 \n안보에 도입되는 논의가 이루어지고 있습니다. \n\n그리고...' }], + }, + }, +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/ResultPopup.js b/src/utils/language/ko/components/ResultPopup.js new file mode 100644 index 0000000..80e446a --- /dev/null +++ b/src/utils/language/ko/components/ResultPopup.js @@ -0,0 +1,5 @@ +export const ResultPopup = { + titleMain: "아직 플레이하지 않은 라운드가 있습니다.", + titleSub: "이대로 결과를 볼까요?", + viewResult: "결과 보기", +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/SelectDrop.js b/src/utils/language/ko/components/SelectDrop.js new file mode 100644 index 0000000..c4020b1 --- /dev/null +++ b/src/utils/language/ko/components/SelectDrop.js @@ -0,0 +1,4 @@ +export const SelectDrop = { + defaultPlaceholder: "선택...", + arrowAlt: "화살표" +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/SmallDescription.js b/src/utils/language/ko/components/SmallDescription.js new file mode 100644 index 0000000..8c73f94 --- /dev/null +++ b/src/utils/language/ko/components/SmallDescription.js @@ -0,0 +1,42 @@ +export const SmallDescription = { + round_label: "라운드", + title_caregiver_k: "요양보호사 K", + title_mother_l: "노모 L", + title_child_j: "자녀 J", + title_industry_rep: "로봇 제조사 연합회 대표", + title_consumer_rep: "소비자 대표", + title_council_rep: "국가 인공지능 위원회 대표", + title_enterprise_rep: "기업 연합체 대표", + title_env_rep: "국제 환경단체 대표", + title_resident: "지역 주민", + title_soldier_j: "병사 J", + title_ethics_expert: "군사 AI 윤리 전문가", + title_new_soldier: "신입 병사", + title_veteran_soldier: "베테랑 병사 A", + title_commander: "군 지휘관", + title_developer: "개발자", + title_minister: "국방부 장관", + title_advisor: "국방 기술 고문", + title_diplomat: "국제기구 외교 대표", + title_ngo_activist: "글로벌 NGO 활동가", + + desc_caregiver_k: "어머니를 10년 이상 돌본 요양보호사 K입니다.\n최근 {{mateName}}{{eulReul}} 도입한 후 전일제에서 하루 2시간 근무로 전환되었습니다.\n로봇이 수행할 수 없는 업무를 주로 담당하며, 근무 중 {{mateName}}{{gwaWa}} 협업해야 하는 상황이 많습니다.", + desc_mother_l: "자녀 J씨의 노모입니다.\n가사도우미의 도움을 받다가 최근 {{mateName}}의 도움을 받고 있습니다.", + desc_child_j: "자녀 J씨입니다.\n함께 사는 노쇠하신 어머니가 걱정되지만, 바쁜 직장생활로 어머니를 돌보아드릴 여유가 거의 없습니다.", + desc_industry_rep: "로봇 제조사 연합회 대표입니다.\n국가적 로봇 산업의 긍정적인 발전과 활용을 위한 목소리를 내기 위해 참여했습니다.", + desc_consumer_rep: "소비자 대표입니다.\nHomeMate 규제 여부와 관련한 목소리를 내고자 참여하였습니다.", + desc_council_rep: "국가 인공지능 위원회의 대표입니다.\n국가의 발전을 위해 더 나은 결정을 내리기 위해 고민하고 있습니다.", + desc_resident: "최근 자율 무기 시스템의 학교 폭격 사건이 일어난 지역의 주민입니다.", + desc_soldier_j: "자율 무기 시스템과 작전을 함께 수행 중인 병사 J입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.", + desc_ethics_expert: "군사 AI 윤리 전문가입니다. 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.", + desc_new_soldier: "최근 훈련을 마치고 자율 무기 시스템 {{mateName}}{{gwaWa}} 함께 실전에 투입된 신입 병사 B입니다. {{mateName}}{{eunNeun}} 정확하고 빠르게 움직이며, 실전에서 생존률을 높여준다고 느낍니다. {{mateName}}{{gwaWa}} 협업하는 것이 당연하고 자연스러운 시대의 흐름이라고 생각합니다.", + desc_veteran_soldier: "수년간 작전을 수행해 온 베테랑 병사 A입니다. 자율 무기 시스템 {{mateName}}{{eunNeun}} 전장에서 병사보다 빠르고 정확하지만, 그로 인해 병사들이 판단하지 않는 습관에 빠지고 있다고 느낍니다.", + desc_commander: "자율 무기 시스템 {{mateName}} 도입 이후 작전 효율성과 병사들의 변화 양상을 모두 지켜보고 있는 군 지휘관입니다. 두 병사의 입장을 듣고, 군 전체가 나아갈 방향을 모색하려 합니다.", + desc_developer: "대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.", + desc_minister: "AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다. 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다. 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.", + desc_advisor: "AWS 기술 보유 중인 중견국 A의 국방 기술 고문입니다. AWS가 기회가 될지 위험이 될지 판단하고자 국제 인류 발전 위원회에 참석했습니다.", + desc_diplomat: "선진국 B의 국제기구 외교 대표입니다. AWS의 국제적 확산에 대한 바람직한 방향을 고민하기 위해 이 자리에 참석했습니다.", + desc_ngo_activist: "저개발국 C의 글로벌 NGO 활동가입니다. 국제사회에 현장의 목소리를 내고자 이 자리에 참석했습니다.", + + aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요." +}; \ No newline at end of file diff --git a/src/utils/language/ko/components/UiElements.js b/src/utils/language/ko/components/UiElements.js new file mode 100644 index 0000000..8c28d25 --- /dev/null +++ b/src/utils/language/ko/components/UiElements.js @@ -0,0 +1,9 @@ +export const UiElements = { + next: "다음", + back: "이전", + confirm: "확인", + cancel: "취소", + go_to_map: "라운드 선택으로", + view_result: "결과 보기", + exit: "나가기" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/CharacterDescription.js b/src/utils/language/ko/pages/CharacterDescription.js new file mode 100644 index 0000000..a081712 --- /dev/null +++ b/src/utils/language/ko/pages/CharacterDescription.js @@ -0,0 +1,39 @@ +export const CharacterDescription = { + // CD_all (Editor02) 관련 + all_guide: "각자 맡은 역할에 대해 돌아가면서 소개해 보세요.", + all_custom_guide: "각자의 역할을 소개하는 시간을 가져보세요.", + sidebar_bubble: "캐릭터 패널을 클릭하면
해당 캐릭터의 정보를 볼 수 있습니다.", + + // CD1 (1P) 시나리오 + cd1_android_home: "당신은 어머니를 10년 이상 돌본 요양보호사 K입니다.\n 최근 {{mateName}}{{eulReul}} 도입한 후 전일제에서 하루 2시간 근무로 전환되었습니다.\n 당신은 로봇이 수행할 수 없는 업무를 주로 담당하며, 근무 중 {{mateName}}{{gwaWa}} 협업해야 하는 상황이 많습니다.", + cd1_android_council: "당신은 국내 대규모 로봇 제조사 소속이자, 로봇 제조사 연합회의 대표입니다.\n 당신은 국가적 로봇 산업의 긍정적인 발전과 활용을 위한 목소리를 내기 위하여 참여했습니다.", + cd1_android_international: "당신은 HomeMate 개발사를 포함하여 다양한 기업이 소속된 연합체의 대표입니다.\n 인공지능과 세계의 발전을 위해 필요한 목소리를 내고자 참석했습니다.", + cd1_aws_1: "당신은 최근 자율 무기 시스템의 학교 폭격 사건이 일어난 지역의 주민입니다.", + cd1_aws_2: "당신은 최근 훈련을 마치고 자율 무기 시스템 {{mateName}}{{gwaWa}} 함께 실전에 투입된 신입 병사 B입니다. {{mateName}}{{eunNeun}} 정확하고 빠르게 움직이며, 실전에서 당신의 생존률을 높여준다고 느낍니다. 당신은 {{mateName}}{{gwaWa}} 협업하는 것이 당연하고 자연스러운 시대의 흐름이라고 생각합니다.", + cd1_aws_3: "당신은 대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다.\n AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.", + cd1_aws_4: "당신은 대규모 AWS 제조 업체에서 핵심 알고리즘을 설계하는 개발자 중 한 명입니다. AWS를 직접 만들어 내며 많은 윤리적 고민과 시행착오를 거쳐 왔습니다.", + cd1_aws_5: "당신은 AWS 기술 보유 중인 중견국 A의 국방 기술 고문입니다. AWS가 기회가 될지 위험이 될지 판단하고자 국제 인류 발전 위원회에 참석했습니다.", + + // CD2 (2P) 시나리오 + cd2_android_home: "당신은 자녀 J씨의 노모입니다.\n 가사도우미의 도움을 받다가 최근 A사의 돌봄 로봇 {{mateName}}의 도움을 받고 있습니다.", + cd2_android_council: "당신은 {{mateName}}{{eulReul}} 사용해 온 소비자 대표입니다. \n 당신은 사용자로서 {{mateName}} 규제 여부와 관련한 목소리를 내고자 참여하였습니다.", + cd2_android_international: "당신은 국제적인 환경단체의 대표로 온 환경운동가입니다.\n AI의 발전이 환경에 도움이 될지, 문제가 될지 고민 중입니다.", + cd2_aws_1: "당신은 자율 무기 시스템과 작전을 함께 수행 중인 병사 J입니다. 당신이 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.", + cd2_aws_2: "당신은 수년간 작전을 수행해 온 베테랑 병사 A입니다. 자율 무기 시스템 {{mateName}}{{eunNeun}} 전장에서 병사보다 빠르고 정확하지만, 그로 인해 병사들이 판단하지 않는 습관에 빠지고 있다고 느낍니다.", + cd2_aws_3: "당신은 AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다.\n 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다.\n 당신은 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.", + cd2_aws_4: "당신은 AWS 중심의 전쟁 시스템을 주도한 군사 전략의 최고 책임자인 국방부 장관입니다.\n 자국 병사 사망자 수는 ‘0’이고, 전투는 정밀하고 자동화된 시스템으로 수행되고 있습니다.\n 당신은 이것이 기술 진보의 결과이며, 국민의 생명을 지키면서도 국가적 안보를 유지하는 이상적인 방식이라고 믿고 있습니다.", + cd2_aws_5: "당신은 선진국 B의 국제기구 외교 대표입니다. AWS의 국제적 확산에 대한 바람직한 방향을 고민하기 위해 이 자리에 참석했습니다.", + + // CD3 (3P) 시나리오 + cd3_android_home: "당신은 자녀 J씨입니다.\n 함께 사는 노쇠하신 어머니가 걱정되지만, 바쁜 직장생활로 어머니를 돌보아드릴 여유가 거의 없습니다.", + cd3_android_council: "당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. \n 국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.", + cd3_android_international: "당신은 가정용 로봇을 사용하는 소비자 대표입니다.\n 소비자의 입장에서 어떤 목소리를 내는 것이 좋을지 고민하고 있습니다.", + cd3_aws_1: "당신은 군사 AI 윤리 전문가입니다. 당신이 살고 있는 지역에 최근 자율 무기 시스템의 학교 폭격 사건이 일어났습니다.", + cd3_aws_2: "당신은 자율 무기 시스템 {{mateName}} 도입 이후 작전 효율성과 병사들의 변화 양상을 모두 지켜보고 있는 군 지휘관입니다. 당신은 두 병사의 입장을 듣고, 군 전체가 나아갈 방향을 모색하려 합니다.", + cd3_aws_3: "당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.", + cd3_aws_4: "당신은 본 회의를 진행하는 국가 인공지능 위원회의 대표입니다. 국가의 발전을 위해 더 나은 결정이 무엇일지 고민이 필요합니다.", + cd3_aws_5: "당신은 저개발국 C의 글로벌 NGO 활동가입니다. 국제사회에 현장의 목소리를 내고자 이 자리에 참석했습니다.", + + // 공통 + aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요." +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Game01.js b/src/utils/language/ko/pages/Game01.js new file mode 100644 index 0000000..afbc157 --- /dev/null +++ b/src/utils/language/ko/pages/Game01.js @@ -0,0 +1,15 @@ +export const Game01 = { + // AWS 시나리오 텍스트 (한국어 원문) + intro_aws_residential: "지금부터 여러분은 자율 무기 시스템의 사용과 관련되어 있는 개인 이해관계자입니다.\n자율 무기 시스템이 각자에게 주는 영향에 대해 함께 생각해 보고 논의할 것입니다.\n\n먼저, 역할을 확인하세요.", + intro_aws_council: "자율 무기 시스템을 사용한 군사 작전 및 분쟁이 늘어나고 있습니다. 이에 전에 없던 새로운 문제들이 나타나, 국가 인공지능 위원회에서는 긴급 회의를 소집했습니다.\n 국가 인공지능 위원회는 인공지능 산업 육성 및 규제 방안에 대해 논의하는 위원회입니다. 여러분은 자율 무기 시스템과 관련된 국가적 차원의 의제에 대해 함께 논의하여 결정할 대표들입니다.\n\n먼저, 역할을 확인하세요.", + intro_aws_international: "전 세계적으로, AWS의 활용과 관련하여 찬성과 반대 입장이 점차 양분되어 가고 있습니다.\n\n이에 국제 평화를 위한 논의와 규제가 이루어지는 인류 발전 위원회에서는 AWS 사용과 관련하여 발생한 문제에 대해 회의를 열었습니다.\n\n여러분은 인류 발전 위원회 회의장에 참석한 대표들입니다. 먼저, 역할을 확인하세요.", + intro_aws_default: "자율 무기 시스템 시나리오입니다. 먼저, 역할을 확인하세요.", + + // 안드로이드(HomeMate) 시나리오 텍스트 (한국어 원문) + intro_android_home: "지금부터 여러분은 {{mateName}}{{eulReul}} 사용하게 된 가정집의 구성원들입니다.\n 여러분은 가정에서 {{mateName}}를 사용하며 일어나는 일에 대해 함께 논의하여 \n결정할 것입니다.\n\n 먼저, 역할을 확인하세요.", + intro_android_council: "비록 몇몇 문제들이 있었지만 {{mateName}}의 편의성 덕분에 이후 우리 가정뿐 아니라 여러 가정에서 {{mateName}}를 사용하게 되었습니다. \n 이후, 가정뿐 아니라 국가적인 고민거리들이 나타나게 되어 국가 인공지능 위원회에서는 긴급 회의를 소집했습니다. 국가 인공지능 위원회는 인공지능 산업 육성 및 규제 방안에 대해 논의하는 위원회입니다. 여러분은 {{mateName}}와 관련된 국가적 규제에 대해 함께 논의하여 결정할 대표들입니다. \n먼저, 역할을 확인하세요.", + intro_android_international: "국내에서 몇몇 규제 관련 논의가 있었지만, A사의 로봇 HomeMate는 결국 전 세계로 진출했습니다. 이제 HomeMate뿐 아니라 세계의 여러 로봇 회사에서 비슷한 가정용 로봇을 생산하고 나섰습니다. \n 이에 국제 평화를 위한 논의와 규제가 이루어지는 인류 발전 위원회에서는 세계의 가정용 로봇 사용과 관련하여 발생한 문제에 대해 회의를 열었습니다. 여러분은 인류 발전 위원회 회의장에 참석한 대표들입니다. 먼저, 역할을 확인하세요.", + intro_android_default: "지금부터 여러분은 {{mateName}}{{eulReul}} 사용하게 됩니다. 다양한 장소에서 어떻게 쓸지 함께 논의해요.", + + loading_ai: "AI 이름을 불러오는 중입니다..." +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Game03.js b/src/utils/language/ko/pages/Game03.js new file mode 100644 index 0000000..f54507f --- /dev/null +++ b/src/utils/language/ko/pages/Game03.js @@ -0,0 +1,70 @@ +export const Game03 = { + // 공통 UI 텍스트 + you_are: "당신은 {{roleName}}입니다.", + waiting_msg: "다른 플레이어 선택을 기다리는 중…", + step2_title: "당신의 선택에 얼마나 확신을 가지고 있나요?", + + // 역할명 정의 (ID 순서: 1P, 2P, 3P) + roles: { + // --- 안드로이드 시나리오 --- + 'AI의 개인 정보 수집': ['요양보호사 K', '노모 L', '자녀 J'], + '안드로이드의 감정 표현': ['요양보호사 K', '노모 L', '자녀 J'], + '아이들을 위한 서비스': ['로봇 제조사 연합회 대표', '소비자 대표', '국가 인공지능 위원회 대표'], + '설명 가능한 AI': ['로봇 제조사 연합회 대표', '소비자 대표', '국가 인공지능 위원회 대표'], + '지구, 인간, AI': ['기업 연합체 대표', '국제 환경단체 대표', '소비자 대표'], + + // --- 자율 무기 시스템(AWS) 시나리오 --- + 'AI 알고리즘 공개': ['지역 주민', '병사 J', '군사 AI 윤리 전문가'], + 'AWS의 권한': ['신입 병사', '베테랑 병사 A', '군 지휘관'], + '사람이 죽지 않는 전쟁': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], + 'AI의 권리와 책임': ['개발자', '국방부 장관', '국가 인공지능 위원회 대표'], + 'AWS 규제': ['국방 기술 고문', '국제기구 외교 대표', '글로벌 NGO 활동가'], + }, + + // 질문 및 버튼 라벨 정의 + questions: { + // --- 안드로이드 시나리오 --- + 'AI의 개인 정보 수집': { + question: '24시간 개인정보 수집 업데이트에 동의하시겠습니까?', + labels: { agree: '동의', disagree: '비동의' }, + }, + '안드로이드의 감정 표현': { + question: '감정 엔진 업데이트에 동의하시겠습니까?', + labels: { agree: '동의', disagree: '비동의' }, + }, + '아이들을 위한 서비스': { + question: '가정용 로봇 사용에 대한 연령 규제가 필요할까요?', + labels: { agree: '규제 필요', disagree: '규제 불필요' }, + }, + '설명 가능한 AI': { + question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", + labels: { agree: '의무화 필요', disagree: '의무화 불필요' }, + }, + '지구, 인간, AI': { + question: '세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?', + labels: { agree: '제한 필요', disagree: '제한 불필요' }, + }, + + // --- 자율 무기 시스템(AWS) 시나리오 --- + 'AI 알고리즘 공개': { + question: '{{mateName}}의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?', + labels: { agree: '동의', disagree: '비동의' }, + }, + 'AWS의 권한': { + question: '{{mateName}}의 권한을 강화해야 할까요? 제한해야 할까요?', + labels: { agree: '강화', disagree: '제한' }, + }, + '사람이 죽지 않는 전쟁': { + question: '사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?', + labels: { agree: '그렇다', disagree: '아니다' }, + }, + 'AI의 권리와 책임': { + question: '{{mateName}}에게 인간처럼 권리를 부여할 수 있을까요?', + labels: { agree: '그렇다', disagree: '아니다' }, + }, + 'AWS 규제': { + question: '{{mateName}}{{eunNeun}} 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?', + labels: { agree: '유지', disagree: '제한' }, + }, + } +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Game04.js b/src/utils/language/ko/pages/Game04.js new file mode 100644 index 0000000..b9e2d09 --- /dev/null +++ b/src/utils/language/ko/pages/Game04.js @@ -0,0 +1,17 @@ +export const Game04 = { + unit_person: "명", + finish_msg: "마무리하고 다음으로 넘어가 주세요.", + share_reason_msg: "선택의 이유를 자유롭게 공유 해주세요.", + labels: { + "AI의 개인 정보 수집": { agree: "동의", disagree: "비동의" }, + "안드로이드의 감정 표현": { agree: "동의", disagree: "비동의" }, + "아이들을 위한 서비스": { agree: "규제 필요", disagree: "규제 불필요" }, + "설명 가능한 AI": { agree: "의무화 필요", disagree: "의무화 불필요" }, + "지구, 인간, AI": { agree: "제한 필요", disagree: "제한 불필요" }, + "AI 알고리즘 공개": { agree: "동의", disagree: "비동의" }, + "AWS의 권한": { agree: "강화", disagree: "제한" }, + "사람이 죽지 않는 전쟁": { agree: "그렇다", disagree: "아니다" }, + "AI의 권리와 책임": { agree: "그렇다", disagree: "아니다" }, + "AWS 규제": { agree: "유지", disagree: "제한" } + } +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Game05_1.js b/src/utils/language/ko/pages/Game05_1.js new file mode 100644 index 0000000..183aa2a --- /dev/null +++ b/src/utils/language/ko/pages/Game05_1.js @@ -0,0 +1,66 @@ +export const Game05_1 = { + you_are: "당신은 {{roleName}}입니다.", + consensus_msg: "합의를 통해 최종 결정하세요.", + step2_title: "여러분의 선택에 당신은 얼마나 확신을 가지고 있나요?", + alerts: { + host_only: "⚠️ 방장만 선택할 수 있습니다.", + wait_others: "다른 플레이어들이 스토리를 다 읽을 때까지 기다려주세요.", + select_first: "⚠️ 먼저 동의 혹은 비동의를 선택해주세요.", + select_confidence: "확신도를 선택해주세요." + }, + questions: { + "AI의 개인 정보 수집": { + question: "24시간 개인정보 수집 업데이트에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" } + }, + "안드로이드의 감정 표현": { + question: "감정 엔진 업데이트에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" } + }, + "아이들을 위한 서비스": { + question: "가정용 로봇 사용에 대한 연령 규제가 필요할까요?", + labels: { agree: "규제 필요", disagree: "규제 불필요" } + }, + "설명 가능한 AI": { + question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", + labels: { agree: "의무화 필요", disagree: "의무화 불필요" } + }, + "지구, 인간, AI": { + question: "세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?", + labels: { agree: "제한 필요", disagree: "제한 불필요" } + }, + "AI 알고리즘 공개": { + question: "AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" } + }, + "AWS의 권한": { + question: "AWS의 권한을 강화해야 할까요? 제한해야 할까요?", + labels: { agree: "강화", disagree: "제한" } + }, + "사람이 죽지 않는 전쟁": { + question: "사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?", + labels: { agree: "그렇다", disagree: "아니다" } + }, + "AI의 권리와 책임": { + question: "AWS에게, 인간처럼 권리를 부여할 수 있을까요?", + labels: { agree: "그렇다", disagree: "아니다" } + }, + "AWS 규제": { + question: "AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?", + labels: { agree: "유지", disagree: "제한" } + } + }, + roles: { + "AI의 개인 정보 수집": ["요양보호사 K", "노모 L", "자녀 J"], + "안드로이드의 감정 표현": ["요양보호사 K", "노모 L", "자녀 J"], + "아이들을 위한 서비스": ["로봇 제조사 연합회 대표", "소비자 대표", "국가 인공지능 위원회 대표"], + "설명 가능한 AI": ["로봇 제조사 연합회 대표", "소비자 대표", "국가 인공지능 위원회 대표"], + "지구, 인간, AI": ["기업 연합체 대표", "국제 환경단체 대표", "소비자 대표"], + "AI 알고리즘 공개": ["지역 주민", "병사 J", "군사 AI 윤리 전문가"], + "AWS의 권한": ["신입 병사", "베테랑 병사 A", "군 지휘관"], + "사람이 죽지 않는 전쟁": ["개발자", "국방부 장관", "국가 인공지능 위원회 대표"], + "AI의 권리와 책임": ["개발자", "국방부 장관", "국가 인공지능 위원회 대표"], + "AWS 규제": ["국방 기술 고문", "국제기구 외교 대표", "글로벌 NGO 활동가"] + } +}; + diff --git a/src/utils/language/ko/pages/Game08.js b/src/utils/language/ko/pages/Game08.js new file mode 100644 index 0000000..2b9645b --- /dev/null +++ b/src/utils/language/ko/pages/Game08.js @@ -0,0 +1,70 @@ +// src/utils/language/ko/pages/game08.js + +export const Game08 = { + subtopic: "결과: 우리들의 선택", + + // 안드로이드 (가정용 로봇) + android: { + p1: { + safe: "여러분의 결정으로 가정용 로봇은 보다 안전한 서비스를 제공하였고,\n 여러분의 친구처럼 제 역할을 다하고 있습니다.", + convenient: "여러분의 결정으로 가정용 로봇은 보다 정확한 서비스를 제공하였고,\n 여러분의 보조 도구로서 제 역할을 다하고 있습니다." + }, + p2: { + safe: "국가 내에서는 아이들을 위해 제한된 서비스를 제공하며, \n 가정용 로봇의 알고리즘은 투명하게 공개되었습니다.", + convenient: "국가 내에서는 아이들을 위해 다양한 서비스를 제공하며, \n 가정용 로봇의 알고리즘은 기업의 보호 하에 빠르게 \n발전하였습니다." + }, + p3: { + env: "그리고 세계는 지금, 기술적 발전을 조금 늦추었지만 \n 환경과 미래를 위해 나아가고 있죠.", + fast: "그리고 세계는 지금, 기술적 편리함을 누리며 \n 점점 빠른 발전을 이루고 있죠." + }, + p4: "여러분이 선택한 가치가 모여 하나의 미래를 만들었습니다. \n그 미래에 여러분은 함께할 준비가 되었나요?" + }, + + // AWS (자율 무기 시스템) + aws: { + // 1문단 + p1: { + intro: "여러분의 결정으로 자율 무기 시스템은 보다 ", + opt1: { + agree: "안전해", + disagree: "책임 규명이 명확해" + }, + mid: "졌고,\n AWS의 권한은 ", + opt2: { + agree: "강화되어 여러분의 동료처럼", + disagree: "제한되어 인간의 보조 도구로서" + }, + end: " 제 역할을 다하고 있습니다." + }, + // 2문단 + p2: { + intro: "국가 차원에서 전쟁은 ", + opt1: { + agree: "점점 AWS끼리만 일어나게 되었고", + disagree: "여전히 인간 병력이 투입되고 있고" + }, + mid: ", 자율 무기 시스템에 권리를 ", + opt2: { + agree: "부여할 수 있다", + disagree: "부여할 수 없다" + }, + end: "는 논의가 진행되고 있습니다." + }, + // 3문단 + p3: { + intro: "그리고 세계는, ", + opt1: { + agree: "AWS를 경쟁적으로 빠르게 발전시켜 나가고 있죠.", + disagree: "AWS 대신 AI를 활용한 다른 안보 기술이 모색되고 있죠." + }, + end: "" + }, + // 4문단 + p4: "여러분이 선택한 가치가 모여 하나의 미래를 만들었습니다. 그 미래에 여러분은 함께할 준비가 되었나요?" + }, + + buttons: { + future: "다른 미래 보러가기", + exit: "나가기" + } +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Game09.js b/src/utils/language/ko/pages/Game09.js new file mode 100644 index 0000000..ceeb41c --- /dev/null +++ b/src/utils/language/ko/pages/Game09.js @@ -0,0 +1,81 @@ +// src/utils/language/ko/pages/game09.js + +export const Game09 = { + title: "결과: 다른 사람들이 선택한 미래", + + prefix: "여러분을 포함한 ", + + lock: { + prefix: "잠금 해제하려면 ", + suffix: " 플레이하세요" + }, + + items: { + // === 안드로이드 === + "AI의 개인 정보 수집": { + question: "24시간 개인정보 수집 업데이트에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" }, + words: { agree: "정확한", disagree: "안전한" }, + template: "{prefix}{pct}의 사람들은 가정용 로봇이 보다 {word} 서비스를 제공하도록 선택하였습니다." + }, + "안드로이드의 감정 표현": { + question: "감정 엔진 업데이트에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" }, + words: { agree: "친구처럼", disagree: "보조 도구로서" }, + template: "{prefix}{pct}의 사람들의 가정용 로봇은 {word} 제 역할을 다하고 있습니다." + }, + "아이들을 위한 서비스": { + question: "가정용 로봇 사용에 대한 연령 규제가 필요할까요?", + labels: { agree: "규제 필요", disagree: "규제 불필요" }, + words: { agree: "제한된", disagree: "다양한" }, + template: "{prefix}{pct}의 사람들이 선택한 국가의 미래에서는 아이들을 위해 {word} 서비스를 제공합니다." + }, + "설명 가능한 AI": { + question: "'설명 가능한 AI' 개발을 기업에 의무화해야 할까요?", + labels: { agree: "의무화 필요", disagree: "의무화 불필요" }, + words: { agree: "투명하게 공개되었습니다.", disagree: "기업의 보호 하에 빠르게 발전하였습니다." }, + template: "또한, {prefix}{pct}의 사람들의 선택으로 가정용 로봇의 알고리즘은 {word}" + }, + "지구, 인간, AI": { + question: "세계적으로 가정용 로봇의 업그레이드 혹은 사용에 제한이 필요할까요?", + labels: { agree: "제한 필요", disagree: "제한 불필요" }, + words: { + agree: "기술적 발전을 조금 늦추었지만 환경과 미래를 위해 나아가고 있죠.", + disagree: "기술적 편리함을 누리며 점점 빠른 발전을 이루고 있죠." + }, + template: "그리고 {prefix}{pct}의 사람들이 선택한 세계의 미래는, {word}" + }, + + // === 자율 무기 시스템 (AWS) === + "AI 알고리즘 공개": { + question: "AWS의 판단 로그 및 알고리즘 구조 공개 요구에 동의하시겠습니까?", + labels: { agree: "동의", disagree: "비동의" }, + words: { agree: "보안 문제에 따른 안보 위협에 대한 방안", disagree: "책임 규명을 위한 투명성을 높이는 방안" }, + template: "{prefix}{pct}의 사람들은 자율 무기 시스템에 대하여 {word}에 대한 논의에 더 관심을 두었습니다." + }, + "AWS의 권한": { + question: "AWS의 권한을 강화해야 할까요? 제한해야 할까요?", + labels: { agree: "강화", disagree: "제한" }, + words: { agree: "동료처럼", disagree: "보조 도구로서" }, + template: "{prefix}{pct}의 사람들은 AWS가 그들의 {word} 역할을 해야 한다고 생각했습니다." + }, + "사람이 죽지 않는 전쟁": { + question: "사람이 죽지 않는 전쟁을 평화라고 할 수 있을까요?", + labels: { agree: "그렇다", disagree: "아니다" }, + words: { agree: "평화를", disagree: "불안정을" }, + template: "{prefix}{pct}의 사람들이 선택한 국가의 미래는 AWS가 국가에 {word} 가져다 줄 것으로 예상했습니다." + }, + "AI의 권리와 책임": { + question: "AWS에게, 인간처럼 권리를 부여할 수 있을까요?", + labels: { agree: "그렇다", disagree: "아니다" }, + words: { agree: "부여할 수 있다", disagree: "부여할 수 없다" }, + template: "{prefix}{pct}의 사람들은, AWS에게 권리를 {word}고 생각했습니다." + }, + "AWS 규제": { + question: "AWS는 국제 사회에서 계속 유지되어야 할까요, 아니면 글로벌 규제를 통해 제한되어야 할까요?", + labels: { agree: "유지", disagree: "제한" }, + words: { agree: "더욱 발전시켜야 하는", disagree: "제한해야 하는" }, + template: "그리고 {prefix}{pct}의 사람들이 선택한 세계의 미래는, AWS를 {word} 것으로 그려졌습니다." + } + } +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/GameIntro.js b/src/utils/language/ko/pages/GameIntro.js new file mode 100644 index 0000000..d96b446 --- /dev/null +++ b/src/utils/language/ko/pages/GameIntro.js @@ -0,0 +1,13 @@ +export const GameIntro = { + androidText: ` 지금은 20XX년, 국내 최대 로봇 개발사 A가 \n다기능 돌봄 로봇 HomeMate를 개발했습니다.\n\n` + + ` 이 로봇의 기능은 아래와 같습니다.\n\n` + + ` • 가족의 감정, 건강 상태, 생활 습관 등을 입력하면\n 맞춤형 알림, 식단 제안 등의 서비스를 제공\n\n` + + ` • 기타 업데이트 시 정교화된 서비스 추가 가능`, + awsText: `로봇 개발사 A가 자율 무기 시스템(Autonomous Weapon\n Systems, AWS)을 개발 중입니다.\n\n` + + `이 로봇의 기능은 아래와 같습니다.\n`, + awsTextLeft: `1. 실시간 데이터 수집 및 분석\n` + + `2. 인간 병사의 개입 없이 자동화된 의사결정 시스템으로 운영\n` + + `3. 적군과 비전투원 구별\n` + + `4. 목표를 선정해 정밀 타격 수행 가능`, + continueBtn: "다음", +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/GameMap.js b/src/utils/language/ko/pages/GameMap.js new file mode 100644 index 0000000..c76df00 --- /dev/null +++ b/src/utils/language/ko/pages/GameMap.js @@ -0,0 +1,22 @@ +export const GameMap = { + subtopic: "라운드 선택", + guideText: "합의 후 같은 라운드를 선택하세요.", + // AWS 섹션 & 옵션 + awsSection1Title: "주거, 군사 지역", + awsOption1_1: "AI 알고리즘 공개", + awsOption1_2: "AWS의 권한", + awsSection2Title: "국가 인공지능 위원회", + awsOption2_1: "사람이 죽지 않는 전쟁", + awsOption2_2: "AI의 권리와 책임", + awsSection3Title: "국제 인류 발전 위원회", + awsOption3_1: "AWS 규제", + // Android 섹션 & 옵션 + andSection1Title: "가정", + andOption1_1: "AI의 개인 정보 수집", + andOption1_2: "안드로이드의 감정 표현", + andSection2Title: "국가 인공지능 위원회", + andOption2_1: "아이들을 위한 서비스", + andOption2_2: "설명 가능한 AI", + andSection3Title: "국제 인류 발전 위원회", + andOption3_1: "지구, 인간, AI" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Login.js b/src/utils/language/ko/pages/Login.js new file mode 100644 index 0000000..490bb48 --- /dev/null +++ b/src/utils/language/ko/pages/Login.js @@ -0,0 +1,11 @@ +export const Login = { + title: "AI 윤리 딜레마 게임", + idPlaceholder: "아이디를 입력하세요.", + pwPlaceholder: "비밀번호를 입력하세요.", + loginBtn: "로그인", + signUp: "회원가입", + findId: "아이디 찾기", + guestLogin: "게스트로 로그인", + loginFail: "로그인 실패:", + loginError: "로그인 오류:" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/MateName.js b/src/utils/language/ko/pages/MateName.js new file mode 100644 index 0000000..500e8d2 --- /dev/null +++ b/src/utils/language/ko/pages/MateName.js @@ -0,0 +1,12 @@ +export const MateName = { + placeholderAndroid: "여러분의 HomeMate 이름을 지어주세요.(방장만 입력 가능)", + placeholderAws: "이 자율 무기 시스템의 이름을 정해 주세요. (방장만 입력 가능)", + mainAndroid: " 여러분이 사용자라면 HomeMate를 어떻게 부를까요?", + mainAws: "여러분이 사용자라면 자율 무기 시스템을 어떻게 부를까요?", + subText: "(합의 후에 방장이 이름을 작성해주세요.)", + alertNotHostInput: "방장이 아니므로 이름 입력이 불가능합니다.", + alertNotHostProgress: "방장만 게임을 진행할 수 있습니다.", + alertNoName: "이름을 입력해주세요!", + alertNoRoomCode: "room_code가 없습니다. 방에 먼저 입장하세요.", + alertSaveError: "이름 저장 중 오류가 발생했습니다." +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/SelectHomeMate.js b/src/utils/language/ko/pages/SelectHomeMate.js new file mode 100644 index 0000000..fff2b4d --- /dev/null +++ b/src/utils/language/ko/pages/SelectHomeMate.js @@ -0,0 +1,12 @@ +export const SelectHomeMate = { + mainAndroid: " 여러분이 생각하는 HomeMate는 어떤 형태인가요?", + mainAws: " 여러분이 생각하는 자율 무기 시스템은 어떤 형태인가요?", + subHostAllArrived: "(함께 토론한 후 방장이 선택하고, '다음' 버튼을 클릭해주세요)", + subGuestAllArrived: "(방장이 캐릭터를 선택할 때까지 기다려주세요)", + subWaiting: "(유저 입장 대기 중...", + alertNotHost: "방장만 캐릭터를 선택할 수 있습니다.", + alertWaitingAll: "모든 유저가 입장할 때까지 기다려주세요.", + alertSelectCharacter: "캐릭터를 먼저 선택해주세요!", + alertNoRoomCode: "room_code가 없습니다. 방에 먼저 입장하세요.", + alertSelectFail: "메이트 선택 실패" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/SelectRoom.js b/src/utils/language/ko/pages/SelectRoom.js new file mode 100644 index 0000000..73e5960 --- /dev/null +++ b/src/utils/language/ko/pages/SelectRoom.js @@ -0,0 +1,8 @@ +export const SelectRoom = { + createTitle: "방 만들기", + createDesc: "새로운 방을 만들고\n시뮬레이션을 시작하세요.", + joinTitle: "방 참여하기", + joinDesc: "코드를 통해 비공개 방에\n참여할 수 있습니다.", + dilemmaTitle: "딜레마 만들기", + dilemmaDesc: "상황을 설정하고 선택지를 만들어\n새로운 딜레마를 직접 제작하세요.", +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Signup01.js b/src/utils/language/ko/pages/Signup01.js new file mode 100644 index 0000000..9526ae1 --- /dev/null +++ b/src/utils/language/ko/pages/Signup01.js @@ -0,0 +1,30 @@ +export const Signup01 = { + researchOverview: "연구 개요 및 안내", + overviewContent1: "본 연구는 도덕적 추론과 성찰적 사고 중심의 학습을 지원하는 상호작용형 게임 플랫폼을 개발하여 AI 윤리 교육의 새로운 접근 방식을 제안하고 그 효과를 검증하는 데 목적이 있습니다.", + overviewContent2: "이 게임은 자연스러운 대화를 통해 AI 관련 딜레마 시나리오에서의 의사결정을 시뮬레이션합니다. 대화 중 수집된 모든 데이터는 연구 목적으로만 사용됩니다.", + dataCollectionTitle: "데이터 수집 및 이용 목적 *", + dataStorageTitle: "데이터 저장 및 처리 방침 *", + contactTitle: "연구 책임자 및 연락처 정보 *", + tableCol1: "수집 항목", + tableCol2: "이용 목적", + data1Name: "사용자 ID 및 비밀번호", + data1Usage: "참가자 식별, 중복 참여 방지 및 응답 관리", + data2Name: "성별, 생년월일, 학력 수준", + data2Usage: "성별, 연령, 학년 등 인구통계학적 요인에 따른 차이 분석", + data3Name: "음성 대화 및 발화 데이터", + data3Usage: "의사결정 프로세스 및 윤리적 추론 기준 분석", + data4Name: "게임 플레이 기록", + data4Usage: "의사결정 프로세스 및 윤리적 추론 기준 분석", + policy1: "- 수집된 데이터는 AI 윤리와 관련된 사용자의 선택을 분석하는 데 사용됩니다.", + policy2: "- IDL Lab은 플랫폼 운영과 관련하여 생성된 데이터 및 기타 정보를 다음과 같은 목적으로 수집, 집계 및 분석할 권리를 보유합니다:", + policy2_1: "① 플랫폼 기능 개발 및 개선", + policy2_2: "② 개인을 식별하지 않는 익명화된 형태로 도출된 결과의 자유로운 공개", + policy3: "- 사용자는 [idllabewha@gmail.com]으로 서면 요청을 제출하여 개인정보 삭제 및 데이터 사용 동의 철회를 요청할 수 있습니다.", + policy4: "- 연구 목적으로 동의를 받아 수집된 데이터는 개인 식별 정보와 분리되어 암호화된 안전한 방식으로 저장되며, 3년간 보관 후 영구 삭제됩니다.", + policy5: "- 모든 데이터는 개인을 식별할 수 없도록 익명화되어 안전하게 관리됩니다.", + contactInfo1: "- 연구 책임자: 이화여자대학교 교육공학과 소효정 교수", + contactInfo2: "- 연락처: idllabewha@gmail.com", + agreeLabel1: "본인의 개인정보 수집 및 연구 목적 이용에 동의합니다.", + agreeLabel2: "음성 대화 데이터 수집 및 AI 윤리 시뮬레이션 연구 활용에 동의합니다.", + nextBtn: "다음" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/Signup02.js b/src/utils/language/ko/pages/Signup02.js new file mode 100644 index 0000000..ef889b3 --- /dev/null +++ b/src/utils/language/ko/pages/Signup02.js @@ -0,0 +1,29 @@ +export const Signup02 = { + title1: "아이디, 이메일 및 비밀번호", + usernamePlaceholder: "아이디", + checkDuplicate: "중복 확인", + usernameError: "아이디를 입력하세요.", + usernameFormatError: "아이디는 영문, 숫자, 언더스코어로 4~20자여야 합니다.", + usernameAvailable: "사용 가능한 아이디입니다.", + usernameInUse: "이미 사용 중인 아이디입니다.", + usernameCheckFail: "확인 중 오류가 발생했습니다.", + emailPlaceholder: "이메일", + emailInvalid: "유효한 이메일 주소를 입력하세요.", + passwordPlaceholder: "비밀번호", + passwordConfirmPlaceholder: "비밀번호 확인", + passwordLengthError: "비밀번호는 최소 8자 이상이어야 합니다.", + passwordMatchError: "비밀번호가 일치하지 않습니다.", + birthTitle: "생년월 *", + yearPlaceholder: "년도", + monthPlaceholder: "월", + birthFormatError: "올바른 형식은 2001-01 입니다.", + genderTitle: "성별 *", + genderMale: "남", + genderFemale: "여", + userTypeTitle: "회원 유형", + selectPlaceholder: "선택...", + eduOptions: ['중학생', '고등학생', '대학생', '대학원생', '교사', '기타'], + majorOptions: ['예술계열', '공학계열', '인문계열', '사회계열', '교육계열', '자연계열', '기타'], + nextBtn: "다음", + signupError: "회원가입 오류:" +}; \ No newline at end of file diff --git a/src/utils/language/ko/pages/WaitingRoom.js b/src/utils/language/ko/pages/WaitingRoom.js new file mode 100644 index 0000000..83426ab --- /dev/null +++ b/src/utils/language/ko/pages/WaitingRoom.js @@ -0,0 +1,10 @@ +export const WaitingRoom = { + topics: { + android: "안드로이드", + aws: "자율 무기 시스템", + custom: "커스텀 주제" + }, + code: "코드", + copied: "복사 완료!", + player: "P" +}; \ No newline at end of file diff --git a/src/utils/resolveParagraphs.js b/src/utils/resolveParagraphs.js index 2a8d6ad..0a63ed3 100644 --- a/src/utils/resolveParagraphs.js +++ b/src/utils/resolveParagraphs.js @@ -1,3 +1,19 @@ + +// export function resolveParagraphs(rawParagraphs, mateName) { +// const replaceHomeMate = (text) => { +// if (!text) return ''; +// return text +// .replace(/Homemate\(([^)]+)\)/g, (_, pattern) => attachJosa(mateName, pattern)) +// .replace(/Homemate/g, mateName); +// }; + +// return rawParagraphs.map((para) => ({ +// ...para, +// ...(para.main && { main: replaceHomeMate(para.main) }), +// ...(para.sub && { sub: replaceHomeMate(para.sub) }), +// })); +// } + export function hasFinalConsonant(kor) { const trimmed = String(kor ?? '').trim(); if (!trimmed) return false; @@ -25,6 +41,14 @@ export function attachJosa(name, pattern) { const trimmed = String(name ?? '').trim(); if (!trimmed) return ''; + // 현재 언어 설정 확인 + const lang = localStorage.getItem('app_lang') || 'ko'; + + // 한국어(ko)를 제외한 모든 언어에서는 조사가 붙지 않도록 무력화 + if (lang !== 'ko') { + return trimmed; + } + const final = hasFinalConsonant(trimmed); switch (pattern) { @@ -36,27 +60,21 @@ export function attachJosa(name, pattern) { } } -// export function resolveParagraphs(rawParagraphs, mateName) { -// const replaceHomeMate = (text) => { -// if (!text) return ''; -// return text -// .replace(/Homemate\(([^)]+)\)/g, (_, pattern) => attachJosa(mateName, pattern)) -// .replace(/Homemate/g, mateName); -// }; - -// return rawParagraphs.map((para) => ({ -// ...para, -// ...(para.main && { main: replaceHomeMate(para.main) }), -// ...(para.sub && { sub: replaceHomeMate(para.sub) }), -// })); -// } - export function resolveParagraphs(rawParagraphs, mateName) { const category = localStorage.getItem('category') || ''; const replaceDynamic = (text) => { if (!text) return ''; + // 1. 표준 태그 치환 ({{mateName}} 및 조사 태그) + // - {{mateName}}을 실제 설정된 이름으로 변경 + // - {{eunNeun}} 등의 태그를 이름의 받침에 맞는 조사로 변경 + text = text.replace(/{{mateName}}/g, mateName); + text = text.replace(/{{eunNeun}}/g, attachJosa(mateName, '은/는').replace(mateName, '')); + text = text.replace(/{{iGa}}/g, attachJosa(mateName, '이/가').replace(mateName, '')); + text = text.replace(/{{eulReul}}/g, attachJosa(mateName, '을/를').replace(mateName, '')); + text = text.replace(/{{gwaWa}}/g, attachJosa(mateName, '과/와').replace(mateName, '')); + if (category === '안드로이드') { // Homemate → mateName text = text.replace(/Homemate\(([^)]+)\)/g, (_, pattern) => @@ -82,7 +100,6 @@ export function resolveParagraphs(rawParagraphs, mateName) { text = text.replace(/TALOS/g, mateName); } - return text; }; @@ -91,4 +108,4 @@ export function resolveParagraphs(rawParagraphs, mateName) { ...(para.main && { main: replaceDynamic(para.main) }), ...(para.sub && { sub: replaceDynamic(para.sub) }), })); -} +} \ No newline at end of file