update game 50 users

This commit is contained in:
2026-06-06 12:40:41 +00:00
parent d2122d267d
commit 76d31a224a
63 changed files with 55099 additions and 67 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+205 -5
View File
@@ -279,11 +279,211 @@
"battleQuizMcq": [
{
"categoryId": "cybercrime",
"text": "ฟหกฟหก",
"text": "การเข้าถึงระบบคอมพิวเตอร์ของผู้อื่นโดยไม่ได้รับอนุญาต ผิดกฎหมายใด?",
"choices": [
"ๆไำ",
"ฟหฟหด",
"ฟหกไๆไก"
"พ.ร.บ.จราจร",
"พ.ร.บ.คอมพิวเตอร์",
"กฎหมายแรงงาน"
],
"correctIndex": 1
},
{
"categoryId": "cybercrime",
"text": "การหลอกให้เหยื่อโอนเงินผ่านเว็บ/ข้อความปลอม เรียกว่าอะไร?",
"choices": [
"Phishing",
"Backup",
"Caching"
],
"correctIndex": 0
},
{
"categoryId": "cybercrime",
"text": "มัลแวร์ที่เข้ารหัสไฟล์แล้วเรียกค่าไถ่ คือ?",
"choices": [
"Adware",
"Ransomware",
"Firewall"
],
"correctIndex": 1
},
{
"categoryId": "cyber_law",
"text": "การนำเข้าข้อมูลเท็จสู่ระบบคอมพิวเตอร์ที่ทำให้ผู้อื่นเสียหาย มีโทษตามกฎหมายใด?",
"choices": [
"พ.ร.บ.คอมพิวเตอร์",
"พ.ร.บ.ลิขสิทธิ์",
"ไม่มีความผิด"
],
"correctIndex": 0
},
{
"categoryId": "cyber_law",
"text": "ข้อมูล log การเข้าใช้งานระบบ จัดเป็นพยานหลักฐานประเภทใด?",
"choices": [
"พยานบุคคล",
"พยานเอกสารอิเล็กทรอนิกส์",
"ไม่ถือเป็นหลักฐาน"
],
"correctIndex": 1
},
{
"categoryId": "cyber_law",
"text": "ผู้ให้บริการต้องเก็บข้อมูลจราจรทางคอมพิวเตอร์ (log) อย่างน้อยกี่วันตาม พ.ร.บ.คอมพิวเตอร์?",
"choices": [
"7 วัน",
"90 วัน",
"1 ปี"
],
"correctIndex": 1
},
{
"categoryId": "privacy",
"text": "กฎหมายคุ้มครองข้อมูลส่วนบุคคลของไทย เรียกย่อว่าอะไร?",
"choices": [
"PDPA",
"GDPR",
"DMCA"
],
"correctIndex": 0
},
{
"categoryId": "privacy",
"text": "ก่อนเก็บข้อมูลส่วนบุคคล โดยทั่วไปต้องทำสิ่งใดก่อน?",
"choices": [
"ขอความยินยอม",
"เผยแพร่ทันที",
"ขายต่อ"
],
"correctIndex": 0
},
{
"categoryId": "privacy",
"text": "ข้อใดเป็น “ข้อมูลส่วนบุคคลอ่อนไหว”?",
"choices": [
"ชื่อเล่น",
"ข้อมูลสุขภาพ",
"รหัสไปรษณีย์จังหวัด"
],
"correctIndex": 1
},
{
"categoryId": "digital_security",
"text": "วิธีใดช่วยเพิ่มความปลอดภัยบัญชีได้ดีที่สุด?",
"choices": [
"ใช้รหัสผ่านเดียวทุกที่",
"เปิดยืนยันตัวตน 2 ชั้น (2FA)",
"จดรหัสไว้หน้าจอ"
],
"correctIndex": 1
},
{
"categoryId": "digital_security",
"text": "รหัสผ่านแบบใดปลอดภัยที่สุด?",
"choices": [
"123456",
"วันเกิดตัวเอง",
"ยาว+ผสมอักขระหลายแบบ"
],
"correctIndex": 2
},
{
"categoryId": "digital_security",
"text": "ควรทำอย่างไรเมื่อได้รับลิงก์แปลกจากคนไม่รู้จัก?",
"choices": [
"กดทันที",
"ไม่กดและตรวจสอบที่มา",
"ส่งต่อให้เพื่อน"
],
"correctIndex": 1
},
{
"categoryId": "social_media",
"text": "การโพสต์ข้อความหมิ่นประมาทผู้อื่นบนโซเชียล มีผลอย่างไร?",
"choices": [
"ไม่ผิดเพราะเป็นออนไลน์",
"อาจมีความผิดทางกฎหมาย",
"ผิดเฉพาะถ้าใช้ชื่อจริง"
],
"correctIndex": 1
},
{
"categoryId": "social_media",
"text": "ก่อนแชร์ข่าว ควรทำสิ่งใด?",
"choices": [
"ตรวจสอบความถูกต้องของแหล่งข่าว",
"แชร์ให้เร็วที่สุด",
"แชร์เฉพาะพาดหัว"
],
"correctIndex": 0
},
{
"categoryId": "social_media",
"text": "การตั้งค่าความเป็นส่วนตัวของโพสต์ช่วยเรื่องใด?",
"choices": [
"ควบคุมว่าใครเห็นได้",
"เพิ่มความเร็วเน็ต",
"ลบไวรัส"
],
"correctIndex": 0
},
{
"categoryId": "ethics",
"text": "การคัดลอกผลงานผู้อื่นมาเป็นของตนเรียกว่าอะไร?",
"choices": [
"การอ้างอิง",
"การลอกเลียน (plagiarism)",
"การสำรองข้อมูล"
],
"correctIndex": 1
},
{
"categoryId": "ethics",
"text": "พฤติกรรมใดถือว่า “กลั่นแกล้งทางออนไลน์” (cyberbullying)?",
"choices": [
"ให้กำลังใจเพื่อน",
"ส่งข้อความข่มขู่ซ้ำ ๆ",
"กดไลก์รูปภาพ"
],
"correctIndex": 1
},
{
"categoryId": "ethics",
"text": "การใช้ข้อมูลของผู้อื่นโดยไม่ขออนุญาตถือว่า?",
"choices": [
"ยอมรับได้เสมอ",
"ไม่เหมาะสมและอาจผิดกฎหมาย",
"เป็นมารยาทที่ดี"
],
"correctIndex": 1
},
{
"categoryId": "ai_data",
"text": "ข้อมูลที่ใช้สอนโมเดล AI ควรมีลักษณะใด?",
"choices": [
"มีอคติมาก ๆ",
"มีคุณภาพและได้รับอนุญาต",
"ละเมิดลิขสิทธิ์ก็ได้"
],
"correctIndex": 1
},
{
"categoryId": "ai_data",
"text": "ผลลัพธ์จาก AI ควรถูกตรวจสอบโดยใคร?",
"choices": [
"ไม่ต้องตรวจ",
"มนุษย์ผู้รับผิดชอบ",
"AI ตัวอื่นเท่านั้น"
],
"correctIndex": 1
},
{
"categoryId": "ai_data",
"text": "Deepfake คือสิ่งใด?",
"choices": [
"สื่อปลอมที่สร้างด้วย AI",
"โปรแกรมป้องกันไวรัส",
"ระบบสำรองข้อมูล"
],
"correctIndex": 0
}
@@ -1790,4 +1990,4 @@
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+103
View File
@@ -402,5 +402,108 @@
<script src="js/version.js?v=0.0258"></script>
<script src="js/editor.js?v=0.0335"></script>
<div class="version-tag">v —</div>
<!-- ===== QB overlay layout: map เต็มจอ + แถบเครื่องมือลอย ดัก/พับ/ปิดเปิดได้ (เพิ่มทับ ไม่แตะ editor.js) ===== -->
<style id="qb-editor-overlay-style">
.container.editor-page { max-width:none !important; width:auto !important; margin:0 !important; padding:0 !important; }
.editor-page > h1 { position:fixed; top:6px; left:50%; transform:translateX(-50%); margin:0; font-size:13px; font-weight:700; opacity:.45; z-index:3; pointer-events:none; letter-spacing:.5px; }
/* map เต็มจอ อยู่ข้างหลัง — เลื่อนดูได้ครบ (ไม่ใช้ flex-center ที่ตัดบน/ล่าง) */
.editor-workspace { position:fixed !important; inset:0 !important; margin:0 !important; padding:16px !important;
overflow:auto !important; z-index:1; text-align:center; }
.editor-workspace .canvas-wrap { display:inline-block; margin:0 auto; box-shadow:0 12px 50px rgba(0,0,0,.6); }
/* ซ่อน mock HUD ของ Tower Stack ที่ไม่เกี่ยวกับการ set ฉาก */
#editor-stack-hud-mock { display:none !important; }
.editor-workspace #editor-status { position:fixed; left:50%; bottom:6px; transform:translateX(-50%); margin:0; z-index:5;
background:rgba(8,12,28,.72); padding:4px 12px; border-radius:999px; font-size:12px; max-width:80vw; }
/* แถบเครื่องมือ = แผงโปร่งแสง ลอยทับแมป */
.editor-toolbar-wrap { position:fixed !important; z-index:40; margin:0 !important; padding:8px !important; overflow:auto;
background:rgba(10,15,32,.82) !important; -webkit-backdrop-filter:blur(9px); backdrop-filter:blur(9px);
border:1px solid #2a3357; box-shadow:0 10px 40px rgba(0,0,0,.55); transition:transform .2s ease; }
.editor-toolbar-wrap.qb-left { top:0; left:0; height:100vh; width:min(440px,94vw); border-radius:0 16px 16px 0; }
.editor-toolbar-wrap.qb-right { top:0; right:0; height:100vh; width:min(440px,94vw); border-radius:16px 0 0 16px; }
.editor-toolbar-wrap.qb-top { top:0; left:0; right:0; width:100%; max-height:48vh; border-radius:0 0 16px 16px; }
.editor-toolbar-wrap.qb-bottom { bottom:0; left:0; right:0; width:100%; max-height:48vh; border-radius:16px 16px 0 0; }
.editor-toolbar-wrap.qb-left.qb-hidden { transform:translateX(-103%); }
.editor-toolbar-wrap.qb-right.qb-hidden { transform:translateX(103%); }
.editor-toolbar-wrap.qb-top.qb-hidden { transform:translateY(-103%); }
.editor-toolbar-wrap.qb-bottom.qb-hidden { transform:translateY(103%); }
.editor-toolbar-wrap.qb-left .editor-toolbar-grid, .editor-toolbar-wrap.qb-right .editor-toolbar-grid { display:flex !important; flex-direction:column; gap:8px; }
.editor-toolbar-wrap.qb-top .editor-toolbar-grid, .editor-toolbar-wrap.qb-bottom .editor-toolbar-grid { display:flex !important; flex-direction:row; flex-wrap:nowrap; gap:8px; overflow-x:auto; align-items:flex-start; }
.editor-toolbar-wrap.qb-top .editor-card, .editor-toolbar-wrap.qb-bottom .editor-card { min-width:300px; flex:0 0 auto; }
.editor-toolbar-wrap .editor-card { background:rgba(18,24,52,.7) !important; border:1px solid #2a3357 !important; border-radius:12px !important; padding:9px 12px !important; margin:0 !important; }
/* การ์ดพับได้ (กดหัวข้อ) */
.editor-toolbar-wrap .editor-card__title { cursor:pointer; user-select:none; display:flex; align-items:center; gap:7px; }
.editor-toolbar-wrap .editor-card__title::before { content:'\25BE'; font-size:.78em; opacity:.85; transition:transform .15s; }
.editor-toolbar-wrap .editor-card.qb-card-collapsed > *:not(.editor-card__title) { display:none !important; }
.editor-toolbar-wrap .editor-card.qb-card-collapsed .editor-card__title::before { transform:rotate(-90deg); }
/* แถบหัวคุม (ดัก/พับ) */
#qb-tb-head { position:sticky; top:0; z-index:3; display:flex; align-items:center; gap:6px; padding:6px 4px; margin-bottom:8px; background:rgba(10,15,32,.6); border-radius:9px; }
#qb-tb-head b { flex:1; font-size:13px; white-space:nowrap; color:#eef2ff; }
.qb-ctrl-btn { border:1px solid #2a3357; background:#0d1430; color:#eef2ff; border-radius:8px; min-width:32px; height:32px; padding:0 7px; cursor:pointer; font:inherit; font-weight:700; display:inline-grid; place-items:center; }
.qb-ctrl-btn:hover { filter:brightness(1.25); }
.qb-ctrl-btn.on { border-color:#22d3ee; background:rgba(34,211,238,.18); }
#qb-tb-reopen { position:fixed; z-index:42; top:10px; left:10px; display:none; box-shadow:0 6px 20px rgba(0,0,0,.4); }
#qb-tb-reopen.show { display:inline-grid; }
</style>
<button id="qb-tb-reopen" class="qb-ctrl-btn" type="button">🛠️ เครื่องมือ</button>
<script>
(function(){
var tw = document.querySelector('.editor-toolbar-wrap');
if(!tw){ return; }
var reopen = document.getElementById('qb-tb-reopen');
// หัวคุม
var hdr = document.createElement('div');
hdr.id = 'qb-tb-head';
hdr.innerHTML = '<b>🛠️ เครื่องมือฉาก</b>'+
'<button type="button" class="qb-ctrl-btn" data-dock="top" title="ดักบน">⬆</button>'+
'<button type="button" class="qb-ctrl-btn" data-dock="left" title="ดักซ้าย">⬅</button>'+
'<button type="button" class="qb-ctrl-btn" data-dock="right" title="ดักขวา">➡</button>'+
'<button type="button" class="qb-ctrl-btn" data-dock="bottom" title="ดักล่าง">⬇</button>'+
'<button type="button" class="qb-ctrl-btn" id="qb-tb-collapse" title="พับเก็บแถบ">✕</button>';
tw.insertBefore(hdr, tw.firstChild);
function setDock(d){
tw.classList.remove('qb-left','qb-right','qb-top','qb-bottom');
tw.classList.add('qb-'+d);
hdr.querySelectorAll('[data-dock]').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-dock')===d); });
try{ localStorage.setItem('qbEdDock2', d); }catch(e){}
}
function setHidden(h){
tw.classList.toggle('qb-hidden', h);
reopen.classList.toggle('show', h);
try{ localStorage.setItem('qbEdHidden2', h?'1':'0'); }catch(e){}
}
hdr.querySelectorAll('[data-dock]').forEach(function(b){ b.addEventListener('click', function(){ setDock(b.getAttribute('data-dock')); }); });
document.getElementById('qb-tb-collapse').addEventListener('click', function(){ setHidden(true); });
reopen.addEventListener('click', function(){ setHidden(false); });
// การ์ดแต่ละใบ กดหัวข้อเพื่อพับ/เปิด
function keyFor(card, title){ return title.id || (title.textContent||'').trim().slice(0,40); }
var cards = tw.querySelectorAll('.editor-card');
cards.forEach(function(card){
var title = card.querySelector('.editor-card__title');
if(!title) return;
title.addEventListener('click', function(){
card.classList.toggle('qb-card-collapsed');
try{
var st = JSON.parse(localStorage.getItem('qbEdCards2')||'{}');
st[keyFor(card,title)] = card.classList.contains('qb-card-collapsed')?1:0;
localStorage.setItem('qbEdCards2', JSON.stringify(st));
}catch(e){}
});
});
// คืนค่าที่จำไว้
var dock='left'; try{ dock=localStorage.getItem('qbEdDock2')||'left'; }catch(e){}
setDock(dock);
try{ if(localStorage.getItem('qbEdHidden2')==='1') setHidden(true); }catch(e){}
try{
var st = JSON.parse(localStorage.getItem('qbEdCards2')||'{}');
cards.forEach(function(card){ var t=card.querySelector('.editor-card__title'); if(t && st[keyFor(card,t)]) card.classList.add('qb-card-collapsed'); });
}catch(e){}
// ให้ editor.js คำนวณขนาด canvas ใหม่ตาม layout เต็มจอ
setTimeout(function(){ try{ window.dispatchEvent(new Event('resize')); }catch(e){} }, 60);
})();
</script>
</body>
</html>
@@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<title>Editor ฉาก — Game</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;800&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=20260427-canvas-pixel">
<link rel="stylesheet" href="css/editor-stack-hud-mock.css?v=0.0001">
</head>
<body>
<div class="container editor-page">
<h1>Editor ฉาก</h1>
<div class="editor-toolbar-wrap">
<div class="editor-toolbar-grid">
<section class="editor-card" aria-labelledby="editor-card-game-label">
<h2 class="editor-card__title" id="editor-card-game-label">ประเภทเกม</h2>
<div class="editor-card__row">
<label class="editor-field-grow">เลือกโหมด
<select id="game-type">
<option value="zep">เดินอิสระ (ZEP)</option>
<option value="lobby">ห้องโถง (รอผู้เล่น)</option>
<option value="gauntlet">พรมแดงสุดท้าย (Last Light)</option>
<option value="frogger">กบข้ามถนน</option>
<option value="quiz">เกมตอบคำถาม (ถูก/ผิด)</option>
<option value="quiz_carry">ตอบคำถาม — หยิบคำตอบมาวางกลาง</option>
<option value="quiz_battle">Quiz Battle — โดม A B C (กด E)</option>
<option value="stack">สลับจังหวะลงตึก (Stack)</option>
<option value="jump_survive">กระโดดให้รอด (Jump Survival)</option>
<option value="space_shooter">ยิงยานอวกาศ (Space Shooter)</option>
<option value="balloon_boss">ลูกโป้งยิงบอส (Mega Virus)</option>
</select>
</label>
</div>
<div class="game-type-hint editor-card__hint" id="game-type-hint" role="note" aria-labelledby="editor-card-game-label"></div>
<div id="editor-quiz-carry-countdown-wrap" class="editor-card__row editor-card__row--wrap" style="display:none;gap:0.65rem 1rem;align-items:center;margin-top:0.45rem;" aria-label="หยิบมาวาง พรีวิวนับถอยหลัง">
<label class="editor-field-nogrow" style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;">
<input type="checkbox" id="carry-embed-countdown-highlight" checked>
<span>พรีวิว: ไฮไลต์โซนตัวเลือกตอนนับ 3-2-1</span>
</label>
<label class="editor-field-nogrow">สีขอบไฮไลต์
<input type="color" id="carry-embed-countdown-color" value="#ffb44a" title="สีขอบเรืองรอบช่องตัวเลือก (ตรงกับโซนที่วาดเป็น ตัวเลือก 1–16 บนกริด)">
</label>
<label class="editor-field-grow">ตำแหน่งเลข 3-2-1
<select id="carry-embed-countdown-anchor" title="ตามกริด = วาดช่องในโหมด «ตำแหน่งเลข 3-2-1» · อัตโนมัติ = โซนทองหรือโซนกลาง">
<option value="grid">ตามกริดที่วาด (โหมดวาดด้านล่าง)</option>
<option value="map" selected>บนแมป — โซนทอง (คำถาม) หรือโซนกลาง</option>
<option value="screen">กลางจอ (ม่านทึบ)</option>
</select>
</label>
</div>
</section>
<section class="editor-card" aria-labelledby="editor-card-map-label">
<h2 class="editor-card__title" id="editor-card-map-label">โหลด / ตั้งชื่อฉาก</h2>
<div class="editor-card__row editor-card__row--wrap">
<label>แก้จากฉากเดิม <select id="map-load"><option value="">โหลดรายการ...</option></select></label>
<button type="button" id="btn-load">โหลดฉาก</button>
<label class="editor-field-grow">ชื่อฉาก <input type="text" id="map-name" placeholder="ชื่อฉาก (ไม่ซ้ำ)"></label>
</div>
</section>
<section class="editor-card" aria-labelledby="editor-card-grid-label">
<h2 class="editor-card__title" id="editor-card-grid-label">ขนาดกริด</h2>
<div class="editor-card__row editor-card__row--wrap">
<label>กว้าง <input type="number" id="map-w" value="20" min="10" max="50"></label>
<label>สูง <input type="number" id="map-h" value="15" min="10" max="40"></label>
<label>ขนาดไทล์ <input type="number" id="tile-size" value="32" min="16" max="64"></label>
<label title="ลงจากมุมซ้ายบนของจุดเกิด — เน้นขยายแนวตั้งก่อน (เช่น 4 = สูง 4 ช่อง)">ตัวละคร สูง (แนวตั้ง) <input type="number" id="character-cells-h" value="1" min="1" max="4"></label>
<label title="ไปทางขวาจากมุมซ้ายบน — แนวนอน">× กว้าง (แนวนอน) <input type="number" id="character-cells-w" value="1" min="1" max="4"></label>
<div class="editor-footprint-row editor-footprint-row--wall" role="group" aria-labelledby="editor-fp-wall-head">
<div id="editor-fp-wall-head" class="editor-footprint-row__head">ชนกำแพง · กล่องตรวจ</div>
<label class="editor-footprint-row__field" title="เฉพาะกำแพง (โหมดวาดกำแพง / objects = เดินไม่ได้) — แนวนอนกลางใต้ร่าง · เล็กกว่าตัว = หัว/ลำตัวทับสไปรต์กำแพงได้ · โซน interactive / hub / อื่น ๆ ใช้ขนาดตัวด้านบน" aria-describedby="editor-character-collision-hint">กว้าง <input type="number" id="character-collision-w" value="1" min="1" max="4" aria-label="ชนกำแพง กว้าง (ช่องกริด)"></label>
<label class="editor-footprint-row__field" title="เฉพาะกำแพง — สูงจากเท้าขึ้นมา · น้อยกว่าสูงตัว = ส่วนบนไม่ชนกำแพง · โซนอื่นใช้สูงตัวด้านบน" aria-describedby="editor-character-collision-hint">× สูง <input type="number" id="character-collision-h" value="1" min="1" max="4" aria-label="ชนกำแพง สูงจากเท้า (ช่องกริด)"></label>
</div>
<div class="editor-footprint-row editor-footprint-row--no-overlap" role="group" aria-labelledby="editor-fp-nooverlap-head">
<div id="editor-fp-nooverlap-head" class="editor-footprint-row__head">โซนห้ามซ้อน · กล่องตรวจ</div>
<label class="editor-footprint-row__field" title="กล่องตรวจทับโซน «ห้ามซ้อนผู้เล่น» (เทียบชนกำแพง) — แนวนอนกลางใต้ร่าง · 0 = เต็มความกว้างตัว" aria-describedby="editor-character-collision-hint">กว้าง <input type="number" id="block-player-separation-w" value="0" min="0" max="4" step="1" aria-label="โซนห้ามซ้อน กว้างกล่องตรวจ (ช่องกริด)"></label>
<label class="editor-footprint-row__field" title="กล่องตรวจทับโซน «ห้ามซ้อนผู้เล่น» — สูงจากเท้าขึ้นมา · 0 = เต็มสูงตัว" aria-describedby="editor-character-collision-hint">× สูง <input type="number" id="block-player-separation-h" value="0" min="0" max="4" step="1" aria-label="โซนห้ามซ้อน สูงกล่องตรวจจากเท้า (ช่องกริด)"></label>
</div>
<button type="button" id="btn-new">สร้างกริดใหม่</button>
<p id="editor-character-collision-hint" class="editor-card__hint" style="margin:0.4rem 0 0;width:100%;max-width:44rem;line-height:1.45;">
<strong>ชนกำแพง กว้าง×สูง</strong> ใช้แค่ตรวจชน <strong>กำแพง</strong> (ช่องวาด «กำแพง») เท่านั้น
· <strong>interactive</strong> · <strong>โซนห้ามซ้อน</strong> · <strong>hub / ส่งคำตอบ quiz carry</strong> · โซนคำถาม/ตัวเลือก ฯลฯ ยังใช้พื้นที่ตัวละครตาม
<strong>สูง (แนวตั้ง) × กว้าง (แนวนอน)</strong> เหมือนเดิม — ในเกม (<code>play.js</code>) แยกฟังก์ชันชนกำแพงกับ footprint โซนแล้ว · <strong>ห้ามซ้อน กว้าง×สูง</strong> = กำหนดกล่องตรวจทับโซนห้ามซ้อน (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) ไม่ใช่การขยายรอบร่าง
</p>
</div>
</section>
<section class="editor-card editor-card--wide" aria-labelledby="editor-card-draw-label">
<h2 class="editor-card__title" id="editor-card-draw-label">เครื่องมือวาด</h2>
<p class="editor-draw-build-hint" style="margin:0 0 0.4rem;font-size:0.74rem;color:#9ece6a;line-height:1.35;">มีโหมด <strong>รูปบนกริด</strong> ในเมนู «โหมดวาด» — ถ้าไม่เห็นข้อความนี้หรือไม่มีเมนูนั้น = หน้าเว็บยังเป็นไฟล์เก่า (ลอง <strong>Ctrl+F5</strong> หรืออัปโหลด <code>editor.html</code> / <code>js/editor.js</code> ขึ้นเซิร์ฟใหม่)</p>
<div class="editor-card__row editor-card__row--wrap">
<label class="editor-field-drawmode">โหมดวาด
<select id="draw-mode" title="จัดกลุ่มตามประเภท — ตัวเลือกที่ไม่ใช้กับโหมดเกมจะถูกซ่อน">
<optgroup label="พื้นฐาน / ทุกโหมด">
<option value="wall">กำแพง (เดินไม่ได้)</option>
<option value="noOverlap">โซนห้ามซ้อนผู้เล่น</option>
<option value="cellImage">รูปบนกริด (อัปโหลด / เลือกจากคลัง)</option>
<option value="interactive">โซน interactive (จุดกด/ใช้ได้)</option>
<option value="customizeSpot">จุดห้องแต่งตัว (เดินไปกด F — คลิกซ้ายวาง / ขวาลบ)</option>
<option value="hostConsoleSpot" id="draw-mode-option-host-console">จุดตั้งค่าโฮสต์ (Host กด F — คลิกซ้ายวาง / ขวาลบ)</option>
<option value="spawnArea" id="draw-mode-option-spawn-area">พื้นที่สุ่มจุดเกิด (ฟ้า — ผู้เล่นใหม่สุ่มในช่องนี้)</option>
<option value="lobbyPlayerSpawn" id="draw-mode-option-lobby-spawn" hidden>จุดเกิดผู้เล่น 1–6 (ลำดับเข้า P1…P6)</option>
<option value="startGame" id="draw-mode-option-start-game">พื้นที่เริ่มเกม (host ยืนแล้วกดเริ่มได้)</option>
<option value="color">สีช่อง (ทาสีบนกริด)</option>
</optgroup>
<optgroup label="พรมแดงสุดท้าย">
<option value="gauntletPlayerSpawn" id="draw-mode-option-gauntlet-player-spawn" hidden>ลำดับเกิด 1–6 (คลิกซ้ายตามลำดับ)</option>
<option value="gauntletLaserStart" id="draw-mode-option-gauntlet-laser-start" hidden>เลเซอร์: แถวต้น (ขอบบนของแถบ)</option>
<option value="gauntletLaserEnd" id="draw-mode-option-gauntlet-laser-end" hidden>เลเซอร์: แถวปลาย (ขอบล่างของแถบ)</option>
</optgroup>
<optgroup label="Stack">
<option value="stackRelease" id="draw-mode-option-stack-release" hidden>จุดปล่อยตึก (crane / ด้านบน)</option>
<option value="stackLand" id="draw-mode-option-stack-land" hidden>จุดซ้อนตึก (แพลตฟอร์มรองรับ)</option>
</optgroup>
<optgroup label="ตอบคำถาม (ยืนโซน ถูก/ผิด)">
<option value="quizTrue" id="draw-mode-option-quiz-true">โซน ถูก (ตอบ จริง)</option>
<option value="quizFalse" id="draw-mode-option-quiz-false">โซน ผิด (ตอบ เท็จ)</option>
<option value="quizQuestion" id="draw-mode-option-quiz-question">พื้นที่โชว์ข้อความคำถาม</option>
</optgroup>
<optgroup label="Quiz Battle">
<option value="quizBattlePath" id="draw-mode-option-quiz-battle-path" hidden>เส้นทางเดิน (ม่วง — วาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง)</option>
<option value="quizBattleDome" id="draw-mode-option-quiz-battle-dome" hidden>โดมคำถาม (ช่องกด E ในเกม)</option>
</optgroup>
<optgroup label="กระโดดให้รอด">
<option value="jumpSurvivePlatform1" id="draw-mode-option-jump-platform-1" hidden>แพลตฟอร์ม 1 (ยืนได้ — รูปช่อง 1 จาก Admin)</option>
<option value="jumpSurvivePlatform2" id="draw-mode-option-jump-platform-2" hidden>แพลตฟอร์ม 2 (ยืนได้ — รูปช่อง 2 จาก Admin)</option>
<option value="jumpSurvivePlatform3" id="draw-mode-option-jump-platform-3" hidden>แพลตฟอร์ม 3 (ยืนได้ — รูปช่อง 3 จาก Admin)</option>
<option value="jumpSurviveHazard" id="draw-mode-option-jump-hazard" hidden>โซนตาย (แดง — ตัวละครโดนกล่องชนแล้วตายทันที)</option>
</optgroup>
<optgroup label="ยิงยานอวกาศ">
<option value="shooterSpawnPaint" id="draw-mode-option-shooter-spawn" hidden>จุดเกิดยาน ผู้เล่น 1–6 (เลือกช่อง P ด้านขวา)</option>
</optgroup>
<optgroup label="ลูกโป้งยิงบอส">
<option value="balloonBossPlayerPaint" id="draw-mode-option-balloon-boss-player" hidden>จุดเกิดผู้เล่น 1–6 (เลือก P ด้านขวา)</option>
<option value="balloonBossBossPaint" id="draw-mode-option-balloon-boss-boss" hidden>จุดเกิดบอส (คลิกช่องเดียว — กลางจอในเกม)</option>
</optgroup>
<optgroup label="หยิบมาวาง (โซนกลาง + ตัวเลขช่อง)">
<option value="quizCarryHub" id="draw-mode-option-quiz-carry-hub" class="quiz-carry-mode-opt" hidden>โซนกลาง (วางคำตอบ + โชว์คำถาม)</option>
<option value="quizCarryOpt1" class="quiz-carry-mode-opt" hidden>ตัวเลือก 1</option>
<option value="quizCarryOpt2" class="quiz-carry-mode-opt" hidden>ตัวเลือก 2</option>
<option value="quizCarryOpt3" class="quiz-carry-mode-opt" hidden>ตัวเลือก 3</option>
<option value="quizCarryOpt4" class="quiz-carry-mode-opt" hidden>ตัวเลือก 4</option>
<option value="quizCarryOpt5" class="quiz-carry-mode-opt" hidden>ตัวเลือก 5</option>
<option value="quizCarryOpt6" class="quiz-carry-mode-opt" hidden>ตัวเลือก 6</option>
<option value="quizCarryOpt7" class="quiz-carry-mode-opt" hidden>ตัวเลือก 7</option>
<option value="quizCarryOpt8" class="quiz-carry-mode-opt" hidden>ตัวเลือก 8</option>
<option value="quizCarryOpt9" class="quiz-carry-mode-opt" hidden>ตัวเลือก 9</option>
<option value="quizCarryOpt10" class="quiz-carry-mode-opt" hidden>ตัวเลือก 10</option>
<option value="quizCarryOpt11" class="quiz-carry-mode-opt" hidden>ตัวเลือก 11</option>
<option value="quizCarryOpt12" class="quiz-carry-mode-opt" hidden>ตัวเลือก 12</option>
<option value="quizCarryOpt13" class="quiz-carry-mode-opt" hidden>ตัวเลือก 13</option>
<option value="quizCarryOpt14" class="quiz-carry-mode-opt" hidden>ตัวเลือก 14</option>
<option value="quizCarryOpt15" class="quiz-carry-mode-opt" hidden>ตัวเลือก 15</option>
<option value="quizCarryOpt16" class="quiz-carry-mode-opt" hidden>ตัวเลือก 16</option>
<option value="carryEmbedCountdown" class="quiz-carry-mode-opt" hidden>ตำแหน่งเลข 3-2-1 (กริด cyan · พรีวิว embed)</option>
</optgroup>
</select>
</label>
<span id="cell-color-wrap" class="editor-cell-color" style="display:none;">
<label>สี <input type="color" id="cell-color" value="#24283b" title="สี"></label>
<label>Alpha <input type="range" id="cell-alpha" min="0" max="100" value="100" title="ความโปร่งใส 0=ใส 100=ทึบ"> <span id="cell-alpha-value">100</span>%</label>
</span>
<div id="editor-grid-image-tools" class="editor-grid-image-tools" style="display:none;">
<div class="editor-grid-image-size-row">
<label>กว้าง (ช่อง) <input type="number" id="grid-image-brush-w" value="1" min="1" max="24" title="จำนวนช่องกริดแนวนอน"></label>
<label>สูง (ช่อง) <input type="number" id="grid-image-brush-h" value="1" min="1" max="24" title="จำนวนช่องกริดแนวตั้ง"></label>
</div>
<div id="grid-sprite-carry-bind-wrap" class="editor-grid-image-carry-bind-row" style="display:none;" title="quiz carry: สไปรต์ที่ไม่ทับช่องโซนตัวเลือก — ต้องผูกข้อเองถึงจะสลับรูปหยิบแล้ว">
<label>ผูกสไปรต์กับข้อ (หยิบแล้ว)
<select id="grid-sprite-carry-bind">
<option value="auto">อัตโนมัติ (ช่องที่ทับโซนตัวเลือก)</option>
<option value="1">ข้อ 1</option>
<option value="2">ข้อ 2</option>
<option value="3">ข้อ 3</option>
<option value="4">ข้อ 4</option>
<option value="5">ข้อ 5</option>
<option value="6">ข้อ 6</option>
<option value="7">ข้อ 7</option>
<option value="8">ข้อ 8</option>
<option value="9">ข้อ 9</option>
<option value="10">ข้อ 10</option>
<option value="11">ข้อ 11</option>
<option value="12">ข้อ 12</option>
<option value="13">ข้อ 13</option>
<option value="14">ข้อ 14</option>
<option value="15">ข้อ 15</option>
<option value="16">ข้อ 16</option>
</select>
</label>
<span class="editor-hint-inline">Alt+คลิกซ้ายบนสไปรต์ = ใส่/เปลี่ยนผูก · รายการ &quot;อัตโนมัติ&quot; = ล้างผูก</span>
</div>
<div class="editor-grid-image-gallery-wrap">
<span class="editor-grid-image-gallery-label">คลังรูป (คลิกเลือก)</span>
<p class="editor-grid-image-gallery-note">สองช่องคือรูปที่บันทึกในแมป (ก่อนหยิบ / หยิบแล้ว) — ไม่อัปเดตตามการหยิบในห้อง · ทดสอบ idle/held ให้เปิดหน้าเล่น หลังบันทึกแมป</p>
<div id="grid-cell-image-gallery" class="grid-cell-image-gallery" role="listbox" aria-label="คลังรูปบนกริด"></div>
</div>
<div class="editor-grid-image-upload-row">
<label>อัปโหลดเข้าคลัง <input type="file" id="grid-cell-image-upload" accept="image/*"></label>
<input type="file" id="grid-cell-image-held-upload" accept="image/*" hidden tabindex="-1" aria-hidden="true" title="อัปโหลดรูปตอนถือ (quiz carry)">
<button type="button" id="btn-grid-image-remove-from-lib" title="ลบรูปที่เลือกในคลัง (สไปรต์ที่ใช้รูปนี้จะหาย)">ลบจากคลัง</button>
</div>
</div>
<label class="editor-toggle" title="ปิด = ไม่วาดกริด/ช่องฉาก แต่ยังแสดงรูปที่วาดบนกริด · Off = hide tile grid, grid images still show"><input type="checkbox" id="show-map-in-game" checked> โชว์กริดในเกม</label>
<button type="button" id="btn-clear-gauntlet-spawns" hidden title="ล้างรายการจุดเกิดพรมแดงที่กำหนดเอง">ล้างลำดับ 16</button>
<button type="button" id="btn-clear-gauntlet-laser-span" hidden title="ล้างช่วงแนวตั้งของเลเซอร์ — กลับไปเต็มความสูงแมป (ไม่ต้องเปลี่ยนรูปจาก Admin)">ล้างช่วง laser</button>
<label id="shooter-slot-paint-wrap" class="editor-field-grow" style="display:none;" title="ช่องผู้เล่นลำดับที่ 1–6 ตรงกับคนในเกม (คุณ = ลำดับแรกจาก join)">ช่องเกิดยาน P
<select id="shooter-slot-paint">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</label>
<label id="balloon-boss-slot-paint-wrap" class="editor-field-grow" style="display:none;" title="ลำดับ P1P6 เหมือนยิงยาน">ช่องเกิดผู้เล่น P
<select id="balloon-boss-slot-paint">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</label>
<button type="button" id="btn-clear-shooter-spawns" hidden title="ล้างจุดเกิดยานทั้งหมด">ล้างจุดเกิดยาน</button>
<button type="button" id="btn-clear-balloon-boss-spawns" hidden title="ล้างจุดเกิดผู้เล่น + บอส">ล้างจุดเกิดบอสโหมด</button>
<div id="lobby-spawn-wrap" class="editor-card__row" style="display:none;flex-wrap:wrap;gap:0.35rem 0.75rem;align-items:center;width:100%;">
<label class="editor-field-grow" title="ใช้เมื่อเข้าห้อง/ทดลองเล่น (ไม่รวมพรมแดง·ยิงยาน·บอส·กบที่มีจุดเกิดแยก)">จุดเกิดผู้เล่น
<select id="lobby-spawn-mode">
<option value="random">สุ่มในพื้นที่ฟ้า — ไม่มีฟ้าใช้ปุ่มตั้งจุดเกิด</option>
<option value="fixed">คงที่ — จุดเดียว (ปุ่มตั้งจุดเกิด)</option>
<option value="slots6">6 จุดตามลำดับเข้า (P1…P6)</option>
</select>
</label>
</div>
<label id="lobby-slot-paint-wrap" class="editor-field-grow" style="display:none;" title="คนแรกที่เข้าห้อง = P1 ตามลำดับ join">ช่องเกิด P
<select id="lobby-slot-paint">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</label>
<button type="button" id="btn-clear-lobby-spawns" hidden title="ล้างจุด P1P6 บนแผนที่">ล้างจุด P1P6</button>
<button type="button" id="btn-spawn">ตั้งจุดเกิด</button>
</div>
<div class="editor-card__row editor-card__row--actions">
<button type="button" id="btn-save" class="editor-btn-primary">บันทึกฉาก</button>
<button type="button" id="btn-play-test" title="บันทึกฉากแล้วเท่านั้น — เปิดแท็บใหม่เล่นทดสอบด้วยตัวละครตัวแรกที่สร้าง (จาก /Game/character.html)">ทดลองเล่น</button>
</div>
</section>
</div>
<div id="frogger-lanes-wrap" class="frogger-lanes" style="display:none;">
<h3 id="frogger-lanes-title">ตั้งค่าแถว — กบข้ามถนน</h3>
<p class="legend" id="frogger-lanes-legend">แถวบน (y=0) = ปลายทาง · แถวล่าง = จุดเกิด · ถนน/น้ำ ตั้งความเร็ว ทิศทาง และ <strong>ระยะห่าง</strong> (ยิ่งมากยิ่งห่าง เกมง่ายขึ้น)</p>
<div id="frogger-lanes-table"></div>
</div>
<section class="editor-card editor-card--bg" aria-labelledby="editor-card-bg-label">
<h2 class="editor-card__title" id="editor-card-bg-label">พื้นหลัง</h2>
<div class="editor-card__row editor-card__row--wrap">
<label>รูปแผนที่ <input type="file" id="bg-image" accept="image/*"></label>
<button type="button" id="btn-clear-bg">ลบรูปพื้นหลัง</button>
</div>
<div id="editor-stack-tower-vertical-bg-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.45rem 0 0.35rem;line-height:1.45"><strong>พื้นหลังแนวตั้ง (Stack Tower)</strong> ฉาก <code>mnn93hpi</code> — <strong>รูปแรก</strong> = ช่วงเริ่ม (intro) · <strong>รูปที่สอง</strong> = ไทล์วนลูป (loop) · เปิดแล้วใช้แทนรูปแผนที่เดียวในเกม · ความเร็ว 0 = ไม่เลื่อนอัตโนมัติ — ตั้ง <strong>ทุกกี่ชั้น</strong> แล้วเลื่อนแถบขึ้นทีละขั้น (แอนิเมชัน) · <em>English:</em> Speed 0 = no auto-scroll; optional step scroll every N placed blocks.</p>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem">
<label class="editor-bg-scroll-check"><input type="checkbox" id="editor-stack-tower-vbg-enabled"> ใช้ intro + loop แนวตั้ง / Use vertical strip BG</label>
<label title="0 = นิ่ง (ไม่เลื่อนอัตโนมัติ)">ความเร็ว px/s <input type="number" id="editor-stack-tower-vbg-speed" min="0" max="400" step="4" value="0" style="width:5rem"></label>
<label>ทิศทาง
<select id="editor-stack-tower-vbg-direction">
<option value="down" selected>บน → ล่าง</option>
<option value="up">ล่าง → บน</option>
</select>
</label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem;margin-top:0.35rem">
<label title="0 = ปิดการเลื่อนเป็นขั้น">เลื่อนทุก N ชั้น / Step every N layers <input type="number" id="editor-stack-tower-vbg-step-layers" min="0" max="200" step="1" value="0" style="width:4rem"></label>
<label title="0 = ใช้ความสูงไทล์ loop บนจอ">ขั้น px (0=สูง loop) / Step px <input type="number" id="editor-stack-tower-vbg-step-px" min="0" max="8000" step="8" value="0" style="width:4.5rem"></label>
<label>ระยะเวลาแอนิเมชัน ms <input type="number" id="editor-stack-tower-vbg-step-anim-ms" min="120" max="4000" step="20" value="520" style="width:4.5rem"></label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem;margin-top:0.2rem">
<label title="เว้นว่าง = ใช้จุดปล่อยจากโซนวาดบนแผนที่ · ตัวเลข = ระยะห่างจุดยึดเชือกเหนือขอบบนของบล็อกบนสุด (world px)">ช่องว่างปล่อยเหนือบล็อกบนสุด (world px, ว่าง=ค่าแมป) <input type="number" id="editor-stack-tower-vbg-release-gap" min="0" max="800" step="2" value="" placeholder="—" style="width:4.2rem" title="Release rope gap above top block"></label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="margin-top:0.35rem">
<label>รูปเริ่ม (intro) <input type="file" id="editor-stack-tower-vbg-intro-file" accept="image/*"></label>
<label>รูปลูป (loop) <input type="file" id="editor-stack-tower-vbg-loop-file" accept="image/*"></label>
<button type="button" id="editor-stack-tower-vbg-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
</div>
<div id="editor-bg-scroll-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.5rem 0 0.35rem;line-height:1.45"><strong>เลื่อนพื้นหลังแนวตั้ง</strong> (เฉพาะฉากรหัส <code>mnpz6rkp</code>) — รูป intro ต่อด้วย loop · บันทึกฉากแล้วหน้าเล่นใช้ค่าเดียวกัน · <em>English:</em> Intro + looping strip; saved in map JSON.</p>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem">
<label class="editor-bg-scroll-check"><input type="checkbox" id="editor-bg-scroll-enabled"> เปิดเลื่อน / Enable scroll</label>
<label>ความเร็ว px/s <input type="number" id="editor-bg-scroll-speed" min="8" max="400" step="4" value="56" style="width:5rem" title="ความเร็วการเลื่อนแนวตั้ง"></label>
<label>ทิศทาง
<select id="editor-bg-scroll-direction" title="เริ่มชิดล่าง intro แล้วเลื่อนเข้า loop ใต้ intro — ล่าง→บน = พิกเซลไหลขึ้น · บน→ล่าง = พลิกมุมมองในกรอบจอให้ไหลลง (strip เดิม)">
<option value="up" selected>ล่าง → บน (ไหลขึ้น)</option>
<option value="down">บน → ล่าง (ไหลลง)</option>
</select>
</label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="margin-top:0.35rem">
<label>รูปต้นทาง (intro) <input type="file" id="editor-bg-scroll-intro-file" accept="image/*"></label>
<label>รูปลูป (loop) <input type="file" id="editor-bg-scroll-loop-file" accept="image/*"></label>
<button type="button" id="editor-bg-scroll-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
</div>
<div id="editor-gauntlet-runway-bg-block" class="editor-bg-scroll-block" hidden>
<p class="legend" style="margin:0.5rem 0 0.35rem;line-height:1.45"><strong>พื้นหลังรันเวย์แนวนอน</strong> (เฉพาะฉากรหัส <code>mno9kb07</code> + โหมดพรมแดง) — รูป 1 = จุดเริ่ม (START) แล้วเลื่อนไปวนรูป 2–4 ระหว่างเล่น · พอหมดเวลาหรือเหลือผู้เล่นคนสุดท้าย (หลายคน) จะซ่อนอุปสรรค์แล้วแสดงรูปที่ 5 (FINISH) · บันทึกเป็น <code>gauntletCrownRunwayBg</code> ในแมป · <em>English:</em> Horizontal strip: start → loop images 24 → finish; obstacles hidden on end.</p>
<div class="editor-card__row editor-card__row--wrap" style="align-items:center;gap:0.75rem 1rem">
<label class="editor-bg-scroll-check"><input type="checkbox" id="editor-gauntlet-runway-bg-enabled" checked> เปิดเลื่อน / Enable runway BG</label>
<label>ความเร็ว px/s <input type="number" id="editor-gauntlet-runway-bg-speed" min="8" max="400" step="4" value="48" style="width:5rem" title="ความเร็วเลื่อนแนวนอน (จอ)"></label>
<label title="จุดเกิด (คอลัมน์ซ้ายสุด) เทียบตำแหน่งในแถบรูป START — เก็บเป็น runwaySpawnAlignFrac ในแมป">จุดเกิด vs START (0.020.95) <input type="number" id="editor-gauntlet-runway-bg-align-frac" min="0.02" max="0.95" step="0.01" value="0.32" style="width:4.2rem"></label>
</div>
<div class="editor-card__row editor-card__row--wrap" style="margin-top:0.35rem">
<label>รูปเริ่ม (1) <input type="file" id="editor-gauntlet-runway-bg-start-file" accept="image/*"></label>
<label>รูปลูป (2) <input type="file" id="editor-gauntlet-runway-bg-loop2-file" accept="image/*"></label>
<label>รูปลูป (3) <input type="file" id="editor-gauntlet-runway-bg-loop3-file" accept="image/*"></label>
<label>รูปลูป (4) <input type="file" id="editor-gauntlet-runway-bg-loop4-file" accept="image/*"></label>
<label>รูปจบ (5) <input type="file" id="editor-gauntlet-runway-bg-finish-file" accept="image/*"></label>
<button type="button" id="editor-gauntlet-runway-bg-reset-assets">คืนรูปเริ่มจากเซิร์ฟ / Reset to bundled</button>
</div>
<div id="editor-gauntlet-runway-bg-previews" class="legend" style="margin-top:0.45rem;display:flex;flex-wrap:nowrap;gap:0.45rem 0.55rem;align-items:flex-end;overflow-x:auto;padding-bottom:2px;max-width:100%" aria-label="พรีวิวรูปรันเวย์ที่ใช้จริง (รวมค่าเริ่มจากเซิร์ฟถ้ายังไม่อัปโหลด)">
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">1 START</figcaption><img id="editor-gauntlet-runway-bg-prev-0" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">2 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-1" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">3 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-2" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">4 LOOP</figcaption><img id="editor-gauntlet-runway-bg-prev-3" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
<figure style="margin:0;text-align:center"><figcaption style="font-size:0.72rem;opacity:0.9">5 FINISH</figcaption><img id="editor-gauntlet-runway-bg-prev-4" width="128" height="72" alt="" style="display:block;width:128px;height:72px;object-fit:cover;border:1px solid #3d3f52;border-radius:6px;background:#1a1b26"></figure>
</div>
<p id="editor-gauntlet-runway-bg-upload-summary" class="legend" style="margin:0.4rem 0 0;line-height:1.5;font-size:0.82rem" aria-live="polite"></p>
</div>
</section>
<section class="editor-card editor-card--help" aria-labelledby="editor-card-help-label">
<h2 class="editor-card__title" id="editor-card-help-label">วิธีใช้บนกริด</h2>
<ul class="editor-help-list legend editor-legend-short">
<li><strong>คลิกซ้าย</strong> = วาด · <strong>คลิกขวา</strong> = ลบ · ลากค้างได้</li>
<li>โหมดหลัก: กำแพง · โซนห้ามซ้อน · โซน interactive</li>
<li><strong>พื้นที่สุ่มจุดเกิด</strong> (ฟ้าอ่อน) — ไม่วาดช่องนี้ได้ ใช้ปุ่ม «ตั้งจุดเกิด»</li>
<li><strong>พื้นที่เริ่มเกม</strong> (ห้องโถง — ส้ม) — host ยืนแล้วกดเริ่ม</li>
<li><strong>ถามตอบ</strong>: โซนถูก/ผิด + พื้นที่คำถาม (ทอง)</li>
<li><strong>หยิบมาวาง</strong>: ม่วง = โซนกลาง (กำแพง) · <strong>ทอง</strong> = พื้นที่โชว์ข้อความคำถามบนแผนที่ (ถ้าไม่วาดทอง ข้อความไปที่โซนม่วง) · <strong>interactive เขียว</strong> = ยืนแล้วกด F ส่งคำตอบ · โหมดวาด <strong>ตัวเลือก 116</strong> = ช่องบนกริดตรงกับลำดับ <code>choices</code> · พรีวิวนับ <strong>3-2-1</strong> ไฮไลต์โซนข้อถูก (หรือใส่ <code>countdownHighlightSlot</code> 116 ต่อข้อใน <code>quizQuestions</code>) · <code>correctIndex</code> / <code>answerTrue</code></li>
<li><strong>Stack</strong>: โหมดวาดจุดปล่อย (ฟ้า) · จุดซ้อนตึก (ชมพู) · ถ้าอัปโหลดรูปทาง FTP ใช้โฟลเดอร์ <code>Game/public/img/TowerBlock</code> (ไม่มีช่องว่าง) — ชื่อ <code>Tower block</code> มี space มักได้ FTP <strong>550</strong></li>
<li><strong>กระโดดให้รอด</strong>: โหมด <strong>แพลตฟอร์ม</strong> (ฟ้าอมเขียว) · โหมด <strong>โซนตาย</strong> (แดง — โดนแล้วตายทันที คงที่ในโลก) · กำแพง = ขอบซ้ายขวา/บน · Space / W = กระโดด</li>
<li><strong>ลูกโป้งยิงบอส (Mega Virus)</strong>: จุดเกิดผู้เล่น P1P6 + <strong>จุดเกิดบอส</strong> 1 ช่อง · ในเกมเดินช้า ยิงมีดีเลย์ · ลูกโป้งหมด = ตกรอบ · อัปโหลดรูปทาง FTP ใช้ <code>Game/public/img/MegaVirus</code> (<strong>ไม่มีช่องว่าง</strong>) — โฟลเดอร์ <code>Mega Virus</code> มี space มักได้ FTP <strong>550</strong></li>
<li><strong>สีช่อง</strong>: เลือกโหมดสี + Alpha แล้วคลิกบนกริด</li>
<li><strong>รูปบนกริด</strong>: โหมด «รูปบนกริด» — ตั้งกว้าง×สูง (ช่อง) · เลือกรูปจากแกลเลอรี · คลิกซ้ายวาง (ทับสไปรต์ที่ทับกัน) · คลิกขาวางลบทั้งแผ่น · บันทึกฉากเก็บเป็น <code>gridImageSprites</code> (ไฟล์ใหญ่ = JSON ใหญ่)</li>
<li>ปิด «โชว์กริดในเกม» = ซ่อนกริด/กำแพงในเกม (รูปพื้นหลังยังแสดง)</li>
</ul>
</section>
</div>
<div class="editor-workspace">
<div class="canvas-wrap">
<canvas id="editor-canvas"></canvas>
<div id="editor-stack-hud-mock" class="is-hidden" aria-hidden="true">
<aside class="editor-stack-hud-mock__score" aria-label="SCORE">
<div class="editor-stack-hud-mock__score-title">SCORE</div>
<ul class="editor-stack-hud-mock__score-list">
<li class="editor-stack-hud-mock__score-row">
<div class="editor-stack-hud-mock__score-left">
<span class="editor-stack-hud-mock__av-wrap"><img class="editor-stack-hud-mock__av" src="/Game/img/characters/1_down.png" alt=""></span>
<span class="editor-stack-hud-mock__name">ผู้เล่นทดสอบ</span>
</div>
<span class="editor-stack-hud-mock__points">0</span>
</li>
</ul>
</aside>
<div class="editor-stack-hud-mock__center">
<div class="editor-stack-hud-mock__time-label">TIME</div>
<div class="editor-stack-hud-mock__time-val">0</div>
<div class="editor-stack-hud-mock__time-sub">TOWER STACK · DECRYPT — TIME (sec) · เหลือเวลาถอดรหัส</div>
</div>
<div class="editor-stack-hud-mock__right">
<div class="editor-stack-hud-mock__integrity">
<div class="editor-stack-hud-mock__integrity-title">SYSTEM INTEGRITY:</div>
<div class="editor-stack-hud-mock__hearts" aria-hidden="true"><span class="editor-stack-hud-mock__bracket">[</span><span class="editor-stack-hud-mock__heart">♥</span><span class="editor-stack-hud-mock__heart">♥</span><span class="editor-stack-hud-mock__heart">♥</span><span class="editor-stack-hud-mock__bracket">]</span></div>
<div class="editor-stack-hud-mock__meta">P1 • ผู้เล่นทดสอบ • TEAM 0 • COMBO x0</div>
</div>
<div class="editor-stack-hud-mock__avatar-col">
<div class="editor-stack-hud-mock__avatar-frame">
<img class="editor-stack-hud-mock__avatar-img" src="/Game/img/characters/1_down.png" alt="">
</div>
<div class="editor-stack-hud-mock__link-chip" title="LINK">O</div>
</div>
</div>
</div>
</div>
<p class="legend" id="editor-status">บันทึกแล้วจะได้รหัสฉาก ใช้รหัสนี้ตอนสร้างห้องในล็อบบี้</p>
</div>
</div>
<script src="js/version.js?v=0.0258"></script>
<script src="js/editor.js?v=0.0335"></script>
<div class="version-tag">v —</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+96
View File
@@ -923,6 +923,7 @@
let quizBattleMcqPool = [];
let quizBattleAnsweredComps = new Set();
let quizBattleModalCompId = null;
let qbAutoLastComp = null; // โดมล่าสุดที่ trigger อัตโนมัติ (กันเด้งซ้ำขณะยืนค้าง)
const SPACE_SHOOTER_SHIP_COLORS = ['#50fa7b', '#ff79c6', '#ff5555', '#f1fa8c', '#bd93f9', '#8be9fd'];
document.getElementById('room-id').textContent = spaceId;
@@ -6285,6 +6286,16 @@
const tx = +p[0], ty = +p[1];
if (!g[ty] || g[ty][tx] !== 1) return false;
}
/* กันเดินลงเกิน 1 ช่อง: เท้า (จุดล่างของตัว ≈ py+0.5) ต้องอยู่บนเส้นทางด้วย → หยุดในครึ่งบนของช่องล่างสุด ไม่ล้ำลงช่องกำแพง */
const h = md.height || 15, w = md.width || 20;
const fy = Math.floor(py + 0.5);
if (fy >= 0 && fy < h) {
const { cw } = getCharacterFootprintWH(md);
const minTx = Math.floor(px), maxTx = Math.min(w - 1, minTx + cw - 1);
for (let tx = Math.max(0, minTx); tx <= maxTx; tx++) {
if (!g[fy] || g[fy][tx] !== 1) return false;
}
}
return true;
}
@@ -6425,6 +6436,9 @@
playLiveQuizScores[myId] = (playLiveQuizScores[myId] || 0) + 1;
renderPlayQuizScoreboard(playLiveQuizScores);
}
/* ส่งให้ server เก็บคะแนน (authoritative) แล้ว broadcast อันดับให้ทุกคน — แบบ ZEP */
try { if (typeof socket !== 'undefined' && socket) socket.emit('quizbattle-solve', { compId }); } catch (e) { /* ignore */ }
updateQuizBattleProgressHud();
flashQuizBattleFeedback('ถูกต้อง · +1', true);
} else {
flashQuizBattleFeedback('ยังไม่ถูก — ลองใหม่ (กด E อีกครั้ง)', false);
@@ -6433,6 +6447,25 @@
hideQuizBattleMcqModal();
}
/** ZEP-style: เดินถึงสถานี (โดม) ที่ยังไม่ตอบ → เปิดคำถามอัตโนมัติ (ครั้งเดียวต่อการเข้า · เดินออกแล้วกลับมาใหม่ถึงเด้งอีก) */
function maybeAutoOpenQuizBattle() {
if (!isQuizBattle() || myId == null) return;
const compNow = getPrimaryQuizBattleDomeCompAt(me.x, me.y);
if (compNow == null) { qbAutoLastComp = null; return; }
const ov = document.getElementById('quiz-battle-mcq-overlay');
const modalOpen = quizBattleModalCompId != null || (ov && !ov.classList.contains('is-hidden'));
if (modalOpen) return;
if (compNow === qbAutoLastComp) return; // เข้าโดมนี้รอบนี้ไปแล้ว
qbAutoLastComp = compNow;
if (quizBattleAnsweredComps.has(compNow)) return; // ตอบถูกแล้ว
const pool = quizBattleMcqPool;
if (!pool.length) return;
const q = pool[(compNow - 1) % pool.length];
if (!q) return;
quizBattleModalCompId = compNow;
showQuizBattleMcqModal(q);
}
function tryOpenQuizBattleFromKey() {
if (!isQuizBattle() || !mapData || myId == null) return;
const compId = getPrimaryQuizBattleDomeCompAt(me.x, me.y);
@@ -6505,6 +6538,57 @@
quizBattleAnsweredComps = new Set();
quizBattleModalCompId = null;
hideQuizBattleMcqModal();
hideQuizBattleProgressHud();
}
/* ===== Quiz Battle ฉากเดิน (ZEP) — Progress + Ranking sync จาก server ===== */
let quizBattleScoresBound = false;
function ensureQuizBattleScoresListener() {
if (quizBattleScoresBound || typeof socket === 'undefined' || !socket) return;
quizBattleScoresBound = true;
socket.on('quizbattle-scores', (data) => {
if (!data || typeof data.scores !== 'object' || !isQuizBattle()) return;
const sc = data.scores || {};
if (!playLiveQuizScores) playLiveQuizScores = {};
/* server เป็นเจ้าของคะแนน (จำนวนโดมที่ปลดได้) — sync ทับของทุกคน เพื่ออันดับตรงกันแบบ ZEP */
Object.keys(sc).forEach((id) => { playLiveQuizScores[id] = sc[id]; });
renderPlayQuizScoreboard(playLiveQuizScores);
updateQuizBattleProgressHud();
});
}
function quizBattleTotalDomes() {
if (!mapData || !isQuizBattle()) return 0;
const comp = mapData.quizBattleDomeComp;
if (!comp) return 0;
let mx = 0;
for (let y = 0; y < comp.length; y++) {
const row = comp[y] || [];
for (let x = 0; x < row.length; x++) if (row[x] > mx) mx = row[x];
}
return mx;
}
function updateQuizBattleProgressHud() {
if (!isQuizBattle()) return;
let el = document.getElementById('quiz-battle-progress-hud');
if (!el) {
el = document.createElement('div');
el.id = 'quiz-battle-progress-hud';
el.style.cssText = 'position:fixed;top:12px;left:12px;z-index:60;background:rgba(8,12,28,0.82);color:#fff;font-family:Kanit,Sarabun,system-ui,sans-serif;font-weight:700;padding:8px 18px;border-radius:999px;border:1px solid rgba(255,255,255,0.2);font-size:18px;box-shadow:0 4px 14px rgba(0,0,0,0.35);pointer-events:none';
document.body.appendChild(el);
}
const total = quizBattleTotalDomes();
const mine = (myId != null && playLiveQuizScores && playLiveQuizScores[myId] != null)
? playLiveQuizScores[myId]
: (quizBattleAnsweredComps ? quizBattleAnsweredComps.size : 0);
el.textContent = 'Progress ' + mine + '/' + total + (total > 0 && mine >= total ? ' ✓' : '');
el.style.color = (total > 0 && mine >= total) ? '#34d399' : '#fff';
}
function hideQuizBattleProgressHud() {
const el = document.getElementById('quiz-battle-progress-hud');
if (el) el.remove();
}
function setupPlayQuizBattleUi() {
@@ -6538,6 +6622,16 @@
if (myId != null) playLiveQuizScores[myId] = 0;
others.forEach((_, id) => { playLiveQuizScores[id] = 0; });
renderPlayQuizScoreboard(playLiveQuizScores);
/* sync คะแนน/อันดับข้ามผู้เล่นจาก server (ZEP) + โชว์ Progress N/total */
ensureQuizBattleScoresListener();
updateQuizBattleProgressHud();
try { if (typeof socket !== 'undefined' && socket) socket.emit('quizbattle-hello', (res) => {
if (res && res.scores && typeof res.scores === 'object') {
Object.keys(res.scores).forEach((id) => { playLiveQuizScores[id] = res.scores[id]; });
renderPlayQuizScoreboard(playLiveQuizScores);
updateQuizBattleProgressHud();
}
}); } catch (e) { /* ignore */ }
loadQuizBattleMcqPool().then(() => {
const q2 = document.getElementById('quiz-game-question');
if (!q2) return;
@@ -11498,6 +11592,7 @@
enforceQuizBattleLaneOnMePlay();
const t = Date.now();
if (t - lastSend > 80) { lastSend = t; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
maybeAutoOpenQuizBattle();
draw();
requestAnimationFrame(tick);
return;
@@ -11548,6 +11643,7 @@
me.isWalking = !!(accX !== 0 || accY !== 0) || playPath.length > 0 || movedThisTick;
const now = Date.now();
if (now - lastSend > 80) { lastSend = now; socket.emit('move', { x: me.x, y: me.y, direction: me.direction }); }
maybeAutoOpenQuizBattle();
draw();
requestAnimationFrame(tick);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ตั้งค่าฉาก Quiz Battle</title>
<style>
* { box-sizing: border-box; }
:root { --bg:#0a0f20; --panel:#141a32cc; --line:#2a3357; --txt:#eef2ff; --accent:#22d3ee; }
html,body { margin:0; height:100%; overflow:hidden; }
body { background:var(--bg); color:var(--txt); font-family:'Kanit','Sarabun','Segoe UI',system-ui,sans-serif; }
/* ===== map เต็มจอ อยู่ข้างหลัง ตรงกลาง ===== */
#workspace {
position:fixed; inset:0; z-index:1;
display:flex; align-items:center; justify-content:center;
overflow:auto; padding:10px;
background:radial-gradient(ellipse at 50% 0,rgba(40,60,120,.22),transparent 60%);
}
.canvas-wrap { position:relative; box-shadow:0 12px 50px rgba(0,0,0,.6); border-radius:6px; overflow:hidden; }
canvas { display:block; cursor:crosshair; touch-action:none; }
/* ===== แถบเครื่องมือลอย (ดัก/พับได้) ===== */
#toolbar { position:fixed; z-index:20; width:288px; max-height:94vh; overflow:auto;
background:var(--panel); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
border:1px solid var(--line); border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,.5);
display:flex; flex-direction:column; gap:12px; padding:14px; transition:opacity .15s, transform .15s; }
/* ตำแหน่งดัก */
#toolbar.dock-right { top:50%; right:14px; left:auto; bottom:auto; transform:translateY(-50%); }
#toolbar.dock-left { top:50%; left:14px; right:auto; bottom:auto; transform:translateY(-50%); }
#toolbar.dock-top { top:14px; left:50%; right:auto; bottom:auto; transform:translateX(-50%); width:min(96vw,720px); flex-direction:row; flex-wrap:wrap; align-items:center; }
#toolbar.dock-bottom { bottom:14px; left:50%; right:auto; top:auto; transform:translateX(-50%); width:min(96vw,720px); flex-direction:row; flex-wrap:wrap; align-items:center; }
#toolbar.dock-top .grow, #toolbar.dock-bottom .grow { flex:1 1 150px; }
#toolbar.dock-top .tools, #toolbar.dock-bottom .tools { grid-template-columns:repeat(4,1fr); }
#toolbar.collapsed { display:none; }
.tb-head { display:flex; align-items:center; gap:8px; }
.tb-title { font-size:15px; font-weight:700; flex:1; white-space:nowrap; }
.tb-head .ico-btn { font-size:15px; }
.ico-btn { border:1px solid var(--line); background:#0d1430; color:var(--txt); border-radius:9px; width:32px; height:32px; display:grid; place-items:center; cursor:pointer; font-size:14px; }
.ico-btn:hover { filter:brightness(1.2); }
.ico-btn.on { border-color:var(--accent); background:rgba(34,211,238,.16); }
.dockset { display:flex; gap:5px; }
.field { display:flex; flex-direction:column; gap:5px; }
.field > span { font-size:11.5px; color:#a9b4dd; font-weight:600; }
select { width:100%; background:#0d1430; color:var(--txt); border:1px solid var(--line); border-radius:10px; padding:9px 10px; font:inherit; font-weight:600; }
.tools { display:grid; grid-template-columns:1fr 1fr; gap:7px; }
.tool { display:flex; flex-direction:column; align-items:center; gap:3px; padding:10px 4px; border:2px solid var(--line); background:#0d1430; color:var(--txt); border-radius:11px; cursor:pointer; font-weight:600; font-size:12.5px; transition:.12s; }
.tool .ic { font-size:20px; line-height:1; }
.tool:hover { border-color:#3b4570; filter:brightness(1.1); }
.tool.active { border-color:var(--accent); background:rgba(34,211,238,.16); box-shadow:0 0 0 1px var(--accent) inset; }
.row { display:flex; gap:7px; } .row > * { flex:1; }
.btn { border:none; border-radius:10px; padding:10px 12px; font:inherit; font-weight:700; cursor:pointer; color:#04121f; background:linear-gradient(180deg,#67e8f9,#22d3ee); white-space:nowrap; }
.btn.sec { background:#1e2747; color:#eef2ff; border:1px solid var(--line); }
.btn:disabled { opacity:.5; cursor:default; }
.stat { font-size:12px; color:#cfd7ff; background:#0d1430; border:1px solid var(--line); border-radius:10px; padding:8px 11px; line-height:1.6; }
.stat b { color:var(--accent); }
.toast { font-size:12.5px; font-weight:600; min-height:16px; }
.toast.ok { color:#6ee7b7; } .toast.err { color:#fca5a5; }
.hint { font-size:11.5px; color:#9fb0e6; line-height:1.5; }
/* ปุ่มเปิดแถบ (ตอนพับ) */
#reopen { position:fixed; z-index:21; top:14px; right:14px; display:none;
border:1px solid var(--line); background:var(--panel); backdrop-filter:blur(8px);
color:var(--txt); border-radius:12px; padding:10px 16px; font:inherit; font-weight:700; cursor:pointer; box-shadow:0 6px 20px rgba(0,0,0,.4); }
#reopen.show { display:block; }
.brushlabel { position:fixed; bottom:12px; left:14px; z-index:15; background:rgba(8,12,28,.85); border:1px solid var(--line); padding:7px 13px; border-radius:999px; font-size:12.5px; font-weight:600; pointer-events:none; }
</style>
</head>
<body>
<!-- map เต็มจอ ข้างหลัง -->
<div id="workspace"><div class="canvas-wrap"><canvas id="cv"></canvas></div></div>
<!-- ปุ่มเปิดแถบเครื่องมือ (โผล่ตอนพับ) -->
<button id="reopen">🧰 เครื่องมือ</button>
<!-- แถบเครื่องมือลอย -->
<div id="toolbar" class="dock-right">
<div class="tb-head">
<span class="tb-title">🗺️ ตั้งค่าฉาก</span>
<div class="dockset" title="ย้ายตำแหน่งแถบ">
<button class="ico-btn" data-dock="top" title="บน"></button>
<button class="ico-btn" data-dock="left" title="ซ้าย"></button>
<button class="ico-btn" data-dock="right" title="ขวา"></button>
<button class="ico-btn" data-dock="bottom" title="ล่าง"></button>
</div>
<button class="ico-btn" id="btn-collapse" title="พับเก็บ"></button>
</div>
<div class="field grow"><span>ห้อง (หัวข้อ)</span><select id="room-select"></select></div>
<div class="field grow">
<span>เครื่องมือ — คลิก/ลากบนภาพ</span>
<div class="tools">
<button class="tool active" data-tool="path"><span class="ic">🛣️</span>เส้นทาง</button>
<button class="tool" data-tool="dome"><span class="ic"></span>จุดคำถาม</button>
<button class="tool" data-tool="spawn"><span class="ic">🚩</span>จุดเกิด</button>
<button class="tool" data-tool="erase"><span class="ic">🧽</span>ยางลบ</button>
</div>
</div>
<div class="field"><span>ขนาดพู่กัน</span>
<select id="brush"><option value="1">1×1</option><option value="2" selected>2×2</option><option value="3">3×3</option></select>
</div>
<div class="stat grow" id="stat"></div>
<div class="row">
<button class="btn sec" id="btn-clear-path">ล้างเส้นทาง</button>
<button class="btn sec" id="btn-clear-dome">ล้างจุดคำถาม</button>
</div>
<button class="btn grow" id="btn-save">💾 บันทึก</button>
<button class="btn sec grow" id="btn-test">▶ ทดสอบเล่น</button>
<div class="toast" id="toast"></div>
<div class="hint">วางจุดคำถามให้ตรงแท่นในภาพ · ลากเส้นทางจากจุดเกิดด้านบนลงไปถึงปลายล่าง · ไม่วาดเส้นทาง = เดินอิสระ</div>
</div>
<div class="brushlabel" id="brushlabel">🛣️ เส้นทาง</div>
<script>
(function(){
'use strict';
var BASE = '/Game';
var ROOMS = ['กฎหมายใกล้ตัว','กฎหมายสิทธิ์พื้นฐาน','กฎหมายจราจร','คดีเกี่ยวกับทรัพย์','คดีหมิ่นประมาท','คดีทางเพศ','คดีอาชญากรรม','อาชญากรรมออนไลน์','กระบวนการยุติธรรม','งานบริการกระทรวงยุติธรรม'];
var qs = new URLSearchParams(location.search);
var curId = qs.get('id') || 'qbroom1';
var map=null, W=24, H=49, path=[], dome=[], spawn={x:11,y:2};
var bgImg=new Image(), tool='path', brush=2, painting=false, paintVal=1;
var cv=document.getElementById('cv'), ctx=cv.getContext('2d');
var toastEl=document.getElementById('toast'), statEl=document.getElementById('stat'), brushLabel=document.getElementById('brushlabel');
var toolbar=document.getElementById('toolbar'), reopen=document.getElementById('reopen');
function toast(m,ok){ toastEl.textContent=m||''; toastEl.className='toast '+(ok===false?'err':ok===true?'ok':''); }
function emptyGrid(w,h){ var a=[]; for(var y=0;y<h;y++){var r=[];for(var x=0;x<w;x++)r.push(0);a.push(r);} return a; }
function norm(src,w,h){ var a=emptyGrid(w,h); if(Array.isArray(src)){for(var y=0;y<h;y++){var row=src[y]||[];for(var x=0;x<w;x++)a[y][x]=row[x]===1?1:0;}} return a; }
// ===== แถบเครื่องมือ: ดัก + พับ (จำค่าใน localStorage) =====
function setDock(d){
toolbar.className = toolbar.className.replace(/dock-\w+/g,'').trim();
toolbar.classList.add('dock-'+d);
document.querySelectorAll('.dockset .ico-btn').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-dock')===d); });
try{ localStorage.setItem('qbEdDock', d); }catch(e){}
setTimeout(layout,0);
}
document.querySelectorAll('.dockset .ico-btn').forEach(function(b){ b.addEventListener('click',function(){ setDock(b.getAttribute('data-dock')); }); });
function setCollapsed(c){
toolbar.classList.toggle('collapsed', c);
reopen.classList.toggle('show', c);
try{ localStorage.setItem('qbEdCollapsed', c?'1':'0'); }catch(e){}
}
document.getElementById('btn-collapse').addEventListener('click',function(){ setCollapsed(true); });
reopen.addEventListener('click',function(){ setCollapsed(false); });
// restore prefs
var savedDock='right'; try{ savedDock=localStorage.getItem('qbEdDock')||'right'; }catch(e){}
setDock(savedDock);
try{ if(localStorage.getItem('qbEdCollapsed')==='1') setCollapsed(true); }catch(e){}
// room dropdown
var sel=document.getElementById('room-select');
for(var i=1;i<=10;i++){ var o=document.createElement('option'); o.value='qbroom'+i; o.textContent='ห้อง '+i+' — '+ROOMS[i-1]; sel.appendChild(o); }
sel.value=curId;
sel.addEventListener('change',function(){ location.search='?id='+encodeURIComponent(sel.value); });
// tools
document.querySelectorAll('.tool').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.tool').forEach(function(x){x.classList.remove('active');});
b.classList.add('active'); tool=b.getAttribute('data-tool');
brushLabel.textContent = b.querySelector('.ic').textContent+' '+b.textContent.trim();
});
});
document.getElementById('brush').addEventListener('change',function(e){ brush=parseInt(e.target.value,10)||1; });
function load(){
toast('กำลังโหลดฉาก…');
fetch(BASE+'/api/maps/'+encodeURIComponent(curId)+'?_='+Date.now(),{cache:'no-store'})
.then(function(r){ if(!r.ok) throw new Error('โหลดฉากไม่ได้ ('+r.status+')'); return r.json(); })
.then(function(m){
map=m; W=m.width||24; H=m.height||49;
path=norm(m.quizBattlePathArea,W,H); dome=norm(m.quizBattleDomeArea,W,H);
spawn=(m.spawn&&typeof m.spawn.x==='number')?{x:m.spawn.x,y:m.spawn.y}:{x:11,y:2};
if(m.backgroundImage){ bgImg=new Image(); bgImg.onload=draw; bgImg.onerror=draw; bgImg.src=m.backgroundImage+(m.backgroundImage.indexOf('?')<0?('?_='+Date.now()):''); }
toast('โหลด "'+(m.name||curId)+'" แล้ว',true); layout();
})
.catch(function(e){ toast(e.message,false); });
}
function layout(){
var maxH=Math.max(300,window.innerHeight-24);
var maxW=Math.max(200,window.innerWidth-24);
var cell=Math.min(maxH/H, maxW/W); cell=Math.max(6,Math.floor(cell));
cv.width=W*cell; cv.height=H*cell; cv._cell=cell; draw();
}
function draw(){
var cell=cv._cell, w=cv.width, h=cv.height;
ctx.clearRect(0,0,w,h);
if(bgImg&&bgImg.complete&&bgImg.naturalWidth) ctx.drawImage(bgImg,0,0,w,h);
else { ctx.fillStyle='#10162e'; ctx.fillRect(0,0,w,h); }
ctx.strokeStyle='rgba(255,255,255,.06)'; ctx.lineWidth=1;
for(var x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*cell+.5,0); ctx.lineTo(x*cell+.5,h); ctx.stroke(); }
for(var y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*cell+.5); ctx.lineTo(w,y*cell+.5); ctx.stroke(); }
ctx.fillStyle='rgba(122,162,247,.42)';
for(var py=0;py<H;py++) for(var px=0;px<W;px++) if(path[py][px]) ctx.fillRect(px*cell,py*cell,cell,cell);
var num=0;
for(var dy2=0;dy2<H;dy2++) for(var dx2=0;dx2<W;dx2++) if(dome[dy2][dx2]){
num++; var cx=dx2*cell+cell/2, cy=dy2*cell+cell/2, r=Math.max(8,cell*0.62);
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fillStyle='rgba(255,209,102,.92)'; ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='#7a5a10'; ctx.stroke();
ctx.fillStyle='#3a2c00'; ctx.font='bold '+Math.max(10,cell*0.7)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(String(num),cx,cy+1);
}
var sx=spawn.x*cell, sy=spawn.y*cell;
ctx.fillStyle='rgba(52,211,153,.9)'; ctx.fillRect(sx,sy,cell,cell);
ctx.fillStyle='#04241a'; ctx.font='bold '+Math.max(10,cell*0.6)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('🚩',sx+cell/2,sy+cell/2);
var np=0,nd=0; for(var a=0;a<H;a++) for(var b=0;b<W;b++){ if(path[a][b])np++; if(dome[a][b])nd++; }
statEl.innerHTML='ฉาก: <b>'+(map&&map.name||curId)+'</b><br>เส้นทาง <b>'+np+'</b> ช่อง · จุดคำถาม <b>'+nd+'</b> · เกิด ('+spawn.x+','+spawn.y+')';
}
function tileAt(ev){
var rect=cv.getBoundingClientRect();
var px=(ev.clientX!=null?ev.clientX:(ev.touches&&ev.touches[0].clientX))-rect.left;
var py=(ev.clientY!=null?ev.clientY:(ev.touches&&ev.touches[0].clientY))-rect.top;
var cell=cv._cell; return { x:Math.floor(px/cell), y:Math.floor(py/cell) };
}
function applyAt(tx,ty){
if(tx<0||ty<0||tx>=W||ty>=H) return;
if(tool==='spawn'){ spawn={x:tx,y:ty}; return; }
var b=brush, half=Math.floor(b/2);
for(var dy=0;dy<b;dy++) for(var dx=0;dx<b;dx++){
var x=tx+dx-half, y=ty+dy-half; if(x<0||y<0||x>=W||y>=H) continue;
if(tool==='path') path[y][x]=paintVal;
else if(tool==='dome') dome[y][x]=paintVal;
else if(tool==='erase'){ path[y][x]=0; dome[y][x]=0; }
}
}
function onDown(ev){
ev.preventDefault(); var t=tileAt(ev);
if(tool==='path') paintVal=(path[t.y]&&path[t.y][t.x])?0:1;
else if(tool==='dome') paintVal=(dome[t.y]&&dome[t.y][t.x])?0:1;
else paintVal=1;
painting=true; applyAt(t.x,t.y); draw();
}
function onMove(ev){ if(!painting) return; ev.preventDefault(); var t=tileAt(ev); applyAt(t.x,t.y); draw(); }
function onUp(){ painting=false; }
cv.addEventListener('mousedown',onDown);
window.addEventListener('mousemove',onMove);
window.addEventListener('mouseup',onUp);
cv.addEventListener('touchstart',onDown,{passive:false});
cv.addEventListener('touchmove',onMove,{passive:false});
window.addEventListener('touchend',onUp);
window.addEventListener('resize',layout);
document.getElementById('btn-clear-path').addEventListener('click',function(){ path=emptyGrid(W,H); draw(); toast('ล้างเส้นทางแล้ว',true); });
document.getElementById('btn-clear-dome').addEventListener('click',function(){ dome=emptyGrid(W,H); draw(); toast('ล้างจุดคำถามแล้ว',true); });
document.getElementById('btn-save').addEventListener('click',function(){
var btn=this; btn.disabled=true; toast('กำลังบันทึก…');
fetch(BASE+'/api/maps/'+encodeURIComponent(curId),{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ quizBattlePathArea:path, quizBattleDomeArea:dome, spawn:spawn, width:W, height:H })})
.then(function(r){return r.json();}).then(function(res){ btn.disabled=false;
if(res&&res.ok) toast('บันทึก "'+curId+'" สำเร็จ ✓',true); else toast((res&&res.error)||'บันทึกไม่สำเร็จ',false);
}).catch(function(e){ btn.disabled=false; toast('บันทึกไม่สำเร็จ: '+e.message,false); });
});
document.getElementById('btn-test').addEventListener('click',function(){
toast('กำลังเปิดห้องทดสอบ…');
fetch(BASE+'/api/spaces',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({ mapId:curId, name:'TEST '+curId+' '+Date.now(), maxPlayers:50 })})
.then(function(r){return r.json();}).then(function(res){
if(res&&res.ok){ window.open(BASE+'/play.html?space='+encodeURIComponent(res.spaceId)+'&map='+encodeURIComponent(curId)+'&nick=Tester','_blank'); toast('เปิดแท็บทดสอบแล้ว',true); }
else toast((res&&res.error)||'เปิดทดสอบไม่ได้',false);
}).catch(function(e){ toast(e.message,false); });
});
load();
})();
</script>
</body>
</html>
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ตั้งค่าฉาก Quiz Battle — ง่าย ๆ</title>
<style>
* { box-sizing: border-box; }
:root { --bg:#0b1024; --panel:#141a32; --line:#2a3357; --txt:#eef2ff; --accent:#22d3ee; --path:#7aa2f7; --dome:#ffd166; }
html,body { margin:0; height:100%; }
body { background:var(--bg); color:var(--txt); font-family:'Kanit','Sarabun','Segoe UI',system-ui,sans-serif; display:flex; height:100vh; overflow:hidden; }
/* ซ้าย = แผง เครื่องมือ */
.side { width:300px; min-width:300px; background:var(--panel); border-right:1px solid var(--line); display:flex; flex-direction:column; gap:14px; padding:16px; overflow-y:auto; }
.side h1 { font-size:18px; margin:0 0 2px; }
.side .hint { font-size:12.5px; color:#a9b4dd; line-height:1.5; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-size:12px; color:#a9b4dd; font-weight:600; }
select, button { font:inherit; }
select { background:#0d1430; color:var(--txt); border:1px solid var(--line); border-radius:10px; padding:10px 12px; font-weight:600; }
.tools { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.tool { display:flex; flex-direction:column; align-items:center; gap:4px; padding:12px 6px; border:2px solid var(--line); background:#0d1430; color:var(--txt); border-radius:12px; cursor:pointer; font-weight:600; font-size:13px; transition:.12s; }
.tool .ic { font-size:22px; line-height:1; }
.tool:hover { border-color:#3b4570; filter:brightness(1.1); }
.tool.active { border-color:var(--accent); background:rgba(34,211,238,.14); box-shadow:0 0 0 1px var(--accent) inset; }
.row { display:flex; gap:8px; }
.row > * { flex:1; }
.btn { border:none; border-radius:10px; padding:11px 12px; font-weight:700; cursor:pointer; color:#04121f; background:linear-gradient(180deg,#67e8f9,#22d3ee); }
.btn.sec { background:#1e2747; color:#eef2ff; border:1px solid var(--line); }
.btn.warn { background:rgba(239,68,68,.18); color:#fecaca; border:1px solid rgba(239,68,68,.4); }
.btn:disabled { opacity:.5; cursor:default; }
.stat { font-size:12.5px; color:#cfd7ff; background:#0d1430; border:1px solid var(--line); border-radius:10px; padding:10px 12px; line-height:1.7; }
.stat b { color:var(--accent); }
.legend { font-size:12px; color:#9fb0e6; line-height:1.6; }
.legend .sw { display:inline-block; width:12px; height:12px; border-radius:3px; vertical-align:-1px; margin-right:5px; }
.toast { font-size:13px; font-weight:600; min-height:18px; }
.toast.ok { color:#6ee7b7; } .toast.err { color:#fca5a5; }
/* ขวา = ผืนผ้าใบฉาก */
.stage { flex:1; display:flex; align-items:center; justify-content:center; overflow:auto; padding:18px; background:radial-gradient(ellipse at 50% 0,rgba(40,60,120,.25),transparent 60%); }
.canvas-wrap { position:relative; box-shadow:0 10px 40px rgba(0,0,0,.5); border-radius:8px; overflow:hidden; }
canvas { display:block; cursor:crosshair; touch-action:none; }
.brushlabel { position:fixed; bottom:14px; right:18px; background:rgba(8,12,28,.85); border:1px solid var(--line); padding:8px 14px; border-radius:999px; font-size:13px; font-weight:600; pointer-events:none; }
</style>
</head>
<body>
<div class="side">
<div>
<h1>🗺️ ตั้งค่าฉาก Quiz Battle</h1>
<div class="hint">เลือกห้อง → เลือกเครื่องมือ → <b>คลิกหรือลากบนภาพ</b> เพื่อวาดเส้นทาง/วางจุดคำถาม แล้วกด <b>บันทึก</b></div>
</div>
<div class="field">
<label>เลือกห้อง (หัวข้อ)</label>
<select id="room-select"></select>
</div>
<div class="field">
<label>เครื่องมือ</label>
<div class="tools" id="tools">
<button class="tool active" data-tool="path"><span class="ic">🛣️</span>เส้นทางเดิน</button>
<button class="tool" data-tool="dome"><span class="ic">❓</span>จุดคำถาม</button>
<button class="tool" data-tool="spawn"><span class="ic">🚩</span>จุดเกิด</button>
<button class="tool" data-tool="erase"><span class="ic">🧽</span>ยางลบ</button>
</div>
</div>
<div class="field">
<label>ขนาดพู่กัน (วาดทีละกี่ช่อง)</label>
<select id="brush"><option value="1">1×1</option><option value="2" selected>2×2</option><option value="3">3×3</option></select>
</div>
<div class="stat" id="stat">—</div>
<div class="legend">
<span class="sw" style="background:rgba(122,162,247,.6)"></span>เส้นทางเดิน &nbsp;
<span class="sw" style="background:#ffd166"></span>จุดคำถาม &nbsp;
<span class="sw" style="background:#34d399"></span>จุดเกิด<br>
ถ้าไม่วาดเส้นทางเลย = เดินได้อิสระทั้งฉาก
</div>
<div class="row">
<button class="btn sec" id="btn-clear-path">ล้างเส้นทาง</button>
<button class="btn sec" id="btn-clear-dome">ล้างจุดคำถาม</button>
</div>
<button class="btn" id="btn-save">💾 บันทึกฉากนี้</button>
<button class="btn sec" id="btn-test">▶ เปิดทดสอบเล่น (แท็บใหม่)</button>
<div class="toast" id="toast"></div>
<div class="hint">เคล็ดลับ: วางจุดคำถามให้ตรงแท่นเรืองแสงในภาพ และลากเส้นทางเชื่อมจากจุดเกิดด้านบนลงไปถึงปลายล่าง</div>
</div>
<div class="stage">
<div class="canvas-wrap" id="wrap">
<canvas id="cv"></canvas>
</div>
</div>
<div class="brushlabel" id="brushlabel">เครื่องมือ: เส้นทางเดิน</div>
<script>
(function(){
'use strict';
var BASE = '/Game';
var ROOMS = [
'กฎหมายใกล้ตัว','กฎหมายสิทธิ์พื้นฐาน','กฎหมายจราจร','คดีเกี่ยวกับทรัพย์','คดีหมิ่นประมาท',
'คดีทางเพศ','คดีอาชญากรรม','อาชญากรรมออนไลน์','กระบวนการยุติธรรม','งานบริการกระทรวงยุติธรรม'
];
var qs = new URLSearchParams(location.search);
var curId = qs.get('id') || 'qbroom1';
var map = null, W = 24, H = 49;
var path = [], dome = [], spawn = { x: 11, y: 2 };
var bgImg = new Image();
var tool = 'path', brush = 2, painting = false, paintVal = 1;
var cv = document.getElementById('cv'), ctx = cv.getContext('2d');
var wrap = document.getElementById('wrap');
var toastEl = document.getElementById('toast');
var statEl = document.getElementById('stat');
var brushLabel = document.getElementById('brushlabel');
function toast(msg, ok){ toastEl.textContent = msg||''; toastEl.className = 'toast ' + (ok===false?'err':ok===true?'ok':''); }
function emptyGrid(w,h){ var a=[]; for(var y=0;y<h;y++){var r=[];for(var x=0;x<w;x++)r.push(0);a.push(r);} return a; }
function norm(src,w,h){ var a=emptyGrid(w,h); if(Array.isArray(src)){for(var y=0;y<h;y++){var row=src[y]||[];for(var x=0;x<w;x++)a[y][x]=row[x]===1?1:0;}} return a; }
// room dropdown
var sel = document.getElementById('room-select');
for (var i=1;i<=10;i++){ var o=document.createElement('option'); o.value='qbroom'+i; o.textContent='ห้อง '+i+' — '+ROOMS[i-1]; sel.appendChild(o); }
sel.value = curId;
sel.addEventListener('change', function(){ location.search = '?id=' + encodeURIComponent(sel.value); });
// tools
Array.prototype.forEach.call(document.querySelectorAll('.tool'), function(b){
b.addEventListener('click', function(){
document.querySelectorAll('.tool').forEach(function(x){x.classList.remove('active');});
b.classList.add('active'); tool = b.getAttribute('data-tool');
brushLabel.textContent = 'เครื่องมือ: ' + b.textContent.trim();
});
});
document.getElementById('brush').addEventListener('change', function(e){ brush = parseInt(e.target.value,10)||1; });
function load(){
toast('กำลังโหลดฉาก…');
fetch(BASE + '/api/maps/' + encodeURIComponent(curId) + '?_=' + Date.now(), { cache:'no-store' })
.then(function(r){ if(!r.ok) throw new Error('โหลดฉากไม่ได้ ('+r.status+')'); return r.json(); })
.then(function(m){
map = m; W = m.width||24; H = m.height||49;
path = norm(m.quizBattlePathArea, W, H);
dome = norm(m.quizBattleDomeArea, W, H);
spawn = (m.spawn && typeof m.spawn.x==='number') ? { x:m.spawn.x, y:m.spawn.y } : { x:11, y:2 };
if (m.backgroundImage){ bgImg = new Image(); bgImg.onload = layout; bgImg.onerror = layout; bgImg.src = m.backgroundImage + (m.backgroundImage.indexOf('?')<0?('?_='+Date.now()):''); }
else { layout(); }
toast('โหลดฉาก "' + (m.name||curId) + '" แล้ว', true);
layout();
})
.catch(function(e){ toast(e.message, false); });
}
function layout(){
// ผ้าใบสูงเท่าพื้นที่ว่าง คงสัดส่วน W:H
var maxH = Math.max(300, window.innerHeight - 36);
var maxW = Math.max(200, window.innerWidth - 300 - 56);
var cell = Math.min(maxH / H, maxW / W);
cell = Math.max(6, Math.floor(cell));
cv.width = W * cell; cv.height = H * cell;
cv._cell = cell;
draw();
}
function draw(){
var cell = cv._cell, w = cv.width, h = cv.height;
ctx.clearRect(0,0,w,h);
if (bgImg && bgImg.complete && bgImg.naturalWidth){ ctx.drawImage(bgImg, 0,0, w,h); }
else { ctx.fillStyle='#10162e'; ctx.fillRect(0,0,w,h); }
// grid
ctx.strokeStyle='rgba(255,255,255,.06)'; ctx.lineWidth=1;
for(var x=0;x<=W;x++){ ctx.beginPath(); ctx.moveTo(x*cell+.5,0); ctx.lineTo(x*cell+.5,h); ctx.stroke(); }
for(var y=0;y<=H;y++){ ctx.beginPath(); ctx.moveTo(0,y*cell+.5); ctx.lineTo(w,y*cell+.5); ctx.stroke(); }
// path
ctx.fillStyle='rgba(122,162,247,.42)';
for(var py=0;py<H;py++) for(var px=0;px<W;px++) if(path[py][px]) ctx.fillRect(px*cell, py*cell, cell, cell);
// domes (numbered stations) — เรียงเลขตามลำดับ comp อย่างง่าย (ไล่จากบนลงล่าง ซ้ายไปขวา)
var num=0;
for(var dy=0;dy<H;dy++) for(var dx=0;dx<W;dx++) if(dome[dy][dx]){
num++;
var cx=dx*cell+cell/2, cy=dy*cell+cell/2, r=Math.max(8,cell*0.62);
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.fillStyle='rgba(255,209,102,.92)'; ctx.fill();
ctx.lineWidth=2; ctx.strokeStyle='#7a5a10'; ctx.stroke();
ctx.fillStyle='#3a2c00'; ctx.font='bold '+Math.max(10,cell*0.7)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(String(num), cx, cy+1);
}
// spawn
var sx=spawn.x*cell, sy=spawn.y*cell;
ctx.fillStyle='rgba(52,211,153,.9)'; ctx.fillRect(sx, sy, cell, cell);
ctx.fillStyle='#04241a'; ctx.font='bold '+Math.max(10,cell*0.6)+'px Kanit,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('🚩', sx+cell/2, sy+cell/2);
// stats
var np=0, nd=0;
for(var i=0;i<H;i++) for(var j=0;j<W;j++){ if(path[i][j])np++; if(dome[i][j])nd++; }
statEl.innerHTML = 'ฉาก: <b>'+(map&&map.name||curId)+'</b><br>เส้นทาง: <b>'+np+'</b> ช่อง · จุดคำถาม: <b>'+nd+'</b> จุด<br>จุดเกิด: ('+spawn.x+','+spawn.y+')';
}
function tileAt(ev){
var rect = cv.getBoundingClientRect();
var px = (ev.clientX!=null?ev.clientX:(ev.touches&&ev.touches[0].clientX)) - rect.left;
var py = (ev.clientY!=null?ev.clientY:(ev.touches&&ev.touches[0].clientY)) - rect.top;
var cell = cv._cell;
return { x: Math.floor(px/cell), y: Math.floor(py/cell) };
}
function applyAt(tx,ty){
if(tx<0||ty<0||tx>=W||ty>=H) return;
if(tool==='spawn'){ spawn={x:tx,y:ty}; return; }
var b = (tool==='spawn')?1:brush, half=Math.floor(b/2);
for(var dy=0; dy<b; dy++) for(var dx=0; dx<b; dx++){
var x=tx+dx-half, y=ty+dy-half;
if(x<0||y<0||x>=W||y>=H) continue;
if(tool==='path') path[y][x]=paintVal;
else if(tool==='dome') dome[y][x]=paintVal;
else if(tool==='erase'){ path[y][x]=0; dome[y][x]=0; }
}
}
function onDown(ev){
ev.preventDefault();
var t = tileAt(ev);
if(tool==='path') paintVal = path[t.y] && path[t.y][t.x] ? 0 : 1; // คลิกซ้ำ = ลบ (toggle)
else if(tool==='dome') paintVal = dome[t.y] && dome[t.y][t.x] ? 0 : 1;
else paintVal = 1;
painting = true; applyAt(t.x,t.y); draw();
}
function onMove(ev){ if(!painting) return; ev.preventDefault(); var t=tileAt(ev); applyAt(t.x,t.y); draw(); }
function onUp(){ painting=false; }
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', onDown, {passive:false});
cv.addEventListener('touchmove', onMove, {passive:false});
window.addEventListener('touchend', onUp);
window.addEventListener('resize', layout);
document.getElementById('btn-clear-path').addEventListener('click', function(){ path=emptyGrid(W,H); draw(); toast('ล้างเส้นทางแล้ว',true); });
document.getElementById('btn-clear-dome').addEventListener('click', function(){ dome=emptyGrid(W,H); draw(); toast('ล้างจุดคำถามแล้ว',true); });
document.getElementById('btn-save').addEventListener('click', function(){
var btn=this; btn.disabled=true; toast('กำลังบันทึก…');
fetch(BASE + '/api/maps/' + encodeURIComponent(curId), {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ quizBattlePathArea: path, quizBattleDomeArea: dome, spawn: spawn, width: W, height: H })
}).then(function(r){ return r.json(); }).then(function(res){
btn.disabled=false;
if(res && res.ok) toast('บันทึกฉาก "'+curId+'" สำเร็จ ✓ (เข้าเล่นได้เลย)', true);
else toast((res&&res.error)||'บันทึกไม่สำเร็จ', false);
}).catch(function(e){ btn.disabled=false; toast('บันทึกไม่สำเร็จ: '+e.message, false); });
});
document.getElementById('btn-test').addEventListener('click', function(){
toast('กำลังเปิดห้องทดสอบ…');
fetch(BASE + '/api/spaces', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mapId: curId, name: 'TEST '+curId+' '+Date.now(), maxPlayers: 50 }) })
.then(function(r){return r.json();}).then(function(res){
if(res&&res.ok){ window.open(BASE+'/play.html?space='+encodeURIComponent(res.spaceId)+'&map='+encodeURIComponent(curId)+'&nick=Tester','_blank'); toast('เปิดแท็บทดสอบแล้ว',true); }
else toast((res&&res.error)||'เปิดทดสอบไม่ได้',false);
}).catch(function(e){ toast(e.message,false); });
});
load();
})();
</script>
</body>
</html>
+355 -1
View File
@@ -4777,7 +4777,9 @@ const server = http.createServer((req, res) => {
const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid);
let maxPlayers = parseInt(d.maxPlayers, 10);
if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10;
maxPlayers = Math.min(10, Math.max(1, maxPlayers));
/* Quiz Battle (ฉากเดินตอบ แบบ ZEP) รองรับได้ถึง 50 คน · โหมดอื่นคงเพดานเดิม 10 */
const mpCap = mapData.gameType === 'quiz_battle' ? 50 : 10;
maxPlayers = Math.min(mpCap, Math.max(1, maxPlayers));
if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6);
const mapId = d.mapId ? String(d.mapId).trim() : null;
const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview';
@@ -5866,7 +5868,356 @@ function spaceAllowsQuizCarryLobbyRelaxed(space) {
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
}
/* ============================================================
* Quiz Battle (Kahoot/ZEP-style) องตอบคำถามพรอมก บไดงส 50 คน
* 10 อง · auto-start (กด Start แลวนบถอยหลงเรมเอง) · คำถามจาก battleQuizMcq
* แยกจากระบบ spaces (โลกเด) ใช io เดยวก · event นต qb-*
* ============================================================ */
const QB_ROOM_COUNT = 10;
const QB_MAX_PLAYERS = 50;
const QB_QUESTIONS_PER_ROUND = 10; // จำนวนข้อต่อรอบ (สุ่มจาก pool battleQuizMcq)
const QB_ANSWER_MS = 20000; // เวลาตอบต่อข้อ
const QB_REVEAL_MS = 4000; // โชว์เฉลย + leaderboard ก่อนข้อต่อไป
const QB_COUNTDOWN_MS = 5000; // นับถอยหลังก่อนเริ่ม (auto-start)
const QB_BASE_POINTS = 1000; // คะแนนเต็มต่อข้อ (ตอบถูก + เร็วสุด)
const QB_STREAK_BONUS = 100; // โบนัสต่อ streak ตอบถูกติดกัน
const QB_MIN_PLAYERS_TO_START = 1; // ขั้นต่ำที่กด Start ได้
const QB_END_LINGER_MS = 12000; // โชว์อันดับจบเกมก่อนรีเซ็ตห้องกลับ lobby
const QB_LOBBY_WATCH_ROOM = 'qb-lobby-watchers';
const qbRooms = new Map(); // roomId -> room state
function qbRoomId(n) { return 'qb-' + n; }
function qbCreateRoom(n) {
return {
n,
id: qbRoomId(n),
status: 'lobby', // lobby | countdown | question | reveal | ended
players: new Map(), // socketId -> player
questions: [],
qIndex: -1,
qStartAt: 0,
qEndsAt: 0,
timers: [],
};
}
function qbGetRoom(n) {
const id = qbRoomId(n);
let r = qbRooms.get(id);
if (!r) { r = qbCreateRoom(n); qbRooms.set(id, r); }
return r;
}
function qbClearTimers(room) {
if (Array.isArray(room.timers)) room.timers.forEach((t) => clearTimeout(t));
room.timers = [];
}
function qbCountsSnapshot() {
const rooms = [];
for (let n = 1; n <= QB_ROOM_COUNT; n++) {
const r = qbRooms.get(qbRoomId(n));
rooms.push({ n, count: r ? r.players.size : 0, status: r ? r.status : 'lobby' });
}
return rooms;
}
function qbBroadcastCounts() {
const payload = { rooms: qbCountsSnapshot() };
io.to(QB_LOBBY_WATCH_ROOM).emit('qb-counts', payload);
for (let n = 1; n <= QB_ROOM_COUNT; n++) {
const r = qbRooms.get(qbRoomId(n));
if (r && r.players.size) io.to(r.id).emit('qb-counts', payload);
}
}
function qbPlayerList(room) {
return [...room.players.values()]
.map((p) => ({ id: p.id, name: p.name, score: p.score }))
.sort((a, b) => b.score - a.score);
}
function qbLeaderboard(room) {
return qbPlayerList(room).map((p, i) => ({ rank: i + 1, id: p.id, name: p.name, score: p.score }));
}
function qbEmitRoomState(room) {
io.to(room.id).emit('qb-room-state', {
n: room.n,
status: room.status,
max: QB_MAX_PLAYERS,
players: qbPlayerList(room),
});
}
function qbShuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** หยิบคำถามจาก battleQuizMcq (อ่านสด) → สุ่ม → ตัด N ข้อ · คืน [] ถ้าไม่มีคำถาม */
function qbPickQuestions() {
let pool = [];
try { pool = loadQuizSettings().battleQuizMcq || []; } catch (e) { pool = []; }
pool = pool.filter((q) => q && q.text && Array.isArray(q.choices) && q.choices.length >= 2);
if (!pool.length) return [];
return qbShuffle(pool).slice(0, QB_QUESTIONS_PER_ROUND);
}
function qbResetRoom(room) {
qbClearTimers(room);
room.status = 'lobby';
room.questions = [];
room.qIndex = -1;
for (const p of room.players.values()) {
p.score = 0;
p.streak = 0;
p.answeredIndex = -1;
}
io.to(room.id).emit('qb-reset', {});
qbEmitRoomState(room);
qbBroadcastCounts();
}
function qbStartCountdown(room) {
if (room.status !== 'lobby') return { ok: false, error: 'เกมเริ่มไปแล้ว' };
if (room.players.size < QB_MIN_PLAYERS_TO_START) return { ok: false, error: 'ผู้เล่นไม่พอ' };
const questions = qbPickQuestions();
if (!questions.length) return { ok: false, error: 'ยังไม่มีคำถามในคลัง (เพิ่มที่หน้า Admin)' };
qbClearTimers(room);
room.questions = questions;
room.qIndex = -1;
room.status = 'countdown';
for (const p of room.players.values()) { p.score = 0; p.streak = 0; p.answeredIndex = -1; }
const endsAt = Date.now() + QB_COUNTDOWN_MS;
io.to(room.id).emit('qb-countdown', { endsAt, ms: QB_COUNTDOWN_MS, totalQuestions: questions.length });
qbBroadcastCounts();
room.timers.push(setTimeout(() => qbNextQuestion(room), QB_COUNTDOWN_MS));
return { ok: true };
}
function qbNextQuestion(room) {
qbClearTimers(room);
room.qIndex += 1;
if (room.qIndex >= room.questions.length) { qbEndGame(room); return; }
const q = room.questions[room.qIndex];
room.status = 'question';
room.qStartAt = Date.now();
room.qEndsAt = room.qStartAt + QB_ANSWER_MS;
for (const p of room.players.values()) { p.answeredIndex = -1; p.answeredAt = 0; p.lastDelta = 0; p.lastCorrect = false; }
io.to(room.id).emit('qb-question', {
index: room.qIndex,
total: room.questions.length,
text: q.text,
choices: q.choices,
endsAt: room.qEndsAt,
durationMs: QB_ANSWER_MS,
});
io.to(room.id).emit('qb-answered-count', { answered: 0, total: room.players.size });
room.timers.push(setTimeout(() => qbReveal(room), QB_ANSWER_MS));
}
function qbAllAnswered(room) {
for (const p of room.players.values()) { if (p.answeredIndex < 0) return false; }
return room.players.size > 0;
}
function qbReveal(room) {
qbClearTimers(room);
if (room.status !== 'question') return;
room.status = 'reveal';
const q = room.questions[room.qIndex];
const correctIndex = q.correctIndex;
const results = [...room.players.values()].map((p) => ({
id: p.id, name: p.name, correct: !!p.lastCorrect, delta: p.lastDelta || 0, score: p.score, choice: p.answeredIndex,
}));
const isLast = room.qIndex >= room.questions.length - 1;
io.to(room.id).emit('qb-reveal', {
index: room.qIndex,
correctIndex,
results,
leaderboard: qbLeaderboard(room),
isLast,
nextInMs: QB_REVEAL_MS,
});
room.timers.push(setTimeout(() => qbNextQuestion(room), QB_REVEAL_MS));
}
function qbEndGame(room) {
qbClearTimers(room);
room.status = 'ended';
io.to(room.id).emit('qb-ended', { leaderboard: qbLeaderboard(room), lingerMs: QB_END_LINGER_MS });
qbBroadcastCounts();
room.timers.push(setTimeout(() => qbResetRoom(room), QB_END_LINGER_MS));
}
/** ให้คะแนนแบบ Kahoot: ตอบถูก = ฐาน × (0.5 + 0.5×เศษเวลาที่เหลือ) + โบนัส streak */
function qbScoreAnswer(room, p, choiceIndex) {
const q = room.questions[room.qIndex];
const now = Date.now();
p.answeredIndex = choiceIndex;
p.answeredAt = now;
const correct = Number(choiceIndex) === Number(q.correctIndex);
p.lastCorrect = correct;
if (correct) {
const remain = Math.max(0, room.qEndsAt - now);
const frac = QB_ANSWER_MS > 0 ? remain / QB_ANSWER_MS : 0;
p.streak = (p.streak || 0) + 1;
const base = Math.round(QB_BASE_POINTS * (0.5 + 0.5 * frac));
const bonus = (p.streak - 1) * QB_STREAK_BONUS;
p.lastDelta = base + bonus;
p.score += p.lastDelta;
} else {
p.streak = 0;
p.lastDelta = 0;
}
return p.lastDelta;
}
function qbLeaveRoom(socket) {
const roomId = socket.data.qbRoom;
if (!roomId) return;
const room = qbRooms.get(roomId);
socket.data.qbRoom = null;
socket.leave(roomId);
if (!room) return;
if (room.players.delete(socket.id)) {
if (room.players.size === 0) {
qbClearTimers(room);
room.status = 'lobby';
room.questions = [];
room.qIndex = -1;
} else {
qbEmitRoomState(room);
if (room.status === 'question' && qbAllAnswered(room)) qbReveal(room);
}
qbBroadcastCounts();
}
}
/* ============================================================
* Quiz Battle (ฉากเดนตอบ แบบ ZEP) sync คะแนน/นดบขามผเลนแบบ authoritative
* เกบบน space: qbwSolved = Map(socketId -> Set(compId ตอบถ)) · score = จำนวนโดมทปลด
* event: quizbattle-hello (ขอคะแนนปจจ) · quizbattle-solve {compId} · server งกล quizbattle-scores
* ============================================================ */
function qbwScoresSnapshot(space) {
const scores = {};
if (space && space.qbwSolved) {
for (const [pid, set] of space.qbwSolved) scores[pid] = set ? set.size : 0;
}
if (space && space.peers) {
for (const id of space.peers.keys()) if (scores[id] == null) scores[id] = 0;
}
return scores;
}
function qbwBroadcastScores(sid, space) {
if (!sid || !space) return;
io.to(sid).emit('quizbattle-scores', { scores: qbwScoresSnapshot(space) });
}
io.on('connection', (socket) => {
/* ===== Quiz Battle ฉากเดินตอบ (ZEP) — sync คะแนน ===== */
socket.on('quizbattle-hello', (cb) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (typeof cb === 'function') cb({ ok: !!space, scores: space ? qbwScoresSnapshot(space) : {} });
});
socket.on('quizbattle-solve', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'quiz_battle') return reply({ ok: false });
const compId = parseInt(data && data.compId, 10);
if (!(compId > 0)) return reply({ ok: false });
if (!space.qbwSolved) space.qbwSolved = new Map();
let set = space.qbwSolved.get(socket.id);
if (!set) { set = new Set(); space.qbwSolved.set(socket.id, set); }
set.add(compId);
qbwBroadcastScores(sid, space);
reply({ ok: true, solved: set.size });
});
/* ===== Quiz Battle (Kahoot-style 50 คน) ===== */
socket.on('qb-watch-counts', (cb) => {
socket.join(QB_LOBBY_WATCH_ROOM);
if (typeof cb === 'function') cb({ ok: true, rooms: qbCountsSnapshot() });
});
socket.on('qb-join', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const n = parseInt(data && data.room, 10);
if (!(n >= 1 && n <= QB_ROOM_COUNT)) return reply({ ok: false, error: 'ห้องไม่ถูกต้อง' });
const room = qbGetRoom(n);
if (socket.data.qbRoom && socket.data.qbRoom !== room.id) qbLeaveRoom(socket);
if (!room.players.has(socket.id) && room.players.size >= QB_MAX_PLAYERS) {
return reply({ ok: false, error: 'ห้องเต็ม (50 คน)' });
}
const name = String((data && data.name) || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น';
const characterId = String((data && data.characterId) || '').trim().slice(0, 64);
let p = room.players.get(socket.id);
if (!p) {
p = { id: socket.id, name, characterId, score: 0, streak: 0, answeredIndex: -1, answeredAt: 0, lastDelta: 0, lastCorrect: false };
room.players.set(socket.id, p);
} else {
p.name = name; p.characterId = characterId;
}
socket.data.qbRoom = room.id;
socket.join(room.id);
socket.leave(QB_LOBBY_WATCH_ROOM);
qbEmitRoomState(room);
qbBroadcastCounts();
// ส่งสถานะปัจจุบันให้คนที่เพิ่งเข้า (เผื่อเกมกำลังเล่นอยู่)
const snapshot = { n: room.n, status: room.status, max: QB_MAX_PLAYERS };
if (room.status === 'question') {
const q = room.questions[room.qIndex];
snapshot.question = { index: room.qIndex, total: room.questions.length, text: q.text, choices: q.choices, endsAt: room.qEndsAt, durationMs: QB_ANSWER_MS };
} else if (room.status === 'countdown') {
snapshot.totalQuestions = room.questions.length;
}
reply({ ok: true, ...snapshot, players: qbPlayerList(room) });
});
socket.on('qb-leave', (cb) => {
qbLeaveRoom(socket);
if (typeof cb === 'function') cb({ ok: true });
});
socket.on('qb-start', (cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const roomId = socket.data.qbRoom;
const room = roomId ? qbRooms.get(roomId) : null;
if (!room || !room.players.has(socket.id)) return reply({ ok: false, error: 'ยังไม่ได้เข้าห้อง' });
reply(qbStartCountdown(room));
});
socket.on('qb-answer', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const roomId = socket.data.qbRoom;
const room = roomId ? qbRooms.get(roomId) : null;
if (!room || room.status !== 'question') return reply({ ok: false });
const p = room.players.get(socket.id);
if (!p || p.answeredIndex >= 0) return reply({ ok: false, already: true });
const q = room.questions[room.qIndex];
const ci = parseInt(data && data.choiceIndex, 10);
if (!(ci >= 0 && ci < q.choices.length)) return reply({ ok: false });
const delta = qbScoreAnswer(room, p, ci);
let answered = 0;
for (const pp of room.players.values()) if (pp.answeredIndex >= 0) answered++;
io.to(room.id).emit('qb-answered-count', { answered, total: room.players.size });
reply({ ok: true, accepted: true });
if (qbAllAnswered(room)) qbReveal(room);
});
socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex }, cb) => {
const space = spaces.get(spaceId);
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
@@ -6718,6 +7069,7 @@ io.on('connection', (socket) => {
});
socket.on('disconnect', () => {
qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space)
let sid = socket.data.spaceId;
let space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
@@ -6743,6 +7095,7 @@ io.on('connection', (socket) => {
io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
}
}
if (space.qbwSolved) space.qbwSolved.delete(socket.id); // Quiz Battle (ฉากเดิน) — เอาคะแนนคนออกแล้ว sync ใหม่ด้านล่าง
space.peers.delete(socket.id);
if (space.peers.size === 0) {
stopGauntletTicker(space);
@@ -6765,6 +7118,7 @@ io.on('connection', (socket) => {
}
}
io.to(sid).emit('user-left', { id: socket.id });
if (space.qbwSolved) qbwBroadcastScores(sid, space); // Quiz Battle ฉากเดิน — อัปเดตอันดับหลังมีคนออก
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -45,6 +45,6 @@
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0220"></script>
<script src="quiz-battle.js?v=0.0500"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quiz Battle — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=0.0221">
</head>
<body>
<div class="qb-bg" aria-hidden="true">
<img src="IMAGE/Quiz-Battle-bg.png" alt="" class="qb-bg-img" decoding="async">
</div>
<div class="qb-vignette" aria-hidden="true"></div>
<div class="qb-ui">
<header class="qb-top">
<button type="button" class="qb-join-back" id="btn-back" aria-label="กลับล็อบบี้">
<img src="IMAGE/btn-exit-room.png" alt="" class="qb-join-back-img" decoding="async">
</button>
<div class="qb-top-fill" aria-hidden="true"></div>
<div class="qb-room-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame">
<div class="qb-room-profile-avatar-viewport">
<img src="../Main-Menu/char-main.png" alt="" class="qb-room-profile-avatar" id="lobby-profile-avatar" width="64" height="64" decoding="async">
</div>
</div>
</div>
</header>
<main class="qb-main">
<div class="qb-room-banner" aria-hidden="true">
<img src="IMAGE/quiz-room.png" alt="" decoding="async">
</div>
<div class="qb-grid" id="qb-grid" role="list" aria-label="เลือกห้องควิซ"></div>
<p class="qb-toast" id="qb-toast" role="status"></p>
</main>
</div>
<div class="room-lobby-br-fixed qb-room-lobby-br-fixed" id="qb-room-lobby-br-fixed">
<button type="button" id="btn-voice" class="btn-voice-icon" title="เปิด/ปิดเสียงพูด" aria-label="เปิด/ปิดเสียงพูด">
<img src="../Game/img/btn-mic-mute.png" alt="เปิดเสียง" id="btn-voice-icon-img" decoding="async">
</button>
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0220"></script>
</body>
</html>
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quiz Battle — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=0.0221">
</head>
<body>
<div class="qb-bg" aria-hidden="true">
<img src="IMAGE/Quiz-Battle-bg.png" alt="" class="qb-bg-img" decoding="async">
</div>
<div class="qb-vignette" aria-hidden="true"></div>
<div class="qb-ui">
<header class="qb-top">
<button type="button" class="qb-join-back" id="btn-back" aria-label="กลับล็อบบี้">
<img src="IMAGE/btn-exit-room.png" alt="" class="qb-join-back-img" decoding="async">
</button>
<div class="qb-top-fill" aria-hidden="true"></div>
<div class="qb-room-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame">
<div class="qb-room-profile-avatar-viewport">
<img src="../Main-Menu/char-main.png" alt="" class="qb-room-profile-avatar" id="lobby-profile-avatar" width="64" height="64" decoding="async">
</div>
</div>
</div>
</header>
<main class="qb-main">
<div class="qb-room-banner" aria-hidden="true">
<img src="IMAGE/quiz-room.png" alt="" decoding="async">
</div>
<div class="qb-grid" id="qb-grid" role="list" aria-label="เลือกห้องควิซ"></div>
<p class="qb-toast" id="qb-toast" role="status"></p>
</main>
</div>
<div class="room-lobby-br-fixed qb-room-lobby-br-fixed" id="qb-room-lobby-br-fixed">
<button type="button" id="btn-voice" class="btn-voice-icon" title="เปิด/ปิดเสียงพูด" aria-label="เปิด/ปิดเสียงพูด">
<img src="../Game/img/btn-mic-mute.png" alt="เปิดเสียง" id="btn-voice-icon-img" decoding="async">
</button>
</div>
<!-- จอเล่น Quiz Battle (Kahoot-style 50 คน) — เนื้อหาในจอสร้างด้วย JS -->
<div class="qb-play" id="qb-play" aria-hidden="true">
<div class="qb-play-top">
<span class="qb-play-pill" id="qb-play-room">ห้อง —</span>
<span class="qb-play-pill" id="qb-play-players">👥 <b id="qb-play-count">0</b>/50</span>
<span class="qb-play-pill" id="qb-play-score" hidden>คะแนน <b id="qb-play-score-val">0</b></span>
<button type="button" class="qb-play-leave" id="qb-play-leave">ออก</button>
</div>
<div class="qb-play-body" id="qb-play-body"></div>
</div>
<script src="../Game/socket.io/socket.io.js"></script>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0300"></script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Quiz Battle — JD JUSTICE DIVERS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=0.0221">
</head>
<body>
<div class="qb-bg" aria-hidden="true">
<img src="IMAGE/Quiz-Battle-bg.png" alt="" class="qb-bg-img" decoding="async">
</div>
<div class="qb-vignette" aria-hidden="true"></div>
<div class="qb-ui">
<header class="qb-top">
<button type="button" class="qb-join-back" id="btn-back" aria-label="กลับล็อบบี้">
<img src="IMAGE/btn-exit-room.png" alt="" class="qb-join-back-img" decoding="async">
</button>
<div class="qb-top-fill" aria-hidden="true"></div>
<div class="qb-room-profile-wrap" aria-hidden="true">
<div class="qb-room-profile-frame">
<div class="qb-room-profile-avatar-viewport">
<img src="../Main-Menu/char-main.png" alt="" class="qb-room-profile-avatar" id="lobby-profile-avatar" width="64" height="64" decoding="async">
</div>
</div>
</div>
</header>
<main class="qb-main">
<div class="qb-room-banner" aria-hidden="true">
<img src="IMAGE/quiz-room.png" alt="" decoding="async">
</div>
<div class="qb-grid" id="qb-grid" role="list" aria-label="เลือกห้องควิซ"></div>
<p class="qb-toast" id="qb-toast" role="status"></p>
</main>
</div>
<div class="room-lobby-br-fixed qb-room-lobby-br-fixed" id="qb-room-lobby-br-fixed">
<button type="button" id="btn-voice" class="btn-voice-icon" title="เปิด/ปิดเสียงพูด" aria-label="เปิด/ปิดเสียงพูด">
<img src="../Game/img/btn-mic-mute.png" alt="เปิดเสียง" id="btn-voice-icon-img" decoding="async">
</button>
</div>
<script src="../app-base.js?v=2"></script>
<script src="quiz-battle.js?v=0.0400"></script>
</body>
</html>
+94 -60
View File
@@ -7,6 +7,11 @@
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
/* ฉากเดินตอบแบบ ZEP — แต่ละห้อง(หัวข้อ) = 1 space บนแผนที่ quiz_battle ของหัวข้อนั้น (qbroom1..10) */
function mapIdForRoom(n) { return 'qbroom' + n; }
var QB_MAX_PLAYERS = 50;
var ROOM_NAME_PREFIX = 'Quiz Battle ห้อง ';
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
@@ -15,91 +20,130 @@
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
function setToast(msg) {
if (toast) toast.textContent = msg || '';
}
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function setToast(msg) { if (toast) toast.textContent = msg || ''; }
function getSelectedCharacterId() {
try {
return (localStorage.getItem(CHAR_KEY) || '').trim();
} catch (e) {
return '';
try { return (localStorage.getItem(CHAR_KEY) || '').trim(); } catch (e) { return ''; }
}
function getPlayerName() {
var keys = ['gameNickname', 'nickname', 'displayName', 'playerName', 'userName'];
for (var i = 0; i < keys.length; i++) {
try { var v = (localStorage.getItem(keys[i]) || '').trim(); if (v) return v.slice(0, 24); } catch (e) { /* ignore */ }
}
return 'ผู้เล่น' + Math.floor(1000 + Math.random() * 9000);
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [
BASE + '/img/characters/' + enc + '_down.png',
BASE + '/img/characters/' + enc + '_down_0.png'
];
return [BASE + '/img/characters/' + enc + '_down.png', BASE + '/img/characters/' + enc + '_down_0.png'];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (!cid) {
av.onerror = null;
av.src = fallbackAvatar;
return;
}
// ใช้รูปเดียวกับหน้า Main-Lobby ก่อน (composited idle ที่บันทึกไว้)
if (!cid) { av.onerror = null; av.src = fallbackAvatar; return; }
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () {
av.onerror = null;
av.src = fallbackAvatar;
};
av.onerror = function () { av.onerror = null; av.src = fallbackAvatar; };
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else {
av.onerror = null;
av.src = fallbackAvatar;
}
else { av.onerror = null; av.src = fallbackAvatar; }
};
av.src = urls[0];
}
/* ===================== เข้าฉากเดิน (ZEP walkable) ===================== */
var entering = false;
function roomName(n) { return ROOM_NAME_PREFIX + n; }
function gotoPlay(spaceId) {
var nick = getPlayerName();
var url = BASE + '/play.html?space=' + encodeURIComponent(spaceId) +
'&map=' + encodeURIComponent(mapIdForRoom(currentRoomNo)) +
'&nick=' + encodeURIComponent(nick);
try { localStorage.setItem('lastCreatedSpaceName', roomName(currentRoomNo)); } catch (e) { /* ignore */ }
window.location.href = url;
}
var currentRoomNo = 0;
/** หา space สาธารณะของห้องนี้ที่ยังมีคนอยู่ ถ้าไม่มีให้สร้างใหม่ แล้วเข้าเล่น */
function startQuizForRoom(n) {
try {
localStorage.setItem('quizBattleRoom', String(n));
} catch (e) { /* ignore */ }
setToast('เริ่มเกมห้อง ' + n + ' (ต่อระบบจริงในเวอร์ชันถัดไป)');
if (entering) return;
entering = true;
currentRoomNo = n;
setToast('กำลังเข้าห้อง ' + n + '');
var wantName = roomName(n);
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var found = null;
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].spaceName === wantName && (list[i].peerCount || 0) < QB_MAX_PLAYERS) { found = list[i]; break; }
}
}
if (found) { gotoPlay(found.spaceId); return null; }
// สร้างห้องใหม่บนแผนที่ quiz_battle
return fetch(BASE + '/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapId: mapIdForRoom(n), name: wantName, maxPlayers: QB_MAX_PLAYERS })
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.ok && res.spaceId) { gotoPlay(res.spaceId); }
else { setToast((res && res.error) || 'สร้างห้องไม่สำเร็จ'); entering = false; }
});
})
.catch(function () { setToast('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ ลองอีกครั้ง'); entering = false; });
}
/** อัปเดตจำนวนคนในแต่ละห้องจากรายชื่อ space (poll) */
function refreshRoomCounts() {
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var counts = {};
if (Array.isArray(list)) {
list.forEach(function (s) {
if (!s || !s.spaceName) return;
if (s.spaceName.indexOf(ROOM_NAME_PREFIX) === 0) {
var n = parseInt(s.spaceName.slice(ROOM_NAME_PREFIX.length), 10);
if (n >= 1 && n <= 10) counts[n] = (s.peerCount || 0);
}
});
}
for (var i = 1; i <= 10; i++) {
var el = document.getElementById('qb-count-' + i);
if (el) el.textContent = (counts[i] || 0) + '/' + QB_MAX_PLAYERS;
}
})
.catch(function () { /* keep last */ });
}
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) {
img.src = getVoiceIconPath(micOn);
img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง';
}
if (img) { img.src = getVoiceIconPath(micOn); img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; }
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
@@ -134,11 +178,7 @@
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) {
ev.preventDefault();
ev.stopPropagation();
startQuizForRoom(roomNo);
};
return function (ev) { ev.preventDefault(); ev.stopPropagation(); startQuizForRoom(roomNo); };
})(i));
var startImg = document.createElement('img');
@@ -149,14 +189,15 @@
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.textContent = '0/50';
countEl.id = 'qb-count-' + i;
countEl.textContent = '0/' + QB_MAX_PLAYERS;
card.appendChild(roomImg);
card.appendChild(startBtn);
card.appendChild(countEl);
grid.appendChild(card);
}
setToast('กดปุ่มเริ่มเกมในแต่ละห้องเพื่อเล่น');
setToast('กดเริ่มเกมในห้องเพื่อเข้าฉากเดินตอบคำถาม');
}
function syncMainScale() {
@@ -164,8 +205,6 @@
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
// Base layout from image dimensions:
// banner 419x151, banner top offset, card 271x351, 5 columns, 2 rows, gaps + toast spacing.
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
@@ -179,7 +218,6 @@
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
// Base stage from mock-normal viewport.
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
@@ -191,16 +229,12 @@
applyProfileAvatar();
buildGrid();
bindVoiceButton();
refreshRoomCounts();
setInterval(refreshRoomCounts, 5000);
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () {
syncUiScale();
syncMainScale();
});
window.addEventListener('resize', function () { syncUiScale(); syncMainScale(); });
window.addEventListener('orientationchange', function () {
setTimeout(function () {
syncUiScale();
syncMainScale();
}, 120);
setTimeout(function () { syncUiScale(); syncMainScale(); }, 120);
});
})();
@@ -0,0 +1,206 @@
(function () {
'use strict';
var QB_IMAGE = typeof appPath === 'function' ? appPath('/Quiz-Battle/IMAGE') : '/Quiz-Battle/IMAGE';
var BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
var CHAR_KEY = 'gameCharacterId';
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
}
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
function setToast(msg) {
if (toast) toast.textContent = msg || '';
}
function getSelectedCharacterId() {
try {
return (localStorage.getItem(CHAR_KEY) || '').trim();
} catch (e) {
return '';
}
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [
BASE + '/img/characters/' + enc + '_down.png',
BASE + '/img/characters/' + enc + '_down_0.png'
];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (!cid) {
av.onerror = null;
av.src = fallbackAvatar;
return;
}
// ใช้รูปเดียวกับหน้า Main-Lobby ก่อน (composited idle ที่บันทึกไว้)
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () {
av.onerror = null;
av.src = fallbackAvatar;
};
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else {
av.onerror = null;
av.src = fallbackAvatar;
}
};
av.src = urls[0];
}
function startQuizForRoom(n) {
try {
localStorage.setItem('quizBattleRoom', String(n));
} catch (e) { /* ignore */ }
setToast('เริ่มเกมห้อง ' + n + ' (ต่อระบบจริงในเวอร์ชันถัดไป)');
}
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) {
img.src = getVoiceIconPath(micOn);
img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง';
}
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
var micOn = false;
try { micOn = localStorage.getItem(VOICE_STATE_KEY) === '1'; } catch (e) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
btnVoice.addEventListener('click', function () {
micOn = !micOn;
try { localStorage.setItem(VOICE_STATE_KEY, micOn ? '1' : '0'); } catch (e2) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
});
}
function buildGrid() {
if (!grid) return;
grid.innerHTML = '';
for (var i = 1; i <= 10; i++) {
var card = document.createElement('div');
card.className = 'qb-card';
card.setAttribute('data-room', String(i));
card.setAttribute('role', 'listitem');
card.setAttribute('aria-label', 'ห้อง ' + i);
var roomImg = document.createElement('img');
roomImg.className = 'qb-card-room';
roomImg.src = QB_IMAGE + '/Room-' + pad2(i) + '.png';
roomImg.alt = '';
roomImg.decoding = 'async';
var startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) {
ev.preventDefault();
ev.stopPropagation();
startQuizForRoom(roomNo);
};
})(i));
var startImg = document.createElement('img');
startImg.src = QB_IMAGE + '/btn-start-quiz.png';
startImg.alt = 'เริ่มเกม';
startImg.decoding = 'async';
startBtn.appendChild(startImg);
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.textContent = '0/50';
card.appendChild(roomImg);
card.appendChild(startBtn);
card.appendChild(countEl);
grid.appendChild(card);
}
setToast('กดปุ่มเริ่มเกมในแต่ละห้องเพื่อเล่น');
}
function syncMainScale() {
var mainEl = document.querySelector('.qb-main');
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
// Base layout from image dimensions:
// banner 419x151, banner top offset, card 271x351, 5 columns, 2 rows, gaps + toast spacing.
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
var scaleH = availableH / baseHeight;
var scale = Math.max(0.22, Math.min(1, Math.min(scaleW, scaleH)));
mainEl.style.setProperty('--qb-main-scale', scale.toFixed(4));
}
function syncUiScale() {
var uiEl = document.querySelector('.qb-ui');
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
// Base stage from mock-normal viewport.
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
document.getElementById('btn-back')?.addEventListener('click', function () {
window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
});
applyProfileAvatar();
buildGrid();
bindVoiceButton();
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () {
syncUiScale();
syncMainScale();
});
window.addEventListener('orientationchange', function () {
setTimeout(function () {
syncUiScale();
syncMainScale();
}, 120);
});
})();
@@ -0,0 +1,497 @@
(function () {
'use strict';
var QB_IMAGE = typeof appPath === 'function' ? appPath('/Quiz-Battle/IMAGE') : '/Quiz-Battle/IMAGE';
var BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
var CHAR_KEY = 'gameCharacterId';
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
}
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
function setToast(msg) {
if (toast) toast.textContent = msg || '';
}
function getSelectedCharacterId() {
try {
return (localStorage.getItem(CHAR_KEY) || '').trim();
} catch (e) {
return '';
}
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [
BASE + '/img/characters/' + enc + '_down.png',
BASE + '/img/characters/' + enc + '_down_0.png'
];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (!cid) {
av.onerror = null;
av.src = fallbackAvatar;
return;
}
// ใช้รูปเดียวกับหน้า Main-Lobby ก่อน (composited idle ที่บันทึกไว้)
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () {
av.onerror = null;
av.src = fallbackAvatar;
};
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else {
av.onerror = null;
av.src = fallbackAvatar;
}
};
av.src = urls[0];
}
/* ===================== Quiz Battle realtime engine ===================== */
var ANSWER_COLORS = ['c0', 'c1', 'c2', 'c3'];
var ANSWER_ICONS = ['▲', '◆', '●', '■'];
function getPlayerName() {
var keys = ['gameNickname', 'nickname', 'displayName', 'playerName', 'userName'];
for (var i = 0; i < keys.length; i++) {
try {
var v = (localStorage.getItem(keys[i]) || '').trim();
if (v) return v.slice(0, 24);
} catch (e) { /* ignore */ }
}
return 'ผู้เล่น' + Math.floor(1000 + Math.random() * 9000);
}
var socket = null;
var myId = null;
var current = { room: 0, status: 'lobby', myScore: 0, total: 0, picked: -1, timerRAF: 0 };
var elPlay = document.getElementById('qb-play');
var elBody = document.getElementById('qb-play-body');
var elRoomPill = document.getElementById('qb-play-room');
var elCountB = document.getElementById('qb-play-count');
var elScoreWrap = document.getElementById('qb-play-score');
var elScoreVal = document.getElementById('qb-play-score-val');
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function openPlay(roomNo) {
current.room = roomNo;
elRoomPill.textContent = 'ห้อง ' + roomNo;
elPlay.classList.add('is-open');
elPlay.setAttribute('aria-hidden', 'false');
}
function closePlay() {
stopTimer();
elPlay.classList.remove('is-open');
elPlay.setAttribute('aria-hidden', 'true');
elBody.innerHTML = '';
current.room = 0;
current.status = 'lobby';
}
function setScore(v) {
current.myScore = v;
elScoreWrap.hidden = false;
elScoreVal.textContent = String(v);
}
function setCount(c) { elCountB.textContent = String(c); }
function stopTimer() {
if (current.timerRAF) { cancelAnimationFrame(current.timerRAF); current.timerRAF = 0; }
}
function runTimer(fillEl, endsAt, durationMs) {
stopTimer();
function tick() {
var remain = Math.max(0, endsAt - Date.now());
var frac = durationMs > 0 ? remain / durationMs : 0;
fillEl.style.transform = 'scaleX(' + frac.toFixed(4) + ')';
if (remain > 0) current.timerRAF = requestAnimationFrame(tick);
}
tick();
}
// ---- screen renderers ----
function renderLobby(state) {
current.status = 'lobby';
stopTimer();
elScoreWrap.hidden = true;
var players = state.players || [];
setCount(players.length);
var chips = players.map(function (p) {
return '<span class="qb-player-chip' + (p.id === myId ? ' me' : '') + '">' + esc(p.name) + '</span>';
}).join('');
elBody.innerHTML =
'<div class="qb-play-screen is-active">' +
'<div class="qb-lobby-title">ห้อง ' + current.room + '</div>' +
'<div class="qb-lobby-sub">รอผู้เล่น… กด “เริ่มเกม” เพื่อนับถอยหลังเริ่มพร้อมกัน (สูงสุด 50 คน)</div>' +
'<div class="qb-players" id="qb-players">' + chips + '</div>' +
'<button type="button" class="qb-btn" id="qb-start-btn">เริ่มเกม</button>' +
'<div class="qb-answered-info" id="qb-lobby-msg"></div>' +
'</div>';
document.getElementById('qb-start-btn').addEventListener('click', function () {
var btn = this;
btn.disabled = true;
socket.emit('qb-start', function (res) {
if (!res || !res.ok) {
btn.disabled = false;
document.getElementById('qb-lobby-msg').textContent = (res && res.error) || 'เริ่มไม่สำเร็จ';
}
});
});
}
function renderCountdown(seconds) {
current.status = 'countdown';
stopTimer();
elBody.innerHTML =
'<div class="qb-play-screen is-active">' +
'<div class="qb-countdown-label">เกมกำลังจะเริ่ม…</div>' +
'<div class="qb-countdown-num" id="qb-cd-num">' + seconds + '</div>' +
'</div>';
}
function renderQuestion(q) {
current.status = 'question';
current.picked = -1;
var n = q.choices.length;
var answers = q.choices.map(function (c, i) {
return '<button type="button" class="qb-answer ' + ANSWER_COLORS[i] + '" data-i="' + i + '">' +
'<span class="qb-answer-icon">' + ANSWER_ICONS[i] + '</span>' +
'<span class="qb-answer-text">' + esc(c) + '</span>' +
'</button>';
}).join('');
elBody.innerHTML =
'<div class="qb-play-screen is-active">' +
'<div class="qb-qmeta">ข้อ ' + (q.index + 1) + ' / ' + q.total + '</div>' +
'<div class="qb-timebar"><div class="qb-timebar-fill" id="qb-timefill"></div></div>' +
'<div class="qb-question-text">' + esc(q.text) + '</div>' +
'<div class="qb-answers" data-count="' + n + '" id="qb-answers">' + answers + '</div>' +
'<div class="qb-answered-info" id="qb-answered">รอผู้เล่นตอบ…</div>' +
'</div>';
runTimer(document.getElementById('qb-timefill'), q.endsAt, q.durationMs);
var btns = elBody.querySelectorAll('.qb-answer');
btns.forEach(function (b) {
b.addEventListener('click', function () {
if (current.picked >= 0) return;
var i = parseInt(b.getAttribute('data-i'), 10);
current.picked = i;
btns.forEach(function (x) { x.disabled = true; });
b.classList.add('is-picked');
socket.emit('qb-answer', { choiceIndex: i }, function () {});
});
});
}
function renderReveal(data) {
current.status = 'reveal';
stopTimer();
var me = (data.results || []).filter(function (r) { return r.id === myId; })[0];
if (me) setScore(me.score);
// mark answer tiles
var btns = elBody.querySelectorAll('.qb-answer');
if (btns.length) {
btns.forEach(function (b) {
var i = parseInt(b.getAttribute('data-i'), 10);
b.disabled = true;
if (i === data.correctIndex) b.classList.add('is-correct');
else if (i === current.picked) b.classList.add('is-wrong');
else b.classList.add('is-dimmed');
});
}
var fb = '';
if (me) {
fb = me.correct
? '<div class="qb-feedback ok">ถูกต้อง! ✓</div><div class="qb-delta">+' + me.delta + ' คะแนน</div>'
: '<div class="qb-feedback no">' + (me.choice >= 0 ? 'ยังไม่ถูก ✗' : 'หมดเวลา ⏱') + '</div>';
}
var board = (data.leaderboard || []).slice(0, 5).map(function (r) {
return '<div class="qb-board-row' + (r.id === myId ? ' me' : '') + (r.rank === 1 ? ' top1' : '') + '">' +
'<span class="qb-board-rank">' + r.rank + '</span>' +
'<span class="qb-board-name">' + esc(r.name) + '</span>' +
'<span class="qb-board-score">' + r.score + '</span>' +
'</div>';
}).join('');
var existing = elBody.querySelector('.qb-play-screen');
var html =
'<div class="qb-play-screen is-active">' +
fb +
'<div class="qb-board"><div class="qb-board-title">อันดับ</div>' + board + '</div>' +
'<div class="qb-answered-info">' + (data.isLast ? 'กำลังสรุปผล…' : 'ข้อต่อไปใน ' + Math.round((data.nextInMs || 0) / 1000) + ' วิ') + '</div>' +
'</div>';
if (existing) existing.outerHTML = html; else elBody.innerHTML = html;
}
function renderFinal(data) {
current.status = 'ended';
stopTimer();
elScoreWrap.hidden = true;
var board = (data.leaderboard || []).map(function (r) {
return '<div class="qb-board-row' + (r.id === myId ? ' me' : '') + (r.rank === 1 ? ' top1' : '') + '">' +
'<span class="qb-board-rank">' + (r.rank === 1 ? '🏆' : r.rank) + '</span>' +
'<span class="qb-board-name">' + esc(r.name) + '</span>' +
'<span class="qb-board-score">' + r.score + '</span>' +
'</div>';
}).join('');
var mine = (data.leaderboard || []).filter(function (r) { return r.id === myId; })[0];
elBody.innerHTML =
'<div class="qb-play-screen is-active">' +
'<div class="qb-lobby-title">จบเกม! 🎉</div>' +
(mine ? '<div class="qb-lobby-sub">คุณได้อันดับ ' + mine.rank + ' · ' + mine.score + ' คะแนน</div>' : '') +
'<div class="qb-board"><div class="qb-board-title">อันดับสุดท้าย</div>' + board + '</div>' +
'<div class="qb-answered-info">กำลังกลับสู่ห้องรอ… กดเริ่มใหม่ได้อีกครั้ง</div>' +
'</div>';
}
function bindSocketHandlers() {
socket.on('connect', function () {
myId = socket.id;
socket.emit('qb-watch-counts', function (res) {
if (res && res.rooms) updateCounts(res.rooms);
});
});
socket.on('qb-counts', function (data) { if (data && data.rooms) updateCounts(data.rooms); });
socket.on('qb-room-state', function (data) {
setCount((data.players || []).length);
if (current.status === 'lobby' && current.room === data.n) renderLobby(data);
});
socket.on('qb-countdown', function (data) {
current.total = data.totalQuestions || 0;
var endsAt = data.endsAt;
var sec = Math.max(1, Math.ceil((endsAt - Date.now()) / 1000));
renderCountdown(sec);
var iv = setInterval(function () {
var s = Math.ceil((endsAt - Date.now()) / 1000);
var numEl = document.getElementById('qb-cd-num');
if (s <= 0 || !numEl) { clearInterval(iv); return; }
numEl.textContent = s;
}, 250);
});
socket.on('qb-question', function (q) { renderQuestion(q); });
socket.on('qb-answered-count', function (d) {
var el = document.getElementById('qb-answered');
if (el) el.textContent = 'ตอบแล้ว ' + d.answered + ' / ' + d.total + ' คน';
});
socket.on('qb-reveal', function (d) { renderReveal(d); });
socket.on('qb-ended', function (d) { renderFinal(d); });
socket.on('qb-reset', function () {
// server reset room → ขอ state ใหม่ผ่าน re-join เบา ๆ
if (current.room) {
socket.emit('qb-join', { room: current.room, name: getPlayerName(), characterId: getSelectedCharacterId() }, function (res) {
if (res && res.ok) renderLobby({ n: res.n, players: res.players });
});
}
});
socket.on('disconnect', function () {
if (current.room) {
var el = document.getElementById('qb-lobby-msg') || document.getElementById('qb-answered');
if (el) el.textContent = 'การเชื่อมต่อหลุด — กำลังเชื่อมต่อใหม่…';
}
});
}
function updateCounts(rooms) {
rooms.forEach(function (r) {
var el = document.getElementById('qb-count-' + r.n);
if (el) el.textContent = r.count + '/50';
});
}
function applyJoinSnapshot(res) {
setCount((res.players || []).length);
if (res.status === 'lobby') {
renderLobby({ n: res.n, players: res.players });
} else if (res.status === 'countdown') {
current.total = res.totalQuestions || 0;
renderCountdown(3);
} else if (res.status === 'question' && res.question) {
renderQuestion(res.question);
} else {
renderLobby({ n: res.n, players: res.players || [] });
}
}
function startQuizForRoom(n) {
if (!socket || !socket.connected) {
setToast('กำลังเชื่อมต่อเซิร์ฟเวอร์… ลองอีกครั้ง');
return;
}
socket.emit('qb-join', { room: n, name: getPlayerName(), characterId: getSelectedCharacterId() }, function (res) {
if (!res || !res.ok) {
setToast((res && res.error) || 'เข้าห้องไม่สำเร็จ');
return;
}
openPlay(n);
applyJoinSnapshot(res);
});
}
function initSocket() {
if (typeof io !== 'function') {
setToast('โหลด socket.io ไม่สำเร็จ — รีเฟรชหน้าอีกครั้ง');
return;
}
socket = io({ path: BASE + '/socket.io', transports: ['websocket', 'polling'] });
bindSocketHandlers();
}
document.getElementById('qb-play-leave').addEventListener('click', function () {
if (socket) socket.emit('qb-leave', function () {});
closePlay();
});
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) {
img.src = getVoiceIconPath(micOn);
img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง';
}
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
var micOn = false;
try { micOn = localStorage.getItem(VOICE_STATE_KEY) === '1'; } catch (e) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
btnVoice.addEventListener('click', function () {
micOn = !micOn;
try { localStorage.setItem(VOICE_STATE_KEY, micOn ? '1' : '0'); } catch (e2) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
});
}
function buildGrid() {
if (!grid) return;
grid.innerHTML = '';
for (var i = 1; i <= 10; i++) {
var card = document.createElement('div');
card.className = 'qb-card';
card.setAttribute('data-room', String(i));
card.setAttribute('role', 'listitem');
card.setAttribute('aria-label', 'ห้อง ' + i);
var roomImg = document.createElement('img');
roomImg.className = 'qb-card-room';
roomImg.src = QB_IMAGE + '/Room-' + pad2(i) + '.png';
roomImg.alt = '';
roomImg.decoding = 'async';
var startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) {
ev.preventDefault();
ev.stopPropagation();
startQuizForRoom(roomNo);
};
})(i));
var startImg = document.createElement('img');
startImg.src = QB_IMAGE + '/btn-start-quiz.png';
startImg.alt = 'เริ่มเกม';
startImg.decoding = 'async';
startBtn.appendChild(startImg);
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.id = 'qb-count-' + i;
countEl.textContent = '0/50';
card.appendChild(roomImg);
card.appendChild(startBtn);
card.appendChild(countEl);
grid.appendChild(card);
}
setToast('กดปุ่มเริ่มเกมในแต่ละห้องเพื่อเล่น');
}
function syncMainScale() {
var mainEl = document.querySelector('.qb-main');
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
// Base layout from image dimensions:
// banner 419x151, banner top offset, card 271x351, 5 columns, 2 rows, gaps + toast spacing.
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
var scaleH = availableH / baseHeight;
var scale = Math.max(0.22, Math.min(1, Math.min(scaleW, scaleH)));
mainEl.style.setProperty('--qb-main-scale', scale.toFixed(4));
}
function syncUiScale() {
var uiEl = document.querySelector('.qb-ui');
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
// Base stage from mock-normal viewport.
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
document.getElementById('btn-back')?.addEventListener('click', function () {
window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
});
applyProfileAvatar();
buildGrid();
bindVoiceButton();
initSocket();
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () {
syncUiScale();
syncMainScale();
});
window.addEventListener('orientationchange', function () {
setTimeout(function () {
syncUiScale();
syncMainScale();
}, 120);
});
})();
@@ -0,0 +1,240 @@
(function () {
'use strict';
var QB_IMAGE = typeof appPath === 'function' ? appPath('/Quiz-Battle/IMAGE') : '/Quiz-Battle/IMAGE';
var BASE = typeof appPath === 'function' ? appPath('/Game') : '/Game';
var CHAR_KEY = 'gameCharacterId';
var LOBBY_IDLE_DOWN_PREFIX = 'jdCharLobbyIdleDown:';
var VOICE_STATE_KEY = 'qbVoiceMicOn';
/* ฉากเดินตอบแบบ ZEP — แต่ละห้องในเมนู = 1 space บนแผนที่ quiz_battle (เดิน + โดมคำถาม) */
var QUIZ_BATTLE_MAP_ID = 'mnq9bl0e'; // แผนที่ quiz_battle (เปลี่ยนเป็นฉาก forest ใหม่ได้ภายหลัง)
var QB_MAX_PLAYERS = 50;
var ROOM_NAME_PREFIX = 'Quiz Battle ห้อง ';
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = typeof appPath === 'function' ? appPath('/Login/') : '/Login/';
return;
}
var grid = document.getElementById('qb-grid');
var toast = document.getElementById('qb-toast');
function pad2(n) { return n < 10 ? '0' + n : String(n); }
function setToast(msg) { if (toast) toast.textContent = msg || ''; }
function getSelectedCharacterId() {
try { return (localStorage.getItem(CHAR_KEY) || '').trim(); } catch (e) { return ''; }
}
function getPlayerName() {
var keys = ['gameNickname', 'nickname', 'displayName', 'playerName', 'userName'];
for (var i = 0; i < keys.length; i++) {
try { var v = (localStorage.getItem(keys[i]) || '').trim(); if (v) return v.slice(0, 24); } catch (e) { /* ignore */ }
}
return 'ผู้เล่น' + Math.floor(1000 + Math.random() * 9000);
}
function characterDownUrls(id) {
var enc = encodeURIComponent(id);
return [BASE + '/img/characters/' + enc + '_down.png', BASE + '/img/characters/' + enc + '_down_0.png'];
}
function applyProfileAvatar() {
var av = document.getElementById('lobby-profile-avatar');
if (!av) return;
var fallbackAvatar = typeof appPath === 'function' ? appPath('/Main-Menu/char-main.png') : '/Main-Menu/char-main.png';
var cid = getSelectedCharacterId();
if (!cid) { av.onerror = null; av.src = fallbackAvatar; return; }
try {
var savedLobbyAvatar = localStorage.getItem(LOBBY_IDLE_DOWN_PREFIX + cid) || '';
if (savedLobbyAvatar && savedLobbyAvatar.indexOf('data:image/') === 0) {
av.onerror = function () { av.onerror = null; av.src = fallbackAvatar; };
av.src = savedLobbyAvatar;
return;
}
} catch (e0) { /* ignore */ }
var urls = characterDownUrls(cid);
var avStep = 0;
av.onerror = function () {
avStep += 1;
if (avStep === 1) av.src = urls[1];
else { av.onerror = null; av.src = fallbackAvatar; }
};
av.src = urls[0];
}
/* ===================== เข้าฉากเดิน (ZEP walkable) ===================== */
var entering = false;
function roomName(n) { return ROOM_NAME_PREFIX + n; }
function gotoPlay(spaceId) {
var nick = getPlayerName();
var url = BASE + '/play.html?space=' + encodeURIComponent(spaceId) +
'&map=' + encodeURIComponent(QUIZ_BATTLE_MAP_ID) +
'&nick=' + encodeURIComponent(nick);
try { localStorage.setItem('lastCreatedSpaceName', roomName(currentRoomNo)); } catch (e) { /* ignore */ }
window.location.href = url;
}
var currentRoomNo = 0;
/** หา space สาธารณะของห้องนี้ที่ยังมีคนอยู่ ถ้าไม่มีให้สร้างใหม่ แล้วเข้าเล่น */
function startQuizForRoom(n) {
if (entering) return;
entering = true;
currentRoomNo = n;
setToast('กำลังเข้าห้อง ' + n + '…');
var wantName = roomName(n);
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var found = null;
if (Array.isArray(list)) {
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].spaceName === wantName && (list[i].peerCount || 0) < QB_MAX_PLAYERS) { found = list[i]; break; }
}
}
if (found) { gotoPlay(found.spaceId); return null; }
// สร้างห้องใหม่บนแผนที่ quiz_battle
return fetch(BASE + '/api/spaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapId: QUIZ_BATTLE_MAP_ID, name: wantName, maxPlayers: QB_MAX_PLAYERS })
}).then(function (r) { return r.json(); }).then(function (res) {
if (res && res.ok && res.spaceId) { gotoPlay(res.spaceId); }
else { setToast((res && res.error) || 'สร้างห้องไม่สำเร็จ'); entering = false; }
});
})
.catch(function () { setToast('เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ ลองอีกครั้ง'); entering = false; });
}
/** อัปเดตจำนวนคนในแต่ละห้องจากรายชื่อ space (poll) */
function refreshRoomCounts() {
fetch(BASE + '/api/spaces', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
var counts = {};
if (Array.isArray(list)) {
list.forEach(function (s) {
if (!s || !s.spaceName) return;
if (s.spaceName.indexOf(ROOM_NAME_PREFIX) === 0) {
var n = parseInt(s.spaceName.slice(ROOM_NAME_PREFIX.length), 10);
if (n >= 1 && n <= 10) counts[n] = (s.peerCount || 0);
}
});
}
for (var i = 1; i <= 10; i++) {
var el = document.getElementById('qb-count-' + i);
if (el) el.textContent = (counts[i] || 0) + '/' + QB_MAX_PLAYERS;
}
})
.catch(function () { /* keep last */ });
}
function getVoiceIconPath(micOn) {
var imgPath = micOn ? '/Game/img/btn-mic-on.png' : '/Game/img/btn-mic-mute.png';
return typeof appPath === 'function' ? appPath(imgPath) : imgPath;
}
function setVoiceButtonIcon(btn, micOn) {
if (!btn) return;
var img = btn.querySelector('#btn-voice-icon-img') || btn.querySelector('img');
if (img) { img.src = getVoiceIconPath(micOn); img.alt = micOn ? 'ปิดเสียง' : 'เปิดเสียง'; }
btn.title = micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด';
btn.setAttribute('aria-label', micOn ? 'ปิดเสียงพูด' : 'เปิดเสียงพูด');
}
function bindVoiceButton() {
var btnVoice = document.getElementById('btn-voice');
if (!btnVoice) return;
var micOn = false;
try { micOn = localStorage.getItem(VOICE_STATE_KEY) === '1'; } catch (e) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
btnVoice.addEventListener('click', function () {
micOn = !micOn;
try { localStorage.setItem(VOICE_STATE_KEY, micOn ? '1' : '0'); } catch (e2) { /* ignore */ }
setVoiceButtonIcon(btnVoice, micOn);
});
}
function buildGrid() {
if (!grid) return;
grid.innerHTML = '';
for (var i = 1; i <= 10; i++) {
var card = document.createElement('div');
card.className = 'qb-card';
card.setAttribute('data-room', String(i));
card.setAttribute('role', 'listitem');
card.setAttribute('aria-label', 'ห้อง ' + i);
var roomImg = document.createElement('img');
roomImg.className = 'qb-card-room';
roomImg.src = QB_IMAGE + '/Room-' + pad2(i) + '.png';
roomImg.alt = '';
roomImg.decoding = 'async';
var startBtn = document.createElement('button');
startBtn.type = 'button';
startBtn.className = 'qb-card-start';
startBtn.setAttribute('aria-label', 'เริ่มเกมห้อง ' + i);
startBtn.addEventListener('click', (function (roomNo) {
return function (ev) { ev.preventDefault(); ev.stopPropagation(); startQuizForRoom(roomNo); };
})(i));
var startImg = document.createElement('img');
startImg.src = QB_IMAGE + '/btn-start-quiz.png';
startImg.alt = 'เริ่มเกม';
startImg.decoding = 'async';
startBtn.appendChild(startImg);
var countEl = document.createElement('span');
countEl.className = 'qb-card-count';
countEl.id = 'qb-count-' + i;
countEl.textContent = '0/' + QB_MAX_PLAYERS;
card.appendChild(roomImg);
card.appendChild(startBtn);
card.appendChild(countEl);
grid.appendChild(card);
}
setToast('กดเริ่มเกมในห้องเพื่อเข้าฉากเดินตอบคำถาม');
}
function syncMainScale() {
var mainEl = document.querySelector('.qb-main');
if (!mainEl) return;
var availableW = Math.max(1, mainEl.clientWidth);
var availableH = Math.max(1, mainEl.clientHeight);
var baseWidth = (271 * 5) + (18 * 4);
var baseHeight = 60 + 151 + 12 + (351 * 2) + 14 + 8 + 24;
var scaleW = availableW / baseWidth;
var scaleH = availableH / baseHeight;
var scale = Math.max(0.22, Math.min(1, Math.min(scaleW, scaleH)));
mainEl.style.setProperty('--qb-main-scale', scale.toFixed(4));
}
function syncUiScale() {
var uiEl = document.querySelector('.qb-ui');
if (!uiEl) return;
var vw = Math.max(1, window.innerWidth || 0);
var vh = Math.max(1, window.innerHeight || 0);
var scale = Math.max(0.34, Math.min(1, Math.min(vw / 1920, vh / 1080)));
uiEl.style.setProperty('--qb-ui-scale', scale.toFixed(4));
}
document.getElementById('btn-back')?.addEventListener('click', function () {
window.location.href = typeof appPath === 'function' ? appPath('/Main-Lobby/') : '/Main-Lobby/';
});
applyProfileAvatar();
buildGrid();
bindVoiceButton();
refreshRoomCounts();
setInterval(refreshRoomCounts, 5000);
syncUiScale();
syncMainScale();
window.addEventListener('resize', function () { syncUiScale(); syncMainScale(); });
window.addEventListener('orientationchange', function () {
setTimeout(function () { syncUiScale(); syncMainScale(); }, 120);
});
})();
+216
View File
@@ -378,6 +378,222 @@ body {
-webkit-user-drag: none;
}
/* ============================================================
Quiz Battle play overlay (Kahoot/ZEP-style) — จอเล่นจริง 50 คน
============================================================ */
.qb-play {
position: fixed;
inset: 0;
z-index: 50;
display: none;
flex-direction: column;
background:
radial-gradient(ellipse at 50% 0%, rgba(37, 99, 235, 0.28) 0%, transparent 60%),
linear-gradient(180deg, #0b1024 0%, #060912 100%);
color: #fff;
font-family: 'Kanit', 'Sarabun', system-ui, sans-serif;
padding: max(14px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
.qb-play.is-open { display: flex; }
/* แถบหัว: ห้อง / จำนวนผู้เล่น / คะแนนเรา */
.qb-play-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex: 0 0 auto;
}
.qb-play-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: clamp(14px, 2.2vw, 20px);
font-weight: 600;
white-space: nowrap;
}
.qb-play-pill b { color: #57efff; }
.qb-play-leave {
border: none;
background: rgba(239, 68, 68, 0.18);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fecaca;
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.qb-play-leave:hover { background: rgba(239, 68, 68, 0.3); }
/* body กลางจอ */
.qb-play-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(14px, 2.5vh, 28px);
text-align: center;
}
.qb-play-screen { width: 100%; max-width: 1100px; display: none; flex-direction: column; align-items: center; gap: clamp(12px, 2.4vh, 26px); }
.qb-play-screen.is-active { display: flex; }
/* timer bar */
.qb-timebar {
width: min(100%, 760px);
height: 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.qb-timebar-fill {
height: 100%;
width: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #34d399, #fbbf24, #ef4444);
transform-origin: left center;
transition: transform 0.2s linear;
}
/* คำถาม */
.qb-qmeta { font-size: clamp(13px, 2vw, 18px); color: rgba(255,255,255,0.7); font-weight: 600; }
.qb-question-text {
font-size: clamp(22px, 4.2vw, 44px);
font-weight: 700;
line-height: 1.25;
padding: 18px 22px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
max-width: 900px;
}
.qb-answers {
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(10px, 1.8vw, 18px);
}
.qb-answers[data-count="3"] .qb-answer:last-child { grid-column: 1 / -1; }
.qb-answer {
position: relative;
display: flex;
align-items: center;
gap: 14px;
min-height: clamp(64px, 12vh, 110px);
padding: 14px 20px;
border: none;
border-radius: 16px;
color: #fff;
font: inherit;
font-size: clamp(16px, 2.6vw, 26px);
font-weight: 600;
text-align: left;
cursor: pointer;
transition: transform 0.1s ease, filter 0.1s ease, opacity 0.2s ease;
box-shadow: 0 6px 0 rgba(0, 0, 0, 0.25), 0 10px 24px rgba(0, 0, 0, 0.35);
}
.qb-answer:hover:not(:disabled) { transform: translateY(-2px); filter: brightness(1.08); }
.qb-answer:active:not(:disabled) { transform: translateY(0); }
.qb-answer:disabled { cursor: default; }
.qb-answer .qb-answer-icon {
flex: 0 0 auto;
width: clamp(28px, 5vw, 44px);
height: clamp(28px, 5vw, 44px);
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.28);
border-radius: 10px;
font-weight: 800;
}
.qb-answer.c0 { background: #e21b3c; }
.qb-answer.c1 { background: #1368ce; }
.qb-answer.c2 { background: #d89e00; }
.qb-answer.c3 { background: #26890c; }
.qb-answer.is-picked { outline: 4px solid #fff; outline-offset: 2px; }
.qb-answer.is-correct { box-shadow: 0 0 0 4px #fff, 0 0 28px rgba(38,137,12,0.8); }
.qb-answer.is-wrong { opacity: 0.45; }
.qb-answer.is-dimmed { opacity: 0.4; }
.qb-answered-info { font-size: clamp(14px, 2.2vw, 20px); color: rgba(255,255,255,0.78); font-weight: 600; }
/* countdown */
.qb-countdown-num { font-size: clamp(80px, 22vw, 220px); font-weight: 800; line-height: 1; color: #57efff; text-shadow: 0 0 40px rgba(87,239,255,0.6); }
.qb-countdown-label { font-size: clamp(18px, 3vw, 30px); font-weight: 600; }
/* reveal / result feedback */
.qb-feedback { font-size: clamp(28px, 6vw, 64px); font-weight: 800; }
.qb-feedback.ok { color: #34d399; }
.qb-feedback.no { color: #f87171; }
.qb-delta { font-size: clamp(20px, 3.4vw, 34px); font-weight: 700; color: #fbbf24; }
/* leaderboard */
.qb-board { width: min(100%, 560px); display: flex; flex-direction: column; gap: 8px; }
.qb-board-title { font-size: clamp(18px, 3vw, 28px); font-weight: 700; margin-bottom: 4px; }
.qb-board-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: clamp(15px, 2.4vw, 20px);
}
.qb-board-row.me { background: rgba(87, 239, 255, 0.16); border-color: rgba(87,239,255,0.4); }
.qb-board-row.top1 { background: linear-gradient(90deg, rgba(251,191,36,0.25), rgba(255,255,255,0.05)); border-color: rgba(251,191,36,0.5); }
.qb-board-rank { flex: 0 0 auto; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; background: rgba(255,255,255,0.14); font-weight: 800; }
.qb-board-row.top1 .qb-board-rank { background: #fbbf24; color: #1a1500; }
.qb-board-name { flex: 1; text-align: left; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qb-board-score { flex: 0 0 auto; font-weight: 700; color: #57efff; }
/* lobby (waiting room) */
.qb-lobby-title { font-size: clamp(26px, 5vw, 52px); font-weight: 800; }
.qb-lobby-sub { font-size: clamp(15px, 2.6vw, 22px); color: rgba(255,255,255,0.78); }
.qb-players {
width: min(100%, 720px);
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-height: 34vh;
overflow-y: auto;
}
.qb-player-chip {
padding: 8px 16px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.14);
font-size: clamp(13px, 2.2vw, 18px);
font-weight: 600;
}
.qb-player-chip.me { background: rgba(87,239,255,0.18); border-color: rgba(87,239,255,0.45); }
/* primary action button */
.qb-btn {
border: none;
border-radius: 14px;
padding: 14px 40px;
font: inherit;
font-size: clamp(18px, 3vw, 26px);
font-weight: 700;
color: #04121f;
background: linear-gradient(180deg, #67e8f9, #22d3ee);
cursor: pointer;
box-shadow: 0 8px 24px rgba(34,211,238,0.35);
transition: transform 0.1s ease, filter 0.1s ease;
}
.qb-btn:hover:not(:disabled) { transform: translateY(-2px); filter: brightness(1.05); }
.qb-btn:active:not(:disabled) { transform: translateY(0); }
.qb-btn:disabled { opacity: 0.5; cursor: default; }
.qb-btn.ghost { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.2); box-shadow: none; }
@@ -0,0 +1,386 @@
* { box-sizing: border-box; }
@font-face {
font-family: 'NotoSansThai';
src: url('../FONTS/NotoSansThai-ExtraBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'NotoSansThaiMedium';
src: url('../FONTS/NotoSansThai-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
html {
min-height: 100%;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
font-family: 'Kanit', 'Sarabun', 'Segoe UI', system-ui, sans-serif;
color: #fff;
background: #030712;
}
.qb-bg {
position: fixed;
inset: 0;
z-index: 0;
}
.qb-bg-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.qb-vignette {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(ellipse at 50% 25%, transparent 15%, rgba(0, 0, 0, 0.4) 100%);
}
.qb-ui {
--qb-ui-scale: 1;
position: relative;
z-index: 2;
min-height: 100vh;
min-height: 100dvh;
padding:
max(calc(10px * var(--qb-ui-scale)), env(safe-area-inset-top))
max(calc(12px * var(--qb-ui-scale)), env(safe-area-inset-right))
max(calc(14px * var(--qb-ui-scale)), env(safe-area-inset-bottom))
max(calc(12px * var(--qb-ui-scale)), env(safe-area-inset-left));
overflow: hidden;
}
.qb-top {
position: absolute;
top: max(calc(10px * var(--qb-ui-scale)), env(safe-area-inset-top));
left: max(calc(12px * var(--qb-ui-scale)), env(safe-area-inset-left));
right: max(calc(12px * var(--qb-ui-scale)), env(safe-area-inset-right));
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: calc(8px * var(--qb-ui-scale));
min-height: calc(157px * var(--qb-ui-scale));
z-index: 6;
pointer-events: none;
}
.qb-top-fill {
flex: 1;
min-width: calc(8px * var(--qb-ui-scale));
pointer-events: none;
}
/* กรอบแดง 1: ใช้ layout แบบ Join Room back button */
.qb-join-back {
position: fixed;
top: max(2px, env(safe-area-inset-top));
left: max(2px, env(safe-area-inset-left));
padding: 0;
border: none;
background: transparent;
cursor: pointer;
z-index: 20;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
.qb-join-back-img {
--qb-exit-btn-height: 142px;
display: block;
height: var(--qb-exit-btn-height);
width: calc(var(--qb-exit-btn-height) * (122 / 142));
object-fit: contain;
}
/* กรอบแดง 2: ใช้ layout แบบ room-lobby-profile-avatar-viewport */
.qb-room-profile-wrap {
flex-shrink: 0;
pointer-events: none;
}
.qb-room-profile-frame {
position: relative;
width: 159px;
height: 157px;
min-width: 159px;
min-height: 157px;
max-width: 159px;
max-height: 157px;
aspect-ratio: 159 / 157;
border-radius: 0;
border: none;
overflow: hidden;
background: url(../Game/img/btn-profile-bg.png) center/100% 100% no-repeat;
box-shadow: none;
}
.qb-room-profile-avatar-viewport {
position: absolute;
left: 18px;
top: 10px;
width: 124px;
height: 118px;
overflow: hidden;
border-radius: 12px;
}
.qb-room-profile-avatar {
position: absolute;
left: 50%;
top: 5px;
display: block;
width: 120%;
height: auto;
max-width: none;
object-fit: contain;
object-position: center top;
transform: translateX(-50%);
transform-origin: center center;
image-rendering: pixelated;
image-rendering: crisp-edges;
pointer-events: none;
}
.qb-main {
--qb-main-scale: 1;
--qb-banner-width: calc(419px * var(--qb-main-scale));
--qb-banner-height: calc(151px * var(--qb-main-scale));
--qb-card-width: calc(271px * var(--qb-main-scale));
--qb-card-height: calc(351px * var(--qb-main-scale));
--qb-card-start-height: calc(83px * var(--qb-main-scale));
--qb-card-start-width: calc(168px * var(--qb-main-scale));
--qb-grid-gap-x: calc(18px * var(--qb-main-scale));
--qb-grid-gap-y: calc(14px * var(--qb-main-scale));
--qb-main-gap-banner-grid: calc(12px * var(--qb-main-scale));
--qb-main-gap-grid-toast: calc(8px * var(--qb-main-scale));
position: relative;
z-index: 3;
width: 100%;
height: calc(100dvh - max(calc(10px * var(--qb-ui-scale)), env(safe-area-inset-top)) - max(calc(14px * var(--qb-ui-scale)), env(safe-area-inset-bottom)));
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
overflow: hidden;
padding-top: calc(2px * var(--qb-main-scale));
}
.qb-main::-webkit-scrollbar {
width: 0;
height: 0;
}
.qb-main::-webkit-scrollbar-track {
background: transparent;
}
.qb-main::-webkit-scrollbar-thumb {
background: transparent;
}
.qb-room-banner {
flex: 0 0 auto;
width: var(--qb-banner-width);
height: var(--qb-banner-height);
max-width: 100%;
flex-shrink: 0;
margin: 0 auto var(--qb-main-gap-banner-grid);
margin-top: calc(60px * var(--qb-main-scale));
}
.qb-room-banner img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.35));
}
.qb-grid {
width: auto;
max-width: 100%;
display: grid;
grid-template-columns: repeat(5, var(--qb-card-width));
grid-auto-rows: var(--qb-card-height);
justify-content: center;
align-content: start;
column-gap: var(--qb-grid-gap-x);
row-gap: var(--qb-grid-gap-y);
padding: 0;
}
.qb-card {
position: relative;
width: var(--qb-card-width);
height: var(--qb-card-height);
padding: 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: default;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
.qb-card-room {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
user-select: none;
}
.qb-card-start {
position: absolute;
left: 50%;
bottom: calc(25px * var(--qb-main-scale));
transform: translateX(-50%);
width: var(--qb-card-start-width);
height: var(--qb-card-start-height);
border: none;
padding: 0;
background: transparent;
cursor: pointer;
z-index: 2;
transition: transform 0.14s ease, filter 0.14s ease;
}
.qb-card-start:hover,
.qb-card-start:focus-visible {
transform: translateX(-50%) translateY(-1px);
filter: brightness(1.06);
}
.qb-card-start:focus-visible {
outline: 2px solid #22d3ee;
outline-offset: 3px;
border-radius: 8px;
}
.qb-card-start:active {
transform: translateX(-50%) translateY(0);
}
.qb-card-start img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.qb-card-count {
position: absolute;
top: calc(44px * var(--qb-main-scale));
right: calc(54px * var(--qb-main-scale));
font-family: 'NotoSansThaiMedium', 'NotoSansThai', 'Kanit', 'Sarabun', system-ui, sans-serif;
font-size: calc(22px * var(--qb-main-scale));
font-weight: 500;
line-height: 1;
color: #ffffff;
letter-spacing: 0;
text-shadow:
0 0 calc(10px * var(--qb-main-scale)) rgba(87, 239, 255, 0.55),
0 0 calc(2px * var(--qb-main-scale)) rgba(0, 0, 0, 0.65);
pointer-events: none;
user-select: none;
z-index: 3;
}
.qb-toast {
text-align: center;
font-size: calc(20px * var(--qb-main-scale));
color: rgba(248, 250, 252, 0.88);
line-height: 1.2;
min-height: calc(24px * var(--qb-main-scale));
margin-top: var(--qb-main-gap-grid-toast);
flex-shrink: 0;
padding: 0 calc(8px * var(--qb-main-scale)) calc(4px * var(--qb-main-scale));
}
/* มุมขวาล่าง — btn-voice (ซ่อนไว้ก่อน: ทับการ์ด Room 09–10 ในหน้าเลือกห้อง) */
.qb-room-lobby-br-fixed {
--lobby-br-btn: calc(121px * var(--qb-ui-scale, 1));
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
position: fixed;
bottom: max(calc(0.95rem * var(--qb-ui-scale, 1)), env(safe-area-inset-bottom));
right: max(calc(1.25rem * var(--qb-ui-scale, 1)), env(safe-area-inset-right));
z-index: 400;
flex-direction: row;
align-items: flex-end;
gap: calc(10px * var(--qb-ui-scale, 1));
pointer-events: none;
}
.qb-room-lobby-br-fixed .btn-voice-icon {
pointer-events: auto;
box-sizing: border-box;
width: var(--lobby-br-btn);
height: var(--lobby-br-btn);
min-width: var(--lobby-br-btn);
min-height: var(--lobby-br-btn);
max-width: var(--lobby-br-btn);
max-height: var(--lobby-br-btn);
margin: 0;
padding: 0;
border: none;
border-radius: 0;
appearance: none;
-webkit-appearance: none;
flex-shrink: 0;
cursor: pointer;
background: transparent;
box-shadow: none;
display: flex;
align-items: center;
justify-content: center;
}
.qb-room-lobby-br-fixed .btn-voice-icon img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
display: block;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB