minigame 4 colour question

This commit is contained in:
2026-04-27 07:45:39 +00:00
parent 8597578a28
commit 9745d91016
14 changed files with 990 additions and 46 deletions
+10
View File
@@ -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";
+67
View File
@@ -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;
+246 -7
View File
@@ -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');
+47 -8
View File
@@ -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;
}
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
/**
* Proxy ค่าคำถาม quiz-settings ระหว่างแผง Admin กับ Node (Game/server.js)
* แก้กรณี nginx ส่ง /Game/ เป็น static แล้ว PUT ไม่ถึง Node — เบราว์เซอร์เรียก PHP แทน
*
* รองรับ Node เวอร์ชันเก่าที่ GET ไม่ส่ง battleQuizMcq / PUT เขียนทับไฟล์แล้วตัดฟิลด์:
* - GET: ดึง battleQuizMcq จากไฟล์บนดิสก์มาแปะในคำตอบ
* - PUT: merge body ลง quiz-settings.json บนดิสก์เสมอ (หลัง proxy Node) เพื่อให้ข้อมูลคงอยู่หลังรีเฟรช
*/
require __DIR__ . '/_common.php';
require_login();
if (!function_exists('curl_init')) {
json_response(['ok' => 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);
+49 -2
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="admin.css?v=14">
<link rel="stylesheet" href="admin.css?v=15">
</head>
<body>
<a class="skip-link" href="#admin-main">ข้ามไปเนื้อหา</a>
@@ -180,6 +180,53 @@
<span id="quiz-carry-session-hint" class="muted" style="display:block;font-size:0.85rem;margin-top:0.2rem">สุ่มคำถามจากชุดจนกว่าจะครบจำนวนนี้ (นับทุกข้อที่จบด้วยถูกหรือหมดเวลา) · 0 = เล่นต่อไม่จบอัตโนมัติ · <em>English:</em> Preview / editor embed only.</span>
</label>
</div>
<fieldset class="quiz-carry-theme-fieldset" style="margin:0.75rem 0 1rem;padding:0.75rem 1rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-elevated)">
<legend class="quiz-carry-theme-legend" style="font-size:0.92rem;font-weight:600;padding:0 0.35rem">แผงข้อความบนแผนที่ (คำถาม / ตัวเลือกที่ถือ)</legend>
<p class="muted" style="margin:0 0 0.65rem;font-size:0.82rem;line-height:1.45">ใช้กับ <code>#quiz-map-question-panel</code> ในโหมดหยิบมาวาง (โซนทองหรือโซนกลาง) · เก็บที่ <code>carryMapPanelTheme</code> ใน <code>quiz-settings.json</code> · <em>English:</em> Color pickers + alpha (0100%) — saved as <code>rgba()</code>.</p>
<div class="quiz-carry-theme-grid">
<div class="quiz-carry-theme-row" data-quiz-carry-theme="bg">
<span class="quiz-carry-theme-label">พื้นหลังแผง</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-theme-bg-swatch" value="#0c0e1c" title="เลือกสี" aria-label="สีพื้นหลังแผง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-theme-bg-alpha" min="0" max="100" value="88" step="1" aria-label="ความโปร่งใสพื้นหลังแผง เปอร์เซ็นต์" />
<output id="quiz-carry-theme-bg-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-theme-bg-alpha">88%</output>
</label>
<code id="quiz-carry-theme-bg-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-theme-bg" value="" />
</div>
<div class="quiz-carry-theme-row" data-quiz-carry-theme="border">
<span class="quiz-carry-theme-label">สีขอบ</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-theme-border-swatch" value="#ffd666" title="เลือกสีขอบ" aria-label="สีขอบแผง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-theme-border-alpha" min="0" max="100" value="70" step="1" aria-label="ความโปร่งใสขอบ เปอร์เซ็นต์" />
<output id="quiz-carry-theme-border-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-theme-border-alpha">70%</output>
</label>
<code id="quiz-carry-theme-border-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-theme-border" value="" />
</div>
<div class="quiz-carry-theme-row quiz-carry-theme-row--narrow">
<label class="admin-field quiz-carry-theme-label">ความหนาขอบ (px)
<input type="number" id="quiz-carry-theme-border-w" class="admin-inp-num" min="0" max="12" step="1" value="2" />
</label>
</div>
<div class="quiz-carry-theme-row" data-quiz-carry-theme="text">
<span class="quiz-carry-theme-label">สีตัวอักษร</span>
<div class="quiz-carry-color-controls">
<input type="color" id="quiz-carry-theme-text-swatch" value="#f1f5f9" title="เลือกสีตัวอักษร" aria-label="สีข้อความบนแผง" />
<label class="quiz-carry-alpha-label"><span>โปร่ง A</span>
<input type="range" id="quiz-carry-theme-text-alpha" min="0" max="100" value="100" step="1" aria-label="ความโปร่งใสตัวอักษร เปอร์เซ็นต์" />
<output id="quiz-carry-theme-text-alpha-out" class="quiz-carry-alpha-out" for="quiz-carry-theme-text-alpha">100%</output>
</label>
<code id="quiz-carry-theme-text-preview" class="quiz-carry-theme-code" aria-hidden="true"></code>
</div>
<input type="hidden" id="quiz-carry-theme-text" value="" />
</div>
</div>
</fieldset>
<h3 class="admin-subheading">รายการคำถาม</h3>
<div id="quiz-carry-admin-list" class="quiz-carry-admin-list"></div>
<div class="quiz-admin-actions">
@@ -521,6 +568,6 @@
</div>
</main>
</div>
<script src="admin.js?v=27"></script>
<script src="admin.js?v=31"></script>
</body>
</html>
+10 -1
View File
@@ -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
}
}
@@ -0,0 +1,48 @@
<?php
/**
* สาธารณะ — ส่งฟิลด์ quiz_carry จาก quiz-settings.json บนดิสก์ (ใต้ Game/data)
* หน้าเล่น merge ทับคำตอบจาก Node เพื่อให้ธีมแผงคำถามตรงกับที่ Admin บันทึก (PHP merge ลงไฟล์เดียวกับนี้)
* Public — carry fields from on-disk quiz-settings.json; play.js merges over Node so panel theme matches Admin saves.
*/
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
$path = dirname(__DIR__) . '/data/quiz-settings.json';
$raw = @file_get_contents($path);
if ($raw === false || $raw === '') {
echo '{}';
exit;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
echo '{}';
exit;
}
$out = [];
$keys = ['carryMapPanelTheme', 'carryReadMs', 'carryAnswerMs', 'carrySessionLength', 'carryQuestions'];
foreach ($keys as $k) {
if (!array_key_exists($k, $data)) {
continue;
}
if ($k === 'carryMapPanelTheme') {
if (is_array($data[$k])) {
$out[$k] = $data[$k];
}
continue;
}
if ($k === 'carryQuestions') {
if (is_array($data[$k])) {
$out[$k] = $data[$k];
}
continue;
}
$out[$k] = $data[$k];
}
echo json_encode($out, JSON_UNESCAPED_UNICODE);
+1 -1
View File
@@ -207,7 +207,7 @@
</div>
</div>
<script src="js/version.js?v=0.0169"></script>
<script src="js/editor.js?v=20260428-zones-over-grid"></script>
<script src="js/editor.js?v=20260428-play-cache-bust"></script>
<div class="version-tag">v —</div>
</body>
</html>
+2 -1
View File
@@ -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');
})
+173 -13
View File
@@ -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));
+13 -6
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-store, max-age=0">
<title>เล่น — Game</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -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 @@
</div>
<script src="/Game/socket.io/socket.io.js"></script>
<script src="js/version.js?v=0.0166"></script>
<script src="js/play.js?v=0.104"></script>
<script src="js/play.js?v=0.114"></script>
<div class="version-tag">v —</div>
</body>
</html>
+11 -5
View File
@@ -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;
}
+118 -2
View File
@@ -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;
}