diff --git a/www/html/Admin/admin.js b/www/html/Admin/admin.js index 708750f..d05c902 100644 --- a/www/html/Admin/admin.js +++ b/www/html/Admin/admin.js @@ -4674,6 +4674,8 @@ 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' }, + { tab: 'qb-map-editor', panel: 'tab-panel-qb-map-editor', fsBtn: 'btn-qb-map-editor-fullscreen' }, + { tab: 'ai-admin', panel: 'tab-panel-ai-admin', fsBtn: 'btn-ai-admin-fullscreen' }, ]; embedPanels.forEach(function (ep) { if (name === ep.tab) return; @@ -4704,6 +4706,14 @@ if (name === 'troublesome') loadTroublesomePanel(); if (name === 'test-mode') { loadForcedMinigamePanel(); loadSpecialCardByMapPanel(); loadTroublesomeForcePanel(); } if (name === 'sound') loadSoundPanel(); + if (name === 'qb-map-editor') ensureEmbedFrameSrc('admin-qb-map-editor-frame'); + if (name === 'ai-admin') ensureEmbedFrameSrc('admin-ai-admin-frame'); + } + + /* ฝัง iframe editor แบบ lazy — ตั้ง src จาก data-src ครั้งแรกที่เปิด tab (ไม่โหลดตอนเปิดหน้า Admin) */ + function ensureEmbedFrameSrc(id) { + var f = el(id); + if (f && !f.getAttribute('src') && f.getAttribute('data-src')) f.setAttribute('src', f.getAttribute('data-src')); } function boot() { @@ -4894,6 +4904,8 @@ } bindEmbedPanelFullscreen('btn-map-editor-fullscreen', 'tab-panel-map-editor'); bindEmbedPanelFullscreen('btn-character-fullscreen', 'tab-panel-characters'); + bindEmbedPanelFullscreen('btn-qb-map-editor-fullscreen', 'tab-panel-qb-map-editor'); + bindEmbedPanelFullscreen('btn-ai-admin-fullscreen', 'tab-panel-ai-admin'); /* Test Mode toggle — เก็บใน localStorage (share ระหว่าง Admin กับ Game ภายใต้ origin เดียวกัน) */ var TEST_MODE_KEY = 'justiceTestMode'; diff --git a/www/html/Admin/admin.js.bak-20260706-062808 b/www/html/Admin/admin.js.bak-20260706-062808 new file mode 100644 index 0000000..708750f --- /dev/null +++ b/www/html/Admin/admin.js.bak-20260706-062808 @@ -0,0 +1,5729 @@ +(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 SOUND_SETTINGS_API = '/Game/api/sound-settings'; + var GAME_GAUNTLET_ASSETS_API = '/Game/api/gauntlet-assets'; + /** ลำดับอัปโหลดจริงอยู่ที่ quizCarryPlaqueUploadWithFallback — Node ก่อน แล้วค่อย PHP */ + var GAME_QUIZ_CARRY_PLAQUE_UPLOAD_NODE = '/Game/api/quiz-carry-plaque-upload'; + var GAME_QUIZ_CARRY_PLAQUE_UPLOAD_PHP = '/Admin/api/game-quiz-carry-plaque-upload.php'; + /** >0 ระหว่างอัปโหลดรูปป้าย — ห้ามบันทึกก่อนจบไม่งั้น plaqueImageUrl ว่างถูกเขียนลง JSON */ + var quizCarryPlaqueUploadPending = 0; + /** epoch ms เมื่อเริ่มอัปโหลดล่าสุด — กันสถานะค้างบล็อกบันทึกตลอด */ + var quizCarryPlaqueUploadStartMs = 0; + var quizCarrySaveInFlight = false; + /** ซิงก์ range ↔ ตัวเลข carryChoicePlaqueMapScale (wire ครั้งเดียว) */ + var quizCarryPlaqueScaleControlsWired = false; + /** สำรอง URL หลังอัปโหลดสำเร็จต่อช่อง — กัน race ที่ช่อง text ว่างตอน buildForSave แต่รูปอัปโหลดแล้วจริง */ + var quizCarryPlaquePendingUrlBySlot = {}; + var stackGameSaveInFlight = false; + var megaVirusSaveInFlight = 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))); + var rqIn = el('quiz-round-q-count'); + if (rqIn) { + var rq = data.quizRoundQuestionCount != null ? parseInt(data.quizRoundQuestionCount, 10) : 10; + if (Number.isNaN(rq) || rq < 1) rq = 10; + rq = Math.min(50, rq); + rqIn.value = String(rq); + } + renderQuizAdminQuestions(data.questions); + if (data.quizMapPanelTheme && typeof data.quizMapPanelTheme === 'object') { + quizTfFillFormFromTheme(data.quizMapPanelTheme); + } else { + quizTfFillFormFromTheme({}); + } + 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 roundQEl = el('quiz-round-q-count'); + var roundQ = roundQEl ? parseInt(roundQEl.value, 10) : 10; + if (Number.isNaN(roundQ) || roundQ < 1) roundQ = 10; + roundQ = Math.min(50, roundQ); + + 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, + quizRoundQuestionCount: roundQ, + questions: questions, + quizMapPanelTheme: quizTfBuildThemeForSave(), + }).then(function () { + setMsg('quiz-settings-msg', 'บันทึกแล้ว', 'ok'); + }).catch(function (e) { + setMsg('quiz-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); + }); + } + + /* ===== ชุดคำถามพิเศษ (Level×Case = 15 ชุด) รับการ์ดพิเศษ ===== */ + var SPECIAL_CARD_RARITIES = ['common', 'rare', 'legendary']; + var SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; + var SPECIAL_CARD_CATALOG = [ + { id: 1, en: 'Time Dilation', th: 'เพิ่มเวลาเล่นมินิเกมทันที (+10 วินาที)' }, + { id: 2, en: 'Extension', th: 'เพิ่มเวลาในห้องพิจารณาคดี (+10 วิ FINAL VOTE)' }, + { id: 3, en: 'Ban Card', th: 'ห้ามเล่นมินิเกม 1 ตา' }, + { id: 4, en: 'Silence Card', th: 'การ์ดปิดปาก' }, + { id: 5, en: 'Bail Coin', th: 'เหรียญประกันตัว' }, + { id: 6, en: 'Fund', th: 'รับ coin เพิ่ม' }, + { id: 7, en: 'Free Evidence', th: 'รับหลักฐานฟรี' }, + ]; + var specialQuizSetsState = {}; + var specialQuizCurrentKey = 'L1_C1'; + var specialCardPickerBuilt = false; + + function specialCardTxtFilename(id) { + return id === 7 ? 'card--7txt.png' : 'card-' + id + '-txt.png'; + } + + function specialCardImageUrl(id) { + return SPECIAL_CARD_ASSET_BASE + 'card-' + id + '.png'; + } + + function specialCardTxtImageUrl(id) { + return SPECIAL_CARD_ASSET_BASE + specialCardTxtFilename(id); + } + + function specialCardCatalogEntry(id) { + for (var i = 0; i < SPECIAL_CARD_CATALOG.length; i++) { + if (SPECIAL_CARD_CATALOG[i].id === id) return SPECIAL_CARD_CATALOG[i]; + } + return null; + } + + function resolveSpecialCardIdFromSaved(card) { + if (!card || typeof card !== 'object') return null; + var cid = Number(card.cardId); + if (cid >= 1 && cid <= 7) return cid; + var url = String(card.imageUrl || ''); + var m = url.match(/\/card-(\d)\.png/i); + return m ? Number(m[1]) : null; + } + + function updateSpecialCardPreview() { + var prev = el('special-card-preview'); + var img = el('special-card-preview-img'); + var cid = parseInt(el('special-card-id') && el('special-card-id').value, 10); + if (!prev || !img) return; + if (!(cid >= 1 && cid <= 7)) { + prev.hidden = true; + img.removeAttribute('src'); + return; + } + img.src = specialCardImageUrl(cid); + img.alt = (specialCardCatalogEntry(cid) || {}).en || ('Card ' + cid); + prev.hidden = false; + } + + function setSpecialCardPickerSelection(cardId) { + var picker = el('special-card-picker'); + if (!picker) return; + picker.querySelectorAll('.sq-card-pick').forEach(function (btn) { + var on = String(btn.getAttribute('data-card-id')) === String(cardId); + btn.classList.toggle('is-selected', on); + btn.setAttribute('aria-checked', on ? 'true' : 'false'); + }); + } + + function selectSpecialCard(cardId, fillMeta) { + var idInp = el('special-card-id'); + var imgInp = el('special-card-image'); + var txtInp = el('special-card-txt-image'); + if (!(cardId >= 1 && cardId <= 7)) { + if (idInp) idInp.value = ''; + if (imgInp) imgInp.value = ''; + if (txtInp) txtInp.value = ''; + setSpecialCardPickerSelection(null); + updateSpecialCardPreview(); + return; + } + var entry = specialCardCatalogEntry(cardId); + if (idInp) idInp.value = String(cardId); + if (imgInp) imgInp.value = specialCardImageUrl(cardId); + if (txtInp) txtInp.value = specialCardTxtImageUrl(cardId); + if (fillMeta && entry) { + if (el('special-card-th')) el('special-card-th').value = entry.th; + if (el('special-card-en')) el('special-card-en').value = entry.en; + } + setSpecialCardPickerSelection(cardId); + updateSpecialCardPreview(); + } + + function ensureSpecialCardPicker() { + if (specialCardPickerBuilt) return; + var picker = el('special-card-picker'); + if (!picker) return; + picker.innerHTML = ''; + SPECIAL_CARD_CATALOG.forEach(function (entry) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sq-card-pick'; + btn.setAttribute('role', 'radio'); + btn.setAttribute('aria-checked', 'false'); + btn.setAttribute('data-card-id', String(entry.id)); + btn.title = entry.en + ' — ' + entry.th; + var img = document.createElement('img'); + img.src = specialCardImageUrl(entry.id); + img.alt = entry.en; + img.width = 72; + img.height = 96; + img.decoding = 'async'; + img.loading = 'lazy'; + var cap = document.createElement('span'); + cap.className = 'sq-card-pick-cap'; + cap.textContent = entry.en; + btn.appendChild(img); + btn.appendChild(cap); + btn.addEventListener('click', function () { + selectSpecialCard(entry.id, true); + }); + picker.appendChild(btn); + }); + specialCardPickerBuilt = true; + } + + function specialQuizSelectorKey() { + var L = el('special-quiz-level'); + var C = el('special-quiz-case'); + return 'L' + ((L && L.value) || '1') + '_C' + ((C && C.value) || '1'); + } + + function updateSpecialQuizCount() { + var c = el('special-quiz-count'); + if (!c) return; + var n = document.querySelectorAll('#special-quiz-questions-list .sq-mcq-row').length; + c.textContent = '(' + n + ' ข้อ)'; + } + + var SPECIAL_QUIZ_CHOICE_COUNT = 4; + var SPECIAL_QUIZ_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']; + + /** คำถามตัวเลือก A/B/C/D — เลือกคำตอบถูก 1 ข้อ */ + function addSpecialQuizRow(q) { + q = q || {}; + var list = el('special-quiz-questions-list'); + if (!list) return; + var choices = Array.isArray(q.choices) ? q.choices : []; + var correctIndex = Number(q.correctIndex); + if (!(correctIndex >= 0 && correctIndex < SPECIAL_QUIZ_CHOICE_COUNT)) correctIndex = 0; + + var row = document.createElement('div'); + row.className = 'quiz-admin-q-row sq-mcq-row'; + + var qLab = document.createElement('label'); + qLab.className = 'sq-mcq-q'; + qLab.appendChild(document.createTextNode('คำถาม')); + var qInp = document.createElement('input'); + qInp.type = 'text'; + qInp.className = 'sq-mcq-text'; + qInp.maxLength = 500; + qInp.placeholder = 'พิมพ์คำถาม...'; + qInp.value = q.text || ''; + qLab.appendChild(qInp); + + var grid = document.createElement('div'); + grid.className = 'sq-mcq-choices'; + for (var i = 0; i < SPECIAL_QUIZ_CHOICE_COUNT; i++) { + var cl = document.createElement('label'); + cl.className = 'sq-mcq-choice'; + var badge = document.createElement('span'); + badge.className = 'sq-mcq-letter'; + badge.textContent = SPECIAL_QUIZ_LETTERS[i]; + var ci = document.createElement('input'); + ci.type = 'text'; + ci.className = 'sq-mcq-choice-input'; + ci.maxLength = 200; + ci.placeholder = 'ตัวเลือก ' + SPECIAL_QUIZ_LETTERS[i]; + ci.value = choices[i] || ''; + ci.setAttribute('data-choice-index', String(i)); + cl.appendChild(badge); + cl.appendChild(ci); + grid.appendChild(cl); + } + + var foot = document.createElement('div'); + foot.className = 'sq-mcq-foot'; + var ansLab = document.createElement('label'); + ansLab.className = 'sq-mcq-ans'; + ansLab.appendChild(document.createTextNode('คำตอบที่ถูก ')); + var ansSel = document.createElement('select'); + ansSel.className = 'sq-mcq-correct'; + for (var j = 0; j < SPECIAL_QUIZ_CHOICE_COUNT; j++) { + var o = document.createElement('option'); + o.value = String(j); + o.textContent = SPECIAL_QUIZ_LETTERS[j]; + if (j === correctIndex) o.selected = true; + ansSel.appendChild(o); + } + ansLab.appendChild(ansSel); + + 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(); + updateSpecialQuizCount(); + }); + foot.appendChild(ansLab); + foot.appendChild(rm); + + row.appendChild(qLab); + row.appendChild(grid); + row.appendChild(foot); + list.appendChild(row); + } + + function renderSpecialQuizSet(key) { + ensureSpecialCardPicker(); + var set = specialQuizSetsState[key] || { questions: [], specialCard: {} }; + var card = (set.specialCard && typeof set.specialCard === 'object') ? set.specialCard : {}; + var cardId = resolveSpecialCardIdFromSaved(card); + selectSpecialCard(cardId, false); + if (el('special-card-th')) el('special-card-th').value = card.th || (cardId ? (specialCardCatalogEntry(cardId) || {}).th : '') || ''; + if (el('special-card-en')) el('special-card-en').value = card.en || (cardId ? (specialCardCatalogEntry(cardId) || {}).en : '') || ''; + if (el('special-card-rarity')) { + el('special-card-rarity').value = SPECIAL_CARD_RARITIES.indexOf(card.rarity) >= 0 ? card.rarity : 'rare'; + } + if (cardId && el('special-card-image')) el('special-card-image').value = card.imageUrl || specialCardImageUrl(cardId); + if (cardId && el('special-card-txt-image')) el('special-card-txt-image').value = card.txtImageUrl || specialCardTxtImageUrl(cardId); + var list = el('special-quiz-questions-list'); + if (list) { + list.innerHTML = ''; + var qs = (set.questions && set.questions.length) + ? set.questions + : [{ type: 'mcq', text: '', choices: ['', '', '', ''], correctIndex: 0 }]; + qs.forEach(addSpecialQuizRow); + } + var tag = el('special-quiz-set-tag'); + if (tag) tag.textContent = 'ชุดที่แก้: ' + key; + updateSpecialQuizCount(); + } + + function captureSpecialQuizForm(key) { + var questions = []; + document.querySelectorAll('#special-quiz-questions-list .sq-mcq-row').forEach(function (row) { + var inp = row.querySelector('.sq-mcq-text'); + var t = inp && inp.value ? String(inp.value).trim() : ''; + if (!t) return; + var choices = []; + row.querySelectorAll('.sq-mcq-choice-input').forEach(function (ci) { + choices.push(String(ci.value || '').trim()); + }); + if (choices.filter(function (c) { return c; }).length < 2) return; + var sel = row.querySelector('.sq-mcq-correct'); + var correctIndex = sel ? parseInt(sel.value, 10) : 0; + if (!(correctIndex >= 0 && correctIndex < choices.length) || !choices[correctIndex]) { + correctIndex = choices.findIndex(function (c) { return c; }); + if (correctIndex < 0) correctIndex = 0; + } + questions.push({ type: 'mcq', text: t, choices: choices, correctIndex: correctIndex }); + }); + var rar = el('special-card-rarity') ? el('special-card-rarity').value : 'rare'; + var cardId = parseInt(el('special-card-id') && el('special-card-id').value, 10); + if (!(cardId >= 1 && cardId <= 7)) cardId = null; + specialQuizSetsState[key] = { + questions: questions, + specialCard: { + cardId: cardId, + th: el('special-card-th') ? String(el('special-card-th').value || '').trim() : '', + en: el('special-card-en') ? String(el('special-card-en').value || '').trim() : '', + rarity: SPECIAL_CARD_RARITIES.indexOf(rar) >= 0 ? rar : 'rare', + imageUrl: el('special-card-image') ? String(el('special-card-image').value || '').trim() : '', + txtImageUrl: el('special-card-txt-image') ? String(el('special-card-txt-image').value || '').trim() : '', + }, + }; + } + + function onSpecialQuizSelectorChange() { + captureSpecialQuizForm(specialQuizCurrentKey); + specialQuizCurrentKey = specialQuizSelectorKey(); + renderSpecialQuizSet(specialQuizCurrentKey); + } + + function loadSpecialQuizPanel() { + setMsg('special-quiz-msg', 'กำลังโหลด...', ''); + gameQuizFetch('GET').then(function (data) { + specialQuizSetsState = (data.specialQuizSets && typeof data.specialQuizSets === 'object') ? data.specialQuizSets : {}; + specialQuizCurrentKey = specialQuizSelectorKey(); + renderSpecialQuizSet(specialQuizCurrentKey); + setMsg('special-quiz-msg', '', ''); + }).catch(function (e) { + setMsg('special-quiz-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function saveSpecialQuizPanel() { + captureSpecialQuizForm(specialQuizCurrentKey); + setMsg('special-quiz-msg', 'กำลังบันทึก...', ''); + gameQuizFetch('PUT', { specialQuizSets: specialQuizSetsState }).then(function (res) { + if (res && res.specialQuizSets && typeof res.specialQuizSets === 'object') { + specialQuizSetsState = res.specialQuizSets; + } + setMsg('special-quiz-msg', 'บันทึกแล้ว (15 ชุด)', 'ok'); + renderSpecialQuizSet(specialQuizCurrentKey); + }).catch(function (e) { + setMsg('special-quiz-msg', e.message || 'บันทึกไม่ได้', 'error'); + }); + } + + function quizTfThemeHiddenId(kind) { + if (kind === 'bg') return 'quiz-tf-theme-bg'; + if (kind === 'border') return 'quiz-tf-theme-border'; + return 'quiz-tf-theme-text'; + } + + function quizTfRowPrefix(kind) { + return 'quiz-tf-theme-' + kind; + } + + function quizTfSetPickersFromColorString(kind, cssStr) { + var fb = QUIZ_CARRY_THEME_DEFAULTS[kind]; + var o = quizCarryParseCssColor(cssStr, fb); + var pref = quizTfRowPrefix(kind); + var sw = el(pref + '-swatch'); + var ra = el(pref + '-alpha'); + var out = el(pref + '-alpha-out'); + var hi = el(quizTfThemeHiddenId(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 quizTfComposeFromPickers(kind) { + var pref = quizTfRowPrefix(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 quizTfSyncRowOutput(kind) { + var s = quizTfComposeFromPickers(kind); + var hi = el(quizTfThemeHiddenId(kind)); + var pref = quizTfRowPrefix(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; + } + + function quizTfBindThemePickersOnce() { + if (quizTfBindThemePickersOnce._done) return; + ['bg', 'border', 'text'].forEach(function (kind) { + var pref = quizTfRowPrefix(kind); + var sw = el(pref + '-swatch'); + var ra = el(pref + '-alpha'); + if (!sw || !ra) return; + if (ra.getAttribute('data-tf-bound') === '1') return; + ra.setAttribute('data-tf-bound', '1'); + function sync() { + quizTfSyncRowOutput(kind); + } + sw.addEventListener('input', sync); + sw.addEventListener('change', sync); + ra.addEventListener('input', sync); + ra.addEventListener('change', sync); + }); + quizTfBindThemePickersOnce._done = true; + } + + function quizTfBuildThemeForSave() { + quizTfBindThemePickersOnce(); + ['bg', 'border', 'text'].forEach(function (k) { + quizTfSyncRowOutput(k); + }); + var fbBg = QUIZ_CARRY_THEME_DEFAULTS.bg; + var fbBr = QUIZ_CARRY_THEME_DEFAULTS.border; + var fbTx = QUIZ_CARRY_THEME_DEFAULTS.text; + var sBg = quizTfComposeFromPickers('bg'); + var sBr = quizTfComposeFromPickers('border'); + var sTx = quizTfComposeFromPickers('text'); + var bwEl = el('quiz-tf-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))); + var qfMinEl = el('quiz-tf-theme-qfont-min'); + var qfMaxEl = el('quiz-tf-theme-qfont-max'); + var qMin = qfMinEl ? parseInt(String(qfMinEl.value || '10'), 10) : 10; + var qMax = qfMaxEl ? parseInt(String(qfMaxEl.value || '24'), 10) : 24; + if (!Number.isFinite(qMin)) qMin = 10; + if (!Number.isFinite(qMax)) qMax = 24; + qMin = Math.max(10, Math.min(40, Math.round(qMin))); + qMax = Math.max(14, Math.min(56, Math.round(qMax))); + if (qMax < qMin) { + var swap = qMin; + qMin = qMax; + qMax = swap; + } + 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, + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; + } + + function quizTfFillFormFromTheme(th) { + quizTfBindThemePickersOnce(); + var t = th && typeof th === 'object' ? th : {}; + quizTfSetPickersFromColorString('bg', t.panelBg); + quizTfSetPickersFromColorString('border', t.panelBorder); + quizTfSetPickersFromColorString('text', t.textColor); + var bwEl = el('quiz-tf-theme-border-w'); + var bw = parseInt(String(t.borderWidthPx), 10); + if (bwEl) bwEl.value = String(Number.isFinite(bw) && bw >= 0 ? Math.min(12, bw) : 2); + var qfMinEl = el('quiz-tf-theme-qfont-min'); + var qfMaxEl = el('quiz-tf-theme-qfont-max'); + var qMin = parseInt(String(t.questionFontMinPx), 10); + var qMax = parseInt(String(t.questionFontMaxPx), 10); + if (qfMinEl) qfMinEl.value = String(Number.isFinite(qMin) ? Math.max(10, Math.min(40, qMin)) : 10); + if (qfMaxEl) qfMaxEl.value = String(Number.isFinite(qMax) ? Math.max(14, Math.min(56, qMax)) : 24); + } + + 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 choiceImages = Array.isArray(q.choiceImageUrls) ? q.choiceImageUrls : []; + 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); + var imgInp = document.createElement('input'); + imgInp.type = 'url'; + imgInp.className = 'quiz-carry-slot-img'; + imgInp.placeholder = 'รูป (URL — ไม่บังคับ)'; + imgInp.maxLength = 512; + imgInp.autocomplete = 'off'; + imgInp.value = choiceImages[s] != null ? String(choiceImages[s]) : ''; + imgInp.setAttribute('aria-label', 'URL รูปตัวเลือก ' + (s + 1)); + lab.appendChild(imgInp); + 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); + }); + } + + /** ค่าเริ่มต้น 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))); + var qfMinEl = el('quiz-carry-theme-qfont-min'); + var qfMaxEl = el('quiz-carry-theme-qfont-max'); + var qMin = qfMinEl ? parseInt(String(qfMinEl.value || '10'), 10) : 10; + var qMax = qfMaxEl ? parseInt(String(qfMaxEl.value || '24'), 10) : 24; + if (!Number.isFinite(qMin)) qMin = 10; + if (!Number.isFinite(qMax)) qMax = 24; + qMin = Math.max(10, Math.min(40, Math.round(qMin))); + qMax = Math.max(14, Math.min(56, Math.round(qMax))); + if (qMax < qMin) { + var swap = qMin; + qMin = qMax; + qMax = swap; + } + 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, + questionFontMinPx: qMin, + questionFontMaxPx: qMax, + }; + } + + 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; + } + + /** ธีมนับ 3-2-1 embed — สีม่าน / กล่อง / ขนาด (carryEmbedCountdownTheme) */ + var QUIZ_CARRY_ECD_COLS = [ + { sw: 'quiz-carry-ecd-overlay-swatch', ra: 'quiz-carry-ecd-overlay-alpha', out: 'quiz-carry-ecd-overlay-alpha-out', hi: 'quiz-carry-ecd-overlay-val', pr: 'quiz-carry-ecd-overlay-preview' }, + { sw: 'quiz-carry-ecd-inner-bg-swatch', ra: 'quiz-carry-ecd-inner-bg-alpha', out: 'quiz-carry-ecd-inner-bg-alpha-out', hi: 'quiz-carry-ecd-inner-bg-val', pr: 'quiz-carry-ecd-inner-bg-preview' }, + { sw: 'quiz-carry-ecd-inner-border-swatch', ra: 'quiz-carry-ecd-inner-border-alpha', out: 'quiz-carry-ecd-inner-border-alpha-out', hi: 'quiz-carry-ecd-inner-border-val', pr: 'quiz-carry-ecd-inner-border-preview' }, + ]; + var QUIZ_CARRY_ECD_DEFAULTS_RGBA = { + overlay: { r: 8, g: 10, b: 20, a: 0.42 }, + innerBg: { r: 12, g: 14, b: 28, a: 0.82 }, + innerBorder: { r: 122, g: 162, b: 247, a: 0.45 }, + }; + + function quizCarryEcdColKey(idx) { + if (idx === 0) return 'overlay'; + if (idx === 1) return 'innerBg'; + return 'innerBorder'; + } + + function quizCarryEcdSyncRow(idx) { + var row = QUIZ_CARRY_ECD_COLS[idx]; + var sw = el(row.sw); + var ra = el(row.ra); + var hi = el(row.hi); + var pr = el(row.pr); + var out = el(row.out); + if (!sw || !ra || !hi) return; + var prRgb = quizCarryParseHexToRgb(sw.value); + if (!prRgb) return; + var pct = parseInt(String(ra.value), 10); + if (!Number.isFinite(pct)) pct = 100; + pct = Math.max(0, Math.min(100, pct)); + var s = quizCarryRgbToRgbaString({ r: prRgb.r, g: prRgb.g, b: prRgb.b, a: pct / 100 }); + hi.value = s; + if (out) out.textContent = pct + '%'; + if (pr) pr.textContent = s; + } + + function quizCarryEcdSetRowFromRgba(idx, cssStr) { + var row = QUIZ_CARRY_ECD_COLS[idx]; + var key = quizCarryEcdColKey(idx); + var fb = QUIZ_CARRY_ECD_DEFAULTS_RGBA[key]; + var o = quizCarryParseCssColor(cssStr, fb); + var sw = el(row.sw); + var ra = el(row.ra); + var out = el(row.out); + var hi = el(row.hi); + var pr = el(row.pr); + 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 quizCarryEcdBindPickersOnce() { + if (quizCarryEcdBindPickersOnce._done) return; + for (var i = 0; i < QUIZ_CARRY_ECD_COLS.length; i++) { + (function (idx) { + var row = QUIZ_CARRY_ECD_COLS[idx]; + var sw = el(row.sw); + var ra = el(row.ra); + if (!sw || !ra) return; + if (ra.getAttribute('data-ecd-bound') === '1') return; + ra.setAttribute('data-ecd-bound', '1'); + function sync() { + quizCarryEcdSyncRow(idx); + } + sw.addEventListener('input', sync); + sw.addEventListener('change', sync); + ra.addEventListener('input', sync); + ra.addEventListener('change', sync); + })(i); + } + quizCarryEcdBindPickersOnce._done = true; + } + + function quizCarryEcdBuildForSave() { + quizCarryEcdBindPickersOnce(); + for (var i = 0; i < QUIZ_CARRY_ECD_COLS.length; i++) quizCarryEcdSyncRow(i); + var o0 = el('quiz-carry-ecd-overlay-val'); + var o1 = el('quiz-carry-ecd-inner-bg-val'); + var o2 = el('quiz-carry-ecd-inner-border-val'); + var bwEl = el('quiz-carry-ecd-inner-border-w'); + var radEl = el('quiz-carry-ecd-inner-radius'); + var dig = el('quiz-carry-ecd-digit-swatch'); + var m1 = el('quiz-carry-ecd-map-cqmin'); + var m2 = el('quiz-carry-ecd-map-cqh'); + var m3 = el('quiz-carry-ecd-map-max-px'); + var sv = el('quiz-carry-ecd-screen-vw'); + var sm = el('quiz-carry-ecd-screen-max-px'); + var bw = bwEl ? parseInt(String(bwEl.value || '1'), 10) : 1; + if (!Number.isFinite(bw)) bw = 1; + bw = Math.max(0, Math.min(12, Math.round(bw))); + var rad = radEl ? parseInt(String(radEl.value || '12'), 10) : 12; + if (!Number.isFinite(rad)) rad = 12; + rad = Math.max(0, Math.min(32, Math.round(rad))); + var cqmin = m1 ? parseInt(String(m1.value || '78'), 10) : 78; + var cqh = m2 ? parseInt(String(m2.value || '82'), 10) : 82; + var mpx = m3 ? parseInt(String(m3.value || '200'), 10) : 200; + cqmin = Math.max(35, Math.min(100, cqmin)); + cqh = Math.max(35, Math.min(100, cqh)); + mpx = Math.max(48, Math.min(400, mpx)); + var vw = sv ? parseInt(String(sv.value || '28'), 10) : 28; + var smx = sm ? parseInt(String(sm.value || '132'), 10) : 132; + vw = Math.max(6, Math.min(44, vw)); + smx = Math.max(48, Math.min(220, smx)); + var digitHex = dig && /^#[0-9a-fA-F]{6}$/.test(String(dig.value || '').trim()) ? String(dig.value).trim() : '#ffe066'; + return { + overlayBackdrop: (o0 && o0.value.trim()) ? o0.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.overlay), + innerBg: (o1 && o1.value.trim()) ? o1.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.innerBg), + innerBorder: (o2 && o2.value.trim()) ? o2.value.trim().slice(0, 120) : quizCarryRgbToRgbaString(QUIZ_CARRY_ECD_DEFAULTS_RGBA.innerBorder), + innerBorderWpx: bw, + innerRadiusPx: rad, + digitColor: digitHex.slice(0, 32), + mapDigitCqmin: cqmin, + mapDigitCqh: cqh, + mapDigitMaxPx: mpx, + screenDigitVw: vw, + screenDigitMaxPx: smx, + }; + } + + function quizCarryEcdFillForm(th) { + quizCarryEcdBindPickersOnce(); + var t = th && typeof th === 'object' ? th : {}; + quizCarryEcdSetRowFromRgba(0, t.overlayBackdrop); + quizCarryEcdSetRowFromRgba(1, t.innerBg); + quizCarryEcdSetRowFromRgba(2, t.innerBorder); + var bwEl = el('quiz-carry-ecd-inner-border-w'); + var radEl = el('quiz-carry-ecd-inner-radius'); + var dig = el('quiz-carry-ecd-digit-swatch'); + var m1 = el('quiz-carry-ecd-map-cqmin'); + var m2 = el('quiz-carry-ecd-map-cqh'); + var m3 = el('quiz-carry-ecd-map-max-px'); + var sv = el('quiz-carry-ecd-screen-vw'); + var sm = el('quiz-carry-ecd-screen-max-px'); + var bw = parseInt(String(t.innerBorderWpx), 10); + if (bwEl) bwEl.value = String(Number.isFinite(bw) && bw >= 0 ? Math.min(12, bw) : 1); + var rad = parseInt(String(t.innerRadiusPx), 10); + if (radEl) radEl.value = String(Number.isFinite(rad) && rad >= 0 ? Math.min(32, rad) : 12); + if (dig && typeof t.digitColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(t.digitColor.trim())) dig.value = t.digitColor.trim(); + var cqmin = parseInt(String(t.mapDigitCqmin), 10); + var cqh = parseInt(String(t.mapDigitCqh), 10); + var mpx = parseInt(String(t.mapDigitMaxPx), 10); + if (m1) m1.value = String(Number.isFinite(cqmin) ? Math.max(35, Math.min(100, cqmin)) : 78); + if (m2) m2.value = String(Number.isFinite(cqh) ? Math.max(35, Math.min(100, cqh)) : 82); + if (m3) m3.value = String(Number.isFinite(mpx) ? Math.max(48, Math.min(400, mpx)) : 200); + var vw = parseInt(String(t.screenDigitVw), 10); + var smx = parseInt(String(t.screenDigitMaxPx), 10); + if (sv) sv.value = String(Number.isFinite(vw) ? Math.max(6, Math.min(44, vw)) : 28); + if (sm) sm.value = String(Number.isFinite(smx) ? Math.max(48, Math.min(220, smx)) : 132); + } + + var QUIZ_CARRY_PLAQUE_SLOT_COUNT = 16; + var QUIZ_CARRY_PLAQUE_KINDS = ['fixed', 'fill', 'text']; + var QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA = { + fixed: { r: 122, g: 200, b: 255, a: 0.9 }, + fill: { r: 12, g: 10, b: 20, a: 0.88 }, + text: { r: 248, g: 249, b: 255, a: 1 }, + }; + + function quizCarryPlaqueSlotPrefix(si) { + return 'quiz-carry-plaque-s' + si + '-'; + } + + /** + * อัปโหลดป้าย: ลอง POST ตรง Node (/Game/api/ — nginx proxy 20M) ก่อน แล้วค่อย PHP ถ้าล้ม + * เดิมลอง PHP ก่อนทำให้พังง่ายเมื่อ php-fpm ต่อ 127.0.0.1:3001 ไม่ได้หรือ session ผิด แม้เกมจะรันอยู่ + */ + function quizCarryPlaqueUploadWithFallback(dataUrl) { + var body = JSON.stringify({ imageDataUrl: dataUrl }); + var opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body }; + return gauntletAssetsJsonFetch(GAME_QUIZ_CARRY_PLAQUE_UPLOAD_NODE, opts).catch(function (errFirst) { + var msg = String((errFirst && errFirst.message) || ''); + if (/\b401\b|Unauthorized|หมดเซสชัน/i.test(msg)) { + return Promise.reject(errFirst); + } + var tryPhp = + /\b404\b|\b405\b|Not Found|ไม่พบ|502|503|504|Bad Gateway|Failed to fetch|NetworkError|Load failed|ECONNREFUSED/i.test( + msg, + ) || + /\b413\b|Request Entity Too Large|Payload too large|รับ body ไม่ได้|too large/i.test(msg); + if (!tryPhp) { + return Promise.reject(errFirst); + } + return gauntletAssetsJsonFetch(GAME_QUIZ_CARRY_PLAQUE_UPLOAD_PHP, opts).then(function (res) { + try { + setMsg( + 'quiz-carry-settings-msg', + 'อัปโหลดสำเร็จผ่าน PHP (รองจาก /Game/api/) · Upload OK via PHP fallback', + 'ok', + ); + } catch (e1) { /* ignore */ } + return res; + }); + }); + } + + /** URL สำหรับ — path ที่ขึ้นต้นด้วย / ให้ต่อ origin (รองรับ Admin ใต้โดเมนเดียวกับ /Game/) */ + function quizCarryPlaqueAbsMediaUrl(path) { + var s = String(path || '').trim(); + if (!s) return ''; + if (/^https?:\/\//i.test(s)) return s; + if (s.charAt(0) === '/') return String(window.location.origin || '') + s; + return ''; + } + + function quizCarryPlaqueUpdateImagePreview(si) { + var pfx = quizCarryPlaqueSlotPrefix(si); + var inp = el(pfx + 'plaque-image-url'); + var img = el(pfx + 'plaque-preview-img'); + var errEl = el(pfx + 'plaque-preview-err'); + if (!img) return; + var raw = inp && inp.value ? String(inp.value).trim() : ''; + if (errEl) errEl.textContent = ''; + if (!raw) { + img.style.display = 'none'; + img.removeAttribute('src'); + return; + } + var abs = quizCarryPlaqueAbsMediaUrl(raw); + if (!abs) { + img.style.display = 'none'; + if (errEl) errEl.textContent = 'รูปแบบ URL ไม่รองรับ'; + return; + } + img.onerror = function () { + img.style.display = 'none'; + if (errEl) errEl.textContent = 'โหลดตัวอย่างไม่ได้ — ตรวจ path / สิทธิ์ไฟล์ / รีสตาร์ท Node'; + }; + img.onload = function () { + try { + img.removeAttribute('width'); + img.removeAttribute('height'); + } catch (e) { /* ignore */ } + img.style.display = 'block'; + if (errEl) errEl.textContent = ''; + }; + var sep = abs.indexOf('?') >= 0 ? '&' : '?'; + img.src = abs + sep + '_pv=' + Date.now(); + } + + function quizCarryPlaqueUpdateNeonUi(si) { + var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode'); + var grid = modeEl && modeEl.closest ? modeEl.closest('.quiz-carry-theme-grid') : null; + if (!grid) return; + if (modeEl && modeEl.value === 'fixed') grid.classList.remove('quiz-carry-plaque-grid--neon'); + else grid.classList.add('quiz-carry-plaque-grid--neon'); + var hint = grid.querySelector('.quiz-carry-plaque-neon-hint'); + if (hint) hint.style.display = modeEl && modeEl.value === 'fixed' ? 'none' : ''; + } + + function quizCarryPlaqueRowIds(si, kind) { + var seg = kind === 'fixed' ? 'fixed' : kind === 'fill' ? 'fill' : 'text'; + var p = quizCarryPlaqueSlotPrefix(si); + return { + sw: p + seg + '-swatch', + ra: p + seg + '-alpha', + out: p + seg + '-alpha-out', + hi: p + seg + '-val', + }; + } + + function quizCarryPlaqueEnsureMounted() { + var root = el('quiz-carry-plaque-slots-root'); + if (!root || root.getAttribute('data-mounted') === '1') return; + for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) { + var det = document.createElement('details'); + det.className = 'quiz-carry-plaque-slot-details'; + if (si === 0) det.open = true; + var summ = document.createElement('summary'); + summ.textContent = 'ตัวเลือก ' + (si + 1); + det.appendChild(summ); + var grid = document.createElement('div'); + grid.className = 'quiz-carry-theme-grid quiz-carry-plaque-grid'; + var rowMode = document.createElement('div'); + rowMode.className = 'quiz-carry-theme-row quiz-carry-theme-row--narrow'; + rowMode.style.marginBottom = '0.45rem'; + var labMode = document.createElement('label'); + labMode.className = 'admin-field quiz-carry-theme-label'; + labMode.appendChild(document.createTextNode('โหมดขอบป้าย')); + var selMode = document.createElement('select'); + selMode.id = quizCarryPlaqueSlotPrefix(si) + 'border-mode'; + selMode.setAttribute('aria-label', 'โหมดขอบป้ายช่อง ' + (si + 1)); + [['neon', 'เรืองตามช่อง (neon)'], ['fixed', 'สีขอบคงที่']].forEach(function (opt) { + var o = document.createElement('option'); + o.value = opt[0]; + o.textContent = opt[1]; + selMode.appendChild(o); + }); + labMode.appendChild(selMode); + rowMode.appendChild(labMode); + grid.appendChild(rowMode); + selMode.addEventListener('change', function () { + quizCarryPlaqueUpdateNeonUi(si); + }); + + var hintNeon = document.createElement('p'); + hintNeon.className = 'muted quiz-carry-plaque-neon-hint'; + hintNeon.style.cssText = 'margin:0 0 0.5rem;font-size:0.78rem;line-height:1.45'; + hintNeon.innerHTML = + 'Neon / เรืองตามช่อง: ขอบบนแมปใช้สีตามเลขช่อง ไม่ใช้ «สีขอบ (fixed)» ด้านล่าง — ต้องการขอบตาม picker ให้เลือก สีขอบคงที่ · ' + + 'English: In Neon mode the map uses per-slot hue; Fixed border uses the fixed-border pickers.'; + grid.appendChild(hintNeon); + + function addColorRow(labelText, kind, defHex, defAlpha) { + var ids = quizCarryPlaqueRowIds(si, kind); + var row = document.createElement('div'); + row.className = 'quiz-carry-theme-row'; + row.setAttribute('data-quiz-carry-plaque-color-row', kind); + var lbl = document.createElement('span'); + lbl.className = 'quiz-carry-theme-label'; + lbl.textContent = labelText; + row.appendChild(lbl); + var controls = document.createElement('div'); + controls.className = 'quiz-carry-color-controls'; + var sw = document.createElement('input'); + sw.type = 'color'; + sw.id = ids.sw; + sw.value = defHex; + sw.title = labelText; + controls.appendChild(sw); + var labA = document.createElement('label'); + labA.className = 'quiz-carry-alpha-label'; + var spanA = document.createElement('span'); + spanA.textContent = 'โปร่ง A'; + labA.appendChild(spanA); + var ra = document.createElement('input'); + ra.type = 'range'; + ra.id = ids.ra; + ra.min = '0'; + ra.max = '100'; + ra.step = '1'; + ra.value = String(defAlpha); + labA.appendChild(ra); + var out = document.createElement('output'); + out.id = ids.out; + out.className = 'quiz-carry-alpha-out'; + out.setAttribute('for', ids.ra); + out.textContent = defAlpha + '%'; + labA.appendChild(out); + controls.appendChild(labA); + var hi = document.createElement('input'); + hi.type = 'hidden'; + hi.id = ids.hi; + controls.appendChild(hi); + row.appendChild(controls); + grid.appendChild(row); + } + addColorRow('สีขอบ (fixed)', 'fixed', '#7ac8ff', 90); + addColorRow('พื้นหลังกล่อง', 'fill', '#0c0a14', 88); + addColorRow('สีตัวอักษร', 'text', '#f8f9ff', 100); + var rowW = document.createElement('div'); + rowW.className = 'quiz-carry-theme-row quiz-carry-theme-row--narrow'; + var labW = document.createElement('label'); + labW.className = 'admin-field quiz-carry-theme-label'; + labW.appendChild(document.createTextNode('ความหนาขอบ (px)')); + var inpW = document.createElement('input'); + inpW.type = 'number'; + inpW.className = 'admin-inp-num'; + inpW.id = quizCarryPlaqueSlotPrefix(si) + 'border-w'; + inpW.min = '0'; + inpW.max = '8'; + inpW.step = '0.5'; + inpW.value = '2.5'; + labW.appendChild(inpW); + rowW.appendChild(labW); + grid.appendChild(rowW); + + var rowImg = document.createElement('div'); + /* ห้ามใช้ quiz-carry-theme-row--narrow — มี max-width:12rem ทำช่อง URL แคบมาก ดูเหมือนอัปโหลดไม่ทำงาน */ + rowImg.className = 'quiz-carry-theme-row quiz-carry-plaque-image-row'; + rowImg.style.cssText = 'flex-wrap:wrap;gap:0.65rem;align-items:flex-end'; + var labUrl = document.createElement('label'); + labUrl.className = 'admin-field'; + labUrl.style.cssText = 'flex:1;min-width:200px;margin:0'; + labUrl.appendChild(document.createTextNode('รูปบนป้าย (ช่องนี้)')); + var inpImgUrl = document.createElement('input'); + inpImgUrl.type = 'text'; + inpImgUrl.className = 'admin-inp-text'; + inpImgUrl.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url'; + inpImgUrl.placeholder = '/Game/img/quiz-carry-plaque-assets/... หรือ https://...'; + inpImgUrl.setAttribute('aria-label', 'URL รูปป้ายช่อง ' + (si + 1)); + labUrl.appendChild(document.createElement('br')); + labUrl.appendChild(inpImgUrl); + var prevErr = document.createElement('span'); + prevErr.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-preview-err'; + prevErr.className = 'muted quiz-carry-plaque-preview-err'; + prevErr.style.cssText = 'display:block;font-size:0.72rem;margin:0.25rem 0 0.15rem;min-height:1em'; + labUrl.appendChild(prevErr); + var prevImg = document.createElement('img'); + prevImg.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-preview-img'; + prevImg.alt = 'พรีวิวรูปป้ายช่อง ' + (si + 1); + prevImg.className = 'quiz-carry-plaque-preview-img'; + /* ห้าม width/height = 0 — บางเบราว์เซอร์แสดงกล่อง 0×0 แม้ onload แล้ว · Don't use 0×0 attrs or preview stays invisible */ + prevImg.style.cssText = 'display:none;max-height:120px;max-width:min(100%,320px);width:auto;height:auto;border-radius:8px;border:1px solid var(--border);object-fit:contain;background:var(--card)'; + labUrl.appendChild(prevImg); + inpImgUrl.addEventListener('input', function () { + var v = String(inpImgUrl.value || '').trim(); + if (!v) delete quizCarryPlaquePendingUrlBySlot[si]; + quizCarryPlaqueUpdateImagePreview(si); + }); + inpImgUrl.addEventListener('change', function () { + var v2 = String(inpImgUrl.value || '').trim(); + if (!v2) delete quizCarryPlaquePendingUrlBySlot[si]; + quizCarryPlaqueUpdateImagePreview(si); + }); + rowImg.appendChild(labUrl); + var upWrap = document.createElement('div'); + upWrap.className = 'admin-field'; + upWrap.style.margin = '0'; + var fileInp = document.createElement('input'); + fileInp.type = 'file'; + fileInp.accept = 'image/png,image/jpeg,image/webp,image/gif'; + fileInp.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-file'; + fileInp.style.fontSize = '0.82rem'; + var upMsg = document.createElement('span'); + upMsg.className = 'muted'; + upMsg.id = quizCarryPlaqueSlotPrefix(si) + 'plaque-image-msg'; + upMsg.style.cssText = 'display:block;font-size:0.75rem;margin-top:0.25rem'; + fileInp.addEventListener('change', function () { + while (upMsg.firstChild) upMsg.removeChild(upMsg.firstChild); + upMsg.textContent = ''; + if (!fileInp.files || !fileInp.files[0]) return; + upMsg.textContent = 'กำลังอ่านไฟล์และอัปโหลด… · Reading file & uploading…'; + try { + upMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } catch (scrollErr) { /* ignore */ } + quizCarryPlaqueUploadPending++; + quizCarryPlaqueUploadStartMs = Date.now(); + readFileAsDataURL(fileInp.files[0]) + .then(function (dataUrl) { + return quizCarryPlaqueUploadWithFallback(dataUrl); + }) + .then(function (res) { + if (res && res.url) { + var uOk = String(res.url).trim(); + inpImgUrl.value = uOk; + quizCarryPlaquePendingUrlBySlot[si] = uOk; + quizCarryPlaqueUpdateImagePreview(si); + upMsg.textContent = ''; + var ok1 = document.createElement('span'); + ok1.textContent = 'อัปโหลดแล้ว · path ที่ได้: '; + upMsg.appendChild(ok1); + var codeU = document.createElement('code'); + codeU.style.cssText = 'font-size:0.78rem;word-break:break-all'; + codeU.textContent = res.url; + upMsg.appendChild(codeU); + var br = document.createElement('br'); + upMsg.appendChild(br); + var ok2 = document.createElement('span'); + ok2.textContent = 'กด «บันทึก» ด้านล่างเพื่อเขียนลง quiz-settings.json · Then click Save below'; + upMsg.appendChild(ok2); + } else { + upMsg.textContent = (res && res.error) || 'ตอบกลับไม่มี url — ตรวจ Node /Game/api/quiz-carry-plaque-upload'; + } + }) + .catch(function (e) { + upMsg.textContent = e.message || 'อัปโหลดไม่สำเร็จ'; + setMsg('quiz-carry-settings-msg', e.message || 'อัปโหลดรูปป้ายไม่สำเร็จ · Plaque upload failed', 'error'); + try { + upMsg.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + var smUp = el('quiz-carry-settings-msg'); + if (smUp) smUp.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } catch (scrollE) { /* ignore */ } + }) + .finally(function () { + fileInp.value = ''; + quizCarryPlaqueUploadPending = Math.max(0, quizCarryPlaqueUploadPending - 1); + if (quizCarryPlaqueUploadPending === 0) quizCarryPlaqueUploadStartMs = 0; + }); + }); + upWrap.appendChild(document.createTextNode('อัปโหลด')); + upWrap.appendChild(document.createElement('br')); + upWrap.appendChild(fileInp); + upWrap.appendChild(upMsg); + rowImg.appendChild(upWrap); + grid.appendChild(rowImg); + + det.appendChild(grid); + root.appendChild(det); + quizCarryPlaqueUpdateNeonUi(si); + } + root.setAttribute('data-mounted', '1'); + } + + function quizCarryPlaqueComposeFromPickers(si, kind) { + var ids = quizCarryPlaqueRowIds(si, kind); + var sw = el(ids.sw); + var ra = el(ids.ra); + var fb = QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA[kind] || QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA.fill; + if (!sw || !ra) return quizCarryRgbToRgbaString(fb); + var pr = quizCarryParseHexToRgb(sw.value); + if (!pr) return quizCarryRgbToRgbaString(fb); + var pct = parseInt(String(ra.value), 10); + if (!Number.isFinite(pct)) pct = Math.round((fb.a != null ? fb.a : 1) * 100); + pct = Math.max(0, Math.min(100, pct)); + return quizCarryRgbToRgbaString({ r: pr.r, g: pr.g, b: pr.b, a: pct / 100 }); + } + + function quizCarryPlaqueSetPickersFromString(si, kind, cssStr) { + var ids = quizCarryPlaqueRowIds(si, kind); + var fb = QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA[kind] || QUIZ_CARRY_PLAQUE_DEFAULTS_RGBA.fill; + var o = quizCarryParseCssColor(cssStr, fb); + var sw = el(ids.sw); + var ra = el(ids.ra); + var hi = el(ids.hi); + var out = el(ids.out); + if (sw) sw.value = quizCarryHexFromRgb(o.r, o.g, o.b); + if (ra) ra.value = String(Math.round((o.a != null ? o.a : 1) * 100)); + if (out) out.textContent = ra ? ra.value + '%' : ''; + if (hi) hi.value = quizCarryRgbToRgbaString(o); + } + + function quizCarryPlaqueSyncRow(si, kind) { + var s = quizCarryPlaqueComposeFromPickers(si, kind); + var ids = quizCarryPlaqueRowIds(si, kind); + var hi = el(ids.hi); + var out = el(ids.out); + var ra = el(ids.ra); + if (hi) hi.value = s; + if (out && ra) out.textContent = ra.value + '%'; + } + + var quizCarryPlaqueBindPickersDone = false; + function quizCarryPlaqueBindPickersOnce() { + if (quizCarryPlaqueBindPickersDone) return; + quizCarryPlaqueEnsureMounted(); + for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) { + QUIZ_CARRY_PLAQUE_KINDS.forEach(function (kind) { + var ids = quizCarryPlaqueRowIds(si, kind); + var sw = el(ids.sw); + var ra = el(ids.ra); + if (sw) { + sw.addEventListener('input', function () { + quizCarryPlaqueSyncRow(si, kind); + }); + } + if (ra) { + ra.addEventListener('input', function () { + quizCarryPlaqueSyncRow(si, kind); + }); + } + }); + } + quizCarryPlaqueBindPickersDone = true; + } + + function quizCarryPlaqueBuildForSave() { + quizCarryPlaqueEnsureMounted(); + quizCarryPlaqueBindPickersOnce(); + var arr = []; + for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) { + for (var ki = 0; ki < QUIZ_CARRY_PLAQUE_KINDS.length; ki++) { + quizCarryPlaqueSyncRow(si, QUIZ_CARRY_PLAQUE_KINDS[ki]); + } + var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode'); + var mode = modeEl && modeEl.value === 'fixed' ? 'fixed' : 'neon'; + var bwEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-w'); + var bw = bwEl ? parseFloat(String(bwEl.value)) : 2.5; + if (!Number.isFinite(bw)) bw = 2.5; + bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10; + var imgUrlEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url'); + var plaqueImageUrl = imgUrlEl ? String(imgUrlEl.value || '').trim() : ''; + if (!plaqueImageUrl && quizCarryPlaquePendingUrlBySlot[si]) { + plaqueImageUrl = String(quizCarryPlaquePendingUrlBySlot[si] || '').trim(); + if (imgUrlEl && plaqueImageUrl) imgUrlEl.value = plaqueImageUrl; + } + plaqueImageUrl = plaqueImageUrl.slice(0, 512); + arr.push({ + borderMode: mode, + fixedBorder: quizCarryPlaqueComposeFromPickers(si, 'fixed'), + fillBg: quizCarryPlaqueComposeFromPickers(si, 'fill'), + textColor: quizCarryPlaqueComposeFromPickers(si, 'text'), + borderWidthPx: bw, + plaqueImageUrl: plaqueImageUrl, + }); + } + return arr; + } + + function quizCarryPlaqueFillSlot(si, t) { + var obj = t && typeof t === 'object' ? t : {}; + var modeEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-mode'); + if (modeEl) modeEl.value = String(obj.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon'; + quizCarryPlaqueSetPickersFromString(si, 'fixed', obj.fixedBorder); + quizCarryPlaqueSetPickersFromString(si, 'fill', obj.fillBg); + quizCarryPlaqueSetPickersFromString(si, 'text', obj.textColor); + var bwEl = el(quizCarryPlaqueSlotPrefix(si) + 'border-w'); + var bw = parseFloat(String(obj.borderWidthPx)); + if (bwEl) { + bwEl.value = String(Number.isFinite(bw) ? Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10 : 2.5); + } + var imgEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-url'); + if (imgEl) { + imgEl.value = obj.plaqueImageUrl != null ? String(obj.plaqueImageUrl) : ''; + } + var srvU = obj.plaqueImageUrl != null ? String(obj.plaqueImageUrl).trim() : ''; + if (srvU) quizCarryPlaquePendingUrlBySlot[si] = srvU.slice(0, 512); + var msgEl = el(quizCarryPlaqueSlotPrefix(si) + 'plaque-image-msg'); + if (msgEl) msgEl.textContent = ''; + quizCarryPlaqueUpdateNeonUi(si); + quizCarryPlaqueUpdateImagePreview(si); + } + + function quizCarryPlaqueFillAllFromApi(data) { + quizCarryPlaqueBindPickersOnce(); + var arr = null; + if (data && Array.isArray(data.carryChoicePlaqueThemes) && data.carryChoicePlaqueThemes.length) { + arr = data.carryChoicePlaqueThemes; + } else if (data && data.carryChoicePlaqueTheme && typeof data.carryChoicePlaqueTheme === 'object') { + arr = []; + for (var u = 0; u < QUIZ_CARRY_PLAQUE_SLOT_COUNT; u++) arr.push(data.carryChoicePlaqueTheme); + } + for (var si = 0; si < QUIZ_CARRY_PLAQUE_SLOT_COUNT; si++) { + var th = (arr && arr[si]) ? arr[si] : {}; + quizCarryPlaqueFillSlot(si, th); + quizCarryPlaqueUpdateImagePreview(si); + } + } + + function quizCarrySetPlaqueMapScaleInputs(scale) { + var pms = Number(scale); + if (!Number.isFinite(pms)) pms = 1.25; + pms = Math.max(0.85, Math.min(2.5, pms)); + var cent = Math.round(pms * 100); + cent = Math.max(85, Math.min(250, cent)); + var num = el('quiz-carry-plaque-map-scale'); + var rng = el('quiz-carry-plaque-map-scale-range'); + var out = el('quiz-carry-plaque-map-scale-out'); + if (num) num.value = String(Math.round((cent / 100) * 100) / 100); + if (rng) rng.value = String(cent); + if (out) out.textContent = '× ' + (num ? num.value : String(cent / 100)); + } + + function wireQuizCarryPlaqueMapScaleControls() { + if (quizCarryPlaqueScaleControlsWired) return; + var num = el('quiz-carry-plaque-map-scale'); + var rng = el('quiz-carry-plaque-map-scale-range'); + if (!num || !rng) return; + quizCarryPlaqueScaleControlsWired = true; + function fromRange() { + var c = parseInt(String(rng.value), 10); + if (!Number.isFinite(c)) c = 125; + c = Math.max(85, Math.min(250, c)); + rng.value = String(c); + var sc = c / 100; + num.value = String(Math.round(sc * 100) / 100); + var out = el('quiz-carry-plaque-map-scale-out'); + if (out) out.textContent = '× ' + num.value; + } + function fromNum() { + var p = parseFloat(String(num.value || '').replace(',', '.')); + if (!Number.isFinite(p)) p = 1.25; + p = Math.max(0.85, Math.min(2.5, p)); + rng.value = String(Math.round(p * 100)); + num.value = String(Math.round(p * 100) / 100); + var out = el('quiz-carry-plaque-map-scale-out'); + if (out) out.textContent = '× ' + num.value; + } + rng.addEventListener('input', fromRange); + rng.addEventListener('change', fromRange); + num.addEventListener('input', fromNum); + num.addEventListener('change', fromNum); + } + + 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') { + var ov = opts.themeOverride; + if ( + ov.panelBg != null || + ov.borderWidthPx != null || + ov.panelBorder != null || + ov.textColor != null || + ov.questionFontMinPx != null || + ov.questionFontMaxPx != null + ) { + data.carryMapPanelTheme = ov; + } else if (ov.carryMapPanelTheme && typeof ov.carryMapPanelTheme === 'object') { + data.carryMapPanelTheme = ov.carryMapPanelTheme; + } + if (ov.carryEmbedCountdownTheme && typeof ov.carryEmbedCountdownTheme === 'object') { + data.carryEmbedCountdownTheme = ov.carryEmbedCountdownTheme; + } + if (ov.carryChoicePlaqueThemes && Array.isArray(ov.carryChoicePlaqueThemes)) { + data.carryChoicePlaqueThemes = ov.carryChoicePlaqueThemes; + if (ov.carryChoicePlaqueThemes[0] && typeof ov.carryChoicePlaqueThemes[0] === 'object') { + data.carryChoicePlaqueTheme = ov.carryChoicePlaqueThemes[0]; + } + } else if (ov.carryChoicePlaqueTheme && typeof ov.carryChoicePlaqueTheme === 'object') { + /* legacy อ็อบเจ็กต์เดียว — ต้องตั้งทั้งอาร์เรย์ ไม่งั้น Fill ยังใช้ carryChoicePlaqueThemes จาก GET เก่า (URL ป้ายหายหลังบันทึก) */ + data.carryChoicePlaqueTheme = ov.carryChoicePlaqueTheme; + var expPlaque = []; + for (var exi = 0; exi < QUIZ_CARRY_PLAQUE_SLOT_COUNT; exi++) { + expPlaque.push(ov.carryChoicePlaqueTheme); + } + data.carryChoicePlaqueThemes = expPlaque; + } + } + 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; + if (t.carryWalkSpeedMultForMapId !== undefined) data.carryWalkSpeedMultForMapId = t.carryWalkSpeedMultForMapId; + if (t.carryWalkSpeedMult !== undefined) data.carryWalkSpeedMult = t.carryWalkSpeedMult; + } + renderQuizCarryAdminList(data.carryQuestions || []); + var readSec = el('quiz-carry-read-sec'); + var ansSec = el('quiz-carry-answer-sec'); + if (readSec) { + var rms = Number(data.carryReadMs); + readSec.value = String(Number.isFinite(rms) ? Math.round(rms / 1000) : 3); + } + if (ansSec) { + var ams = Number(data.carryAnswerMs); + ansSec.value = String(Number.isFinite(ams) ? Math.round(ams / 1000) : 5); + } + var sessLen = el('quiz-carry-session-len'); + if (sessLen) { + var sl = parseInt(String(data.carrySessionLength), 10); + sessLen.value = String(Number.isFinite(sl) && sl >= 0 ? sl : 0); + } + var walkMapIdInp = el('quiz-carry-walk-map-id'); + if (walkMapIdInp) { + walkMapIdInp.value = data.carryWalkSpeedMultForMapId ? String(data.carryWalkSpeedMultForMapId).trim() : ''; + } + var walkMultInp = el('quiz-carry-walk-mult'); + if (walkMultInp) { + var wmv = Number(data.carryWalkSpeedMult); + walkMultInp.value = String(Number.isFinite(wmv) ? wmv : 1.42); + } + quizCarrySetPlaqueMapScaleInputs(data.carryChoicePlaqueMapScale); + 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); + } + var qfMinInp = el('quiz-carry-theme-qfont-min'); + var qfMaxInp = el('quiz-carry-theme-qfont-max'); + if (qfMinInp) { + var qm = parseInt(String(th.questionFontMinPx), 10); + qfMinInp.value = String(Number.isFinite(qm) ? Math.max(10, Math.min(40, qm)) : 10); + } + if (qfMaxInp) { + var qx = parseInt(String(th.questionFontMaxPx), 10); + qfMaxInp.value = String(Number.isFinite(qx) ? Math.max(14, Math.min(56, qx)) : 24); + } + quizCarryEcdFillForm(data.carryEmbedCountdownTheme || {}); + quizCarryPlaqueFillAllFromApi(data); + 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(); + } + + /** รอจน plaque upload จบ (pending=0) — กันกดบันทึกทันทีหลังเลือกไฟล์แล้ว build ได้ URL ว่าง · returns false if timeout */ + function waitUntilQuizCarryUploadsDone(maxMs) { + maxMs = maxMs || 120000; + return new Promise(function (resolve) { + if (quizCarryPlaqueUploadPending <= 0) return resolve(true); + var started = Date.now(); + var iv = setInterval(function () { + if (quizCarryPlaqueUploadPending <= 0) { + clearInterval(iv); + resolve(true); + } else if (Date.now() - started >= maxMs) { + clearInterval(iv); + resolve(false); + } + }, 40); + }); + } + + function saveQuizCarryPanel() { + var list = el('quiz-carry-admin-list'); + var saveBtn = el('btn-quiz-carry-save'); + if (!list) { + setMsg('quiz-carry-settings-msg', 'ไม่พบรายการคำถามในแท็บนี้ — รีเฟรชหน้า · quiz-carry-admin-list missing', 'error'); + return; + } + if (quizCarryPlaqueUploadPending > 0 && quizCarryPlaqueUploadStartMs > 0 && Date.now() - quizCarryPlaqueUploadStartMs > 120000) { + quizCarryPlaqueUploadPending = 0; + quizCarryPlaqueUploadStartMs = 0; + setMsg( + 'quiz-carry-settings-msg', + 'ยกเลิกสถานะอัปโหลดค้าง (>2 นาที) แล้วดำเนินการบันทึก — ถ้ายังต้องการรูปให้อัปโหลดใหม่ · Stuck upload cleared; re-upload if needed', + 'error', + ); + } + if (quizCarrySaveInFlight) { + setMsg('quiz-carry-settings-msg', 'กำลังบันทึกอยู่แล้ว — รอสักครู่ · Save already in progress', 'error'); + 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-label').forEach(function (lab, idx) { + var ti = lab.querySelector('.quiz-carry-slot-inp'); + var ii = lab.querySelector('.quiz-carry-slot-img'); + var tv = ti && ti.value ? String(ti.value).trim() : ''; + var iv = ii && ii.value ? String(ii.value).trim().slice(0, 512) : ''; + if (tv) slots.push({ idx: idx, text: tv, imageUrl: iv }); + }); + 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; + var rowOut = { + text: text, + choices: slots.map(function (s) { + return s.text; + }), + correctIndex: correctIndex, + }; + var urls = slots.map(function (s) { + return s.imageUrl || ''; + }); + if (urls.some(function (u) { + return u; + })) rowOut.choiceImageUrls = urls; + carryQuestions.push(rowOut); + }); + + var readSecEl = el('quiz-carry-read-sec'); + var ansSecEl = el('quiz-carry-answer-sec'); + var readSec = readSecEl ? parseInt(String(readSecEl.value || '3'), 10) : 3; + var ansSec = ansSecEl ? parseInt(String(ansSecEl.value || '5'), 10) : 5; + if (!Number.isFinite(readSec) || readSec < 0) readSec = 0; + if (readSec > 120) readSec = 120; + if (!Number.isFinite(ansSec) || ansSec < 1) ansSec = 1; + if (ansSec > 300) ansSec = 300; + var carryReadMs = readSec * 1000; + var carryAnswerMs = ansSec * 1000; + var sessEl = el('quiz-carry-session-len'); + var carrySessionLength = sessEl ? parseInt(String(sessEl.value || '0'), 10) : 0; + if (!Number.isFinite(carrySessionLength) || carrySessionLength < 0) carrySessionLength = 0; + if (carrySessionLength > 500) carrySessionLength = 500; + + 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; + } + + quizCarrySaveInFlight = true; + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.setAttribute('aria-busy', 'true'); + } + var waitLine = + quizCarryPlaqueUploadPending > 0 + ? 'กำลังรอให้อัปโหลดรูปป้ายจบ แล้วจะบันทึกอัตโนมัติ (ไม่ต้องกดซ้ำ)… · Waiting for plaque upload, then save…' + : 'กำลังบันทึก… · Saving…'; + setMsg('quiz-carry-settings-msg', waitLine, ''); + try { + var smEl = el('quiz-carry-settings-msg'); + if (smEl) smEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } catch (scrollSave) { /* ignore */ } + + waitUntilQuizCarryUploadsDone(120000).then(function (uploadsOk) { + if (!uploadsOk) { + quizCarrySaveInFlight = false; + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.removeAttribute('aria-busy'); + } + setMsg( + 'quiz-carry-settings-msg', + 'รออัปโหลดนานเกิน 2 นาที — ตรวจเครือข่าย/เซิร์ฟเกมแล้วลองใหม่ · Upload wait timeout', + 'error', + ); + return; + } + var plaqueScaleEl = el('quiz-carry-plaque-map-scale'); + var plaqueMapScale = 1.25; + if (plaqueScaleEl) { + var psn = parseFloat(String(plaqueScaleEl.value || '').replace(',', '.')); + if (Number.isFinite(psn)) plaqueMapScale = Math.max(0.85, Math.min(2.5, psn)); + } + var walkMapIdRaw = el('quiz-carry-walk-map-id') && el('quiz-carry-walk-map-id').value ? String(el('quiz-carry-walk-map-id').value).trim() : ''; + var walkMapId = walkMapIdRaw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64); + var walkMultRaw = el('quiz-carry-walk-mult') ? parseFloat(String(el('quiz-carry-walk-mult').value || '').replace(',', '.')) : NaN; + var putBody = { + carryReadMs: carryReadMs, + carryAnswerMs: carryAnswerMs, + carrySessionLength: carrySessionLength, + carryMapPanelTheme: carryMapPanelTheme, + carryEmbedCountdownTheme: quizCarryEcdBuildForSave(), + carryChoicePlaqueThemes: quizCarryPlaqueBuildForSave(), + carryChoicePlaqueMapScale: plaqueMapScale, + }; + if (walkMapId) { + putBody.carryWalkSpeedMultForMapId = walkMapId; + putBody.carryWalkSpeedMult = Number.isFinite(walkMultRaw) ? Math.max(0.5, Math.min(3, walkMultRaw)) : 1.42; + } else { + putBody.carryWalkSpeedMultForMapId = ''; + putBody.carryWalkSpeedMult = null; + } + 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 ecdFromRes = res && res.carryEmbedCountdownTheme && typeof res.carryEmbedCountdownTheme === 'object' ? res.carryEmbedCountdownTheme : null; + var plaqueFromRes = null; + if (res && Array.isArray(res.carryChoicePlaqueThemes) && res.carryChoicePlaqueThemes.length) { + plaqueFromRes = res.carryChoicePlaqueThemes; + } else if (res && res.carryChoicePlaqueTheme && typeof res.carryChoicePlaqueTheme === 'object') { + plaqueFromRes = []; + for (var pxi = 0; pxi < QUIZ_CARRY_PLAQUE_SLOT_COUNT; pxi++) { + plaqueFromRes.push(res.carryChoicePlaqueTheme); + } + } + if (!plaqueFromRes) { + plaqueFromRes = quizCarryPlaqueBuildForSave(); + } + var themeOk = !!themeFromRes || !!ecdFromRes || !!plaqueFromRes; + var themeTail = themeOk ? ' · ธีมแผง/ป้าย/นับถอยหลังบันทึกแล้ว' : ''; + setMsg('quiz-carry-settings-msg', 'บันทึกแล้ว' + tail + themeTail, 'ok'); + quizCarryPlaquePendingUrlBySlot = {}; + loadQuizCarryPanel({ + clearMsg: false, + themeOverride: { + carryMapPanelTheme: themeFromRes || carryMapPanelTheme, + carryEmbedCountdownTheme: ecdFromRes || quizCarryEcdBuildForSave(), + carryChoicePlaqueThemes: Array.isArray(plaqueFromRes) ? plaqueFromRes : null, + carryChoicePlaqueTheme: + Array.isArray(plaqueFromRes) && plaqueFromRes[0] && typeof plaqueFromRes[0] === 'object' + ? plaqueFromRes[0] + : null, + }, + timingOverride: { + carryReadMs: carryReadMs, + carryAnswerMs: carryAnswerMs, + carrySessionLength: carrySessionLength, + carryWalkSpeedMultForMapId: walkMapId, + carryWalkSpeedMult: walkMapId ? (Number.isFinite(walkMultRaw) ? Math.max(0.5, Math.min(3, walkMultRaw)) : 1.42) : null, + }, + fetchDelayMs: 120, + }); + }).catch(function (e) { + setMsg('quiz-carry-settings-msg', e.message || 'บันทึกไม่ได้', 'error'); + }).finally(function () { + quizCarrySaveInFlight = false; + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.removeAttribute('aria-busy'); + } + }); + }); + } + + /** หมวด Quiz Battle — ชื่อไทยให้สอดคล้องธีมการศึกษาไซเบอร์ / หน้า Quiz-Battle */ + var QB_LABELS_ABC = ['A', 'B', 'C']; + + var QUIZ_BATTLE_CATEGORIES = [ + { id: 'topic1', labelTh: 'ห้อง 1 — กฎหมายใกล้ตัว', labelEn: 'Room 1' }, + { id: 'topic2', labelTh: 'ห้อง 2 — กฎหมายสิทธิ์พื้นฐาน', labelEn: 'Room 2' }, + { id: 'topic3', labelTh: 'ห้อง 3 — กฎหมายจราจร', labelEn: 'Room 3' }, + { id: 'topic4', labelTh: 'ห้อง 4 — คดีเกี่ยวกับทรัพย์', labelEn: 'Room 4' }, + { id: 'topic5', labelTh: 'ห้อง 5 — คดีหมิ่นประมาท', labelEn: 'Room 5' }, + { id: 'topic6', labelTh: 'ห้อง 6 — คดีทางเพศ', labelEn: 'Room 6' }, + { id: 'topic7', labelTh: 'ห้อง 7 — คดีอาชญากรรม', labelEn: 'Room 7' }, + { id: 'topic8', labelTh: 'ห้อง 8 — อาชญากรรมออนไลน์', labelEn: 'Room 8' }, + { id: 'topic9', labelTh: 'ห้อง 9 — กระบวนการยุติธรรม', labelEn: 'Room 9' }, + { id: 'topic10', labelTh: 'ห้อง 10 — งานบริการกระทรวงยุติธรรม', labelEn: 'Room 10' }, + ]; + + 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 : 'topic1', + 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('topic1'); + 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 || 'topic1'); + + 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 || 'topic1') === 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); + }); + } + + /** พรีวิวคอลัมน์ beam — สูงแนวตั้ง + สีเติม/กรอบตามช่อง (คล้ายในเกม) */ + function styleGameTimingLaserBeamPreviewShell() { + var prev = el('game-timing-laser-line-preview'); + if (!prev) return; + var fillIn = el('game-timing-laser-fill'); + var strokeIn = el('game-timing-laser-stroke'); + var lwIn = el('game-timing-laser-line-width'); + var fill = fillIn && fillIn.value ? String(fillIn.value).trim() : ''; + var stroke = strokeIn && strokeIn.value ? String(strokeIn.value).trim() : ''; + var lw = lwIn ? parseInt(lwIn.value, 10) : 2; + if (Number.isNaN(lw)) lw = 2; + lw = Math.max(0, Math.min(24, lw)); + prev.style.backgroundColor = fill || ''; + if (lw > 0 && stroke) { + prev.style.boxShadow = 'inset 0 0 0 ' + Math.min(8, lw) + 'px ' + stroke; + } else { + prev.style.boxShadow = ''; + } + } + + 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); + }); + styleGameTimingLaserBeamPreviewShell(); + } + + function clamp255(x) { + return Math.max(0, Math.min(255, Math.round(Number(x)))); + } + + function parseCssColorToRgba(str) { + str = String(str || '').trim(); + if (!str) return null; + var m = str.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i); + if (m) { + var r = clamp255(m[1]); + var g = clamp255(m[2]); + var b = clamp255(m[3]); + var a = + m[4] !== undefined && m[4] !== '' && !Number.isNaN(Number(m[4])) + ? Math.max(0, Math.min(1, Number(m[4]))) + : 1; + return { r: r, g: g, b: b, a: a }; + } + m = str.match(/^#([\da-f]{3}|[\da-f]{6})$/i); + if (m) { + var h = m[1]; + var r2; + var g2; + var b2; + if (h.length === 3) { + r2 = parseInt(h[0] + h[0], 16); + g2 = parseInt(h[1] + h[1], 16); + b2 = parseInt(h[2] + h[2], 16); + } else { + r2 = parseInt(h.slice(0, 2), 16); + g2 = parseInt(h.slice(2, 4), 16); + b2 = parseInt(h.slice(4, 6), 16); + } + if (Number.isNaN(r2) || Number.isNaN(g2) || Number.isNaN(b2)) return null; + return { r: r2, g: g2, b: b2, a: 1 }; + } + var probe = document.createElement('span'); + probe.style.cssText = 'position:absolute;visibility:hidden;left:0;top:0;background-color:' + str; + document.body.appendChild(probe); + var bg = getComputedStyle(probe).backgroundColor; + document.body.removeChild(probe); + m = bg && bg.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/); + if (!m) return null; + r2 = clamp255(m[1]); + g2 = clamp255(m[2]); + b2 = clamp255(m[3]); + var a2 = m[4] !== undefined && !Number.isNaN(Number(m[4])) ? Math.max(0, Math.min(1, Number(m[4]))) : 1; + return { r: r2, g: g2, b: b2, a: a2 }; + } + + function rgbToHex6(r, g, b) { + function hx(n) { + var h = clamp255(n).toString(16); + return h.length === 1 ? '0' + h : h; + } + return '#' + hx(r) + hx(g) + hx(b); + } + + function laserHexFromTextOrFallback(text, fallbackHex) { + var c = parseCssColorToRgba(text); + if (!c) return fallbackHex; + return rgbToHex6(c.r, c.g, c.b); + } + + function formatLaserRgba(r, g, b, a) { + var aa = Math.round(Math.max(0, Math.min(1, a)) * 100) / 100; + return 'rgba(' + clamp255(r) + ',' + clamp255(g) + ',' + clamp255(b) + ',' + aa + ')'; + } + + function syncGameTimingLaserColorSwatches() { + function apply(swatchId, textVal) { + var sw = el(swatchId); + if (!sw) return; + var v = textVal != null ? String(textVal).trim() : ''; + sw.style.backgroundColor = v || ''; + sw.classList.toggle('is-empty', !v); + sw.title = v || 'ว่าง — พิมพ์ค่า CSS หรือกดสี่เหลี่ยมเพื่อเลือกสี'; + } + var fillIn = el('game-timing-laser-fill'); + var strokeIn = el('game-timing-laser-stroke'); + apply('game-timing-laser-fill-swatch', fillIn && fillIn.value); + apply('game-timing-laser-stroke-swatch', strokeIn && strokeIn.value); + var fillPk = el('game-timing-laser-fill-picker'); + var strokePk = el('game-timing-laser-stroke-picker'); + if (fillPk && fillIn) { + var hf = laserHexFromTextOrFallback(fillIn.value, '#ef4444'); + if (fillPk.value.toLowerCase() !== hf.toLowerCase()) fillPk.value = hf; + } + if (strokePk && strokeIn) { + var hs = laserHexFromTextOrFallback(strokeIn.value, '#fca5a5'); + if (strokePk.value.toLowerCase() !== hs.toLowerCase()) strokePk.value = hs; + } + styleGameTimingLaserBeamPreviewShell(); + } + + function bindGameTimingLaserColorSwatches() { + var fillIn = el('game-timing-laser-fill'); + var strokeIn = el('game-timing-laser-stroke'); + if (fillIn) { + fillIn.addEventListener('input', syncGameTimingLaserColorSwatches); + fillIn.addEventListener('change', syncGameTimingLaserColorSwatches); + } + if (strokeIn) { + strokeIn.addEventListener('input', syncGameTimingLaserColorSwatches); + strokeIn.addEventListener('change', syncGameTimingLaserColorSwatches); + } + function wirePicker(pickerId, textEl, defaultAlpha) { + var pk = el(pickerId); + var tx = textEl; + if (!pk || !tx) return; + function applyHex() { + var hex = String(pk.value || '').trim(); + if (!/^#[\da-f]{6}$/i.test(hex)) return; + var r = parseInt(hex.slice(1, 3), 16); + var g = parseInt(hex.slice(3, 5), 16); + var b = parseInt(hex.slice(5, 7), 16); + var prev = parseCssColorToRgba(tx.value); + var a = prev && typeof prev.a === 'number' ? prev.a : defaultAlpha; + tx.value = formatLaserRgba(r, g, b, a); + syncGameTimingLaserColorSwatches(); + } + pk.addEventListener('input', applyHex); + pk.addEventListener('change', applyHex); + } + wirePicker('game-timing-laser-fill-picker', fillIn, 0.35); + wirePicker('game-timing-laser-stroke-picker', strokeIn, 0.95); + var lwIn = el('game-timing-laser-line-width'); + if (lwIn) { + lwIn.addEventListener('input', styleGameTimingLaserBeamPreviewShell); + lwIn.addEventListener('change', styleGameTimingLaserBeamPreviewShell); + } + syncGameTimingLaserColorSwatches(); + } + + 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 = {}; + var parsed = false; + try { + j = text ? JSON.parse(text) : {}; + parsed = true; + } catch (e) { + if (!r.ok) { + var snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 220); + var tail = ''; + if (r.status === 413) { + tail = + ' · คำขอใหญ่เกิน (nginx client_max_body_size / PHP post_max_size) — รัน deploy-nginx และตั้ง post_max_size ≥20M · Payload too large'; + } else if (r.status === 401) { + tail = ' · ล็อกอิน Admin ใหม่ · Re-login to Admin'; + } else if (r.status === 502 || r.status === 503 || r.status === 504) { + tail = ' · เซิร์ฟเกม/PHP upstream ล่ม — ตรวจ Node + php-fpm · Upstream error'; + } else if (r.status === 404) { + tail = ' · ตรวจ path ไฟล์ / รีสตาร์ทบริการหลัง deploy · Not found'; + } + throw new Error((snippet || r.statusText || 'Request failed') + tail + ' (HTTP ' + r.status + ')'); + } + throw e; + } + if (!r.ok) { + var plain = (j && j.error) || r.statusText || 'Request failed'; + if (r.status === 413) { + plain = + (j && j.error) || + 'ไฟล์/คำขอใหญ่เกินขีดจำกัด (nginx หรือ PHP) — ตั้ง client_max_body_size (server หรือ /Admin/api/) และ post_max_size ≥20M · Payload too large'; + } + if (r.status === 401) { + plain = + (j && j.error) || + 'หมดเซสชันแอดมิน — ล็อกอินใหม่แล้วลองอัปโหลดอีกครั้ง · Re-login to Admin and retry upload'; + } + if (r.status === 404) { + plain += ' — รีสตาร์ท Node ที่รัน Game/server.js หลัง deploy · Restart Game Node'; + } + if (r.status === 502 || r.status === 503 || r.status === 504) { + plain += + ' — PHP/Node upstream ล่ม — ตรวจ game-zep (Node) และ php-fpm · Check Node + PHP-FPM'; + } + if (parsed && (!j || Object.keys(j).length === 0)) { + plain = (text || '').replace(/\s+/g, ' ').trim().slice(0, 200) || plain; + if (r.status) plain += ' (HTTP ' + r.status + ')'; + } + 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) : ''; + } + if (el('stack-tower-mission-sec')) { + var st = Number(data.stackTowerMissionTimeSec); + el('stack-tower-mission-sec').value = String(Number.isFinite(st) && st > 0 ? Math.floor(Math.max(10, Math.min(7200, st))) : 90); + } + if (el('stack-team-misses-max')) { + var sm = Number(data.stackTeamMissesMax); + el('stack-team-misses-max').value = String(Number.isFinite(sm) ? Math.floor(Math.max(1, Math.min(20, sm))) : 3); + } + if (el('stack-tower-progress-blocks')) { + var pb = Number(data.stackTowerProgressBlocks); + el('stack-tower-progress-blocks').value = String(Number.isFinite(pb) ? Math.floor(Math.max(1, Math.min(500, pb))) : 50); + } + if (el('stack-heavy-block-percent')) { + var shp = Number(data.stackHeavyBlockPercent); + el('stack-heavy-block-percent').value = String(Number.isFinite(shp) ? Math.max(0, Math.min(100, Math.floor(shp))) : 35); + } + var normArr = Array.isArray(data.stackBlockNormalImageUrls) ? data.stackBlockNormalImageUrls : []; + var heavyArr = Array.isArray(data.stackBlockHeavyImageUrls) ? data.stackBlockHeavyImageUrls : []; + for (var si = 1; si <= 8; si++) { + var nin = el('stack-block-normal-url-' + si); + var hin = el('stack-block-heavy-url-' + si); + var nv = normArr[si - 1] != null && String(normArr[si - 1]) !== '' ? String(normArr[si - 1]) : STACK_BLOCK_DEFAULT_NORMAL[si - 1]; + var hv = heavyArr[si - 1] != null && String(heavyArr[si - 1]) !== '' ? String(heavyArr[si - 1]) : STACK_BLOCK_DEFAULT_HEAVY[si - 1]; + if (nin) nin.value = nv || ''; + if (hin) hin.value = hv || ''; + updateStackBlockSeatPreview(si); + } + } + /* #4 MG3 8 สีตาม lobby theme — ค่าเริ่มต้น Block-Code-1..8 (ตรง PLAY_LOBBY_THEME_HEX) */ + var STACK_BLOCK_THEME_NAMES = ['1-red', '2-orange', '3-Yellow', '4-Green', '5-Blue', '6-Navy', '7-Violet', '8-Pink']; + var STACK_BLOCK_DEFAULT_NORMAL = STACK_BLOCK_THEME_NAMES.map(function (s) { return '/Game/img/TowerBlock/Block-Code-' + s + '.png'; }); + var STACK_BLOCK_DEFAULT_HEAVY = STACK_BLOCK_THEME_NAMES.map(function (s) { return '/Game/img/TowerBlock/Block-Code-' + s + '-2.png'; }); + + function readStackBlockNormalUrlsFromForm() { + var out = []; + for (var i = 1; i <= 8; i++) { + var inp = el('stack-block-normal-url-' + i); + out.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return out; + } + + function readStackBlockHeavyUrlsFromForm() { + var out = []; + for (var j = 1; j <= 8; j++) { + var inp = el('stack-block-heavy-url-' + j); + out.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return out; + } + + function updateStackBlockSeatPreview(seat) { + ['normal', 'heavy'].forEach(function (kind) { + var inp = el('stack-block-' + kind + '-url-' + seat); + var img = el('stack-block-' + kind + '-prev-' + seat); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = (kind === 'heavy' ? 'Heavy ' : 'Normal ') + 'P' + seat; + img.src = v; + }); + } + + function bindStackBlockVisualInputs() { + for (var s = 1; s <= 8; s++) { + (function (seat) { + var nIn = el('stack-block-normal-url-' + seat); + var hIn = el('stack-block-heavy-url-' + seat); + if (nIn) { + nIn.addEventListener('change', function () { updateStackBlockSeatPreview(seat); }); + nIn.addEventListener('blur', function () { updateStackBlockSeatPreview(seat); }); + } + if (hIn) { + hIn.addEventListener('change', function () { updateStackBlockSeatPreview(seat); }); + hIn.addEventListener('blur', function () { updateStackBlockSeatPreview(seat); }); + } + var bn = el('btn-stack-block-normal-clear-' + seat); + if (bn) { + bn.addEventListener('click', function () { + var i = el('stack-block-normal-url-' + seat); + if (i) i.value = ''; + updateStackBlockSeatPreview(seat); + }); + } + var bh = el('btn-stack-block-heavy-clear-' + seat); + if (bh) { + bh.addEventListener('click', function () { + var i = el('stack-block-heavy-url-' + seat); + if (i) i.value = ''; + updateStackBlockSeatPreview(seat); + }); + } + })(s); + } + } + + 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 secIn = el('stack-tower-mission-sec') ? parseInt(String(el('stack-tower-mission-sec').value), 10) : 90; + if (!Number.isFinite(secIn) || secIn < 10 || secIn > 7200) { + setMsg('stack-game-msg', 'เวลารอบต้องอยู่ระหว่าง 10–7200 วินาที', 'error'); + return; + } + var missIn = el('stack-team-misses-max') ? parseInt(String(el('stack-team-misses-max').value), 10) : 3; + if (!Number.isFinite(missIn) || missIn < 1 || missIn > 20) { + setMsg('stack-game-msg', 'โอกาสพลาดต้องอยู่ระหว่าง 1–20 ครั้ง', 'error'); + return; + } + var progBlocksIn = el('stack-tower-progress-blocks') ? parseInt(String(el('stack-tower-progress-blocks').value), 10) : 50; + if (!Number.isFinite(progBlocksIn) || progBlocksIn < 1 || progBlocksIn > 500) { + setMsg('stack-game-msg', 'Progress เต็ม 100%: จำนวนชั้นต้องอยู่ระหว่าง 1–500', 'error'); + return; + } + var heavyPctIn = el('stack-heavy-block-percent') ? parseInt(String(el('stack-heavy-block-percent').value), 10) : 35; + if (!Number.isFinite(heavyPctIn) || heavyPctIn < 0 || heavyPctIn > 100) { + setMsg('stack-game-msg', 'โอกาสบล็อกใหญ่ (%) ต้องอยู่ระหว่าง 0–100', 'error'); + return; + } + 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; + data.stackTowerMissionTimeSec = secIn; + data.stackTeamMissesMax = missIn; + data.stackTowerProgressBlocks = progBlocksIn; + data.stackHeavyBlockPercent = heavyPctIn; + data.stackBlockNormalImageUrls = readStackBlockNormalUrlsFromForm(); + data.stackBlockHeavyImageUrls = readStackBlockHeavyUrlsFromForm(); + 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' + : 'อัตโนมัติ (จากแผนที่)'; + var stf = fresh && fresh.stackTowerMissionTimeSec != null ? Number(fresh.stackTowerMissionTimeSec) : secIn; + var msf = fresh && fresh.stackTeamMissesMax != null ? Number(fresh.stackTeamMissesMax) : missIn; + var pbf = fresh && fresh.stackTowerProgressBlocks != null ? Number(fresh.stackTowerProgressBlocks) : progBlocksIn; + setMsg('stack-game-msg', 'บันทึกสำเร็จ — สวิง ' + hz + ' Hz · กว้าง ' + wLabel + ' · เวลา ' + stf + 's · พลาด ' + msf + ' ครั้ง · Progress 100% ที่ ' + pbf + ' ชั้น · รูปบล็อก P1–P6 + ใหญ่ ' + (fresh && fresh.stackHeavyBlockPercent != null ? fresh.stackHeavyBlockPercent : heavyPctIn) + '% · โหลดยืนยันจากเซิร์ฟเวอร์แล้ว · ผู้เล่นต้องรีเฟรชหน้าเล่นหรือเข้าห้องใหม่', '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); + if (el('game-timing-jump-height')) el('game-timing-jump-height').value = String(data.gauntletJumpHeightMult != null ? data.gauntletJumpHeightMult : 0.52); + el('game-timing-limit-sec').value = String(data.gauntletTimeLimitSec != null ? data.gauntletTimeLimitSec : 0); + /* troublesomeOfferSec/RollMaxSec ย้ายไปแท็บ "บทตัวป่วน" แล้ว */ + 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(); + syncGameTimingLaserColorSwatches(); + applyGameTimingDataToStackInputs(data); + setMsg('game-timing-msg', '', ''); + return refreshGauntletAssetLibrary(); + }) + .catch(function (e) { + setMsg('game-timing-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function normalizeJumpSurvivePlatformTileUrlInput(v) { + var s = String(v || '').trim().slice(0, 500); + if (!s) return ''; + if (!/^https?:\/\//i.test(s) && s.charAt(0) !== '/') s = '/' + s.replace(/^\/+/, ''); + return s; + } + + function updateJumpSurvivePlatformTilePreview(slot) { + var img = el('jump-survive-platform-tile-prev-' + slot); + var inp = el('jump-survive-platform-tile-url-' + slot); + if (!img) return; + var v = inp && inp.value ? normalizeJumpSurvivePlatformTileUrlInput(inp.value) : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + img.alt = 'Jump platform tile ' + slot + ' preview'; + img.src = v; + } + + function readJumpSurvivePlatformTilesFromForm() { + var out = []; + for (var s = 1; s <= 3; s++) { + var inpU = el('jump-survive-platform-tile-url-' + s); + var url = inpU ? normalizeJumpSurvivePlatformTileUrlInput(inpU.value) : ''; + var inpW = el('jump-survive-platform-tile-w-' + s); + var inpH = el('jump-survive-platform-tile-h-' + s); + var w = inpW ? parseInt(String(inpW.value), 10) : 0; + var h = inpH ? parseInt(String(inpH.value), 10) : 0; + if (!Number.isFinite(w) || w < 0) w = 0; + if (!Number.isFinite(h) || h < 0) h = 0; + out.push({ url: url, w: w, h: h }); + } + return out; + } + + function bindJumpSurvivePlatformTileInputs() { + for (var s = 1; s <= 3; s++) { + (function (slot) { + var inp = el('jump-survive-platform-tile-url-' + slot); + var btn = el('btn-jump-survive-platform-tile-clear-' + slot); + if (inp) { + inp.addEventListener('change', function () { updateJumpSurvivePlatformTilePreview(slot); }); + inp.addEventListener('blur', function () { updateJumpSurvivePlatformTilePreview(slot); }); + } + if (btn) { + btn.addEventListener('click', function () { + var i = el('jump-survive-platform-tile-url-' + slot); + if (i) i.value = ''; + updateJumpSurvivePlatformTilePreview(slot); + }); + } + })(s); + } + } + + 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); + } + var inpT = el('jump-survive-mission-sec'); + if (inpT) { + var jss = Number(data.jumpSurviveMissionTimeSec); + inpT.value = String(Number.isFinite(jss) && jss > 0 ? Math.floor(jss) : 0); + } + var tiles = Array.isArray(data.jumpSurvivePlatformTiles) ? data.jumpSurvivePlatformTiles : []; + var legacy = typeof data.jumpSurvivePlatformTileUrl === 'string' ? data.jumpSurvivePlatformTileUrl : ''; + for (var s = 1; s <= 3; s++) { + var o = tiles[s - 1] && typeof tiles[s - 1] === 'object' ? tiles[s - 1] : {}; + var url = typeof o.url === 'string' ? o.url : ''; + if (s === 1 && !url && legacy) url = legacy; + var inpU = el('jump-survive-platform-tile-url-' + s); + if (inpU) inpU.value = url; + var w = Number(o.w); + var h = Number(o.h); + var inpW = el('jump-survive-platform-tile-w-' + s); + var inpH = el('jump-survive-platform-tile-h-' + s); + if (inpW) inpW.value = String(Number.isFinite(w) && w > 0 ? Math.floor(w) : 0); + if (inpH) inpH.value = String(Number.isFinite(h) && h > 0 ? Math.floor(h) : 0); + updateJumpSurvivePlatformTilePreview(s); + } + setMsg('jump-survive-timing-msg', '', ''); + }) + .catch(function (e) { + setMsg('jump-survive-timing-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function updateSpaceShooterShipPreview(slot) { + var img = el('space-shooter-ship-prev-' + slot); + var inp = el('space-shooter-ship-url-' + slot); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = 'Ship slot ' + slot; + img.src = v; + } + + function bindSpaceShooterShipUrlInputs() { + for (var s = 1; s <= 6; s++) { + (function (slot) { + var inp = el('space-shooter-ship-url-' + slot); + var btn = el('btn-space-shooter-ship-clear-' + slot); + if (inp) { + inp.addEventListener('change', function () { updateSpaceShooterShipPreview(slot); }); + inp.addEventListener('blur', function () { updateSpaceShooterShipPreview(slot); }); + } + if (btn) { + btn.addEventListener('click', function () { + var i = el('space-shooter-ship-url-' + slot); + if (i) i.value = ''; + updateSpaceShooterShipPreview(slot); + }); + } + })(s); + } + } + + function readSpaceShooterShipImageUrlsFromForm() { + var urls = []; + for (var i = 1; i <= 6; i++) { + var inp = el('space-shooter-ship-url-' + i); + urls.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return urls; + } + + function updateSpaceShooterDamageOverlayPreview(hit) { + var img = el('space-shooter-damage-prev-' + hit); + var inp = el('space-shooter-damage-url-' + hit); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = 'Damage overlay hit ' + hit; + img.src = v; + } + + function readSpaceShooterShipDamageOverlayUrlsFromForm() { + var urls = []; + for (var h = 1; h <= 3; h++) { + var inp = el('space-shooter-damage-url-' + h); + urls.push(inp && inp.value ? String(inp.value).trim() : ''); + } + return urls; + } + + function bindSpaceShooterDamageOverlayPanel() { + for (var h = 1; h <= 3; h++) { + (function (hit) { + var inp = el('space-shooter-damage-url-' + hit); + var btnClr = el('btn-space-shooter-damage-clear-' + hit); + var btnUp = el('btn-space-shooter-damage-upload-' + hit); + var fileInp = el('space-shooter-damage-file-' + hit); + if (inp) { + inp.addEventListener('change', function () { updateSpaceShooterDamageOverlayPreview(hit); }); + inp.addEventListener('blur', function () { updateSpaceShooterDamageOverlayPreview(hit); }); + } + if (btnClr) { + btnClr.addEventListener('click', function () { + var i = el('space-shooter-damage-url-' + hit); + if (i) i.value = ''; + if (fileInp) fileInp.value = ''; + updateSpaceShooterDamageOverlayPreview(hit); + }); + } + if (btnUp && fileInp) { + btnUp.addEventListener('click', function () { + if (!fileInp.files || !fileInp.files[0]) { + setMsg('space-shooter-timing-msg', 'เลือกไฟล์รูปก่อน · Pick an image file first', 'error'); + return; + } + readFileAsDataURL(fileInp.files[0]) + .then(function (dataUrl) { + return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + imageDataUrl: dataUrl, + label: 'space-shooter-damage-hit-' + hit, + }), + }); + }) + .then(function (j) { + if (!j || !j.ok || !j.url) { + throw new Error((j && j.error) || 'อัปโหลดไม่สำเร็จ · Upload failed'); + } + var inpU = el('space-shooter-damage-url-' + hit); + if (inpU) inpU.value = String(j.url).trim(); + updateSpaceShooterDamageOverlayPreview(hit); + setMsg( + 'space-shooter-timing-msg', + 'อัปโหลด overlay ช่อง ' + hit + ' แล้ว — กดบันทึกด้านล่างเพื่อเก็บลง game-timing.json · Uploaded; click Save', + 'ok' + ); + }) + .catch(function (e) { + setMsg('space-shooter-timing-msg', e.message || 'อัปโหลดไม่สำเร็จ', 'error'); + }); + }); + } + })(h); + } + } + + function updateSpaceShooterAstFallPreview() { + var img = el('space-shooter-ast-fall-prev'); + var inp = el('space-shooter-ast-fall-url'); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = 'Asteroid falling'; + img.src = v; + } + + function updateSpaceShooterAstExplodeRowPreview(row) { + if (!row) return; + var img = row.querySelector('.space-shooter-ast-explode-prev'); + var inp = row.querySelector('.space-shooter-ast-explode-url'); + if (!img) return; + var v = inp && inp.value ? String(inp.value).trim() : ''; + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + if (!/^https?:\/\//i.test(v) && v.charAt(0) !== '/') v = '/' + v.replace(/^\/+/, ''); + img.alt = 'Asteroid explode frame'; + img.src = v; + } + + function renumberSpaceShooterAstExplodeRows() { + var wrap = el('space-shooter-ast-explode-rows'); + if (!wrap) return; + var rows = wrap.querySelectorAll('.space-shooter-ast-explode-row'); + rows.forEach(function (row, i) { + var slot = row.querySelector('.space-shooter-ship-slot'); + if (slot) slot.textContent = String(i + 1); + }); + } + + function addSpaceShooterAstExplodeRow(initialUrl) { + var wrap = el('space-shooter-ast-explode-rows'); + if (!wrap) return; + var rows = wrap.querySelectorAll('.space-shooter-ast-explode-row'); + if (rows.length >= 31) { + setMsg('space-shooter-timing-msg', 'เฟรมแตกสูงสุด 31 แถว (รวมรูปตกแล้วไม่เกิน 32 URL) · Max 31 explosion rows', 'error'); + return; + } + var ix = rows.length + 1; + var row = document.createElement('div'); + row.className = 'space-shooter-ship-row space-shooter-ast-explode-row'; + row.innerHTML = + '' + + ix + + '' + + '' + + '' + + '' + + '' + + ''; + var inp = row.querySelector('.space-shooter-ast-explode-url'); + if (inp && initialUrl != null && String(initialUrl).trim()) inp.value = String(initialUrl).trim(); + if (inp) { + inp.addEventListener('change', function () { + updateSpaceShooterAstExplodeRowPreview(row); + }); + inp.addEventListener('blur', function () { + updateSpaceShooterAstExplodeRowPreview(row); + }); + } + wrap.appendChild(row); + updateSpaceShooterAstExplodeRowPreview(row); + } + + function clearSpaceShooterAstExplodeRows() { + var wrap = el('space-shooter-ast-explode-rows'); + if (wrap) wrap.innerHTML = ''; + } + + function readSpaceShooterAsteroidSpriteUrlsFromForm() { + var out = []; + var fallInp = el('space-shooter-ast-fall-url'); + var fall = fallInp && fallInp.value ? String(fallInp.value).trim() : ''; + if (fall) out.push(fall); + var wrap = el('space-shooter-ast-explode-rows'); + if (wrap) { + var rows = wrap.querySelectorAll('.space-shooter-ast-explode-row'); + for (var ri = 0; ri < rows.length && out.length < 32; ri++) { + var inp = rows[ri].querySelector('.space-shooter-ast-explode-url'); + var u = inp && inp.value ? String(inp.value).trim() : ''; + if (u) out.push(u); + } + } + return out; + } + + function bindSpaceShooterAsteroidSpritePanel() { + var wrap = el('space-shooter-ast-explode-rows'); + if (!wrap || wrap.getAttribute('data-bound') === '1') return; + wrap.setAttribute('data-bound', '1'); + wrap.addEventListener('click', function (ev) { + var t = ev.target; + if (!t || !t.closest) return; + var rm = t.closest('.btn-space-shooter-ast-explode-remove'); + if (rm) { + var row = rm.closest('.space-shooter-ast-explode-row'); + if (row && row.parentNode) row.parentNode.removeChild(row); + renumberSpaceShooterAstExplodeRows(); + return; + } + var clr = t.closest('.btn-space-shooter-ast-explode-clear'); + if (clr) { + var rowC = clr.closest('.space-shooter-ast-explode-row'); + if (!rowC) return; + var iu = rowC.querySelector('.space-shooter-ast-explode-url'); + var fi = rowC.querySelector('.space-shooter-ast-explode-file'); + if (iu) iu.value = ''; + if (fi) fi.value = ''; + updateSpaceShooterAstExplodeRowPreview(rowC); + return; + } + var up = t.closest('.btn-space-shooter-ast-explode-upload'); + if (!up) return; + var rowU = up.closest('.space-shooter-ast-explode-row'); + if (!rowU) return; + var fileInp = rowU.querySelector('.space-shooter-ast-explode-file'); + if (!fileInp || !fileInp.files || !fileInp.files[0]) { + setMsg('space-shooter-timing-msg', 'เลือกไฟล์รูปก่อน · Pick an image file first', 'error'); + return; + } + readFileAsDataURL(fileInp.files[0]) + .then(function (dataUrl) { + return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + imageDataUrl: dataUrl, + label: 'space-shooter-ast-explode', + }), + }); + }) + .then(function (j) { + if (!j || !j.ok || !j.url) { + throw new Error((j && j.error) || 'อัปโหลดไม่สำเร็จ · Upload failed'); + } + var inpU = rowU.querySelector('.space-shooter-ast-explode-url'); + if (inpU) inpU.value = String(j.url).trim(); + updateSpaceShooterAstExplodeRowPreview(rowU); + setMsg( + 'space-shooter-timing-msg', + 'อัปโหลดเฟรมแตกแล้ว — กดบันทึกด้านล่างเพื่อเก็บ · Uploaded; click Save', + 'ok' + ); + }) + .catch(function (e) { + setMsg('space-shooter-timing-msg', e.message || 'อัปโหลดไม่สำเร็จ', 'error'); + }); + }); + var btnAdd = el('btn-space-shooter-ast-add-explode'); + if (btnAdd) { + btnAdd.addEventListener('click', function () { + setMsg('space-shooter-timing-msg', '', ''); + addSpaceShooterAstExplodeRow(''); + }); + } + var fallInp = el('space-shooter-ast-fall-url'); + var fallFile = el('space-shooter-ast-fall-file'); + var fallUp = el('btn-space-shooter-ast-fall-upload'); + var fallClr = el('btn-space-shooter-ast-fall-clear'); + if (fallInp) { + fallInp.addEventListener('change', updateSpaceShooterAstFallPreview); + fallInp.addEventListener('blur', updateSpaceShooterAstFallPreview); + } + if (fallClr) { + fallClr.addEventListener('click', function () { + if (fallInp) fallInp.value = ''; + if (fallFile) fallFile.value = ''; + updateSpaceShooterAstFallPreview(); + }); + } + if (fallUp && fallFile) { + fallUp.addEventListener('click', function () { + if (!fallFile.files || !fallFile.files[0]) { + setMsg('space-shooter-timing-msg', 'เลือกไฟล์รูปก่อน · Pick an image file first', 'error'); + return; + } + readFileAsDataURL(fallFile.files[0]) + .then(function (dataUrl) { + return gauntletAssetsJsonFetch(GAME_GAUNTLET_ASSETS_API + '/upload', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + imageDataUrl: dataUrl, + label: 'space-shooter-ast-fall', + }), + }); + }) + .then(function (j) { + if (!j || !j.ok || !j.url) { + throw new Error((j && j.error) || 'อัปโหลดไม่สำเร็จ · Upload failed'); + } + if (fallInp) fallInp.value = String(j.url).trim(); + updateSpaceShooterAstFallPreview(); + setMsg( + 'space-shooter-timing-msg', + 'อัปโหลดรูปตกแล้ว — กดบันทึกด้านล่างเพื่อเก็บ · Uploaded; click Save', + 'ok' + ); + }) + .catch(function (e) { + setMsg('space-shooter-timing-msg', e.message || 'อัปโหลดไม่สำเร็จ', 'error'); + }); + }); + } + } + + function loadSpaceShooterTimingPanel() { + gameTimingFetch('GET') + .then(function (data) { + var inpT = el('space-shooter-mission-sec'); + if (inpT) { + var ss = Number(data.spaceShooterMissionTimeSec); + inpT.value = String(Number.isFinite(ss) && ss > 0 ? Math.floor(ss) : 0); + } + var inpIv = el('space-shooter-asteroid-interval-ms'); + if (inpIv) { + var iv = Number(data.spaceShooterAsteroidIntervalMs); + inpIv.value = String(Number.isFinite(iv) && iv >= 200 ? Math.min(10000, Math.floor(iv)) : 1040); + } + var urls = data.spaceShooterShipImageUrls; + if (!Array.isArray(urls)) urls = []; + for (var k = 1; k <= 6; k++) { + var inpU = el('space-shooter-ship-url-' + k); + if (inpU) inpU.value = urls[k - 1] != null ? String(urls[k - 1]) : ''; + updateSpaceShooterShipPreview(k); + } + var fallInpL = el('space-shooter-ast-fall-url'); + if (fallInpL) { + var astArr = data.spaceShooterAsteroidSpriteUrls; + if (!Array.isArray(astArr)) astArr = []; + fallInpL.value = astArr[0] != null ? String(astArr[0]) : ''; + updateSpaceShooterAstFallPreview(); + clearSpaceShooterAstExplodeRows(); + for (var ai = 1; ai < astArr.length && ai < 32; ai++) { + addSpaceShooterAstExplodeRow(astArr[ai]); + } + } + var inpFms = el('space-shooter-asteroid-explode-ms'); + if (inpFms) { + var ef = Number(data.spaceShooterAsteroidExplodeFrameMs); + inpFms.value = String(Number.isFinite(ef) ? Math.max(30, Math.min(500, Math.round(ef))) : 70); + } + var dmg = data.spaceShooterShipDamageOverlayUrls; + if (!Array.isArray(dmg)) dmg = []; + for (var dh = 1; dh <= 3; dh++) { + var inpD = el('space-shooter-damage-url-' + dh); + if (inpD) inpD.value = dmg[dh - 1] != null ? String(dmg[dh - 1]) : ''; + updateSpaceShooterDamageOverlayPreview(dh); + } + setMsg('space-shooter-timing-msg', '', ''); + }) + .catch(function (e) { + setMsg('space-shooter-timing-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function saveSpaceShooterTimingPanel() { + var limSs = el('space-shooter-mission-sec') ? parseInt(String(el('space-shooter-mission-sec').value), 10) : 0; + if (Number.isNaN(limSs) || limSs < 0) limSs = 0; + limSs = limSs <= 0 ? 0 : Math.max(10, Math.min(7200, limSs)); + var shipUrls = readSpaceShooterShipImageUrlsFromForm(); + var damageUrls = readSpaceShooterShipDamageOverlayUrlsFromForm(); + var astUrls = readSpaceShooterAsteroidSpriteUrlsFromForm(); + var inpFms = el('space-shooter-asteroid-explode-ms'); + var explodeMs = inpFms ? parseInt(String(inpFms.value), 10) : 70; + if (Number.isNaN(explodeMs)) explodeMs = 70; + explodeMs = Math.max(30, Math.min(500, explodeMs)); + var inpIv = el('space-shooter-asteroid-interval-ms'); + var asteroidIv = inpIv ? parseInt(String(inpIv.value), 10) : 1040; + if (Number.isNaN(asteroidIv)) asteroidIv = 1040; + asteroidIv = Math.max(200, Math.min(10000, asteroidIv)); + gameTimingFetch('GET') + .then(function (data) { + data.spaceShooterMissionTimeSec = limSs; + data.spaceShooterShipImageUrls = shipUrls; + data.spaceShooterShipDamageOverlayUrls = damageUrls; + data.spaceShooterAsteroidSpriteUrls = astUrls; + data.spaceShooterAsteroidExplodeFrameMs = explodeMs; + data.spaceShooterAsteroidIntervalMs = asteroidIv; + return gameTimingFetch('PUT', data); + }) + .then(function () { + setMsg('space-shooter-timing-msg', 'บันทึกแล้ว — เวลา / ความถี่เกิดอุกาบาต / รูปยาน / overlay / อุกาบาต มีผลเมื่อรีเฟรชหน้าเล่นหรือโหลด /api/game-timing', 'ok'); + }) + .catch(function (e) { + setMsg('space-shooter-timing-msg', e.message || 'บันทึกไม่ได้', 'error'); + }); + } + + function encodeSpacesInUrlPath(t) { + var s = String(t || ''); + var q = s.indexOf('?'); + var pathPart = q === -1 ? s : s.slice(0, q); + var query = q === -1 ? '' : s.slice(q); + return pathPart.replace(/ /g, '%20') + query; + } + + function decodeAssetUrlPercentRuns(t) { + var s = String(t || ''); + var q = s.indexOf('?'); + var base = q === -1 ? s : s.slice(0, q); + var query = q === -1 ? '' : s.slice(q); + for (var i = 0; i < 2; i++) { + try { + var d = decodeURIComponent(base); + if (d === base) break; + base = d; + } catch (e) { + break; + } + } + return base + query; + } + + function normalizeGameAssetUrlForWebAdmin(t) { + var s = String(t || ''); + s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/'); + s = s.replace(/^Game\/public\/img\//i, 'Game/img/'); + return s; + } + + /** ให้สอดคล้องกับ server sanitizeGauntletAssetUrl + กัน placeholder .... */ + function normalizeMegaVirusAssetUrl(v) { + var t = v != null ? normalizeGameAssetUrlForWebAdmin(decodeAssetUrlPercentRuns(String(v).trim())).replace(/\/+$/, '') : ''; + if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(t) || /^Game\/img\/MegaVirus\/Artboard$/i.test(t)) { + t = '/Game/img/MegaVirus/Artboard%209.png'; + } + if (!t || t.length > 500) return ''; + if (/\.{4,}/.test(t)) return ''; + if (/[\t\n\r<>"'`]/.test(t)) return ''; + if (/^https?:\/\//i.test(t)) return encodeSpacesInUrlPath(t); + if (/^Game\//i.test(t)) return encodeSpacesInUrlPath('/' + t.replace(/^\/+/, '')); + if (t.charAt(0) === '/') return encodeSpacesInUrlPath(t); + return ''; + } + + function updateMegaVirusBossPreview() { + var img = el('mega-virus-boss-prev'); + var inp = el('mega-virus-boss-url'); + if (!img) return; + var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : ''); + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + img.alt = 'Boss preview'; + img.src = v; + } + + function updateMegaVirusBalloonPreview(slot) { + var img = el('mega-virus-balloon-prev-' + slot); + var inp = el('mega-virus-balloon-url-' + slot); + if (!img) return; + var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : ''); + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + img.alt = 'Balloon P' + slot; + img.src = v; + } + + function updateMegaVirusBalloonFallbackPreview() { + var img = el('mega-virus-balloon-fallback-prev'); + var inp = el('mega-virus-balloon-fallback-url'); + if (!img) return; + var v = normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : ''); + if (!v) { + img.removeAttribute('src'); + img.alt = ''; + return; + } + img.alt = 'Fallback balloon'; + img.src = v; + } + + function readMegaVirusBalloonUrlsFromForm() { + var urls = []; + for (var i = 1; i <= 6; i++) { + var inp = el('mega-virus-balloon-url-' + i); + urls.push(normalizeMegaVirusAssetUrl(inp && inp.value ? String(inp.value).trim() : '')); + } + return urls; + } + + function bindMegaVirusPanel() { + var formMv = el('form-mega-virus-timing'); + if (formMv) { + formMv.addEventListener('submit', function (e) { + e.preventDefault(); + saveMegaVirusTimingPanel(); + }); + } + var bossInp = el('mega-virus-boss-url'); + if (bossInp) { + bossInp.addEventListener('change', updateMegaVirusBossPreview); + bossInp.addEventListener('blur', updateMegaVirusBossPreview); + } + var bossClr = el('btn-mega-virus-boss-clear'); + if (bossClr) { + bossClr.addEventListener('click', function () { + var i = el('mega-virus-boss-url'); + if (i) i.value = ''; + updateMegaVirusBossPreview(); + }); + } + for (var s = 1; s <= 6; s++) { + (function (slot) { + var inp = el('mega-virus-balloon-url-' + slot); + var btn = el('btn-mega-virus-balloon-clear-' + slot); + if (inp) { + inp.addEventListener('change', function () { updateMegaVirusBalloonPreview(slot); }); + inp.addEventListener('blur', function () { updateMegaVirusBalloonPreview(slot); }); + } + if (btn) { + btn.addEventListener('click', function () { + var i = el('mega-virus-balloon-url-' + slot); + if (i) i.value = ''; + updateMegaVirusBalloonPreview(slot); + }); + } + })(s); + } + var fbInp = el('mega-virus-balloon-fallback-url'); + if (fbInp) { + fbInp.addEventListener('change', updateMegaVirusBalloonFallbackPreview); + fbInp.addEventListener('blur', updateMegaVirusBalloonFallbackPreview); + } + var fbClr = el('btn-mega-virus-balloon-fallback-clear'); + if (fbClr) { + fbClr.addEventListener('click', function () { + var i = el('mega-virus-balloon-fallback-url'); + if (i) i.value = ''; + updateMegaVirusBalloonFallbackPreview(); + }); + } + } + + function applyMegaVirusPanelFromTimingData(data) { + if (!data || typeof data !== 'object') return; + var inpT = el('mega-virus-mission-sec'); + if (inpT && Object.prototype.hasOwnProperty.call(data, 'balloonBossMissionTimeSec')) { + var bb = Number(data.balloonBossMissionTimeSec); + inpT.value = String(Number.isFinite(bb) && bb > 0 ? Math.floor(bb) : 0); + } + var inpBp = el('mega-virus-balloons-per-player'); + if (inpBp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBalloonsPerPlayer')) { + var bp = Number(data.balloonBossBalloonsPerPlayer); + inpBp.value = String(Number.isFinite(bp) && bp > 0 ? Math.min(12, Math.floor(bp)) : 0); + } + var bossInp = el('mega-virus-boss-url'); + if (bossInp && Object.prototype.hasOwnProperty.call(data, 'balloonBossBossImageUrl')) { + bossInp.value = data.balloonBossBossImageUrl != null ? String(data.balloonBossBossImageUrl) : ''; + updateMegaVirusBossPreview(); + } + if (Array.isArray(data.balloonBossPlayerBalloonImageUrls)) { + var urls = data.balloonBossPlayerBalloonImageUrls; + for (var k = 1; k <= 6; k++) { + var inpU = el('mega-virus-balloon-url-' + k); + if (inpU) inpU.value = urls[k - 1] != null ? String(urls[k - 1]) : ''; + updateMegaVirusBalloonPreview(k); + } + } + var fb = el('mega-virus-balloon-fallback-url'); + if (fb && Object.prototype.hasOwnProperty.call(data, 'balloonBossPlayerBalloonFallbackUrl')) { + fb.value = data.balloonBossPlayerBalloonFallbackUrl != null ? String(data.balloonBossPlayerBalloonFallbackUrl) : ''; + updateMegaVirusBalloonFallbackPreview(); + } + } + + function loadMegaVirusPanel() { + gameTimingFetch('GET') + .then(function (data) { + applyMegaVirusPanelFromTimingData(data); + setMsg('mega-virus-timing-msg', '', ''); + }) + .catch(function (e) { + setMsg('mega-virus-timing-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function saveMegaVirusTimingPanel() { + if (megaVirusSaveInFlight) return; + megaVirusSaveInFlight = true; + var btn = el('btn-mega-virus-save'); + if (btn) btn.disabled = true; + var limSs = el('mega-virus-mission-sec') ? parseInt(String(el('mega-virus-mission-sec').value), 10) : 0; + if (Number.isNaN(limSs) || limSs < 0) limSs = 0; + limSs = limSs <= 0 ? 0 : Math.max(10, Math.min(7200, limSs)); + var bossUrl = normalizeMegaVirusAssetUrl(el('mega-virus-boss-url') && el('mega-virus-boss-url').value ? String(el('mega-virus-boss-url').value).trim() : ''); + var balloonUrls = readMegaVirusBalloonUrlsFromForm(); + var fbUrl = normalizeMegaVirusAssetUrl(el('mega-virus-balloon-fallback-url') && el('mega-virus-balloon-fallback-url').value + ? String(el('mega-virus-balloon-fallback-url').value).trim() + : ''); + var balloonsPer = el('mega-virus-balloons-per-player') ? parseInt(String(el('mega-virus-balloons-per-player').value), 10) : 0; + if (Number.isNaN(balloonsPer) || balloonsPer < 0) balloonsPer = 0; + balloonsPer = Math.max(0, Math.min(12, balloonsPer)); + gameTimingFetch('GET') + .then(function (data) { + data.balloonBossMissionTimeSec = limSs; + data.balloonBossBossImageUrl = bossUrl; + data.balloonBossPlayerBalloonImageUrls = balloonUrls; + data.balloonBossPlayerBalloonFallbackUrl = fbUrl; + data.balloonBossBalloonsPerPlayer = balloonsPer; + return gameTimingFetch('PUT', data); + }) + .then(function (res) { + if (res && typeof res === 'object') applyMegaVirusPanelFromTimingData(res); + setMsg('mega-virus-timing-msg', 'บันทึกแล้ว — รีเฟรชหน้าเล่น (หรือเปิดใหม่) เพื่อโหลดรูป · Saved; hard-refresh play.', 'ok'); + }) + .catch(function (e) { + setMsg('mega-virus-timing-msg', e.message || 'บันทึกไม่ได้', 'error'); + }) + .then(function () { + megaVirusSaveInFlight = false; + if (btn) btn.disabled = false; + }); + } + + function applyForcedMinigameKeysToForm(keys) { + var arr = Array.isArray(keys) ? keys : []; + for (var i = 1; i <= 3; i++) { + var sel = el('forced-mg-' + i); + if (sel) sel.value = arr[i - 1] || ''; + } + } + + function readForcedMinigameKeysFromForm() { + var out = []; + for (var i = 1; i <= 3; i++) { + var sel = el('forced-mg-' + i); + out.push(sel ? String(sel.value || '') : ''); + } + return out; + } + + function loadForcedMinigamePanel() { + gameTimingFetch('GET') + .then(function (data) { + applyForcedMinigameKeysToForm(data && data.forcedMinigameKeys); + setMsg('forced-minigame-msg', '', ''); + }) + .catch(function (e) { + setMsg('forced-minigame-msg', e.message || 'โหลดไม่ได้', 'error'); + }); + } + + function saveForcedMinigamePanel() { + var keys = readForcedMinigameKeysFromForm(); + var anyForced = keys.some(function (k) { return !!k; }); + var btn = el('btn-forced-minigame-save'); + if (btn) btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.forcedMinigameKeys = keys; + return gameTimingFetch('PUT', data); + }) + .then(function (res) { + if (res && typeof res === 'object') applyForcedMinigameKeysToForm(res.forcedMinigameKeys); + setMsg('forced-minigame-msg', anyForced ? 'บันทึกแล้ว — เริ่มคดีใหม่เพื่อเทสต์การ์ดที่ตั้งไว้' : 'กลับเป็นปกติ (สุ่มทั้ง 3 ใบ) แล้ว', 'ok'); + }) + .catch(function (e) { + setMsg('forced-minigame-msg', e.message || 'บันทึกไม่ได้', 'error'); + }) + .then(function () { + if (btn) btn.disabled = false; + }); + } + + var SPECIAL_CARD_GAMES = [ + { id: 'mng8a80o', label: 'Minigame-1 จริงหรือไม่' }, + { id: 'mno9kb07', label: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' }, + { id: 'mnn93hpi', label: 'Minigame-3 Tower block' }, + { id: 'mnorwqx1', label: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' }, + { id: 'mnptfts2', label: 'Minigame-5 กระโดดขึ้นแท่น' }, + { id: 'mnpz6rkp', label: 'Minigame-6 ยิงอุกาบาต Violent Crime' }, + { id: 'mnq1eml7', label: 'Minigame-7 ยิง Mega Virus' } + ]; + var SPECIAL_CARD_OPTS = [ + { v: '', t: '— ไม่บังคับ —' }, + { v: '1', t: '1 · เพิ่มเวลามินิเกม +10s' }, + { v: '2', t: '2 · เพิ่มเวลาโหวต +10s' }, + { v: '3', t: '3 · แบนผู้เล่น 1 คน' }, + { v: '4', t: '4 · ปิดปากผู้เล่น 1 คน' }, + { v: '5', t: '5 · จับผิดตัว โหวตใหม่' }, + { v: '6', t: '6 · ทุกคน +10 COINS' }, + { v: '7', t: '7 · ทุกคนหลักฐานฟรี +1' } + ]; + var specialCardRowsBuilt = false; + function buildSpecialCardByMapRows() { + if (specialCardRowsBuilt) return; + var wrap = el('special-card-by-map-rows'); + if (!wrap) return; + wrap.innerHTML = ''; + SPECIAL_CARD_GAMES.forEach(function (g) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; + var name = document.createElement('span'); + name.style.cssText = 'min-width:240px;color:#cfd6ea;font-size:14px;'; + name.textContent = g.label; + var sel = document.createElement('select'); + sel.id = 'tsc-' + g.id; + sel.style.cssText = 'padding:6px 8px;min-width:300px;font-size:14px;'; + SPECIAL_CARD_OPTS.forEach(function (o) { + var op = document.createElement('option'); + op.value = o.v; + op.textContent = o.t; + sel.appendChild(op); + }); + row.appendChild(name); + row.appendChild(sel); + wrap.appendChild(row); + }); + specialCardRowsBuilt = true; + } + function loadSpecialCardByMapPanel() { + buildSpecialCardByMapRows(); + gameTimingFetch('GET') + .then(function (data) { + var m = (data && data.testSpecialCardByMap) || {}; + SPECIAL_CARD_GAMES.forEach(function (g) { + var sel = el('tsc-' + g.id); + if (sel) sel.value = (m[g.id] != null && m[g.id] !== '') ? String(m[g.id]) : ''; + }); + setMsg('special-card-by-map-msg', '', ''); + }) + .catch(function (e) { setMsg('special-card-by-map-msg', e.message || 'โหลดไม่ได้', 'error'); }); + } + function saveSpecialCardByMapPanel() { + var map = {}; + SPECIAL_CARD_GAMES.forEach(function (g) { + var sel = el('tsc-' + g.id); + var v = sel ? parseInt(String(sel.value || ''), 10) : 0; + if (v >= 1 && v <= 7) map[g.id] = v; + }); + var btn = el('btn-special-card-by-map-save'); + if (btn) btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.testSpecialCardByMap = map; + return gameTimingFetch('PUT', data); + }) + .then(function (res) { + if (res && res.testSpecialCardByMap) { + var m = res.testSpecialCardByMap; + SPECIAL_CARD_GAMES.forEach(function (g) { + var sel = el('tsc-' + g.id); + if (sel) sel.value = (m[g.id] != null) ? String(m[g.id]) : ''; + }); + } + var any = Object.keys(map).length > 0; + setMsg('special-card-by-map-msg', any ? 'บันทึกแล้ว — เกมที่ตั้งจะขึ้นคำถามพิเศษ + การ์ดนั้นแน่นอน' : 'ล้างการบังคับแล้ว (กลับเป็นสุ่มปกติ)', 'ok'); + }) + .catch(function (e) { setMsg('special-card-by-map-msg', e.message || 'บันทึกไม่ได้', 'error'); }) + .then(function () { if (btn) btn.disabled = false; }); + } + + function loadTroublesomeForcePanel() { + var chk = el('troublesome-force-offer'); + if (!chk) return; + gameTimingFetch('GET') + .then(function (data) { + chk.checked = !!(data && data.troublesomeForceOffer); + setMsg('troublesome-force-msg', '', ''); + }) + .catch(function (e) { setMsg('troublesome-force-msg', e.message || 'โหลดไม่ได้', 'error'); }); + } + function saveTroublesomeForcePanel() { + var chk = el('troublesome-force-offer'); + if (!chk) return; + var on = !!chk.checked; + var btn = el('btn-troublesome-force-save'); + if (btn) btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.troublesomeForceOffer = on; + return gameTimingFetch('PUT', data); + }) + .then(function (res) { + if (res && typeof res === 'object') chk.checked = !!res.troublesomeForceOffer; + setMsg('troublesome-force-msg', on ? 'บันทึกแล้ว — บทตัวป่วนจะขึ้นเสมอ (เริ่มคดีใหม่เพื่อเทสต์)' : 'ปิดแล้ว — กลับเป็นปกติ (ขึ้นเฉพาะคนจริง ≥ 4)', 'ok'); + }) + .catch(function (e) { setMsg('troublesome-force-msg', e.message || 'บันทึกไม่ได้', 'error'); }) + .then(function () { if (btn) btn.disabled = false; }); + } + + function loadSpecialQuizIconExpirePanel() { + var inp = el('special-quiz-icon-expire-sec'); + if (!inp) return; + gameTimingFetch('GET') + .then(function (data) { + var v = (data && data.specialQuizIconExpireSec != null) ? Math.floor(Number(data.specialQuizIconExpireSec)) : 10; + if (!Number.isFinite(v) || v < 0) v = 10; + inp.value = String(Math.min(120, v)); + setMsg('special-quiz-icon-expire-msg', '', ''); + }) + .catch(function (e) { setMsg('special-quiz-icon-expire-msg', e.message || 'โหลดไม่ได้', 'error'); }); + } + function saveSpecialQuizIconExpirePanel() { + var inp = el('special-quiz-icon-expire-sec'); + if (!inp) return; + var v = Math.floor(Number(inp.value)); + if (!Number.isFinite(v) || v < 0) v = 10; + v = Math.min(120, v); + inp.value = String(v); + var btn = el('btn-special-quiz-icon-expire-save'); + if (btn) btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.specialQuizIconExpireSec = v; + return gameTimingFetch('PUT', data); + }) + .then(function (res) { + if (res && res.specialQuizIconExpireSec != null) inp.value = String(res.specialQuizIconExpireSec); + setMsg('special-quiz-icon-expire-msg', v > 0 ? ('บันทึกแล้ว — ไอคอนจะหายใน ' + v + ' วินาที (เริ่มเกมใหม่เพื่อเทสต์)') : 'บันทึกแล้ว — ไอคอนจะไม่หาย (อยู่จนจบเกม)', 'ok'); + }) + .catch(function (e) { setMsg('special-quiz-icon-expire-msg', e.message || 'บันทึกไม่ได้', 'error'); }) + .then(function () { if (btn) btn.disabled = false; }); + } + + 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; + var limJump = el('jump-survive-mission-sec') ? parseInt(String(el('jump-survive-mission-sec').value), 10) : 0; + if (Number.isNaN(limJump) || limJump < 0) limJump = 0; + limJump = limJump <= 0 ? 0 : Math.max(10, Math.min(7200, limJump)); + var platTiles = readJumpSurvivePlatformTilesFromForm(); + gameTimingFetch('GET') + .then(function (data) { + data.jumpSurviveJumpHeightMult = jumpSurvMult; + data.jumpSurviveMissionTimeSec = limJump; + delete data.jumpSurvivePlatformTileUrl; + data.jumpSurvivePlatformTiles = platTiles; + 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(1, 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.gauntletJumpHeightMult = (function () { var v = parseFloat(el('game-timing-jump-height') && el('game-timing-jump-height').value); if (Number.isNaN(v)) v = 0.52; return Math.max(0.2, Math.min(1.5, v)); })(); + data.gauntletTimeLimitSec = limS; + /* troublesomeOfferSec/RollMaxSec ย้ายไปแท็บ "บทตัวป่วน" — ไม่แตะที่นี่ (ค่าจาก GET ส่งต่อคงเดิม) */ + 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·กระโดด / รูปอุปสรรค์ · Minigame-5 / Minigame-6 ตั้งแท็บของแต่ละเกม · ค่า Tower block (Stack) แก้ที่แท็บ Minigame-3 เท่านั้น · จำกัดเวลาพรมแดงใช้กับรอบถัดไปที่โฮสต์เริ่มเกม', '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; + }); + var inApp = name === 'panel-app'; + el('top-actions').hidden = !inApp; + var stats = el('admin-sys-stats'); + if (stats) stats.hidden = !inApp; + var burger = el('btn-sidebar-toggle'); + if (burger) burger.hidden = !inApp; + if (inApp) startSystemStats(); + else stopSystemStats(); + } + + /* ===== ระบบ: ดึง CPU/RAM ของเซิร์ฟเวอร์มาแสดง (อัปเดตทุก 5 วิ) ===== */ + var __sysStatsTimer = null; + function fmtBytes(n) { + if (!(n > 0)) return '0'; + var u = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = Math.floor(Math.log(n) / Math.log(1024)); + i = Math.max(0, Math.min(u.length - 1, i)); + return (n / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0) + ' ' + u[i]; + } + function fmtUptime(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + var d = Math.floor(sec / 86400); sec -= d * 86400; + var h = Math.floor(sec / 3600); sec -= h * 3600; + var m = Math.floor(sec / 60); + if (d > 0) return d + 'd ' + h + 'h'; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + function setBarTone(bar, pct) { + if (!bar) return; + bar.classList.remove('is-warn', 'is-crit'); + if (pct >= 85) bar.classList.add('is-crit'); + else if (pct >= 65) bar.classList.add('is-warn'); + } + function renderSystemStats(s) { + if (!s || !s.ok) return; + var cpuPct = (s.cpu && typeof s.cpu.percent === 'number') ? s.cpu.percent + : (s.cpu ? s.cpu.loadPercent : 0); + var cpuVal = el('sysstat-cpu-val'), cpuBar = el('sysstat-cpu-bar'); + if (cpuVal) cpuVal.textContent = cpuPct + '%'; + if (cpuBar) { cpuBar.style.width = cpuPct + '%'; setBarTone(cpuBar, cpuPct); } + var memPct = s.mem ? s.mem.usedPercent : 0; + var ramVal = el('sysstat-ram-val'), ramBar = el('sysstat-ram-bar'); + if (ramVal && s.mem) ramVal.textContent = memPct + '% ' + fmtBytes(s.mem.used) + '/' + fmtBytes(s.mem.total); + if (ramBar) { ramBar.style.width = memPct + '%'; setBarTone(ramBar, memPct); } + // HDD / ดิสก์ + var diskWrap = el('sysstat-disk-val') ? el('sysstat-disk-val').closest('.sysstat') : null; + if (s.disk) { + var dPct = s.disk.usedPercent; + var dVal = el('sysstat-disk-val'), dBar = el('sysstat-disk-bar'); + if (dVal) dVal.textContent = dPct + '% ' + fmtBytes(s.disk.used) + '/' + fmtBytes(s.disk.total); + if (dBar) { dBar.style.width = dPct + '%'; setBarTone(dBar, dPct); } + if (diskWrap) diskWrap.hidden = false; + } else if (diskWrap) { + diskWrap.hidden = true; // อ่านดิสก์ไม่ได้ (เช่น Node เก่า) → ซ่อน + } + // Node ที่ใช้อยู่ (RAM ของ process เกม) + if (s.node) { + var nPct = typeof s.node.rssPercent === 'number' ? s.node.rssPercent : 0; + var nVal = el('sysstat-node-val'), nBar = el('sysstat-node-bar'); + if (nVal) nVal.textContent = fmtBytes(s.node.rss) + (s.node.version ? ' ' + s.node.version : ''); + if (nBar) { nBar.style.width = Math.min(100, nPct) + '%'; setBarTone(nBar, nPct); } + } + var upVal = el('sysstat-up-val'); + if (upVal && s.uptime) upVal.textContent = fmtUptime(s.uptime.os); + var box = el('admin-sys-stats'); + if (box && s.cpu) box.title = 'CPU ' + cpuPct + '% (load ' + s.cpu.load1 + ', ' + s.cpu.cores + ' cores) · RAM ' + + memPct + '%' + + (s.disk ? ' · HDD ' + s.disk.usedPercent + '% (' + fmtBytes(s.disk.free) + ' ว่าง)' : '') + + (s.node ? ' · Node ' + fmtBytes(s.node.rss) + ' ' + (s.node.version || '') : '') + + ' · uptime ' + fmtUptime(s.uptime && s.uptime.os) + ' · ' + (s.hostname || ''); + } + function pollSystemStats() { + fetch('/Game/api/system-stats', { cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (s) { if (s) renderSystemStats(s); }) + .catch(function () { /* เงียบไว้ ไม่รบกวน admin */ }); + } + function startSystemStats() { + if (__sysStatsTimer) return; + pollSystemStats(); + setTimeout(pollSystemStats, 1200); // ครั้งที่สองให้ได้ค่า CPU% (ครั้งแรก server คืน null) + __sysStatsTimer = setInterval(pollSystemStats, 5000); + } + function stopSystemStats() { + if (__sysStatsTimer) { clearInterval(__sysStatsTimer); __sysStatsTimer = null; } + } + + // ===== Evidence Cards admin (รูปอย่างเดียว · 15 คดี · rarity จากเลขไฟล์) ===== + var EVIDENCE_CARDS_API = '/Game/api/evidence-cards'; + var EVIDENCE_IMAGES_API = '/Game/api/evidence-card-images'; + var EVIDENCE_CASE_COUNT = 15; + var EVIDENCE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3']; + var evidenceData = null; + var evidencePools = null; // { 'suspect-1': {common,rare,legendary}, ... } + var evidenceCurrentCase = '1'; + + function evidencePopulateCaseSelect() { + var sel = el('evidence-case'); + if (!sel || sel.options.length) return; + for (var i = 1; i <= EVIDENCE_CASE_COUNT; i++) { + var o = document.createElement('option'); + o.value = String(i); o.textContent = 'คดี ' + i; + sel.appendChild(o); + } + } + + function loadEvidenceCardsPanel() { + evidencePopulateCaseSelect(); + if (evidencePools == null) { + fetch(EVIDENCE_IMAGES_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { evidencePools = (j && j.pools) || {}; renderEvidenceCase(evidenceCurrentCase); }) + .catch(function () { evidencePools = {}; }); + } + fetch(EVIDENCE_CARDS_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { + evidenceData = (j && j.cases) ? j : { cases: {} }; + var sel = el('evidence-case'); + if (sel) evidenceCurrentCase = sel.value || '1'; + renderEvidenceCase(evidenceCurrentCase); + }) + .catch(function (e) { + setMsg('evidence-cards-msg', (e.message || 'โหลดไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ /Game/api/evidence-cards ไม่ 404', 'error'); + }); + } + + function evidenceGetCase(cid) { + if (!evidenceData) evidenceData = { cases: {} }; + if (!evidenceData.cases) evidenceData.cases = {}; + if (!evidenceData.cases[cid]) evidenceData.cases[cid] = { suspects: [] }; + var cse = evidenceData.cases[cid]; + if (!Array.isArray(cse.suspects)) cse.suspects = []; + for (var si = 0; si < 3; si++) { + if (!cse.suspects[si]) cse.suspects[si] = {}; + var s = cse.suspects[si]; + if (!s.linkName) s.linkName = 'ผู้ต้องสงสัย ' + (si + 1); + if (EVIDENCE_DIRS.indexOf(s.imageDir) < 0) s.imageDir = EVIDENCE_DIRS[si] || EVIDENCE_DIRS[0]; + } + return cse; + } + + function evidencePoolBadge(dir) { + var p = (evidencePools && evidencePools[dir]) || null; + var box = document.createElement('div'); + box.className = 'ev-pool-counts'; + if (!p) { box.textContent = 'กำลังโหลดจำนวนรูป…'; return box; } + box.innerHTML = + 'ธรรมดา ' + (p.common || 0) + '' + + 'Rare ' + (p.rare || 0) + '' + + 'ชี้คนร้าย ' + (p.legendary || 0) + ''; + return box; + } + + function renderEvidenceCase(cid) { + evidenceCurrentCase = cid; + var tag = el('evidence-set-tag'); + if (tag) tag.textContent = 'กำลังแก้: คดี ' + cid; + var root = el('evidence-suspects-root'); + if (!root) return; + var cse = evidenceGetCase(cid); + root.innerHTML = ''; + cse.suspects.forEach(function (s, si) { + var block = document.createElement('div'); + block.className = 'ev-suspect-block'; + + var head = document.createElement('div'); + head.className = 'ev-suspect-head'; + head.innerHTML = 'ผู้ต้องสงสัย ' + (si + 1) + ''; + block.appendChild(head); + + var nameLabel = document.createElement('label'); + nameLabel.className = 'admin-field'; + nameLabel.textContent = 'ชื่อผู้ต้องสงสัย (Suspect Link)'; + var nameInp = document.createElement('input'); + nameInp.type = 'text'; + nameInp.maxLength = 60; + nameInp.value = s.linkName || ''; + nameInp.addEventListener('input', function () { s.linkName = nameInp.value; }); + nameLabel.appendChild(nameInp); + block.appendChild(nameLabel); + + var poolBox = evidencePoolBadge(s.imageDir); + + var dirLabel = document.createElement('label'); + dirLabel.className = 'admin-field'; + dirLabel.textContent = 'โฟลเดอร์รูปการ์ด (พูลที่ใช้สุ่ม)'; + var dirSel = document.createElement('select'); + dirSel.className = 'admin-inp-num'; + EVIDENCE_DIRS.forEach(function (d) { + var o = document.createElement('option'); + o.value = d; o.textContent = d.replace('suspect-', 'ผู้ต้องสงสัย-'); + if (d === s.imageDir) o.selected = true; + dirSel.appendChild(o); + }); + dirSel.addEventListener('change', function () { + s.imageDir = dirSel.value; + var fresh = evidencePoolBadge(s.imageDir); + poolBox.replaceWith(fresh); + poolBox = fresh; + }); + dirLabel.appendChild(dirSel); + block.appendChild(dirLabel); + block.appendChild(poolBox); + + root.appendChild(block); + }); + } + + function saveEvidenceCardsPanel() { + if (!evidenceData) return; + var btn = el('btn-evidence-save'); + if (btn) btn.disabled = true; + setMsg('evidence-cards-msg', 'กำลังบันทึก…', ''); + fetch(EVIDENCE_CARDS_API, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(evidenceData), + }).then(function (r) { + return r.text().then(function (t) { + var j = {}; + try { j = t ? JSON.parse(t) : {}; } catch (e) { /* ignore */ } + if (!r.ok || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); + return j; + }); + }).then(function (j) { + if (j && j.cases) evidenceData = { cases: j.cases }; + renderEvidenceCase(evidenceCurrentCase); + setMsg('evidence-cards-msg', 'บันทึกแล้ว — มีผลในเกมเมื่อรีเฟรช LobbyB', 'ok'); + }).catch(function (e) { + setMsg('evidence-cards-msg', e.message || 'บันทึกไม่ได้', 'error'); + }).then(function () { + if (btn) btn.disabled = false; + }); + } + + (function bindEvidenceCardsPanel() { + evidencePopulateCaseSelect(); + var caseSel = el('evidence-case'); + if (caseSel) caseSel.addEventListener('change', function () { renderEvidenceCase(caseSel.value || '1'); }); + var saveBtn = el('btn-evidence-save'); + if (saveBtn) saveBtn.addEventListener('click', saveEvidenceCardsPanel); + })(); + + /* ===== รูปคดี & Cutscene (LobbyA → LobbyB) ===== */ + var CASE_MEDIA_API = '/Game/api/case-media'; + var CASE_MEDIA_IMAGES_API = '/Game/api/case-media-images'; + var CASE_MEDIA_COUNT = 15; + var caseMediaData = null; + var caseMediaImages = null; // { case:[...], cutscene:[...] } + var caseMediaCurrent = '1'; + + function caseMediaPopulateSelect() { + var sel = el('case-media-case'); + if (!sel || sel.options.length) return; + for (var i = 1; i <= CASE_MEDIA_COUNT; i++) { + var o = document.createElement('option'); + o.value = String(i); o.textContent = 'คดี ' + i; + sel.appendChild(o); + } + } + + function loadCaseMediaPanel() { + caseMediaPopulateSelect(); + if (caseMediaImages == null) { + fetch(CASE_MEDIA_IMAGES_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { + caseMediaImages = (j && j.images) || { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] }; + renderCaseMedia(caseMediaCurrent); + }) + .catch(function () { caseMediaImages = { case: [], cutscene: [], 'suspect-pick': [], 'culprit-prison': [] }; }); + } + fetch(CASE_MEDIA_API + '?_=' + Date.now(), { credentials: 'include', cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (j) { + caseMediaData = (j && j.cases) ? j : { cases: {} }; + var sel = el('case-media-case'); + if (sel) caseMediaCurrent = sel.value || '1'; + renderCaseMedia(caseMediaCurrent); + }) + .catch(function (e) { + setMsg('case-media-msg', (e.message || 'โหลดไม่ได้') + ' — เช็คว่า Node รัน Game/server.js และ /Game/api/case-media ไม่ 404', 'error'); + }); + } + + function caseMediaGetCase(cid) { + if (!caseMediaData) caseMediaData = { cases: {} }; + if (!caseMediaData.cases) caseMediaData.cases = {}; + var n = parseInt(cid, 10) || 1; + if (!caseMediaData.cases[cid]) caseMediaData.cases[cid] = {}; + var c = caseMediaData.cases[cid]; + if (typeof c.name !== 'string') c.name = 'คดีที่ ' + n; + if (typeof c.art !== 'string') c.art = '/Main-Lobby/IMAGE/case/case-' + n + '.png'; + if (typeof c.detail !== 'string') c.detail = '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png'; + if (!Array.isArray(c.story)) c.story = []; + if (typeof c.story[0] !== 'string') c.story[0] = '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png'; + if (typeof c.story[1] !== 'string') c.story[1] = '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png'; + if (!Array.isArray(c.suspects) || c.suspects.length < 3) { + c.suspects = [1, 2, 3].map(function (s) { return '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png'; }); + } + if (!Array.isArray(c.culprits) || c.culprits.length < 3) { + c.culprits = [1, 2, 3].map(function (s) { return '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png'; }); + } + return c; + } + + /** ตัวเลือกรูป (dropdown + พรีวิว) — kind = 'case' | 'cutscene' */ + function caseMediaImagePicker(label, kind, current, onChange) { + var wrap = document.createElement('div'); + wrap.className = 'cm-pick'; + var lab = document.createElement('div'); lab.className = 'cm-pick-label'; lab.textContent = label; + var prev = document.createElement('img'); prev.className = 'cm-pick-prev'; prev.alt = ''; prev.src = current; + prev.onerror = function () { this.style.opacity = '0.25'; }; + var sel = document.createElement('select'); sel.className = 'admin-inp-num'; + var list = (caseMediaImages && caseMediaImages[kind]) ? caseMediaImages[kind].slice() : []; + if (current && list.indexOf(current) < 0) list.unshift(current); + list.forEach(function (u) { + var o = document.createElement('option'); + o.value = u; o.textContent = u.split('/').pop(); + if (u === current) o.selected = true; + sel.appendChild(o); + }); + sel.addEventListener('change', function () { prev.src = sel.value; onChange(sel.value); }); + wrap.appendChild(lab); wrap.appendChild(prev); wrap.appendChild(sel); + return wrap; + } + + function renderCaseMedia(cid) { + caseMediaCurrent = cid; + var tag = el('case-media-set-tag'); + if (tag) tag.textContent = 'กำลังแก้: คดี ' + cid; + var root = el('case-media-root'); + if (!root) return; + var c = caseMediaGetCase(cid); + root.innerHTML = ''; + + var nameLabel = document.createElement('label'); + nameLabel.className = 'admin-field'; + nameLabel.textContent = 'ชื่อคดี'; + var nameInp = document.createElement('input'); + nameInp.type = 'text'; nameInp.maxLength = 80; nameInp.value = c.name || ''; + nameInp.addEventListener('input', function () { c.name = nameInp.value; }); + nameLabel.appendChild(nameInp); + root.appendChild(nameLabel); + + var grid = document.createElement('div'); + grid.className = 'cm-grid'; + grid.appendChild(caseMediaImagePicker('รูปเลือกคดี (การ์ด)', 'case', c.art, function (v) { c.art = v; })); + grid.appendChild(caseMediaImagePicker('รูปรายละเอียดคดี', 'case', c.detail, function (v) { c.detail = v; })); + grid.appendChild(caseMediaImagePicker('Cutscene รูปที่ 1', 'cutscene', c.story[0], function (v) { c.story[0] = v; })); + grid.appendChild(caseMediaImagePicker('Cutscene รูปที่ 2', 'cutscene', c.story[1], function (v) { c.story[1] = v; })); + root.appendChild(grid); + + var susHead = document.createElement('h3'); + susHead.className = 'cm-section-title'; + susHead.textContent = 'เลือกผู้ต้องสงสัย (3 คน) — LobbyB'; + root.appendChild(susHead); + var susGrid = document.createElement('div'); + susGrid.className = 'cm-grid'; + [0, 1, 2].forEach(function (si) { + susGrid.appendChild(caseMediaImagePicker('ผู้ต้องสงสัย ' + (si + 1), 'suspect-pick', c.suspects[si], function (v) { c.suspects[si] = v; })); + }); + root.appendChild(susGrid); + + var culHead = document.createElement('h3'); + culHead.className = 'cm-section-title'; + culHead.textContent = 'ส่งผู้ร้ายเข้าคุก (3 คน) — หน้าผลพิจารณาคดี'; + root.appendChild(culHead); + var culGrid = document.createElement('div'); + culGrid.className = 'cm-grid'; + [0, 1, 2].forEach(function (si) { + culGrid.appendChild(caseMediaImagePicker('คนร้ายในคุก ' + (si + 1), 'culprit-prison', c.culprits[si], function (v) { c.culprits[si] = v; })); + }); + root.appendChild(culGrid); + } + + function saveCaseMediaPanel() { + if (!caseMediaData) return; + var btn = el('btn-case-media-save'); + if (btn) btn.disabled = true; + setMsg('case-media-msg', 'กำลังบันทึก…', ''); + fetch(CASE_MEDIA_API, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(caseMediaData), + }).then(function (r) { + return r.text().then(function (t) { + var j = {}; + try { j = t ? JSON.parse(t) : {}; } catch (e) { /* ignore */ } + if (!r.ok || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); + return j; + }); + }).then(function (j) { + if (j && j.cases) caseMediaData = { cases: j.cases }; + renderCaseMedia(caseMediaCurrent); + setMsg('case-media-msg', 'บันทึกแล้ว — มีผลในเกมเมื่อรีเฟรช LobbyA/LobbyB', 'ok'); + }).catch(function (e) { + setMsg('case-media-msg', e.message || 'บันทึกไม่ได้', 'error'); + }).then(function () { + if (btn) btn.disabled = false; + }); + } + + (function bindCaseMediaPanel() { + caseMediaPopulateSelect(); + var caseSel = el('case-media-case'); + if (caseSel) caseSel.addEventListener('change', function () { renderCaseMedia(caseSel.value || '1'); }); + var saveBtn = el('btn-case-media-save'); + if (saveBtn) saveBtn.addEventListener('click', saveCaseMediaPanel); + })(); + + 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 === 'special-quiz') { loadSpecialQuizPanel(); loadSpecialQuizIconExpirePanel(); } + if (name === 'evidence-cards') loadEvidenceCardsPanel(); + if (name === 'case-media') loadCaseMediaPanel(); + if (name === 'quiz-carry') loadQuizCarryPanel(); + if (name === 'quiz-battle') loadQbBattlePanel(); + if (name === 'jump-survive') loadJumpSurviveTimingPanel(); + if (name === 'space-shooter') loadSpaceShooterTimingPanel(); + if (name === 'mega-virus') loadMegaVirusPanel(); + if (name === 'game-timing') loadGameTimingPanel(); + if (name === 'stack-game') loadStackGamePanel(); + if (name === 'highscore') loadHighscore(); + if (name === 'achievements') loadAchievementsPanel(); + if (name === 'vote-timing') loadVoteTimingPanel(); + if (name === 'troublesome') loadTroublesomePanel(); + if (name === 'test-mode') { loadForcedMinigamePanel(); loadSpecialCardByMapPanel(); loadTroublesomeForcePanel(); } + if (name === 'sound') loadSoundPanel(); + } + + 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); + }); + }); + + /* ===== Sidebar: ค้นหาเมนู / ยุบกลุ่ม / เปิด-ปิดบนมือถือ ===== */ + (function setupAdminSidebar() { + var shell = document.querySelector('.admin-shell'); + var nav = document.querySelector('.admin-tabs'); + if (!nav) return; + var seps = Array.prototype.slice.call(nav.querySelectorAll('.admin-tab-sep')); + var tabs = Array.prototype.slice.call(nav.querySelectorAll('.tab')); + + function isMobile() { return window.matchMedia('(max-width: 980px)').matches; } + function closeSidebar() { + if (shell) shell.classList.remove('sidebar-open'); + var b = el('btn-sidebar-toggle'); if (b) b.setAttribute('aria-expanded', 'false'); + var bd = el('admin-sidebar-backdrop'); if (bd) bd.hidden = true; + } + function openSidebar() { + if (shell) shell.classList.add('sidebar-open'); + var b = el('btn-sidebar-toggle'); if (b) b.setAttribute('aria-expanded', 'true'); + var bd = el('admin-sidebar-backdrop'); if (bd) bd.hidden = false; + } + var burger = el('btn-sidebar-toggle'); + if (burger) burger.addEventListener('click', function () { + if (shell && shell.classList.contains('sidebar-open')) closeSidebar(); else openSidebar(); + }); + var backdrop = el('admin-sidebar-backdrop'); + if (backdrop) backdrop.addEventListener('click', closeSidebar); + nav.addEventListener('click', function (e) { + var t = e.target && e.target.closest ? e.target.closest('.tab') : null; + if (t && isMobile()) closeSidebar(); + }); + + /* ยุบ/ขยายกลุ่ม: ซ่อนแท็บที่อยู่หลัง separator จนถึง separator ถัดไป */ + function applyGroupVisibility() { + seps.forEach(function (sep) { + var collapsed = sep.classList.contains('is-collapsed'); + var n = sep.nextElementSibling; + while (n && !n.classList.contains('admin-tab-sep')) { + if (n.classList && n.classList.contains('tab')) n.classList.toggle('is-group-hidden', collapsed); + n = n.nextElementSibling; + } + }); + } + function toggleGroup(sep) { + sep.classList.toggle('is-collapsed'); + applyGroupVisibility(); + } + seps.forEach(function (sep) { + sep.setAttribute('role', 'button'); + sep.setAttribute('tabindex', '0'); + sep.addEventListener('click', function () { toggleGroup(sep); }); + sep.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(sep); } + }); + }); + + /* ค้นหาเมนู */ + var search = el('admin-nav-search-input'); + var emptyMsg = el('admin-nav-empty'); + if (search) search.addEventListener('input', function () { + var q = search.value.trim().toLowerCase(); + if (q) { + var anyVisible = false; + tabs.forEach(function (t) { + t.classList.remove('is-group-hidden'); + var hit = (t.textContent || '').toLowerCase().indexOf(q) >= 0; + t.classList.toggle('is-search-hidden', !hit); + if (hit) anyVisible = true; + }); + seps.forEach(function (s) { s.style.display = 'none'; }); + if (emptyMsg) emptyMsg.hidden = anyVisible; + } else { + tabs.forEach(function (t) { t.classList.remove('is-search-hidden'); }); + seps.forEach(function (s) { s.style.display = ''; }); + applyGroupVisibility(); + if (emptyMsg) emptyMsg.hidden = true; + } + }); + })(); + + 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'); + + /* Test Mode toggle — เก็บใน localStorage (share ระหว่าง Admin กับ Game ภายใต้ origin เดียวกัน) */ + var TEST_MODE_KEY = 'justiceTestMode'; + function isTestModeOn() { + try { return localStorage.getItem(TEST_MODE_KEY) === '1'; } catch (e) { return false; } + } + function setTestMode(on) { + try { localStorage.setItem(TEST_MODE_KEY, on ? '1' : '0'); } catch (e) { /* ignore */ } + } + function syncTestModeUi() { + var chk = el('test-mode-toggle'); + var lbl = el('test-mode-state-label'); + var msg = el('test-mode-msg'); + var on = isTestModeOn(); + if (chk) chk.checked = on; + if (lbl) { + lbl.textContent = on ? 'เปิดอยู่ (ON)' : 'ปิดอยู่ (OFF)'; + lbl.style.color = on ? '#4ade80' : '#94a3b8'; + } + if (msg) { + msg.textContent = on + ? '✓ Test Mode เปิด — Shortcuts ใช้งานได้ในแท็บอื่นของเบราว์เซอร์นี้' + : '○ Test Mode ปิด — Shortcuts ไม่ทำงาน'; + msg.style.color = on ? '#4ade80' : '#94a3b8'; + } + } + var testModeChk = el('test-mode-toggle'); + if (testModeChk) { + testModeChk.addEventListener('change', function () { + setTestMode(!!testModeChk.checked); + syncTestModeUi(); + }); + } + var forcedMgSaveBtn = el('btn-forced-minigame-save'); + if (forcedMgSaveBtn) forcedMgSaveBtn.addEventListener('click', saveForcedMinigamePanel); + var specialCardSaveBtn = el('btn-special-card-by-map-save'); + if (specialCardSaveBtn) specialCardSaveBtn.addEventListener('click', saveSpecialCardByMapPanel); + var troublesomeForceSaveBtn = el('btn-troublesome-force-save'); + if (troublesomeForceSaveBtn) troublesomeForceSaveBtn.addEventListener('click', saveTroublesomeForcePanel); + var sqIconExpireSaveBtn = el('btn-special-quiz-icon-expire-save'); + if (sqIconExpireSaveBtn) sqIconExpireSaveBtn.addEventListener('click', saveSpecialQuizIconExpirePanel); + syncTestModeUi(); + /* ฟัง storage event — sync เมื่อเปิดหลายแท็บ */ + window.addEventListener('storage', function (e) { + if (e && e.key === TEST_MODE_KEY) syncTestModeUi(); + }); + + 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); + } + bindJumpSurvivePlatformTileInputs(); + var btnSpaceShooterSave = el('btn-space-shooter-save'); + if (btnSpaceShooterSave) { + btnSpaceShooterSave.addEventListener('click', saveSpaceShooterTimingPanel); + } + bindSpaceShooterShipUrlInputs(); + bindStackBlockVisualInputs(); + bindSpaceShooterDamageOverlayPanel(); + bindSpaceShooterAsteroidSpritePanel(); + bindMegaVirusPanel(); + var btnStackGameSave = el('btn-stack-game-save'); + if (btnStackGameSave) { + btnStackGameSave.addEventListener('click', saveStackGamePanel); + } + bindGauntletAssetLibrary(); + bindGameTimingAssignedVisuals(); + bindGameTimingLaserColorSwatches(); + + 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 btnSpecialQuizAdd = el('btn-special-quiz-add'); + var btnSpecialQuizSave = el('btn-special-quiz-save'); + if (btnSpecialQuizAdd) { + btnSpecialQuizAdd.addEventListener('click', function () { + addSpecialQuizRow({ type: 'tf', text: '', answerTrue: true }); + updateSpecialQuizCount(); + }); + } + if (btnSpecialQuizSave) btnSpecialQuizSave.addEventListener('click', saveSpecialQuizPanel); + var specialQuizLevelSel = el('special-quiz-level'); + var specialQuizCaseSel = el('special-quiz-case'); + if (specialQuizLevelSel) specialQuizLevelSel.addEventListener('change', onSpecialQuizSelectorChange); + if (specialQuizCaseSel) specialQuizCaseSel.addEventListener('change', onSpecialQuizSelectorChange); + + 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); + wireQuizCarryPlaqueMapScaleControls(); + quizCarryBindThemePickersOnce(); + + 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 = + '' + escapeHtml(a.displayName || '—') + '' + + '' + escapeHtml(a.email || '—') + '' + + '' + escapeHtml(a.loginType || '') + '' + + '' + escapeHtml(a.providerUserId || '—') + '' + + '' + + '' + + '' + + '' + + '' + (a.blocked ? 'บล็อก' : 'ปกติ') + '' + + ' ' + + ''; + tb.appendChild(tr); + }); + var sumEl = el('accounts-coins-summary'); + if (sumEl) { + sumEl.textContent = (list && list.length) ? ('ผู้ใช้ ' + list.length + ' บัญชี · รวม COINS ' + sumCoins) : 'ยังไม่มีบัญชี'; + } + tb.querySelectorAll('.btn-sm-del').forEach(function (b) { + b.addEventListener('click', function () { + if (!confirm('ลบบัญชีนี้?')) return; + api('accounts.php?id=' + encodeURIComponent(b.getAttribute('data-id')), { method: 'DELETE' }) + .then(loadAccounts) + .catch(function (err) { + setMsg('accounts-msg', err.message, 'error'); + }); + }); + }); + tb.querySelectorAll('.btn-sm-toggle').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + var blocked = b.getAttribute('data-blocked') === '1'; + api('accounts.php', { + method: 'PATCH', + body: { id: id, blocked: !blocked }, + }) + .then(loadAccounts) + .catch(function (err) { + setMsg('accounts-msg', err.message, 'error'); + }); + }); + }); + tb.querySelectorAll('.btn-coins-save').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + var row = b.closest('tr'); + var inp = row ? row.querySelector('input.input-coins') : null; + var coins = Math.max(0, parseInt(inp && inp.value, 10) || 0); + api('accounts.php', { method: 'PATCH', body: { id: id, coins: coins } }) + .then(function () { + setMsg('accounts-msg', 'อัปเดต COINS แล้ว', 'ok'); + return loadAccounts(); + }) + .catch(function (err) { + setMsg('accounts-msg', err.message, 'error'); + }); + }); + }); + } + + function escapeHtml(s) { + var d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + function escapeAttr(s) { + return String(s || '').replace(/"/g, '"'); + } + + function loadAccounts() { + return api('accounts.php').then(function (r) { + renderAccounts(r.accounts); + setMsg('accounts-msg', '', ''); + }); + } + + /* ===== กระดานผู้นำ (High Score) ===== */ + function renderHighscore(rows) { + var tbEl = el('table-highscore'); + if (!tbEl) return; + var tb = tbEl.querySelector('tbody'); + tb.innerHTML = ''; + (rows || []).forEach(function (r) { + var rank = parseInt(r.rank, 10) || 0; + var score = Math.max(0, parseInt(r.score, 10) || 0); + var coins = Math.max(0, parseInt(r.coins, 10) || 0); + var medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ('#' + rank); + var tr = document.createElement('tr'); + tr.innerHTML = + '' + medal + '' + + '' + escapeHtml(r.name || 'ผู้เล่น') + (r.blocked ? ' บล็อก' : '') + '' + + '' + + '' + + '' + + '' + + '' + coins + '' + + ''; + tb.appendChild(tr); + }); + tb.querySelectorAll('.btn-score-save').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + var row = b.closest('tr'); + var inp = row ? row.querySelector('input.input-score') : null; + var score = Math.max(0, parseInt(inp && inp.value, 10) || 0); + api('accounts.php', { method: 'PATCH', body: { id: id, score: score } }) + .then(function () { setMsg('highscore-msg', 'บันทึกคะแนนแล้ว', 'ok'); return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + }); + tb.querySelectorAll('.btn-score-reset').forEach(function (b) { + b.addEventListener('click', function () { + var id = b.getAttribute('data-id'); + if (!confirm('รีเซ็ตคะแนนคนนี้เป็น 0?')) return; + api('leaderboard-admin.php', { method: 'POST', body: { action: 'reset', id: id } }) + .then(function () { return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + }); + } + + function loadHighscore() { + return api('leaderboard-admin.php').then(function (r) { + renderHighscore(r.rows); + var n = (r.rows && r.rows.length) || 0; + setMsg('highscore-msg', n ? ('ผู้เล่นมีคะแนน ' + r.total + ' คน') : 'ยังไม่มีผู้เล่นทำคะแนน', ''); + }).catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + } + + (function bindHighscoreControls() { + var refresh = el('btn-highscore-refresh'); + if (refresh) refresh.addEventListener('click', function () { loadHighscore(); }); + var resetAll = el('btn-highscore-reset-all'); + if (resetAll) resetAll.addEventListener('click', function () { + if (!confirm('รีเซ็ตคะแนนสะสมของผู้เล่นทุกคนเป็น 0? (ย้อนกลับไม่ได้)')) return; + api('leaderboard-admin.php', { method: 'POST', body: { action: 'resetAll' } }) + .then(function (r) { setMsg('highscore-msg', 'รีเซ็ตแล้ว ' + (r.reset || 0) + ' คน', 'ok'); return loadHighscore(); }) + .catch(function (err) { setMsg('highscore-msg', err.message, 'error'); }); + }); + })(); + + /* ===== เวลาโหวต (vote timing) ===== */ + function loadVoteTimingPanel() { + gameTimingFetch('GET') + .then(function (data) { + var pk = (data && data.pickVoteSec != null) ? Number(data.pickVoteSec) : 25; + var tr = (data && data.trialVoteSec != null) ? Number(data.trialVoteSec) : 30; + var rv = (data && data.trialResultRevealMs != null) ? Number(data.trialResultRevealMs) : 7000; + if (el('vote-timing-pick')) el('vote-timing-pick').value = String(Number.isFinite(pk) ? pk : 25); + if (el('vote-timing-trial')) el('vote-timing-trial').value = String(Number.isFinite(tr) ? tr : 30); + if (el('vote-timing-reveal')) el('vote-timing-reveal').value = String(Number.isFinite(rv) ? rv : 7000); + setMsg('vote-timing-msg', '', ''); + }) + .catch(function (e) { setMsg('vote-timing-msg', (e.message || 'โหลดไม่ได้') + ' — เช็ค /Game/api/game-timing', 'error'); }); + } + + (function bindVoteTiming() { + var btn = el('btn-vote-timing-save'); + if (!btn) return; + btn.addEventListener('click', function () { + var pk = Math.max(5, Math.min(120, parseInt((el('vote-timing-pick') || {}).value, 10) || 25)); + var tr = Math.max(5, Math.min(180, parseInt((el('vote-timing-trial') || {}).value, 10) || 30)); + var rv = Math.max(1500, Math.min(20000, parseInt((el('vote-timing-reveal') || {}).value, 10) || 7000)); + btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.pickVoteSec = pk; + data.trialVoteSec = tr; + data.trialResultRevealMs = rv; + return gameTimingFetch('PUT', data); + }) + .then(function () { return gameTimingFetch('GET'); }) + .then(function (fresh) { + var p = (fresh && fresh.pickVoteSec != null) ? fresh.pickVoteSec : pk; + var t = (fresh && fresh.trialVoteSec != null) ? fresh.trialVoteSec : tr; + var r = (fresh && fresh.trialResultRevealMs != null) ? fresh.trialResultRevealMs : rv; + if (el('vote-timing-pick')) el('vote-timing-pick').value = String(p); + if (el('vote-timing-trial')) el('vote-timing-trial').value = String(t); + if (el('vote-timing-reveal')) el('vote-timing-reveal').value = String(r); + setMsg('vote-timing-msg', 'บันทึกแล้ว — โหวตเลือกผู้เล่น ' + p + 's · ชี้ตัวคนร้าย ' + t + 's · โชว์ผล ' + Math.round(r/1000) + 's · ผู้เล่นต้องรีเฟรช/เข้าห้องใหม่', 'ok'); + }) + .catch(function (e) { setMsg('vote-timing-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็ค Node + /Game/api/game-timing', 'error'); }) + .then(function () { btn.disabled = false; }); + }); + })(); + + /* ===== เสียง (sound settings) — master/music/sfx volume 0..1 ===== */ + function soundSettingsFetch(method, body) { + var m = method || 'GET'; + var url = SOUND_SETTINGS_API; + if (m === 'GET' && body == null) url += (url.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) { + var plain = (text || r.statusText || 'Request failed').trim(); + if (r.status === 404) plain += ' — รีสตาร์ท Node (Game/server.js) หลัง deploy'; + throw new Error(plain); + } + if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status + ' ' + r.statusText)); + return j; + }); + }); + } + function pctFromVol(v, d) { var n = Number(v); return Math.round((Number.isFinite(n) ? n : d) * 100); } + function loadSoundPanel() { + soundSettingsFetch('GET') + .then(function (d) { + var setR = function (id, pct) { var e = el(id); if (e) { e.value = String(pct); var lbl = el(id + '-val'); if (lbl) lbl.textContent = pct + '%'; } }; + setR('sound-master', pctFromVol(d && d.masterVol, 1.0)); + setR('sound-music', pctFromVol(d && d.musicVol, 0.35)); + setR('sound-sfx', pctFromVol(d && d.sfxVol, 0.75)); + setMsg('sound-msg', '', ''); + }) + .catch(function (e) { setMsg('sound-msg', (e.message || 'โหลดไม่ได้') + ' — เช็ค /Game/api/sound-settings', 'error'); }); + } + (function bindSound() { + var btn = el('btn-sound-save'); + if (!btn) return; + btn.addEventListener('click', function () { + var rd = function (id, d) { var v = parseInt((el(id) || {}).value, 10); return Math.max(0, Math.min(100, Number.isFinite(v) ? v : d)) / 100; }; + var body = { masterVol: rd('sound-master', 100), musicVol: rd('sound-music', 35), sfxVol: rd('sound-sfx', 75) }; + btn.disabled = true; + soundSettingsFetch('PUT', body) + .then(function () { return soundSettingsFetch('GET'); }) + .then(function (fresh) { + loadSoundPanel(); + setMsg('sound-msg', 'บันทึกแล้ว — Master ' + pctFromVol(fresh.masterVol, 1) + '% · Music ' + pctFromVol(fresh.musicVol, .35) + '% · SFX ' + pctFromVol(fresh.sfxVol, .75) + '% · ผู้เล่นต้องรีเฟรช', 'ok'); + }) + .catch(function (e) { setMsg('sound-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็ค Node + /Game/api/sound-settings', 'error'); }) + .then(function () { btn.disabled = false; }); + }); + })(); + + /* ===== บทตัวป่วน (แยกจากหมวด Minigame-2) — troublesomeRollMaxSec + troublesomeOfferSec ===== */ + function loadTroublesomePanel() { + gameTimingFetch('GET') + .then(function (data) { + var rs = (data && data.troublesomeRollMaxSec != null) ? Number(data.troublesomeRollMaxSec) : 8; + var os = (data && data.troublesomeOfferSec != null) ? Number(data.troublesomeOfferSec) : 15; + if (el('troublesome-roll-sec')) el('troublesome-roll-sec').value = String(Number.isFinite(rs) ? rs : 8); + if (el('troublesome-offer-sec')) el('troublesome-offer-sec').value = String(Number.isFinite(os) ? os : 15); + setMsg('troublesome-msg', '', ''); + }) + .catch(function (e) { setMsg('troublesome-msg', (e.message || 'โหลดไม่ได้') + ' — เช็ค /Game/api/game-timing', 'error'); }); + } + + (function bindTroublesome() { + var btn = el('btn-troublesome-save'); + if (!btn) return; + btn.addEventListener('click', function () { + var rs = Math.max(1, Math.min(30, parseInt((el('troublesome-roll-sec') || {}).value, 10) || 8)); + var os = Math.max(5, Math.min(120, parseInt((el('troublesome-offer-sec') || {}).value, 10) || 15)); + btn.disabled = true; + gameTimingFetch('GET') + .then(function (data) { + data.troublesomeRollMaxSec = rs; + data.troublesomeOfferSec = os; + return gameTimingFetch('PUT', data); + }) + .then(function () { return gameTimingFetch('GET'); }) + .then(function (fresh) { + var r = (fresh && fresh.troublesomeRollMaxSec != null) ? fresh.troublesomeRollMaxSec : rs; + var o = (fresh && fresh.troublesomeOfferSec != null) ? fresh.troublesomeOfferSec : os; + if (el('troublesome-roll-sec')) el('troublesome-roll-sec').value = String(r); + if (el('troublesome-offer-sec')) el('troublesome-offer-sec').value = String(o); + setMsg('troublesome-msg', 'บันทึกแล้ว — เวลาเสนอ ' + r + 's · เวลารับ ' + o + 's · ผู้เล่นต้องรีเฟรช/เข้าห้องใหม่', 'ok'); + }) + .catch(function (e) { setMsg('troublesome-msg', (e.message || 'บันทึกไม่ได้') + ' — เช็ค Node + /Game/api/game-timing', 'error'); }) + .then(function () { btn.disabled = false; }); + }); + })(); + + /* ===== Achievements admin ===== */ + var achvCatalog = []; + var achvCatalogById = {}; + var achvCurrentPlayer = null; + + function achvGroupLabel(g) { + var names = { 1: '1 · Analyst', 2: '2 · Explorer', 3: '3 · Risk', 4: '4 · Support', 5: '5 · Elite' }; + return names[g] || String(g); + } + + function renderAchvCatalog() { + var tb = el('table-achv-catalog'); + if (!tb) return; + tb = tb.querySelector('tbody'); + tb.innerHTML = ''; + achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) { + var tr = document.createElement('tr'); + tr.setAttribute('data-achv-id', a.id); + + var tdG = document.createElement('td'); + var sel = document.createElement('select'); + sel.className = 'achv-f-g'; + for (var g = 1; g <= 5; g++) { + var op = document.createElement('option'); + op.value = String(g); op.textContent = achvGroupLabel(g); + if (g === (parseInt(a.g, 10) || 1)) op.selected = true; + sel.appendChild(op); + } + tdG.appendChild(sel); + + var tdT = document.createElement('td'); + var inT = document.createElement('input'); + inT.type = 'text'; inT.className = 'achv-f-title'; inT.value = a.title || ''; inT.style.width = '100%'; + tdT.appendChild(inT); + + var tdD = document.createElement('td'); + var inD = document.createElement('input'); + inD.type = 'text'; inD.className = 'achv-f-desc'; inD.value = a.desc || ''; inD.style.width = '100%'; + tdD.appendChild(inD); + + var tdTar = document.createElement('td'); + var inTar = document.createElement('input'); + inTar.type = 'number'; inTar.min = '1'; inTar.step = '1'; inTar.className = 'achv-f-target'; + inTar.value = String(Math.max(1, parseInt(a.target, 10) || 1)); inTar.style.width = '72px'; + tdTar.appendChild(inTar); + + var tdE = document.createElement('td'); + var inE = document.createElement('input'); + inE.type = 'checkbox'; inE.className = 'achv-f-enabled'; inE.checked = a.enabled !== false; + tdE.appendChild(inE); + + tr.appendChild(tdG); tr.appendChild(tdT); tr.appendChild(tdD); tr.appendChild(tdTar); tr.appendChild(tdE); + tb.appendChild(tr); + }); + } + + function collectAchvCatalogFromTable() { + var rows = document.querySelectorAll('#table-achv-catalog tbody tr'); + var out = []; + rows.forEach(function (tr) { + out.push({ + id: tr.getAttribute('data-achv-id'), + g: parseInt(tr.querySelector('.achv-f-g').value, 10) || 1, + title: tr.querySelector('.achv-f-title').value, + desc: tr.querySelector('.achv-f-desc').value, + target: Math.max(1, parseInt(tr.querySelector('.achv-f-target').value, 10) || 1), + enabled: tr.querySelector('.achv-f-enabled').checked, + }); + }); + return out; + } + + function loadAchvCatalog() { + return api('achievements.php?action=catalog').then(function (r) { + achvCatalog = (r && r.catalog) || []; + achvCatalogById = {}; + achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; }); + renderAchvCatalog(); + setMsg('achv-catalog-msg', 'โหลดแคตตาล็อก ' + achvCatalog.length + ' รายการ', ''); + }).catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + } + + function renderAchvPlayers(rows) { + var tb = el('table-achv-players'); + if (!tb) return; + tb = tb.querySelector('tbody'); + tb.innerHTML = ''; + (rows || []).forEach(function (p) { + var tr = document.createElement('tr'); + var c1 = document.createElement('td'); c1.textContent = p.displayName || 'Guest'; + var c2 = document.createElement('td'); c2.textContent = p.playerKey; c2.style.fontSize = '12px'; c2.style.opacity = '0.8'; + var c3 = document.createElement('td'); c3.textContent = (p.unlocked || 0) + ' / ' + (p.total || 0); + var c4 = document.createElement('td'); c4.textContent = String(p.coins || 0); + var c5 = document.createElement('td'); + var btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'btn btn-ghost'; btn.textContent = 'จัดการ'; + btn.addEventListener('click', function () { openAchvPlayerDetail(p.playerKey, p.displayName); }); + c5.appendChild(btn); + tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); tr.appendChild(c4); tr.appendChild(c5); + tb.appendChild(tr); + }); + } + + function loadAchvPlayers() { + var q = (el('achv-player-search') && el('achv-player-search').value || '').trim().toLowerCase(); + return api('achievements.php?action=players').then(function (r) { + var rows = (r && r.players) || []; + if (q) { + rows = rows.filter(function (p) { + return (p.displayName || '').toLowerCase().indexOf(q) >= 0 || (p.playerKey || '').toLowerCase().indexOf(q) >= 0; + }); + } + renderAchvPlayers(rows); + setMsg('achv-players-msg', 'ผู้เล่น ' + rows.length + ' คน', ''); + }).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + } + + function openAchvPlayerDetail(key, name) { + achvCurrentPlayer = key; + var box = el('achv-player-detail'); + if (box) box.hidden = false; + if (el('achv-player-detail-name')) el('achv-player-detail-name').textContent = (name || 'Guest') + ' (' + key + ')'; + api('achievements.php?action=player&playerKey=' + encodeURIComponent(key)).then(function (r) { + var prog = (r && r.progress) || {}; + var tb = el('table-achv-player-detail').querySelector('tbody'); + tb.innerHTML = ''; + achvCatalog.slice().sort(function (a, b) { return (a.g - b.g) || 0; }).forEach(function (a) { + var target = Math.max(1, parseInt(a.target, 10) || 1); + var cur = Math.max(0, parseInt(prog[a.id], 10) || 0); + var tr = document.createElement('tr'); + + var c1 = document.createElement('td'); + c1.innerHTML = '' + achvGroupLabel(a.g).charAt(0) + ' · ' + (a.title || a.id); + var c2 = document.createElement('td'); + var inp = document.createElement('input'); + inp.type = 'number'; inp.min = '0'; inp.max = String(target); inp.step = '1'; inp.value = String(Math.min(cur, target)); + inp.style.width = '70px'; + c2.appendChild(inp); + c2.appendChild(document.createTextNode(' / ' + target + (cur >= target ? ' ✔' : ''))); + + var c3 = document.createElement('td'); + var setBtn = document.createElement('button'); + setBtn.type = 'button'; setBtn.className = 'btn btn-ghost'; setBtn.textContent = 'ตั้งค่า'; + setBtn.style.marginRight = '6px'; + setBtn.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'setProgress', playerKey: key, id: a.id, value: parseInt(inp.value, 10) || 0 } }) + .then(function () { setMsg('achv-players-msg', 'บันทึก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + var unBtn = document.createElement('button'); + unBtn.type = 'button'; unBtn.className = 'btn btn-primary'; unBtn.textContent = 'ปลดล็อก'; + unBtn.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'unlock', playerKey: key, id: a.id } }) + .then(function () { setMsg('achv-players-msg', 'ปลดล็อก ' + a.id, 'ok'); openAchvPlayerDetail(key, name); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + c3.appendChild(setBtn); c3.appendChild(unBtn); + + tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); + tb.appendChild(tr); + }); + }).catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + } + + function showAchvSubtab(which) { + var isCat = which !== 'players'; + if (el('achv-panel-catalog')) el('achv-panel-catalog').hidden = !isCat; + if (el('achv-panel-players')) el('achv-panel-players').hidden = isCat; + if (el('achv-subtab-catalog')) el('achv-subtab-catalog').classList.toggle('is-active', isCat); + if (el('achv-subtab-players')) el('achv-subtab-players').classList.toggle('is-active', !isCat); + if (!isCat) loadAchvPlayers(); + } + + var achvPanelInited = false; + function loadAchievementsPanel() { + if (!achvPanelInited) { + achvPanelInited = true; + var sc = el('achv-subtab-catalog'); if (sc) sc.addEventListener('click', function () { showAchvSubtab('catalog'); }); + var sp = el('achv-subtab-players'); if (sp) sp.addEventListener('click', function () { showAchvSubtab('players'); }); + var save = el('btn-achv-save'); + if (save) save.addEventListener('click', function () { + api('achievements.php', { method: 'POST', body: { action: 'saveCatalog', catalog: collectAchvCatalogFromTable() } }) + .then(function (r) { + achvCatalog = (r && r.catalog) || achvCatalog; + achvCatalogById = {}; achvCatalog.forEach(function (a) { achvCatalogById[a.id] = a; }); + renderAchvCatalog(); + setMsg('achv-catalog-msg', 'บันทึกแคตตาล็อกแล้ว', 'ok'); + }) + .catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + }); + var reload = el('btn-achv-reload'); + if (reload) reload.addEventListener('click', function () { loadAchvCatalog(); }); + var resetDef = el('btn-achv-reset-default'); + if (resetDef) resetDef.addEventListener('click', function () { + if (!confirm('คืนค่าแคตตาล็อกกลับเป็นค่าเริ่มต้น 25 รายการ? (ค่าที่แก้ไว้จะหาย)')) return; + api('achievements.php', { method: 'POST', body: { action: 'resetCatalog' } }) + .then(function () { setMsg('achv-catalog-msg', 'คืนค่าเริ่มต้นแล้ว', 'ok'); return loadAchvCatalog(); }) + .catch(function (err) { setMsg('achv-catalog-msg', err.message, 'error'); }); + }); + var pr = el('btn-achv-players-refresh'); if (pr) pr.addEventListener('click', function () { loadAchvPlayers(); }); + var ps = el('achv-player-search'); if (ps) ps.addEventListener('input', function () { loadAchvPlayers(); }); + var rpb = el('btn-achv-player-reset'); + if (rpb) rpb.addEventListener('click', function () { + if (!achvCurrentPlayer) return; + if (!confirm('รีเซ็ต achievement ทั้งหมดของผู้เล่นนี้?')) return; + api('achievements.php', { method: 'POST', body: { action: 'resetPlayer', playerKey: achvCurrentPlayer } }) + .then(function () { setMsg('achv-players-msg', 'รีเซ็ตแล้ว', 'ok'); openAchvPlayerDetail(achvCurrentPlayer, el('achv-player-detail-name') ? el('achv-player-detail-name').textContent : ''); loadAchvPlayers(); }) + .catch(function (err) { setMsg('achv-players-msg', err.message, 'error'); }); + }); + } + return loadAchvCatalog(); + } + + el('form-account-add').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + var body = { + email: fd.get('email'), + displayName: fd.get('displayName'), + loginType: fd.get('loginType'), + providerUserId: fd.get('providerUserId'), + notes: fd.get('notes'), + blocked: fd.get('blocked') === 'on', + coins: Math.max(0, parseInt(fd.get('coins'), 10) || 0), + }; + api('accounts.php', { method: 'POST', body: body }) + .then(function () { + e.target.reset(); + return loadAccounts(); + }) + .catch(function (err) { + setMsg('accounts-msg', err.message, 'error'); + }); + }); + + function renderAdmins(list, myId) { + var tb = el('table-admins').querySelector('tbody'); + tb.innerHTML = ''; + (list || []).forEach(function (a) { + var tr = document.createElement('tr'); + var delBtn = a.id === myId + ? '—' + : ''; + tr.innerHTML = + '' + escapeHtml(a.username) + '' + + '' + escapeHtml(a.role || 'admin') + '' + + '' + escapeHtml((a.createdAt || '').slice(0, 10)) + '' + + '' + delBtn + ''; + tb.appendChild(tr); + }); + tb.querySelectorAll('.btn-adm-del').forEach(function (b) { + b.addEventListener('click', function () { + if (!confirm('ลบแอดมินนี้?')) return; + api('admins.php?id=' + encodeURIComponent(b.getAttribute('data-id')), { method: 'DELETE' }) + .then(loadAdmins) + .catch(function (err) { + setMsg('admins-msg', err.message, 'error'); + }); + }); + }); + } + + function fillAdminResetSelect(admins, myId) { + var sel = el('select-admin-reset-target'); + if (!sel) return; + sel.innerHTML = ''; + (admins || []).forEach(function (a) { + if (!a.id || a.id === myId) return; + var opt = document.createElement('option'); + opt.value = a.id; + opt.textContent = a.username + ' (' + (a.role || 'admin') + ')'; + sel.appendChild(opt); + }); + } + + function loadAdmins() { + return api('admins.php').then(function (r) { + return api('session.php').then(function (s) { + var myId = s.admin && s.admin.id; + renderAdmins(r.admins, myId); + fillAdminResetSelect(r.admins, myId); + setMsg('admins-msg', '', ''); + }); + }); + } + + el('form-change-password').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + var cur = fd.get('currentPassword'); + var n1 = fd.get('newPassword'); + var n2 = fd.get('newPassword2'); + if (n1 !== n2) { + setMsg('password-self-msg', 'รหัสใหม่ไม่ตรงกัน', 'error'); + return; + } + api('password.php', { + method: 'POST', + body: { currentPassword: cur, newPassword: n1 }, + }) + .then(function () { + e.target.reset(); + setMsg('password-self-msg', 'เปลี่ยนรหัสผ่านแล้ว', 'ok'); + }) + .catch(function (err) { + setMsg('password-self-msg', err.message, 'error'); + }); + }); + + el('form-admin-reset-other').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + var tid = fd.get('targetId'); + var n1 = fd.get('newPassword'); + var n2 = fd.get('newPassword2'); + if (!tid) { + setMsg('admins-msg', 'เลือกแอดมิน', 'error'); + return; + } + if (n1 !== n2) { + setMsg('admins-msg', 'รหัสใหม่ไม่ตรงกัน', 'error'); + return; + } + api('admins.php', { + method: 'PATCH', + body: { id: tid, newPassword: n1 }, + }) + .then(function () { + e.target.reset(); + setMsg('admins-msg', 'ตั้งรหัสใหม่ให้แอดมินที่เลือกแล้ว', 'ok'); + }) + .catch(function (err) { + setMsg('admins-msg', err.message, 'error'); + }); + }); + + el('form-admin-add').addEventListener('submit', function (e) { + e.preventDefault(); + var fd = new FormData(e.target); + api('admins.php', { + method: 'POST', + body: { + username: fd.get('username'), + password: fd.get('password'), + role: fd.get('role'), + }, + }) + .then(function () { + e.target.reset(); + return loadAdmins(); + }) + .catch(function (err) { + setMsg('admins-msg', err.message, 'error'); + }); + }); + + function getDesiredAdminTab(admin) { + var want = null; + try { + want = new URL(window.location.href).searchParams.get('tab'); + } catch (e) { /* ignore */ } + var allowed = { 'change-password': true, oauth: true, 'map-editor': true, quiz: true, 'special-quiz': true, 'vote-timing': true, troublesome: true, 'quiz-carry': true, 'quiz-battle': true, 'jump-survive': true, 'space-shooter': true, 'mega-virus': true, 'game-timing': true, 'stack-game': true, characters: true, accounts: true, admins: true }; + if (!want || !allowed[want]) return 'oauth'; + if (want === 'admins' && (!admin || admin.role !== 'super')) return 'oauth'; + return want; + } + + function loadAll(admin) { + loadOAuth().catch(function (e) { + setMsg('oauth-msg', e.message, 'error'); + }); + loadAccounts().catch(function (e) { + setMsg('accounts-msg', e.message, 'error'); + }); + var isSuper = admin && admin.role === 'super'; + el('tab-admins').hidden = !isSuper; + el('form-admin-add').hidden = !isSuper; + if (isSuper) { + loadAdmins().catch(function (e) { + setMsg('admins-msg', e.message, 'error'); + }); + } + setTab(getDesiredAdminTab(admin)); + /** อย่า prefetch ซิงก์ Stack ที่นี่ — GET ที่เริ่มก่อน PUT บันทึกอาจกลับช้ากว่าและทับช่องกว้าง/HZ ด้วยข้อมูลเก่า · โหลดจริงที่แท็บ Stack / เวลาเกมแทน */ + } + + boot().then(function (admin) { + if (admin) loadAll(admin); + }); +})(); diff --git a/www/html/Admin/index.html b/www/html/Admin/index.html index 84cbc97..4e98160 100644 --- a/www/html/Admin/index.html +++ b/www/html/Admin/index.html @@ -102,6 +102,8 @@ + + @@ -195,6 +197,28 @@ + + + +