(function () { 'use strict'; var API = '/Admin/api/'; /** ผ่าน PHP Admin เพื่อให้ PUT ถึง Node แม้ nginx ไม่ proxy /Game/api/ ไปยัง Node */ var GAME_QUIZ_API = '/Admin/api/game-quiz-settings.php'; var GAME_TIMING_API = '/Game/api/game-timing'; var GAME_GAUNTLET_ASSETS_API = '/Game/api/gauntlet-assets'; var stackGameSaveInFlight = false; /** โหมดแทนที่รูป: ชื่อไฟล์เป้าหมายก่อนเปิด file picker */ var gauntletReplaceTarget = null; var cred = { credentials: 'include' }; function el(id) { return document.getElementById(id); } function api(path, opts) { opts = opts || {}; opts.credentials = 'include'; if (!opts.headers) opts.headers = {}; if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } return fetch(API + path, opts).then(function (r) { return r.text().then(function (text) { var j = {}; try { j = text ? JSON.parse(text) : {}; } catch (e) { if (!r.ok) throw new Error(text || r.statusText); throw e; } if (!r.ok) throw new Error(j.error || r.statusText || 'Request failed'); return j; }); }); } function setMsg(id, text, cls) { var n = el(id); if (!n) return; n.textContent = text || ''; n.className = 'msg' + (cls ? ' ' + cls : ''); } function gameQuizFetch(method, body) { var m = method || 'GET'; var url = GAME_QUIZ_API; if (m === 'GET' && body == null) { url += (GAME_QUIZ_API.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now(); } var opts = { method: m, credentials: 'include', cache: 'no-store' }; if (body != null) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } return fetch(url, opts).then(function (r) { return r.text().then(function (text) { var j = {}; try { j = text ? JSON.parse(text) : {}; } catch (e) { if (!r.ok) { throw new Error((text || '').slice(0, 280) || r.statusText || 'Request failed'); } throw new Error('คำตอบไม่ใช่ JSON (HTTP ' + r.status + ') — ตรวจ nginx / PHP proxy'); } if (!r.ok) { throw new Error(j.error || ('HTTP ' + r.status + ' ' + r.statusText)); } return j; }); }); } function gameTimingFetch(method, body) { var m = method || 'GET'; var url = GAME_TIMING_API; if (m === 'GET' && body == null) { url += (GAME_TIMING_API.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now(); } var opts = { method: m, credentials: 'include', cache: 'no-store' }; if (body != null) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } return fetch(url, opts).then(function (r) { return r.text().then(function (text) { var j = {}; try { j = text ? JSON.parse(text) : {}; } catch (e) { if (!r.ok) { var plain = (text || r.statusText || 'Request failed').trim(); if (r.status === 404) { plain += ' — รีสตาร์ท Node ที่รัน Game/server.js (pm2 / systemd) หลัง deploy · Restart Game Node after deploy'; } throw new Error(plain); } throw e; } if (!r.ok) { var base = j.error || r.statusText || 'Request failed'; if (r.status === 404) { base += ' — รีสตาร์ท Node ที่รัน Game/server.js (pm2 / systemd) หลัง deploy · Restart Game Node after deploy'; } throw new Error(base); } return j; }); }); } function addQuizAdminRow(text, answerTrue) { var list = el('quiz-admin-questions-list'); if (!list) return; var row = document.createElement('div'); row.className = 'quiz-admin-q-row'; var lab1 = document.createElement('label'); lab1.className = 'quiz-admin-q-label'; lab1.appendChild(document.createTextNode('คำถาม ')); var inp = document.createElement('input'); inp.type = 'text'; inp.className = 'quiz-admin-q-text'; inp.placeholder = 'พิมพ์คำถาม...'; inp.maxLength = 500; inp.value = text || ''; lab1.appendChild(inp); var lab2 = document.createElement('label'); lab2.className = 'quiz-admin-ans-label'; lab2.appendChild(document.createTextNode('คำตอบถูก ')); var sel = document.createElement('select'); sel.className = 'quiz-admin-q-ans'; [['true', 'จริง (ถูก)'], ['false', 'เท็จ (ผิด)']].forEach(function (pair) { var o = document.createElement('option'); o.value = pair[0]; o.textContent = pair[1]; var isTrue = answerTrue !== false; if (pair[0] === 'true' ? isTrue : !isTrue) o.selected = true; sel.appendChild(o); }); lab2.appendChild(sel); var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm quiz-admin-q-remove'; rm.setAttribute('aria-label', 'ลบข้อนี้'); rm.textContent = 'ลบ'; rm.addEventListener('click', function () { row.remove(); if (!list.querySelector('.quiz-admin-q-row')) addQuizAdminRow('', true); }); row.appendChild(lab1); row.appendChild(lab2); row.appendChild(rm); list.appendChild(row); } function renderQuizAdminQuestions(arr) { var list = el('quiz-admin-questions-list'); if (!list) return; list.innerHTML = ''; var rows = (arr && arr.length) ? arr : [{ text: '', answerTrue: true }]; rows.forEach(function (q) { addQuizAdminRow(q.text || '', q.answerTrue !== false); }); } function loadQuizSettingsPanel() { gameQuizFetch('GET').then(function (data) { el('quiz-read-sec').value = String(Math.round((data.readMs || 10000) / 1000)); el('quiz-answer-sec').value = String(Math.round((data.answerMs || 5000) / 1000)); var bMs = data.betweenMs != null ? data.betweenMs : 3500; el('quiz-between-sec').value = String(Math.max(0, Math.round(bMs / 1000))); renderQuizAdminQuestions(data.questions); setMsg('quiz-settings-msg', '', ''); }).catch(function (e) { setMsg('quiz-settings-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function saveQuizSettingsPanel() { var readS = parseInt(el('quiz-read-sec').value, 10); var ansS = parseInt(el('quiz-answer-sec').value, 10); var betS = parseInt(el('quiz-between-sec').value, 10); if (Number.isNaN(readS) || readS < 1) readS = 10; if (Number.isNaN(ansS) || ansS < 1) ansS = 5; if (Number.isNaN(betS) || betS < 0) betS = 3; readS = Math.min(300, readS); ansS = Math.min(300, ansS); betS = Math.min(120, betS); var questions = []; document.querySelectorAll('#quiz-admin-questions-list .quiz-admin-q-row').forEach(function (row) { var inp = row.querySelector('.quiz-admin-q-text'); var sel = row.querySelector('.quiz-admin-q-ans'); var t = inp && inp.value ? String(inp.value).trim() : ''; if (!t) return; questions.push({ text: t, answerTrue: !!(sel && sel.value === 'true') }); }); gameQuizFetch('PUT', { readMs: readS * 1000, answerMs: ansS * 1000, betweenMs: betS * 1000, questions: questions, }).then(function () { setMsg('quiz-settings-msg', 'บันทึกแล้ว', 'ok'); }).catch(function (e) { setMsg('quiz-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); }); } var QUIZ_CARRY_MAX_SLOTS = 16; function rebuildQuizCarryCorrectSelect(row) { var sel = row.querySelector('.quiz-carry-correct-sel'); if (!sel) return; var inputs = row.querySelectorAll('.quiz-carry-slot-inp'); var prev = sel.value; sel.innerHTML = ''; var has = false; for (var i = 0; i < inputs.length; i++) { var t = inputs[i].value ? String(inputs[i].value).trim() : ''; if (!t) continue; has = true; var o = document.createElement('option'); o.value = String(i); var preview = t.length > 48 ? t.slice(0, 45) + '…' : t; o.textContent = String(i + 1) + '. ' + preview; sel.appendChild(o); } if (!has) { var ox = document.createElement('option'); ox.value = ''; ox.textContent = '— กรอกตัวเลือกอย่างน้อย 2 ช่อง —'; sel.appendChild(ox); sel.disabled = true; return; } sel.disabled = false; var ok = false; for (var j = 0; j < sel.options.length; j++) { if (sel.options[j].value === prev) { sel.selectedIndex = j; ok = true; break; } } if (!ok) sel.selectedIndex = 0; } function bindQuizCarryRowInputs(row) { row.querySelectorAll('.quiz-carry-slot-inp').forEach(function (inp) { inp.addEventListener('input', function () { rebuildQuizCarryCorrectSelect(row); }); }); } function addQuizCarryAdminRow(q) { var list = el('quiz-carry-admin-list'); if (!list) return; q = q || {}; var choices = Array.isArray(q.choices) ? q.choices : []; var block = document.createElement('div'); block.className = 'quiz-carry-admin-block'; var head = document.createElement('div'); head.className = 'quiz-carry-admin-block-head'; var labQ = document.createElement('label'); labQ.className = 'quiz-carry-q-label'; labQ.appendChild(document.createTextNode('คำถาม')); var inpQ = document.createElement('input'); inpQ.type = 'text'; inpQ.className = 'quiz-carry-q-text'; inpQ.placeholder = 'พิมพ์คำถาม…'; inpQ.maxLength = 500; inpQ.value = q.text ? String(q.text) : ''; labQ.appendChild(inpQ); var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm quiz-carry-block-remove'; rm.setAttribute('aria-label', 'ลบข้อนี้'); rm.textContent = 'ลบข้อ'; rm.addEventListener('click', function () { block.remove(); if (!list.querySelector('.quiz-carry-admin-block')) addQuizCarryAdminRow(null); }); head.appendChild(labQ); head.appendChild(rm); block.appendChild(head); var grid = document.createElement('div'); grid.className = 'quiz-carry-choice-grid'; for (var s = 0; s < QUIZ_CARRY_MAX_SLOTS; s++) { var lab = document.createElement('label'); lab.className = 'quiz-carry-slot-label'; lab.appendChild(document.createTextNode('ตัวเลือก ' + (s + 1))); var inp = document.createElement('input'); inp.type = 'text'; inp.className = 'quiz-carry-slot-inp'; inp.placeholder = s < 2 ? 'จำเป็น' : 'ไม่บังคับ'; inp.maxLength = 160; inp.value = choices[s] != null ? String(choices[s]) : ''; lab.appendChild(inp); grid.appendChild(lab); } block.appendChild(grid); var cw = document.createElement('div'); cw.className = 'quiz-carry-correct-wrap'; var labC = document.createElement('label'); labC.className = 'quiz-carry-correct-label'; labC.appendChild(document.createTextNode('ข้อที่ถูกต้อง')); var sel = document.createElement('select'); sel.className = 'quiz-carry-correct-sel'; sel.setAttribute('aria-label', 'เลือกข้อที่ถูก'); labC.appendChild(sel); cw.appendChild(labC); block.appendChild(cw); list.appendChild(block); bindQuizCarryRowInputs(block); rebuildQuizCarryCorrectSelect(block); var ci = Number(q.correctIndex); if (Number.isFinite(ci) && ci >= 0) { var targetSlot = ci; var opts = sel.querySelectorAll('option'); for (var k = 0; k < opts.length; k++) { if (opts[k].value === String(targetSlot)) { sel.selectedIndex = k; break; } } } } function renderQuizCarryAdminList(arr) { var list = el('quiz-carry-admin-list'); if (!list) return; list.innerHTML = ''; var rows = arr && arr.length ? arr : [null]; rows.forEach(function (q) { addQuizCarryAdminRow(q); }); } function loadQuizCarryPanel() { gameQuizFetch('GET').then(function (data) { renderQuizCarryAdminList(data.carryQuestions || []); setMsg('quiz-carry-settings-msg', '', ''); }).catch(function (e) { setMsg('quiz-carry-settings-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function saveQuizCarryPanel() { var list = el('quiz-carry-admin-list'); if (!list) return; var carryQuestions = []; list.querySelectorAll('.quiz-carry-admin-block').forEach(function (block) { var inpQ = block.querySelector('.quiz-carry-q-text'); var text = inpQ && inpQ.value ? String(inpQ.value).trim() : ''; if (!text) return; var slots = []; block.querySelectorAll('.quiz-carry-slot-inp').forEach(function (inp, idx) { var v = inp.value ? String(inp.value).trim() : ''; if (v) slots.push({ idx: idx, text: v }); }); if (slots.length < 2) return; var sel = block.querySelector('.quiz-carry-correct-sel'); var slotStr = sel && !sel.disabled && sel.value !== '' ? sel.value : null; if (slotStr == null) return; var slotNum = parseInt(slotStr, 10); var correctIndex = -1; for (var i = 0; i < slots.length; i++) { if (slots[i].idx === slotNum) { correctIndex = i; break; } } if (correctIndex < 0) return; carryQuestions.push({ text: text, choices: slots.map(function (s) { return s.text; }), correctIndex: correctIndex, }); }); gameQuizFetch('PUT', { carryQuestions: carryQuestions }).then(function () { setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว', 'ok'); }).catch(function (e) { setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); }); } /** หมวด Quiz Battle — ชื่อไทยให้สอดคล้องธีมการศึกษาไซเบอร์ / หน้า Quiz-Battle */ var QB_LABELS_ABC = ['A', 'B', 'C']; var QUIZ_BATTLE_CATEGORIES = [ { id: 'cybercrime', labelTh: 'อาชญากรรมออนไลน์', labelEn: 'Cybercrime' }, { id: 'cyber_law', labelTh: 'กฎหมายไซเบอร์', labelEn: 'Cyber law' }, { id: 'privacy', labelTh: 'ความเป็นส่วนตัวและข้อมูลส่วนบุคคล', labelEn: 'Privacy & PDPA' }, { id: 'digital_security', labelTh: 'ความปลอดภัยดิจิทัล', labelEn: 'Digital security' }, { id: 'social_media', labelTh: 'สื่อสังคมออนไลน์', labelEn: 'Social media' }, { id: 'ethics', labelTh: 'จริยธรรมดิจิทัล', labelEn: 'Digital ethics' }, { id: 'ai_data', labelTh: 'AI และข้อมูล', labelEn: 'AI & data' }, ]; function qbBattleCategoryLabel(catId) { for (var i = 0; i < QUIZ_BATTLE_CATEGORIES.length; i++) { if (QUIZ_BATTLE_CATEGORIES[i].id === catId) return QUIZ_BATTLE_CATEGORIES[i].labelTh; } return 'Quiz Battle'; } function fillQbBattleFilterOptions() { var sel = el('qb-battle-filter-cat'); if (!sel) return; var keep = sel.value; while (sel.options.length > 1) sel.remove(1); QUIZ_BATTLE_CATEGORIES.forEach(function (c) { var o = document.createElement('option'); o.value = c.id; o.textContent = c.labelTh; sel.appendChild(o); }); if (keep) { for (var oi = 0; oi < sel.options.length; oi++) { if (sel.options[oi].value === keep) { sel.value = keep; break; } } } } function applyQbBattleFilter() { var list = el('qb-battle-admin-list'); var f = el('qb-battle-filter-cat'); if (!list || !f) return; var fv = f.value || ''; list.querySelectorAll('.qb-battle-block').forEach(function (row) { var catInp = row.querySelector('.qb-battle-cat'); var cat = catInp && catInp.value ? catInp.value : ''; row.hidden = !!(fv && cat !== fv); }); refreshQbBattlePreview(); } function getFirstVisibleQbBattleBlock() { var list = el('qb-battle-admin-list'); if (!list) return null; var rows = list.querySelectorAll('.qb-battle-block'); for (var i = 0; i < rows.length; i++) { if (rows[i].hidden) continue; var q = rows[i].querySelector('.qb-battle-q-text'); var t = q && q.value ? String(q.value).trim() : ''; if (t) return rows[i]; } for (var j = 0; j < rows.length; j++) { if (!rows[j].hidden) return rows[j]; } return null; } function readQbBattleBlockData(row) { if (!row) return null; var catSel = row.querySelector('.qb-battle-cat'); var qInp = row.querySelector('.qb-battle-q-text'); var a0 = row.querySelector('.qb-battle-a'); var a1 = row.querySelector('.qb-battle-b'); var a2 = row.querySelector('.qb-battle-c'); var radios = row.querySelectorAll('input[type="radio"][name^="qb-correct-"]'); var ci = 0; for (var i = 0; i < radios.length; i++) { if (radios[i].checked) { ci = parseInt(radios[i].value, 10); if (!Number.isFinite(ci)) ci = 0; break; } } return { categoryId: catSel && catSel.value ? catSel.value : 'cybercrime', text: qInp && qInp.value ? String(qInp.value).trim() : '', choices: [ a0 && a0.value ? String(a0.value).trim() : '', a1 && a1.value ? String(a1.value).trim() : '', a2 && a2.value ? String(a2.value).trim() : '', ], correctIndex: Math.max(0, Math.min(2, ci)), }; } function refreshQbBattlePreview() { var hudCat = el('qb-preview-hud-cat'); var qtext = el('qb-preview-modal-q'); var c0 = el('qb-preview-c0'); var c1 = el('qb-preview-c1'); var c2 = el('qb-preview-c2'); var qModal = el('qb-cyber-modal-question'); var sModal = el('qb-cyber-modal-summary'); var layer = el('qb-modal-layer'); if (!hudCat || !qtext || !c0 || !c1 || !c2 || !qModal || !sModal) return; var modeBtn = document.querySelector('.qb-preview-mode-btn.is-active'); var mode = modeBtn && modeBtn.getAttribute('data-qb-mode') ? modeBtn.getAttribute('data-qb-mode') : 'question'; var row = getFirstVisibleQbBattleBlock(); var d = readQbBattleBlockData(row); var defQ = 'การกระทำใดผิดกฎหมายไซเบอร์?'; var defC = [ 'การเจาะระบบเข้าสู่ข้อมูลส่วนบุคคล', 'การใช้ WI-FI สาธารณะเพื่อดูข่าว', 'การอัปเดตซอฟต์แวร์เพื่อความปลอดภัย', ]; hudCat.textContent = d ? qbBattleCategoryLabel(d.categoryId) : qbBattleCategoryLabel('cybercrime'); qtext.textContent = d && d.text ? d.text : defQ; var ch = d ? d.choices : defC; for (var s = 0; s < 3; s++) { var eln = s === 0 ? c0 : s === 1 ? c1 : c2; var txt = ch[s] ? ch[s] : defC[s]; eln.textContent = QB_LABELS_ABC[s] + ' : ' + txt; } var choiceEls = document.querySelectorAll('#qb-preview-choices .qb-cyber-choice'); choiceEls.forEach(function (node) { node.classList.remove('qb-cyber-choice--correct', 'qb-cyber-choice--wrong', 'qb-cyber-choice--neutral'); var mark = node.querySelector('.qb-cyber-choice-mark'); if (mark) mark.textContent = ''; }); if (mode === 'summary') { qModal.hidden = true; sModal.hidden = false; if (layer) layer.classList.add('qb-modal-layer--summary'); } else { qModal.hidden = false; sModal.hidden = true; if (layer) layer.classList.remove('qb-modal-layer--summary'); var ci = d ? d.correctIndex : 0; if (mode === 'revealed') { var wrongPick = (ci + 1) % 3; choiceEls.forEach(function (node, idx) { var mark = node.querySelector('.qb-cyber-choice-mark'); if (idx === ci) { node.classList.add('qb-cyber-choice--correct'); if (mark) mark.textContent = '✓'; } else if (idx === wrongPick) { node.classList.add('qb-cyber-choice--wrong'); if (mark) mark.textContent = '✕'; } else { node.classList.add('qb-cyber-choice--neutral'); } }); } } } function addQbBattleRow(q) { var list = el('qb-battle-admin-list'); if (!list) return; q = q || {}; var uid = 'r' + Math.random().toString(36).slice(2, 10); var block = document.createElement('div'); block.className = 'qb-battle-block'; block.setAttribute('data-category', q.categoryId || 'cybercrime'); var head = document.createElement('div'); head.className = 'qb-battle-block-head'; var labCat = document.createElement('label'); labCat.className = 'qb-battle-cat-label'; labCat.appendChild(document.createTextNode('หมวด')); var selCat = document.createElement('select'); selCat.className = 'qb-battle-cat'; QUIZ_BATTLE_CATEGORIES.forEach(function (c) { var o = document.createElement('option'); o.value = c.id; o.textContent = c.labelTh; if ((q.categoryId || 'cybercrime') === c.id) o.selected = true; selCat.appendChild(o); }); selCat.addEventListener('change', function () { block.setAttribute('data-category', selCat.value); applyQbBattleFilter(); }); labCat.appendChild(selCat); var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm qb-battle-remove'; rm.textContent = 'ลบข้อ'; rm.addEventListener('click', function () { block.remove(); if (!list.querySelector('.qb-battle-block')) addQbBattleRow(null); applyQbBattleFilter(); }); head.appendChild(labCat); head.appendChild(rm); block.appendChild(head); var labQ = document.createElement('label'); labQ.className = 'qb-battle-q-label'; labQ.appendChild(document.createTextNode('คำถาม')); var inpQ = document.createElement('input'); inpQ.type = 'text'; inpQ.className = 'qb-battle-q-text'; inpQ.maxLength = 500; inpQ.placeholder = 'พิมพ์คำถาม…'; inpQ.value = q.text ? String(q.text) : ''; labQ.appendChild(inpQ); block.appendChild(labQ); var choices = Array.isArray(q.choices) ? q.choices : []; var grid = document.createElement('div'); grid.className = 'qb-battle-abc-grid'; [['A', 'a', 0], ['B', 'b', 1], ['C', 'c', 2]].forEach(function (tri) { var lab = document.createElement('label'); lab.className = 'qb-battle-opt-label'; lab.appendChild(document.createTextNode('ตัวเลือก ' + tri[0])); var inp = document.createElement('input'); inp.type = 'text'; inp.className = 'qb-battle-' + tri[1]; inp.maxLength = 160; inp.placeholder = tri[0]; inp.value = choices[tri[2]] != null ? String(choices[tri[2]]) : ''; lab.appendChild(inp); grid.appendChild(lab); }); block.appendChild(grid); var ci = Number(q.correctIndex); if (!Number.isFinite(ci)) ci = 0; ci = Math.max(0, Math.min(2, Math.floor(ci))); var fs = document.createElement('fieldset'); fs.className = 'qb-battle-correct-fs'; var leg = document.createElement('legend'); leg.textContent = 'ข้อที่ถูกต้อง'; fs.appendChild(leg); var rg = document.createElement('div'); rg.className = 'qb-battle-correct-rg'; for (var r = 0; r < 3; r++) { var labR = document.createElement('label'); labR.className = 'qb-battle-radio-label'; var radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'qb-correct-' + uid; radio.value = String(r); if (r === ci) radio.checked = true; labR.appendChild(radio); labR.appendChild(document.createTextNode(' ' + QB_LABELS_ABC[r])); rg.appendChild(labR); } fs.appendChild(rg); block.appendChild(fs); function onFieldInput() { refreshQbBattlePreview(); } inpQ.addEventListener('input', onFieldInput); grid.querySelectorAll('input').forEach(function (inp) { inp.addEventListener('input', onFieldInput); }); rg.querySelectorAll('input[type="radio"]').forEach(function (rdo) { rdo.addEventListener('change', onFieldInput); }); list.appendChild(block); applyQbBattleFilter(); } function renderQbBattleList(arr, clearMsg) { var list = el('qb-battle-admin-list'); if (!list) return; list.innerHTML = ''; fillQbBattleFilterOptions(); var rows = arr && arr.length ? arr : [null]; rows.forEach(function (q) { addQbBattleRow(q); }); if (clearMsg !== false) setMsg('qb-battle-settings-msg', '', ''); } function loadQbBattlePanel(keepSavedMsg) { gameQuizFetch('GET').then(function (data) { renderQbBattleList(data.battleQuizMcq || [], !keepSavedMsg); refreshQbBattlePreview(); }).catch(function (e) { setMsg('qb-battle-settings-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function classifyQbBattleRow(block) { var d = readQbBattleBlockData(block); if (!d) return { kind: 'empty', d: null }; var hasQ = !!d.text; var filled = 0; for (var i = 0; i < d.choices.length; i++) { if (d.choices[i]) filled++; } if (!hasQ && filled === 0) return { kind: 'empty', d: d }; if (hasQ && filled === 3) return { kind: 'ok', d: d }; return { kind: 'partial', d: d }; } function saveQbBattlePanel() { var list = el('qb-battle-admin-list'); if (!list) return; var battleQuizMcq = []; var partialLines = []; var blocks = list.querySelectorAll('.qb-battle-block'); for (var bi = 0; bi < blocks.length; bi++) { var block = blocks[bi]; var st = classifyQbBattleRow(block); if (st.kind === 'empty') continue; if (st.kind === 'partial') { partialLines.push('แถวที่ ' + (bi + 1) + ': ต้องกรอกคำถามและตัวเลือก A, B, C ให้ครบทุกช่อง'); continue; } battleQuizMcq.push({ categoryId: st.d.categoryId, text: st.d.text, choices: st.d.choices, correctIndex: st.d.correctIndex, }); } if (partialLines.length) { setMsg( 'qb-battle-settings-msg', 'ยังบันทึกไม่ได้ — ' + partialLines.join(' · ') + ' · (Empty question/options are skipped by the server; fill all fields.)', 'error' ); return; } gameQuizFetch('PUT', { battleQuizMcq: battleQuizMcq }).then(function (res) { var n = battleQuizMcq.length; var savedN = res && typeof res.battleQuizMcqSaved === 'number' ? res.battleQuizMcqSaved : n; if (n > 0 && savedN !== n) { setMsg( 'qb-battle-settings-msg', 'เซิร์ฟเวอร์รับเพียง ' + savedN + ' จาก ' + n + ' ข้อ (ข้อที่ไม่ผ่านกฎถูกตัด) · Server kept ' + savedN + '/' + n + ' — ตรวจหมวด/คำถาม/A B C ให้ครบ · Restart Node (Game/server.js) ถ้ายังผิดปกติ', 'error' ); } else { setMsg( 'qb-battle-settings-msg', n ? ('บันทึกแล้ว ' + n + ' ข้อ (ดิสก์ + Node) · Saved ' + n + ' question(s)') : 'บันทึกแล้ว (0 ข้อ) · Saved empty set', 'ok' ); } return loadQbBattlePanel(true); }).catch(function (e) { setMsg('qb-battle-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); }); } function laneUrlsToText(arr) { if (!arr || !arr.length) return ''; return arr.map(function (u) { return String(u || '').trim(); }).filter(Boolean).join('\n'); } function parseLaneUrlsTextarea() { var raw = el('game-timing-lane-urls') && el('game-timing-lane-urls').value ? String(el('game-timing-lane-urls').value) : ''; var lines = raw.split(/[\n\r]+/); var out = []; for (var i = 0; i < lines.length && out.length < 24; i++) { var t = lines[i].trim(); if (t) out.push(t); } return out; } function moveLaneUrl(index, dir) { var urls = parseLaneUrlsTextarea(); var j = index + dir; if (j < 0 || j >= urls.length) return; var t = urls[index]; urls[index] = urls[j]; urls[j] = t; var ta = el('game-timing-lane-urls'); if (ta) ta.value = urls.join('\n'); renderGameTimingLaneVisuals(); } function renderGameTimingLaneVisuals() { var wrap = el('game-timing-lane-visual-list'); if (!wrap) return; wrap.innerHTML = ''; var urls = parseLaneUrlsTextarea(); if (!urls.length) { var ph = document.createElement('p'); ph.className = 'muted'; ph.style.margin = '0'; ph.textContent = 'ยังไม่มีรูป — กด «Lane» ที่การ์ดในคลัง · No lane images yet'; wrap.appendChild(ph); return; } urls.forEach(function (u, i) { var card = document.createElement('div'); card.className = 'game-timing-visual-card'; card.setAttribute('role', 'listitem'); var img = document.createElement('img'); img.className = 'game-timing-visual-card-img'; img.src = u; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.onerror = function () { img.classList.add('is-broken'); }; var actions = document.createElement('div'); actions.className = 'game-timing-visual-card-actions'; function mkBtn(cls, text, fn) { var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm ' + cls; b.textContent = text; b.addEventListener('click', fn); return b; } actions.appendChild(mkBtn('btn-ghost', 'ดู', function () { window.open(u, '_blank', 'noopener,noreferrer'); })); actions.appendChild(mkBtn('btn-ghost', '←', function () { moveLaneUrl(i, -1); })); actions.appendChild(mkBtn('btn-ghost', '→', function () { moveLaneUrl(i, 1); })); actions.appendChild(mkBtn('btn-danger', 'ลบ', function () { var next = urls.filter(function (_, j) { return j !== i; }); var ta = el('game-timing-lane-urls'); if (ta) ta.value = next.join('\n'); renderGameTimingLaneVisuals(); setGauntletUploadMsg('เอาออกจาก Lane แล้ว (ไฟล์ในคลังยังอยู่) · Removed from lane', 'ok'); })); card.appendChild(img); card.appendChild(actions); wrap.appendChild(card); }); } function renderGameTimingLaserVisuals() { var slots = [ { inpId: 'game-timing-laser-top-url', prevId: 'game-timing-laser-top-preview' }, { inpId: 'game-timing-laser-bottom-url', prevId: 'game-timing-laser-bottom-preview' }, { inpId: 'game-timing-laser-line-url', prevId: 'game-timing-laser-line-preview' }, ]; slots.forEach(function (s) { var inp = el(s.inpId); var prev = el(s.prevId); if (!prev) return; prev.innerHTML = ''; var url = inp && inp.value ? String(inp.value).trim() : ''; if (!url) { var ph = document.createElement('div'); ph.className = 'game-timing-laser-placeholder'; ph.textContent = 'ยังไม่ตั้ง · Not set'; prev.appendChild(ph); return; } var img = document.createElement('img'); img.className = 'game-timing-laser-preview-img'; img.src = url; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.onerror = function () { img.classList.add('is-broken'); }; prev.appendChild(img); }); } function bindGameTimingAssignedVisuals() { function wire(which, viewId, clearId) { var inpId = 'game-timing-laser-' + which + '-url'; var vBtn = el(viewId); var cBtn = el(clearId); if (vBtn) vBtn.addEventListener('click', function () { var inp = el(inpId); var u = inp && inp.value ? String(inp.value).trim() : ''; if (!u) return; window.open(u, '_blank', 'noopener,noreferrer'); }); if (cBtn) cBtn.addEventListener('click', function () { var inp = el(inpId); if (inp) inp.value = ''; renderGameTimingLaserVisuals(); setGauntletUploadMsg('ล้างช่อง Laser แล้ว · Laser slot cleared', 'ok'); }); } wire('top', 'btn-game-timing-laser-top-view', 'btn-game-timing-laser-top-clear'); wire('bottom', 'btn-game-timing-laser-bottom-view', 'btn-game-timing-laser-bottom-clear'); wire('line', 'btn-game-timing-laser-line-view', 'btn-game-timing-laser-line-clear'); } function gauntletAssetsJsonFetch(url, opts) { opts = opts || {}; opts.credentials = 'include'; return fetch(url, opts).then(function (r) { return r.text().then(function (text) { var j = {}; try { j = text ? JSON.parse(text) : {}; } catch (e) { if (!r.ok) throw new Error(text || r.statusText); throw e; } if (!r.ok) { var plain = j.error || r.statusText || 'Request failed'; if (r.status === 404) { plain += ' — รีสตาร์ท Node ที่รัน Game/server.js หลัง deploy · Restart Game Node'; } throw new Error(plain); } return j; }); }); } function setGauntletUploadMsg(text, cls) { setMsg('gauntlet-asset-upload-msg', text, cls); } function readFileAsDataURL(file) { return new Promise(function (resolve, reject) { var fr = new FileReader(); fr.onload = function () { resolve(fr.result); }; fr.onerror = function () { reject(new Error('อ่านไฟล์ไม่ได้ · Cannot read file')); }; fr.readAsDataURL(file); }); } function appendLaneUrlUnique(url) { var ta = el('game-timing-lane-urls'); if (!ta || !url) return; var lines = parseLaneUrlsTextarea(); if (lines.indexOf(url) >= 0) { setGauntletUploadMsg('มี URL นี้ใน Lane แล้ว · Already in lane list', 'ok'); return; } lines.push(url); ta.value = lines.join('\n'); renderGameTimingLaneVisuals(); setGauntletUploadMsg('เพิ่มใน Lane แล้ว · Added to lane URLs', 'ok'); } function setLaserUrlField(which, url) { var ids = { top: 'game-timing-laser-top-url', bottom: 'game-timing-laser-bottom-url', line: 'game-timing-laser-line-url' }; var inp = el(ids[which]); if (!inp) return; inp.value = url; renderGameTimingLaserVisuals(); setGauntletUploadMsg('ตั้งค่า Laser แล้ว · Laser field updated', 'ok'); } function uploadGauntletDataUrls(fileArr) { var files = [].slice.call(fileArr || []); if (!files.length) return Promise.resolve(); var rf = gauntletReplaceTarget; gauntletReplaceTarget = null; if (rf) files = files.slice(0, 1); setGauntletUploadMsg('กำลังอัปโหลด… · Uploading…', ''); var idx = 0; function step() { if (idx >= files.length) { setGauntletUploadMsg(rf ? 'แทนที่รูปแล้ว · Image replaced' : ('อัปโหลดแล้ว ' + files.length + ' ไฟล์ · Uploaded'), 'ok'); return refreshGauntletAssetLibrary(); } var file = files[idx++]; return readFileAsDataURL(file).then(function (dataUrl) { var body = { imageDataUrl: dataUrl }; if (rf) body.replaceFilename = rf; else if (file.name) body.label = String(file.name).replace(/\.[^.]+$/, '').slice(0, 120); return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); }).then(step).catch(function (e) { setGauntletUploadMsg(e.message || 'อัปโหลดไม่สำเร็จ · Upload failed', 'error'); return refreshGauntletAssetLibrary(); }); } return step(); } function refreshGauntletAssetLibrary() { var listEl = el('gauntlet-asset-list'); if (!listEl) return Promise.resolve(); return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API, { method: 'GET' }) .then(function (data) { listEl.innerHTML = ''; var items = data.items || []; if (!items.length) { var empty = document.createElement('p'); empty.className = 'muted'; empty.style.margin = '0'; empty.textContent = 'ยังไม่มีรูปในคลัง — อัปโหลดด้านบน · No assets yet — upload above'; listEl.appendChild(empty); return; } items.forEach(function (it, ix) { var card = document.createElement('article'); card.className = 'gauntlet-asset-card'; var img = document.createElement('img'); img.className = 'gauntlet-asset-card-preview'; img.src = it.url + '?t=' + (it.mtime || 0); img.alt = it.label || ''; var labelWrap = document.createElement('div'); labelWrap.className = 'gauntlet-asset-card-label-wrap'; var labelInp = document.createElement('input'); labelInp.type = 'text'; labelInp.maxLength = 120; labelInp.value = it.label || ''; var lid = 'gauntlet-asset-label-' + ix; labelInp.id = lid; var lab = document.createElement('label'); lab.htmlFor = lid; lab.textContent = 'ชื่อเรียก · Label'; labelWrap.appendChild(lab); labelWrap.appendChild(labelInp); var urlP = document.createElement('p'); urlP.className = 'gauntlet-asset-card-url'; urlP.textContent = it.url; var actions = document.createElement('div'); actions.className = 'gauntlet-asset-card-actions'; function mkBtn(cls, text, handler) { var b = document.createElement('button'); b.type = 'button'; b.className = 'btn ' + cls; b.textContent = text; b.addEventListener('click', handler); return b; } actions.appendChild(mkBtn('btn-ghost', 'ดูรูป', function () { window.open(it.url, '_blank', 'noopener,noreferrer'); })); actions.appendChild(mkBtn('btn-ghost', 'บันทึกชื่อ', function () { gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: it.filename, label: labelInp.value }), }) .then(function () { setGauntletUploadMsg('บันทึกชื่อแล้ว · Label saved', 'ok'); return refreshGauntletAssetLibrary(); }) .catch(function (e) { setGauntletUploadMsg(e.message, 'error'); }); })); actions.appendChild(mkBtn('btn-ghost', 'แทนที่รูป', function () { gauntletReplaceTarget = it.filename; var finp = el('gauntlet-asset-file-input'); if (finp) finp.click(); })); actions.appendChild(mkBtn('btn-ghost', 'Lane', function () { appendLaneUrlUnique(it.url); })); actions.appendChild(mkBtn('btn-ghost', 'Laser บน', function () { setLaserUrlField('top', it.url); })); actions.appendChild(mkBtn('btn-ghost', 'Laser ล่าง', function () { setLaserUrlField('bottom', it.url); })); actions.appendChild(mkBtn('btn-ghost', 'Laser เส้น', function () { setLaserUrlField('line', it.url); })); actions.appendChild(mkBtn('btn-danger', 'ลบ', function () { if (!confirm('ลบไฟล์ ' + it.filename + ' จากเซิร์ฟเวอร์? · Delete from server?')) return; gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '?file=' + encodeURIComponent(it.filename), { method: 'DELETE' }) .then(function () { setGauntletUploadMsg('ลบแล้ว · Deleted', 'ok'); return refreshGauntletAssetLibrary(); }) .catch(function (e) { setGauntletUploadMsg(e.message, 'error'); }); })); card.appendChild(img); card.appendChild(labelWrap); card.appendChild(urlP); card.appendChild(actions); listEl.appendChild(card); }); }) .catch(function (e) { listEl.innerHTML = ''; var p = document.createElement('p'); p.className = 'msg error'; p.textContent = e.message || 'โหลดคลังไม่ได้'; listEl.appendChild(p); }); } function bindGauntletAssetLibrary() { var drop = el('gauntlet-asset-drop'); var finp = el('gauntlet-asset-file-input'); if (!drop || !finp) return; drop.addEventListener('click', function () { finp.click(); }); drop.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); finp.click(); } }); ['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); e.stopPropagation(); drop.classList.add('is-dragover'); }); }); ['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); e.stopPropagation(); if (ev !== 'drop') drop.classList.remove('is-dragover'); }); }); drop.addEventListener('drop', function (e) { drop.classList.remove('is-dragover'); var files = e.dataTransfer && e.dataTransfer.files; if (!files || !files.length) return; var arr = []; for (var i = 0; i < files.length; i++) arr.push(files[i]); uploadGauntletDataUrls(arr); }); finp.addEventListener('change', function () { if (!finp.files || !finp.files.length) return; var arr = []; for (var i = 0; i < finp.files.length; i++) arr.push(finp.files[i]); finp.value = ''; uploadGauntletDataUrls(arr); }); } /** ว่าง / อัตโนมัติ / auto → null · ตัวเลข 0.85–3.2 tile */ function parseStackBlockWidthForSave(raw) { var t = String(raw == null ? '' : raw).trim(); if (!t) return { ok: true, value: null }; var low = t.toLowerCase(); if (t === 'อัตโนมัติ' || low === 'auto' || low === 'automatic') return { ok: true, value: null }; var w = parseFloat(t.replace(',', '.')); if (!Number.isFinite(w)) { return { ok: false, error: 'กว้างบล็อก: ใส่ตัวเลข 0.85–3.2 หรือปล่อยว่างเพื่ออัตโนมัติ' }; } if (w < 0.85 || w > 3.2) { return { ok: false, error: 'กว้างบล็อกต้องอยู่ระหว่าง 0.85–3.2 tile หรือปล่อยว่าง' }; } return { ok: true, value: Math.round(Math.max(0.85, Math.min(3.2, w)) * 100) / 100 }; } /** ช่อง Hz ใช้ type=text — ปล่อยว่าง = 0.55 */ function parseStackSwingHzForSave(raw) { var t = String(raw == null ? '' : raw).trim().replace(',', '.'); if (!t) return { ok: true, value: 0.55 }; var h = parseFloat(t); if (!Number.isFinite(h)) { return { ok: false, error: 'สวิง Hz: ใส่ตัวเลข (เช่น 0.58) หรือปล่อยว่างให้เป็น 0.55' }; } h = Math.max(0.08, Math.min(2.8, h)); return { ok: true, value: Math.round(h * 100) / 100 }; } /** ซิงก์ช่อง Stack จากข้อมูลเซิร์ฟเวอร์ (ช่องอยู่ใน DOM แม้แท็บอื่นเปิดอยู่ — ต้องไม่ปล่อยให้ค้างค่า default จาก HTML) */ function applyGameTimingDataToStackInputs(data) { if (!data || typeof data !== 'object') return; if (el('stack-swing-hz')) { var hz = data.stackSwingHz != null && data.stackSwingHz !== '' ? Number(data.stackSwingHz) : NaN; if (!Number.isFinite(hz)) hz = 0.55; hz = Math.max(0.08, Math.min(2.8, hz)); el('stack-swing-hz').value = String(Math.round(hz * 100) / 100); } if (el('stack-block-width-tiles')) { var rawW = data.stackBlockWidthTiles; var bw = rawW != null && rawW !== '' ? Number(rawW) : NaN; el('stack-block-width-tiles').value = Number.isFinite(bw) && bw >= 0.85 ? String(Math.round(bw * 100) / 100) : ''; } } function loadStackGamePanel() { gameTimingFetch('GET') .then(function (data) { applyGameTimingDataToStackInputs(data); setMsg('stack-game-msg', '', ''); }) .catch(function (e) { setMsg('stack-game-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function saveStackGamePanel() { if (stackGameSaveInFlight) return; var hzParsed = parseStackSwingHzForSave(el('stack-swing-hz') ? el('stack-swing-hz').value : ''); if (!hzParsed.ok) { setMsg('stack-game-msg', hzParsed.error, 'error'); return; } var hzIn = hzParsed.value; var bwParsed = parseStackBlockWidthForSave(el('stack-block-width-tiles') ? el('stack-block-width-tiles').value : ''); if (!bwParsed.ok) { setMsg('stack-game-msg', bwParsed.error, 'error'); return; } var blockW = bwParsed.value; var btn = el('btn-stack-game-save'); stackGameSaveInFlight = true; if (btn) { btn.disabled = true; if (!btn.dataset.prevLabel) btn.dataset.prevLabel = btn.textContent; btn.textContent = 'กำลังบันทึก…'; } gameTimingFetch('GET') .then(function (data) { data.stackSwingHz = hzIn; data.stackBlockWidthTiles = blockW; return gameTimingFetch('PUT', data); }) .then(function () { return gameTimingFetch('GET'); }) .then(function (fresh) { applyGameTimingDataToStackInputs(fresh); var hz = fresh && fresh.stackSwingHz != null ? Number(fresh.stackSwingHz) : hzIn; if (!Number.isFinite(hz)) hz = hzIn; hz = Math.round(hz * 100) / 100; var wLabel = fresh && fresh.stackBlockWidthTiles != null && Number.isFinite(Number(fresh.stackBlockWidthTiles)) ? String(Math.round(Number(fresh.stackBlockWidthTiles) * 100) / 100) + ' tile' : 'อัตโนมัติ (จากแผนที่)'; setMsg('stack-game-msg', 'บันทึกสำเร็จ — สวิง ' + hz + ' Hz · กว้าง ' + wLabel + ' · โหลดยืนยันจากเซิร์ฟเวอร์แล้ว · ผู้เล่นต้องรีเฟรชหน้าเล่นหรือเข้าห้องใหม่', 'ok'); }) .catch(function (e) { setMsg('stack-game-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ URL /Game/api/game-timing ไม่ 404', 'error'); }) .then(function () { stackGameSaveInFlight = false; if (btn) { btn.disabled = false; btn.textContent = btn.dataset.prevLabel || 'บันทึก'; } }); } function loadGameTimingPanel() { gameTimingFetch('GET') .then(function (data) { el('game-timing-tick-ms').value = String(data.gauntletTickMs != null ? data.gauntletTickMs : 220); el('game-timing-jump-ticks').value = String(data.gauntletJumpTicks != null ? data.gauntletJumpTicks : 16); el('game-timing-limit-sec').value = String(data.gauntletTimeLimitSec != null ? data.gauntletTimeLimitSec : 0); if (el('game-timing-lane-urls')) el('game-timing-lane-urls').value = laneUrlsToText(data.gauntletLaneImageUrls); if (el('game-timing-laser-top-url')) el('game-timing-laser-top-url').value = data.gauntletLaserTopUrl != null ? String(data.gauntletLaserTopUrl) : ''; if (el('game-timing-laser-bottom-url')) el('game-timing-laser-bottom-url').value = data.gauntletLaserBottomUrl != null ? String(data.gauntletLaserBottomUrl) : ''; if (el('game-timing-laser-line-url')) el('game-timing-laser-line-url').value = data.gauntletLaserLineUrl != null ? String(data.gauntletLaserLineUrl) : ''; if (el('game-timing-laser-fill')) el('game-timing-laser-fill').value = data.gauntletLaserFillColor != null ? String(data.gauntletLaserFillColor) : ''; if (el('game-timing-laser-stroke')) el('game-timing-laser-stroke').value = data.gauntletLaserStrokeColor != null ? String(data.gauntletLaserStrokeColor) : ''; var lw = data.gauntletLaserLineWidthPx != null ? Number(data.gauntletLaserLineWidthPx) : 2; if (el('game-timing-laser-line-width')) el('game-timing-laser-line-width').value = String(Number.isFinite(lw) ? lw : 2); renderGameTimingLaneVisuals(); renderGameTimingLaserVisuals(); applyGameTimingDataToStackInputs(data); setMsg('game-timing-msg', '', ''); return refreshGauntletAssetLibrary(); }) .catch(function (e) { setMsg('game-timing-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function loadJumpSurviveTimingPanel() { gameTimingFetch('GET') .then(function (data) { var inp = el('jump-survive-height-mult'); if (inp) { var jsm = Number(data.jumpSurviveJumpHeightMult); inp.value = String(Number.isFinite(jsm) ? Math.round(Math.max(0.5, Math.min(4, jsm)) * 100) / 100 : 1.5); } setMsg('jump-survive-timing-msg', '', ''); }) .catch(function (e) { setMsg('jump-survive-timing-msg', e.message || 'โหลดไม่ได้', 'error'); }); } function saveJumpSurviveTimingPanel() { var inp = el('jump-survive-height-mult'); var jumpSurvMult = inp ? parseFloat(String(inp.value)) : 1.5; if (Number.isNaN(jumpSurvMult)) jumpSurvMult = 1.5; jumpSurvMult = Math.round(Math.max(0.5, Math.min(4, jumpSurvMult)) * 100) / 100; gameTimingFetch('GET') .then(function (data) { data.jumpSurviveJumpHeightMult = jumpSurvMult; return gameTimingFetch('PUT', data); }) .then(function () { setMsg('jump-survive-timing-msg', 'บันทึกแล้ว — ผู้เล่นกระโดดให้รอดได้ค่าใหม่เมื่อรีเฟรชหน้าเล่นหรือ sync จากเซิร์ฟเวอร์', 'ok'); }) .catch(function (e) { setMsg('jump-survive-timing-msg', e.message || 'บันทึกไม่ได้', 'error'); }); } function saveGameTimingPanel() { var tickMs = parseInt(el('game-timing-tick-ms').value, 10); var jumpT = parseInt(el('game-timing-jump-ticks').value, 10); var limS = parseInt(el('game-timing-limit-sec').value, 10); if (Number.isNaN(tickMs)) tickMs = 220; if (Number.isNaN(jumpT)) jumpT = 16; if (Number.isNaN(limS) || limS < 0) limS = 0; tickMs = Math.max(80, Math.min(800, tickMs)); jumpT = Math.max(4, Math.min(40, jumpT)); limS = limS <= 0 ? 0 : Math.max(10, Math.min(7200, limS)); var lwPx = el('game-timing-laser-line-width') ? parseInt(el('game-timing-laser-line-width').value, 10) : 2; if (Number.isNaN(lwPx)) lwPx = 2; lwPx = Math.max(0, Math.min(24, lwPx)); /** แท็บเวลาเกม = เฉพาะ Gauntlet — อย่าส่ง stackSwingHz/stackBlockWidthTiles จากฟอร์ม (หลังรีเฟรชช่อง Stack ค้าง 0.55 จาก HTML จะทับค่าที่บันทึกไว้) */ gameTimingFetch('GET') .then(function (data) { data.gauntletTickMs = tickMs; data.gauntletJumpTicks = jumpT; data.gauntletTimeLimitSec = limS; data.gauntletLaneImageUrls = parseLaneUrlsTextarea(); data.gauntletLaserTopUrl = el('game-timing-laser-top-url') ? String(el('game-timing-laser-top-url').value).trim() : ''; data.gauntletLaserBottomUrl = el('game-timing-laser-bottom-url') ? String(el('game-timing-laser-bottom-url').value).trim() : ''; data.gauntletLaserLineUrl = el('game-timing-laser-line-url') ? String(el('game-timing-laser-line-url').value).trim() : ''; data.gauntletLaserFillColor = el('game-timing-laser-fill') ? String(el('game-timing-laser-fill').value).trim() : ''; data.gauntletLaserStrokeColor = el('game-timing-laser-stroke') ? String(el('game-timing-laser-stroke').value).trim() : ''; data.gauntletLaserLineWidthPx = lwPx; return gameTimingFetch('PUT', data); }) .then(function () { setMsg('game-timing-msg', 'บันทึกแล้ว — พรมแดง tick·กระโดด / รูปอุปสรรค์ · กระโดดให้รอดตั้งที่แท็บกระโดดให้รอด · ค่า Stack แก้ที่แท็บ Stack เท่านั้น · จำกัดเวลาใช้กับรอบถัดไปที่โฮสต์เริ่มเกม', 'ok'); }) .catch(function (e) { setMsg('game-timing-msg', e.message || 'บันทึกไม่ได้', 'error'); }); } function showPanel(name) { ['panel-setup', 'panel-login', 'panel-app'].forEach(function (id) { var p = el(id); if (p) p.hidden = id !== name; }); el('top-actions').hidden = name !== 'panel-app'; } function setTab(name) { document.querySelectorAll('.tab').forEach(function (t) { var on = t.getAttribute('data-tab') === name; t.classList.toggle('is-active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); }); document.querySelectorAll('.tab-panel').forEach(function (p) { p.hidden = !p.id || p.id !== 'tab-panel-' + name; }); var embedPanels = [ { tab: 'map-editor', panel: 'tab-panel-map-editor', fsBtn: 'btn-map-editor-fullscreen' }, { tab: 'characters', panel: 'tab-panel-characters', fsBtn: 'btn-character-fullscreen' }, ]; embedPanels.forEach(function (ep) { if (name === ep.tab) return; var pnl = el(ep.panel); var fsBtn = el(ep.fsBtn); if (pnl && pnl.classList.contains('is-fullscreen')) { pnl.classList.remove('is-fullscreen'); if (fsBtn) { fsBtn.textContent = 'เต็มจอ'; fsBtn.setAttribute('aria-pressed', 'false'); } } }); if (name === 'quiz') loadQuizSettingsPanel(); if (name === 'quiz-carry') loadQuizCarryPanel(); if (name === 'quiz-battle') loadQbBattlePanel(); if (name === 'jump-survive') loadJumpSurviveTimingPanel(); if (name === 'game-timing') loadGameTimingPanel(); if (name === 'stack-game') loadStackGamePanel(); } function boot() { return api('session.php').then(function (s) { if (s.needsSetup) { showPanel('panel-setup'); return null; } if (!s.loggedIn) { showPanel('panel-login'); return null; } showPanel('panel-app'); el('admin-user-label').textContent = (s.admin && s.admin.username) || ''; return s.admin; }); } el('form-setup').addEventListener('submit', function (e) { e.preventDefault(); var fd = new FormData(e.target); var p1 = fd.get('password'); var p2 = fd.get('password2'); if (p1 !== p2) { setMsg('setup-msg', 'รหัสผ่านไม่ตรงกัน', 'error'); return; } api('setup.php', { method: 'POST', body: { password: p1 } }) .then(function () { setMsg('setup-msg', 'สร้างสำเร็จ — กรุณาล็อกอิน', 'ok'); showPanel('panel-login'); }) .catch(function (err) { setMsg('setup-msg', err.message || 'ผิดพลาด', 'error'); }); }); (function bindLoginPasswordToggle() { var inp = el('login-password'); var btn = el('login-password-toggle'); if (!inp || !btn) return; btn.addEventListener('click', function () { var show = inp.type === 'password'; inp.type = show ? 'text' : 'password'; btn.setAttribute('aria-pressed', show ? 'true' : 'false'); btn.setAttribute('aria-label', show ? 'ซ่อนรหัสผ่าน' : 'แสดงรหัสผ่าน'); btn.textContent = show ? 'ซ่อน' : 'แสดง'; }); })(); el('form-login').addEventListener('submit', function (e) { e.preventDefault(); var fd = new FormData(e.target); api('login.php', { method: 'POST', body: { username: fd.get('username'), password: fd.get('password'), }, }) .then(function () { setMsg('login-msg', '', ''); return boot(); }) .then(function (admin) { if (admin) loadAll(admin); }) .catch(function (err) { setMsg('login-msg', err.message || 'เข้าไม่ได้', 'error'); }); }); el('btn-logout').addEventListener('click', function () { api('logout.php', { method: 'POST' }) .then(function () { location.reload(); }) .catch(function () { location.reload(); }); }); document.querySelectorAll('.tab').forEach(function (tab) { tab.addEventListener('click', function () { var name = tab.getAttribute('data-tab'); if (name === 'change-password' && tab.classList.contains('is-active')) { setTab('oauth'); return; } setTab(name); }); }); function bindEmbedPanelFullscreen(btnId, panelId) { var btn = el(btnId); var panel = el(panelId); if (!btn || !panel) return; function setFs(on) { panel.classList.toggle('is-fullscreen', on); btn.textContent = on ? 'ออกจากเต็มจอ' : 'เต็มจอ'; btn.setAttribute('aria-pressed', on ? 'true' : 'false'); } btn.addEventListener('click', function () { setFs(!panel.classList.contains('is-fullscreen')); }); document.addEventListener('keydown', function (e) { if (e.code !== 'Escape' || !panel.classList.contains('is-fullscreen')) return; setFs(false); }); } bindEmbedPanelFullscreen('btn-map-editor-fullscreen', 'tab-panel-map-editor'); bindEmbedPanelFullscreen('btn-character-fullscreen', 'tab-panel-characters'); var btnGameTimingSave = el('btn-game-timing-save'); if (btnGameTimingSave) { btnGameTimingSave.addEventListener('click', function () { saveGameTimingPanel(); }); } var btnJumpSurviveSave = el('btn-jump-survive-save'); if (btnJumpSurviveSave) { btnJumpSurviveSave.addEventListener('click', saveJumpSurviveTimingPanel); } var btnStackGameSave = el('btn-stack-game-save'); if (btnStackGameSave) { btnStackGameSave.addEventListener('click', saveStackGamePanel); } bindGauntletAssetLibrary(); bindGameTimingAssignedVisuals(); var btnQuizAdd = el('btn-quiz-admin-add'); var btnQuizSave = el('btn-quiz-admin-save'); if (btnQuizAdd) { btnQuizAdd.addEventListener('click', function () { addQuizAdminRow('', true); }); } if (btnQuizSave) btnQuizSave.addEventListener('click', saveQuizSettingsPanel); var btnQuizCarryAdd = el('btn-quiz-carry-add'); var btnQuizCarrySave = el('btn-quiz-carry-save'); if (btnQuizCarryAdd) { btnQuizCarryAdd.addEventListener('click', function () { addQuizCarryAdminRow(null); }); } if (btnQuizCarrySave) btnQuizCarrySave.addEventListener('click', saveQuizCarryPanel); var btnQbBattleAdd = el('btn-qb-battle-add'); var btnQbBattleSave = el('btn-qb-battle-save'); if (btnQbBattleAdd) { btnQbBattleAdd.addEventListener('click', function () { addQbBattleRow(null); refreshQbBattlePreview(); }); } if (btnQbBattleSave) btnQbBattleSave.addEventListener('click', saveQbBattlePanel); var qbFilterCat = el('qb-battle-filter-cat'); if (qbFilterCat) qbFilterCat.addEventListener('change', applyQbBattleFilter); document.querySelectorAll('.qb-preview-mode-btn').forEach(function (b) { b.addEventListener('click', function () { document.querySelectorAll('.qb-preview-mode-btn').forEach(function (x) { x.classList.toggle('is-active', x === b); }); refreshQbBattlePreview(); }); }); function loadOAuth() { return api('oauth.php').then(function (r) { var o = r.oauth || {}; var f = el('form-oauth'); ['facebookAppId', 'facebookAppSecret', 'facebookRedirectUri', 'googleClientId', 'googleClientSecret', 'googleRedirectUri'].forEach(function (k) { var inp = f.querySelector('[name="' + k + '"]'); if (inp) inp.value = o[k] || ''; }); }); } el('form-oauth').addEventListener('submit', function (e) { e.preventDefault(); var fd = new FormData(e.target); var body = {}; fd.forEach(function (v, k) { body[k] = v; }); api('oauth.php', { method: 'PUT', body: body }) .then(function () { setMsg('oauth-msg', 'บันทึกแล้ว', 'ok'); }) .catch(function (err) { setMsg('oauth-msg', err.message || 'บันทึกไม่สำเร็จ', 'error'); }); }); function renderAccounts(list) { var tb = el('table-accounts').querySelector('tbody'); tb.innerHTML = ''; var sumCoins = 0; (list || []).forEach(function (a) { var c = Math.max(0, parseInt(a.coins, 10) || 0); sumCoins += c; var tr = document.createElement('tr'); tr.innerHTML = '