From 9745d9101650c650247fe83162baef82ebf299ea Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Mon, 27 Apr 2026 07:45:39 +0000 Subject: [PATCH] minigame 4 colour question --- nginx/srv1361159.hstgr.cloud | 10 + www/html/Admin/admin.css | 67 +++++ www/html/Admin/admin.js | 253 +++++++++++++++++- www/html/Admin/api/game-quiz-settings.php | 55 +++- www/html/Admin/game-quiz-settings.php | 195 ++++++++++++++ www/html/Admin/index.html | 51 +++- www/html/Game/data/quiz-settings.json | 11 +- .../Game/public/api-quiz-carry-from-disk.php | 48 ++++ www/html/Game/public/editor.html | 2 +- www/html/Game/public/js/editor.js | 3 +- www/html/Game/public/js/play.js | 186 ++++++++++++- www/html/Game/public/play.html | 19 +- www/html/Game/public/room-lobby.html | 16 +- www/html/Game/server.js | 120 ++++++++- 14 files changed, 990 insertions(+), 46 deletions(-) create mode 100644 www/html/Admin/game-quiz-settings.php create mode 100644 www/html/Game/public/api-quiz-carry-from-disk.php diff --git a/nginx/srv1361159.hstgr.cloud b/nginx/srv1361159.hstgr.cloud index e7a76b2..f60986b 100644 --- a/nginx/srv1361159.hstgr.cloud +++ b/nginx/srv1361159.hstgr.cloud @@ -63,6 +63,16 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + # ชื่อไฟล์ .php อยู่ใต้ /Game/ ไม่ใช่ /Game/api/ — fastcgi จะหา $document_root/Game/... ผิดที่ (ไฟล์จริงใน Game/public/) → ส่งไป Node แทน + location = /Game/api-quiz-carry-from-disk.php { + client_max_body_size 20M; + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } location ~ ^/Game/(?!socket\.io/)(.+\.(html|js))$ { alias /var/www/html/Game/public/$1; add_header Cache-Control "no-store, no-cache, must-revalidate"; diff --git a/www/html/Admin/admin.css b/www/html/Admin/admin.css index d9bc8e3..c8b55b1 100644 --- a/www/html/Admin/admin.css +++ b/www/html/Admin/admin.css @@ -1466,6 +1466,73 @@ code { } } +/* —— Quiz carry: map panel theme (color + alpha) —— */ +.quiz-carry-theme-grid { + display: flex; + flex-direction: column; + gap: 0.85rem; + margin-top: 0.25rem; +} +.quiz-carry-theme-row { + display: grid; + grid-template-columns: minmax(7.5rem, 10rem) 1fr; + gap: 0.65rem 1rem; + align-items: center; +} +.quiz-carry-theme-row--narrow { + grid-template-columns: 1fr; + max-width: 12rem; +} +.quiz-carry-theme-label { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); +} +.quiz-carry-color-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem 1rem; +} +.quiz-carry-color-controls input[type='color'] { + width: 44px; + height: 36px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + cursor: pointer; +} +.quiz-carry-alpha-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + font-size: 0.8rem; + color: var(--muted); + flex: 1; + min-width: min(100%, 220px); +} +.quiz-carry-alpha-label input[type='range'] { + flex: 1; + min-width: 120px; + max-width: 280px; + accent-color: var(--accent); +} +.quiz-carry-alpha-out { + font-variant-numeric: tabular-nums; + min-width: 2.75rem; + font-size: 0.8rem; + color: var(--text); +} +.quiz-carry-theme-code { + font-size: 0.72rem; + color: var(--muted); + word-break: break-all; + max-width: 100%; + flex-basis: 100%; +} + /* —— Quiz Battle admin (โดม + ป๊อปอัป cyber) —— */ .tab-panel-quiz-battle { --qb-cyan: #00f2ff; diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index 72aa2b2..29c5038 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -352,8 +352,194 @@ }); } - function loadQuizCarryPanel() { - gameQuizFetch('GET').then(function (data) { + /** ค่าเริ่มต้น RGBA สำหรับแผงคำถามบนแผนที่ (quiz_carry) */ + var QUIZ_CARRY_THEME_DEFAULTS = { + bg: { r: 12, g: 14, b: 28, a: 0.88 }, + border: { r: 255, g: 214, b: 102, a: 0.7 }, + text: { r: 241, g: 245, b: 249, a: 1 }, + }; + + function quizCarryClampByte(n) { + return Math.max(0, Math.min(255, Math.round(Number(n)) || 0)); + } + + function quizCarryHexFromRgb(r, g, b) { + return '#' + [r, g, b].map(function (x) { + var h = quizCarryClampByte(x).toString(16); + return h.length === 1 ? '0' + h : h; + }).join(''); + } + + function quizCarryParseHexToRgb(hex) { + var h = String(hex || '').trim().replace(/^#/, ''); + if (!h) return null; + if (h.length === 3) { + return { + r: parseInt(h[0] + h[0], 16), + g: parseInt(h[1] + h[1], 16), + b: parseInt(h[2] + h[2], 16), + alpha01: null, + }; + } + if (h.length === 6) { + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + alpha01: null, + }; + } + if (h.length === 8) { + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + alpha01: parseInt(h.slice(6, 8), 16) / 255, + }; + } + return null; + } + + function quizCarryParseCssColor(str, fallback) { + var s = String(str == null ? '' : str).trim(); + if (!s) return fallback; + var m = /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/i.exec(s); + if (m) { + var a = m[4] != null && m[4] !== '' ? parseFloat(m[4]) : 1; + if (!Number.isFinite(a)) a = 1; + return { + r: quizCarryClampByte(parseInt(m[1], 10)), + g: quizCarryClampByte(parseInt(m[2], 10)), + b: quizCarryClampByte(parseInt(m[3], 10)), + a: Math.max(0, Math.min(1, a)), + }; + } + if (/^#/.test(s)) { + var pr = quizCarryParseHexToRgb(s); + if (!pr) return fallback; + var a = pr.alpha01 != null ? pr.alpha01 : 1; + return { r: pr.r, g: pr.g, b: pr.b, a: Math.max(0, Math.min(1, a)) }; + } + return fallback; + } + + function quizCarryRgbToRgbaString(o) { + var a = Math.max(0, Math.min(1, Number(o.a))); + var t = Math.round(a * 1000) / 1000; + return 'rgba(' + quizCarryClampByte(o.r) + ', ' + quizCarryClampByte(o.g) + ', ' + quizCarryClampByte(o.b) + ', ' + t + ')'; + } + + function quizCarryRowPrefix(kind) { + return 'quiz-carry-theme-' + kind; + } + + function quizCarryThemeHiddenId(kind) { + if (kind === 'bg') return 'quiz-carry-theme-bg'; + if (kind === 'border') return 'quiz-carry-theme-border'; + return 'quiz-carry-theme-text'; + } + + function quizCarrySetPickersFromColorString(kind, cssStr) { + var fb = QUIZ_CARRY_THEME_DEFAULTS[kind]; + var o = quizCarryParseCssColor(cssStr, fb); + var pref = quizCarryRowPrefix(kind); + var sw = el(pref + '-swatch'); + var ra = el(pref + '-alpha'); + var out = el(pref + '-alpha-out'); + var hi = el(quizCarryThemeHiddenId(kind)); + var pr = el(pref + '-preview'); + if (!sw || !ra || !hi) return; + sw.value = quizCarryHexFromRgb(o.r, o.g, o.b); + var pct = Math.max(0, Math.min(100, Math.round(Number(o.a) * 100))); + ra.value = String(pct); + if (out) out.textContent = pct + '%'; + hi.value = quizCarryRgbToRgbaString(o); + if (pr) pr.textContent = hi.value; + } + + function quizCarryComposeFromPickers(kind) { + var pref = quizCarryRowPrefix(kind); + var sw = el(pref + '-swatch'); + var ra = el(pref + '-alpha'); + if (!sw || !ra) return ''; + var pr = quizCarryParseHexToRgb(sw.value); + if (!pr) return ''; + var pct = parseInt(String(ra.value), 10); + if (!Number.isFinite(pct)) pct = 100; + pct = Math.max(0, Math.min(100, pct)); + return quizCarryRgbToRgbaString({ r: pr.r, g: pr.g, b: pr.b, a: pct / 100 }); + } + + function quizCarrySyncRowOutput(kind) { + var s = quizCarryComposeFromPickers(kind); + var hi = el(quizCarryThemeHiddenId(kind)); + var pref = quizCarryRowPrefix(kind); + var ra = el(pref + '-alpha'); + var out = el(pref + '-alpha-out'); + var pr = el(pref + '-preview'); + if (hi) hi.value = s; + if (out && ra) out.textContent = String(Math.max(0, Math.min(100, parseInt(ra.value, 10) || 0))) + '%'; + if (pr && hi) pr.textContent = hi.value; + } + + /** อ่านค่าจาก color + range โดยตรง (ไม่พึ่ง hidden ที่อาจว่าง) ก่อน PUT */ + function quizCarryBuildThemeForSave() { + quizCarryBindThemePickersOnce(); + ['bg', 'border', 'text'].forEach(function (k) { + quizCarrySyncRowOutput(k); + }); + var fbBg = QUIZ_CARRY_THEME_DEFAULTS.bg; + var fbBr = QUIZ_CARRY_THEME_DEFAULTS.border; + var fbTx = QUIZ_CARRY_THEME_DEFAULTS.text; + var sBg = quizCarryComposeFromPickers('bg'); + var sBr = quizCarryComposeFromPickers('border'); + var sTx = quizCarryComposeFromPickers('text'); + var bwEl = el('quiz-carry-theme-border-w'); + var bw = bwEl ? parseInt(String(bwEl.value || '2'), 10) : 2; + if (!Number.isFinite(bw)) bw = 2; + bw = Math.max(0, Math.min(12, Math.round(bw))); + return { + panelBg: (sBg && sBg.trim()) ? sBg.trim().slice(0, 120) : quizCarryRgbToRgbaString(fbBg), + panelBorder: (sBr && sBr.trim()) ? sBr.trim().slice(0, 120) : quizCarryRgbToRgbaString(fbBr), + textColor: (sTx && sTx.trim()) ? sTx.trim().slice(0, 120) : quizCarryRgbToRgbaString(fbTx), + borderWidthPx: bw, + }; + } + + function quizCarryBindThemePickersOnce() { + if (quizCarryBindThemePickersOnce._done) return; + ['bg', 'border', 'text'].forEach(function (kind) { + var pref = quizCarryRowPrefix(kind); + var sw = el(pref + '-swatch'); + var ra = el(pref + '-alpha'); + if (!sw || !ra) return; + if (ra.getAttribute('data-bound') === '1') return; + ra.setAttribute('data-bound', '1'); + function sync() { + quizCarrySyncRowOutput(kind); + } + sw.addEventListener('input', sync); + sw.addEventListener('change', sync); + ra.addEventListener('input', sync); + ra.addEventListener('change', sync); + }); + quizCarryBindThemePickersOnce._done = true; + } + + function loadQuizCarryPanel(opts) { + opts = opts || {}; + var delay = Number(opts.fetchDelayMs) || 0; + var runLoad = function () { + gameQuizFetch('GET').then(function (data) { + if (opts.themeOverride && typeof opts.themeOverride === 'object') { + data.carryMapPanelTheme = opts.themeOverride; + } + if (opts.timingOverride && typeof opts.timingOverride === 'object') { + var t = opts.timingOverride; + if (t.carryReadMs != null) data.carryReadMs = t.carryReadMs; + if (t.carryAnswerMs != null) data.carryAnswerMs = t.carryAnswerMs; + if (t.carrySessionLength != null) data.carrySessionLength = t.carrySessionLength; + } renderQuizCarryAdminList(data.carryQuestions || []); var readSec = el('quiz-carry-read-sec'); var ansSec = el('quiz-carry-answer-sec'); @@ -370,10 +556,23 @@ var sl = parseInt(String(data.carrySessionLength), 10); sessLen.value = String(Number.isFinite(sl) && sl >= 0 ? sl : 0); } - setMsg('quiz-carry-settings-msg', '', ''); + quizCarryBindThemePickersOnce(); + var th = data.carryMapPanelTheme && typeof data.carryMapPanelTheme === 'object' ? data.carryMapPanelTheme : {}; + quizCarrySetPickersFromColorString('bg', th.panelBg); + quizCarrySetPickersFromColorString('border', th.panelBorder); + quizCarrySetPickersFromColorString('text', th.textColor); + var bwInp = el('quiz-carry-theme-border-w'); + if (bwInp) { + var bw = parseInt(String(th.borderWidthPx), 10); + bwInp.value = String(Number.isFinite(bw) && bw >= 0 ? Math.min(12, bw) : 2); + } + if (opts.clearMsg !== false) setMsg('quiz-carry-settings-msg', '', ''); }).catch(function (e) { setMsg('quiz-carry-settings-msg', e.message || 'โหลดไม่ได้', 'error'); }); + }; + if (delay > 0) setTimeout(runLoad, delay); + else runLoad(); } function saveQuizCarryPanel() { @@ -426,13 +625,52 @@ if (!Number.isFinite(carrySessionLength) || carrySessionLength < 0) carrySessionLength = 0; if (carrySessionLength > 500) carrySessionLength = 500; - gameQuizFetch('PUT', { - carryQuestions: carryQuestions, + var carryMapPanelTheme = quizCarryBuildThemeForSave(); + + var blockEls = list.querySelectorAll('.quiz-carry-admin-block'); + var hasAnyQuestionText = false; + for (var bi = 0; bi < blockEls.length; bi++) { + var qn = blockEls[bi].querySelector('.quiz-carry-q-text'); + if (qn && String(qn.value || '').trim()) { + hasAnyQuestionText = true; + break; + } + } + if (carryQuestions.length === 0 && hasAnyQuestionText) { + setMsg( + 'quiz-carry-settings-msg', + 'ยังบันทึกชุดคำถามไม่ได้: แต่ละข้อต้องมีคำถาม ตัวเลือกอย่างน้อย 2 ช่องที่กรอกแล้ว และเลือก «ข้อที่ถูกต้อง» — ตรวจแถวที่มีข้อความค้าง', + 'error', + ); + return; + } + + var putBody = { carryReadMs: carryReadMs, carryAnswerMs: carryAnswerMs, carrySessionLength: carrySessionLength, - }).then(function () { - setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว', 'ok'); + carryMapPanelTheme: carryMapPanelTheme, + }; + if (carryQuestions.length > 0) { + putBody.carryQuestions = carryQuestions; + } + + gameQuizFetch('PUT', putBody).then(function (res) { + var tail = carryQuestions.length ? ' · คำถาม ' + carryQuestions.length + ' ข้อ' : ' · คงชุดคำถามเดิม (อัปเดตเวลา/ธีมอย่างเดียว)'; + var themeFromRes = res && res.carryMapPanelTheme && typeof res.carryMapPanelTheme === 'object' ? res.carryMapPanelTheme : null; + var themeOk = !!themeFromRes; + var themeTail = themeOk ? ' · ธีมแผงบันทึกแล้ว' : ''; + setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว' + tail + themeTail, 'ok'); + loadQuizCarryPanel({ + clearMsg: false, + themeOverride: themeFromRes || carryMapPanelTheme, + timingOverride: { + carryReadMs: carryReadMs, + carryAnswerMs: carryAnswerMs, + carrySessionLength: carrySessionLength, + }, + fetchDelayMs: 120, + }); }).catch(function (e) { setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); }); @@ -1578,6 +1816,7 @@ }); } if (btnQuizCarrySave) btnQuizCarrySave.addEventListener('click', saveQuizCarryPanel); + quizCarryBindThemePickersOnce(); var btnQbBattleAdd = el('btn-qb-battle-add'); var btnQbBattleSave = el('btn-qb-battle-save'); diff --git a/www/html/Admin/api/game-quiz-settings.php b/www/html/Admin/api/game-quiz-settings.php index 7fb7a8e..0e2ff72 100644 --- a/www/html/Admin/api/game-quiz-settings.php +++ b/www/html/Admin/api/game-quiz-settings.php @@ -6,7 +6,7 @@ declare(strict_types=1); * แก้กรณี nginx ส่ง /Game/ เป็น static แล้ว PUT ไม่ถึง Node — เบราว์เซอร์เรียก PHP แทน * * รองรับ Node เวอร์ชันเก่าที่ GET ไม่ส่ง battleQuizMcq / PUT เขียนทับไฟล์แล้วตัดฟิลด์: - * - GET: ดึง battleQuizMcq จากไฟล์บนดิสก์มาแปะในคำตอบ + * - GET: ดึงจากดิสก์มาแปะทับบางฟิลด์ (battleQuizMcq + carry*) เพราะหลัง PUT เรา merge ลงไฟล์ใต้ www เสมอ — Node อาจอ่านคนละ path กับ PHP * - PUT: merge body ลง quiz-settings.json บนดิสก์เสมอ (หลัง proxy Node) เพื่อให้ข้อมูลคงอยู่หลังรีเฟรช */ require __DIR__ . '/_common.php'; @@ -37,16 +37,22 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool } $path = quiz_settings_disk_path(); $base = []; + $hadFileButInvalidJson = false; if (is_file($path)) { $prev = @file_get_contents($path); if ($prev !== false && $prev !== '') { $d = json_decode($prev, true); if (is_array($d)) { $base = $d; + } else { + $hadFileButInvalidJson = true; } } } - $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'questions', 'carryQuestions', 'battleQuizMcq']; + if ($hadFileButInvalidJson) { + return false; + } + $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq']; foreach ($mergeKeys as $k) { if (array_key_exists($k, $patch)) { $base[$k] = $patch[$k]; @@ -66,9 +72,10 @@ function merge_quiz_settings_disk_from_put(string $rawBody): bool } /** - * Node เก่าอาจไม่ใส่ battleQuizMcq ใน JSON — อ่านจากดิสก์มาแปะให้แอดมินโหลดได้หลังรีเฟรช + * แปะค่าจาก quiz-settings.json ใต้ docroot ทับ JSON จาก Node — ให้แอดมินรีเฟรชแล้วเห็นค่าที่ merge หลัง PUT (กันคนละไฟล์กับ Node) + * Overlay keys from on-disk quiz-settings.json over Node JSON — Admin refresh matches post-PUT merge (fixes Node vs disk path mismatch). */ -function overlay_battle_quiz_mcq_from_disk(string $nodeBody): string +function overlay_quiz_settings_from_disk(string $nodeBody): string { $j = json_decode($nodeBody, true); if (!is_array($j)) { @@ -83,10 +90,23 @@ function overlay_battle_quiz_mcq_from_disk(string $nodeBody): string return $nodeBody; } $disk = json_decode($raw, true); - if (!is_array($disk) || !array_key_exists('battleQuizMcq', $disk)) { + if (!is_array($disk)) { return $nodeBody; } - $j['battleQuizMcq'] = $disk['battleQuizMcq']; + if (array_key_exists('battleQuizMcq', $disk)) { + $j['battleQuizMcq'] = $disk['battleQuizMcq']; + } + if (array_key_exists('carryMapPanelTheme', $disk) && is_array($disk['carryMapPanelTheme'])) { + $j['carryMapPanelTheme'] = $disk['carryMapPanelTheme']; + } + foreach (['carryReadMs', 'carryAnswerMs', 'carrySessionLength'] as $ck) { + if (array_key_exists($ck, $disk)) { + $j[$ck] = $disk[$ck]; + } + } + if (array_key_exists('carryQuestions', $disk) && is_array($disk['carryQuestions'])) { + $j['carryQuestions'] = $disk['carryQuestions']; + } $enc = json_encode($j, JSON_UNESCAPED_UNICODE); return $enc !== false ? $enc : $nodeBody; } @@ -141,7 +161,26 @@ function proxy_quiz_curl(string $method, ?string $body = null): void header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); - echo json_encode(['ok' => true, 'diskFallback' => true], JSON_UNESCAPED_UNICODE); + $payload = ['ok' => true, 'diskFallback' => true]; + $rawDisk = @file_get_contents(quiz_settings_disk_path()); + if ($rawDisk !== false && $rawDisk !== '') { + $dj = json_decode($rawDisk, true); + if (is_array($dj)) { + if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) { + $payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme']; + } + if (array_key_exists('carryReadMs', $dj)) { + $payload['carryReadMs'] = $dj['carryReadMs']; + } + if (array_key_exists('carryAnswerMs', $dj)) { + $payload['carryAnswerMs'] = $dj['carryAnswerMs']; + } + if (array_key_exists('carrySessionLength', $dj)) { + $payload['carrySessionLength'] = $dj['carrySessionLength']; + } + } + } + echo json_encode($payload, JSON_UNESCAPED_UNICODE); exit; } json_response([ @@ -154,7 +193,7 @@ function proxy_quiz_curl(string $method, ?string $body = null): void header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store'); if ($method === 'GET' && $code >= 200 && $code < 300) { - echo overlay_battle_quiz_mcq_from_disk($out); + echo overlay_quiz_settings_from_disk($out); } else { echo $out; } diff --git a/www/html/Admin/game-quiz-settings.php b/www/html/Admin/game-quiz-settings.php new file mode 100644 index 0000000..0d9f6f1 --- /dev/null +++ b/www/html/Admin/game-quiz-settings.php @@ -0,0 +1,195 @@ + false, 'error' => 'ต้องเปิด PHP extension curl (ติดตั้งแพ็กเกจ php-curl แล้วรีสตาร์ท PHP-FPM)'], 500); +} + +$port = preg_replace('/[^0-9]/', '', (string)(getenv('GAME_NODE_PORT') ?: '3001')) ?: '3001'; +$host = getenv('GAME_NODE_INTERNAL_HOST') ?: '127.0.0.1'; +$target = 'http://' . $host . ':' . $port . '/Game/api/quiz-settings'; + +function quiz_settings_disk_path(): string +{ + return dirname(__DIR__, 2) . '/Game/data/quiz-settings.json'; +} + +/** + * รวมฟิลด์ที่ส่งมาใน PUT เข้ากับไฟล์บนดิสก์ (ไม่ทับฟิลด์ที่ไม่ได้ส่งมา) + */ +function merge_quiz_settings_disk_from_put(string $rawBody): bool +{ + $patch = json_decode($rawBody, true); + if (!is_array($patch)) { + return false; + } + $path = quiz_settings_disk_path(); + $base = []; + if (is_file($path)) { + $prev = @file_get_contents($path); + if ($prev !== false && $prev !== '') { + $d = json_decode($prev, true); + if (is_array($d)) { + $base = $d; + } + } + } + $mergeKeys = ['readMs', 'answerMs', 'betweenMs', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryMapPanelTheme', 'questions', 'carryQuestions', 'battleQuizMcq']; + foreach ($mergeKeys as $k) { + if (array_key_exists($k, $patch)) { + $base[$k] = $patch[$k]; + } + } + $dir = dirname($path); + if (!is_dir($dir)) { + if (!@mkdir($dir, 0755, true)) { + return false; + } + } + $json = json_encode($base, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + if ($json === false) { + return false; + } + return @file_put_contents($path, $json) !== false; +} + +/** + * Node เก่าอาจไม่ใส่ battleQuizMcq ใน JSON — อ่านจากดิสก์มาแปะให้แอดมินโหลดได้หลังรีเฟรช + */ +function overlay_battle_quiz_mcq_from_disk(string $nodeBody): string +{ + $j = json_decode($nodeBody, true); + if (!is_array($j)) { + return $nodeBody; + } + $path = quiz_settings_disk_path(); + if (!is_file($path)) { + return $nodeBody; + } + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + return $nodeBody; + } + $disk = json_decode($raw, true); + if (!is_array($disk) || !array_key_exists('battleQuizMcq', $disk)) { + return $nodeBody; + } + $j['battleQuizMcq'] = $disk['battleQuizMcq']; + $enc = json_encode($j, JSON_UNESCAPED_UNICODE); + return $enc !== false ? $enc : $nodeBody; +} + +function proxy_quiz_curl(string $method, ?string $body = null): void +{ + global $target; + $ch = curl_init($target); + if ($ch === false) { + json_response(['ok' => false, 'error' => 'curl init failed'], 500); + } + $headers = ['Accept: application/json']; + if ($body !== null && $method !== 'GET') { + $headers[] = 'Content-Type: application/json'; + } + $opts = [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]; + if ($body !== null && $method !== 'GET') { + $opts[CURLOPT_POSTFIELDS] = $body; + } + curl_setopt_array($ch, $opts); + $out = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($method === 'PUT' && $body !== null && $body !== '') { + merge_quiz_settings_disk_from_put($body); + } + + if ($out === false || $out === '') { + if ($method === 'GET') { + $disk = quiz_settings_disk_path(); + if (is_file($disk)) { + $rawFile = @file_get_contents($disk); + if ($rawFile !== false && $rawFile !== '') { + http_response_code(200); + header('Content-Type: application/json; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store'); + echo $rawFile; + exit; + } + } + } + if ($method === 'PUT' && $body !== null && $body !== '' && is_file(quiz_settings_disk_path())) { + http_response_code(200); + header('Content-Type: application/json; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store'); + $payload = ['ok' => true, 'diskFallback' => true]; + $rawDisk = @file_get_contents(quiz_settings_disk_path()); + if ($rawDisk !== false && $rawDisk !== '') { + $dj = json_decode($rawDisk, true); + if (is_array($dj)) { + if (isset($dj['carryMapPanelTheme']) && is_array($dj['carryMapPanelTheme'])) { + $payload['carryMapPanelTheme'] = $dj['carryMapPanelTheme']; + } + if (array_key_exists('carryReadMs', $dj)) { + $payload['carryReadMs'] = $dj['carryReadMs']; + } + if (array_key_exists('carryAnswerMs', $dj)) { + $payload['carryAnswerMs'] = $dj['carryAnswerMs']; + } + if (array_key_exists('carrySessionLength', $dj)) { + $payload['carrySessionLength'] = $dj['carrySessionLength']; + } + } + } + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + exit; + } + json_response([ + 'ok' => false, + 'error' => 'ไม่ต่อถึงเซิร์ฟเวอร์เกม (Node) — ตรวจว่า pm2/systemd รัน Game/server.js อยู่ที่พอร์ต ' . (getenv('GAME_NODE_PORT') ?: '3001') . ' · ' . $err, + ], 502); + } + http_response_code($code > 0 ? $code : 500); + header('Content-Type: application/json; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store'); + if ($method === 'GET' && $code >= 200 && $code < 300) { + echo overlay_battle_quiz_mcq_from_disk($out); + } else { + echo $out; + } + exit; +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +if ($method === 'GET') { + proxy_quiz_curl('GET', null); +} +if ($method === 'PUT') { + $raw = file_get_contents('php://input'); + if ($raw === false) { + $raw = ''; + } + proxy_quiz_curl('PUT', $raw); +} + +json_response(['ok' => false, 'error' => 'Method not allowed'], 405); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 570e03d..2ae322c 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -9,7 +9,7 @@ - + @@ -180,6 +180,53 @@ สุ่มคำถามจากชุดจนกว่าจะครบจำนวนนี้ (นับทุกข้อที่จบด้วยถูกหรือหมดเวลา) · 0 = เล่นต่อไม่จบอัตโนมัติ · English: Preview / editor embed only. +
+ แผงข้อความบนแผนที่ (คำถาม / ตัวเลือกที่ถือ) +

