update game 50 users
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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+คลิกซ้ายบนสไปรต์ = ใส่/เปลี่ยนผูก · รายการ "อัตโนมัติ" = ล้างผูก</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="ล้างรายการจุดเกิดพรมแดงที่กำหนดเอง">ล้างลำดับ 1–6</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="ลำดับ P1–P6 เหมือนยิงยาน">ช่องเกิดผู้เล่น 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="ล้างจุด P1–P6 บนแผนที่">ล้างจุด P1–P6</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 2–4 → 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.02–0.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>ตัวเลือก 1–16</strong> = ช่องบนกริดตรงกับลำดับ <code>choices</code> · พรีวิวนับ <strong>3-2-1</strong> ไฮไลต์โซนข้อถูก (หรือใส่ <code>countdownHighlightSlot</code> 1–16 ต่อข้อใน <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>: จุดเกิดผู้เล่น P1–P6 + <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>
|
||||
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 295 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>เส้นทางเดิน
|
||||
<span class="sw" style="background:#ffd166"></span>จุดคำถาม
|
||||
<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>
|
||||
@@ -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 ฉากเดิน — อัปเดตอันดับหลังมีคนออก
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 9.9 MiB |
|
After Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 14 MiB |
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 9.5 MiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 20 MiB |
|
After Width: | Height: | Size: 10 MiB |