ใช้กับ #quiz-map-question-panel ในโหมดหยิบมาวาง (โซนทองหรือโซนกลาง) · เก็บที่ carryMapPanelTheme ใน quiz-settings.json · English: Color pickers + alpha (0–100%) — saved as rgba().

+
+
+ พื้นหลังแผง +
+ + + +
+ +
+
+ สีขอบ +
+ + + +
+ +
+
+ +
+
+ สีตัวอักษร +
+ + + +
+ +
+
+

รายการคำถาม

@@ -521,6 +568,6 @@
- + diff --git a/www/html/Game/data/quiz-settings.json b/www/html/Game/data/quiz-settings.json index 288e779..baf87ff 100644 --- a/www/html/Game/data/quiz-settings.json +++ b/www/html/Game/data/quiz-settings.json @@ -117,5 +117,14 @@ ], "correctIndex": 0 } - ] + ], + "carryReadMs": 3000, + "carryAnswerMs": 5000, + "carrySessionLength": 10, + "carryMapPanelTheme": { + "panelBg": "rgba(255, 0, 0, 0)", + "panelBorder": "rgba(255, 255, 255, 0)", + "textColor": "rgba(241, 245, 249, 1)", + "borderWidthPx": 0 + } } \ No newline at end of file diff --git a/www/html/Game/public/api-quiz-carry-from-disk.php b/www/html/Game/public/api-quiz-carry-from-disk.php new file mode 100644 index 0000000..d03d398 --- /dev/null +++ b/www/html/Game/public/api-quiz-carry-from-disk.php @@ -0,0 +1,48 @@ + - +
v —
diff --git a/www/html/Game/public/js/editor.js b/www/html/Game/public/js/editor.js index 0df4b71..51ef0e2 100644 --- a/www/html/Game/public/js/editor.js +++ b/www/html/Game/public/js/editor.js @@ -1709,7 +1709,8 @@ var q = page + '?space=' + encodeURIComponent(sid) + '&nick=' + encodeURIComponent(nick) + '&map=' + encodeURIComponent(id) - + '&preview=1&defaultChar=1&editorEmbed=1'; + + '&preview=1&defaultChar=1&editorEmbed=1' + + '&_h=' + Date.now(); if (statusEl) statusEl.textContent = 'เปิดหน้าทดสอบเล่นในแท็บใหม่แล้ว — ปิดแท็บเมื่อเล่นเสร็จ'; window.open(BASE + '/' + q, '_blank', 'noopener,noreferrer'); }) diff --git a/www/html/Game/public/js/play.js b/www/html/Game/public/js/play.js index c8eb336..921e77d 100644 --- a/www/html/Game/public/js/play.js +++ b/www/html/Game/public/js/play.js @@ -580,6 +580,10 @@ let playHostId = null; /** ซิงก์จากเซิร์ฟเวอร์: socketId → กด Ready แล้ว */ let quizCarryLobbyReadyMap = {}; + /** จาก /api/quiz-settings carryMapPanelTheme — ใช้กับ #quiz-map-question-panel เฉพาะ quiz_carry */ + let quizCarryMapPanelTheme = null; + /** สแนปจาก join-space (Node) — ใช้เมื่อ fetch HTTP quiz-settings ไม่ได้หรือไม่มีธีม */ + let quizCarryJoinSettingsSnap = null; /** jump_survive — กล้องตามตัวในกรอบ + แพลตฟอร์มเลื่อนขึ้น (scroll) ไม่ลากทั้งฉาก */ let jumpSurviveCamCenterX = 0; @@ -733,6 +737,92 @@ return getTileBoundsForOnesGrid(md.quizQuestionArea); } + function clearQuizMapPanelThemeInline(panel, textEl) { + if (!panel || !textEl) return; + panel.style.removeProperty('--qmap-bg'); + panel.style.removeProperty('--qmap-border'); + panel.style.removeProperty('--qmap-border-w'); + panel.style.removeProperty('--qmap-shadow'); + panel.style.removeProperty('background'); + panel.style.removeProperty('border'); + panel.style.removeProperty('border-color'); + panel.style.removeProperty('border-width'); + panel.style.removeProperty('border-style'); + panel.style.removeProperty('box-shadow'); + textEl.style.removeProperty('--qmap-text'); + textEl.style.removeProperty('--qmap-text-shadow'); + textEl.style.removeProperty('color'); + textEl.style.removeProperty('text-shadow'); + } + + function parseCarryMapPanelThemeObject(raw) { + let t = raw; + if (typeof t === 'string') { + try { + t = JSON.parse(t); + } catch (e) { + t = null; + } + } + if (!t || typeof t !== 'object') return null; + const bw = Number(t.borderWidthPx); + const borderWidthPx = Number.isFinite(bw) ? Math.max(0, Math.min(12, Math.round(bw))) : 2; + return { + panelBg: String(t.panelBg || '').trim().slice(0, 120), + panelBorder: String(t.panelBorder || '').trim().slice(0, 120), + borderWidthPx, + textColor: String(t.textColor || '').trim().slice(0, 120), + }; + } + + function setQuizCarryMapPanelThemeFromApi(s) { + quizCarryMapPanelTheme = null; + if (!s || typeof s !== 'object') return; + const parsed = parseCarryMapPanelThemeObject(s.carryMapPanelTheme); + if (!parsed) return; + quizCarryMapPanelTheme = parsed; + } + + /** ธีมจากแผนที่ (ถ้ามี) ชนะค่าจาก API — ใส่ carryMapPanelTheme ใน JSON แมปได้โดยตรง */ + function getEffectiveCarryMapPanelThemeForApply() { + if (mapData && mapData.carryMapPanelTheme != null) { + const parsed = parseCarryMapPanelThemeObject(mapData.carryMapPanelTheme); + if (parsed) return parsed; + } + return quizCarryMapPanelTheme; + } + + function applyQuizCarryMapPanelThemeIfNeeded(panel, textEl) { + if (!panel || !textEl) return; + if (!isQuizCarry()) { + clearQuizMapPanelThemeInline(panel, textEl); + return; + } + const th = getEffectiveCarryMapPanelThemeForApply(); + if (!th) return; + clearQuizMapPanelThemeInline(panel, textEl); + const bgStr = th.panelBg != null ? String(th.panelBg).trim() : ''; + const brStr = th.panelBorder != null ? String(th.panelBorder).trim() : ''; + const txStr = th.textColor != null ? String(th.textColor).trim() : ''; + const wRaw = Number(th.borderWidthPx); + const w = Number.isFinite(wRaw) ? Math.max(0, Math.min(12, Math.round(wRaw))) : 0; + const bgUse = bgStr || 'transparent'; + const brUse = brStr || 'transparent'; + const txUse = txStr || '#f1f5f9'; + panel.style.setProperty('--qmap-bg', bgUse); + panel.style.setProperty('--qmap-border', brUse); + panel.style.setProperty('--qmap-border-w', String(w) + 'px'); + panel.style.setProperty('--qmap-shadow', 'none'); + textEl.style.setProperty('--qmap-text', txUse); + textEl.style.setProperty('--qmap-text-shadow', 'none'); + /* โหลด play.html แบบแคชเก่า (ไม่มี var) ยังต้องทับได้ — ใช้ !important คู่กับตัวแปร */ + panel.style.setProperty('background', bgUse, 'important'); + panel.style.setProperty('border', String(w) + 'px solid ' + brUse, 'important'); + panel.style.setProperty('box-shadow', 'none', 'important'); + textEl.style.setProperty('color', txUse, 'important'); + textEl.style.setProperty('text-shadow', 'none', 'important'); + } + function syncPlayQuizMapPanel() { const panel = document.getElementById('quiz-map-question-panel'); const textEl = document.getElementById('quiz-map-question-text'); @@ -741,11 +831,13 @@ if (previewMode && editorEmbedReturn && !isQuizCarry()) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); return; } if (isQuizBattle()) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); return; } if (!isQuiz() && !isQuizCarry()) return; @@ -756,6 +848,7 @@ if (!bounds || !text) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, textEl); return; } const camX = me.x * tileSize; @@ -771,6 +864,7 @@ panel.style.height = Math.round(Math.max(40, hPx)) + 'px'; panel.classList.remove('is-hidden'); panel.setAttribute('aria-hidden', 'false'); + applyQuizCarryMapPanelThemeIfNeeded(panel, textEl); } /** quiz_carry + embed: คำถามไปที่แผงทองบนแผนที่ / ใน overlay นับถอยหลัง — ไม่ใช้แถบบน */ @@ -1361,7 +1455,7 @@ resetPreviewBotsQuizState(); let settings = null; try { - const r = await fetch(BASE + '/api/quiz-settings'); + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); if (r.ok) settings = await r.json(); } catch (e) { /* use map fallback */ } let pool = []; @@ -1419,9 +1513,11 @@ const ov = document.getElementById('quiz-game-overlay'); if (ov) ov.classList.add('is-hidden'); const panel = document.getElementById('quiz-map-question-panel'); + const mapQText = document.getElementById('quiz-map-question-text'); if (panel) { panel.classList.add('is-hidden'); panel.setAttribute('aria-hidden', 'true'); + clearQuizMapPanelThemeInline(panel, mapQText); } playQuizPhaseLocal = null; playQuizPlayerLocal = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; @@ -1433,6 +1529,8 @@ playLiveQuizScores = {}; playPath = []; hidePlayQuizHud(); + quizCarryJoinSettingsSnap = null; + quizCarryMapPanelTheme = null; resetQuizCarryPlayState(); resetQuizBattlePlayState(); } @@ -2364,27 +2462,75 @@ }); } + /** รวม carry จากดิสก์ผ่าน Node GET /Game/api/quiz-carry-from-disk (อยู่ใต้ /Game/api/ เพื่อให้ nginx proxy ตรงกับเซิร์ฟอื่น) — ทาง .php ใต้ /Game/ มักโดน fastcgi SCRIPT_FILENAME ผิดจึง 404 */ + async function mergeQuizCarrySettingsFromDisk(s) { + const out = s && typeof s === 'object' ? s : {}; + const urls = [ + BASE + '/api/quiz-carry-from-disk?_=' + Date.now(), + BASE + '/api-quiz-carry-from-disk.php?_=' + (Date.now() + 1), + ]; + for (let ui = 0; ui < urls.length; ui++) { + try { + const rd = await fetch(urls[ui], { cache: 'no-store' }); + if (!rd.ok) continue; + const disk = await rd.json(); + if (!disk || typeof disk !== 'object') continue; + if (disk.carryMapPanelTheme && typeof disk.carryMapPanelTheme === 'object') { + out.carryMapPanelTheme = disk.carryMapPanelTheme; + } + if (disk.carryReadMs != null) out.carryReadMs = disk.carryReadMs; + if (disk.carryAnswerMs != null) out.carryAnswerMs = disk.carryAnswerMs; + if (disk.carrySessionLength != null) out.carrySessionLength = disk.carrySessionLength; + if (Array.isArray(disk.carryQuestions) && disk.carryQuestions.length > 0) { + out.carryQuestions = disk.carryQuestions; + } + } catch (e) { /* next url */ } + } + return out; + } + async function loadQuizCarryPoolAndStart() { quizCarryRoundsCompleted = 0; quizCarrySessionEnded = false; quizCarrySessionLength = 0; let pool = []; + let s = {}; + const snap = quizCarryJoinSettingsSnap && typeof quizCarryJoinSettingsSnap === 'object' ? quizCarryJoinSettingsSnap : null; try { - const r = await fetch(BASE + '/api/quiz-settings'); + const r = await fetch(BASE + '/api/quiz-settings?_=' + Date.now(), { cache: 'no-store' }); if (r.ok) { - const s = await r.json(); - quizCarryCarryTimingMs = { - carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000), - carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000), - }; - quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0); - const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0; - const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []); - for (let i = 0; i < source.length; i++) { - const n = normalizeQuizCarryQuestionFromAny(source[i]); - if (n) pool.push(n); + const raw = await r.text(); + const trimmed = (raw || '').trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') s = parsed; } } + } catch (e) { /* Node/HTML error — ต่อจาก merge ดิสก์ */ } + try { + s = await mergeQuizCarrySettingsFromDisk(s); + } catch (e) { /* ignore */ } + if (snap) { + if (s.carryReadMs == null && snap.carryReadMs != null) s.carryReadMs = snap.carryReadMs; + if (s.carryAnswerMs == null && snap.carryAnswerMs != null) s.carryAnswerMs = snap.carryAnswerMs; + if (s.carrySessionLength == null && snap.carrySessionLength != null) s.carrySessionLength = snap.carrySessionLength; + } + if (snap && snap.carryMapPanelTheme && typeof snap.carryMapPanelTheme === 'object') { + s.carryMapPanelTheme = snap.carryMapPanelTheme; + } + try { + quizCarryCarryTimingMs = { + carryReadMs: clampPreviewMs(s.carryReadMs, 3000, 0, 120000), + carryAnswerMs: clampPreviewMs(s.carryAnswerMs, 5000, 1000, 300000), + }; + quizCarrySessionLength = clampCarrySessionLen(s.carrySessionLength, 0); + setQuizCarryMapPanelThemeFromApi(s); + const carryOnly = s && Array.isArray(s.carryQuestions) && s.carryQuestions.length > 0; + const source = carryOnly ? s.carryQuestions : (s && Array.isArray(s.questions) ? s.questions : []); + for (let i = 0; i < source.length; i++) { + const n = normalizeQuizCarryQuestionFromAny(source[i]); + if (n) pool.push(n); + } } catch (e) { /* map only */ } if (!pool.length && mapData) pool = buildQuizCarryPoolFromMap(mapData); quizCarryPool = pool; @@ -2395,6 +2541,10 @@ playQuizText = 'ไม่มีคำถาม — ตั้งใน Admin → คำถามหลายตัวเลือก หรือใส่ในแมป (quizQuestions)'; const qEl = document.getElementById('quiz-game-question'); if (qEl) qEl.textContent = playQuizText; + try { + syncPlayQuizMapPanel(); + } catch (e) { /* ignore */ } + quizCarryJoinSettingsSnap = null; return; } if (previewMode && editorEmbedReturn) { @@ -2405,6 +2555,10 @@ if (!(previewMode && editorEmbedReturn && quizCarryEmbedCountdownEndAt > Date.now())) { quizCarryRestoreEmbedPhaseLabel(); } + try { + syncPlayQuizMapPanel(); + } catch (e) { /* ignore */ } + quizCarryJoinSettingsSnap = null; } function setupPlayQuizCarryUi() { @@ -5596,6 +5750,12 @@ return; } myId = socket.id; + if (res.quizCarrySettingsSnap && typeof res.quizCarrySettingsSnap === 'object') { + quizCarryJoinSettingsSnap = res.quizCarrySettingsSnap; + if (res.quizCarrySettingsSnap.carryMapPanelTheme && typeof res.quizCarrySettingsSnap.carryMapPanelTheme === 'object') { + setQuizCarryMapPanelThemeFromApi({ carryMapPanelTheme: res.quizCarrySettingsSnap.carryMapPanelTheme }); + } + } if (previewMode && editorEmbedReturn && res.mapData && res.mapData.gameType === 'lobby') { const q = []; q.push('space=' + encodeURIComponent(spaceId)); diff --git a/www/html/Game/public/play.html b/www/html/Game/public/play.html index b200848..dbc75fa 100644 --- a/www/html/Game/public/play.html +++ b/www/html/Game/public/play.html @@ -3,6 +3,7 @@ + เล่น — Game @@ -85,10 +86,14 @@ justify-content: center; text-align: center; overflow: auto; - background: rgba(12, 14, 28, 0.88); - border: 2px solid rgba(255, 214, 102, 0.7); + --qmap-bg: rgba(12, 14, 28, 0.88); + --qmap-border: rgba(255, 214, 102, 0.7); + --qmap-border-w: 2px; + --qmap-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + background: var(--qmap-bg); + border: var(--qmap-border-w) solid var(--qmap-border); border-radius: 12px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + box-shadow: var(--qmap-shadow); } #quiz-map-question-panel.is-hidden { display: none !important; } #quiz-map-question-text { @@ -96,8 +101,10 @@ font-size: clamp(0.78rem, 2.4vmin, 1.12rem); font-weight: 600; line-height: 1.45; - color: #f1f5f9; - text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55); + --qmap-text: #f1f5f9; + --qmap-text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55); + color: var(--qmap-text); + text-shadow: var(--qmap-text-shadow); max-height: 100%; overflow: auto; } @@ -902,7 +909,7 @@ - +
v —
diff --git a/www/html/Game/public/room-lobby.html b/www/html/Game/public/room-lobby.html index a6af4d1..80c33c5 100644 --- a/www/html/Game/public/room-lobby.html +++ b/www/html/Game/public/room-lobby.html @@ -680,10 +680,14 @@ justify-content: center; text-align: center; overflow: auto; - background: rgba(12, 14, 28, 0.88); - border: 2px solid rgba(255, 214, 102, 0.7); + --qmap-bg: rgba(12, 14, 28, 0.88); + --qmap-border: rgba(255, 214, 102, 0.7); + --qmap-border-w: 2px; + --qmap-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + background: var(--qmap-bg); + border: var(--qmap-border-w) solid var(--qmap-border); border-radius: 12px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + box-shadow: var(--qmap-shadow); } #quiz-map-question-panel.is-hidden { display: none !important; @@ -699,8 +703,10 @@ font-size: clamp(0.78rem, 2.4vmin, 1.12rem); font-weight: 600; line-height: 1.45; - color: #f1f5f9; - text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55); + --qmap-text: #f1f5f9; + --qmap-text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55); + color: var(--qmap-text); + text-shadow: var(--qmap-text-shadow); max-height: 100%; overflow: auto; } diff --git a/www/html/Game/server.js b/www/html/Game/server.js index 8b7dcfe..d8194f7 100644 --- a/www/html/Game/server.js +++ b/www/html/Game/server.js @@ -54,6 +54,10 @@ const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters'); const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets'); const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json'); +/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */ +const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH + ? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim()) + : ''; const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json'); /** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; @@ -93,12 +97,50 @@ function defaultQuizSettings() { carryAnswerMs: 5000, /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ carrySessionLength: 0, + /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ + carryMapPanelTheme: defaultCarryMapPanelTheme(), questions: [], carryQuestions: [], battleQuizMcq: [], }; } +function defaultCarryMapPanelTheme() { + return { + panelBg: 'rgba(12, 14, 28, 0.88)', + panelBorder: 'rgba(255, 214, 102, 0.7)', + borderWidthPx: 2, + textColor: '#f1f5f9', + }; +} + +function sanitizeCssColorToken(input, fallback) { + const t = String(input == null ? '' : input).trim().slice(0, 120); + if (!t) return fallback; + if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback; + if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; + if (/^rgba?\(\s*[^)]+\)$/i.test(t)) { + const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, ''); + if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim(); + } + return fallback; +} + +function sanitizeCarryMapPanelTheme(raw) { + const d = defaultCarryMapPanelTheme(); + if (!raw || typeof raw !== 'object') return d; + return { + panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg), + panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder), + borderWidthPx: (() => { + let w = Number(raw.borderWidthPx); + if (!Number.isFinite(w)) w = d.borderWidthPx; + return Math.max(0, Math.min(12, Math.round(w))); + })(), + textColor: sanitizeCssColorToken(raw.textColor, d.textColor), + }; +} + /** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */ const QUIZ_BATTLE_CATEGORY_IDS = new Set([ 'cybercrime', @@ -174,6 +216,35 @@ function clampCarrySessionLength(n, def) { return Math.max(0, Math.min(500, Math.floor(v))); } +function mergeQuizCarryFromMirrorIfSet(base) { + if (!QUIZ_SETTINGS_MIRROR_PATH) return base; + try { + if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base; + const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8'); + const j = JSON.parse(raw); + if (!j || typeof j !== 'object') return base; + if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') { + base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + } + if (j.carryReadMs != null) { + base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs); + } + if (j.carryAnswerMs != null) { + base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs); + } + if (j.carrySessionLength != null) { + base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength); + } + if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) { + base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions); + } + return base; + } catch (e) { + console.error('mergeQuizCarryFromMirrorIfSet', e.message); + return base; + } +} + function loadQuizSettings() { try { if (fs.existsSync(QUIZ_SETTINGS_PATH)) { @@ -182,20 +253,23 @@ function loadQuizSettings() { const questions = Array.isArray(j.questions) ? j.questions : []; const carryQuestions = sanitizeCarryQuestions(j.carryQuestions); const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq); - return { + const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); + const out = { readMs: clampQuizMs(j.readMs, d.readMs), answerMs: clampQuizMs(j.answerMs, d.answerMs), betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs), carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs), carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs), carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength), + carryMapPanelTheme, questions, carryQuestions, battleQuizMcq, }; + return mergeQuizCarryFromMirrorIfSet(out); } } catch (e) { console.error('loadQuizSettings', e.message); } - return defaultQuizSettings(); + return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings()); } const GAUNTLET_DEFAULT_TICK_MS = 220; @@ -500,6 +574,9 @@ function saveQuizSettings(d) { const battleQuizMcq = Array.isArray(d.battleQuizMcq) ? sanitizeBattleQuizMcq(d.battleQuizMcq) : (prev.battleQuizMcq || []); + const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' + ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) + : prev.carryMapPanelTheme; const out = { readMs, answerMs, @@ -507,6 +584,7 @@ function saveQuizSettings(d) { carryReadMs, carryAnswerMs, carrySessionLength, + carryMapPanelTheme, questions, carryQuestions, battleQuizMcq, @@ -516,6 +594,7 @@ function saveQuizSettings(d) { ok: true, battleQuizMcqSaved: (out.battleQuizMcq || []).length, carryQuestionsSaved: (out.carryQuestions || []).length, + carryMapPanelTheme: out.carryMapPanelTheme, }; } catch (e) { console.error('saveQuizSettings', e.message); @@ -1139,6 +1218,31 @@ const server = http.createServer((req, res) => { return; } } + const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; + const quizCarryDiskPaths = new Set([ + BASE_PATH + '/api/quiz-carry-from-disk', + BASE_PATH + '/api-quiz-carry-from-disk.php', + ]); + if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) { + if (req.method === 'GET') { + const g = loadQuizSettings(); + const slice = { + carryMapPanelTheme: g.carryMapPanelTheme, + carryReadMs: g.carryReadMs, + carryAnswerMs: g.carryAnswerMs, + carrySessionLength: g.carrySessionLength, + carryQuestions: g.carryQuestions, + }; + res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }); + return res.end(JSON.stringify(slice)); + } + res.writeHead(405); + return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); + } const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { if (req.method === 'GET') { @@ -1166,6 +1270,7 @@ const server = http.createServer((req, res) => { ok: true, battleQuizMcqSaved: saved.battleQuizMcqSaved, carryQuestionsSaved: saved.carryQuestionsSaved, + carryMapPanelTheme: saved.carryMapPanelTheme, })); } catch (e) { res.writeHead(400); @@ -2365,6 +2470,17 @@ io.on('connection', (socket) => { suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, }; + if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { + try { + const qs = loadQuizSettings(); + joinCb.quizCarrySettingsSnap = { + carryMapPanelTheme: qs.carryMapPanelTheme, + carryReadMs: qs.carryReadMs, + carryAnswerMs: qs.carryAnswerMs, + carrySessionLength: qs.carrySessionLength, + }; + } catch (e) { /* ignore */ } + } if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; }